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);
});
});
+18 -9
View File
@@ -130,14 +130,11 @@
const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean);
const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean);
// Sicherheitscheck: Anzahl muss stimmen. Nicht jedes Meilenstein wird als
// eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten
// Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
// die positionierten zählen.
// Sicherheitscheck: Anzahl muss stimmen. Jeder Meilenstein hat eine
// eigene .gantt-milestone-row (auch ohne Position, dann oben unter
// "Projekt gesamt").
if(todoRowIds.length !== scheduledIds.size) return;
const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id);
if(milestoneRowIds.length !== positionedIds.length) return;
if(milestoneRowIds.length !== milestones.length) return;
// TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden
// gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne
@@ -153,7 +150,7 @@
// Regeln:
// - Steht der Meilenstein direkt vor einer TODO -> before:todoId
// - Steht er direkt nach einer TODO -> after:todoId
// - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (oben in Projektzeile)
// - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (eigene Zeile oben)
// - Steht er ganz am Ende (nach letzter TODO) -> after:lastTodoId
rows.forEach((row, idx) => {
if(!row.classList.contains('gantt-milestone-row')) return;
@@ -165,7 +162,11 @@
const prevRow = rows[idx - 1];
const nextRow = rows[idx + 1];
if(prevRow && prevRow.classList.contains('gantt-todo')){
const todoDavor = rows.slice(0, idx).some(r => r.classList.contains('gantt-todo'));
if(!todoDavor){
// Vor der ersten Aufgabe: ohne Position (eigene Zeile oben).
position = '';
} else if(prevRow && prevRow.classList.contains('gantt-todo')){
position = 'after:' + prevRow.dataset.todoId;
} else if(nextRow && nextRow.classList.contains('gantt-todo')){
position = 'before:' + nextRow.dataset.todoId;
@@ -449,6 +450,14 @@
const globalAbsence = globalPerson && Array.isArray(globalPerson.absences)
? globalPerson.absences.find(x => x.id === absenceId) : null;
if(!globalAbsence) return;
// Abwesenheit einer anderen Person: Art und Notiz kennt nur sie selbst
// (loadGlobalPeople() schickt sie verdeckt). Das Verschieben legt neu an
// und bräuchte die Art — sonst wäre der Eintrag nach dem Löschen weg.
if(globalAbsence.verdeckt){
app.showToast('Abwesenheiten anderer Personen lassen sich hier nicht verschieben.');
renderPlanGantt();
return;
}
const type = globalAbsence.type || '';
const note = globalAbsence.note || '';
// Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende
+2 -2
View File
@@ -58,7 +58,7 @@
const hint = document.getElementById('filesNoProjectHint');
const uploadSection = document.getElementById('filesUploadSection');
if(hint) hint.style.display = hasNr ? 'none' : '';
if(uploadSection) uploadSection.style.display = hasNr ? '' : 'none';
if(uploadSection) uploadSection.style.display = (hasNr && hatRecht('proj.dateien')) ? '' : 'none';
const fileInput = document.getElementById('fileUploadInput');
if(fileInput) fileInput.value = '';
if(hasNr) loadReviewerCheckboxes();
@@ -212,7 +212,7 @@
// Dieselbe Regel wie im Server (DELETE /project-files/:id): der Uploader,
// solange die Datei noch offen ist — oder ein Administrator. Vorher stand
// der Knopf bei jeder Datei und endete für andere in einer Fehlermeldung.
const isAdmin = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin');
const isAdmin = hatRecht('proj.dateienFremd'); // Recht „Fremde Dateien löschen“
const isUploader = !!(typeof currentUser !== 'undefined' && currentUser && f.uploadedByUserId === currentUser.id);
const canDelete = isAdmin || (isUploader && f.status === 'pending');
const reviewerChips = (f.reviewers || []).map(r =>
+187 -76
View File
@@ -10,7 +10,7 @@
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
// offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten;
// Team-Auslastung dagegen über berechneAuslastung() aus js/load/load.js,
// Team-Auslastung dagegen über aufgabenVerlaufJePerson() aus js/load/load.js,
// siehe computeResourceLoads() weiter unten — dieselbe Rechnung wie im
// Auslastungs-Tab, nicht aus den Plan-Daten selbst). renderPlanGantt ist die
// zentrale Neu-Render-Funktion (rendert am Ende auch Auslastung und KPIs
@@ -26,7 +26,7 @@
// renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts
// neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den
// Projektzeitraum), berücksichtigt Urlaub, kritischen Pfad, Sperrstatus und
// Projektzeitraum), berücksichtigt Urlaub, Sperrstatus und
// Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die
// Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert.
window.renderPlanGantt = function(){
@@ -41,16 +41,23 @@
document.getElementById('planGanttStart').value = formatDateISO(start);
document.getElementById('planGanttEnd').value = formatDateISO(end);
}
// Zeitraum auch sichtbar ins Feld schreiben (es zeigte sonst nur den
// Platzhalter, solange niemand einen Zeitraum gewählt hatte).
const zeitraumAnzeige = document.getElementById('planGanttRange');
if(zeitraumAnzeige) zeitraumAnzeige.value = formatDateDE(start) + ' – ' + formatDateDE(end);
const showVacation = document.getElementById('planShowVacation').checked;
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
const ghost = collectGhostDates();
container.innerHTML = buildGanttHtml([app.project], start, end, {
showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id,
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath,
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay,
ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label,
filter: app.ganttFilter || 'alle', phasenZu: app.phasenZu || (app.phasenZu = {}),
eigenePersonId: (typeof currentUser !== 'undefined' && currentUser && currentUser.personId) || '',
});
renderOhneTermin();
container.scrollLeft = prevScroll;
if(typeof ganttGriffeEinsetzen === 'function') ganttGriffeEinsetzen(container);
requestAnimationFrame(() => renderGanttDependencies(container));
renderPlanWarnings();
renderResourceLoad();
@@ -61,6 +68,7 @@
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
if(typeof renderPlanTasks === 'function') renderPlanTasks();
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
};
@@ -121,71 +129,68 @@
</div>`;
};
// kpiTile: baut das HTML einer einzelnen Kachel für renderPlanKpis().
// Parameter: label (Kachel-Titel), value (Hauptzahl), sub (Unterzeile),
// warn (optional, hebt den Wert farblich hervor, z. B. bei überfälligen
// Aufgaben). Rückgabe: HTML-String.
function kpiTile(label, value, sub, warn){
return `<div class="plan-kpi-tile${warn ? ' warn' : ''}">
// bandZelle: eine Kennzahl im Lageband des Dashboards. Parameter: label,
// value, sub (Texte), warn (Unterzeile rot). Rückgabe: HTML-String.
function bandZelle(label, value, sub, warn){
return `<div class="plan-lage-zelle">
<div class="kpi-label">${esc(label)}</div>
<div class="kpi-value">${esc(value)}</div>
<div class="kpi-sub">${esc(sub)}</div>
<div class="kpi-sub${warn ? ' ist-rot' : ''}">${esc(sub)}</div>
</div>`;
}
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
// #planKpiRow in App.html) mit vier Kennzahlen, die alle aus den
// bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle):
// Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl.
// überfällige), nächster anstehender Meilenstein und Team-Auslastung
// (Ø-Prozentwert aus computeResourceLoads(), dieselbe Zahl wie im
// Auslastungs-Modal). Wird nach jedem GANTT-Rendering aufgerufen, damit sie
// nach TODO-/Meilenstein-/Zuordnungsänderungen sofort aktuell ist.
// renderPlanKpis: füllt das Lageband oben im Dashboard (#planKpiRow,
// Anordnung "Lagebild"): links die laufende Leistungsphase mit Ring
// (amtierende LPH und ihr Fortschritt aus dem Wochenbericht), daneben offene
// Aufgaben (mit Überfälligem), nächster Meilenstein und Team-Auslastung
// (computeResourceLoads(), dieselbe Zahl wie im Auslastungs-Tab). Der
// frühere "Gesamtfortschritt" (Ø der Aufgaben) ist entfallen — für den
// Stand eines Projekts zählt die Leistungsphase. Wird nach jedem
// GANTT-Rendering aufgerufen, damit es sofort aktuell ist.
window.renderPlanKpis = function(){
const row = document.getElementById('planKpiRow');
if(!row || !app.project) return;
const p = app.project;
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const today = new Date();
today.setHours(0, 0, 0, 0);
let progressTile;
if(todos.length === 0){
progressTile = kpiTile('Gesamtfortschritt', '–', 'Keine Aufgaben angelegt');
} else {
const avg = Math.round(todos.reduce((s, t) => s + (parseInt(t.progress, 10) || 0), 0) / todos.length);
const doneCount = todos.filter(t => t.done).length;
progressTile = kpiTile('Gesamtfortschritt', avg + '%', doneCount + ' von ' + todos.length + ' Aufgaben erledigt');
}
const lph = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || '';
const roh = lph && p.lph ? p.lph[lph] : '';
const pct = roh === undefined || roh === '' ? null : Math.max(0, Math.min(100, Number(roh) || 0));
const phase = `<div class="plan-lage-zelle plan-lage-phase">
<span class="plan-lage-ring" style="--p:${pct || 0}"></span>
<div><div class="kpi-label">Laufende Phase</div>
<div class="kpi-value">${lph ? esc(lph) + (pct === null ? '' : ' · ' + pct + ' %') : '–'}</div>
<div class="kpi-sub">${lph ? (pct === 100 ? 'abgeschlossen' : 'Fortschritt laut Wochenbericht') : 'Keine amtierende Phase gesetzt'}</div></div>
</div>`;
const open = todos.filter(t => !t.done);
const overdue = open.filter(t => t.end && parseDateISO(t.end) < today);
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'Im Zeitplan' : 'Alle Aufgaben erledigt');
const openTile = kpiTile('Offene Aufgaben', String(open.length), openSub, overdue.length > 0);
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt');
const upcoming = milestones
.filter(m => m.date && parseDateISO(m.date) >= today)
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
let milestoneTile;
if(upcoming.length > 0){
milestoneTile = kpiTile('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein');
} else {
milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
}
const msZelle = upcoming.length > 0
? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein')
: bandZelle('Nächster Meilenstein', '–', milestones.length > 0 ? 'keine anstehenden' : 'keine angelegt');
const { loads } = computeResourceLoads();
const loadValues = Object.values(loads);
let loadTile;
let loadZelle;
if(loadValues.length === 0){
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet');
loadZelle = bandZelle('Team-Auslastung', '–', 'niemand zugeordnet');
} else {
const avgPct = Math.round(loadValues.reduce((s, l) => s + l.quote * 100, 0) / loadValues.length);
const top = loadValues.slice().sort((a, b) => b.quote - a.quote)[0];
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.stunden > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch nicht bewertbar');
loadZelle = bandZelle('Team-Auslastung', avgPct + ' %', top.stunden > 0
? 'höchste: ' + (top.person.fullName || top.person.name) + ' ' + Math.round(top.quote * 100) + ' %' : 'noch nicht bewertbar');
}
row.innerHTML = progressTile + openTile + milestoneTile + loadTile;
row.innerHTML = phase + bandZelle('Offene Aufgaben', String(open.length), openSub, overdue.length > 0) + msZelle + loadZelle;
};
// renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
@@ -202,7 +207,13 @@
let svg = chart.querySelector('.gantt-dependencies-svg');
if(svg) svg.remove();
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'));
// Linien abgeschaltet (Menü "Mehr") — dann nichts zeichnen.
const pfeile = document.getElementById('planShowPfeile');
if(pfeile && !pfeile.checked) return;
// Ausgeblendete Zeilen (zugeklappte Phase, Filter) haben keine Lage —
// Linien dorthin würden ins Leere zeigen.
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'))
.filter(b => b.offsetParent !== null);
if(nodeBars.length === 0) return;
const chartRect = chart.getBoundingClientRect();
@@ -283,6 +294,97 @@
renderPlanGantt();
};
// ---------------------------------------------------------------------------
// Ruhige Leiste (Eriks Wahl "A"): Maßstab, Heute, Filter, Menüs.
// ---------------------------------------------------------------------------
const TP_STUFEN = { fit: null, woche: 8, tag: 32 };
// tpMassstab: Maßstab "Übersicht" (an die Breite angepasst), "Wochen" oder
// "Tage" — feste Pixel je Tag statt der früheren −/+-Knöpfe.
window.tpMassstab = function(stufe){
app.pxPerDay = TP_STUFEN[stufe] === undefined ? null : TP_STUFEN[stufe];
document.querySelectorAll('#tpMassstab button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.stufe === stufe)));
renderPlanGantt();
if(app.pxPerDay) tpZuHeuteScrollen();
};
function tpZuHeuteScrollen(){
const c = document.getElementById('planGanttContainer');
const heute = c && c.querySelector('.gantt-grid-today');
if(!c || !heute) return;
const r = heute.getBoundingClientRect(), cr = c.getBoundingClientRect();
c.scrollLeft += (r.left - cr.left) - c.clientWidth / 3;
}
// tpHeute: Zeitraum ab zwei Wochen vor heute, gleiche Länge wie bisher
// (mindestens 12 Wochen), und heute ins Bild holen.
window.tpHeute = function(){
const s = parseDateISO(document.getElementById('planGanttStart').value);
const e = parseDateISO(document.getElementById('planGanttEnd').value);
const tage = s && e ? Math.max(84, daysBetween(s, e)) : 84;
const von = new Date(); von.setHours(0, 0, 0, 0); von.setDate(von.getDate() - 14);
const bis = new Date(von); bis.setDate(bis.getDate() + tage);
document.getElementById('planGanttStart').value = formatDateISO(von);
document.getElementById('planGanttEnd').value = formatDateISO(bis);
const anzeige = document.getElementById('planGanttRange');
if(anzeige) anzeige.value = formatDateDE(von) + ' – ' + formatDateDE(bis);
renderPlanGantt();
if(app.pxPerDay) tpZuHeuteScrollen();
};
// tpFilter: Alle / Meine (eigene Zuständigkeit) / Überfällig. Gefilterte
// Zeilen werden nur ausgeblendet, nicht weggelassen — das Umsortieren per
// Ziehen (plan-drag.js) zählt die Zeilen im DOM.
window.tpFilter = function(filter){
app.ganttFilter = filter;
document.querySelectorAll('#tpFilter button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter)));
renderPlanGantt();
};
// tpPhaseUmschalten: klappt eine Leistungsphase im Terminplan auf/zu.
window.tpPhaseUmschalten = function(phase){
if(!app.phasenZu) app.phasenZu = {};
app.phasenZu[phase] = !app.phasenZu[phase];
renderPlanGantt();
};
// tpMenueZu: schließt die Menüs "Mehr" und "+ Neu".
window.tpMenueZu = function(){
document.querySelectorAll('.tp-menue[open]').forEach(d => d.removeAttribute('open'));
};
// Ein Klick außerhalb schließt ein offenes Menü; ein Menü öffnen schließt
// das andere.
document.addEventListener('click', e => {
document.querySelectorAll('.tp-menue[open]').forEach(d => { if(!d.contains(e.target)) d.removeAttribute('open'); });
});
document.addEventListener('keydown', e => { if(e.key === 'Escape') window.tpMenueZu(); });
// Die Liste hängt rechtsbündig unter dem Knopf. Bricht die Leiste in einem
// schmalen Fenster um, steht "+ Neu" links und die Liste rutschte unter die
// Seitenleiste — dann linksbündig aufklappen (Klasse .nach-rechts).
document.addEventListener('toggle', e => {
const d = e.target;
if(!d.classList || !d.classList.contains('tp-menue') || !d.open) return;
const liste = d.querySelector('.tp-menue-liste');
if(!liste) return;
liste.classList.remove('nach-rechts');
const seite = document.querySelector('.side-menu');
const r = seite ? seite.getBoundingClientRect() : null;
const linkeGrenze = r && r.width && getComputedStyle(seite).position !== 'fixed' ? r.right : 0;
if(liste.getBoundingClientRect().left < linkeGrenze + 8) liste.classList.add('nach-rechts');
}, true);
// renderOhneTermin: Hinweis unter dem Diagramm auf Aufgaben, die keinen
// Balken haben (ohne Zeitraum oder bewusst ausgeblendet) — sie stehen nur
// unter "Aufgaben". Vorher fehlte jeder Hinweis, dass es sie gibt.
function renderOhneTermin(){
const el = document.getElementById('planOhneTermin');
if(!el) return;
const todos = (app.project && app.project.plan && Array.isArray(app.project.plan.todos)) ? app.project.plan.todos : [];
const ohne = todos.filter(t => !t.done && !(t.start && t.end && t.showInGantt !== false));
el.hidden = ohne.length === 0;
el.innerHTML = ohne.length ? `<span class="tp-ohne-zahl">${ohne.length}</span> ${ohne.length === 1 ? 'offene Aufgabe' : 'offene Aufgaben'} ohne Termin <span class="tp-ohne-hinweis">stehen nur unter „Aufgaben“ →</span>` : '';
}
// zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms.
// Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit).
window.zoomPlanGantt = function(dir){
@@ -291,7 +393,7 @@
let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end) return;
const totalDays = Math.max(1, daysBetween(start, end) + 1);
const cw = container ? Math.max(200, container.clientWidth - 250) : 900;
const cw = container ? Math.max(200, container.clientWidth - (ganttSpaltenBreite() + 30)) : 900;
const fitPx = Math.max(3, cw / totalDays);
const base = app.pxPerDay || fitPx;
let next;
@@ -308,33 +410,20 @@
// ---------- Ressourcen-Auslastung ----------
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel
// (berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor(),
// js/load/load.js) — Wochenstunden aus Honorar × Phasenanteil ×
// Restfortschritt, bezogen auf die Kapazität der Person.
//
// Vorher zählte diese Stelle etwas ganz anderes: Aufgabentage aus den
// TODO-Zeiträumen DIESES Terminplans, ohne jeden Bezug zu Honorar oder
// Kapazität — ein Blick auf dieselbe Person zeigte hier eine andere Zahl
// als im Auslastungs-Tab. Erik: "Die Auslastung der Projektbeteiligten im
// Terminplan ist eine andere als die in der sonstigen Anwendung... gleich-
// setzen mit der in der sonstigen Anwendung." Jetzt wird für JEDES Projekt
// der Sitzung (dieses UND alle übrigen) genau derselbe
// berechneAuslastung()-Aufruf gemacht wie in renderLoad() — dieselbe
// Person zeigt hier also exakt dieselbe Gesamtzahl wie im Auslastungs-Tab,
// nur aufgeteilt in den Anteil DIESES Projekts und den Anteil der übrigen
// Projekte der Sitzung (sonst wäre eine Überbuchung hier unsichtbar:
// jemand kann in diesem Projekt entspannt aussehen und trotzdem in vier
// weiteren ausgebucht sein — dieselbe Überlegung wie zuvor, nur jetzt in
// Stunden statt Tagen).
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel:
// Stunden dieser Woche aus Aufgaben mit Aufwand, gleich geteilt auf die
// Zuständigen (aufgabenVerlaufJePerson(), js/load/load.js), bezogen auf die
// Kapazität der Person. Für JEDES Projekt der Sitzung, aufgeteilt in den
// Anteil DIESES Projekts und den der übrigen — sonst sähe jemand hier
// entspannt aus, der in vier anderen Projekten ausgebucht ist.
//
// Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
// Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit
// beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt
// Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere,
// stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos
// (Titel der eigenen TODOs in DIESEM Projekt, rein informativ — zählt
// nicht mehr in die Stundenrechnung mit ein).
// (Titel der eigenen Aufgaben in DIESEM Projekt, ohne Phasenbalken),
// offenProjekt (offene Aufgabenstunden dieses Projekts, alle Wochen).
function computeResourceLoads(){
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
@@ -353,25 +442,42 @@
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
});
// Zuständige einer Aufgabe zählen mit, auch wenn sie unter „Beteiligte“
// fehlen — wie auf der Auslastungsseite. Sonst fielen ihre
// Aufgabenstunden hier stillschweigend weg.
const personen = (app.week && app.week.people) || [];
todos.forEach(t => {
if(!Array.isArray(t.assignees)) return;
if(!Array.isArray(t.assignees) || balkenPhase(t)) return;
t.assignees.forEach(pid => {
if(loads[pid]) loads[pid].todos.push(t.title);
if(!loads[pid]){
const person = personen.find(x => x.id === pid);
if(!person) return;
loads[pid] = {
person, roles: ['nur Aufgabe'], todos: [],
kapazitaet: kapazitaetsFaktor(person) * vollzeit,
stundenProjekt: 0, stundenAndere: 0, andereProjekte: [],
};
}
loads[pid].todos.push(t.title);
});
});
// Offene Aufgabenstunden dieses Projekts insgesamt (nicht nur diese
// Woche) — liegt eine Aufgabe erst später, zeigte die Wochenzahl sonst 0.
Object.values(loads).forEach(l => { l.offenProjekt = 0; });
offeneAufwandsAufgaben(app.project).forEach(({ t, rest }) => {
t.assignees.forEach(pid => { if(loads[pid]) loads[pid].offenProjekt += rest / t.assignees.length; });
});
// Stunden dieser Woche allein aus Aufgaben mit Aufwand — dieselbe
// Rechnung wie auf der Auslastungsseite (aufgabenVerlaufJePerson(), load.js).
((app.week && app.week.projects) || []).forEach(pr => {
const aktivePhase = lpActiveList(pr)[0];
Object.keys(pr.assign || {}).forEach(aKey => {
const pid = aKey.slice(0, aKey.indexOf('|'));
if(!loads[pid]) return; // nur Personen, die auch in DIESEM Projekt beteiligt sind
const rolle = aKey.slice(aKey.indexOf('|') + 1);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, pr.assign[aKey], pr.assign);
if(al.stunden <= 0) return;
if(pr.id === app.project.id){
loads[pid].stundenProjekt += al.stunden;
} else {
loads[pid].stundenAndere += al.stunden;
const av = typeof aufgabenVerlaufJePerson === 'function' ? aufgabenVerlaufJePerson(pr) : {};
Object.keys(av).forEach(pid => {
if(!loads[pid] || !(av[pid][0] > 0)) return;
if(pr.id === app.project.id) loads[pid].stundenProjekt += av[pid][0];
else {
loads[pid].stundenAndere += av[pid][0];
const label = pr.nr ? (pr.nr + ' ' + (pr.name || '')) : (pr.name || 'Projekt');
if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label);
}
@@ -456,10 +562,15 @@
</div>`
: `<div class="plan-resource-other muted">keine weiteren Projekte</div>`;
const offenText = l.offenProjekt > 0
? `<div class="plan-resource-other muted">${stundenFormat(l.offenProjekt)} h offen in Aufgaben dieses Projekts</div>`
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`;
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h</span></div>
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${offenText}
${otherBadge}
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
${absBadge}
+10 -78
View File
@@ -1,19 +1,16 @@
// -----------------------------
// Projektplan — Verlauf über mehrere Sitzungen
// -----------------------------
// Dieses Modul macht die Sitzungs-Historie eines Projekts sichtbar: die
// Kompakt-Karte "Verlauf" unter dem GANTT zeigt den letzten Kommentarwechsel,
// ein Klick öffnet #planHistoryModal mit dem vollständigen Verlauf.
// Dieses Modul liefert die Fassungen eines Projekts über die Sitzungen
// (Vorwochen-Helfer für den GANTT). Angezeigt wird der Kommentar-Verlauf
// im Projekt-Dashboard von plan-info.js.
//
// Der Verlauf selbst wird NICHT hier gezeichnet, sondern von
// renderProjektVerlauf() in js/utils.js — derselben Darstellung, die auch der
// Wochenbericht unter der Projektkarte und die Projektliste zeigen. Zuvor
// Der Verlauf in Wochenbericht und Projektliste wird von
// renderProjektVerlauf() in js/utils.js gezeichnet. Zuvor
// hatte jede dieser Stellen ihre eigene Fassung mit eigener Auswahl (hier nur
// Sitzungen mit Kommentarwechsel, im Bericht auch Termine und
// Leistungsphasen), weshalb derselbe Projektverlauf je nach Aufrufort anders
// aussah und sich widersprechen konnte. Der Dialog übergibt kein weekLabel:
// Er kennt keine "aktuelle" Fassung, die auszulassen wäre, und zeigt deshalb
// alle Sitzungen einschließlich der geöffneten.
// aussah und sich widersprechen konnte.
//
// Hintergrund: Beim wöchentlichen cloneWeek() (js/weeks.js) wird der
// Kommentartext in die neue Sitzung kopiert und dort überschrieben. Der alte
@@ -86,75 +83,10 @@
return null;
}
// historyEntries: die Verlaufsliste des aktuell geöffneten Projekts
// (neueste zuerst, Wiederholungen desselben Textes gefiltert). Logik in
// js/utils.js, siehe projectStatusHistory().
function historyEntries(){
return projectStatusHistory(app.project && app.project.projectUid);
}
// renderPlanHistory: füllt die Kompakt-Karte "Verlauf" (#planHistorySummary
// in App.html) — Anzahl der Einträge und die Sitzung des letzten
// Kommentarwechsels. Wird von renderPlanPage() (plan-state.js) nach dem
// Laden aufgerufen. Keine Parameter, keine Rückgabe.
window.renderPlanHistory = function(){
const summaryEl = document.getElementById('planHistorySummary');
if(!summaryEl || !app.project) return;
// Ein Sandbox-Plan der Planung lebt in genau einer, nie geklonten Sitzung
// (SANDBOX_WEEK_LABEL) — es gibt also nie einen "Verlauf über mehrere
// Sitzungen" zu zeigen, nur immer denselben einen Schnappschuss.
if(app.isSandboxPlan && app.isSandboxPlan()){
summaryEl.innerHTML = '<span class="hint">Kein Verlauf — dieser Terminplan gehört zu keiner Sitzung.</span>';
return;
}
if(!app.project.projectUid){
summaryEl.innerHTML = '<span class="hint">Für dieses Projekt ist kein Verlauf verfügbar.</span>';
return;
}
const entries = historyEntries();
if(entries.length === 0){
summaryEl.innerHTML = '<span class="hint">Noch keine Kommentare erfasst.</span>';
return;
}
const latest = entries[0];
const versionCount = projectVersions().length;
summaryEl.innerHTML = `<div class="plan-mini-card-preview">${esc(latest.text)}</div>
<div class="plan-mini-chip-row">
<span class="plan-mini-chip">${entries.length} Eintrag${entries.length === 1 ? '' : 'e'}</span>
<span class="plan-mini-chip">zuletzt ${esc(latest.week.label)}</span>
<span class="plan-mini-chip">${versionCount} Sitzung${versionCount === 1 ? '' : 'en'}</span>
</div>`;
};
// openHistoryModal/closeHistoryModal: öffnen bzw. schließen das
// Verlaufs-Modal (#planHistoryModal). Der Inhalt wird beim Öffnen frisch
// aufgebaut, damit ein gerade im Projektinfo-Modal geänderter Kommentar
// sofort mit auftaucht.
window.openHistoryModal = function(){
const listEl = document.getElementById('planHistoryList');
if(listEl){
if(!app.project || !app.project.projectUid){
listEl.innerHTML = '<span class="hint">Dieses Projekt hat keine dauerhafte Projekt-Kennung und kann daher nicht über Sitzungen hinweg verfolgt werden.</span>';
} else {
// Dieselbe Darstellung wie im Wochenbericht und im Projektboard
// (renderProjektVerlauf(), js/utils.js). Der Dialog hatte zuvor eine
// eigene, enger gefasste Liste — sie zeigte nur Kommentartexte und
// konnte dem Verlauf an den anderen Stellen widersprechen.
//
// weekLabel bleibt leer: Im Dialog gibt es keine "aktuelle" Fassung,
// die auszulassen wäre — hier sollen ALLE Sitzungen erscheinen,
// einschließlich der gerade geöffneten.
listEl.innerHTML = renderProjektVerlauf(app.project, '', app.isLocked());
}
}
document.getElementById('planHistoryModal').style.display = 'flex';
};
window.closeHistoryModal = function(){
document.getElementById('planHistoryModal').style.display = 'none';
};
// Die Kompakt-Karte "Verlauf" (renderPlanHistory()) und der Dialog
// #planHistoryModal (openHistoryModal()/closeHistoryModal()) sind entfallen:
// Der Kommentar-Verlauf steht jetzt in der Kachel "Kommentar der Sitzung"
// des Projekt-Dashboards (renderPlanKommentarVerlauf(), plan-info.js).
// Export für die anderen plan-Module (plan-gantt.js/plan-build-gantt.js:
// Geister-Balken der letzten geplanten Sitzung).
+10 -3
View File
@@ -94,7 +94,11 @@
}
const stundensatz = honorarConfig.stundensatz;
// Beginnt der Plan an einem Wochenende/Feiertag, auf den nächsten
// Arbeitstag schieben — wie der Assistent "Neues Projekt". Sonst stand der
// Balken ab Samstag, gezählt wurde aber erst ab Montag.
let cursor = startDate;
while(!isWorkday(cursor)) cursor = addDays(cursor, 1);
let summeStunden = 0, summeTage = 0, summeHonorar = 0;
const zeilen = phasen.map(lph => {
@@ -137,7 +141,7 @@
// Eingaben gelten als 0 = "kein Honorar hinterlegt".
// Parameter: val (Eingabewert). Keine Rückgabe.
window.setHonorarBetrag = function(val){
if(app.isLocked()) return;
if(app.stammGesperrt()) return;
const v = parseFloat(String(val).replace(',', '.'));
app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0;
app.savePlan();
@@ -152,7 +156,7 @@
// Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan
// chronologisch aufsteigt. Parameter: lph, checked (Boolean).
window.toggleBeauftragteLp = function(lph, checked){
if(app.isLocked()) return;
if(app.stammGesperrt()) return;
const p = app.project;
const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
if(checked) set.add(lph); else set.delete(lph);
@@ -260,7 +264,7 @@
function renderHonorarHint(){
const el = document.getElementById('honorarAdminHint');
if(!el) return;
el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
el.style.display = hatRecht('a.honorar') ? '' : 'none';
}
// invalidateHonorarConfig: verwirft den Zwischenspeicher, damit der nächste
@@ -326,6 +330,9 @@
plan.todos.push({
id: uid(),
title: `${z.lph} (${fmtNum(z.stunden, 0)} h)`,
// Kennzeichen für die Auslastung: Die Reststunden dieser Phase
// werden auf die Wochen dieses Balkens verteilt (load.js).
lph: z.lph,
start: formatDateISO(z.start),
end: formatDateISO(z.ende),
done: false,
+136 -6
View File
@@ -70,9 +70,109 @@
// amtierende Leistungsphase, LPH-Prozentliste) aus app.project auf. Wird
// nach jedem Laden (renderPlanPage(), plan-state.js) sowie nach jeder
// Änderung hier selbst erneut aufgerufen. Keine Parameter, keine Rückgabe.
// ---------------------------------------------------------------------------
// Kommentar-Verlauf im Projekt-Dashboard
// ---------------------------------------------------------------------------
// planKommentarDaten: sucht über alle Sitzungen dieses Projekts (bis
// einschließlich der angezeigten) den jüngsten Kommentar und listet die
// früheren je Gewerk. Ein Eintrag im Verlauf entsteht nur, wenn sich der
// Text eines Gewerks gegenüber der Sitzung davor geändert hat — ein
// unverändert mitgeführter Kommentar stünde sonst jede Woche erneut da.
// Rückgabe: { letzter: {week, k, aktuell} | null, verlauf: [{week, gewerk, html, autor, at}] }
function planKommentarDaten(p, week){
const bisKey = week && week.label ? weekSortKey(week.label) : null;
let versionen = (typeof projectVersionsForUid === 'function' && p.projectUid)
? projectVersionsForUid(p.projectUid).filter(v => !bisKey || weekSortKey(v.week.label) <= bisKey)
: [];
// Angezeigte Sitzung immer mit dem lebenden Projektobjekt (Sandbox, oder
// ein Projekt, das in db.weeks noch nicht steht).
versionen = versionen.filter(v => !week || v.week.label !== week.label);
versionen.push({ week: week || { label: '' }, project: p });
let letzter = null;
for(let i = versionen.length - 1; i >= 0; i--){
const k = kommentarVon(versionen[i].project, null);
if(statusPlainText(k.status)){
letzter = { week: versionen[i].week, k, aktuell: i === versionen.length - 1 };
break;
}
}
const gewerke = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
const verlauf = [];
gewerke.forEach(g => {
let vorher = '';
versionen.forEach(v => {
const k = kommentarVon(v.project, g);
const text = statusPlainText(k.status);
if(text && text !== vorher && !(letzter && v.week === letzter.week)){
verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' });
}
vorher = text;
});
});
// Derselbe Text in mehreren Gewerken derselben Sitzung (Altbestand: die
// Umstellung auf Kommentare je Gewerk hat ihn in jedes kopiert) wird ein
// Eintrag mit allen Gewerken, statt zweimal untereinander zu stehen.
const zusammen = [];
verlauf.forEach(e => {
const gleich = zusammen.find(z => z.week === e.week && statusPlainText(z.html) === statusPlainText(e.html));
if(gleich){ gleich.gewerk += ', ' + e.gewerk; if(!gleich.autor) gleich.autor = e.autor; }
else zusammen.push(Object.assign({}, e));
});
zusammen.sort((a, b) => weekSortKey(b.week.label).localeCompare(weekSortKey(a.week.label))
|| String(b.at).localeCompare(String(a.at)));
return { letzter, verlauf: zusammen };
}
// "KW 43 / 2026" -> "KW 43" (das Jahr nur, wenn es nicht das laufende ist)
function kurzKw(label){
const m = /^(KW\s*\d+)\s*\/\s*(\d{4})$/.exec(String(label || '').trim());
if(!m) return label || '';
return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2];
}
const PLAN_VERLAUF_KURZ = 3;
let planVerlaufAlle = false;
let planVerlaufProjekt = null;
function renderPlanKommentarVerlauf(verlauf){
const el = document.getElementById('planStatusVerlauf');
if(!el) return;
const uid = app.project && (app.project.projectUid || app.project.id);
if(uid !== planVerlaufProjekt){ planVerlaufProjekt = uid; planVerlaufAlle = false; }
if(!verlauf.length){ el.hidden = true; el.innerHTML = ''; return; }
el.hidden = false;
const zeigen = planVerlaufAlle ? verlauf : verlauf.slice(0, PLAN_VERLAUF_KURZ);
const zeilen = zeigen.map((e, i) => `
<li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}">
<span class="plan-verlauf-kw">${esc(kurzKw(e.week.label))}</span>
<span class="plan-verlauf-punkt" aria-hidden="true"></span>
<div class="plan-verlauf-inhalt">
<div class="plan-verlauf-text">${e.html}</div>
<div class="plan-verlauf-meta">${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}</div>
</div>
</li>`).join('');
const mehr = verlauf.length > PLAN_VERLAUF_KURZ
? `<button type="button" class="plan-verlauf-mehr" onclick="planVerlaufUmschalten()">${planVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + verlauf.length + ' anzeigen'}</button>`
: '';
el.innerHTML = `
<div class="plan-verlauf-kopf">
<span class="plan-verlauf-titel">Verlauf</span>
<span class="plan-verlauf-anzahl">${verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span>
</div>
<ol class="plan-verlauf-liste">${zeilen}</ol>${mehr}`;
}
window.planVerlaufUmschalten = function(){
planVerlaufAlle = !planVerlaufAlle;
window.renderPlanInfo();
};
window.renderPlanInfo = function(){
const p = app.project;
const locked = app.isLocked();
const locked = app.stammGesperrt();
// Kommentar, Fortschritt und Zuordnung sind hier NUR ZU SEHEN.
//
@@ -83,15 +183,42 @@
// Personen konnten dasselbe Feld an verschiedenen Stellen pflegen.
// Was hier bleibt, sind die Stammdaten des Projekts (Bereich
// "Einstellungen", siehe plan-settings.js).
// Gezeigt wird der ZULETZT GESCHRIEBENE Kommentar (Eriks Wunsch, Entwurf
// "A"): Hat die Sitzung noch keinen, der jüngste aus einer früheren — mit
// Hinweis, aus welcher. Darunter der Verlauf der früheren Kommentare.
const kom = planKommentarDaten(p, app.week);
const editEl = document.getElementById('planStatusEdit');
if(editEl){
editEl.className = 'status-edit is-ansicht';
editEl.contentEditable = 'false';
const text = sanitizeStatusHtml(p.status || '');
editEl.innerHTML = text || '<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>';
// Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js).
const text = kom.letzter ? sanitizeStatusHtml(kom.letzter.k.status) : '';
editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>';
}
const hinweisEl = document.getElementById('planStatusHinweis');
if(hinweisEl){
const aelter = kom.letzter && !kom.letzter.aktuell;
hinweisEl.hidden = !aelter;
hinweisEl.innerHTML = aelter
? `In dieser Sitzung noch kein Kommentar — angezeigt wird der letzte aus <b>${esc(kurzKw(kom.letzter.week.label))}</b>.`
: '';
}
// Wer den Kommentar wann geschrieben hat (jüngster Eintrag aller Gewerke).
const autorEl = document.getElementById('planStatusAutor');
if(autorEl){
const k = kom.letzter ? kom.letzter.k : {};
autorEl.innerHTML = k.status && k.statusAuthor
? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}`
+ (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '')
: '';
}
renderPlanKommentarVerlauf(kom.verlauf);
const statusWrap = document.getElementById('planStatusWrap');
if(statusWrap) statusWrap.classList.add('locked-cell');
// Nicht mehr als gesperrt markieren: .locked-cell dimmt auf 55 % und legt im
// Dunkelmodus ein Grau darunter — der Kommentar war dadurch kaum lesbar.
// Dass er hier nur angezeigt wird, zeigen schon .is-ansicht und der
// Verweis "Im Wochenbericht bearbeiten".
if(statusWrap) statusWrap.classList.remove('locked-cell');
setzeBerichtVerweis('planStatusVerweis', locked);
// Gruppe — Stammdatum, bleibt hier pflegbar
@@ -176,7 +303,7 @@
// setPlanGroup: ändert die Gruppe des Projekts. Parameter: val (String).
// Keine Rückgabe; bei gesperrter Woche abgebrochen.
window.setPlanGroup = function(val){
if(app.isLocked()) return;
if(app.stammGesperrt()) return;
app.project.group = val;
app.savePlan();
};
@@ -185,11 +312,14 @@
// Projekts auf. Parameter: val (LPH-Name oder leer). Rendert das
// Infopanel neu, damit die Markierung in der LPH-Liste sofort mitzieht.
window.setPlanActiveLp = function(val){
if(app.isLocked()) return;
if(app.stammGesperrt()) return;
if(val) app.project.aktuelleLP = [val];
else delete app.project.aktuelleLP;
app.savePlan();
window.renderPlanInfo();
// Abschnitt "Leistungsphasen" der Einstellungen: Kennzeichen "läuft"
// und die Kurzfassung in der Unternavigation nachziehen.
if(typeof renderPlanSettings === 'function') renderPlanSettings();
};
// Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind
+293
View File
@@ -0,0 +1,293 @@
// plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per
// Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf
// den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus
// Aufgaben. Der Terminplan ist nur die grafische Darstellung der
// Aufgabenliste — deshalb entstehen hier ausschließlich Aufgaben
// (plan.todos), keine Meilensteine.
//
// Nichts wird ohne Prüfung angelegt: Jeder Vorschlag öffnet das gewohnte
// Aufgaben-Panel vorausgefüllt (openTodoModal(null, prefill)), die Mail
// steht als Notiz darin (t.notiz). Mehrere Vorschläge erst als Liste zum
// Abhaken, dann nacheinander im Panel. Server: POST /plan-eintrag-aus-mail
// (speichert nichts).
(function(){
const app = window.PlanApp;
const ANBIETER_KEY = 'projuebersicht-mail-ki';
const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben'];
let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit)
let laeuft = false;
function aktiverAbschnitt(){
if(typeof currentTab === 'undefined' || currentTab !== 'plan') return '';
const s = document.querySelector('.plan-section.is-active');
return s ? s.dataset.section : '';
}
function ablageErlaubt(){
return ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked();
}
function bringtMail(dt){
if(!dt) return false;
const typen = Array.from(dt.types || []);
return typen.includes('Files') || typen.includes('text/plain') || typen.includes('text/html');
}
// ---------- Ablagefläche ----------
function flaeche(){
let el = document.getElementById('mailAblage');
if(!el){
el = document.createElement('div');
el.id = 'mailAblage';
el.className = 'mail-ablage';
el.hidden = true;
el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')}
<b id="mailAblageTitel">Mail hier ablegen</b>
<span id="mailAblageText">Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.</span></div>`;
document.body.appendChild(el);
}
return el;
}
function zeigeFlaeche(titel, text){
const el = flaeche();
document.getElementById('mailAblageTitel').textContent = titel;
if(text !== undefined) document.getElementById('mailAblageText').textContent = text;
el.classList.toggle('ist-beschaeftigt', laeuft);
el.hidden = false;
}
function versteckeFlaeche(){
const el = document.getElementById('mailAblage');
if(el) el.hidden = true;
}
document.addEventListener('dragenter', e => {
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)) return;
tiefe++;
zeigeFlaeche('Mail hier ablegen', 'Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.');
});
document.addEventListener('dragover', e => {
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
document.addEventListener('dragleave', () => {
if(tiefe === 0) return;
tiefe--;
if(tiefe === 0 && !laeuft) versteckeFlaeche();
});
document.addEventListener('drop', e => {
if(tiefe === 0 && !ablageErlaubt()) return;
tiefe = 0;
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)){ if(!laeuft) versteckeFlaeche(); return; }
e.preventDefault();
ausDataTransfer(e.dataTransfer);
});
// Strg+V als Ausweg, wenn ein Mailprogramm das Ziehen nicht unterstützt —
// nur außerhalb von Eingabefeldern, damit normales Einfügen unberührt bleibt.
document.addEventListener('paste', e => {
if(laeuft || !ablageErlaubt()) return;
const ziel = e.target;
if(ziel && (ziel.closest('input, textarea, select, [contenteditable="true"], .modal-bg'))) return;
const dt = e.clipboardData;
if(!dt) return;
const datei = Array.from(dt.files || []).find(istEml);
const text = (dt.getData('text/plain') || '').trim();
if(!datei && !text) return;
e.preventDefault();
ausDataTransfer(dt);
});
function istEml(f){ return f && /\.eml$/i.test(f.name || '') || (f && f.type === 'message/rfc822'); }
function ausDataTransfer(dt){
const dateien = Array.from(dt.files || []);
const eml = dateien.find(istEml);
if(eml){
if(eml.size > 5 * 1024 * 1024){ fehler('Die Mail ist zu groß (mehr als 5 MB).'); return; }
eml.text().then(t => auswerten({ eml: t })).catch(() => fehler('Die Mail-Datei ließ sich nicht lesen.'));
return;
}
if(dateien.some(f => /\.msg$/i.test(f.name || ''))){
fehler('Outlook-Dateien (.msg) lassen sich nicht direkt lesen. Bitte den Mailtext markieren und hineinziehen oder mit Strg+V einfügen.');
return;
}
let text = (dt.getData('text/plain') || '').trim();
if(!text){
const html = dt.getData('text/html') || '';
const div = document.createElement('div');
div.innerHTML = html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '');
text = (div.innerText || div.textContent || '').trim();
}
if(!text){
if(dateien.length) fehler('Diese Datei ist keine Mail. Abgelegt werden können markierter Mailtext oder .eml-Dateien.');
else versteckeFlaeche();
return;
}
auswerten({ text });
}
function fehler(meldung){
laeuft = false;
versteckeFlaeche();
app.showToast(meldung, 'error');
}
// ---------- Auswerten ----------
function anbieterWaehlen(){
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
const bereit = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
let zuletzt = '';
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
return bereit.includes(zuletzt) ? zuletzt : (bereit[0] || '');
}).catch(() => '');
}
function projektPersonen(){
const gesehen = new Set();
const liste = [];
projectAssignees(app.project, app.week).forEach(a => {
if(gesehen.has(a.person.id)) return;
gesehen.add(a.person.id);
liste.push(a.person);
});
return liste;
}
function kontext(){
const todos = (app.project.plan && app.project.plan.todos) || [];
return {
heute: heuteIso(),
projekt: [app.project.nr, app.project.name].filter(Boolean).join(' '),
personen: projektPersonen().map(p => p.fullName || p.name).filter(Boolean),
phasen: todos.filter(t => t.lph && t.start && t.end).map(t => ({ lph: t.lph, start: t.start, ende: t.end }))
};
}
function auswerten(nutzlast){
laeuft = true;
zeigeFlaeche('Mail wird ausgewertet …', 'Die KI liest die Mail. Das dauert einige Sekunden.');
anbieterWaehlen().then(anbieter => fetch(baseUrl() + '/plan-eintrag-aus-mail', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(Object.assign({ anbieter, kontext: kontext() }, nutzlast))
}))
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
laeuft = false;
versteckeFlaeche();
if(!ok || !d.ok){ app.showToast(d.error || 'Die Mail konnte nicht ausgewertet werden.', 'error'); return; }
if(d.erkennungsart === 'chatgpt' || d.erkennungsart === 'claude'){
try { localStorage.setItem(ANBIETER_KEY, d.erkennungsart); } catch(e){ /* egal */ }
}
verarbeiten(d);
})
.catch(() => fehler('Der Server ist nicht erreichbar.'));
}
// ---------- Vorschläge ins Panel ----------
// personenZuordnen: Namen aus der KI-Antwort → Personen-IDs des Projekts
// (voller Name, sonst Nachname, sonst Kürzel; Groß-/Kleinschreibung egal).
function personenZuordnen(namen){
const leute = projektPersonen();
const ids = [];
(namen || []).forEach(n => {
const s = String(n || '').trim().toLowerCase();
if(!s) return;
const treffer = leute.find(p => String(p.fullName || '').toLowerCase() === s)
|| leute.find(p => { const nn = String(p.fullName || '').toLowerCase().split(/\s+/).pop(); return nn && (s === nn || s.split(/\s+/).pop() === nn); })
|| leute.find(p => String(p.name || '').toLowerCase() === s);
if(treffer && !ids.includes(treffer.id)) ids.push(treffer.id);
});
return ids;
}
function notizText(mail, eintrag){
const kopf = [];
const datum = mail.datum ? formatDateDE(parseDateISO(mail.datum)) : '';
kopf.push('Aus Mail' + (mail.absender ? ' von ' + mail.absender : '') + (datum ? ' vom ' + datum : ''));
if(mail.betreff) kopf.push('Betreff: ' + mail.betreff);
if(eintrag.zitat) kopf.push('Bezug: „' + eintrag.zitat + '“');
return kopf.join('\n') + '\n\n' + String(mail.text || '').slice(0, 8000);
}
function hinweisText(d){
if(d.erkennungsart === 'chatgpt' || d.erkennungsart === 'claude'){
return 'Vorschlag der KI aus der Mail — bitte prüfen und speichern.';
}
return 'Die KI war nicht verfügbar (' + (d.erkennungshinweis || 'unbekannter Fehler') + '). Termine bitte selbst eintragen.';
}
function verarbeiten(d){
const eintraege = Array.isArray(d.eintraege) ? d.eintraege : [];
if(eintraege.length === 0){
app.showToast('In der Mail steht nichts für den Terminplan.');
return;
}
if(eintraege.length === 1){ nacheinanderOeffnen(d, eintraege); return; }
auswahlZeigen(d, eintraege);
}
function nacheinanderOeffnen(d, liste){
const rest = liste.slice();
const gesamt = rest.length;
const naechster = () => {
const e = rest.shift();
if(!e){ renderPlanGantt(); return; }
const nr = gesamt - rest.length;
openTodoModal(null, {
title: e.titel,
start: e.start,
end: e.ende,
assignees: personenZuordnen(e.zustaendig),
aufwand: e.aufwandStunden,
notiz: notizText(d.mail || {}, e),
mailHinweis: (gesamt > 1 ? `Vorschlag ${nr} von ${gesamt}. ` : '') + hinweisText(d),
nachSchliessen: naechster
});
};
naechster();
}
// ---------- Mehrere Vorschläge: Auswahl ----------
function auswahlZeigen(d, eintraege){
let bg = document.getElementById('mailAuswahlModal');
if(!bg){
bg = document.createElement('div');
bg.id = 'mailAuswahlModal';
bg.className = 'modal-bg';
bg.style.display = 'none';
bg.addEventListener('click', e => { if(e.target === bg) bg.style.display = 'none'; });
document.body.appendChild(bg);
}
const zeitraum = e => {
if(!e.start) return 'ohne Termin';
const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende));
return a === b ? a : a + ' – ' + b;
};
bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail">
<h2>${eintraege.length} Einträge in der Mail</h2>
<p class="hint">${esc(hinweisText(d))} Die ausgewählten öffnen sich nacheinander im Aufgaben-Panel.</p>
<div class="mail-auswahl-liste">${eintraege.map((e, i) => `
<label class="mail-auswahl-zeile"><input type="checkbox" checked data-i="${i}">
<span class="mail-auswahl-titel">${esc(e.titel)}</span>
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></label>`).join('')}</div>
<div class="modal-actions">
<button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button>
<button type="button" data-aktion="weiter">Prüfen und anlegen</button>
</div></div>`;
bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; };
bg.querySelector('[data-aktion="weiter"]').onclick = () => {
const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked')).map(cb => eintraege[Number(cb.dataset.i)]);
bg.style.display = 'none';
if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt);
};
bg.style.display = 'flex';
}
})();
+160 -6
View File
@@ -55,7 +55,7 @@
if(positionSelect){
const currentPosition = currentMs && currentMs.position ? currentMs.position : '';
const defaultAfterTodo = isNew && plan.todos && plan.todos.length > 0 ? plan.todos[plan.todos.length - 1].id : null;
let opts = '<option value="">Oben in der Projektzeile</option>';
let opts = '<option value="">Oben, eigene Zeile</option>';
if(plan.todos && plan.todos.length > 0){
plan.todos.forEach(t => {
const beforeVal = 'before:' + t.id;
@@ -69,6 +69,14 @@
}
positionSelect.innerHTML = opts;
}
window.msLageSync();
// Seitenpanel: Überschrift und "Löschen" nur beim Bearbeiten — vorher
// stand "Löschen" auch beim Anlegen eines neuen Meilensteins.
const msTitel = document.getElementById('milestoneModalTitle');
if(msTitel) msTitel.textContent = isNew ? 'Neuer Meilenstein' : 'Meilenstein bearbeiten';
const msLoeschen = document.getElementById('milestoneEditDelete');
if(msLoeschen) msLoeschen.hidden = isNew;
const dependsOnList = document.getElementById('milestoneEditDependsOn');
if(dependsOnList){
@@ -110,6 +118,35 @@
document.getElementById('milestoneEditName').focus();
};
// msLageSync/msLageSetzen: "Wo im Terminplan" als Umschalter "In der
// Projektzeile | Bei einer Aufgabe" über dem bisherigen Auswahlfeld
// (#milestoneEditPosition, Wert '' = Projektzeile, sonst before:/after:
// einer Aufgabe). Gespeichert wird weiter allein der Wert des Felds.
window.msLageSync = function(){
const sel = document.getElementById('milestoneEditPosition');
const seg = document.getElementById('milestoneEditLage');
if(!sel || !seg) return;
const beiAufgabe = !!sel.value;
const hatAufgaben = sel.options.length > 1;
seg.querySelectorAll('button').forEach(b => {
b.setAttribute('aria-pressed', String((b.dataset.lage === 'aufgabe') === beiAufgabe));
if(b.dataset.lage === 'aufgabe') b.disabled = !hatAufgaben;
});
sel.hidden = !beiAufgabe;
// Die Option "Oben, eigene Zeile" steht schon im Umschalter.
if(sel.options[0] && sel.options[0].value === '') sel.options[0].hidden = true;
};
window.msLageSetzen = function(art){
const sel = document.getElementById('milestoneEditPosition');
if(!sel) return;
if(art === 'zeile') sel.value = '';
else if(!sel.value){
const nachOptionen = Array.from(sel.options).filter(o => o.value.indexOf('after:') === 0);
if(nachOptionen.length) sel.value = nachOptionen[nachOptionen.length - 1].value;
}
window.msLageSync();
};
// closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den
// Bearbeitungsindex zurück.
window.closeMilestoneModal = function(){
@@ -186,6 +223,9 @@
// todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für
// die Rückfrage beim Abbrechen (siehe todoFormSnapshot()).
let todoModalSnapshot = '';
// todoNachSchliessen: Rückruf nach dem Schließen des Panels (gespeichert
// oder verworfen) — "Termin aus Mail" öffnet damit den nächsten Vorschlag.
let todoNachSchliessen = null;
// todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und
// "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit"
@@ -358,6 +398,69 @@
list.querySelectorAll('.todo-depends-group').forEach(group => { group.hidden = !!query; });
};
// ---------------------------------------------------------------------------
// Leistungsphase als Balken (Eriks Wunsch): "+ Neu" → "Leistungsphase"
// öffnet dasselbe Panel im Phasen-Modus. Ein Balken ist NUR über das Feld
// t.lph eine Phase (balkenPhase(), load.js) — nicht mehr über seinen Titel.
// ---------------------------------------------------------------------------
const LPH_NAMEN = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung', 'W+M': 'Wartung und Monitoring'
};
let todoPhasenModus = false;
let todoPhaseAutoTitel = '';
const phasenTitel = l => l ? (l + (LPH_NAMEN[l] ? ' ' + LPH_NAMEN[l] : '')) : '';
// addPlanPhase: "+ Neu" → "Leistungsphase".
// Optional lph: diese Phase vorwählen (Bereich "Leistungsphasen", Kachel
// ohne Zeitraum).
window.addPlanPhase = function(lph){ openTodoModal(null, { phase: true, lph: typeof lph === 'string' ? lph : '' }); };
// todoPhaseGewechselt: Auswahl der Phase geändert — den vorgeschlagenen
// Titel mitziehen, solange niemand ihn von Hand geändert hat.
window.todoPhaseGewechselt = function(){
const sel = document.getElementById('todoEditLph');
const titel = document.getElementById('todoEditTitle');
if(!sel || !titel) return;
if(!titel.value.trim() || titel.value === todoPhaseAutoTitel){
todoPhaseAutoTitel = phasenTitel(sel.value);
titel.value = todoPhaseAutoTitel;
}
};
// todoPhasenModusSetzen: Panel für eine Leistungsphase herrichten (Auswahl
// füllen, "Wer"/"Speichern & neu" ausblenden) oder zurück zur Aufgabe.
function todoPhasenModusSetzen(an, todo, isNew, vorgabeLph){
todoPhasenModus = an;
const abs = document.getElementById('todoEditPhaseAbs');
const wer = document.getElementById('todoEditWerAbs');
const neu = document.getElementById('todoEditSaveNew');
if(abs) abs.hidden = !an;
if(wer) wer.hidden = an;
if(neu) neu.hidden = an;
const titelEl = document.getElementById('todoModalTitle');
if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten');
if(!an) return;
const plan = app.ensurePlanLocal();
const vergeben = new Set((plan.todos || []).filter(t => t !== todo && t.lph).map(t => t.lph));
const beauftragt = Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : [];
const sel = document.getElementById('todoEditLph');
if(!sel) return;
sel.innerHTML = LPHS.map(l => {
const zusatz = vergeben.has(l) ? ' (schon im Plan)' : (beauftragt.length && !beauftragt.includes(l) ? ' (nicht beauftragt)' : '');
return `<option value="${esc(l)}"${vergeben.has(l) ? ' disabled' : ''}>${esc(phasenTitel(l) + zusatz)}</option>`;
}).join('');
const frei = l => !vergeben.has(l);
sel.value = (todo && todo.lph) || (vorgabeLph && frei(vorgabeLph) ? vorgabeLph : '') || LPHS.find(l => frei(l) && (!beauftragt.length || beauftragt.includes(l))) || LPHS.find(frei) || '';
if(isNew){
todoPhaseAutoTitel = phasenTitel(sel.value);
document.getElementById('todoEditTitle').value = todoPhaseAutoTitel;
} else {
todoPhaseAutoTitel = '';
}
}
// openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe
// oder null für eine neue), prefill (optional, Vorbelegung für "Speichern &
// Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer
@@ -380,7 +483,18 @@
const titleEl = document.getElementById('todoModalTitle');
if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten';
document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || '');
document.getElementById('todoEditTitle').value = isNew ? (pre.title || '') : (currentTodo.title || '');
const notizEl = document.getElementById('todoEditNotiz');
if(notizEl) notizEl.value = isNew ? (pre.notiz || '') : (currentTodo.notiz || '');
// Hinweis über der Notiz, nur bei einem Vorschlag aus "Termin aus Mail".
const mailHinweisEl = document.getElementById('todoEditMailHinweis');
if(mailHinweisEl){
mailHinweisEl.hidden = !(isNew && pre.mailHinweis);
mailHinweisEl.textContent = (isNew && pre.mailHinweis) || '';
}
todoNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null;
const saveNewBtn = document.getElementById('todoEditSaveNew');
if(saveNewBtn) saveNewBtn.hidden = !!todoNachSchliessen;
// Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare
// Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der
@@ -404,6 +518,12 @@
// Wert (dieselben Rahmendaten wie Zeitraum/Zuständige, s. saveTodoEdit()).
const showInGanttEl = document.getElementById('todoEditShowInGantt');
if(showInGanttEl) showInGanttEl.checked = isNew ? (pre.showInGantt !== undefined ? pre.showInGantt : true) : currentTodo.showInGantt !== false;
const aufwandEl = document.getElementById('todoEditAufwand');
if(aufwandEl){
const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand);
aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : '';
}
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
@@ -465,13 +585,25 @@
uniquePeople.push(a.person);
}
});
// Bereits Zuständige, die (nicht mehr) dem Projekt zugeordnet sind —
// etwa über "Aufgabe aus Kommentar" aus dem ganzen Teilnehmerkreis
// gewählt — ebenfalls als Chip zeigen. Ohne sie gingen sie beim
// Speichern stillschweigend verloren, weil saveTodoEdit() nur die
// gedrückten Chips liest.
currentAssignees.forEach(id => {
if(seenPeople.has(id)) return;
seenPeople.add(id);
const p = ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
uniquePeople.push(Object.assign({}, p, { nichtImProjekt: true }));
});
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
// Personenspalten im Projektboard.
assigneesDiv.innerHTML = uniquePeople.map(person => {
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
const short = person.name || initialsForName(person.fullName || '') || '?';
const full = person.fullName || person.name || '';
return `<button type="button" class="person-chip" data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
const fremd = person.nichtImProjekt ? ' title="Nicht (mehr) dem Projekt zugeordnet"' : '';
return `<button type="button" class="person-chip${person.nichtImProjekt ? ' ist-fremd' : ''}"${fremd} data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
}
@@ -512,8 +644,10 @@
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId);
const gantt = document.getElementById('todoEditShowInGantt');
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
currentTodoEditStatus(), read('todoEditProgress'), deps, people]);
currentTodoEditStatus(), read('todoEditProgress'), deps, people,
gantt ? gantt.checked : true, read('todoEditAufwand'), read('todoEditLph'), read('todoEditNotiz')]);
}
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
@@ -527,6 +661,9 @@
document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null;
todoModalSnapshot = '';
const danach = todoNachSchliessen;
todoNachSchliessen = null;
if(danach) setTimeout(danach, 0);
return true;
};
@@ -573,12 +710,21 @@
const showInGanttEl = document.getElementById('todoEditShowInGantt');
const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
// Aufwand in Stunden (optional): zählt zur Auslastung der Zuständigen.
const aufwandEl = document.getElementById('todoEditAufwand');
const aufwand = aufwandEl ? Math.max(0, parseFloat(String(aufwandEl.value).replace(',', '.')) || 0) : 0;
// Phasen-Modus: welche Leistungsphase dieser Balken ist.
const lphSel = document.getElementById('todoEditLph');
const lph = todoPhasenModus && lphSel ? lphSel.value : '';
const notizEl = document.getElementById('todoEditNotiz');
const notiz = notizEl ? notizEl.value.trim() : '';
if(todoPhasenModus && !lph){ alert('Bitte eine Leistungsphase wählen.'); return; }
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = [];
if(app.currentTodoIndex === null){
plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt });
plan.todos.push(Object.assign({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt, aufwand, notiz }, lph ? { lph, assignees: [], aufwand: 0 } : {}));
} else if(plan.todos[app.currentTodoIndex]){
const t = plan.todos[app.currentTodoIndex];
t.title = title;
@@ -589,6 +735,9 @@
t.dependsOn = dependsOn;
t.assignees = assignees;
t.showInGantt = showInGantt;
t.aufwand = aufwand;
t.notiz = notiz;
if(todoPhasenModus) t.lph = lph;
}
app.savePlan();
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
@@ -633,7 +782,12 @@
if(app.isLocked()) return;
const plan = app.ensurePlanLocal();
if(!plan.todos[idx]) return;
plan.todos[idx].done = !plan.todos[idx].done;
const t = plan.todos[idx];
t.done = !t.done;
// Fortschritt mitführen: Sonst blieb nach dem Zurücknehmen "100 %" stehen,
// und der Dialog las daraus wieder "fertig" (todoStatusFrom()).
if(t.done) t.progress = 100;
else if(Number(t.progress) >= 100) t.progress = 0;
app.savePlan();
renderPlanGantt();
};
+180
View File
@@ -0,0 +1,180 @@
// -----------------------------
// Projektbereich — Leistungsphasen
// -----------------------------
// Bereich "Leistungsphasen" (Eriks Wahl "B"): die Phasenbalken des
// Terminplans (Aufgaben mit t.lph, balkenPhase() in load.js) als eigener
// Bereich statt als Zeilen zwischen den Aufgaben. Oben ein Ablaufbalken über
// alle Phasen mit Heute-Linie, darunter eine Kachel je Phase: Fortschritt
// (aus dem Wochenbericht, p.lph), Zeitraum (aus dem Balken), Aufwand
// (Summe der Stunden ihrer Aufgaben) und die Aufgaben, die in ihrem
// Zeitraum beginnen — dieselbe Zuordnung wie die Gliederung im Terminplan
// (plan-build-gantt.js).
//
// Keine eigene Datenhaltung: Bearbeiten öffnet dasselbe Panel wie im
// Terminplan (openTodoModal() im Phasen-Modus), Fortschritt bleibt im
// Wochenbericht ("Ein Ort je Angabe").
//
// renderPlanPhasen() wird von renderPlanGantt() (plan-gantt.js) und beim
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) aufgerufen.
// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört —
// der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die
// Gliederung im Terminplan. Parameter: t (Aufgabe), todos (alle Aufgaben des
// Plans). Rückgabe: Phasenname ('LPH 5') oder ''.
function aufgabenPhase(t, todos){
if(!t || balkenPhase(t)) return '';
const s0 = parseDateISO(t.start);
if(!s0) return '';
const k = (todos || [])
.filter(b => balkenPhase(b) && b.start && b.end)
.sort((a, b) => String(a.start).localeCompare(String(b.start)))
.find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end));
return k ? balkenPhase(k) : '';
}
(function(){
const app = window.PlanApp;
const LPH_NAMEN = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung',
'W+M': 'Wartung und Monitoring'
};
const kurzDatum = d => d ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) : '';
const langDatum = d => d ? formatDateDE(d) : '';
// phasenDaten: je Phase alles, was Ablaufbalken und Kachel brauchen.
// Rückgabe: Array, sortiert läuft → geplant → fertig, darin nach Beginn.
function phasenDaten(){
const p = app.project;
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
// Phasen: beauftragte plus alle, die einen Balken haben.
const namen = LPHS.filter(l => beauftragt.includes(l) || todos.some(t => balkenPhase(t) === l));
const amtierend = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || null;
const laufend = typeof arbeitsPhase === 'function' ? arbeitsPhase(p, amtierend) : amtierend;
return namen.map(l => {
const idx = todos.findIndex(t => balkenPhase(t) === l);
const balken = idx >= 0 ? todos[idx] : null;
const start = balken ? parseDateISO(balken.start) : null;
const ende = balken ? parseDateISO(balken.end) : null;
const prozent = typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0;
const aufgaben = todos.filter(t => aufgabenPhase(t, todos) === l);
// Aufwand = Summe der Stunden ihrer Aufgaben (dieselbe Quelle wie die
// Auslastung, js/load/load.js), nicht mehr aus dem Honorar.
const stunden = Math.round(aufgaben.reduce((s, t) => s + (Number(t.aufwand) || 0), 0) * 10) / 10;
const status = prozent >= 100 ? 'fertig' : (l === laufend ? 'laeuft' : 'geplant');
return { l, idx, balken, start, ende, prozent, stunden, aufgaben, status,
beauftragt: !beauftragt.length || beauftragt.includes(l) };
}).sort((a, b) => {
const rang = { laeuft: 0, geplant: 1, fertig: 2 };
if(rang[a.status] !== rang[b.status]) return rang[a.status] - rang[b.status];
return LPHS.indexOf(a.l) - LPHS.indexOf(b.l);
});
}
// ablaufHtml: der schmale Balken über alle Phasen mit Zeitraum. Phasen
// ohne Zeitraum fehlen hier (sie stehen als Kachel mit Hinweis darunter).
function ablaufHtml(daten){
const mit = daten.filter(d => d.start && d.ende && d.ende >= d.start)
.sort((a, b) => a.start - b.start);
if(!mit.length) return '';
const von = mit[0].start.getTime();
const bis = Math.max(...mit.map(d => d.ende.getTime()));
const spanne = Math.max(1, bis - von);
const pos = t => Math.min(100, Math.max(0, (t - von) / spanne * 100));
const heute = new Date(); heute.setHours(0, 0, 0, 0);
const heutePos = heute.getTime() >= von && heute.getTime() <= bis ? pos(heute.getTime()) : null;
const segmente = mit.map(d => {
const links = pos(d.start.getTime());
const breite = Math.max(1.5, pos(d.ende.getTime() + 86400000) - links);
const titel = `${d.l}: ${langDatum(d.start)} – ${langDatum(d.ende)}, ${d.prozent} %`;
return `<button type="button" class="phasen-ablauf-seg is-${d.status}" style="left:${links}%;width:${breite}%;--fort:${d.prozent}%"
title="${esc(titel)}" onclick="phasenKachelZeigen('${esc(d.l)}')"><span>${esc(breite > 6 ? d.l : d.l.replace('LPH ', ''))}</span></button>`;
}).join('');
const heuteHtml = heutePos === null ? '' : `<span class="phasen-ablauf-heute" style="left:${heutePos}%" title="heute"></span>`;
const monat = d => d.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' });
return `<div class="plan-panel phasen-ablauf">
<div class="phasen-kopf">Ablauf</div>
<div class="phasen-ablauf-spur">${segmente}${heuteHtml}</div>
<div class="phasen-ablauf-achse"><span>${esc(monat(mit[0].start))}</span>${heutePos === null ? '' : `<span class="phasen-ablauf-heute-text" style="left:${heutePos}%">heute, ${esc(kurzDatum(heute))}</span>`}<span>${esc(monat(new Date(bis)))}</span></div>
</div>`;
}
// kachelHtml: eine Kachel je Phase.
function kachelHtml(d, locked){
const statusText = { laeuft: 'läuft', geplant: 'geplant', fertig: 'fertig' }[d.status];
const erledigt = d.aufgaben.filter(t => t.done).length;
const aufgabenText = d.aufgaben.length ? `${erledigt} von ${d.aufgaben.length} erledigt` : 'keine';
const zeitraum = d.start && d.ende
? `${d.start.getFullYear() === d.ende.getFullYear() ? kurzDatum(d.start) : langDatum(d.start)} – ${langDatum(d.ende)}`
: '<span class="phasen-leer">kein Zeitraum</span>';
const bearbeiten = locked ? ''
: (d.balken
? `<button class="secondary small" onclick="openTodoModal(${d.idx})">Bearbeiten</button>`
: `<button class="secondary small" onclick="addPlanPhase('${esc(d.l)}')">Zeitraum festlegen</button>`);
const ansehen = d.aufgaben.length
? `<button class="secondary small" onclick="phasenAufgabenZeigen('${esc(d.l)}')">Aufgaben ansehen</button>` : '';
return `<div class="phasen-kachel is-${d.status}" data-phase="${esc(d.l)}">
<div class="phasen-kachel-kopf">
<div class="phasen-ring" style="--fort:${d.prozent}%"><span>${d.prozent} %</span></div>
<div class="phasen-kachel-name">
<b>${esc(d.l)}</b>
<span>${esc(LPH_NAMEN[d.l] || '')}</span>
</div>
<span class="phasen-chip is-${d.status}">${statusText}</span>
</div>
${d.beauftragt ? '' : '<div class="phasen-hinweis">nicht beauftragt</div>'}
<div class="phasen-werte">
<span><small>Zeitraum</small><b>${zeitraum}</b></span>
<span><small>Aufwand</small><b>${d.stunden ? d.stunden.toLocaleString('de-DE') + ' h' : '<span class="phasen-leer">keine Stunden</span>'}</b></span>
<span><small>Aufgaben</small><b>${aufgabenText}</b></span>
</div>
${bearbeiten || ansehen ? `<div class="phasen-knoepfe">${bearbeiten}${ansehen}</div>` : ''}
</div>`;
}
// renderPlanPhasen: baut den Bereich neu auf. Keine Parameter.
window.renderPlanPhasen = function(){
const el = document.getElementById('planPhasenWrap');
if(!el || !app.project) return;
const locked = app.isLocked();
const daten = phasenDaten();
if(!daten.length){
el.innerHTML = `<div class="plan-panel"><div class="plan-task-empty">${locked
? 'Für dieses Projekt sind keine Leistungsphasen hinterlegt.'
: 'Noch keine Leistungsphase. Unter „Einstellungen“ beauftragen oder über „+ Leistungsphase“ bzw. „Terminplan aus Honorar“ anlegen.'}</div></div>`;
return;
}
const summeH = daten.reduce((s, d) => s + d.stunden, 0);
const enden = daten.filter(d => d.ende).map(d => d.ende.getTime());
const laufend = daten.find(d => d.status === 'laeuft');
const teile = [daten.filter(d => d.beauftragt).length + ' beauftragt'];
if(laufend) teile.push('läuft: ' + laufend.l);
if(summeH) teile.push(summeH.toLocaleString('de-DE') + ' h in Aufgaben');
if(enden.length) teile.push('Ende voraussichtlich ' + langDatum(new Date(Math.max(...enden))));
el.innerHTML = `<div class="phasen-zusammenfassung">${esc(teile.join(' · '))}<span>Fortschritt aus dem Wochenbericht</span></div>
${ablaufHtml(daten)}
<div class="phasen-raster">${daten.map(d => kachelHtml(d, locked)).join('')}</div>`;
};
// phasenKachelZeigen: Klick auf ein Segment im Ablauf — Kachel ins Bild
// holen und kurz hervorheben.
window.phasenKachelZeigen = function(l){
const k = document.querySelector(`#planPhasenWrap .phasen-kachel[data-phase="${CSS.escape(l)}"]`);
if(!k) return;
k.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
k.classList.remove('ist-markiert'); void k.offsetWidth; k.classList.add('ist-markiert');
};
// phasenAufgabenZeigen: in den Bereich Aufgaben, gefiltert auf diese Phase.
window.phasenAufgabenZeigen = function(l){
if(typeof setzeAufgabenFilter === 'function') setzeAufgabenFilter(l);
showPlanSection('aufgaben');
};
})();
+1 -1
View File
@@ -177,7 +177,7 @@
body.innerHTML = `
<div class="todo-depends-list" id="protokollTodosPunkteListe" style="max-height:280px">
${erkanntePunkte.map((p, i) => {
const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : (p.status === 'offen');
const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : ((p.status || (p.erledigt ? 'erledigt' : 'offen')) === 'offen');
punkteAuswahl[i] = checked;
return `<label class="todo-depends-item">
<input type="checkbox" value="${i}" ${checked ? 'checked' : ''}>
+118 -31
View File
@@ -2,7 +2,7 @@
// Projektplan — Protokolle (Besprechungsprotokolle je Projekt)
// -----------------------------
// Bereich "Protokolle" des Projektbereichs (siehe js/plan/plan-sections.js)
// plus eine Kompakt-Karte im Dashboard (#planProtokolleSummary, springt per
// plus eine Kachel im Dashboard (#planDashProtokolle, springt per
// Klick über openProtokollePanel() in diesen Bereich — gleiches Muster wie
// #planFilesSummary/openFilesPanel() in plan-files.js).
//
@@ -77,28 +77,103 @@
});
}
// renderProtokolleSummary: füllt die Kompakt-Vorschau der Dashboard-Karte
// (#planProtokolleSummary) — letztes Protokoll + Anzahl offener Punkte,
// gleiches Muster wie renderFilesSummary() in plan-files.js.
// renderProtokolleSummary: füllt die Dashboard-Kachel "Protokolle"
// (#planDashProtokolle, Eriks Wunsch — vorher nur eine Zeile in
// "Unterlagen"). Oben das letzte Protokoll, darunter dessen OFFENE Punkte
// (die Frage nach einer Besprechung ist "was ist noch offen?"), unten die
// früheren Protokolle als Kurzliste. Die Punkte kommen aus einem zweiten
// Abruf (GET /project-protokolle/:id) — die Liste liefert nur Zahlen.
const DASH_PROTOKOLL_PUNKTE = 5;
const DASH_PROTOKOLL_FRUEHERE = 3;
let dashProtokollAbruf = 0;
function datumDe(iso){
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''));
return m ? `${m[3]}.${m[2]}.${m[1]}` : String(iso || '');
}
function renderProtokolleSummary(){
const el = document.getElementById('planProtokolleSummary');
const el = document.getElementById('planDashProtokolle');
const panel = document.getElementById('planDashProtokollePanel');
if(!el) return;
if(!app.project || !app.project.nr){
el.innerHTML = '<span class="hint">Keine Projektnummer.</span>';
return;
}
// Ohne Projektnummer (Planung-Terminplan) gibt es keine Protokolle —
// die Kachel verschwindet wie der Bereich selbst.
if(panel) panel.hidden = !(app.project && app.project.nr);
if(!app.project || !app.project.nr){ el.innerHTML = ''; return; }
if(protokolleCache.length === 0){
el.innerHTML = '<span class="hint">Noch keine Protokolle.</span>';
el.innerHTML = `<div class="plan-dash-leer">Noch keine Protokolle.
<button type="button" class="plan-panel-link" onclick="dashNeuesProtokoll()">+ Neues Protokoll</button></div>`;
return;
}
const letztes = protokolleCache[0]; // Server liefert nach nummer DESC
const chip = letztes.offenCount > 0
? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>`
: '<span class="plan-mini-chip">Alle erledigt</span>';
el.innerHTML = `<div class="plan-mini-card-preview">Nr. ${esc(letztes.nummer)} · ${esc(letztes.betreff || 'ohne Betreff')}</div>
<div class="plan-mini-chip-row">${chip}</div>`;
const frueher = protokolleCache.slice(1, 1 + DASH_PROTOKOLL_FRUEHERE);
const zahlen = [
letztes.offenCount ? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>` : '<span class="plan-mini-chip">nichts offen</span>',
letztes.erledigtCount ? `<span class="plan-mini-chip">${letztes.erledigtCount} erledigt</span>` : '',
letztes.infoCount ? `<span class="plan-mini-chip">${letztes.infoCount} Info</span>` : ''
].join('');
el.innerHTML = `
<button type="button" class="plan-prot-kopf" onclick="dashOeffneProtokoll('${esc(letztes.id)}')">
<span class="plan-prot-nr">Nr. ${esc(letztes.nummer)}</span>
<span class="plan-prot-titel"><b>${esc(letztes.betreff || 'ohne Betreff')}</b>
<span>${esc(datumDe(letztes.datum))}${letztes.ort ? ' · ' + esc(letztes.ort) : ''}${letztes.teilnehmerCount ? ' · ' + letztes.teilnehmerCount + ' Teilnehmer' : ''}</span></span>
</button>
<div class="plan-mini-chip-row">${zahlen}</div>
<div class="plan-prot-punkte" id="planDashProtokollPunkte">${letztes.offenCount ? '<span class="hint">Lade offene Punkte…</span>' : ''}</div>
${frueher.length ? `<div class="plan-termin-gruppe">Frühere Protokolle</div>
${frueher.map(pr => `<button type="button" class="plan-prot-frueher" onclick="dashOeffneProtokoll('${esc(pr.id)}')">
<span>Nr. ${esc(pr.nummer)} · ${esc(datumDe(pr.datum))}</span>
<span class="plan-prot-frueher-titel">${esc(pr.betreff || 'ohne Betreff')}</span>
<span class="plan-prot-frueher-zahl">${pr.offenCount ? pr.offenCount + ' offen' : ''}</span>
</button>`).join('')}` : ''}`;
if(letztes.offenCount) ladeOffenePunkte(letztes.id);
}
function ladeOffenePunkte(id){
const nr = ++dashProtokollAbruf;
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(id))
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(d => {
if(nr !== dashProtokollAbruf) return; // inzwischen anderes Projekt
const ziel = document.getElementById('planDashProtokollPunkte');
if(!ziel || !d.protokoll) return;
const punkte = d.protokoll.punkte || [];
const nummern = window.berechneProtokollNummern(punkte);
const heute = typeof heuteIso === 'function' ? heuteIso() : new Date().toISOString().slice(0, 10);
const offen = punkte.map((p, i) => Object.assign({}, p, { anzeigeNr: nummern[i] }))
.filter(p => p.status === 'offen')
// Überfälliges zuerst, dann nach Termin, ohne Termin zuletzt
.sort((a, b) => (a.termin || '9999').localeCompare(b.termin || '9999'));
const zeigen = offen.slice(0, DASH_PROTOKOLL_PUNKTE);
const rest = offen.length - zeigen.length;
ziel.innerHTML = zeigen.map(p => {
const ueber = p.termin && p.termin < heute;
const titel = p.thema || statusPlainText(p.text).slice(0, 80) || 'ohne Thema';
return `<div class="plan-termin ist-klickbar" onclick="dashOeffneProtokoll('${esc(d.protokoll.id)}')">
<span class="plan-termin-art">${esc(p.anzeigeNr)}</span>
<span class="plan-termin-text"><b>${esc(titel)}</b><span>${p.gruppe ? esc(p.gruppe) + ' · ' : ''}${p.zustaendig ? esc(p.zustaendig) : 'ohne Zuständigen'}</span></span>
<span class="plan-termin-datum${ueber ? ' ist-rot' : ''}">${p.termin ? esc(datumDe(p.termin)) + (typeof relativerTag === 'function' ? '<em>' + esc(relativerTag(p.termin)) + '</em>' : '') : '—'}</span>
</div>`;
}).join('') + (rest > 0 ? `<button type="button" class="plan-panel-link plan-prot-mehr" onclick="dashOeffneProtokoll('${esc(d.protokoll.id)}')">… ${rest} weitere offene Punkte</button>` : '');
})
.catch(err => {
if(nr !== dashProtokollAbruf) return;
const ziel = document.getElementById('planDashProtokollPunkte');
if(ziel) ziel.innerHTML = '<span class="hint">Offene Punkte konnten nicht geladen werden.</span>';
console.error('Offene Protokollpunkte laden fehlgeschlagen:', err);
});
}
// dashOeffneProtokoll: aus der Kachel direkt in DIESES Protokoll.
window.dashOeffneProtokoll = function(id){
window.openProtokollePanel();
window.oeffneProtokoll(id);
};
window.dashNeuesProtokoll = function(){
window.openProtokollePanel();
window.neuesProtokoll();
};
// openProtokollePanel: springt in den Bereich "Protokolle" — Gegenstück zu
// openFilesPanel()/openResourceModal().
window.openProtokollePanel = function(){
@@ -175,7 +250,7 @@
// currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten).
// Keine Rückgabe; rendert nur den Punkte-Block neu.
window.verschiebePunkt = function(idx, richtung){
if(!currentProtokoll || app.isLocked()) return;
if(!currentProtokoll || app.protokolleGesperrt()) return;
const punkte = currentProtokoll.punkte;
const gruppe = String(punkte[idx].gruppe || '').trim();
const gleicheGruppeIdx = punkte.map((p, i) => i).filter(i => String(punkte[i].gruppe || '').trim() === gruppe);
@@ -197,7 +272,7 @@
// Parameter: gruppe (Name), richtung (-1 = nach vorn, +1 = nach hinten).
// Keine Rückgabe.
window.verschiebeGruppe = function(gruppe, richtung){
if(!currentProtokoll || app.isLocked()) return;
if(!currentProtokoll || app.protokolleGesperrt()) return;
const punkte = currentProtokoll.punkte;
const namen = [];
punkte.forEach(p => {
@@ -230,7 +305,7 @@
function renderProtokolleListe(){
const container = document.getElementById('protokolleContainer');
if(!container) return;
const locked = app.isLocked();
const locked = app.protokolleGesperrt();
const anlegeBtns = locked ? '' : `<button onclick="neuesProtokoll()">+ Neues Protokoll</button>
<button class="secondary" onclick="oeffneProtokollImportDialog()">Protokoll importieren</button>`;
if(protokolleCache.length === 0){
@@ -262,7 +337,7 @@
// "anwesend" zurückgesetzt), sonst Fallback auf projectAssignees(), sonst
// leer. Das eigentliche Fortschreiben der Punkte übernimmt der Server.
window.neuesProtokoll = async function(){
if(app.isLocked()) return;
if(app.protokolleGesperrt()) return;
let teilnehmer = [];
try {
if(protokolleCache.length > 0){
@@ -317,7 +392,7 @@
// firmenweiten Stand, und kann sich seit dem letzten Öffnen geändert
// haben.
window.oeffneProtokollImportDialog = function(){
if(app.isLocked()) return;
if(app.protokolleGesperrt()) return;
document.getElementById('protokollImportFile').value = '';
document.getElementById('protokollImportBetreff').value = '';
document.getElementById('protokollImportOrt').value = '';
@@ -460,7 +535,7 @@
const container = document.getElementById('protokolleContainer');
if(!container || !currentProtokoll) return;
const p = currentProtokoll;
const locked = app.isLocked();
const locked = app.protokolleGesperrt();
const datumDisplay = formatDateDE(parseDateISO(p.datum)) || '';
const quelleHinweis = p.quelleDateiname
@@ -634,7 +709,7 @@
// aktualisiereListenZeile() oben.
function renderPunkteBlockHtml(){
const p = currentProtokoll;
const locked = app.isLocked();
const locked = app.protokolleGesperrt();
const gruppen = gruppenReihenfolge();
const nummern = window.berechneProtokollNummern(p.punkte);
@@ -717,7 +792,7 @@
// in currentProtokoll.punkte, -1 = kein Punkt vorhanden), nummern (das
// Ergebnis von berechneProtokollNummern()). Rückgabe: HTML-String.
function renderSchreibbereichHtml(idx, nummern){
const locked = app.isLocked();
const locked = app.protokolleGesperrt();
if(idx < 0){
return `<div class="protokoll-schreiben ist-leer">
<p>Noch kein Punkt angelegt.</p>
@@ -874,17 +949,17 @@
// ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ----------
window.fuegeTeilnehmerHinzu = function(){
if(!currentProtokoll || app.isLocked()) return;
if(!currentProtokoll || app.protokolleGesperrt()) return;
currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true });
renderProtokollDetail();
};
window.entferneTeilnehmer = function(idx){
if(!currentProtokoll || app.isLocked()) return;
if(!currentProtokoll || app.protokolleGesperrt()) return;
currentProtokoll.teilnehmer.splice(idx, 1);
renderProtokollDetail();
};
window.fuegePunktHinzu = function(gruppe){
if(!currentProtokoll || app.isLocked()) return;
if(!currentProtokoll || app.protokolleGesperrt()) return;
// nummer wird nicht gesetzt — sie ergibt sich beim Rendern automatisch
// aus Gruppe und Position (siehe berechneProtokollNummern() oben).
// erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist
@@ -894,7 +969,19 @@
thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
erstelltAm: currentProtokoll.datum || '', entfaelltNaechstes: false
};
currentProtokoll.punkte.push(neuerPunkt);
// Hinter dem letzten Punkt DERSELBEN Gruppe einfügen statt ans Ende: Die
// Nummern folgen der Reihenfolge im Array (berechneProtokollNummern()),
// die Anzeige aber gruppiert. Ein ans Ende gehängter Punkt "ohne Gruppe"
// bekam sonst eine Nummer hinter allen Gruppen (1–5, dann 7, darunter 6.1).
// Gibt es noch keinen Punkt ohne Gruppe, kommt er an den Anfang — dieser
// Abschnitt steht immer zuerst.
const liste = currentProtokoll.punkte;
const g = gruppe || '';
let letzter = -1;
liste.forEach((x, i) => { if((x.gruppe || '') === g) letzter = i; });
if(letzter >= 0) liste.splice(letzter + 1, 0, neuerPunkt);
else if(!g) liste.unshift(neuerPunkt);
else liste.push(neuerPunkt);
// Den neuen Punkt gleich im Schreibbereich öffnen und ins Thema-Feld
// springen — sonst stünde er zwar links in der Liste, rechts bliebe
// aber der vorige Punkt stehen und man müsste ihn erst anklicken.
@@ -914,7 +1001,7 @@
// (nicht mehr einem Feld am Punkt, siehe currentProtokollUmbenenneGruppe()
// oben) fokussiert und markiert, damit er sich sofort überschreiben lässt.
window.fuegeNeueGruppeHinzu = function(){
if(!currentProtokoll || app.isLocked()) return;
if(!currentProtokoll || app.protokolleGesperrt()) return;
const bestehende = new Set(gruppenReihenfolge());
let name = 'Neue Gruppe';
let n = 2;
@@ -927,7 +1014,7 @@
if(input){ input.focus(); input.select(); }
};
window.entfernePunkt = function(idx){
if(!currentProtokoll || app.isLocked()) return;
if(!currentProtokoll || app.protokolleGesperrt()) return;
// War der gelöschte Punkt der geöffnete, rückt der an seine Stelle
// getretene nach (bzw. der letzte, wenn es der letzte war) — sonst
// stünde der Schreibbereich leer da, obwohl noch Punkte vorhanden sind.
@@ -947,7 +1034,7 @@
// Punkte komplett (siehe server.js). Ausdrückliches Speichern, kein
// Auto-Save, wie die Board-Kommentare.
window.speichereProtokoll = function(){
if(!currentProtokoll || app.isLocked()) return;
if(!currentProtokoll || app.protokolleGesperrt()) return;
// Errechnete Nummern vor dem Senden in die Punkte schreiben, damit die
// gespeicherten Werte mit der Anzeige übereinstimmen (maßgeblich ist beim
// Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert —
@@ -995,7 +1082,7 @@
};
window.loescheProtokoll = function(){
if(!currentProtokoll || app.isLocked()) return;
if(!currentProtokoll || app.protokolleGesperrt()) return;
if(!confirm('Protokoll Nr. ' + currentProtokoll.nummer + ' wirklich löschen?')) return;
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), { method: 'DELETE' })
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
+7 -115
View File
@@ -1,13 +1,11 @@
// -----------------------------
// Projektplan — Kritischer Pfad, Auto-Schedule & Mail-Link
// Projektplan — Auto-Schedule & Mail-Link
// -----------------------------
// Dieses Modul berechnet über computeCriticalPath die Aufgabe(n) auf dem
// kritischen Pfad (längste Vorgänger-Kette) und hebt sie im GANTT hervor,
// verschiebt bei autoScheduleTodos TODOs und Meilensteine automatisch hinter
// ihre Vorgänger und erstellt bei todoMail einen mailto-Link an alle
// zugeordneten Personen. Die Hervorhebung übergibt renderPlanGantt als
// Option criticalPath an buildGanttHtml (plan-build-gantt.js); den Schalter
// criticalPathActive hält der Zustand in window.PlanApp (plan-state.js).
// Dieses Modul verschiebt bei autoScheduleTodos TODOs und Meilensteine
// automatisch hinter ihre Vorgänger (aufgerufen beim Ziehen eines Balkens,
// plan-drag.js, und über "Auto-Schedule" im Bereich Aufgaben) und erstellt
// bei todoMail einen mailto-Link an alle zugeordneten Personen. Die frühere
// Hervorhebung des kritischen Pfads ist auf Eriks Wunsch entfallen.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
@@ -15,113 +13,7 @@
(function(){
const app = window.PlanApp;
// ---------- Kritischer Pfad & Auto-Schedule ----------
// computeCriticalPath: liefert Array der TODO-IDs, die auf einem kritischen Pfad liegen
// (längste Kette von Vorgänger-Abhängigkeiten, End-to-Start)
window.computeCriticalPath = function(project){
if(!project || !project.plan) return [];
const todos = (project.plan.todos || []).filter(t => t.start && t.end);
const milestones = (project.plan.milestones || []).filter(m => m.date);
if(todos.length === 0 && milestones.length === 0) return [];
// Einheitliche Knoten: TODOs und Meilensteine (Dauer 1)
const nodes = todos.map(t => ({ type: 'todo', id: t.id, obj: t, start: parseDateISO(t.start), end: parseDateISO(t.end), duration: Math.max(1, daysBetween(parseDateISO(t.start), parseDateISO(t.end)) + 1) }));
milestones.forEach(m => {
const d = parseDateISO(m.date);
nodes.push({ type: 'milestone', id: 'm_' + m.id, obj: m, start: d, end: d, duration: 1 });
});
const nodeMap = new Map(nodes.map(n => [n.id, n]));
// depsOf: liefert die Vorgänger-Liste eines Knotens (leeres Array, falls
// keine vorhanden).
function depsOf(n){
return Array.isArray(n.obj.dependsOn) ? n.obj.dependsOn : [];
}
// Frühester Start (ES) und frühestes Ende (EF) berechnen.
// visiting-Set schützt vor Endlos-Rekursion bei zyklischen Abhängigkeiten.
const es = {}, ef = {};
const esVisiting = new Set();
// computeES: berechnet rekursiv den frühesten Start (ES) und frühestes
// Ende (EF) eines Knotens aus seinen Vorgängern. Liefert ES oder null
// bei Zyklen (visiting-Set).
function computeES(n){
if(es[n.id] !== undefined) return es[n.id];
if(esVisiting.has(n.id)){ es[n.id] = null; ef[n.id] = null; return null; } // Zyklus
if(!n.start || !n.end){ es[n.id] = null; ef[n.id] = null; return null; }
esVisiting.add(n.id);
const deps = depsOf(n);
let maxEF = null;
deps.forEach(depId => {
const dep = nodeMap.get(depId);
if(!dep) return;
const depStart = computeES(dep);
if(!depStart) return;
const depEnd = addDays(depStart, dep.duration - 1);
if(maxEF === null || depEnd > maxEF) maxEF = depEnd;
});
esVisiting.delete(n.id);
es[n.id] = maxEF === null ? n.start : addDays(maxEF, 1);
ef[n.id] = addDays(es[n.id], n.duration - 1);
return es[n.id];
}
nodes.forEach(n => computeES(n));
const validNodes = nodes.filter(n => es[n.id]);
if(validNodes.length === 0) return [];
const maxEF = validNodes.reduce((max, n) => { const d = ef[n.id]; return !max || d > max ? d : max; }, null);
// Spätestes Ende (LF) und spätester Start (LS) rückwärts berechnen.
// Wichtig: LF eines Knotens ist der Tag VOR dem spätesten Start (LS)
// seines frühesten Nachfolgers – nicht vor dessen LF.
const lf = {}, ls = {};
const lfVisiting = new Set();
// computeLF: berechnet rekursiv den spätesten Start (LS) und das späteste
// Ende (LF) eines Knotens rückwärts aus seinen Nachfolgern. Liefert LF
// oder null bei Zyklen (visiting-Set).
function computeLF(n){
if(lf[n.id] !== undefined) return lf[n.id];
if(lfVisiting.has(n.id)){ lf[n.id] = null; ls[n.id] = null; return null; } // Zyklus
if(!n.start || !n.end){ lf[n.id] = null; ls[n.id] = null; return null; }
lfVisiting.add(n.id);
const successors = validNodes.filter(succ => depsOf(succ).includes(n.id));
let minLF = null;
successors.forEach(succ => {
if(!computeLF(succ)) return;
const succLS = ls[succ.id];
if(!succLS) return;
const candidate = addDays(succLS, -1);
if(minLF === null || candidate < minLF) minLF = candidate;
});
lfVisiting.delete(n.id);
lf[n.id] = minLF === null ? maxEF : minLF;
ls[n.id] = addDays(lf[n.id], -(n.duration - 1));
return lf[n.id];
}
validNodes.forEach(n => computeLF(n));
return validNodes.filter(n => n.type === 'todo' && es[n.id] && ls[n.id] && daysBetween(es[n.id], ls[n.id]) === 0).map(n => n.id);
};
// toggleCriticalPath: schaltet die Hervorhebung des kritischen Pfads im
// GANTT ein/aus (Wert in app.criticalPathActive), aktualisiert den
// Toolbar-Button und zeigt über einen Toast an, wie viele Aufgaben auf
// dem kritischen Pfad liegen.
window.toggleCriticalPath = function(){
app.criticalPathActive = !app.criticalPathActive;
const btn = document.getElementById('criticalPathBtn');
if(btn) btn.classList.toggle('active', app.criticalPathActive);
renderPlanGantt();
if(app.criticalPathActive){
const path = computeCriticalPath(app.project);
if(path.length === 0){
app.showToast('Kein kritischer Pfad ermittelt (zu wenig Aufgaben oder Abhängigkeiten).');
} else {
app.showToast(path.length + ' Aufgabe(n) auf dem kritischen Pfad hervorgehoben.');
}
}
};
// ---------- Auto-Schedule ----------
// autoScheduleTodos: verschiebt alle TODOs und Meilensteine, die von
// anderen Elementen abhängen, automatisch auf den frühesten möglichen
+115 -43
View File
@@ -36,7 +36,7 @@
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
// Navigationsleiste. Muss zu den data-section-Attributen in
// App.html passen.
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'phasen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
@@ -63,6 +63,7 @@
if(!app.project) return;
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
@@ -106,53 +107,88 @@
if(nav) nav.style.display = '';
}
// upcomingEntries: sammelt die nächsten Termine des Projekts — offene
// Aufgaben mit Endtermin und Meilensteine mit Datum — in einer gemeinsamen,
// chronologisch sortierten Liste. Überfällige Einträge stehen zuerst, damit
// sie im Dashboard nicht untergehen. Rückgabe: Array von
// { kind, title, date, overdue }.
// upcomingEntries: offene Aufgaben mit Endtermin und Meilensteine ab heute
// (vergangene Meilensteine haben kein Erledigt-Kennzeichen und gelten als
// gewesen), chronologisch. idx verweist in plan.todos/plan.milestones, damit
// ein Klick denselben Dialog öffnet wie im Terminplan.
// Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }.
function upcomingEntries(){
const plan = app.ensurePlanLocal();
const today = new Date();
today.setHours(0, 0, 0, 0);
const heute = heuteIso();
const ich = currentUser && currentUser.personId;
const namen = {};
((app.week && app.week.people) || []).forEach(x => { namen[x.id] = x.fullName || x.name; });
const rows = [];
(plan.todos || []).forEach(t => {
if(t.done) return;
const d = parseDateISO(t.end);
if(!d) return;
rows.push({ kind: 'Aufgabe', title: t.title || 'Aufgabe', date: d, overdue: d < today });
(plan.todos || []).forEach((t, idx) => {
if(t.done || !t.end) return;
const wer = (t.assignees || []).map(id => namen[id] || id);
rows.push({ art: 'aufgabe', idx, titel: t.title || 'Aufgabe', iso: t.end, wer,
meins: !!ich && (t.assignees || []).includes(ich) });
});
(plan.milestones || []).forEach(m => {
const d = parseDateISO(m.date);
if(!d) return;
rows.push({ kind: 'Meilenstein', title: m.name || 'Meilenstein', date: d, overdue: d < today });
(plan.milestones || []).forEach((m, idx) => {
if(!m.date || m.date < heute) return;
rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false });
});
rows.sort((a, b) => a.date - b.date);
rows.sort((a, b) => a.iso.localeCompare(b.iso));
return rows;
}
// renderPlanDashboard: füllt die beiden eigenen Karten des Dashboards
// ("Nächste Termine" und "Team"). Die übrigen Inhalte des Bereichs
// stammen aus bereits vorhandenen Render-Funktionen: die KPI-Kacheln aus
// renderPlanKpis() (plan-gantt.js), Kommentar und Leistungsphasen aus
// renderPlanInfo() (plan-info.js), die Kompakt-Karten aus
// renderResourceLoad()/renderProjectFiles()/renderPlanHistory().
// Keine Parameter, keine Rückgabe.
// PLAN_DASH_ANTEIL: wie viele Einträge je Gruppe offen dastehen — jede
// Gruppe bekommt ihren eigenen Anteil, sonst füllten 25 überfällige
// Aufgaben die Liste und "Diese Woche" stand ohne Einträge da. Der Rest
// steckt hinter "… weitere in Aufgaben".
const PLAN_DASH_ANTEIL = { ueber: 4, woche: 4, spaeter: 3 };
// renderPlanDashboard: füllt "Termine und Aufgaben" (gegliedert nach
// Überfällig / Diese Woche / Später) und "Team" (eine Zeile je PERSON mit
// ihren Rollen als Chips — vorher stand jemand mit drei Rollen dreimal da).
// Kennzahlenband, Kommentar, Leistungsphasen und Unterlagen füllen
// renderPlanKpis()/renderPlanInfo()/renderResourceLoad()/
// renderProjectFiles(). Keine Parameter.
window.renderPlanDashboard = function(){
if(!app.project) return;
const locked = app.isLocked();
const upcomingEl = document.getElementById('planDashUpcoming');
if(upcomingEl){
const rows = upcomingEntries().slice(0, 8);
upcomingEl.innerHTML = rows.length === 0
? '<div class="plan-task-empty">Keine offenen Termine hinterlegt.</div>'
: '<div class="plan-upcoming-list">' + rows.map(r => `<div class="plan-upcoming-item${r.overdue ? ' overdue' : ''}">
<span class="plan-upcoming-date">${esc(formatDateDE(r.date))}</span>
<span>${esc(r.title)}</span>
<span class="plan-upcoming-kind">${esc(r.kind)}</span>
</div>`).join('') + '</div>';
const heute = heuteIso();
const wocheEnde = isoPlusTage(7);
const alle = upcomingEntries();
if(alle.length === 0){
upcomingEl.innerHTML = '<div class="plan-task-empty">Keine offenen Termine. Aufgaben und Meilensteine entstehen im Terminplan.</div>';
} else {
const gruppen = [
{ key: 'ueber', titel: 'Überfällig', klasse: 'ist-rot', treffer: r => r.art === 'aufgabe' && r.iso < heute },
{ key: 'woche', titel: 'Diese Woche', klasse: '', treffer: r => r.iso >= heute && r.iso <= wocheEnde },
{ key: 'spaeter', titel: 'Später', klasse: '', treffer: r => r.iso > wocheEnde },
];
let gezeigt = 0;
let html = '';
gruppen.forEach(g => {
const alleDerGruppe = alle.filter(g.treffer);
const sichtbar = alleDerGruppe.slice(0, PLAN_DASH_ANTEIL[g.key]);
gezeigt += sichtbar.length;
if(!alleDerGruppe.length) return;
html += `<div class="plan-termin-gruppe ${g.klasse}">${esc(g.titel)}<span>${alleDerGruppe.length}</span></div>`;
html += sichtbar.map(r => {
const klick = locked ? '' : ` onclick="${r.art === 'meilenstein' ? 'openMilestoneModal' : 'openTodoModal'}(${r.idx})"`;
const zeichen = r.art === 'meilenstein' ? '◆' : (r.meins ? 'A' : 'a');
const klasse = r.art === 'meilenstein' ? 'ist-ms' : (r.meins ? 'ist-meins' : '');
const wer = r.art === 'meilenstein' ? 'Meilenstein' : (r.wer.length ? r.wer.join(', ') : 'niemand zugewiesen');
const rot = r.art === 'aufgabe' && r.iso < heute;
return `<div class="plan-termin${locked ? '' : ' ist-klickbar'}"${klick}>
<span class="plan-termin-art ${klasse}">${zeichen}</span>
<span class="plan-termin-text"><b>${esc(r.titel)}</b><span>${esc(wer)}</span></span>
<span class="plan-termin-datum${rot ? ' ist-rot' : ''}">${esc(formatDateDE(parseDateISO(r.iso)))}<em>${esc(relativerTag(r.iso))}</em></span>
</div>`;
}).join('');
});
const verborgen = alle.length - gezeigt;
if(verborgen > 0){
html += `<button type="button" class="plan-panel-link plan-termin-mehr" onclick="showPlanSection('aufgaben')">${verborgen} weitere in „Aufgaben“</button>`;
}
upcomingEl.innerHTML = html;
}
}
const teamEl = document.getElementById('planDashTeam');
@@ -162,14 +198,50 @@
teamEl.innerHTML = '<div class="plan-task-empty">Diesem Projekt ist noch niemand zugeordnet. Zuordnung im Bereich „Einstellungen“.</div>';
} else {
const plan = app.ensurePlanLocal();
teamEl.innerHTML = '<div class="plan-dash-team">' + assignees.map(a => {
const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(a.person.id) !== -1).length;
const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
return `<div class="plan-dash-team-row">
<span class="board-avatar ${app.planRoleClass(a.role)} state-${a.state}" title="${esc(title)}">${esc(a.person.name)}</span>
<span>${esc(a.person.fullName || a.person.name)}</span>
<span class="plan-upcoming-kind">${esc(a.role || '')}</span>
<span class="plan-task-meta" style="margin-left:auto">${offen} offen</span>
const jePerson = new Map();
assignees.forEach(a => {
if(!jePerson.has(a.person.id)) jePerson.set(a.person.id, { person: a.person, rollen: [] });
jePerson.get(a.person.id).rollen.push({ rolle: a.role, state: a.state });
});
const zeile = e => {
const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(e.person.id) !== -1).length;
const haupt = e.rollen.find(r => r.state === 1) || e.rollen[0];
return `<div class="plan-team-zeile">
<span class="board-avatar ${app.planRoleClass(haupt.rolle)} state-${haupt.state}">${esc(e.person.name)}</span>
<span class="plan-team-text"><b>${esc(e.person.fullName || e.person.name)}</b>
<span class="plan-team-rollen">${e.rollen.map(r => `<span class="plan-rolle${r.state === 2 ? ' ist-neben' : ''}" title="${r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung'}">${esc(r.rolle || '')}</span>`).join('')}</span></span>
<span class="plan-team-offen">${offen} offen</span>
</div>`;
};
// Nach Gewerk gruppiert (Eriks Wahl "A"): erst die Gewerke des
// Projekts in fester Reihenfolge — auch unbesetzte, mit Hinweis —,
// dann Gewerke von Beteiligten, die nicht zum Projekt gehören, zuletzt
// Personen ohne bekanntes Gewerk. Das Gewerk einer Person kommt aus
// ihrem Konto (personGewerk(), users.department).
const reihenfolge = Object.keys(DEPARTMENTS);
const projGewerke = reihenfolge.filter(g => (app.project.gewerke || []).indexOf(g) !== -1);
const gruppen = new Map(projGewerke.map(g => [g, []]));
[...jePerson.values()].forEach(e => {
const g = personGewerk(e.person) || '';
if(!gruppen.has(g)) gruppen.set(g, []);
gruppen.get(g).push(e);
});
const schluessel = [...gruppen.keys()].sort((x, y) => {
const rang = g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g));
return rang(x) - rang(y);
});
teamEl.innerHTML = '<div class="plan-team">' + schluessel.map(g => {
const leute = gruppen.get(g);
const fremd = g !== '' && projGewerke.indexOf(g) === -1;
const titel = g === '' ? 'Ohne Gewerk' : ((DEPARTMENTS[g] && DEPARTMENTS[g].label !== g) ? DEPARTMENTS[g].label : '');
return `<div class="plan-team-gruppe">
<div class="plan-team-gewerk">
${g ? `<span class="plan-gewerk-schild">${esc(g)}</span>` : ''}
<span class="plan-gewerk-titel">${esc(titel)}${fremd ? (titel ? ' · ' : '') + 'kein Gewerk des Projekts' : ''}</span>
<span class="plan-gewerk-linie"></span>
<span class="plan-gewerk-anzahl">${leute.length === 1 ? '1 Person' : leute.length + ' Personen'}</span>
</div>
${leute.length ? leute.map(zeile).join('') : '<div class="plan-team-luecke">Noch niemand aus diesem Gewerk zugeordnet.</div>'}
</div>`;
}).join('') + '</div>';
}
+311 -89
View File
@@ -51,10 +51,285 @@
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) und nach
// jeder Änderung an Honorar oder beauftragten Leistungsphasen aufgerufen.
// Keine Parameter, keine Rückgabe.
// HOAI-Bezeichnungen der Leistungsphasen für die Zeilen im Abschnitt
// "Leistungsphasen" (nur Anzeige).
const LPH_BEZEICHNUNG = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung'
};
// Gewählter Abschnitt der Einstellungen (Allgemein/Auftrag/Phasen/Team).
let einstTeil = 'allgemein';
window.zeigeEinstTeil = function(teil){
einstTeil = teil;
document.querySelectorAll('#planSection-einstellungen .plan-einst-reiter').forEach(b => {
const an = b.dataset.teil === teil;
b.classList.toggle('is-active', an);
b.setAttribute('aria-current', an ? 'true' : 'false');
});
document.querySelectorAll('#planSection-einstellungen .plan-einst-teil').forEach(t => {
t.hidden = t.dataset.teil !== teil;
});
};
// einstTeamPersonen: alle Personen, die im Abschnitt "Beteiligte" vorkommen
// können — jede Person der Sitzung mit mindestens einer Fachrolle
// (peopleByRole()), je mit den Rollen, die sie hat. Dazu, wer dem Projekt
// zugeordnet ist, obwohl sein Konto die Rolle inzwischen nicht mehr trägt —
// sonst wäre so jemand unsichtbar und ließe sich nicht mehr entfernen.
// Rückgabe: Array {person, rollen:Set}.
function einstTeamPersonen(){
const grouped = peopleByRole(app.week);
const map = new Map();
ROLES.forEach(r => (grouped[r] || []).forEach(entry => {
if(!map.has(entry.person.id)) map.set(entry.person.id, { person: entry.person, rollen: new Set() });
map.get(entry.person.id).rollen.add(r);
}));
const p = app.project;
const leute = (app.week && app.week.people) || [];
Object.keys((p && p.assign) || {}).forEach(key => {
if(!(p.assign[key] > 0)) return;
const [id, r] = key.split('|');
if(map.has(id)) { map.get(id).rollen.add(r); return; }
const person = leute.find(x => x.id === id);
if(person) map.set(id, { person, rollen: new Set([r]) });
});
return [...map.values()];
}
const einstName = person => person.fullName || person.name || '';
const einstNachname = person => einstName(person).split(' ').slice(-1)[0];
const einstBeteiligt = (p, id) => ROLES.some(r => (p.assign[assignKey(id, r)] || 0) > 0);
function einstGewerkRang(p){
const reihenfolge = Object.keys(DEPARTMENTS);
const projGewerke = reihenfolge.filter(g => (p.gewerke || []).indexOf(g) !== -1);
return { projGewerke, rang: g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g)) };
}
// Zustand des Abschnitts "Beteiligte": zuletzt hinzugefügte Person (kurz
// hervorgehoben) und die zuletzt entfernte samt ihrer Zuordnung, damit
// "Rückgängig" sie wiederherstellen kann.
let einstNeu = '';
let einstEntfernt = null;
let einstToastTimer = null;
// renderEinstTeam: Besetzungsleiste, Sperrhinweis, Suchfeld und die
// Personenliste nach Gewerk (Eriks Wahl "B"). Keine Rückgabe.
function renderEinstTeam(p, locked){
if(!p.assign) p.assign = {};
const personen = einstTeamPersonen();
const { projGewerke, rang } = einstGewerkRang(p);
const sperre = document.getElementById('planEinstSperre');
if(sperre) sperre.hidden = !locked;
const sucheWrap = document.getElementById('planEinstSucheWrap');
if(sucheWrap) sucheWrap.hidden = locked;
// Besetzungsleiste: je Rolle, wer die Hauptverantwortung trägt.
const besetzung = document.getElementById('planEinstBesetzung');
if(besetzung){
besetzung.innerHTML = ROLES.map(r => {
const haupt = personen.filter(e => p.assign[assignKey(e.person.id, r)] === 1);
const neben = personen.filter(e => p.assign[assignKey(e.person.id, r)] === 2);
const fehlt = haupt.length === 0;
const text = fehlt
? (neben.length ? 'nur Unterstützung, Haupt fehlt' : 'niemand zugeordnet')
: haupt.map(e => einstNachname(e.person)).join(', ') + (neben.length ? ' + ' + neben.length + ' Unterst.' : '');
return `<div class="plan-einst-besetzt${fehlt ? ' ist-luecke' : ''}"><b>${esc(r)}</b><span>${esc(text)}</span></div>`;
}).join('');
}
const el = document.getElementById('planAssignMatrix');
if(!el) return;
const drin = personen.filter(e => einstBeteiligt(p, e.person.id));
if(!drin.length){
el.innerHTML = personen.length
? `<div class="plan-einst-leer"><b>Noch niemand zugeordnet</b><span>Über das Suchfeld Personen hinzufügen. Vorgeschlagen werden zuerst die Mitarbeiter aus den Gewerken des Projekts.</span></div>`
: '<div class="plan-einst-leer"><b>Niemand verfügbar</b><span>In dieser Sitzung ist niemand mit einer Fachrolle hinterlegt (Admin → Benutzerverwaltung).</span></div>';
} else {
const gruppen = new Map();
drin.forEach(e => {
const g = personGewerk(e.person) || '';
if(!gruppen.has(g)) gruppen.set(g, []);
gruppen.get(g).push(e);
});
el.innerHTML = [...gruppen.keys()].sort((x, y) => rang(x) - rang(y)).map(g => {
const liste = gruppen.get(g).sort((a, b) => einstName(a.person).localeCompare(einstName(b.person), 'de'));
const titel = g === '' ? 'Ohne Gewerk' : ((typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[g]) || '')
+ (projGewerke.indexOf(g) === -1 ? ', kein Gewerk des Projekts' : '');
return `<div class="plan-einst-gruppe">
${g ? `<span class="plan-gewerk-schild">${esc(g)}</span>` : ''}
<span class="plan-gewerk-titel">${esc(titel)}</span>
<span class="plan-gewerk-linie"></span>
<span class="plan-gewerk-anzahl">${liste.length === 1 ? '1 Person' : liste.length + ' Personen'}</span>
</div>` + liste.map(e => {
const id = e.person.id;
const name = einstName(e.person);
const kapseln = ROLES.map(r => {
const hat = e.rollen.has(r);
const st = p.assign[assignKey(id, r)] || 0;
const art = st === 1 ? 'ist-haupt' : (st === 2 ? 'ist-neben' : '');
const titel = !hat ? `${name} hat die Rolle ${r} nicht` : `${r}: ${st === 1 ? 'Hauptverantwortung' : st === 2 ? 'Unterstützung' : 'nicht beteiligt'}`;
return `<button type="button" class="plan-einst-kapsel ${art}${hat ? '' : ' ist-fremd'}" aria-pressed="${st > 0}"
${!hat || locked ? 'disabled' : ''} title="${esc(titel)}"
onclick="planCycleAssign('${esc(id)}','${esc(r)}')">${esc(r)}</button>`;
}).join('');
return `<div class="plan-einst-zeile${einstNeu === id ? ' ist-neu' : ''}">
<span class="plan-einst-person"><span class="plan-einst-ava">${esc(e.person.name)}</span><b>${esc(name)}</b></span>
<span class="plan-einst-kapseln" role="group" aria-label="Rollen von ${esc(name)}">${kapseln}</span>
${locked ? '' : `<button type="button" class="plan-einst-weg" title="Aus dem Projekt entfernen" aria-label="${esc(name)} aus dem Projekt entfernen" onclick="einstEntfernen('${esc(id)}')">×</button>`}
</div>`;
}).join('');
}).join('');
}
const toast = document.getElementById('planEinstToast');
if(toast){
toast.hidden = !einstEntfernt || locked;
toast.innerHTML = einstEntfernt
? `<span>${esc(einstEntfernt.name)} aus dem Projekt entfernt</span><button type="button" onclick="einstRueckgaengig()">Rückgängig</button>` : '';
}
}
// einstNachAenderung: speichern und alles nachziehen, was die Zuordnung
// zeigt (dieselben Wege wie planCycleAssign()).
function einstNachAenderung(){
app.savePlan();
renderPlanSettings();
if(typeof renderBoard === 'function') renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
}
// einstEntfernen: nimmt eine Person mit allen Rollen aus dem Projekt und
// merkt sich die Zuordnung für "Rückgängig" (8 Sekunden).
window.einstEntfernen = function(personId){
const p = app.project;
if(!p || app.stammGesperrt()) return;
const eintrag = {};
ROLES.forEach(r => {
const key = assignKey(personId, r);
if(p.assign[key]) { eintrag[key] = p.assign[key]; delete p.assign[key]; }
});
const e = einstTeamPersonen().find(x => x.person.id === personId);
const person = e ? e.person : ((app.week.people || []).find(x => x.id === personId) || { name: personId });
einstEntfernt = { name: einstName(person), eintrag };
einstNeu = '';
clearTimeout(einstToastTimer);
einstToastTimer = setTimeout(() => { einstEntfernt = null; const t = document.getElementById('planEinstToast'); if(t) t.hidden = true; }, 8000);
einstNachAenderung();
};
window.einstRueckgaengig = function(){
const p = app.project;
if(!p || !einstEntfernt || app.stammGesperrt()) return;
Object.assign(p.assign, einstEntfernt.eintrag);
const key = Object.keys(einstEntfernt.eintrag)[0];
einstNeu = key ? key.split('|')[0] : '';
einstEntfernt = null;
clearTimeout(einstToastTimer);
einstNachAenderung();
};
// Suchfeld "Person hinzufügen": Vorschläge aus allen Personen mit
// Fachrolle, die noch nicht beteiligt sind — erst die Gewerke des
// Projekts, dann die übrigen. Gerendert wird nur die Vorschlagsliste,
// damit das Eingabefeld beim Tippen seinen Fokus behält.
function einstVorschlaege(){
const p = app.project;
const feld = document.getElementById('planEinstSuche');
const s = (feld ? feld.value : '').trim().toLowerCase();
const { projGewerke } = einstGewerkRang(p);
const frei = einstTeamPersonen()
.filter(e => !einstBeteiligt(p, e.person.id))
.filter(e => !s || einstName(e.person).toLowerCase().indexOf(s) !== -1 || String(e.person.name || '').toLowerCase().indexOf(s) !== -1)
.sort((a, b) => einstName(a.person).localeCompare(einstName(b.person), 'de'));
const eigen = frei.filter(e => projGewerke.indexOf(personGewerk(e.person)) !== -1);
const andere = frei.filter(e => projGewerke.indexOf(personGewerk(e.person)) === -1);
return [['Aus den Gewerken des Projekts', eigen], ['Andere Gewerke', andere]].filter(([, l]) => l.length);
}
window.einstSucheZeigen = function(){
const box = document.getElementById('planEinstVorschlaege');
if(!box || !app.project || app.stammGesperrt()) return;
const gruppen = einstVorschlaege();
if(!gruppen.length){
const feld = document.getElementById('planEinstSuche');
box.innerHTML = feld && feld.value.trim() ? '<div class="plan-einst-vorschlag-leer">Niemand gefunden.</div>' : '';
box.hidden = !box.innerHTML;
return;
}
box.innerHTML = gruppen.map(([titel, liste]) => `<div class="plan-einst-vorschlag-titel">${esc(titel)}</div>`
+ liste.map(e => `<button type="button" class="plan-einst-vorschlag" onmousedown="event.preventDefault()"
onclick="einstHinzufuegen('${esc(e.person.id)}')">
<span class="plan-einst-ava">${esc(e.person.name)}</span>
<span><b>${esc(einstName(e.person))}</b><span>${esc([personGewerk(e.person), ROLES.filter(r => e.rollen.has(r)).join(', ')].filter(Boolean).join(', '))}</span></span>
</button>`).join('')).join('');
box.hidden = false;
};
window.einstSucheSchliessen = function(){
setTimeout(() => { const box = document.getElementById('planEinstVorschlaege'); if(box) box.hidden = true; }, 120);
};
// Enter übernimmt den ersten Vorschlag, Esc schließt die Liste.
window.einstSucheTaste = function(event){
if(event.key === 'Escape'){ const box = document.getElementById('planEinstVorschlaege'); if(box) box.hidden = true; return; }
if(event.key !== 'Enter') return;
event.preventDefault();
const erste = einstVorschlaege()[0];
if(erste && erste[1][0]) window.einstHinzufuegen(erste[1][0].person.id);
};
// einstHinzufuegen: nimmt eine Person mit ihrer ersten Fachrolle als
// Hauptverantwortung auf; weitere Rollen setzt man über die Kapseln.
window.einstHinzufuegen = function(personId){
const p = app.project;
if(!p || app.stammGesperrt()) return;
const e = einstTeamPersonen().find(x => x.person.id === personId);
if(!e) return;
const rolle = ROLES.find(r => e.rollen.has(r));
if(!rolle) return;
if(!p.assign) p.assign = {};
p.assign[assignKey(personId, rolle)] = 1;
einstNeu = personId;
einstEntfernt = null;
const feld = document.getElementById('planEinstSuche');
if(feld) feld.value = '';
const box = document.getElementById('planEinstVorschlaege');
if(box) box.hidden = true;
einstNachAenderung();
};
// renderEinstUebersicht: die Kurzfassung unter jedem Abschnitt der
// Unternavigation und welcher Abschnitt sichtbar ist.
function renderEinstUebersicht(){
const p = app.project;
if(!p) return;
const sandbox = app.isSandboxPlan();
const setze = (id, text) => { const el = document.getElementById(id); if(el) el.textContent = text; };
const gewerke = Array.isArray(p.gewerke) ? p.gewerke : [];
const amtierend = Array.isArray(p.aktuelleLP) && p.aktuelleLP[0] ? p.aktuelleLP[0] : '';
setze('einstSumAllgemein', sandbox ? [p.name || '', amtierend].filter(Boolean).join(' · ')
: [p.nr || 'ohne Nummer', gewerke.join(', ') || 'kein Gewerk', amtierend].filter(Boolean).join(' · '));
const honorar = Number(p.honorar) || 0;
const honorarText = honorar > 0 ? honorar.toLocaleString('de-DE') + ' €' : 'kein Honorar';
const status = (typeof AUFTRAGSSTATUS !== 'undefined' && typeof auftragsstatusOf === 'function')
? (AUFTRAGSSTATUS.find(s => s.key === auftragsstatusOf(p)) || {}).label : '';
setze('einstSumAuftrag', sandbox ? honorarText : [status, honorarText].filter(Boolean).join(' · '));
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP.length : 0;
const laeuft = Array.isArray(p.aktuelleLP) && p.aktuelleLP[0] ? ' · läuft: ' + p.aktuelleLP[0] : '';
setze('einstSumPhasen', `${beauftragt} von ${LPHS.length} beauftragt${laeuft}`);
const ids = new Set(Object.keys(p.assign || {}).filter(k => p.assign[k] > 0).map(k => k.split('|')[0]));
const unbesetzt = gewerke.filter(g => !einstTeamPersonen().some(e => ids.has(e.person.id) && personGewerk(e.person) === g));
setze('einstSumTeam', (ids.size === 1 ? '1 Person' : ids.size + ' Personen') + (unbesetzt.length ? ' · ' + unbesetzt.join(', ') + ' unbesetzt' : ''));
window.zeigeEinstTeil(einstTeil);
}
window.renderPlanSettings = function(){
const p = app.project;
if(!p) return;
const locked = app.isLocked();
const locked = app.stammGesperrt();
const sandbox = app.isSandboxPlan();
const lockHint = document.getElementById('planSettingsLockHint');
@@ -62,16 +337,20 @@
// Felder, die nur bei einem echten Projekt Bedeutung haben (Auftrags-
// Workflow, Dateien-Zuordnung über die Nummer, Gewerke-Filter des
// Wochenberichts, Auslastungs-Ersatzgröße) — für die Planung-Sandbox
// Wochenberichts) — für die Planung-Sandbox
// ausgeblendet, siehe CLAUDE.md "Planung — projektunabhängige
// Terminplan-Sandbox". Honorar + beauftragte Leistungsphasen (Grundlage
// des Terminplan-Rechners) und die Beteiligten-Matrix bleiben sichtbar.
['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke', 'settingsGroesse']
['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke']
.forEach(id => {
const el = document.getElementById(id);
const row = el && el.closest('.form-row');
if(row) row.style.display = sandbox ? 'none' : '';
});
['settingsProjNrHinweis'].forEach(id => {
const el = document.getElementById(id);
if(el) el.hidden = sandbox;
});
// Projektnummer und -name: das fokussierte Feld nicht überschreiben
// (dieselbe Vorsichtsmaßnahme wie beim Honorar weiter unten).
@@ -86,25 +365,6 @@
nameEl.disabled = locked;
}
// Projektgröße: Sie ist der Ersatz für ein fehlendes Honorar, deshalb
// steht unter dem Regler, ob sie gerade überhaupt zählt. Eine Zahl, die
// wirkungslos ist, ohne dass man es sieht, wäre schlimmer als keine.
const groesseEl = document.getElementById('settingsGroesse');
if(groesseEl){
const g = Number(p.groesse) || 0;
if(document.activeElement !== groesseEl) groesseEl.value = g > 0 ? g : '';
groesseEl.disabled = locked;
const hinweisEl = document.getElementById('settingsGroesseHinweis');
if(hinweisEl){
const honorar = Number(p.honorar) || 0;
hinweisEl.textContent = honorar > 0
? 'Ein Honorar ist hinterlegt — die Auslastung rechnet damit, die Größe bleibt unberücksichtigt.'
: (g > 0
? 'Kein Honorar hinterlegt: Die Auslastung rechnet mit dieser Größe. 0 = keine Angabe, 1 = großes Projekt.'
: 'Ohne Honorar und ohne Größe fließt dieses Projekt in keine Auslastung ein. 0 = keine Angabe, 1 = großes Projekt.');
}
}
// Schlagwörter: dieselbe Chip-Liste wie im früheren Projekte-Tab.
const tagsEl = document.getElementById('settingsProjTags');
if(tagsEl){
@@ -124,11 +384,12 @@
const gewerkeEl = document.getElementById('settingsGewerke');
if(gewerkeEl && typeof GEWERK_LABELS === 'object'){
const gesetzt = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => `<label class="beauftragte-lp-item" title="${esc(GEWERK_LABELS[g])}">
<input type="checkbox" value="${esc(g)}" ${gesetzt.has(g) ? 'checked' : ''} ${locked ? 'disabled' : ''}
onchange="togglePlanGewerk('${esc(g)}', this.checked)">
${esc(g)}
</label>`).join('');
// Als Umschalt-Knöpfe (Einstellungen "C"): angetippt = gesetzt.
gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => {
const an = gesetzt.has(g);
return `<button type="button" class="plan-einst-chip${an ? ' an' : ''}" aria-pressed="${an}" title="${esc(GEWERK_LABELS[g])}"
${locked ? 'disabled' : ''} onclick="togglePlanGewerk('${esc(g)}', ${!an})">${esc(g)}</button>`;
}).join('');
}
// Auftragsstatus: Das Auswahlfeld kommt fertig aus js/auftrag.js, damit
@@ -139,8 +400,6 @@
statusEl.innerHTML = auftragsstatusSelectHtml(p, { disabled: locked });
}
const aufwandBtn = document.getElementById('settingsAufwandBtn');
if(aufwandBtn) aufwandBtn.disabled = locked || typeof openLpModal !== 'function';
// Honorar: das fokussierte Feld nicht überschreiben, sonst springt beim
// Tippen der Cursor (gleiche Vorsichtsmaßnahme wie im Honorar-Dialog).
@@ -152,36 +411,32 @@
honorarEl.disabled = locked;
}
// Beauftragte Leistungsphasen: dieselbe Kreuzchen-Liste wie im
// Honorar-Dialog, gespeichert über toggleBeauftragteLp() (plan-honorar.js).
// Beauftragte Leistungsphasen: je Phase eine Zeile mit Umschalter
// "Beauftragt | Nicht beauftragt", die laufende Phase trägt "läuft".
// Gespeichert über toggleBeauftragteLp() (plan-honorar.js).
const lpEl = document.getElementById('settingsBeauftragteLp');
if(lpEl){
const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
lpEl.innerHTML = LPHS.map(l => `<label class="beauftragte-lp-item">
<input type="checkbox" value="${esc(l)}" ${gesetzt.has(l) ? 'checked' : ''} ${locked ? 'disabled' : ''}
onchange="toggleBeauftragteLp('${esc(l)}', this.checked)">
${esc(l)}
</label>`).join('');
const laeuft = Array.isArray(p.aktuelleLP) ? p.aktuelleLP[0] : '';
lpEl.innerHTML = LPHS.map(l => {
const an = gesetzt.has(l);
return `<div class="plan-einst-phase${an ? ' ist-an' : ''}">
<span class="plan-einst-phase-name"><b>${esc(l)}</b><span>${esc(LPH_BEZEICHNUNG[l] || '')}</span>${l === laeuft ? '<em>läuft</em>' : ''}</span>
<span class="plan-einst-seg" role="group" aria-label="${esc(l)}">
<button type="button" class="${an ? 'an' : ''}" ${locked ? 'disabled' : ''} onclick="toggleBeauftragteLp('${esc(l)}', true)">Beauftragt</button>
<button type="button" class="${an ? '' : 'an'}" ${locked ? 'disabled' : ''} onclick="toggleBeauftragteLp('${esc(l)}', false)">Nicht beauftragt</button>
</span>
</div>`;
}).join('');
}
// Beteiligte — Stammdatum, hier editierbar (siehe Kopfkommentar). Nicht
// zugeordnete Personen erscheinen blass, damit erkennbar bleibt, wer zur
// Auswahl stünde; ein Klick schaltet den Zustand durch (planCycleAssign()).
const assignMatrix = document.getElementById('planAssignMatrix');
if(assignMatrix){
const grouped = peopleByRole(app.week);
if(!p.assign) p.assign = {};
assignMatrix.innerHTML = ROLES.map(r => {
const entries = grouped[r] || [];
const chips = entries.length ? entries.map(entry => {
const state = p.assign[assignKey(entry.person.id, r)] || 0;
const click = locked ? '' : `onclick="planCycleAssign('${entry.person.id}','${r}')"`;
const title = (entry.person.fullName || entry.person.name) + ' · ' + r;
return `<span class="board-avatar ${app.planRoleClass(r)} state-${state}${locked ? ' locked-cell' : ''}" ${click} title="${esc(title)}">${esc(entry.person.name)}</span>`;
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`;
return `<div class="board-role-row"><span class="board-role-label ${app.planRoleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
}).join('');
}
if(assignMatrix) renderEinstTeam(p, locked);
renderEinstUebersicht();
};
// planCycleAssign: wechselt die Zuordnung einer Person zu DIESEM Projekt
@@ -191,7 +446,7 @@
// Rückgabe.
window.planCycleAssign = function(personId, role){
const p = app.project;
if(!p || app.isLocked()) return;
if(!p || app.stammGesperrt()) return;
if(!p.assign) p.assign = {};
const key = assignKey(personId, role);
const cur = p.assign[key] || 0;
@@ -215,7 +470,7 @@
// Dateien nicht mehr; deshalb die Rückfrage. Parameter: val. Keine Rückgabe.
window.setPlanProjNr = function(val){
const p = app.project;
if(!p || app.isLocked()) return;
if(!p || app.stammGesperrt()) return;
const neuVal = (val || '').trim();
if(neuVal === (p.nr || '')) return;
if(p.nr && !confirm('Die Projektnummer "' + p.nr + '" wird zu "' + neuVal + '".\n\n'
@@ -231,7 +486,7 @@
// setPlanProjName: ändert die Projektbezeichnung. Parameter: val.
window.setPlanProjName = function(val){
const p = app.project;
if(!p || app.isLocked()) return;
if(!p || app.stammGesperrt()) return;
p.name = (val || '').trim();
app.savePlan();
// Die Bezeichnung steht an drei weiteren Stellen: in der Kopfzeile des
@@ -249,28 +504,12 @@
renderPlanSettings();
};
// setPlanGroesse: übernimmt die Projektgröße (0–1). Gespeichert wird erst
// beim Loslassen des Reglers, nicht bei jedem Zwischenwert — sonst liefe
// ein Zug über die Skala als zwanzig Speichervorgänge auf den Server.
// Parameter: val. Keine Rückgabe.
window.setPlanGroesse = function(val){
const p = app.project;
if(!p || app.isLocked()) return;
const n = Math.max(0, Math.min(1, parseFloat(val) || 0));
if(n === (Number(p.groesse) || 0)) return;
p.groesse = n;
app.savePlan();
renderPlanSettings();
// Die Auslastung rechnet mit diesem Wert.
if(typeof renderLoad === 'function' && document.getElementById('loadGrid')) renderLoad();
};
// planTagKeydown: übernimmt ein Schlagwort mit Enter. Parameter: event.
window.planTagKeydown = function(event){
if(event.key !== 'Enter') return;
event.preventDefault();
const p = app.project;
if(!p || app.isLocked()) return;
if(!p || app.stammGesperrt()) return;
const tag = (event.target.value || '').trim();
if(!tag) return;
if(!Array.isArray(p.tags)) p.tags = [];
@@ -283,7 +522,7 @@
// planRemoveTag: entfernt ein Schlagwort. Parameter: tag.
window.planRemoveTag = function(tag){
const p = app.project;
if(!p || app.isLocked() || !Array.isArray(p.tags)) return;
if(!p || app.stammGesperrt() || !Array.isArray(p.tags)) return;
p.tags = p.tags.filter(t => t !== tag);
app.savePlan();
renderPlanSettings();
@@ -296,7 +535,7 @@
// GEWERK_LABELS (js/projekt-wizard.js), damit sie nicht bei jeder Änderung
// durcheinandergerät. Parameter: gewerk, checked (Boolean).
window.togglePlanGewerk = function(gewerk, checked){
if(app.isLocked()) return;
if(app.stammGesperrt()) return;
const p = app.project;
const set = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
if(checked) set.add(gewerk); else set.delete(gewerk);
@@ -304,21 +543,4 @@
app.savePlan();
renderPlanSettings();
};
// planOpenAufwand: öffnet die Aufwands-Gewichtungsmatrix. Der Dialog selbst
// liegt in js/load/load.js (openLpModal) und wird hier nur aufgerufen —
// er gehörte immer zum Projekt, stand aber bisher nur im Projekte-Tab.
window.planOpenAufwand = function(){
const p = app.project;
if(!p || typeof openLpModal !== 'function') return;
// openLpModal() sucht das Projekt in currentWeek(). Zeigt der
// Projektbereich eine ANDERE Sitzung, findet es dort nichts und täte
// stillschweigend nichts — deshalb hier der ausdrückliche Hinweis.
const w = typeof currentWeek === 'function' ? currentWeek() : null;
if(!w || !Array.isArray(w.projects) || !w.projects.some(x => x.id === p.id)){
app.showToast('Der Aufwand lässt sich nur in der laufenden Sitzung bearbeiten.');
return;
}
openLpModal(p.id);
};
})();
+26 -13
View File
@@ -36,7 +36,6 @@ window.PlanApp = {
week: null, // planWeek
project: null, // planProject
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
criticalPathActive: false, // Hervorhebung des kritischen Pfads im GANTT aktiv
// Drag-Zustände
todoDrag: null, // Drag-and-Drop von TODO/Meilenstein-Zeilen
ganttDrag: null, // Balken/Meilenstein/Urlaub verschieben
@@ -122,6 +121,9 @@ window.PlanApp = {
app.projNr = '';
app.projName = app.project.name || '';
renderPlanPage();
// showTab() hat die Adresse schon vor diesem Aufruf geschrieben, als
// der Name noch fehlte (/Projektplan/?…) — jetzt mit Namen nachziehen.
if(typeof updateRouteUrl === 'function') updateRouteUrl('plan');
return true;
}
@@ -177,10 +179,21 @@ window.PlanApp = {
return app.project.plan;
}
// isLocked: liefert true, wenn die aktuelle Woche gesperrt ist (dann sind
// Bearbeitungen im Plan gesperrt). Rückgabe: Boolescher Wert.
// isLocked: liefert true, wenn der TERMINPLAN nicht bearbeitet werden darf
// — gesperrte Sitzung oder fehlendes Recht „Terminplan bearbeiten“ (bei
// einem Planung-Plan: „Planung“; Rollen & Rechte). Rückgabe: Boolean.
function isLocked(){
return !!app.week.locked;
return !!app.week.locked || !hatRecht(isSandboxPlan() ? 'wz.planung' : 'proj.plan');
}
// stammGesperrt: dasselbe für Projekteinstellungen, Gruppe, amtierende
// Leistungsphase und Honorar (Recht „Stammdaten und Beteiligte“).
function stammGesperrt(){
return !!app.week.locked || !hatRecht(isSandboxPlan() ? 'wz.planung' : 'proj.stamm');
}
// protokolleGesperrt: Protokolle (Recht „Protokolle bearbeiten“), in einer
// gesperrten Sitzung wie bisher gesperrt.
function protokolleGesperrt(){
return !!app.week.locked || !hatRecht('proj.protokolle');
}
// isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur
@@ -238,8 +251,6 @@ window.PlanApp = {
// Bereichs), lädt bei vorhandener Projektnummer die Protokollliste.
if(typeof initPlanProtokoll === 'function') initPlanProtokoll();
// Verlauf über alle Sitzungen (Kommentar-Historie) — siehe js/plan/plan-history.js.
renderPlanHistory();
// Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen,
// Projektangaben) — siehe js/plan/plan-settings.js.
@@ -247,10 +258,12 @@ window.PlanApp = {
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html.
// Terminplan (…Btn), Aufgaben (…BtnList) und Leistungsphasen (…BtnPhasen),
// siehe App.html.
const locked = isLocked();
['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn', 'autoScheduleBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList']
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList',
'honorarBtnPhasen', 'addPhaseBtnPhasen']
.forEach(id => {
const btn = document.getElementById(id);
if(btn) btn.style.display = locked ? 'none' : '';
@@ -263,10 +276,8 @@ window.PlanApp = {
document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]')
.forEach(btn => { btn.style.display = sandbox ? 'none' : ''; });
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gibt es nur für
// Sandbox-Pläne der Planung — ein echtes Projekt hat kein Äquivalent.
const sandboxPdfBtn = document.getElementById('sandboxPdfBtn');
if(sandboxPdfBtn) sandboxPdfBtn.style.display = sandbox ? '' : 'none';
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gilt für
// Planung-Pläne UND echte Projekte, der Knopf ist immer sichtbar.
// GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit)
fitPlanGanttRange();
@@ -394,6 +405,8 @@ window.PlanApp = {
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
app.ensurePlanLocal = ensurePlanLocal;
app.isLocked = isLocked;
app.stammGesperrt = stammGesperrt;
app.protokolleGesperrt = protokolleGesperrt;
app.isSandboxPlan = isSandboxPlan;
app.removeDependencyRefs = removeDependencyRefs;
app.renderProjectAssignees = renderProjectAssignees;
+36 -2
View File
@@ -75,6 +75,18 @@
return e < today;
}
// Phasenfilter der Aufgabenliste: '' = alle, 'LPH 5', oder '-' = ohne
// Phase. Gilt nur für das Projekt, für das er gesetzt wurde.
let filter = { projekt: null, phase: '' };
const filterPhase = () => filter.projekt === app.project ? filter.phase : '';
// setzeAufgabenFilter: von außen (Bereich Leistungsphasen) oder über die
// Filterknöpfe. Parameter: phase ('' | 'LPH x' | '-').
window.setzeAufgabenFilter = function(phase){
filter = { projekt: app.project, phase: phase || '' };
window.renderPlanTasks();
};
// renderPlanTasks: baut die Aufgaben- und die Meilensteinliste des Bereichs
// "Aufgaben" neu auf und schreibt die Zähler in die Kartenüberschriften.
// Keine Parameter, keine Rückgabe.
@@ -85,7 +97,25 @@
// ---- Aufgaben ----
const todoEl = document.getElementById('planTodoList');
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const alleTodos = Array.isArray(plan.todos) ? plan.todos : [];
// Phasenbalken stehen im eigenen Bereich "Leistungsphasen"
// (plan-phasen.js), nicht in der Aufgabenliste. idx bleibt der Index in
// plan.todos, den die Dialoge erwarten.
const eintraege = alleTodos.map((t, idx) => ({ t, idx, phase: aufgabenPhase(t, alleTodos) }))
.filter(e => !balkenPhase(e.t));
const todos = eintraege.map(e => e.t);
const fp = filterPhase();
const sichtbar = eintraege.filter(e => !fp || (fp === '-' ? !e.phase : e.phase === fp));
const filterEl = document.getElementById('planTodoFilter');
if(filterEl){
const phasen = LPHS.filter(l => eintraege.some(e => e.phase === l));
const knopf = (wert, text) => `<button type="button" class="plan-task-filter-btn${fp === wert ? ' is-active' : ''}" onclick="setzeAufgabenFilter('${esc(wert)}')">${esc(text)}</button>`;
filterEl.innerHTML = phasen.length
? '<span>Phase:</span>' + knopf('', 'Alle') + phasen.map(l => knopf(l, l)).join('')
+ (eintraege.some(e => !e.phase) ? knopf('-', 'ohne Phase') : '')
: '';
}
if(todoEl){
// In gesperrten Sitzungen sind die Anlege-Schaltflächen ausgeblendet
// (renderPlanPage(), plan-state.js) — dann darf der Leertext auch nicht
@@ -95,7 +125,9 @@
: 'Noch keine Aufgaben. Über „+ Aufgabe“, „+ Vorlage“ oder „+ Terminplan“ anlegen.';
todoEl.innerHTML = todos.length === 0
? '<div class="plan-task-empty">' + leerTextTodos + '</div>'
: todos.map((t, idx) => {
: sichtbar.length === 0
? '<div class="plan-task-empty">Keine Aufgaben in dieser Auswahl.</div>'
: sichtbar.map(({ t, idx, phase }) => {
const status = todoStatus(t);
const range = dateRangeText(t);
const overdue = isOverdue(t);
@@ -109,6 +141,8 @@
${check}
<span class="plan-task-dot ${status}"></span>
<span class="plan-task-title">${esc(t.title || 'Aufgabe')}</span>
${t.notiz ? `<span class="plan-task-notiz" title="${esc(String(t.notiz).slice(0, 300))}">${icon('fileText')}</span>` : ''}
${phase ? `<span class="plan-task-phase" title="Liegt im Zeitraum dieser Leistungsphase">${esc(phase)}</span>` : ''}
${assigneeAvatars(t.assignees)}
${progress}
<span class="plan-task-meta${overdue ? ' overdue' : ''}" title="${range ? '' : 'Ohne Termin — erscheint nicht im Terminplan'}">${range ? esc(range) : '<span class="plan-task-no-schedule">ohne Termin</span>'}</span>
+127 -20
View File
@@ -4,7 +4,8 @@
// In einem Ingenieurbüro wiederholt sich der Ablauf einer Leistungsphase über
// alle Projekte hinweg. Dieses Modul macht daraus wiederverwendbare Vorlagen:
// Der Button "+ Vorlage" in der GANTT-Toolbar öffnet #lphTemplateModal, dort
// wählt man eine Leistungsphase und ein Startdatum, hakt die gewünschten
// wählt man Gewerk, Leistungsphase und Startdatum (Vorlagen gibt es je
// Gewerk), hakt die gewünschten
// Schritte an und fügt sie mit einem Klick als TODOs in den Projektplan ein.
//
// Der Honorar-Terminplan (js/plan/plan-honorar.js) ist ein eigener Dialog mit
@@ -16,7 +17,7 @@
// lph_templates): GET für alle angemeldeten Nutzer, POST/DELETE nur für
// Admins — das Einfügen darf jeder, das Pflegen der firmenweiten Vorlagen
// nicht. Die Pflege-Maske selbst steht seit dem Umbau NICHT mehr in diesem
// Dialog, sondern im Bereich „Einstellungen" (js/settings/settings.js) —
// Dialog, sondern im Admin-Bereich „Aufgaben-Vorlagen" (js/admin/admin.js) —
// hier bleibt für Administratoren nur ein Verweis dorthin.
//
// WICHTIG: Eingefügte Aufgaben sind danach ganz normale plan_todos ohne
@@ -46,13 +47,28 @@
if(app.isLocked()) return;
const lphSelect = document.getElementById('templateLphSelect');
if(lphSelect && !lphSelect.options.length){
// Bei JEDEM Öffnen neu vorbelegen: Der Projektbereich zeigt nacheinander
// verschiedene Projekte im selben Dokument — sonst blieb die amtierende
// Leistungsphase des zuerst geöffneten Projekts vorgewählt.
if(lphSelect){
// Vorbelegung: die amtierende Leistungsphase des Projekts, sonst LPH 1
const aktLp = Array.isArray(app.project.aktuelleLP) ? app.project.aktuelleLP[0] : '';
lphSelect.innerHTML = TEMPLATE_LPHS.map(l =>
`<option value="${esc(l)}" ${l === aktLp ? 'selected' : ''}>${esc(l)}</option>`
).join('');
}
// Gewerk vorbelegen: das eigene, wenn das Projekt es hat, sonst das
// erste Gewerk des Projekts, sonst das eigene (Vorlagen gibt es je
// Gewerk, Eriks Wunsch).
const gewSelect = document.getElementById('templateGewerkSelect');
if(gewSelect){
const projGew = Array.isArray(app.project.gewerke) ? app.project.gewerke : [];
const eigen = currentUser && Object.keys(DEPARTMENTS).includes(currentUser.department) ? currentUser.department : '';
const vorwahl = (eigen && projGew.includes(eigen)) ? eigen : (projGew[0] || eigen || Object.keys(DEPARTMENTS)[0]);
gewSelect.innerHTML = Object.keys(DEPARTMENTS).map(d =>
`<option value="${esc(d)}" ${d === vorwahl ? 'selected' : ''}>${esc(d)}${projGew.includes(d) ? '' : ' (nicht im Projekt)'}</option>`
).join('');
}
// Startdatum vorbelegen: heute
const startInput = document.getElementById('templateStart');
const startDisplay = document.getElementById('templateStartDisplay');
@@ -62,6 +78,7 @@
if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today));
}
templatePhaseVorbelegen();
document.getElementById('lphTemplateModal').style.display = 'flex';
loadTemplates();
};
@@ -88,12 +105,88 @@
});
}
// templatePhaseVorbelegen: Hat das Projekt einen Phasenbalken der gewählten
// Leistungsphase, übernehmen Startdatum und „Dauer der Phase“ seinen
// Zeitraum (in Arbeitstagen). Sonst bleibt das Startdatum, die Dauer ist
// von Hand anzugeben. Keine Parameter.
window.templatePhaseVorbelegen = function(){
const lph = (document.getElementById('templateLphSelect') || {}).value || '';
const todos = (app.ensurePlanLocal().todos || []);
const balken = todos.find(t => typeof balkenPhase === 'function' && balkenPhase(t) === lph && t.start && t.end);
const tageEl = document.getElementById('templatePhasenTage');
const hinweis = document.getElementById('templatePhasenHinweis');
if(balken){
const s = parseDateISO(balken.start), e = parseDateISO(balken.end);
const startInput = document.getElementById('templateStart');
const startDisplay = document.getElementById('templateStartDisplay');
if(startInput) startInput.value = balken.start;
if(startDisplay) startDisplay.value = formatDateDE(s);
if(tageEl) tageEl.value = Math.max(1, workdaysBetween(s, e));
if(hinweis) hinweis.textContent = 'aus dem Phasenbalken ' + lph + ' (' + formatDateDE(s) + ' – ' + formatDateDE(e) + ')';
} else {
if(tageEl) tageEl.value = '';
if(hinweis) hinweis.textContent = 'Kein Phasenbalken für ' + lph + ' — Dauer bitte angeben.';
}
};
// templatePlan: Termine der Schritte nach ihrem Anteil (Eriks Wunsch, z. B.
// Siemon-Tabelle): Jeder Schritt mit Anteil bekommt Anteil ÷ Summe aller
// Anteile der Phase an deren Dauer, nacheinander ab dem Startdatum in der
// gepflegten Reihenfolge (Arbeitstage, ohne Wochenenden/Feiertage). Die
// Grenzen werden über die laufende Summe gerundet, damit sich keine
// Rundungsfehler aufaddieren. Schritte ohne Anteil: Start/Dauer der
// Vorlage, ohne Dauer ohne Termin. Parameter: entries (alle Schritte der
// Phase), startDate, phasenTage. Rückgabe: Map id → {s, e} oder null.
function templatePlan(entries, startDate, phasenTage){
const out = new Map();
const summe = entries.reduce((a, t) => a + (Number(t.anteil) || 0), 0);
let kum = 0;
entries.forEach(t => {
const ant = Number(t.anteil) || 0;
if(ant > 0 && summe > 0){
if(!startDate || !(phasenTage > 0)){ out.set(t.id, null); return; }
const von = Math.round(phasenTage * kum / summe);
const bis = Math.round(phasenTage * (kum + ant) / summe);
kum += ant;
const s = addWorkdays(startDate, von);
const e = addWorkdays(s, Math.max(1, bis - von) - 1);
out.set(t.id, { s, e, anteil: ant / summe });
} else if(t.enthaltenIn){
out.set(t.id, null); // zweiter Durchgang unten
} else if(t.durationDays > 0 && startDate){
const s = addDays(startDate, t.offsetDays);
out.set(t.id, { s, e: addDays(s, t.durationDays - 1) });
} else {
out.set(t.id, null);
}
});
// „in a) enthalten“ (Siemon): zeitgleich mit den genannten Schritten —
// vom frühesten Beginn bis zum spätesten Ende. Der Buchstabe eines
// Schritts steht am Ende seines Titels („… (a)“).
const buchstabe = t => (String(t.title).match(/\(([a-z])\)\s*$/i) || [])[1];
entries.filter(t => t.enthaltenIn).forEach(t => {
const wirte = t.enthaltenIn.split(',')
.map(b => entries.find(x => !x.enthaltenIn && (buchstabe(x) || '').toLowerCase() === b))
.map(x => x && out.get(x.id)).filter(Boolean);
if(!wirte.length) return;
out.set(t.id, {
s: new Date(Math.min(...wirte.map(w => w.s.getTime()))),
e: new Date(Math.max(...wirte.map(w => w.e.getTime()))),
enthaltenIn: t.enthaltenIn
});
});
return out;
}
// templatesForSelectedLph: die Vorlagen-Schritte der gerade gewählten
// Leistungsphase, in ihrer gepflegten Reihenfolge. Rückgabe: Array.
function templatesForSelectedLph(){
const lphSelect = document.getElementById('templateLphSelect');
const gewSelect = document.getElementById('templateGewerkSelect');
const lph = lphSelect ? lphSelect.value : '';
return templateCache.filter(t => t.lph === lph);
const g = gewSelect ? gewSelect.value : '';
// Vorlagen ohne Gewerk (Altbestand) gelten für alle Gewerke
return templateCache.filter(t => t.lph === lph && (!t.gewerk || t.gewerk === g));
}
// renderTemplateList: baut die Auswahlliste der Schritte (Checkbox je
@@ -107,18 +200,23 @@
const entries = templatesForSelectedLph();
const startIso = (document.getElementById('templateStart') || {}).value || '';
const startDate = parseDateISO(startIso);
const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0;
const plan = templatePlan(entries, startDate, phasenTage);
if(entries.length === 0){
listEl.innerHTML = '<span class="hint">Für diese Leistungsphase ist noch keine Vorlage hinterlegt.</span>';
listEl.innerHTML = '<span class="hint">Für dieses Gewerk und diese Leistungsphase ist noch keine Vorlage hinterlegt.</span>';
} else {
listEl.innerHTML = entries.map(t => {
// Vorschau der konkreten Termine (nur wenn ein Startdatum gesetzt ist)
let dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`;
if(startDate){
const s = addDays(startDate, t.offsetDays);
const e = addDays(s, Math.max(0, t.durationDays - 1));
dateHint = formatDateDE(s) + '–' + formatDateDE(e);
}
const termin = plan.get(t.id);
let dateHint;
if(termin) dateHint = formatDateDE(termin.s) + '–' + formatDateDE(termin.e)
+ (termin.anteil ? ' · ' + String(t.anteil).replace('.', ',') + ' %' : '')
+ (termin.enthaltenIn ? ' · in ' + termin.enthaltenIn.split(',').map(x => x + ')').join(' + ') : '');
else if(t.enthaltenIn) dateHint = 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + ') + ' — Dauer der Phase fehlt';
else if(Number(t.anteil) > 0) dateHint = String(t.anteil).replace('.', ',') + ' % — Dauer der Phase fehlt';
else if(t.durationDays > 0) dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`;
else dateHint = 'ohne Termin';
return `<label class="todo-depends-item">
<input type="checkbox" value="${esc(t.id)}" checked>
<span style="flex:1">${esc(t.title)}</span>
@@ -131,13 +229,13 @@
};
// renderTemplateHint: blendet für Administratoren den Verweis auf den
// Bereich „Einstellungen" ein. Das Anlegen und Löschen von Schritten stand
// bis zum Umbau hier im Dialog; es liegt jetzt in js/settings/settings.js.
// Admin-Bereich „Aufgaben-Vorlagen" ein. Das Anlegen und Löschen von Schritten stand
// bis zum Umbau hier im Dialog; es liegt jetzt in js/admin/admin.js.
// Dieser Dialog FÜGT Vorlagen nur noch ein. Keine Parameter, keine Rückgabe.
function renderTemplateHint(){
const el = document.getElementById('templateAdminHint');
if(!el) return;
el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
el.style.display = hatRecht('a.vorlagen') ? '' : 'none';
}
// applyTemplate: erzeugt aus den angehakten Vorlagen-Schritten neue TODOs im
@@ -149,7 +247,6 @@
if(app.isLocked()) return;
const startIso = (document.getElementById('templateStart') || {}).value || '';
const startDate = parseDateISO(startIso);
if(!startDate){ alert('Bitte ein Startdatum wählen.'); return; }
const listEl = document.getElementById('templateTaskList');
const checkedIds = listEl
@@ -157,16 +254,26 @@
: [];
if(checkedIds.length === 0){ alert('Bitte mindestens einen Schritt auswählen.'); return; }
const entries = templatesForSelectedLph().filter(t => checkedIds.includes(t.id));
const alle = templatesForSelectedLph();
const entries = alle.filter(t => checkedIds.includes(t.id));
// Ein Startdatum braucht es nur für Schritte mit Anteil oder Dauer —
// ohne beides „ohne Termin“ (Aufgabe nur in der Liste, nicht im Gantt).
if(!startDate && entries.some(t => t.durationDays > 0 || Number(t.anteil) > 0 || t.enthaltenIn)){ alert('Bitte ein Startdatum wählen.'); return; }
const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0;
if(!phasenTage && entries.some(t => Number(t.anteil) > 0 || t.enthaltenIn)){ alert('Bitte die Dauer der Phase in Arbeitstagen angeben — die Schritte bekommen ihren Anteil davon.'); return; }
// Anteile beziehen sich auf ALLE Schritte der Phase: Abgewählte lassen
// ihre Lücke, die übrigen behalten ihren Platz im Ablauf.
const termine = templatePlan(alle, startDate, phasenTage);
const plan = app.ensurePlanLocal();
entries.forEach(t => {
const s = addDays(startDate, t.offsetDays);
const e = addDays(s, Math.max(0, t.durationDays - 1));
const termin = termine.get(t.id);
const s = termin ? termin.s : null;
const e = termin ? termin.e : null;
plan.todos.push({
id: uid(),
title: t.title,
start: formatDateISO(s),
end: formatDateISO(e),
start: s ? formatDateISO(s) : '',
end: e ? formatDateISO(e) : '',
done: false,
progress: 0,
dependsOn: [],