Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb

- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-29 14:16:52 +01:00
1 parent f4fb3c550b
commit b7e0bd0d92
56 files changed
+12265 -2974

No files matched your search

+105 -13
View File
@@ -86,7 +86,7 @@
// aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die
// TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als
// HTML-String zurückgegeben. Parameter: projects (Array), start/end (ISO-
// Datum), options (Optionen inkl. editable und criticalPath). Rückgabe:
// Datum), options (Optionen inkl. editable). Rückgabe:
// String mit dem kompletten GANTT-HTML.
window.buildGanttHtml = function(projects, start, end, options = {}){
if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options);
@@ -169,12 +169,11 @@
const span = daysBetween(barStart, barEnd) + 1;
const dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : '';
const handles = editable ? `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>` : '';
const isCritical = options.criticalPath && options.criticalPath.includes(todo.id);
const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0));
const conflictTitle = conflicts.length > 0
? '\nAchtung: ' + conflicts.map(c => `${c.name} ${c.type.toLowerCase()} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('; ')
: '';
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}${conflicts.length > 0 ? ' conflict-absence' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}${esc(conflictTitle)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${conflicts.length > 0 ? ' conflict-absence' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}${esc(conflictTitle)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
}
}
// Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt.
@@ -204,10 +203,22 @@
? `<span class="gantt-conflict-badge" title="${esc(conflicts.map(c => `${c.name}: ${c.type} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('\n'))}">${icon('warning')}</span>`
: '';
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}">
// Ruhigere Namensspalte (Eriks Wahl "A"): Vorgänger stehen nicht mehr
// als Schildchen im Namen, sondern im Tooltip (und als Linie im
// Diagramm); rechts die Kürzel der Zuständigen.
const vorgaengerText = depBadge ? '\nVorgänger: ' + (depBadge.match(/title="Vorgänger: ([^"]*)"/) || [,''])[1] : '';
const vorgaengerKonflikt = depBadge.indexOf('conflict') !== -1;
const leute = (Array.isArray(todo.assignees) ? todo.assignees : []).map(id => {
const pers = ((app.week && app.week.people) || []).find(x => x.id === id);
return pers ? pers.name : '';
}).filter(Boolean);
const leuteHtml = leute.length
? `<span class="gantt-leute">${leute.slice(0, 3).map(k => `<span>${esc(k)}</span>`).join('')}${leute.length > 3 ? `<span>+${leute.length - 3}</span>` : ''}</span>` : '';
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}${vorgaengerText}">
${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''}
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}${depBadge}</span>
${conflictBadge}
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}</span>
${vorgaengerKonflikt ? '<span class="gantt-vorgaenger-warn" title="Beginnt vor dem Ende eines Vorgängers">!</span>' : ''}
${conflictBadge}${leuteHtml}
</div>
<div class="gantt-timeline">${barHtml}</div>`;
return row;
@@ -260,16 +271,73 @@
}
if(!projectRow) return;
// Die Projektzeile trägt die Gesamtdauer und Meilensteine ohne eigene
// Zeile; sie heißt jetzt schlicht so, statt den Projektnamen ein
// zweites Mal zu wiederholen.
projectRow.classList.add('tp-projektzeile');
const projektLabel = projectRow.querySelector('.gantt-label');
if(projektLabel) projektLabel.textContent = 'Projekt gesamt';
let insertAfter = projectRow;
const insertedMilestoneIds = new Set();
todos.forEach((todo, tIdx) => {
// Gliederung nach Leistungsphasen (Eriks Wahl "A"): Phasenbalken
// (balkenPhase(), load.js) werden Gruppenköpfe; jede andere Aufgabe
// steht unter der Phase, in deren Balken sie beginnt. Nur die
// ANZEIGE wird umsortiert — plan.todos übernimmt die Reihenfolge erst,
// wenn jemand eine Zeile zieht (reorderPlanRows(), plan-drag.js).
const istPhase = t => typeof balkenPhase === 'function' && !!balkenPhase(t);
const hatBalken = t => t.start && t.end && t.showInGantt !== false;
const phasenKoepfe = todos.filter(t => hatBalken(t) && istPhase(t))
.sort((a, b) => String(a.start).localeCompare(String(b.start)));
const gruppeVon = t => {
if(istPhase(t)) return t.id;
const s0 = parseDateISO(t.start);
const k = s0 && phasenKoepfe.find(p => s0 >= parseDateISO(p.start) && s0 <= parseDateISO(p.end));
return k ? k.id : '';
};
let reihe = todos;
if(phasenKoepfe.length){
reihe = [];
phasenKoepfe.forEach(p => { reihe.push(p); todos.forEach(t => { if(!istPhase(t) && gruppeVon(t) === p.id) reihe.push(t); }); });
todos.forEach(t => { if(reihe.indexOf(t) === -1) reihe.push(t); });
}
const heuteIsoWert = formatDateISO(new Date());
const filter = options.filter || 'alle';
const passtFilter = t => filter === 'alle'
|| (filter === 'meine' && options.eigenePersonId && Array.isArray(t.assignees) && t.assignees.indexOf(options.eigenePersonId) !== -1)
|| (filter === 'ueberfaellig' && !t.done && t.end && t.end < heuteIsoWert);
const phasenZu = options.phasenZu || {};
let aktuelleGruppeZu = false;
let restKopfGesetzt = false;
const einfuegen = row => { insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; };
// Meilensteine ohne Aufgabenbezug: je eine eigene Zeile direkt unter
// "Projekt gesamt", nach Datum sortiert (Eriks Meldung: ohne Aufgaben
// standen alle Meilensteine übereinander in derselben Zeile).
milestones.filter(m => !m.position)
.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')))
.forEach(m => einfuegen(buildMilestoneRow(m, milestones.indexOf(m))));
reihe.forEach(todo => {
const tIdx = todos.indexOf(todo);
if(phasenKoepfe.length && hatBalken(todo)){
if(istPhase(todo)) aktuelleGruppeZu = !!phasenZu[balkenPhase(todo)];
else if(!gruppeVon(todo) && !restKopfGesetzt){
// Aufgaben außerhalb jedes Phasenbalkens bekommen einen eigenen Kopf.
const kopf = document.createElement('div');
kopf.className = 'gantt-row tp-gruppe';
kopf.innerHTML = '<div class="gantt-label">Weitere Aufgaben</div><div class="gantt-timeline"></div>';
einfuegen(kopf);
restKopfGesetzt = true;
aktuelleGruppeZu = false;
}
}
// Meilensteine, die VOR dieser Aufgabe positioniert sind
milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => {
const mIdx = milestones.indexOf(m);
const row = buildMilestoneRow(m, mIdx);
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
insertAfter = row;
if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu');
einfuegen(row);
insertedMilestoneIds.add(m.id);
});
@@ -292,16 +360,40 @@
// kippt.
if(todo.start && todo.end && todo.showInGantt !== false){
const todoRow = buildTodoRow(todo, tIdx);
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
insertAfter = todoRow;
if(phasenKoepfe.length && istPhase(todo)){
const ph = balkenPhase(todo);
const zu = !!phasenZu[ph];
const kinder = todos.filter(t => !istPhase(t) && hatBalken(t) && gruppeVon(t) === todo.id);
todoRow.classList.add('gantt-phase');
const label = todoRow.querySelector('.gantt-label');
if(label){
const knopf = document.createElement('button');
knopf.type = 'button';
knopf.className = 'tp-klappen';
knopf.setAttribute('aria-expanded', String(!zu));
knopf.setAttribute('aria-label', (zu ? 'Aufklappen: ' : 'Zuklappen: ') + ph);
knopf.setAttribute('onclick', `event.stopPropagation(); tpPhaseUmschalten('${ph.replace(/'/g, '')}')`);
knopf.textContent = zu ? '▶' : '▼';
label.insertBefore(knopf, label.firstChild);
const info = document.createElement('span');
info.className = 'tp-phase-info';
info.textContent = kinder.length + ' · ' + (parseInt(todo.progress, 10) || 0) + ' %';
info.title = (kinder.length === 1 ? '1 Aufgabe' : kinder.length + ' Aufgaben') + ', Phase zu ' + (parseInt(todo.progress, 10) || 0) + ' % erledigt';
label.appendChild(info);
}
} else {
if(aktuelleGruppeZu) todoRow.classList.add('gantt-zu');
if(!passtFilter(todo)) todoRow.classList.add('gantt-gefiltert');
}
einfuegen(todoRow);
}
// Meilensteine, die NACH dieser Aufgabe positioniert sind
milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => {
const mIdx = milestones.indexOf(m);
const row = buildMilestoneRow(m, mIdx);
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
insertAfter = row;
if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu');
einfuegen(row);
insertedMilestoneIds.add(m.id);
});
});