alle new
This commit is contained in:
erik committed 2026-10-01 06:46:51 +02:00
1 parent da64472251
commit 5b20adee48
1605 files changed
+396517 -1674

No files matched your search

+146 -8
View File
@@ -9,12 +9,74 @@
// (renderGanttDependencies), die Drag-/Resize- und Klick-Attribute der
// Balken nutzen plan-drag.js und plan-modals.js.
//
// Zwei Zusatzanzeigen werden hier ebenfalls erzeugt:
//
// 1. GEISTER-BALKEN (Option ghostTodos/ghostMilestones): hinter jedem Balken
// bzw. jeder Meilenstein-Raute der Termin aus der VORHERIGEN Sitzung als
// gestrichelter Umriss, sobald er sich unterscheidet. Klassische
// Projektwerkzeuge brauchen dafür eine eigens eingefrorene "Baseline" —
// hier existiert sie kostenlos, weil jede Wochenkopie (cloneWeek() in
// js/weeks.js) bereits ein vollständiger Schnappschuss des damaligen Plans
// ist. Zusammengeführt wird über todo.id bzw. milestone.id: die bleiben
// beim Klonen unverändert (cloneWeek() vergibt nur project.id neu), sind
// also über Sitzungen hinweg stabil. Die Vorwochen-Daten liefert
// renderPlanGantt() (plan-gantt.js) über app.previousProjectVersion()
// aus plan-history.js.
//
// 2. URLAUBS-KOLLISION: überschneidet sich eine Aufgabe zeitlich mit einer
// Abwesenheit einer ihr zugeordneten Person, wird der Balken markiert und
// die Zeile bekommt ein Warnsymbol. Die Urlaubsbalken selbst zeichnet
// gantt.js schon länger, aber rein dekorativ — die Kollision musste bisher
// mit dem Auge gefunden werden.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// rangesOverlap: prüft, ob sich zwei Datumsbereiche (jeweils einschließlich
// Start und Ende) überschneiden. Parameter: vier Date-Objekte.
// Rückgabe: Boolean (false, sobald ein Datum fehlt).
function rangesOverlap(aStart, aEnd, bStart, bEnd){
if(!aStart || !aEnd || !bStart || !bEnd) return false;
return aStart <= bEnd && bStart <= aEnd;
}
// absenceConflictsForTodo: sucht alle Abwesenheiten der einer Aufgabe
// zugeordneten Personen, die sich mit deren Zeitraum überschneiden. Die
// Abwesenheiten kommen aus der globalen Personenliste db.globalPeople
// (gefüllt aus users/user_absences, siehe loadGlobalPeople() in server.js) —
// dieselbe Quelle, aus der auch die Urlaubszeilen im GANTT gezeichnet
// werden. Parameter: todo (Aufgabenobjekt). Rückgabe: Array von
// { name, type, start, end } (leer, wenn keine Kollision).
function absenceConflictsForTodo(todo){
const s = parseDateISO(todo.start), e = parseDateISO(todo.end);
if(!s || !e) return [];
const assignees = Array.isArray(todo.assignees) ? todo.assignees : [];
if(assignees.length === 0) return [];
const conflicts = [];
assignees.forEach(personId => {
const person = (db.globalPeople || []).find(p => p.id === personId);
if(!person) return;
(person.absences || []).forEach(a => {
const as = parseDateISO(a.start), ae = parseDateISO(a.end);
if(!rangesOverlap(s, e, as, ae)) return;
conflicts.push({
name: person.fullName || person.name,
type: a.type || 'Abwesenheit',
start: as,
end: ae,
});
});
});
return conflicts;
}
// Export, damit plan-gantt.js dieselbe Prüfung für die Warnliste über dem
// Diagramm nutzen kann (eine Quelle für beide Anzeigen).
app.absenceConflictsForTodo = absenceConflictsForTodo;
// Referenz auf die Original-Funktion aus gantt.js, die für das Basis-GANTT
// weiterhin verwendet wird.
const originalBuildGanttHtml = window.buildGanttHtml;
@@ -36,6 +98,31 @@
const totalDays = Math.max(1, daysBetween(start, end) + 1);
const editable = options.editable;
// ghostBarHtml: zeichnet einen gestrichelten Umriss für den Termin aus der
// Vorwoche. Liefert '' (nichts), wenn keine Vorwochen-Daten vorliegen, der
// Termin unverändert ist oder er komplett außerhalb des sichtbaren
// Zeitraums liegt. Parameter: prevStartIso/prevEndIso (ISO-Datum aus der
// Vorwoche), curStartIso/curEndIso (aktuelle Termine), label (für den
// Tooltip). Rückgabe: HTML-String.
function ghostBarHtml(prevStartIso, prevEndIso, curStartIso, curEndIso, label){
if(!prevStartIso || !prevEndIso) return '';
if(prevStartIso === curStartIso && prevEndIso === curEndIso) return '';
const gs = parseDateISO(prevStartIso), ge = parseDateISO(prevEndIso);
if(!gs || !ge) return '';
const barStart = gs < start ? start : gs;
const barEnd = ge > end ? end : ge;
if(barEnd < start || barStart > end) return '';
const offset = daysBetween(start, barStart);
const span = daysBetween(barStart, barEnd) + 1;
// Verschiebung in Tagen, positiv = nach hinten gerutscht
const shift = curStartIso ? daysBetween(gs, parseDateISO(curStartIso)) : 0;
const shiftText = shift > 0 ? ` (${shift} Tag${shift === 1 ? '' : 'e'} später)`
: shift < 0 ? ` (${-shift} Tag${-shift === 1 ? '' : 'e'} früher)` : '';
const ref = options.ghostLabel ? esc(options.ghostLabel) : 'vorherige Sitzung';
const title = `${ref}: ${esc(label)} ${formatDateDE(gs)}–${formatDateDE(ge)}${shiftText}`;
return `<div class="gantt-bar ghost" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${title}"></div>`;
}
projects.forEach(p => {
const plan = p.plan || {};
const todos = Array.isArray(plan.todos) ? plan.todos : [];
@@ -61,6 +148,18 @@
row.dataset.todoId = todo.id;
row.dataset.todoIndex = tIdx;
// Urlaubs-Kollision: Aufgabe überschneidet sich mit einer Abwesenheit
// einer zugeordneten Person (siehe absenceConflictsForTodo oben).
const conflicts = absenceConflictsForTodo(todo);
if(conflicts.length > 0) row.className += ' absence-conflict';
// Geister-Balken: Termin derselben Aufgabe (gleiche todo.id) aus der
// vorherigen Sitzung, sofern er sich unterscheidet.
const ghost = options.ghostTodos ? options.ghostTodos.get(todo.id) : null;
const ghostHtml = ghost
? ghostBarHtml(ghost.start, ghost.end, todo.start, todo.end, todo.title || 'TODO')
: '';
let barHtml = '';
if(s && e){
const barStart = s < start ? start : s;
@@ -72,9 +171,14 @@
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));
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}"${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)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
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>`;
}
}
// Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt.
barHtml = ghostHtml + barHtml;
let depBadge = '';
if(Array.isArray(todo.dependsOn) && todo.dependsOn.length > 0){
@@ -83,7 +187,9 @@
const t = todos.find(x => x.id === id);
if(t) return { title: t.title || 'TODO', end: t.end };
const m = milestones.find(x => 'm_' + x.id === id);
if(m) return { title: '🚩 ' + (m.name || 'Meilenstein'), end: m.date };
// Hinweis: title landet weiter unten via esc() in reinem Textkontext
// (kein HTML-Rendering) -> kein Icon-Präfix möglich, nur Text.
if(m) return { title: (m.name || 'Meilenstein'), end: m.date };
return null;
}).filter(Boolean);
if(preds.length > 0){
@@ -93,9 +199,15 @@
}
}
// Warnsymbol bei Urlaubs-Kollision, mit den betroffenen Personen im Tooltip
const conflictBadge = conflicts.length > 0
? `<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')}">
${editable ? '<span class="gantt-todo-drag">⋮⋮</span>' : ''}
${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}
</div>
<div class="gantt-timeline">${barHtml}</div>`;
return row;
@@ -122,8 +234,25 @@
labelStyle = 'left:' + left;
}
}
// Geister-Raute: Termin desselben Meilensteins (gleiche milestone.id)
// aus der vorherigen Sitzung, sofern er sich verschoben hat. Gerade
// bei Meilensteinen ist das die Information, die im Wochenmeeting auf
// den Tisch gehört — sie sind die nach außen zugesagten Termine.
const ghostDate = options.ghostMilestones ? options.ghostMilestones.get(m.id) : null;
if(ghostDate && ghostDate !== m.date){
const gd = parseDateISO(ghostDate);
if(gd && gd >= start && gd <= end){
const gLeft = (daysBetween(start, gd) / totalDays * 100).toFixed(4) + '%';
const shift = d ? daysBetween(gd, d) : 0;
const shiftText = shift > 0 ? ` (${shift} Tag${shift === 1 ? '' : 'e'} später)`
: shift < 0 ? ` (${-shift} Tag${-shift === 1 ? '' : 'e'} früher)` : '';
const ref = options.ghostLabel ? esc(options.ghostLabel) : 'vorherige Sitzung';
diamond = `<div class="gantt-milestone ghost" style="left:${gLeft}" title="${ref}: ${esc(m.name || 'Meilenstein')} ${formatDateDE(gd)}${shiftText}"></div>` + diamond;
}
}
row.innerHTML = `<div class="gantt-label" style="padding-left:24px;font-weight:400;color:var(--text-muted)">
${editable ? '<span class="gantt-todo-drag">⋮⋮</span>' : ''}
${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''}
<span class="gantt-milestone-title"${editable ? ' onclick="openMilestoneModal(' + mIdx + ')" style="cursor:pointer"' : ''}>${esc(m.name || 'Meilenstein')}</span>
</div>
<div class="gantt-timeline">${diamond}<div class="gantt-milestone-label" style="${labelStyle}">${esc(m.name || '')}</div></div>`;
@@ -144,10 +273,19 @@
insertedMilestoneIds.add(m.id);
});
// Aufgabenzeile
const todoRow = buildTodoRow(todo, tIdx);
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
insertAfter = todoRow;
// Aufgabenzeile — nur wenn die Aufgabe terminiert ist (Start UND Ende
// gesetzt). Eine Aufgabe ohne Termin bleibt ganz normal Teil von
// plan.todos und erscheint im Bereich "Aufgaben" (renderPlanTasks(),
// js/plan/plan-tasks.js) — hier im Terminplan aber bewusst nicht: sie
// hat keinen Zeitraum, den ein Balken abbilden könnte, und soll den
// Terminplan nicht mit leeren Zeilen ohne Balken zustellen. Nicht
// jede Aufgabe aus "Aufgaben" landet also im GANTT, wohl aber jede
// Aufgabe aus dem GANTT in "Aufgaben" (dieselbe Liste, siehe oben).
if(todo.start && todo.end){
const todoRow = buildTodoRow(todo, tIdx);
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
insertAfter = todoRow;
}
// Meilensteine, die NACH dieser Aufgabe positioniert sind
milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => {
+353
View File
@@ -0,0 +1,353 @@
// -----------------------------
// Projektplan — Dateien (Upload, Freigabe, Kommentare)
// -----------------------------
// Dieses Modul stellt den Bereich "Dateien" des Projektbereichs bereit
// (Upload-Maske und Dateiliste, siehe js/plan/plan-sections.js; eine
// Kompakt-Karte im Dashboard, #planFilesSummary, zeigt die zuletzt
// hochgeladene Datei und ausstehende Prüfungen und springt per Klick über
// openFilesPanel() in diesen Bereich): Upload (roher
// Binär-Body wie js/import.js), Anzeige aller Dateien eines Projekts samt
// Prüferstatus, Genehmigen-/Ablehnen-Aktionen für den eingeloggten Prüfer
// sowie einen einfachen Kommentar-Thread je Datei. Dateien werden nur noch
// über project_nr allein mit dem Projekt verknüpft (NICHT project_id — siehe
// server.js-Abschnitt "Datei-Upload, Freigabe, Kommentare"), daher ist der
// Upload-Bereich deaktiviert und die Liste zeigt nur einen Hinweis, solange
// das aktuelle Projekt keine Projektnummer (app.project.nr) hat.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js) für app.project, currentUser (js/auth.js) für die
// Sichtbarkeit der Genehmigen-/Ablehnen-Buttons.
(function(){
const app = window.PlanApp;
// filesCache: zuletzt geladene Dateiliste (für Re-Renders nach Aktionen,
// ohne jedes Mal neu zu fetchen); openComments: Set der Datei-IDs, deren
// Kommentar-Thread gerade aufgeklappt ist.
let filesCache = [];
const openComments = new Set();
// statusLabel/statusClass: deutsche Anzeige- bzw. CSS-Klasse für den
// Datei-/Prüfstatus.
function statusLabel(status){
if(status === 'approved') return 'Freigegeben';
if(status === 'rejected') return 'Abgelehnt';
return 'Ausstehend';
}
function statusClass(status){
if(status === 'approved') return 'approved';
if(status === 'rejected') return 'rejected';
return 'pending';
}
// formatDateTimeDE: formatiert einen ISO-Zeitstempel als deutsches
// Datum+Uhrzeit (Fallback: leerer String bei ungültigem Wert).
function formatDateTimeDE(iso){
if(!iso) return '';
const d = new Date(iso);
if(isNaN(d.getTime())) return '';
return formatDateDE(d) + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
}
// initPlanFiles: initialisiert den Dateien-Bereich beim Laden der Seite
// (Prüfer-Checkboxen befüllen, Dateiliste laden) — Nachfolger des früheren
// openFilesModal(), seit der Bereich fest auf der Seite sichtbar ist statt
// in einem Modal. Wird von renderPlanPage() (plan-state.js) aufgerufen.
window.initPlanFiles = function(){
const hasNr = !!(app.project && app.project.nr);
const hint = document.getElementById('filesNoProjectHint');
const uploadSection = document.getElementById('filesUploadSection');
if(hint) hint.style.display = hasNr ? 'none' : '';
if(uploadSection) uploadSection.style.display = hasNr ? '' : 'none';
const fileInput = document.getElementById('fileUploadInput');
if(fileInput) fileInput.value = '';
if(hasNr) loadReviewerCheckboxes();
renderProjectFiles();
};
// openFilesPanel: springt in den Bereich "Dateien" des Projektbereichs
// (js/plan/plan-sections.js), in dem Upload-Maske und Dateiliste stehen.
// Früher öffnete die Funktion ein eigenes Modal; der Name bleibt, weil die
// Kompakt-Karte "Dateien & Freigaben" im Dashboard darauf verweist.
window.openFilesPanel = function(){
if(typeof showPlanSection === 'function') showPlanSection('dateien');
};
// loadReviewerCheckboxes: lädt alle aktiven Benutzerkonten (GET /users,
// nicht die Roster-Personen des Projekts — Prüfer müssen echte Logins
// sein) und baut die Mehrfachauswahl-Checkbox-Liste (gleiches Muster wie
// todoEditAssignees in plan-modals.js).
function loadReviewerCheckboxes(){
const container = document.getElementById('fileUploadReviewers');
if(!container) return;
container.innerHTML = '<span class="hint">Lade Benutzer…</span>';
fetch(baseUrl() + '/users')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
const users = Array.isArray(data.users) ? data.users : [];
if(users.length === 0){
container.innerHTML = '<span class="hint">Keine Benutzer vorhanden.</span>';
return;
}
// Das eigene Konto herausfiltern: Der Server entfernt den Uploader
// still aus der Prüferliste — wer nur sich selbst ankreuzte, bekam
// deshalb die verwirrende Meldung "Mindestens ein Prüfer erforderlich".
const selectable = users.filter(u => !(typeof currentUser !== 'undefined' && currentUser && u.id === currentUser.id));
if(selectable.length === 0){
container.innerHTML = '<span class="hint">Keine weiteren Benutzer vorhanden, die prüfen könnten.</span>';
return;
}
container.innerHTML = selectable.map(u =>
`<label class="todo-depends-item"><input type="checkbox" value="${esc(String(u.id))}"> ${esc(u.displayName || u.username)}</label>`
).join('');
})
.catch(err => {
container.innerHTML = '<span class="hint">Benutzer konnten nicht geladen werden.</span>';
console.error('Benutzerliste laden fehlgeschlagen:', err);
});
}
// submitFileUpload: liest die gewählte Datei und die angehakten Prüfer,
// schickt die Datei als rohen Binär-Body (wie js/import.js) an
// POST /project-files und lädt danach die Liste neu.
window.submitFileUpload = function(){
if(!app.project || !app.project.nr){ alert('Dieses Projekt hat keine Projektnummer.'); return; }
const fileInput = document.getElementById('fileUploadInput');
const file = fileInput && fileInput.files && fileInput.files[0];
if(!file){ alert('Bitte eine Datei auswählen.'); return; }
const reviewersContainer = document.getElementById('fileUploadReviewers');
const reviewerIds = reviewersContainer
? Array.from(reviewersContainer.querySelectorAll('input:checked')).map(cb => cb.value)
: [];
if(reviewerIds.length === 0){ alert('Bitte mindestens einen Prüfer auswählen.'); return; }
const url = baseUrl() + '/project-files'
+ '?projectNr=' + encodeURIComponent(app.project.nr)
+ '&projectName=' + encodeURIComponent(app.project.name || '')
+ '&filename=' + encodeURIComponent(file.name)
+ '&reviewers=' + reviewerIds.join(',');
fetch(url, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Upload fehlgeschlagen.');
if(fileInput) fileInput.value = '';
if(reviewersContainer) reviewersContainer.querySelectorAll('input:checked').forEach(cb => cb.checked = false);
app.showToast('Datei hochgeladen.');
renderProjectFiles();
})
.catch(err => {
console.error('Datei-Upload fehlgeschlagen:', err);
alert('Datei-Upload fehlgeschlagen: ' + (err.message || err));
});
};
// renderProjectFiles: lädt die Dateiliste des aktuellen Projekts
// (GET /project-files?projectNr=) und rendert sie in #filesListContainer.
// Ohne Projektnummer wird nur ein Hinweistext gezeigt.
window.renderProjectFiles = function(){
const container = document.getElementById('filesListContainer');
if(!container) return;
if(!app.project || !app.project.nr){
container.innerHTML = '<span class="hint">Dieses Projekt hat keine Projektnummer und kann daher keine Dateien erhalten.</span>';
const summaryEl = document.getElementById('planFilesSummary');
if(summaryEl) summaryEl.innerHTML = '<span class="hint">Keine Projektnummer.</span>';
return;
}
container.innerHTML = '<span class="hint">Lade Dateien…</span>';
const url = baseUrl() + '/project-files'
+ '?projectNr=' + encodeURIComponent(app.project.nr);
fetch(url)
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
filesCache = Array.isArray(data.files) ? data.files : [];
renderFilesList();
})
.catch(err => {
container.innerHTML = '<span class="hint">Dateien konnten nicht geladen werden.</span>';
console.error('Dateiliste laden fehlgeschlagen:', err);
});
};
// renderFilesSummary: füllt die Kompakt-Vorschau der "Dateien & Freigaben"-
// Karte unter dem GANTT (Cockpit-Layout, #planFilesSummary in
// Test.html) aus filesCache — Name der zuletzt hochgeladenen Datei
// (Server liefert /project-files bereits nach created_at DESC sortiert,
// filesCache[0] ist also die neueste) sowie einen Hinweis auf noch
// ausstehende Prüfungen.
function renderFilesSummary(){
const summaryEl = document.getElementById('planFilesSummary');
if(!summaryEl) return;
if(filesCache.length === 0){
summaryEl.innerHTML = '<span class="hint">Noch keine Dateien.</span>';
return;
}
const pendingCount = filesCache.filter(f => f.status === 'pending').length;
const chip = pendingCount > 0
? `<span class="plan-mini-chip warn">${pendingCount} ausstehend</span>`
: '<span class="plan-mini-chip">Alle geprüft</span>';
summaryEl.innerHTML = `<div class="plan-mini-card-preview">${esc(filesCache[0].originalFilename)}</div>
<div class="plan-mini-chip-row">${chip}</div>`;
}
// renderFilesList: rendert filesCache in #filesListContainer (Name,
// Uploader, Datum, Status-Badge, Prüferstatus je Prüfer, Kommentaranzahl,
// Download-Link, Genehmigen-/Ablehnen-Buttons für den aktuellen Prüfer,
// aufklappbarer Kommentar-Thread).
function renderFilesList(){
const container = document.getElementById('filesListContainer');
if(!container) return;
renderFilesSummary();
if(filesCache.length === 0){
container.innerHTML = '<span class="hint">Noch keine Dateien für dieses Projekt hochgeladen.</span>';
return;
}
container.innerHTML = filesCache.map(f => {
const myReview = currentUser ? (f.reviewers || []).find(r => r.userId === currentUser.id) : null;
const canReview = myReview && myReview.decision === 'pending' && f.status === 'pending';
// 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 isUploader = !!(typeof currentUser !== 'undefined' && currentUser && f.uploadedByUserId === currentUser.id);
const canDelete = isAdmin || (isUploader && f.status === 'pending');
const reviewerChips = (f.reviewers || []).map(r =>
`<span class="file-reviewer-chip ${esc(statusClass(r.decision))}">${esc(r.displayName)}: ${esc(statusLabel(r.decision))}</span>`
).join('');
const downloadUrl = baseUrl() + '/project-files/' + encodeURIComponent(f.id) + '/download';
return `
<div class="file-item" data-file-id="${esc(f.id)}">
<div class="file-item-header">
<a href="${esc(downloadUrl)}" class="file-item-name">${esc(f.originalFilename)}</a>
<span class="file-status-badge ${esc(statusClass(f.status))}">${esc(statusLabel(f.status))}</span>
</div>
<div class="file-item-meta">Hochgeladen von ${esc(f.uploadedBy || '–')} am ${esc(formatDateTimeDE(f.createdAt))}</div>
<div class="file-reviewer-list">${reviewerChips}</div>
<div class="file-item-actions">
${canReview ? `<button class="small" onclick="reviewProjectFile('${esc(f.id)}','approved')">Genehmigen</button>
<button class="small danger" onclick="reviewProjectFile('${esc(f.id)}','rejected')">Ablehnen</button>` : ''}
<button class="small secondary" onclick="toggleFileComments('${esc(f.id)}')">Kommentare (${f.commentCount || 0})</button>
${canDelete ? `<button class="small danger" onclick="deleteProjectFile('${esc(f.id)}')">Löschen</button>` : ''}
</div>
<div class="file-comments" id="fileComments_${esc(f.id)}" style="display:${openComments.has(f.id) ? '' : 'none'}"></div>
</div>`;
}).join('');
// Kommentar-Threads, die vorher aufgeklappt waren, direkt neu laden.
openComments.forEach(fileId => {
if(filesCache.some(f => f.id === fileId)) loadFileComments(fileId);
});
}
// reviewProjectFile: sendet die Freigabe-Entscheidung des aktuellen
// Benutzers für eine Datei und lädt die Liste danach neu.
window.reviewProjectFile = function(fileId, decision){
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId) + '/review', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ decision })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Entscheidung fehlgeschlagen.');
app.showToast('Entscheidung gespeichert.');
renderProjectFiles();
})
.catch(err => {
console.error('Freigabe-Entscheidung fehlgeschlagen:', err);
alert('Entscheidung fehlgeschlagen: ' + (err.message || err));
});
};
// deleteProjectFile: löscht (nach Bestätigung) eine Datei; der Server
// erlaubt das nur dem Uploader (solange 'pending') oder einem Admin.
window.deleteProjectFile = function(fileId){
if(!confirm('Datei wirklich löschen?')) return;
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId), { method: 'DELETE' })
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
openComments.delete(fileId);
app.showToast('Datei gelöscht.');
renderProjectFiles();
})
.catch(err => {
console.error('Datei löschen fehlgeschlagen:', err);
alert('Löschen fehlgeschlagen: ' + (err.message || err));
});
};
// toggleFileComments: klappt den Kommentar-Thread einer Datei auf/zu und
// lädt ihn bei Bedarf.
window.toggleFileComments = function(fileId){
const el = document.getElementById('fileComments_' + fileId);
if(!el) return;
if(openComments.has(fileId)){
openComments.delete(fileId);
el.style.display = 'none';
} else {
openComments.add(fileId);
el.style.display = '';
loadFileComments(fileId);
}
};
// loadFileComments: lädt die Kommentare einer Datei (GET) und rendert
// Liste + Eingabefeld in den zugehörigen Thread-Container.
function loadFileComments(fileId){
const el = document.getElementById('fileComments_' + fileId);
if(!el) return;
el.innerHTML = '<span class="hint">Lade Kommentare…</span>';
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId) + '/comments')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
const comments = Array.isArray(data.comments) ? data.comments : [];
const list = comments.length === 0
? '<span class="hint">Noch keine Kommentare.</span>'
: comments.map(c => `
<div class="file-comment">
<b>${esc(c.displayName || '–')}</b>
<span class="file-comment-date">${esc(formatDateTimeDE(c.createdAt))}</span>
<div>${esc(c.body)}</div>
</div>`).join('');
el.innerHTML = `
<div class="file-comment-list">${list}</div>
<div class="file-comment-form">
<input type="text" id="fileCommentInput_${esc(fileId)}" placeholder="Kommentar schreiben…">
<button class="small" onclick="submitFileComment('${esc(fileId)}')">Senden</button>
</div>`;
})
.catch(err => {
el.innerHTML = '<span class="hint">Kommentare konnten nicht geladen werden.</span>';
console.error('Kommentare laden fehlgeschlagen:', err);
});
}
// submitFileComment: schickt den Text des Eingabefelds einer Datei als
// neuen Kommentar (jeder eingeloggte Benutzer darf kommentieren) und lädt
// den Thread danach neu.
window.submitFileComment = function(fileId){
const input = document.getElementById('fileCommentInput_' + fileId);
const body = input ? input.value.trim() : '';
if(!body) return;
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId) + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ body })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Kommentar fehlgeschlagen.');
// renderProjectFiles() baut die Liste neu auf (aktualisiert auch
// commentCount) und lädt aufgeklappte Threads (openComments) neu.
renderProjectFiles();
})
.catch(err => {
console.error('Kommentar senden fehlgeschlagen:', err);
alert('Kommentar fehlgeschlagen: ' + (err.message || err));
});
};
})();
+251 -21
View File
@@ -5,10 +5,15 @@
// der TODO- und Meilensteinzeilen, die buildGanttHtml aus plan-build-gantt.js
// liefert, und zeichnet darauf die Abhängigkeitslinien zwischen den Aufgaben.
// Es verwaltet außerdem Zoom (pxPerDay in PlanApp), das Anpassen des
// Datumsbereichs (fitPlanGanttRange) sowie die Anzeige der Ressourcen-
// Auslastung. renderPlanGantt ist die zentrale Neu-Render-Funktion und wird
// nach Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js
// erneut aufgerufen.
// Datumsbereichs (fitPlanGanttRange) sowie die Ressourcen-Auslastung
// (computeResourceLoads/renderResourceLoad/openResourceModal, Cockpit-Layout:
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
// offene Aufgaben, nächster Meilenstein, Team-Auslastung, alle aus denselben
// Plan-Daten abgeleitet). renderPlanGantt ist die zentrale Neu-Render-
// Funktion (rendert am Ende auch Auslastung und KPIs neu) und wird nach
// Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js erneut
// aufgerufen.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
@@ -28,7 +33,7 @@
let start = parseDateISO(document.getElementById('planGanttStart').value);
let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end){
const range = ganttRangeFromProjects([app.project], 7);
const range = ganttRangeFromProjects([app.project], 7, projectAssignees(app.project, app.week).map(a => a.person.id));
start = range.start; end = range.end;
document.getElementById('planGanttStart').value = formatDateISO(start);
document.getElementById('planGanttEnd').value = formatDateISO(end);
@@ -36,10 +41,148 @@
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) : [];
container.innerHTML = buildGanttHtml([app.project], start, end, { showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath });
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,
ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label,
});
container.scrollLeft = prevScroll;
requestAnimationFrame(() => renderGanttDependencies(container));
renderPlanWarnings();
renderResourceLoad();
renderPlanKpis();
// Aufgabenliste und Dashboard zeigen dieselben Aufgaben-/Meilensteindaten
// in anderer Form (js/plan/plan-tasks.js, js/plan/plan-sections.js). Sie
// hängen sich hier mit ein, weil renderPlanGantt() die zentrale
// 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 renderPlanDashboard === 'function') renderPlanDashboard();
};
// collectGhostDates: liest die Termine derselben Aufgaben/Meilensteine aus
// der letzten früheren Sitzung MIT Planinhalt (app.previousPlannedProjectVersion()
// aus plan-history.js) und gibt sie als Maps id -> Termin zurück, die
// buildGanttHtml als ghostTodos/ghostMilestones für die gestrichelten
// Vergleichs-Umrisse nutzt. Das Label der gefundenen Sitzung wird
// mitgeliefert, damit die Tooltips sie namentlich nennen können statt
// pauschal "Vorwoche" zu behaupten.
// Leere Maps, wenn der Schalter aus ist, es keine frühere Fassung mit
// Terminplan gibt oder dem Projekt die projectUid fehlt (Altdaten).
// Rückgabe: { todos: Map, milestones: Map, label: String }.
function collectGhostDates(){
const empty = { todos: new Map(), milestones: new Map(), label: '' };
const toggle = document.getElementById('planShowGhost');
if(toggle && !toggle.checked) return empty;
if(typeof app.previousPlannedProjectVersion !== 'function') return empty;
const prev = app.previousPlannedProjectVersion();
if(!prev || !prev.project || !prev.project.plan) return empty;
const todos = new Map();
(prev.project.plan.todos || []).forEach(t => {
if(t.id && t.start && t.end) todos.set(t.id, { start: t.start, end: t.end });
});
const milestones = new Map();
(prev.project.plan.milestones || []).forEach(m => {
if(m.id && m.date) milestones.set(m.id, m.date);
});
return { todos, milestones, label: prev.week.label };
}
// renderPlanWarnings: füllt das Hinweisband über dem GANTT
// (#planWarnings in Test.html) mit den Urlaubs-Kollisionen des
// Projekts — Aufgaben, deren Zeitraum in die Abwesenheit einer zugeordneten
// Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js
// (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste
// garantiert dasselbe melden. Keine Parameter, keine Rückgabe.
window.renderPlanWarnings = function(){
const el = document.getElementById('planWarnings');
if(!el || !app.project) return;
if(typeof app.absenceConflictsForTodo !== 'function'){ el.innerHTML = ''; return; }
const plan = app.ensurePlanLocal();
const rows = [];
(plan.todos || []).forEach(t => {
if(t.done) return; // erledigte Aufgaben sind kein Planungsproblem mehr
const conflicts = app.absenceConflictsForTodo(t);
conflicts.forEach(c => {
rows.push(`<li><b>${esc(t.title || 'TODO')}</b> — ${esc(c.name)} ist ${esc(c.type.toLowerCase())} (${formatDateDE(c.start)}–${formatDateDE(c.end)})</li>`);
});
});
if(rows.length === 0){ el.innerHTML = ''; return; }
el.innerHTML = `<div class="plan-warning-band">
<div class="plan-warning-head">${icon('warning')} ${rows.length} Terminkonflikt${rows.length === 1 ? '' : 'e'} mit Abwesenheiten</div>
<ul class="plan-warning-list">${rows.join('')}</ul>
</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' : ''}">
<div class="kpi-label">${esc(label)}</div>
<div class="kpi-value">${esc(value)}</div>
<div class="kpi-sub">${esc(sub)}</div>
</div>`;
}
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
// #planKpiRow in Test.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.
window.renderPlanKpis = function(){
const row = document.getElementById('planKpiRow');
if(!row || !app.project) return;
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 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 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 { loads, maxDays } = computeResourceLoads();
const loadValues = Object.values(loads);
let loadTile;
if(loadValues.length === 0){
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet');
} else {
const avgPct = Math.round(loadValues.reduce((s, l) => s + (l.days / maxDays * 100), 0) / loadValues.length);
const top = loadValues.slice().sort((a, b) => b.days - a.days)[0];
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.days > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch keine Aufgaben zugewiesen');
}
row.innerHTML = progressTile + openTile + milestoneTile + loadTile;
};
// renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
@@ -129,7 +272,9 @@
// (inkl. 7 Tage Puffer) zurück und rendert das Diagramm neu. Wird auch nach
// Drags außerhalb des sichtbaren Bereichs von plan-drag.js genutzt.
window.fitPlanGanttRange = function(){
const range = ganttRangeFromProjects([app.project], 7);
// Nur die Abwesenheiten der Projektbeteiligten dürfen den Zeitraum
// aufziehen — alle anderen Urlaubszeilen werden ohnehin nicht gezeichnet.
const range = ganttRangeFromProjects([app.project], 7, projectAssignees(app.project, app.week).map(a => a.person.id));
document.getElementById('planGanttStart').value = formatDateISO(range.start);
document.getElementById('planGanttEnd').value = formatDateISO(range.end);
renderPlanGantt();
@@ -159,26 +304,34 @@
// ---------- Ressourcen-Auslastung ----------
// renderResourceLoad: berechnet die Auslastung der Projektbeteiligten in
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs und
// rendert pro Person eine Auslastungskarte mit Balken und Abwesenheits-
// Hinweis. Ein Klick auf eine Karte öffnet das Abwesenheits-Modal aus
// plan-modals.js.
window.renderResourceLoad = function(){
const container = document.getElementById('resourceLoadContainer');
if(!container || !app.project) return;
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten in
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs.
// Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
// Auslastungs-Modal) und renderPlanKpis() (Team-Auslastung-Kachel), damit
// beide dieselbe Zahl zeigen.
//
// Neben der Auslastung IN DIESEM Projekt (days/todos) wird zusätzlich
// erfasst, wie stark dieselbe Person in den ÜBRIGEN Projekten derselben
// Sitzung gebunden ist (otherDays/otherProjects). Ohne diesen Blick über den
// Projektrand ist eine Überbuchung strukturell unsichtbar: jemand kann hier
// entspannt aussehen und trotzdem in vier weiteren Projekten verplant sein.
// Die Daten dafür sind bereits vorhanden — app.week.projects enthält alle
// Projekte der Sitzung (GET /load-all lädt sie ohnehin komplett).
//
// Rückgabe: { loads, maxDays } — loads ist ein Objekt Person-ID ->
// { person, roles, days, todos, otherDays, otherProjects }, maxDays das
// Maximum über alle Personen (mindestens 1, für Prozent-Berechnungen; bezieht
// sich bewusst nur auf die projekteigenen Tage, damit die Balken im Modal
// untereinander vergleichbar bleiben).
function computeResourceLoads(){
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : [];
const assignees = projectAssignees(app.project, app.week);
if(assignees.length === 0){
container.innerHTML = '';
return;
}
const loads = {};
assignees.forEach(a => {
if(!loads[a.person.id]){
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [] };
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [], otherDays: 0, otherProjects: [] };
}
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
});
@@ -193,14 +346,76 @@
});
});
// Auslastung in den anderen Projekten derselben Sitzung
((app.week && app.week.projects) || []).forEach(other => {
if(other.id === app.project.id) return;
const otherTodos = (other.plan && Array.isArray(other.plan.todos)) ? other.plan.todos : [];
otherTodos.forEach(t => {
if(!t.start || !t.end || !Array.isArray(t.assignees)) return;
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
t.assignees.forEach(pid => {
if(!loads[pid]) return; // nur Personen, die auch hier beteiligt sind
loads[pid].otherDays += days;
const label = other.nr ? (other.nr + ' ' + (other.name || '')) : (other.name || 'Projekt');
if(!loads[pid].otherProjects.includes(label)) loads[pid].otherProjects.push(label);
});
});
});
const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0));
return { loads, maxDays };
}
// openResourceModal: springt in den Bereich "Terminplan", in dem die
// Auslastungskarten stehen (#resourceLoadContainer). Früher öffnete die
// Funktion ein eigenes Modal; seit die Seite in Bereiche gegliedert ist
// (js/plan/plan-sections.js), ist der Inhalt fester Bestandteil des
// Terminplan-Bereichs. Der Name bleibt, weil die Kompakt-Karte
// "Ressourcen-Auslastung" im Dashboard darauf verweist.
window.openResourceModal = function(){
if(typeof showPlanSection === 'function') showPlanSection('terminplan');
const container = document.getElementById('resourceLoadContainer');
if(container) container.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
// renderResourceLoad: rendert pro Person eine Auslastungskarte mit Balken
// und Abwesenheits-Hinweis in #resourceLoadContainer (Inhalt des
// Auslastungs-Modals) sowie eine kompakte Vorschau (Balkenreihe) in
// #planResourceSummary (die Kompakt-Karte selbst). Ein Klick auf eine
// Detailkarte öffnet das Abwesenheits-Modal aus plan-modals.js.
window.renderResourceLoad = function(){
const container = document.getElementById('resourceLoadContainer');
const summaryEl = document.getElementById('planResourceSummary');
if(!container || !app.project) return;
const { loads, maxDays } = computeResourceLoads();
const loadValues = Object.values(loads);
if(loadValues.length === 0){
container.innerHTML = '<span class="hint">Keine Beteiligten zugeordnet.</span>';
if(summaryEl) summaryEl.innerHTML = '<span class="hint">Keine Beteiligten zugeordnet.</span>';
return;
}
if(summaryEl){
const cells = loadValues.map(l => {
const pct = Math.round(l.days / maxDays * 100);
const otherHint = l.otherDays > 0 ? ` (+${l.otherDays} anderswo)` : '';
return `<div class="plan-heat-cell" style="opacity:${Math.max(.15, pct / 100)}" title="${esc(l.person.name)}: ${l.days} Tage${otherHint}"></div>`;
}).join('');
const alsoBooked = loadValues.filter(l => l.otherDays > 0).length;
const chip = alsoBooked > 0
? `<span class="plan-mini-chip warn">${alsoBooked} auch in anderen Projekten</span>`
: `<span class="plan-mini-chip">nur in diesem Projekt</span>`;
summaryEl.innerHTML = `<div class="plan-heat-row">${cells}</div>
<div class="plan-mini-chip-row">${chip}</div>`;
}
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)</div>';
html += '<div class="load-grid plan-resource-grid">';
Object.values(loads).forEach(l => {
const pct = Math.round(l.days / maxDays * 100);
const absCount = app.countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">🏖 ${absCount} Abwesenheit(en)</div>` : '';
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : '';
let todoListHtml;
if(l.todos.length === 0){
@@ -212,10 +427,20 @@
}
}
// Gegenüberstellung: Tage in diesem Projekt vs. in den übrigen Projekten
// derselben Sitzung (siehe computeResourceLoads) — macht sichtbar, wenn
// jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
const otherBadge = l.otherDays > 0
? `<div class="plan-resource-other" title="${esc(l.otherProjects.join('\n'))}">
+${l.otherDays} Tage in ${l.otherProjects.length} weiteren Projekt${l.otherProjects.length === 1 ? '' : 'en'}
</div>`
: `<div class="plan-resource-other muted">keine weiteren Projekte</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">${l.days}</div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
${otherBadge}
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
${absBadge}
</div>`;
@@ -226,7 +451,12 @@
// Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen
// Debounce, damit während des Ziehens nicht unnötig neu gerendert wird.
// Seit der Projektbereich ein Bereich der Hauptseite ist, läuft dieser
// Lauscher auch, während ein ganz anderer Bereich sichtbar ist. In einem
// ausgeblendeten Container sind alle gemessenen Positionen 0 — das Zeichnen
// wäre nicht nur überflüssig, sondern falsch. Deshalb der Bereichs-Test.
window.addEventListener('resize', () => {
if(currentTab !== 'plan' || !app.project) return;
if(app.resizeTimer) clearTimeout(app.resizeTimer);
app.resizeTimer = setTimeout(() => renderGanttDependencies(), 150);
});
+164
View File
@@ -0,0 +1,164 @@
// -----------------------------
// 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.
//
// 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
// 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.
//
// Hintergrund: Beim wöchentlichen cloneWeek() (js/weeks.js) wird der
// Kommentartext in die neue Sitzung kopiert und dort überschrieben. Der alte
// Text bleibt zwar in der alten Sitzung erhalten, war aber praktisch
// unsichtbar, weil niemand in alte Sitzungen zurückwechselt. Damit ging der
// Gesprächsfaden eines Projekts verloren — genau das, wofür das wöchentliche
// Board ursprünglich gedacht war.
//
// WICHTIG — kein neuer Endpunkt nötig: Die Projektplan-Seite lädt über
// GET /load-all ohnehin ALLE Sitzungen mit allen Projekten nach db.weeks
// (siehe loadPlanData() in plan-state.js). Zusammen mit project_uid (der
// dauerhaften, wochenunabhängigen Projekt-Identität, siehe
// migrateAddProjectUid() in server.js) lässt sich der Verlauf daher rein im
// Browser zusammenstellen. Ein serverseitiges GET /projects/:uid/history wird
// erst nötig, wenn der Verlauf auch außerhalb dieser Seite gebraucht wird.
//
// Die hier definierten Helfer projectVersions()/previousProjectVersion()
// werden auch von plan-build-gantt.js gebraucht (Geister-Balken der Vorwoche)
// und deshalb auf window.PlanApp exportiert. Dieses Modul muss daher VOR
// plan-build-gantt.js geladen werden.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// projectVersions: alle Fassungen des aktuellen Projekts über alle
// geladenen Sitzungen, chronologisch aufsteigend. Die eigentliche Logik
// liegt seit der Kommentar-Überarbeitung in js/utils.js
// (projectVersionsForUid), weil das Board sie ebenfalls braucht; hier bleibt
// nur die Bindung an das aktuell geöffnete Projekt.
function projectVersions(){
return projectVersionsForUid(app.project && app.project.projectUid);
}
// previousProjectVersion: die Fassung des Projekts aus der Sitzung
// unmittelbar VOR der gerade geöffneten, oder null (früheste Sitzung bzw.
// kein projectUid). Rückgabe: { week, project } | null.
function previousProjectVersion(){
const versions = projectVersions();
const idx = versions.findIndex(v => app.week && v.week.label === app.week.label);
return idx > 0 ? versions[idx - 1] : null;
}
// previousPlannedProjectVersion: die jüngste frühere Fassung, die überhaupt
// einen befüllten Terminplan hat (mindestens eine Aufgabe oder einen
// Meilenstein). Grundlage für die Geister-Balken im GANTT.
//
// Warum nicht einfach previousProjectVersion(): In der Praxis gibt es
// Sitzungen, in denen ein Projekt zwar geführt wird, aber (noch) keinen
// Terminplan hat — etwa weil die Sitzung neu angelegt statt dupliziert
// wurde. Ein Vergleich gegen so eine leere Fassung würde gar nichts
// anzeigen und die Funktion wirkte kaputt, obwohl es zwei Sitzungen weiter
// hinten sehr wohl Vergleichsdaten gibt. Deshalb wird rückwärts gesucht,
// bis eine Fassung mit Planinhalt gefunden ist. Die gefundene Sitzung wird
// in den Tooltips namentlich genannt, damit der Bezug nie unklar ist.
// Rückgabe: { week, project } | null.
function previousPlannedProjectVersion(){
const versions = projectVersions();
const idx = versions.findIndex(v => app.week && v.week.label === app.week.label);
if(idx <= 0) return null;
for(let i = idx - 1; i >= 0; i--){
const plan = versions[i].project.plan;
if(!plan) continue;
const hasTodos = Array.isArray(plan.todos) && plan.todos.length > 0;
const hasMilestones = Array.isArray(plan.milestones) && plan.milestones.length > 0;
if(hasTodos || hasMilestones) return versions[i];
}
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 Test.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';
};
// Export für die anderen plan-Module (plan-gantt.js/plan-build-gantt.js:
// Geister-Balken der letzten geplanten Sitzung).
app.projectVersions = projectVersions;
app.previousProjectVersion = previousProjectVersion;
app.previousPlannedProjectVersion = previousPlannedProjectVersion;
})();
+343
View File
@@ -0,0 +1,343 @@
// -----------------------------
// Projektplan — Terminplan aus dem Honorar errechnen
// -----------------------------
// Eigener Dialog (#honorarPlanModal) mit eigener Schaltfläche "+ Terminplan"
// in der GANTT-Toolbar: statt fester Vorlagen-Schritte (das macht der
// Nachbardialog, js/plan/plan-templates.js) wird hier aus dem vereinbarten
// Honorar ein kompletter Terminplan über die beauftragten Leistungsphasen
// gerechnet. Beide Verfahren lagen früher als Reiter in einem gemeinsamen
// Dialog — getrennt sind sie, weil so auf jeder Schaltfläche steht, was
// dahinter passiert, und keine Maske die Eingaben der anderen mitträgt.
//
// Rechenweg (jede Stufe ist im Dialog als Tabellenspalte sichtbar, damit die
// Zahl nachvollziehbar bleibt und nicht aus einer Blackbox kommt):
//
// 1. Honorar (netto, €) -> im Dialog eingegeben, am Projekt gespeichert (projects.honorar)
// 2. Anteil je Leistungsphase (%) -> firmenweit gepflegt (lph_honorar_anteile)
// 3. ggf. Normierung -> siehe unten
// 4. Honorar je Phase (€) = Honorar × Anteil
// 5. Stunden je Phase = Honorar je Phase ÷ Stundensatz (firmenweit einstellbar, Vorgabe 135 €)
// 6. Dauer je Phase (Werktage) = Stunden ÷ (Arbeitsstunden je Tag × Personen je Phase), aufgerundet
// 7. Termine = nahtlos nacheinander ab Startdatum, nur Arbeitstage
// (Wochenenden und gepflegte Feiertage fallen heraus)
//
// NORMIERUNG (wichtig, im Dialog umschaltbar): Sind nur einzelne Phasen
// beauftragt — etwa LPH 5–8 mit zusammen 69 % —, dann ist das vereinbarte
// Honorar das Honorar für GENAU DIESE Phasen. Ohne Normierung würden nur 69 %
// des Honorars in Stunden umgerechnet und rund ein Drittel der Arbeitszeit
// fiele unter den Tisch. Standardmäßig werden die Anteile deshalb auf die
// beauftragten Phasen hochskaliert (jeder Anteil ÷ Summe der beauftragten
// Anteile). Wer das Honorar als Gesamthonorar über alle Phasen versteht,
// schaltet die Normierung im Dialog ab.
//
// Die erzeugten Aufgaben sind danach ganz normale plan_todos ohne Verbindung
// zur Berechnung — wie bei den Vorlagen (plan-templates.js) wirkt sich eine
// spätere Honorar- oder Satzänderung NICHT rückwirkend auf bereits erzeugte
// Balken aus.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// stundenProTag: angenommene Arbeitsstunden je Werktag und Person. War eine
// Konstante (8) und ist seit dem Ausbau der Einstellungen firmenweit
// gepflegt — der Wert geht direkt in jede errechnete Phasendauer ein.
function stundenProTag(){
return Number(APP_SETTINGS.arbeitsstundenProTag) || 8;
}
// honorarConfig: zuletzt geladene firmenweite Konfiguration
// ({ anteile: {LPH->%}, stundensatz: Number }); null = noch nicht geladen.
let honorarConfig = null;
// personenProPhase: im Dialog eingestellte Personenzahl je Leistungsphase
// (LPH -> Anzahl). Lebt nur für die Dauer des Dialogs, wird nicht gespeichert.
let personenProPhase = {};
// loadHonorarConfig: holt Anteile und Stundensatz vom Server und rendert
// danach neu. Fehler werden in der Tabelle gemeldet, nicht per alert.
function loadHonorarConfig(){
return fetch(baseUrl() + '/honorar-config')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
honorarConfig = { anteile: data.anteile || {}, stundensatz: Number(data.stundensatz) || 135 };
return honorarConfig;
});
}
// beauftragteLphs: die beauftragten Leistungsphasen des Projekts, in der
// Reihenfolge von LPHS (js/globals.js) — nicht in Eingabereihenfolge, damit
// der erzeugte Terminplan immer chronologisch sinnvoll aufsteigt.
// Rückgabe: Array von LPH-Schlüsseln.
function beauftragteLphs(){
const set = new Set(Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : []);
return LPHS.filter(l => set.has(l));
}
// berechneHonorarPlan: führt die Rechnung aus und liefert je beauftragter
// Leistungsphase eine Zeile mit allen Zwischenwerten. Parameter: startDate
// (Date, Beginn der ersten Phase), normieren (Boolean).
// Rückgabe: { zeilen: [...], summe: {...}, problem: String|null }.
function berechneHonorarPlan(startDate, normieren){
const honorar = Number(app.project.honorar) || 0;
const phasen = beauftragteLphs();
if(!honorarConfig) return { zeilen: [], summe: null, problem: 'Konfiguration noch nicht geladen.' };
if(honorar <= 0) return { zeilen: [], summe: null, problem: 'Bitte oben das vereinbarte Netto-Honorar eintragen.' };
if(phasen.length === 0) return { zeilen: [], summe: null, problem: 'Bitte oben mindestens eine beauftragte Leistungsphase ankreuzen.' };
const anteile = honorarConfig.anteile || {};
const summeBeauftragt = phasen.reduce((s, l) => s + (Number(anteile[l]) || 0), 0);
if(summeBeauftragt <= 0){
return { zeilen: [], summe: null, problem: 'Für die beauftragten Leistungsphasen sind keine Honorar-Anteile hinterlegt. Ein Administrator kann sie unten pflegen.' };
}
const stundensatz = honorarConfig.stundensatz;
let cursor = startDate;
let summeStunden = 0, summeTage = 0, summeHonorar = 0;
const zeilen = phasen.map(lph => {
const rohAnteil = Number(anteile[lph]) || 0;
// Normierung: Anteil relativ zur Summe der beauftragten Phasen
const anteil = normieren ? (rohAnteil / summeBeauftragt * 100) : rohAnteil;
const phasenHonorar = honorar * anteil / 100;
const stunden = phasenHonorar / stundensatz;
const personen = Math.max(1, Number(personenProPhase[lph]) || 1);
const tage = Math.max(1, Math.ceil(stunden / (stundenProTag() * personen)));
const start = cursor;
const ende = addWorkdays(start, tage - 1);
cursor = addWorkdays(ende, 1); // nächste Phase beginnt am Folgewerktag
summeStunden += stunden;
summeTage += tage;
summeHonorar += phasenHonorar;
return { lph, rohAnteil, anteil, phasenHonorar, stunden, personen, tage, start, ende };
});
return {
zeilen,
summe: { stunden: summeStunden, tage: summeTage, honorar: summeHonorar, stundensatz, summeBeauftragt },
problem: null,
};
}
// fmtEur / fmtNum: einheitliche deutsche Zahlenformate für die Tabelle.
function fmtEur(v){ return (Math.round(v * 100) / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; }
function fmtNum(v, digits){ return Number(v).toLocaleString('de-DE', { minimumFractionDigits: digits || 0, maximumFractionDigits: digits || 0 }); }
// setHonorarBetrag: übernimmt das vereinbarte Netto-Honorar des Projekts.
// Das Feld steht bewusst hier im Rechen-Dialog und nicht mehr im
// Projektinfo: dort gehören nur allgemeine Projektangaben und der
// Wochenkommentar hin, das Honorar ist eine Eingabe dieser Berechnung.
// Gespeichert wird es trotzdem dauerhaft am Projekt (projects.honorar),
// bleibt also über Sitzungen hinweg erhalten. Leere oder unbrauchbare
// Eingaben gelten als 0 = "kein Honorar hinterlegt".
// Parameter: val (Eingabewert). Keine Rückgabe.
window.setHonorarBetrag = function(val){
if(app.isLocked()) return;
const v = parseFloat(String(val).replace(',', '.'));
app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0;
app.savePlan();
renderHonorarPlan();
// Dasselbe Feld steht auch im Einstellungen-Bereich (plan-settings.js) —
// beide Masken nachziehen, damit sie nicht auseinanderlaufen.
if(typeof renderPlanSettings === 'function') renderPlanSettings();
};
// toggleBeauftragteLp: nimmt eine Leistungsphase in die Liste der
// beauftragten Phasen auf bzw. entfernt sie. Die Liste wird in der
// Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan
// chronologisch aufsteigt. Parameter: lph, checked (Boolean).
window.toggleBeauftragteLp = function(lph, checked){
if(app.isLocked()) return;
const p = app.project;
const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
if(checked) set.add(lph); else set.delete(lph);
p.beauftragteLP = LPHS.filter(l => set.has(l));
app.savePlan();
renderHonorarPlan();
// Die beauftragten Phasen stehen auch im Einstellungen-Bereich
// (plan-settings.js) und im Wochenbericht — dort wird beim nächsten
// Laden gelesen, hier genügt das Nachziehen der zweiten Maske.
if(typeof renderPlanSettings === 'function') renderPlanSettings();
};
// renderHonorarEingaben: hält Honorar-Feld und die Auswahl der beauftragten
// Leistungsphasen auf dem Stand des Projekts. Das fokussierte Honorar-Feld
// wird nicht überschrieben, sonst springt beim Tippen der Cursor.
function renderHonorarEingaben(){
const betragEl = document.getElementById('honorarBetrag');
if(betragEl && document.activeElement !== betragEl){
betragEl.value = Number(app.project.honorar) > 0 ? app.project.honorar : '';
}
const lpEl = document.getElementById('honorarBeauftragteLp');
if(lpEl){
const gesetzt = new Set(Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : []);
lpEl.innerHTML = LPHS.map(l => `<label class="beauftragte-lp-item">
<input type="checkbox" value="${esc(l)}" ${gesetzt.has(l) ? 'checked' : ''}
onchange="toggleBeauftragteLp('${esc(l)}', this.checked)">
${esc(l)}
</label>`).join('');
}
}
// renderHonorarPlan: baut die Eingaben und die Vorschau-Tabelle im Dialog
// neu auf. Wird bei jeder Änderung von Honorar, beauftragten Phasen,
// Startdatum, Normierung oder Personenzahl aufgerufen.
// Keine Parameter, keine Rückgabe.
window.renderHonorarPlan = function(){
const el = document.getElementById('honorarPlanTable');
if(!el || !app.project) return;
renderHonorarEingaben();
const startIso = (document.getElementById('honorarStart') || {}).value || '';
const startDate = parseDateISO(startIso);
const normieren = !!(document.getElementById('honorarNormieren') || {}).checked;
if(!startDate){
el.innerHTML = '<span class="hint">Bitte ein Startdatum wählen.</span>';
return;
}
const { zeilen, summe, problem } = berechneHonorarPlan(startDate, normieren);
if(problem){
el.innerHTML = '<span class="hint">' + esc(problem) + '</span>';
return;
}
const rows = zeilen.map(z => `<tr>
<th class="lp-lph">${esc(z.lph)}</th>
<td class="num">${fmtNum(z.anteil, 1)} %</td>
<td class="num">${fmtEur(z.phasenHonorar)}</td>
<td class="num">${fmtNum(z.stunden, 1)} h</td>
<td><input type="number" min="1" step="1" value="${z.personen}"
onchange="setHonorarPersonen('${esc(z.lph)}', this.value)"
title="Wie viele Personen arbeiten parallel an dieser Phase?"></td>
<td class="num">${z.tage} AT</td>
<td class="nowrap">${formatDateDE(z.start)}–${formatDateDE(z.ende)}</td>
</tr>`).join('');
const normHint = normieren
? `Anteile auf die ${zeilen.length} beauftragten Phasen normiert (Rohsumme ${fmtNum(summe.summeBeauftragt, 1)} % → 100 %).`
: `Rohe HOAI-Anteile, nicht normiert — es werden nur ${fmtNum(summe.summeBeauftragt, 1)} % des Honorars verplant.`;
el.innerHTML = `<table class="lp-matrix honorar-table">
<thead><tr>
<th>Phase</th><th class="num">Anteil</th><th class="num">Honorar</th>
<th class="num">Stunden</th><th>Personen</th><th class="num">Dauer</th><th>Zeitraum</th>
</tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr>
<th class="lp-lph">Summe</th>
<td class="num">${fmtNum(zeilen.reduce((s,z)=>s+z.anteil,0), 1)} %</td>
<td class="num">${fmtEur(summe.honorar)}</td>
<td class="num">${fmtNum(summe.stunden, 1)} h</td>
<td></td>
<td class="num">${summe.tage} AT</td>
<td class="nowrap">${formatDateDE(zeilen[0].start)}–${formatDateDE(zeilen[zeilen.length-1].ende)}</td>
</tr></tfoot>
</table>
<div class="hint" style="margin-top:6px">${esc(normHint)} Stundensatz ${fmtEur(summe.stundensatz)}, ${fmtNum(stundenProTag(), 1)} h je Person und Werktag. AT = Arbeitstage (ohne Wochenenden und gepflegte Feiertage).</div>`;
};
// setHonorarPersonen: übernimmt die im Tabellenfeld eingestellte
// Personenzahl einer Phase und rechnet neu. Parameter: lph, val.
window.setHonorarPersonen = function(lph, val){
personenProPhase[lph] = Math.max(1, parseInt(val, 10) || 1);
renderHonorarPlan();
};
// renderHonorarHint: blendet für Administratoren den Verweis auf den
// Bereich „Einstellungen" ein. Die Pflege-Maske für Anteile und Stundensatz
// stand bis zum Umbau hier im Dialog — an einer Stelle also, die man nur
// fand, wenn man ohnehin gerade einen Terminplan rechnen wollte. Sie liegt
// jetzt in js/settings/settings.js; dieser Dialog RECHNET nur noch damit.
// Keine Parameter, keine Rückgabe.
function renderHonorarHint(){
const el = document.getElementById('honorarAdminHint');
if(!el) return;
el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
}
// invalidateHonorarConfig: verwirft den Zwischenspeicher, damit der nächste
// Aufruf des Dialogs die Konfiguration frisch holt. Gerufen von
// saveHonorarConfig() (js/settings/settings.js) nach jeder Änderung — sonst
// rechnete ein in derselben Sitzung danach geöffneter Dialog mit den alten
// Anteilen weiter, weil die Konfiguration nur einmal je Seitenaufruf
// geladen wird. Keine Parameter, keine Rückgabe.
window.invalidateHonorarConfig = function(){
honorarConfig = null;
};
// openHonorarModal: öffnet den Honorar-Dialog (#honorarPlanModal, eigene
// Schaltfläche "+ Terminplan" in der GANTT-Toolbar). Lädt die firmenweite
// Konfiguration beim ersten Aufruf, belegt das Startdatum vor und rendert.
// Bei gesperrter Sitzung wird gar nicht erst geöffnet, wie bei den anderen
// Bearbeitungs-Dialogen.
window.openHonorarModal = function(){
if(app.isLocked()) return;
document.getElementById('honorarPlanModal').style.display = 'flex';
const startInput = document.getElementById('honorarStart');
const startDisplay = document.getElementById('honorarStartDisplay');
if(startInput && !startInput.value){
const today = formatDateISO(new Date());
startInput.value = today;
if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today));
}
personenProPhase = {};
renderHonorarHint();
if(honorarConfig){ renderHonorarPlan(); return; }
const el = document.getElementById('honorarPlanTable');
if(el) el.innerHTML = '<span class="hint">Lade Honorar-Einstellungen…</span>';
loadHonorarConfig()
.then(() => renderHonorarPlan())
.catch(err => {
console.error('Honorar-Konfiguration laden fehlgeschlagen:', err);
if(el) el.innerHTML = '<span class="hint">Honorar-Einstellungen konnten nicht geladen werden.</span>';
});
};
window.closeHonorarModal = function(){
document.getElementById('honorarPlanModal').style.display = 'none';
};
// applyHonorarPlan: erzeugt aus den berechneten Zeilen je Leistungsphase
// eine Aufgabe im Projektplan. Titel ist die Leistungsphase mit ihrem
// Stundenbudget, damit im GANTT sofort erkennbar ist, worauf der Balken
// beruht. Wie bei den Vorlagen ohne Zuständige und Abhängigkeiten — das
// wird danach im TODO-Dialog ergänzt.
window.applyHonorarPlan = function(){
if(app.isLocked()) return;
const startIso = (document.getElementById('honorarStart') || {}).value || '';
const startDate = parseDateISO(startIso);
if(!startDate){ alert('Bitte ein Startdatum wählen.'); return; }
const normieren = !!(document.getElementById('honorarNormieren') || {}).checked;
const { zeilen, problem } = berechneHonorarPlan(startDate, normieren);
if(problem){ alert(problem); return; }
const plan = app.ensurePlanLocal();
zeilen.forEach(z => {
plan.todos.push({
id: uid(),
title: `${z.lph} (${fmtNum(z.stunden, 0)} h)`,
start: formatDateISO(z.start),
end: formatDateISO(z.ende),
done: false,
progress: 0,
dependsOn: [],
assignees: [],
});
});
app.savePlan();
closeHonorarModal();
fitPlanGanttRange();
app.showToast(zeilen.length + ' Phase(n) als Terminplan eingefügt.');
};
})();
+199
View File
@@ -0,0 +1,199 @@
// -----------------------------
// Projektbereich — Projektangaben (Anzeige und Stammdaten)
// -----------------------------
// Dieses Modul füllt die Projektangaben des Projektbereichs. Es unterscheidet
// dabei zwei Arten von Angaben, und das ist der Kern der Sache:
//
// * NUR ANZEIGE — Kommentar der Sitzung (#planStatusEdit), Fortschritt je
// Leistungsphase (#planLphList) und die Zuordnung der Beteiligten
// (#planAssignMatrix). Diese drei entstehen in der wöchentlichen
// Besprechung und werden ausschließlich im Wochenbericht erfasst. Neben
// jeder von ihnen steht ein Verweis dorthin (setzeBerichtVerweis()), der
// hinüberwechselt UND dort dieses Projekt einstellt.
// * STAMMDATEN, hier pflegbar — Gruppe (#planGroupSelect) und amtierende
// Leistungsphase (#planActiveLpSelect), beide im Bereich "Einstellungen".
//
// Warum: Dieselben drei Angaben waren zuvor an DREI Stellen bearbeitbar —
// Projektboard, Wochenbericht und hier. Niemand wusste, welche davon die
// richtige ist, und zwei Personen konnten dasselbe Feld an verschiedenen
// Stellen ändern. Seitdem hat jede Angabe genau einen Ort, an dem sie
// entsteht, und überall sonst steht sie zum Lesen samt Weg dorthin. Die
// früheren Schreibfunktionen dieses Moduls (planStatusBlur(), setPlanLph(),
// setPlanAssign()) sind damit entfallen.
//
// BEWUSST NICHT hier: Projektnummer, Bezeichnung, Schlagwörter, Honorar und
// beauftragte Leistungsphasen. Sie sind ebenfalls Stammdaten, werden aber von
// js/plan/plan-settings.js gerendert (Honorar und beauftragte Phasen
// geschrieben von setHonorarBetrag()/toggleBeauftragteLp() aus
// js/plan/plan-honorar.js).
//
// renderPlanInfo() wird von
// renderPlanPage() (plan-state.js) nach jedem Laden aufgerufen; Änderungen
// speichern direkt über app.savePlan() und rendern danach nur das Infopanel
// neu (das GANTT bleibt unberührt).
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// PLAN_ACTIVE_LPS: Phasen, die als amtierende Leistungsphase wählbar sind —
// alle echten Leistungsphasen (inkl. LPH 9), aber ohne die Sammelkategorie
// "W+M". Entspricht AUFWAND_LPS in js/load/load.js, hier separat definiert,
// weil load.js auf der Projektplan-Seite bewusst nicht geladen wird (siehe
// Kopfkommentar in Test.html). Bewusst per Filter statt per
// Index-Schnitt, damit eine spätere Ergänzung von LPHS nicht stillschweigend
// durchs Raster fällt.
const PLAN_ACTIVE_LPS = LPHS.filter(l => l !== 'W+M');
// planLpActiveList: liefert die (auf gültige Phasen gefilterte) amtierende
// Leistungsphase eines Projekts als Array — Kopie von lpActiveList() in
// js/load/load.js, das auf dieser Seite nicht geladen wird. Parameter: p
// (Projekt). Rückgabe: Array (leer, wenn keine gesetzt).
function planLpActiveList(p){
const a = p && p.aktuelleLP;
if(Array.isArray(a)) return a.filter(l => PLAN_ACTIVE_LPS.includes(l));
if(typeof a === 'string' && PLAN_ACTIVE_LPS.includes(a)) return [a];
return [];
}
// renderPlanInfo: baut das Projektinfo-Panel (Status-Editor, Gruppe,
// 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.
window.renderPlanInfo = function(){
const p = app.project;
const locked = app.isLocked();
// Kommentar, Fortschritt und Zuordnung sind hier NUR ZU SEHEN.
//
// Sie entstehen in der wöchentlichen Besprechung und werden deshalb im
// Wochenbericht gepflegt — an genau einer Stelle. Zuvor waren dieselben
// drei Angaben an drei Orten bearbeitbar (Projektboard, Wochenbericht,
// Projektbereich); niemand wusste, welcher der richtige ist, und zwei
// Personen konnten dasselbe Feld an verschiedenen Stellen pflegen.
// Was hier bleibt, sind die Stammdaten des Projekts (Bereich
// "Einstellungen", siehe plan-settings.js).
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>';
}
const statusWrap = document.getElementById('planStatusWrap');
if(statusWrap) statusWrap.classList.add('locked-cell');
setzeBerichtVerweis('planStatusVerweis', locked);
// Gruppe — Stammdatum, bleibt hier pflegbar
const groupSel = document.getElementById('planGroupSelect');
if(groupSel){
groupSel.value = p.group || 'Allgemein';
groupSel.disabled = locked;
}
// Amtierende Leistungsphase — Stammdatum, bleibt hier pflegbar
const aktLp = planLpActiveList(p)[0] || '';
const lpSel = document.getElementById('planActiveLpSelect');
if(lpSel){
lpSel.innerHTML = '<option value="">Keine</option>' + PLAN_ACTIVE_LPS.map(l => `<option value="${l}" ${l===aktLp?'selected':''}>${l}</option>`).join('');
lpSel.disabled = locked;
}
// Fortschritt je Leistungsphase — nur Anzeige, ohne Eingabefeld
const lphList = document.getElementById('planLphList');
if(lphList){
if(!p.lph) p.lph = {};
lphList.innerHTML = LPHS.map(l => {
const v = (p.lph[l] !== undefined && p.lph[l] !== '') ? p.lph[l] : '';
const aktiv = l === aktLp;
const pct = v !== '' ? Math.min(100, parseFloat(v)||0) : 0;
return `<div class="board-lph-row is-ansicht${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Amtierende Leistungsphase"' : ''}>
<span class="board-lph-label">${esc(l)}</span>
<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>
<span class="board-lph-wert">${v === '' ? '–' : esc(v) + ' %'}</span>
</div>`;
}).join('');
}
setzeBerichtVerweis('planLphVerweis', locked);
// Beteiligte — nur Anzeige. Nicht zugeordnete Personen erscheinen
// weiterhin blass, damit erkennbar bleibt, wer überhaupt zur Auswahl
// stünde; anklickbar sind sie hier aber nicht mehr.
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 title = (entry.person.fullName || entry.person.name) + ' · ' + r;
return `<span class="board-avatar ${app.planRoleClass(r)} state-${state} is-ansicht" 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('');
}
setzeBerichtVerweis('planAssignVerweis', locked);
};
// setzeBerichtVerweis: füllt einen Hinweis-Platz mit dem Weg zur
// Bearbeitung. Der Verweis ist ein echter Sprung: Er wechselt in den
// Wochenbericht UND wählt dort dieses Projekt aus — ohne das müsste man es
// in einer Liste von dreißig wiederfinden.
// Parameter: elementId, locked. Keine Rückgabe.
function setzeBerichtVerweis(elementId, locked){
const el = document.getElementById(elementId);
if(!el) return;
if(locked){
el.innerHTML = '<span class="hint" style="margin:0">Die Sitzung ist gesperrt.</span>';
return;
}
const uid = esc(app.project && app.project.projectUid ? app.project.projectUid : '');
el.innerHTML = `<button type="button" class="plan-verweis" onclick="springZumWochenbericht('${uid}')">
Im Wochenbericht bearbeiten →
</button>`;
}
// springZumWochenbericht: wechselt in den Wochenbericht und stellt dort
// dieses Projekt ein. Beides zusammen, weil ein Sprung, der nur den Bereich
// wechselt, die eigentliche Arbeit — das Projekt wiederfinden — beim
// Nutzer ließe. Parameter: uid (project_uid). Keine Rückgabe.
window.springZumWochenbericht = function(uid){
if(typeof showTab !== 'function') return;
// Erst die Auswahl setzen, dann umschalten: showTab('wochenbericht')
// rendert den Bereich bereits, und der soll gleich das richtige Projekt
// zeigen — sonst blitzte kurz das erste Projekt der Liste auf.
// berichtAuswahl ist eine globale let-Bindung aus
// js/wochenbericht/wochenbericht.js (klassische Skripte, kein Modul).
if(uid) berichtAuswahl = uid;
showTab('wochenbericht');
};
// setPlanGroup: ändert die Gruppe des Projekts. Parameter: val (String).
// Keine Rückgabe; bei gesperrter Woche abgebrochen.
window.setPlanGroup = function(val){
if(app.isLocked()) return;
app.project.group = val;
app.savePlan();
};
// setPlanActiveLp: setzt bzw. hebt die amtierende Leistungsphase des
// 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(val) app.project.aktuelleLP = [val];
else delete app.project.aktuelleLP;
app.savePlan();
window.renderPlanInfo();
};
// Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind
// entfallen. Fortschritt, Zuordnung und Kommentar werden allein im
// Wochenbericht erfasst (siehe den Kopf dieser Datei); hier gab es damit
// keinen Aufrufer mehr. Die gleichnamigen Funktionen des Projektboards
// (editLph()/cycleAssign()/saveBoardComment() in js/board/board.js) sind
// davon unberührt — dort hängt die Erfassung des Wochenberichts dran.
})();
+284
View File
@@ -0,0 +1,284 @@
// -----------------------------
// Projektbereich — Leistungsstand & Abrechnung
// -----------------------------
// Die vier Zahlen dieser Rechnung sind alle längst gepflegt, standen aber an
// vier verschiedenen Stellen und wurden nirgends zusammengeführt:
//
// * p.honorar — vereinbartes Netto-Honorar (Bereich
// "Einstellungen" bzw. Honorar-Dialog)
// * p.beauftragteLP — welche Leistungsphasen beauftragt sind
// * lph_honorar_anteile — firmenweite HOAI-Anteile je Phase in Prozent
// (GET /honorar-config, Pflege im Admin-Bereich)
// * p.lph[<Phase>] — Fortschritt der Phase in Prozent
// (Prozentliste im Dashboard, plan-info.js)
//
// Daraus ergibt sich der ERBRACHTE LEISTUNGSWERT, also der Teil des Honorars,
// der bereits abgearbeitet ist:
//
// Wert je Phase = Honorar × Anteil der Phase ÷ 100 × Fortschritt ÷ 100
// Erbracht = Summe über alle beauftragten Phasen
//
// NORMIERUNG — bewusst identisch zu berechneHonorarPlan() in
// js/plan/plan-honorar.js: Sind nur einzelne Phasen beauftragt (etwa LPH 1, 4
// und 7 mit zusammen 9 %), dann ist das vereinbarte Honorar das Honorar für
// GENAU DIESE Phasen. Ungenormt käme ein zu 100 % abgearbeitetes Projekt nur
// auf 9 % Leistungsstand — die restlichen 91 % gehören zu Phasen, die nie
// beauftragt waren. Die Anteile werden deshalb auf die beauftragten Phasen
// hochskaliert (jeder Anteil ÷ Summe der beauftragten Anteile × 100), genau
// wie es der Honorar-Dialog in seiner Voreinstellung tut. Rechneten die beiden
// Masken hier unterschiedlich, widersprächen sich Terminplan und Abrechnung
// für dasselbe Projekt.
//
// Diese Anzeige RECHNET nur; sie speichert nichts und legt keine Daten an.
// Geändert wird das Honorar im Bereich "Einstellungen" (plan-settings.js) oder
// im Honorar-Dialog (plan-honorar.js), die Fortschritte in der
// Prozentliste des Dashboards (plan-info.js).
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// honorarConfig: zuletzt geladene firmenweite Konfiguration
// ({ anteile: {LPH->%} }); null = noch nicht geladen. Eigener
// Zwischenspeicher, weil der des Honorar-Dialogs (plan-honorar.js) modulweit
// privat ist — beide holen dieselbe Antwort, aber je einmal je Seitenaufruf.
let honorarConfig = null;
// ladeLaeuft / ladeFehler: verhindern, dass jeder Dashboard-Aufbau eine
// weitere Anfrage auslöst. Ohne das Fehler-Merkmal würde ein ausgefallener
// Server bei jedem Bereichswechsel erneut angefragt.
let ladeLaeuft = false;
let ladeFehler = false;
// fmtEur / fmtNum: deutsche Zahlenformate, gleiche Schreibweise wie in der
// Tabelle des Honorar-Dialogs, damit dieselbe Zahl in beiden Masken gleich
// aussieht.
function fmtEur(v){ return (Math.round(v * 100) / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; }
function fmtNum(v, digits){ return Number(v).toLocaleString('de-DE', { minimumFractionDigits: digits || 0, maximumFractionDigits: digits || 0 }); }
// beauftragteLphs: die beauftragten Leistungsphasen in der Reihenfolge von
// LPHS (js/globals.js), nicht in Eingabereihenfolge — die Aufschlüsselung
// soll immer chronologisch aufsteigen. Rückgabe: Array von LPH-Schlüsseln.
function beauftragteLphs(){
const set = new Set(Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : []);
return LPHS.filter(l => set.has(l));
}
// fortschrittProzent: liest den Fortschritt einer Phase aus p.lph. Die Werte
// stammen aus einem Textfeld (setPlanLph() in plan-info.js speichert den
// getippten String), kommen also auch mal als "80 %" oder "80,5" an; ein
// fehlender Eintrag bedeutet "noch nicht begonnen", nicht "unbekannt".
// Über 100 wird abgeschnitten, damit ein Tippfehler nicht mehr Honorar
// erbracht erscheinen lässt als vereinbart. Rückgabe: Number 0–100.
function fortschrittProzent(lph){
const lphWerte = app.project.lph || {};
const roh = lphWerte[lph];
if(roh === undefined || roh === null || roh === '') return 0;
const v = parseFloat(String(roh).replace('%', '').replace(',', '.'));
if(!Number.isFinite(v) || v <= 0) return 0;
return Math.min(100, v);
}
// berechneLeistungsstand: führt die Rechnung aus und liefert je beauftragter
// Leistungsphase eine Zeile mit allen Zwischenwerten — jede Stufe steht
// später als Tabellenspalte in der Maske, damit die Summe nachvollziehbar
// bleibt und nicht aus einer Blackbox kommt.
// Keine Parameter (rechnet auf app.project und honorarConfig).
// Rückgabe: { honorar, zeilen, erbracht, prozent, offen, summeBeauftragt,
// problem: String|null }.
function berechneLeistungsstand(){
const leer = { honorar: 0, zeilen: [], erbracht: 0, prozent: 0, offen: 0, summeBeauftragt: 0 };
const honorar = Number(app.project.honorar) || 0;
const phasen = beauftragteLphs();
if(honorar <= 0){
return Object.assign({}, leer, { problem: 'Für dieses Projekt ist kein Honorar hinterlegt — der erbrachte Leistungswert lässt sich daraus nicht errechnen. Das vereinbarte Netto-Honorar steht im Bereich „Einstellungen“.' });
}
if(phasen.length === 0){
return Object.assign({}, leer, { honorar, problem: 'Für dieses Projekt ist keine Leistungsphase als beauftragt angekreuzt. Ohne beauftragte Phasen ist nicht bestimmt, welcher Anteil des Honorars worauf entfällt. Die Auswahl steht im Bereich „Einstellungen“.' });
}
const anteile = (honorarConfig && honorarConfig.anteile) || {};
const summeBeauftragt = phasen.reduce((s, l) => s + (Number(anteile[l]) || 0), 0);
if(summeBeauftragt <= 0){
return Object.assign({}, leer, { honorar, problem: 'Für die beauftragten Leistungsphasen sind keine Honorar-Anteile hinterlegt. Ein Administrator pflegt sie im Admin-Bereich unter „Honorar“.' });
}
let erbracht = 0;
const zeilen = phasen.map(lph => {
const rohAnteil = Number(anteile[lph]) || 0;
// Normierung auf die beauftragten Phasen — siehe Kopfkommentar.
const anteil = rohAnteil / summeBeauftragt * 100;
const phasenHonorar = honorar * anteil / 100;
const fortschritt = fortschrittProzent(lph);
const wert = phasenHonorar * fortschritt / 100;
erbracht += wert;
return { lph, rohAnteil, anteil, phasenHonorar, fortschritt, wert };
});
return {
honorar,
zeilen,
erbracht,
prozent: honorar > 0 ? erbracht / honorar * 100 : 0,
offen: honorar - erbracht,
summeBeauftragt,
problem: null,
};
}
// ladeHonorarConfig: holt die firmenweiten Anteile und rendert danach neu.
// Nur die Anteile werden gebraucht — der Stundensatz gehört zur
// Terminplan-Rechnung, nicht zur Abrechnung.
function ladeHonorarConfig(){
ladeLaeuft = true;
fetch(baseUrl() + '/honorar-config')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
honorarConfig = { anteile: data.anteile || {} };
ladeFehler = false;
})
.catch(err => {
console.error('Honorar-Anteile für den Leistungsstand laden fehlgeschlagen:', err);
ladeFehler = true;
})
.then(() => {
ladeLaeuft = false;
window.renderPlanLeistung();
});
}
// retryLeistungConfig: erneuter Versuch nach einem Ladefehler. Ohne diese
// Schaltfläche bliebe die Karte bis zum nächsten Seitenaufbau leer, denn
// ladeFehler unterdrückt absichtlich jede automatische Wiederholung.
window.retryLeistungConfig = function(){
ladeFehler = false;
window.renderPlanLeistung();
};
// schreibeInZiele: legt dieselbe Maske in JEDES Anzeigefeld, das sie haben
// will — das ist #planLeistungWrap (die Karte im Dashboard) und jedes
// Element mit der Klasse .plan-leistung-wrap. Absichtlich nicht auf eine
// einzige feste ID festgelegt: ob der Leistungsstand als Karte im Dashboard
// oder als eigener Unterbereich der .plan-nav steht, ist eine Frage des
// Markups, und diese Rechnung soll dafür nicht angefasst werden müssen.
// Parameter: html (String). Rückgabe: Anzahl der befüllten Felder.
function schreibeInZiele(html){
const ziele = document.querySelectorAll('#planLeistungWrap, .plan-leistung-wrap');
ziele.forEach(el => { el.innerHTML = html; });
return ziele.length;
}
// renderPlanLeistung: baut die Maske "Leistungsstand & Abrechnung" neu auf.
// Fehlt eine der Voraussetzungen (Honorar, beauftragte Phasen, Anteile),
// steht dort ein Hinweis, WAS fehlt — eine Summe von 0 € wäre eine
// Falschaussage, weil unbekannt nicht dasselbe ist wie nichts erbracht.
// Keine Parameter, keine Rückgabe.
window.renderPlanLeistung = function(){
if(!app.project) return;
if(document.querySelectorAll('#planLeistungWrap, .plan-leistung-wrap').length === 0) return;
if(ladeFehler){
schreibeInZiele('<div class="hint">Die firmenweiten Honorar-Anteile konnten nicht geladen werden. '
+ '<button type="button" class="secondary" onclick="retryLeistungConfig()">Erneut versuchen</button></div>');
return;
}
if(!honorarConfig){
schreibeInZiele('<div class="hint">Honorar-Anteile werden geladen…</div>');
if(!ladeLaeuft) ladeHonorarConfig();
return;
}
const s = berechneLeistungsstand();
if(s.problem){
schreibeInZiele('<div class="leistung-leer">' + esc(s.problem) + '</div>');
return;
}
const rows = s.zeilen.map(z => `<tr>
<th class="lp-lph">${esc(z.lph)}</th>
<td class="num">${esc(fmtNum(z.anteil, 1))} %</td>
<td class="num">${esc(fmtEur(z.phasenHonorar))}</td>
<td class="num">${esc(fmtNum(z.fortschritt, 0))} %</td>
<td class="num strong">${esc(fmtEur(z.wert))}</td>
</tr>`).join('');
// Der Balken zeigt denselben Prozentwert wie die Kennzahl daneben; der
// Anschlag bei 100 % ist nur Schutz gegen Rundungsreste.
const balken = Math.max(0, Math.min(100, s.prozent));
schreibeInZiele(`<div class="leistung-kpis">
<div class="leistung-kpi">
<span class="leistung-kpi-label">Erbrachte Leistung</span>
<span class="leistung-kpi-value accent">${esc(fmtEur(s.erbracht))}</span>
<span class="leistung-kpi-sub">${esc(fmtNum(s.prozent, 1))} % des Honorars</span>
</div>
<div class="leistung-kpi">
<span class="leistung-kpi-label">Noch nicht erbracht</span>
<span class="leistung-kpi-value">${esc(fmtEur(s.offen))}</span>
<span class="leistung-kpi-sub">${esc(fmtNum(100 - s.prozent, 1))} % des Honorars</span>
</div>
<div class="leistung-kpi">
<span class="leistung-kpi-label">Honorar (netto)</span>
<span class="leistung-kpi-value">${esc(fmtEur(s.honorar))}</span>
<span class="leistung-kpi-sub">${esc(fmtNum(s.zeilen.length, 0))} beauftragte Leistungsphase${s.zeilen.length === 1 ? '' : 'n'}</span>
</div>
</div>
<div class="leistung-balken" role="img"
aria-label="Erbrachte Leistung ${esc(fmtNum(s.prozent, 1))} Prozent des Honorars">
<span style="width:${balken.toFixed(2)}%"></span>
</div>
<table class="lp-matrix leistung-table">
<thead><tr>
<th>Phase</th><th class="num">Anteil</th><th class="num">Phasenhonorar</th>
<th class="num">Fortschritt</th><th class="num">Erbracht</th>
</tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr>
<th class="lp-lph">Summe</th>
<td class="num">${esc(fmtNum(s.zeilen.reduce((a, z) => a + z.anteil, 0), 1))} %</td>
<td class="num">${esc(fmtEur(s.honorar))}</td>
<td class="num">${esc(fmtNum(s.prozent, 1))} %</td>
<td class="num strong">${esc(fmtEur(s.erbracht))}</td>
</tr></tfoot>
</table>
<div class="hint">Anteile auf die ${esc(fmtNum(s.zeilen.length, 0))} beauftragten Phasen normiert
(Rohsumme der HOAI-Anteile ${esc(fmtNum(s.summeBeauftragt, 1))} % → 100 %), wie in der
Voreinstellung von „Terminplan aus Honorar“. Fortschritt ohne Eintrag zählt als 0 %.
Netto-Werte, keine Rechnungsstellung.</div>`);
};
// Auslöser: renderPlanDashboard() (plan-sections.js) baut die übrigen Karten
// desselben Bereichs. Hier umgehängt statt dort hineingeschrieben, damit
// dieses Modul für sich steht — und weil derselbe Aufruf alle Wege abdeckt:
// Seitenaufbau (renderPlanPage), Wechsel in den Dashboard-Bereich
// (showPlanSection) und jede Änderung am Plan (renderPlanGantt ruft am Ende
// renderPlanDashboard). Honorar und beauftragte Phasen werden in einem
// ANDEREN Bereich bearbeitet; sichtbar wird die Karte also ohnehin erst
// beim Rücksprung ins Dashboard — und der rendert neu. Ein zusätzlicher
// ausdrücklicher Aufruf (etwa für einen eigenen Unterbereich "leistung")
// stört nicht: renderPlanLeistung() rechnet nur und schreibt Markup, ein
// doppelter Aufruf ergibt genau dasselbe Bild.
const originalRenderPlanDashboard = window.renderPlanDashboard;
window.renderPlanDashboard = function(){
if(typeof originalRenderPlanDashboard === 'function') originalRenderPlanDashboard.apply(this, arguments);
window.renderPlanLeistung();
};
// Ändert ein Administrator die Anteile, verwirft plan-honorar.js seinen
// Zwischenspeicher über invalidateHonorarConfig(). Derselbe Ruf muss auch
// hier ankommen, sonst rechnet diese Karte den Rest der Sitzung mit den
// alten Anteilen weiter.
const originalInvalidate = window.invalidateHonorarConfig;
window.invalidateHonorarConfig = function(){
if(typeof originalInvalidate === 'function') originalInvalidate.apply(this, arguments);
honorarConfig = null;
ladeFehler = false;
};
// Für Nachbarmodule und zum Nachrechnen von außen erreichbar halten — die
// Zahl gehört fachlich zum Projekt, nicht zu dieser einen Karte.
app.berechneLeistungsstand = berechneLeistungsstand;
})();
+494 -100
View File
@@ -3,12 +3,23 @@
// -----------------------------
// Dieses Modul stellt die Öffnen-/Schließen-/Speichern-/Löschen-Funktionen
// der drei Dialoge der Projektplan-Seite bereit: Meilenstein, TODO und
// Abwesenheiten. Der Bearbeitungszustand liegt in window.PlanApp
// Abwesenheiten. Der TODO-Dialog fasst "Erledigt" und Fortschritt in einem
// Status-Segment zusammen (offen/arbeit/fertig, siehe todoStatusFrom() —
// gespeichert wird unverändert done + progress), zeigt die Zuständigen als
// Personen-Chips, klappt die Vorgänger-Liste mit Anzahl und Suche ein und
// sperrt darin Einträge, die einen Zirkelbezug erzeugen würden
// (cyclicDependencyIds()). Validierungshinweise stehen am Feld statt in einem
// alert(). Esc, Strg/Cmd+Enter und der Klick auf den Hintergrund sind am Ende
// der Datei einmal für alle drei Dialoge registriert (MODAL_HANDLERS). Der Bearbeitungszustand liegt in window.PlanApp
// (plan-state.js): currentMilestoneIndex, currentTodoIndex und
// currentAbsencePersonId. Alle Änderungen werden über app.savePlan bzw.
// app.saveGlobalPeople gespeichert und das GANTT über renderPlanGantt
// (plan-gantt.js) neu gezeichnet. Bei gesperrten Wochen (app.isLocked) sind
// alle Bearbeitungsfunktionen deaktiviert.
// currentAbsencePersonId. Meilenstein-/TODO-Änderungen werden über
// app.savePlan gespeichert; Abwesenheiten haben seit der Ablösung von
// people/person_roles/person_absences keinen /save-people-Schreibpfad mehr
// und werden direkt gegen POST/DELETE /users/:id/absences geschrieben (die
// numerische User-ID kommt aus app.getUserIdForPersonId(), siehe
// plan-state.js). Das GANTT wird danach über renderPlanGantt() (plan-gantt.js)
// neu gezeichnet. Bei gesperrten Wochen (app.isLocked) sind alle
// Bearbeitungsfunktionen deaktiviert.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
@@ -33,7 +44,12 @@
const isNew = index === null || plan.milestones[index] === undefined;
const currentMs = isNew ? null : plan.milestones[index];
document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || '');
document.getElementById('milestoneEditDate').value = isNew ? '' : (currentMs.date || '');
// Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) —
// dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite.
const msDateIso = isNew ? '' : (currentMs.date || '');
document.getElementById('milestoneEditDate').value = msDateIso;
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
const positionSelect = document.getElementById('milestoneEditPosition');
if(positionSelect){
@@ -60,13 +76,19 @@
let listHtml = '';
// Andere Meilensteine als Vorgänger anbieten
if(plan.milestones.length > 1){
// Zirkelbezüge sperren — dieselbe Prüfung wie im Aufgaben-Dialog.
// Ohne sie ließ sich hier ein Kreis schließen (A hängt an M, M an A),
// woraufhin Auto-Schedule und kritischer Pfad dauerhaft aufgaben.
const msSelfId = currentMs ? 'm_' + currentMs.id : null;
const msBlocked = msSelfId ? cyclicDependencyIds(plan, msSelfId) : new Set();
if(plan.milestones.length >= 1){
listHtml += '<div class="todo-depends-group"><b>Meilensteine</b></div>';
plan.milestones.forEach(m => {
if(currentMs && m.id === currentMs.id) return;
const mId = 'm_' + m.id;
const checked = currentDeps.includes(mId) ? 'checked' : '';
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(mId)}" ${checked}> 🚩 ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
const sperre = msBlocked.has(mId) ? ' disabled title="Würde einen Zirkelbezug erzeugen"' : '';
listHtml += `<label class="todo-depends-item${msBlocked.has(mId) ? ' is-blocked' : ''}"><input type="checkbox" value="${esc(mId)}" ${checked}${sperre}> ${icon('flag')} ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
});
}
@@ -76,7 +98,8 @@
(plan.todos || []).forEach(t => {
const checked = currentDeps.includes(t.id) ? 'checked' : '';
const datesStr = (t.start || t.end) ? ` (${formatDateDE(parseDateISO(t.start))}–${formatDateDE(parseDateISO(t.end))})` : '';
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(t.id)}" ${checked}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
const sperre = msBlocked.has(t.id) ? ' disabled title="Würde einen Zirkelbezug erzeugen"' : '';
listHtml += `<label class="todo-depends-item${msBlocked.has(t.id) ? ' is-blocked' : ''}"><input type="checkbox" value="${esc(t.id)}" ${checked}${sperre}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
});
}
if(listHtml === '') listHtml = '<span class="hint">Keine anderen Aufgaben oder Meilensteine vorhanden.</span>';
@@ -153,11 +176,195 @@
// ---------- TODO-Modal ----------
// TODO_STATUS_VALUES: die drei Zustände des Status-Segments. Sie ersetzen im
// Dialog das frühere Paar aus "Erledigt"-Haken und Fortschrittsregler, das
// denselben Sachverhalt zweimal abgebildet hat (und dabei auseinanderlaufen
// konnte). Gespeichert wird unverändert das bisherige Datenmodell aus
// done (Boolean) + progress (0–100) — siehe todoStatusFrom()/applyTodoStatus().
const TODO_STATUS_VALUES = ['offen', 'arbeit', 'fertig'];
// todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für
// die Rückfrage beim Abbrechen (siehe todoFormSnapshot()).
let todoModalSnapshot = '';
// todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und
// "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit"
// wechselt, bekommt darüber seinen vorherigen Wert wieder statt eines
// Ersatzwerts. Gilt nur für den geöffneten Dialog.
let todoProgressMemo = 50;
// todoStatusFrom: leitet den Segment-Status aus dem gespeicherten TODO ab.
// done gewinnt immer; sonst entscheidet der Fortschritt (0 % = offen,
// 100 % = fertig, dazwischen = in Arbeit). Parameter: todo (Objekt oder
// null für eine neue Aufgabe). Rückgabe: String aus TODO_STATUS_VALUES.
function todoStatusFrom(todo){
if(!todo) return 'offen';
if(todo.done) return 'fertig';
const progress = parseInt(todo.progress, 10) || 0;
if(progress >= 100) return 'fertig';
return progress > 0 ? 'arbeit' : 'offen';
}
// applyTodoStatus: schreibt den Status in das Segment und blendet den
// Fortschrittsregler nur im Zustand "In Arbeit" ein (offen = 0 %,
// erledigt = 100 % sind implizit). Parameter: status (String),
// progress (optional, nur für "arbeit" relevant). Rückgabe: keine.
function applyTodoStatus(status, progress){
const segment = document.getElementById('todoEditStatus');
if(segment){
segment.querySelectorAll('button[data-status]').forEach(btn => {
btn.setAttribute('aria-pressed', String(btn.dataset.status === status));
});
}
const row = document.getElementById('todoEditProgressRow');
if(row) row.hidden = status !== 'arbeit';
const slider = document.getElementById('todoEditProgress');
const label = document.getElementById('todoEditProgressVal');
if(slider){
let value;
if(status === 'offen') value = 0;
else if(status === 'fertig') value = 100;
else {
value = progress === undefined ? todoProgressMemo : progress;
// "In Arbeit" mit 0 % oder 100 % wäre widersprüchlich
if(value <= 0) value = 50;
if(value >= 100) value = 95;
todoProgressMemo = value;
}
slider.value = value;
if(label) label.textContent = value + ' %';
}
}
// setTodoEditStatus: Klick-Handler des Status-Segments im TODO-Modal.
// Parameter: status (String aus TODO_STATUS_VALUES). Rückgabe: keine.
window.setTodoEditStatus = function(status){
if(!TODO_STATUS_VALUES.includes(status)) return;
applyTodoStatus(status);
};
// onTodoProgressInput: Eingabe-Handler des Fortschrittsreglers. Schreibt den
// Wert neben den Regler und merkt ihn für einen späteren Rücksprung auf
// "In Arbeit". Parameter: slider (das Regler-Element). Rückgabe: keine.
window.onTodoProgressInput = function(slider){
const value = parseInt(slider.value, 10) || 0;
todoProgressMemo = value;
const label = document.getElementById('todoEditProgressVal');
if(label) label.textContent = value + ' %';
};
// currentTodoEditStatus: liest den aktuell gewählten Segment-Status aus dem
// DOM. Parameter: keine. Rückgabe: String aus TODO_STATUS_VALUES.
function currentTodoEditStatus(){
const pressed = document.querySelector('#todoEditStatus button[aria-pressed="true"]');
return pressed ? pressed.dataset.status : 'offen';
}
// refreshTodoDuration: aktualisiert das Dauer-Badge neben dem Zeitraum-Feld
// (Arbeitstage Mo–Fr über workdaysBetween(), js/utils.js) und wird als
// onApply-Callback des Bereichspickers aufgerufen. Parameter: keine.
// Rückgabe: keine.
window.refreshTodoDuration = function(){
const badge = document.getElementById('todoEditDuration');
if(!badge) return;
const start = parseDateISO(document.getElementById('todoEditStart').value);
const end = parseDateISO(document.getElementById('todoEditEnd').value);
const days = (start && end) ? workdaysBetween(start, end) : 0;
badge.hidden = days <= 0;
badge.textContent = days > 0 ? (days + ' AT') : '';
clearFieldError('todoEditRange');
};
// setFieldError / clearFieldError: zeigen einen Validierungshinweis direkt
// am betroffenen Feld statt in einem alert(), damit die übrigen Eingaben
// sichtbar bleiben. Erwartet ein <span class="field-error" id="<feldId>Error">
// im Markup. Parameter: fieldId (String), message (String).
function setFieldError(fieldId, message){
const field = document.getElementById(fieldId);
const out = document.getElementById(fieldId + 'Error');
if(field) field.classList.add('is-error');
if(out){ out.textContent = message; out.hidden = false; }
}
function clearFieldError(fieldId){
const field = document.getElementById(fieldId);
const out = document.getElementById(fieldId + 'Error');
if(field) field.classList.remove('is-error');
if(out){ out.textContent = ''; out.hidden = true; }
}
// cyclicDependencyIds: liefert alle Knoten-IDs, die den angegebenen Knoten
// direkt oder über eine Kette als Vorgänger haben — genau diese dürfen
// nicht ihrerseits sein Vorgänger werden, weil daraus ein Zirkelbezug
// entstünde. Ermittelt per Breitensuche über den umgekehrten
// Abhängigkeitsgraphen (Kante: Vorgänger → Nachfolger). Zyklen, die schon
// in den Daten stecken, sind unproblematisch: besuchte Knoten werden nur
// einmal verfolgt. Meilenstein-IDs tragen wie überall das Präfix "m_".
// Parameter: plan (Plan-Objekt), nodeId (String oder null). Rückgabe: Set.
function cyclicDependencyIds(plan, nodeId){
const blocked = new Set();
if(!nodeId) return blocked;
// Nachfolger-Liste aufbauen: successors[Vorgänger] = [Nachfolger, …]
const successors = new Map();
const addEdge = (from, to) => {
if(!successors.has(from)) successors.set(from, []);
successors.get(from).push(to);
};
(plan.todos || []).forEach(t => {
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(dep => addEdge(dep, t.id));
});
(plan.milestones || []).forEach(m => {
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(dep => addEdge(dep, 'm_' + m.id));
});
const queue = [nodeId];
while(queue.length){
const current = queue.shift();
(successors.get(current) || []).forEach(next => {
if(blocked.has(next)) return;
blocked.add(next);
queue.push(next);
});
}
return blocked;
}
// refreshTodoDependsCount: schreibt die Anzahl gewählter Vorgänger in den
// Kopf des eingeklappten Bereichs, damit sie auch zugeklappt sichtbar ist.
// Parameter: keine. Rückgabe: keine.
window.refreshTodoDependsCount = function(){
const badge = document.getElementById('todoEditDependsCount');
const list = document.getElementById('todoEditDependsOn');
if(!badge || !list) return;
const count = list.querySelectorAll('input[type="checkbox"]:checked').length;
badge.textContent = count;
badge.classList.toggle('zero', count === 0);
};
// filterTodoDepends: blendet in der Vorgänger-Liste alle Einträge aus, die
// nicht zum Suchbegriff passen. Bei aktiver Suche verschwinden auch die
// Gruppenüberschriften, weil sie dann keine Gliederung mehr leisten.
// Parameter: keine (liest #todoEditDependsSearch). Rückgabe: keine.
window.filterTodoDepends = function(){
const input = document.getElementById('todoEditDependsSearch');
const list = document.getElementById('todoEditDependsOn');
if(!input || !list) return;
const query = input.value.trim().toLowerCase();
list.querySelectorAll('.todo-depends-item').forEach(item => {
const match = !query || item.textContent.toLowerCase().includes(query);
item.hidden = !match;
});
list.querySelectorAll('.todo-depends-group').forEach(group => { group.hidden = !!query; });
};
// openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe
// oder null für eine neue). Befüllt Titel, Start/Ende, Erledigt-Status,
// Fortschritt, Vorgänger-Checkboxen und die aus dem Projektboard
// stammenden Bearbeiter-Auswahl.
window.openTodoModal = function(index){
// oder null für eine neue), prefill (optional, Vorbelegung für "Speichern &
// Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
// Anlegen.
window.openTodoModal = function(index, prefill){
if(app.isLocked()) return;
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = [];
@@ -165,18 +372,41 @@
const isNew = index === null || plan.todos[index] === undefined;
const currentTodo = !isNew ? plan.todos[index] : null;
const pre = prefill || {};
clearFieldError('todoEditTitle');
clearFieldError('todoEditRange');
const titleEl = document.getElementById('todoModalTitle');
if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten';
document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || '');
document.getElementById('todoEditStart').value = isNew ? '' : (currentTodo.start || '');
document.getElementById('todoEditEnd').value = isNew ? '' : (currentTodo.end || '');
document.getElementById('todoEditDone').checked = isNew ? false : !!currentTodo.done;
const progress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
document.getElementById('todoEditProgress').value = progress;
document.getElementById('todoEditProgressVal').textContent = progress + '%';
// Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare
// Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der
// Zeitraum der zuvor geöffneten Aufgabe.
const start = isNew ? (pre.start || '') : (currentTodo.start || '');
const end = isNew ? (pre.end || '') : (currentTodo.end || '');
document.getElementById('todoEditStart').value = start;
document.getElementById('todoEditEnd').value = end;
const rangeDisplay = document.getElementById('todoEditRange');
if(rangeDisplay){
const from = formatDateDE(parseDateISO(start));
const to = formatDateDE(parseDateISO(end)) || from;
rangeDisplay.value = from ? (from + ' – ' + to) : '';
}
refreshTodoDuration();
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
applyTodoStatus(todoStatusFrom(currentTodo), storedProgress);
const dependsOnList = document.getElementById('todoEditDependsOn');
if(dependsOnList){
const currentDeps = currentTodo && Array.isArray(currentTodo.dependsOn) ? currentTodo.dependsOn : [];
// Vorgänger, die einen Zirkelbezug erzeugen würden, gar nicht erst
// anbieten — bisher fiel das erst beim Auto-Terminieren auf.
const blocked = cyclicDependencyIds(plan, currentTodo ? currentTodo.id : null);
let listHtml = '';
// Meilensteine als Vorgänger anbieten
@@ -185,28 +415,40 @@
plan.milestones.forEach(m => {
const mId = 'm_' + m.id;
const checked = currentDeps.includes(mId) ? 'checked' : '';
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(mId)}" ${checked}> 🚩 ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
listHtml += dependsItemHtml(mId, `${icon('flag')} ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})`, checked, blocked.has(mId));
});
}
// Andere TODOs als Vorgänger anbieten
if(plan.todos && plan.todos.length > 1){
// >= 1 statt > 1: Bei einer NEUEN Aufgabe wird aus der Liste nichts
// herausgefiltert, die einzige vorhandene Aufgabe war damit nicht als
// Vorgänger wählbar (erst ab der dritten funktionierte die Auswahl).
if(plan.todos && plan.todos.length >= 1){
listHtml += '<div class="todo-depends-group"><b>Aufgaben</b></div>';
(plan.todos || []).forEach(t => {
if(currentTodo && t.id === currentTodo.id) return;
const checked = currentDeps.includes(t.id) ? 'checked' : '';
const datesStr = (t.start || t.end) ? ` (${formatDateDE(parseDateISO(t.start))}–${formatDateDE(parseDateISO(t.end))})` : '';
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(t.id)}" ${checked}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
listHtml += dependsItemHtml(t.id, `${esc(t.title || 'TODO')}${datesStr}`, checked, blocked.has(t.id));
});
}
if(listHtml === '') listHtml = '<span class="hint">Keine anderen Aufgaben oder Meilensteine vorhanden.</span>';
dependsOnList.innerHTML = listHtml;
}
const searchInput = document.getElementById('todoEditDependsSearch');
if(searchInput) searchInput.value = '';
refreshTodoDependsCount();
// Bereich nur aufklappen, wenn es dort auch etwas zu sehen gibt
const fold = document.getElementById('todoEditDependsFold');
if(fold) fold.open = !!(currentTodo && Array.isArray(currentTodo.dependsOn) && currentTodo.dependsOn.length > 0);
const assigneesDiv = document.getElementById('todoEditAssignees');
if(assigneesDiv){
const assignees = projectAssignees(app.project, app.week);
const currentAssignees = currentTodo && Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [];
const currentAssignees = currentTodo
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
: (Array.isArray(pre.assignees) ? pre.assignees : []);
const seenPeople = new Set();
const uniquePeople = [];
assignees.forEach(a => {
@@ -215,28 +457,78 @@
uniquePeople.push(a.person);
}
});
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
// Personenspalten im Projektboard.
assigneesDiv.innerHTML = uniquePeople.map(person => {
const checked = currentAssignees.includes(person.id) ? 'checked' : '';
return `<label><input type="checkbox" value="${esc(person.id)}" ${checked}> ${esc(person.name)}</label>`;
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)">`
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
}
const deleteBtn = document.getElementById('todoEditDelete');
if(deleteBtn) deleteBtn.hidden = isNew;
document.getElementById('todoModal').style.display = 'flex';
todoModalSnapshot = todoFormSnapshot();
document.getElementById('todoEditTitle').focus();
};
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
// zurück.
window.closeTodoModal = function(){
document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null;
// dependsItemHtml: baut eine Zeile der Vorgänger-Liste. Gesperrte Einträge
// (Zirkelbezug) werden ausgegraut und deaktiviert statt ausgeblendet, damit
// nachvollziehbar bleibt, warum sie nicht wählbar sind. Parameter: value
// (Knoten-ID), label (bereits escaptes HTML), checked (String), blocked
// (Boolean). Rückgabe: HTML-String.
function dependsItemHtml(value, label, checked, blocked){
if(blocked){
return `<label class="todo-depends-item is-blocked" title="Würde einen Zirkelbezug erzeugen">`
+ `<input type="checkbox" value="${esc(value)}" disabled> ${label}`
+ `<span class="todo-depends-note">Zirkelbezug</span></label>`;
}
return `<label class="todo-depends-item"><input type="checkbox" value="${esc(value)}" ${checked}> ${label}</label>`;
}
// toggleTodoAssignee: schaltet einen Zuständigen-Chip um. Parameter: btn
// (das angeklickte Chip-Element). Rückgabe: keine.
window.toggleTodoAssignee = function(btn){
btn.setAttribute('aria-pressed', btn.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
};
// saveTodoEdit: übernimmt die Modal-Eingaben in den Plan – als neuer TODO
// oder Update des aktuell bearbeiteten. Fehlende End-/Startwerte werden
// als eintägige Aufgabe ergänzt, „Erledigt“ setzt den Fortschritt auf
// 100 %. Speichert und rendert neu.
window.saveTodoEdit = function(){
// todoFormSnapshot: friert die aktuellen Eingaben als Vergleichsstring ein.
// Dient allein der Rückfrage beim Abbrechen (Esc/Klick auf den Hintergrund);
// gespeichert wird ausschließlich über saveTodoEdit(). Parameter: keine.
// Rückgabe: String.
function todoFormSnapshot(){
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);
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
currentTodoEditStatus(), read('todoEditProgress'), deps, people]);
}
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
// zurück. Parameter: askIfDirty (optional, true = bei geänderten Eingaben
// zuerst nachfragen; wird von Esc und dem Klick auf den Hintergrund genutzt).
// Rückgabe: true, wenn tatsächlich geschlossen wurde.
window.closeTodoModal = function(askIfDirty){
if(askIfDirty && todoFormSnapshot() !== todoModalSnapshot){
if(!confirm('Die Änderungen an dieser Aufgabe verwerfen?')) return false;
}
document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null;
todoModalSnapshot = '';
return true;
};
// saveTodoEdit: übernimmt die Modal-Eingaben in den Plan – als neue Aufgabe
// oder Update der aktuell bearbeiteten. Fehlende End-/Startwerte werden als
// eintägige Aufgabe ergänzt; done/progress ergeben sich aus dem
// Status-Segment. Parameter: andNew (optional, true = nach dem Speichern
// gleich das Modal für die nächste Aufgabe öffnen, mit Zeitraum und
// Zuständigen als Vorbelegung). Rückgabe: keine.
window.saveTodoEdit = function(andNew){
if(app.isLocked()) return;
const title = document.getElementById('todoEditTitle').value.trim();
let start = document.getElementById('todoEditStart').value;
@@ -245,19 +537,31 @@
// sonst würde kein Balken im GANTT erscheinen.
if(start && !end) end = start;
if(end && !start) start = end;
const done = document.getElementById('todoEditDone').checked;
let progress = parseInt(document.getElementById('todoEditProgress').value, 10) || 0;
// "Erledigt" impliziert 100 % Fortschritt
if(done && progress < 100) progress = 100;
const status = currentTodoEditStatus();
const done = status === 'fertig';
let progress;
if(status === 'offen') progress = 0;
else if(status === 'fertig') progress = 100;
else progress = parseInt(document.getElementById('todoEditProgress').value, 10) || 0;
clearFieldError('todoEditTitle');
clearFieldError('todoEditRange');
if(!title){
setFieldError('todoEditTitle', 'Bitte einen Titel eingeben.');
document.getElementById('todoEditTitle').focus();
return;
}
if(start && end && parseDateISO(end) < parseDateISO(start)){
setFieldError('todoEditRange', 'Das Enddatum liegt vor dem Startdatum.');
return;
}
const dependsOnList = document.getElementById('todoEditDependsOn');
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
const assigneesDiv = document.getElementById('todoEditAssignees');
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('input:checked')).map(cb => cb.value) : [];
if(!title){ alert('Bitte einen Titel eingeben.'); return; }
if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [];
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = [];
@@ -275,8 +579,12 @@
t.assignees = assignees;
}
app.savePlan();
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
// die nächste Aufgabe mit denselben Rahmendaten öffnen.
todoModalSnapshot = todoFormSnapshot();
closeTodoModal();
renderPlanGantt();
if(andNew) openTodoModal(null, { start, end, assignees });
};
// deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe,
@@ -285,7 +593,7 @@
window.deleteTodoEdit = function(){
if(app.isLocked()) return;
if(app.currentTodoIndex === null){ closeTodoModal(); return; }
if(!confirm('TODO löschen?')) return;
if(!confirm('Aufgabe löschen?')) return;
const plan = app.ensurePlanLocal();
if(plan.todos && plan.todos[app.currentTodoIndex]){
const deletedId = plan.todos[app.currentTodoIndex].id;
@@ -297,30 +605,11 @@
});
app.savePlan();
}
todoModalSnapshot = todoFormSnapshot();
closeTodoModal();
renderPlanGantt();
};
// addTodoFromList: legt eine neue Aufgabe aus den Eingabefeldern der
// TODO-Liste an (Titel, Start/Ende), leert die Felder und rendert neu.
window.addTodoFromList = function(){
if(app.isLocked()) return;
const title = document.getElementById('newTodoTitle').value.trim();
let start = document.getElementById('newTodoStart').value;
let end = document.getElementById('newTodoEnd').value;
if(start && !end) end = start;
if(end && !start) start = end;
if(!title){ alert('Bitte einen Titel eingeben.'); return; }
if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
const plan = app.ensurePlanLocal();
plan.todos.push({ id: uid(), title, start, end, done: false, progress: 0, dependsOn: [], assignees: [] });
document.getElementById('newTodoTitle').value = '';
document.getElementById('newTodoStart').value = '';
document.getElementById('newTodoEnd').value = '';
app.savePlan();
renderPlanGantt();
};
// addTodo: öffnet das TODO-Modal für eine neue Aufgabe.
window.addTodo = function(){
openTodoModal(null);
@@ -419,11 +708,16 @@
}
// addPlanAbsence: legt aus den Formularfeldern eine neue Abwesenheit für die
// aktuelle Person an – in der globalen Personenliste und in der lokalen
// Sitzungs-Kopie. Speichert beides und rendert Liste sowie GANTT neu.
// aktuelle Person an. Schreibt direkt über POST /users/:id/absences (die
// numerische User-ID wird über app.getUserIdForPersonId() aufgelöst); hat
// die Person kein Login-Konto, wird eine klare Fehlermeldung gezeigt statt
// eines stillen Fehlschlags. Bei Erfolg werden globale Personenliste und
// lokale Sitzungs-Kopie mit der vom Server vergebenen ID aktualisiert, Liste
// und GANTT neu gerendert.
window.addPlanAbsence = function(){
if(app.isLocked()) return;
const person = app.findGlobalPerson(app.currentAbsencePersonId);
const personId = app.currentAbsencePersonId;
const person = app.findGlobalPerson(personId);
if(!person) return;
const start = document.getElementById('absenceEditStart').value;
const end = document.getElementById('absenceEditEnd').value;
@@ -431,43 +725,143 @@
const note = document.getElementById('absenceEditNote').value.trim();
if(!start || !end){ alert('Bitte Start- und Enddatum angeben.'); return; }
if(parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
if(!Array.isArray(person.absences)) person.absences = [];
const absence = { id: uid(), start, end, type, note };
person.absences.push(absence);
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
// Auch die Sitzungs-lokale Kopie aktualisieren, damit die Anzeige konsistent bleibt
const localPerson = (app.week.people || []).find(p => p.id === app.currentAbsencePersonId);
if(localPerson){
if(!Array.isArray(localPerson.absences)) localPerson.absences = [];
localPerson.absences.push({ ...absence });
localPerson.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
}
app.saveGlobalPeople();
app.savePlan();
document.getElementById('absenceEditStart').value = '';
document.getElementById('absenceEditEnd').value = '';
const rangeDisplay = document.getElementById('absenceEditRange');
if(rangeDisplay) rangeDisplay.value = '';
document.getElementById('absenceEditNote').value = '';
renderAbsenceModalList();
renderPlanGantt();
app.getUserIdForPersonId(personId).then(userId => {
if(!userId){
alert('Für diese Person existiert kein Benutzerkonto. Abwesenheiten können nur für Personen mit Benutzerkonto gepflegt werden (siehe Benutzerverwaltung).');
return;
}
return fetch(baseUrl() + '/users/' + userId + '/absences', {
method: 'POST',
headers: {'Content-Type':'application/json; charset=utf-8'},
body: JSON.stringify({ start, end, type, note })
}).then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
if(!ok || !json || !json.ok){
alert('Abwesenheit konnte nicht gespeichert werden: ' + ((json && json.error) || 'unbekannter Fehler'));
return;
}
if(!Array.isArray(person.absences)) person.absences = [];
person.absences.push(json.absence);
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
const localPerson = (app.week.people || []).find(p => p.id === personId);
if(localPerson){
if(!Array.isArray(localPerson.absences)) localPerson.absences = [];
localPerson.absences.push({ ...json.absence });
localPerson.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
}
document.getElementById('absenceEditStart').value = '';
document.getElementById('absenceEditEnd').value = '';
const rangeDisplay = document.getElementById('absenceEditRange');
if(rangeDisplay) rangeDisplay.value = '';
document.getElementById('absenceEditNote').value = '';
renderAbsenceModalList();
renderPlanGantt();
});
}).catch(err => {
console.error('Abwesenheit speichern fehlgeschlagen:', err);
alert('Abwesenheit konnte nicht gespeichert werden: ' + (err.message || err));
});
};
// deletePlanAbsence: entfernt die Abwesenheit mit der übergebenen ID aus
// der globalen Personenliste und der lokalen Sitzungs-Kopie, speichert
// beides und rendert Liste sowie GANTT neu.
// deletePlanAbsence: entfernt die Abwesenheit mit der übergebenen ID direkt
// über DELETE /users/:id/absences/:absenceId. Kein Benutzerkonto gefunden ->
// klare Fehlermeldung statt stillem Fehlschlag. Bei Erfolg werden globale
// Personenliste und lokale Sitzungs-Kopie aktualisiert, Liste und GANTT neu
// gerendert.
window.deletePlanAbsence = function(absenceId){
if(app.isLocked()) return;
const person = app.findGlobalPerson(app.currentAbsencePersonId);
const personId = app.currentAbsencePersonId;
const person = app.findGlobalPerson(personId);
if(!person || !Array.isArray(person.absences)) return;
person.absences = person.absences.filter(a => a.id !== absenceId);
const localPerson = (app.week.people || []).find(p => p.id === app.currentAbsencePersonId);
if(localPerson && Array.isArray(localPerson.absences)){
localPerson.absences = localPerson.absences.filter(a => a.id !== absenceId);
}
app.saveGlobalPeople();
app.savePlan();
renderAbsenceModalList();
renderPlanGantt();
app.getUserIdForPersonId(personId).then(userId => {
if(!userId){
alert('Für diese Person existiert kein Benutzerkonto. Abwesenheiten können nur für Personen mit Benutzerkonto gepflegt werden (siehe Benutzerverwaltung).');
return;
}
return fetch(baseUrl() + '/users/' + userId + '/absences/' + encodeURIComponent(absenceId), { method: 'DELETE' })
.then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
if(!ok || !json || !json.ok){
alert('Abwesenheit konnte nicht gelöscht werden: ' + ((json && json.error) || 'unbekannter Fehler'));
return;
}
person.absences = person.absences.filter(a => a.id !== absenceId);
const localPerson = (app.week.people || []).find(p => p.id === personId);
if(localPerson && Array.isArray(localPerson.absences)){
localPerson.absences = localPerson.absences.filter(a => a.id !== absenceId);
}
renderAbsenceModalList();
renderPlanGantt();
});
}).catch(err => {
console.error('Abwesenheit löschen fehlgeschlagen:', err);
alert('Abwesenheit konnte nicht gelöscht werden: ' + (err.message || err));
});
};
// ---------- Tastatur- und Hintergrundbedienung (alle drei Dialoge) ----------
// MODAL_HANDLERS: Zuordnung Dialog → Schließen/Speichern. Zentral registriert,
// damit Esc, Strg/Cmd+Enter und der Klick auf den Hintergrund in allen drei
// Dialogen dieser Seite gleich funktionieren und nicht je Dialog nachgebaut
// werden müssen. dirtyCheck = true: vor dem Schließen bei geänderten
// Eingaben nachfragen (bislang nur im TODO-Dialog umgesetzt).
const MODAL_HANDLERS = [
{ id: 'todoModal', close: askIfDirty => closeTodoModal(askIfDirty), save: () => saveTodoEdit() },
{ id: 'milestoneModal', close: () => closeMilestoneModal(), save: () => saveMilestoneEdit() },
{ id: 'absenceModal', close: () => closeAbsenceModal(), save: null }
];
// visibleModal: liefert den Eintrag aus MODAL_HANDLERS, dessen Dialog gerade
// sichtbar ist (es ist immer höchstens einer offen). Parameter: keine.
// Rückgabe: Eintrag oder null.
function visibleModal(){
for(const entry of MODAL_HANDLERS){
const el = document.getElementById(entry.id);
if(el && el.style.display !== 'none' && el.style.display !== '') return entry;
}
return null;
}
document.addEventListener('keydown', function(e){
// Der Datums-/Bereichspicker liegt über dem Dialog und bekommt Escape
// zuerst — sonst würde der ganze Dialog geschlossen, obwohl der Nutzer
// nur den Kalender wegklicken wollte.
if(e.key === 'Escape' && document.getElementById('rangePickerPopup')){
closeRangePicker();
e.preventDefault();
return;
}
const modal = visibleModal();
if(!modal) return;
if(e.key === 'Escape'){
modal.close(true);
e.preventDefault();
return;
}
// Speichern: Strg/Cmd+Enter überall, einfaches Enter nur in einzeiligen
// Textfeldern (in einem <textarea> wäre das ein Zeilenumbruch).
if(e.key === 'Enter' && modal.save){
const target = e.target;
// Das Suchfeld der Vorgängerliste ist ausgenommen: Enter ist dort der
// Reflex zum Filtern, schloss aber den ganzen Dialog — ohne dass ein
// Vorgänger angehakt war.
const isSearchField = target && target.id === 'todoEditDependsSearch';
const inTextInput = target && target.tagName === 'INPUT' && target.type === 'text' && !isSearchField;
if((e.metaKey || e.ctrlKey) || inTextInput){
modal.save();
e.preventDefault();
}
}
});
// Klick auf den abgedunkelten Hintergrund schließt den Dialog; Klicks
// innerhalb des Dialogs (e.target !== Hintergrund) bleiben unberührt.
MODAL_HANDLERS.forEach(entry => {
const el = document.getElementById(entry.id);
if(!el) return;
el.addEventListener('mousedown', function(e){
if(e.target === el) entry.close(true);
});
});
})();
+764
View File
@@ -0,0 +1,764 @@
// -----------------------------
// Projektplan — Protokolle (Besprechungsprotokolle je Projekt)
// -----------------------------
// Bereich "Protokolle" des Projektbereichs (siehe js/plan/plan-sections.js)
// plus eine Kompakt-Karte im Dashboard (#planProtokolleSummary, springt per
// Klick über openProtokollePanel() in diesen Bereich — gleiches Muster wie
// #planFilesSummary/openFilesPanel() in plan-files.js).
//
// Protokolle hängen wie Dateien NUR an project_nr (nicht an week_label/
// project_id, das sich bei jedem automatischen Sitzungswechsel ändert), sind
// also bei einem Projekt ohne Projektnummer nicht möglich — der Bereich zeigt
// dann nur einen Hinweis, wie bei Dateien.
//
// Das "Fortschreiben" (offene Punkte wandern automatisch mit gleicher Nummer
// ins nächste Protokoll) rechnet ausschließlich der Server beim Anlegen
// (POST /project-protokolle, server.js) — hier wird nur angezeigt/bearbeitet.
// Ob ein Punkt "neu" ist (gelb markiert), steht serverseitig fest (Feld
// `neu`, unabhängig von `nummer`/`gruppe`, die frei editierbare Anzeige-Texte
// sind, siehe CLAUDE.md "### Protokolle").
//
// Die Detailansicht ist bewusst KEIN Modal (ein Protokoll mit vielen Punkten
// ist zu umfangreich dafür), sondern ersetzt inline die Listenansicht
// innerhalb desselben Bereichs-Containers. Um dabei den Eingabefokus beim
// Tippen nicht zu verlieren, wird bei einfachen Textfeldern NICHT bei jedem
// Tastendruck neu gerendert — nur strukturelle Änderungen (Zeile hinzufügen/
// entfernen, Gruppe ändern) lösen ein Neu-Rendern aus.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// protokolleCache: die zuletzt geladene Listenübersicht (Zusammenfassungen,
// keine vollen Punkte/Teilnehmer — die liefert erst das Öffnen eines
// einzelnen Protokolls). currentProtokoll: das volle, gerade in der
// Detailansicht bearbeitete Protokoll, oder null in der Listenansicht.
let protokolleCache = [];
let currentProtokoll = null;
// ---------- Laden ----------
// initPlanProtokoll: wird unbedingt aus renderPlanPage() aufgerufen (wie
// initPlanFiles()), nicht erst beim Öffnen des Bereichs — sonst zeigte die
// Dashboard-Kompaktkarte veraltete Zahlen, bis man den Bereich einmal
// betreten hat.
window.initPlanProtokoll = function(){
const hasNr = !!(app.project && app.project.nr);
const hint = document.getElementById('protokolleNoProjectHint');
const container = document.getElementById('protokolleContainer');
if(hint) hint.style.display = hasNr ? 'none' : '';
if(container) container.style.display = hasNr ? '' : 'none';
currentProtokoll = null;
if(!hasNr){
protokolleCache = [];
renderProtokolleSummary();
return;
}
ladeProtokollListe();
};
// ladeProtokollListe: GET /project-protokolle?projectNr= — füllt
// protokolleCache und rendert Listenansicht + Dashboard-Kompaktkarte neu.
function ladeProtokollListe(){
const container = document.getElementById('protokolleContainer');
if(container && !currentProtokoll) container.innerHTML = '<span class="hint">Lade Protokolle…</span>';
fetch(baseUrl() + '/project-protokolle?projectNr=' + encodeURIComponent(app.project.nr))
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
protokolleCache = Array.isArray(data.protokolle) ? data.protokolle : [];
window.renderPlanProtokolle();
renderProtokolleSummary();
})
.catch(err => {
if(container) container.innerHTML = '<span class="hint">Protokolle konnten nicht geladen werden.</span>';
console.error('Protokollliste laden fehlgeschlagen:', err);
});
}
// renderProtokolleSummary: füllt die Kompakt-Vorschau der Dashboard-Karte
// (#planProtokolleSummary) — letztes Protokoll + Anzahl offener Punkte,
// gleiches Muster wie renderFilesSummary() in plan-files.js.
function renderProtokolleSummary(){
const el = document.getElementById('planProtokolleSummary');
if(!el) return;
if(!app.project || !app.project.nr){
el.innerHTML = '<span class="hint">Keine Projektnummer.</span>';
return;
}
if(protokolleCache.length === 0){
el.innerHTML = '<span class="hint">Noch keine Protokolle.</span>';
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>`;
}
// openProtokollePanel: springt in den Bereich "Protokolle" — Gegenstück zu
// openFilesPanel()/openResourceModal().
window.openProtokollePanel = function(){
if(typeof showPlanSection === 'function') showPlanSection('protokolle');
};
// ---------- Nummer-/Gruppen-Hilfsfunktionen ----------
// berechneProtokollNummern: errechnet die Anzeige-Nummer JEDES Punkts rein
// aus seiner Position — "1.1"/"1.2"/"1.3" für Punkte innerhalb einer Gruppe
// (die erste Ziffer ist die Gruppennummer, die zweite die laufende Position
// INNERHALB dieser Gruppe), schlichte "1"/"2"/"3" für Punkte ohne Gruppe.
// Gruppennummer UND die Nummer eines Punkts ohne Gruppe kommen aus EINEM
// gemeinsamen, fortlaufenden Zähler (nicht zwei getrennten) — sonst gäbe es
// z. B. sowohl einen Punkt "1" ohne Gruppe als auch eine Gruppe mit der
// Nummer "1" (Punkte "1.1", "1.2" …), zwei verschiedene Dinge mit
// derselben führenden Ziffer. Jede oberste Ebene (ob Einzelpunkt oder
// Gruppe) bekommt dadurch eine über das ganze Protokoll hinweg einmalige
// Zahl. Nie an einen gespeicherten Wert gebunden — ändert sich die
// Reihenfolge oder die Gruppe eines Punkts, verschiebt sich die Nummer
// automatisch mit, exakt wie bei einem klassischen Baubesprechungsprotokoll
// mit durchnummerierten Abschnitten. Global verfügbar, weil sowohl die
// Detailansicht hier als auch exportProtokollPDF() (js/pdf.js) dieselbe
// Nummerierung zeigen müssen — dasselbe Prinzip wie classifyProjectChange()
// aus wochenbericht.js, das pdf.js ebenfalls direkt aufruft.
// Parameter: punkte (Array in Anzeige-/array_order-Reihenfolge). Rückgabe:
// Array von Strings, indexgleich zu punkte.
window.berechneProtokollNummern = function(punkte){
const gruppenNr = new Map();
const positionInGruppe = new Map();
let naechsteTopLevelNr = 1;
return (punkte || []).map(p => {
const g = String(p.gruppe || '').trim();
if(!g) return String(naechsteTopLevelNr++);
if(!gruppenNr.has(g)){
gruppenNr.set(g, naechsteTopLevelNr++);
positionInGruppe.set(g, 0);
}
const pos = positionInGruppe.get(g) + 1;
positionInGruppe.set(g, pos);
return gruppenNr.get(g) + '.' + pos;
});
};
// gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order) —
// "Ohne Gruppe" (leerer String) immer zuerst, wenn vorhanden.
function gruppenReihenfolge(){
const namen = [];
let hatOhneGruppe = false;
(currentProtokoll.punkte || []).forEach(p => {
const g = String(p.gruppe || '').trim();
if(!g){ hatOhneGruppe = true; return; }
if(!namen.includes(g)) namen.push(g);
});
return (hatOhneGruppe ? [''] : []).concat(namen);
}
// ---------- Nachträgliches Umordnen ----------
//
// Die Reihenfolge in `currentProtokoll.punkte` (= array_order) bestimmt
// sowohl die Anzeige-Reihenfolge als auch — über berechneProtokollNummern()
// — die Nummern. Zwei getrennte, aufeinander abgestimmte Stellschrauben:
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe (bzw. innerhalb
// "Ohne Gruppe") verschieben (die Zeile steht dort ohnehin nur im passenden
// Abschnitt), eine ganze Gruppe lässt sich als Block gegenüber den anderen
// Gruppen verschieben — das ändert, welche Gruppe die Nummer "1" bekommt.
// "Ohne Gruppe" bleibt bewusst immer der erste Abschnitt (siehe
// gruppenReihenfolge()) und ist deshalb hier nicht mitverschiebbar.
// verschiebePunkt: vertauscht einen Punkt mit seinem Vorgänger/Nachfolger
// INNERHALB derselben Gruppe (bzw. "Ohne Gruppe"). Parameter: idx (Index in
// 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;
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);
const posInGruppe = gleicheGruppeIdx.indexOf(idx);
const zielPos = posInGruppe + richtung;
if(zielPos < 0 || zielPos >= gleicheGruppeIdx.length) return;
const zielIdx = gleicheGruppeIdx[zielPos];
const tmp = punkte[idx];
punkte[idx] = punkte[zielIdx];
punkte[zielIdx] = tmp;
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
// verschiebeGruppe: verschiebt eine ganze Gruppe (alle ihre Punkte als
// Block, ihre eigene Reihenfolge bleibt untereinander erhalten) gegenüber
// den anderen Gruppen — ändert damit auch die Gruppennummer. Baut die
// Punkteliste dafür komplett neu auf: "Ohne Gruppe" zuerst (unverändert),
// danach jede Gruppe in der neuen Reihenfolge. Parameter: gruppe (Name),
// richtung (-1 = nach vorn, +1 = nach hinten). Keine Rückgabe.
window.verschiebeGruppe = function(gruppe, richtung){
if(!currentProtokoll || app.isLocked()) return;
const punkte = currentProtokoll.punkte;
const namen = [];
punkte.forEach(p => {
const g = String(p.gruppe || '').trim();
if(g && !namen.includes(g)) namen.push(g);
});
const pos = namen.indexOf(gruppe);
const zielPos = pos + richtung;
if(pos === -1 || zielPos < 0 || zielPos >= namen.length) return;
const tmpName = namen[pos];
namen[pos] = namen[zielPos];
namen[zielPos] = tmpName;
const neu = punkte.filter(p => !String(p.gruppe || '').trim());
namen.forEach(g => {
punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p));
});
currentProtokoll.punkte = neu;
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
// ---------- Listenansicht ----------
function todoStatusZeile(offen, erledigt){
if(offen === 0 && erledigt === 0) return 'keine Punkte';
if(offen === 0) return 'alle erledigt';
return offen + ' offen · ' + erledigt + ' erledigt';
}
function renderProtokolleListe(){
const container = document.getElementById('protokolleContainer');
if(!container) return;
const locked = app.isLocked();
const anlegeBtns = locked ? '' : `<button onclick="neuesProtokoll()">+ Neues Protokoll</button>
<button class="secondary" onclick="oeffneProtokollImportDialog()">Protokoll importieren</button>`;
if(protokolleCache.length === 0){
container.innerHTML = `<div class="plan-panel">
<div class="protokoll-list-head"><h3 class="plan-panel-title" style="margin:0">Protokolle</h3><span class="toolbar">${anlegeBtns}</span></div>
<span class="hint">${locked ? 'Für diese Sitzung sind keine Protokolle hinterlegt.' : 'Noch keine Protokolle. Über „+ Neues Protokoll" anlegen oder ein bestehendes importieren.'}</span>
</div>`;
return;
}
const zeilen = protokolleCache.map(p => `
<div class="protokoll-row" onclick="oeffneProtokoll('${esc(p.id)}')">
<span class="protokoll-nr">Nr. ${esc(p.nummer)}</span>
<span class="protokoll-betreff">${esc(p.betreff || 'ohne Betreff')}<span class="protokoll-datum">${esc(formatDateDE(parseDateISO(p.datum)) || '')}${p.ort ? ' · ' + esc(p.ort) : ''}</span></span>
<span class="protokoll-verfasser">${esc(p.verfasserName || '')}</span>
<span class="protokoll-punkte-status${p.offenCount === 0 ? ' ist-leer' : ''}"><span class="protokoll-status-dot"></span>${esc(todoStatusZeile(p.offenCount, p.erledigtCount))}</span>
<span class="protokoll-teilnehmer-count">${p.teilnehmerCount} Teilnehmer</span>
</div>`).join('');
container.innerHTML = `<div class="plan-panel">
<div class="protokoll-list-head"><h3 class="plan-panel-title" style="margin:0">Protokolle</h3><span class="toolbar">${anlegeBtns}</span></div>
${zeilen}
</div>`;
}
// ---------- Anlegen ----------
// neuesProtokoll: legt ein neues Protokoll an. Vorbelegung der Teilnehmer
// (rein clientseitige Komfort-Logik vor dem POST, siehe CLAUDE.md): Namen
// aus dem LETZTEN vorherigen Protokoll übernehmen (Anwesenheit auf
// "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;
let teilnehmer = [];
try {
if(protokolleCache.length > 0){
const r = await fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(protokolleCache[0].id));
const d = await r.json();
if(r.ok && d.ok && d.protokoll){
teilnehmer = d.protokoll.teilnehmer.map(t => ({ name: t.name, firma: t.firma, anwesend: true }));
}
}
if(teilnehmer.length === 0){
const zugeordnet = projectAssignees(app.project, app.week);
const gesehen = new Set();
zugeordnet.forEach(a => {
if(gesehen.has(a.person.id)) return;
gesehen.add(a.person.id);
teilnehmer.push({ name: a.person.fullName || a.person.name, firma: '', anwesend: true });
});
}
} catch(err){
console.error('Teilnehmer-Vorbelegung fehlgeschlagen:', err);
}
try {
const url = baseUrl() + '/project-protokolle'
+ '?projectNr=' + encodeURIComponent(app.project.nr)
+ '&projectName=' + encodeURIComponent(app.project.name || '')
+ '&weekLabel=' + encodeURIComponent(app.week ? app.week.label : '');
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ datum: heuteIso(), betreff: '', ort: '', teilnehmer })
});
const d = await r.json();
if(!r.ok || !d.ok){ alert('Protokoll konnte nicht angelegt werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
currentProtokoll = d.protokoll;
renderProtokollDetail();
ladeProtokollListe();
} catch(err){
console.error('Protokoll anlegen fehlgeschlagen:', err);
alert('Protokoll konnte nicht angelegt werden: ' + (err.message || err));
}
};
// ---------- Import (externes Protokoll hochladen) ----------
// oeffneProtokollImportDialog: zeigt den Import-Dialog, Datum vorbelegt
// mit heute (dieselbe Vorbelegung wie neuesProtokoll()). Das Erkennungs-
// Auswahlfeld wird hier aus GET /my-ai-settings aufgebaut statt fest im
// Markup zu stehen — der Hinweis "(kein Schlüssel hinterlegt)" bezieht
// sich auf den EIGENEN Schlüssel des angemeldeten Kontos (siehe
// Einstellungen → Mein Konto → KI-Zugangsdaten), nicht mehr auf einen
// firmenweiten Stand, und kann sich seit dem letzten Öffnen geändert
// haben.
window.oeffneProtokollImportDialog = function(){
if(app.isLocked()) return;
document.getElementById('protokollImportFile').value = '';
document.getElementById('protokollImportBetreff').value = '';
document.getElementById('protokollImportOrt').value = '';
document.getElementById('protokollImportDatum').value = heuteIso();
document.getElementById('protokollImportDatumDisplay').value = formatDateDE(parseDateISO(heuteIso())) || '';
const erkennungSel = document.getElementById('protokollImportErkennung');
if(erkennungSel){
erkennungSel.innerHTML = `<option value="chatgpt">ChatGPT</option><option value="claude">Claude</option>`;
fetch(baseUrl() + '/my-ai-settings')
.then(r => r.json())
.then(d => {
if(!d.ok) return;
erkennungSel.innerHTML = `
<option value="chatgpt">ChatGPT${d.chatgpt && d.chatgpt.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>
<option value="claude">Claude${d.claude && d.claude.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>`;
})
.catch(err => console.error('KI-Einstellungen laden fehlgeschlagen:', err));
}
const errEl = document.getElementById('protokollImportError');
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
setzeProtokollImportLadezustand(false);
document.getElementById('protokollImportModal').style.display = 'flex';
};
window.schliesseProtokollImportDialog = function(){
document.getElementById('protokollImportModal').style.display = 'none';
};
// setzeProtokollImportLadezustand: blendet den Ladehinweis ein/aus und
// sperrt alle Felder + Knöpfe des Dialogs währenddessen — ohne das gab es
// während eines länger laufenden KI-Aufrufs (bis zu KI_TIMEOUT_MS_LOKAL =
// 5 Minuten bei einem eigenen Server, server.js) keinerlei Rückmeldung,
// der Dialog wirkte hängengeblieben, und ein zweiter Klick auf
// "Importieren" hätte denselben Import doppelt losgeschickt.
function setzeProtokollImportLadezustand(aktiv){
['protokollImportFile', 'protokollImportErkennung', 'protokollImportDatumDisplay',
'protokollImportBetreff', 'protokollImportOrt'].forEach(id => {
const el = document.getElementById(id);
if(el) el.disabled = aktiv;
});
const modal = document.getElementById('protokollImportModal');
if(modal) modal.querySelectorAll('.toolbar button').forEach(btn => { btn.disabled = aktiv; });
const ladeEl = document.getElementById('protokollImportLoading');
if(ladeEl) ladeEl.hidden = !aktiv;
}
// importiereProtokoll: liest die gewählte Datei + Kopfdaten, schickt die
// Datei als rohen Binär-Body an POST /project-protokolle/import (gleiches
// Vorgehen wie submitFileUpload() in js/plan/plan-files.js, andere
// Zielroute). Bei Erfolg landet der Nutzer direkt in der ganz normalen,
// voll editierbaren Detailansicht — die erkannten Punkte sind darin
// bereits sichtbar (Hinweisbanner in renderProtokollDetail(), siehe dort)
// und lassen sich dort wie jeder andere Punkt prüfen/korrigieren, bevor
// gespeichert wird.
window.importiereProtokoll = function(){
const errEl = document.getElementById('protokollImportError');
const zeigeFehler = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
const fileInput = document.getElementById('protokollImportFile');
const file = fileInput && fileInput.files && fileInput.files[0];
if(!file) return zeigeFehler('Bitte eine Datei auswählen.');
const datum = document.getElementById('protokollImportDatum').value;
if(!datum) return zeigeFehler('Bitte ein Datum wählen.');
const betreff = document.getElementById('protokollImportBetreff').value;
const ort = document.getElementById('protokollImportOrt').value;
const erkennungSel = document.getElementById('protokollImportErkennung');
const erkennung = erkennungSel ? erkennungSel.value : '';
const url = baseUrl() + '/project-protokolle/import'
+ '?projectNr=' + encodeURIComponent(app.project.nr)
+ '&projectName=' + encodeURIComponent(app.project.name || '')
+ '&weekLabel=' + encodeURIComponent(app.week ? app.week.label : '')
+ '&datum=' + encodeURIComponent(datum)
+ '&betreff=' + encodeURIComponent(betreff)
+ '&ort=' + encodeURIComponent(ort)
+ '&erkennung=' + encodeURIComponent(erkennung)
+ '&filename=' + encodeURIComponent(file.name);
setzeProtokollImportLadezustand(true);
fetch(url, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file
})
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(!ok || !d.ok) throw new Error((d && d.error) || 'Import fehlgeschlagen.');
schliesseProtokollImportDialog();
currentProtokoll = d.protokoll;
// Angeforderte KI nicht verfügbar gewesen -> automatisch auf
// Textmuster zurückgefallen (server.js, POST .../import) — rein
// clientseitiges, EINMALIGES Merkmal (nie vom Server geliefert,
// nie gespeichert), das renderProtokollDetail() in einen deutlich
// sichtbaren, bestehen bleibenden Hinweis übersetzt (nicht nur
// einen nach 2,5s verschwindenden Toast, der beim gleichzeitigen
// Wechsel in die Detailansicht leicht übersehen wird — genau das
// war Eriks Rückmeldung, nachdem die Erkennung bei ihm an LM
// Studio scheiterte und er davon nichts in der Oberfläche sah).
if(erkennung && d.erkennungsart === 'textmuster' && d.erkennungshinweis){
currentProtokoll._kiFallbackHinweis = d.erkennungshinweis;
}
renderProtokollDetail();
ladeProtokollListe();
})
.catch(err => {
console.error('Protokoll-Import fehlgeschlagen:', err);
zeigeFehler(err.message || String(err));
})
.finally(() => setzeProtokollImportLadezustand(false));
};
// oeffneProtokoll: lädt das volle Protokoll (Teilnehmer + Punkte) und
// wechselt in die Detailansicht.
window.oeffneProtokoll = function(id){
const container = document.getElementById('protokolleContainer');
if(container) container.innerHTML = '<span class="hint">Lade Protokoll…</span>';
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(id))
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(d => {
if(!d.ok || !d.protokoll) throw new Error(d.error || 'nicht gefunden');
currentProtokoll = d.protokoll;
renderProtokollDetail();
})
.catch(err => {
console.error('Protokoll laden fehlgeschlagen:', err);
if(container) container.innerHTML = '<span class="hint">Protokoll konnte nicht geladen werden.</span>';
});
};
// schliesseProtokollDetail: zurück zur Listenansicht.
window.schliesseProtokollDetail = function(){
currentProtokoll = null;
renderProtokolleListe();
};
// ---------- Detailansicht ----------
function renderProtokollDetail(){
const container = document.getElementById('protokolleContainer');
if(!container || !currentProtokoll) return;
const p = currentProtokoll;
const locked = app.isLocked();
const datumDisplay = formatDateDE(parseDateISO(p.datum)) || '';
const quelleHinweis = p.quelleDateiname
? ` · importiert aus <a href="${baseUrl()}/project-protokolle/${esc(p.id)}/quelldatei" target="_blank" rel="noopener">${esc(p.quelleDateiname)}</a>`
: '';
// Hinweisbanner: nur bei einem importierten Protokoll (quelleDateiname
// gesetzt) — die automatisch erkannten Punkte sind ein Entwurf, siehe
// erkennePunkteAusText() (server.js) und CLAUDE.md "### Protokolle".
// Kein eigener Zustand: leitet sich allein aus dem geladenen Protokoll
// ab, verschwindet also nie von selbst, sondern nur wenn man das
// Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar.
const importBanner = p.quelleDateiname
? `<div class="protokoll-import-banner">🔎 Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>`
: '';
// Rein clientseitiges Merkmal (siehe importiereProtokoll() oben) — nur
// direkt nach GENAU dem Import gesetzt, der die KI angefordert, aber
// nicht erreicht hat. Bewusst deutlich wärmer als importBanner oben
// (amber statt dem neutralen Gelb-Ton), damit ein echter Fehlschlag
// nicht im selben Ton wie die übliche "bitte prüfen"-Erinnerung
// untergeht, und bewusst kein Toast (verschwindet von selbst) — genau
// das war Eriks Rückmeldung, nachdem er von einem KI-Fehlschlag nichts
// in der Oberfläche sah.
const kiFallbackBanner = p._kiFallbackHinweis
? `<div class="protokoll-ki-fallback-banner">⚠️ Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>`
: '';
const kopf = `
${kiFallbackBanner}
${importBanner}
<div class="protokoll-detail-head">
<div>
<h3 style="margin:0 0 4px">${esc(p.betreff || 'ohne Betreff')}</h3>
<div class="protokoll-detail-meta">Protokoll Nr. ${esc(p.nummer)} · verfasst von ${esc(p.verfasserName || '—')}${quelleHinweis}</div>
</div>
<div class="toolbar">
${locked ? '' : `<button class="secondary" onclick="exportProtokollPDF('${esc(p.id)}')">Als PDF exportieren</button>`}
${locked ? '' : `<button class="secondary" onclick="loescheProtokoll()">Löschen</button>`}
${locked ? '' : `<button onclick="speichereProtokoll()">Speichern</button>`}
</div>
</div>
<div class="protokoll-field-grid">
<div class="protokoll-field">
<label>Datum</label>
<input type="text" id="protokollDatumDisplay" class="range-picker-display" value="${esc(datumDisplay)}" placeholder="Datum wählen…" readonly
onclick="openDatePicker({ anchor: this, inputId: 'protokollDatum', displayId: 'protokollDatumDisplay', onApply: syncProtokollDatum })">
<input type="hidden" id="protokollDatum" value="${esc(p.datum)}">
</div>
<div class="protokoll-field">
<label>Betreff</label>
<input type="text" value="${esc(p.betreff)}" oninput="currentProtokollSetzeFeld('betreff', this.value)">
</div>
<div class="protokoll-field">
<label>Ort</label>
<input type="text" value="${esc(p.ort)}" oninput="currentProtokollSetzeFeld('ort', this.value)">
</div>
</div>`;
const teilnehmerZeilen = p.teilnehmer.map((t, idx) => `
<div class="protokoll-teilnehmer-row">
<input type="text" value="${esc(t.name)}" placeholder="Name" oninput="currentProtokollSetzeTeilnehmerFeld(${idx}, 'name', this.value)">
<input type="text" value="${esc(t.firma)}" placeholder="Firma / Rolle" oninput="currentProtokollSetzeTeilnehmerFeld(${idx}, 'firma', this.value)">
<label class="protokoll-anwesend-toggle"><input type="checkbox" ${t.anwesend ? 'checked' : ''} onchange="currentProtokollSetzeTeilnehmerFeld(${idx}, 'anwesend', this.checked)"> anwesend</label>
<span class="protokoll-row-remove" onclick="entferneTeilnehmer(${idx})" title="Teilnehmer entfernen">✕</span>
</div>`).join('');
const teilnehmerBlock = `
<div class="protokoll-block">
<div class="protokoll-block-head"><h4>Teilnehmer</h4></div>
${teilnehmerZeilen || '<span class="hint">Noch keine Teilnehmer.</span>'}
${locked ? '' : '<div class="add-row-btn" onclick="fuegeTeilnehmerHinzu()">+ Teilnehmer</div>'}
</div>`;
container.innerHTML = `<div class="plan-panel">
<div class="protokoll-back-link" onclick="schliesseProtokollDetail()">← Zurück zur Liste</div>
${kopf}
${teilnehmerBlock}
<div class="protokoll-block" id="protokollPunkteBlock">${renderPunkteBlockHtml()}</div>
</div>`;
}
// renderPunkteBlockHtml: baut nur den Punkte-Block (in Abschnitten je
// Gruppe) — eigene Funktion, weil er nach einer Gruppenänderung isoliert
// neu gerendert wird, ohne den Rest der Detailansicht (und damit den Fokus
// in anderen Feldern) anzufassen.
function renderPunkteBlockHtml(){
const p = currentProtokoll;
const locked = app.isLocked();
const gruppen = gruppenReihenfolge();
const gruppenNamen = gruppen.filter(g => g); // ohne '' für die Datalist-Vorschläge
const nummern = window.berechneProtokollNummern(p.punkte);
const gruppenHtml = gruppen.map(gruppe => {
const punkteDerGruppe = p.punkte
.map((punkt, idx) => ({ punkt, idx }))
.filter(x => String(x.punkt.gruppe || '').trim() === gruppe);
const titel = gruppe ? esc(gruppe) : 'Ohne Gruppe';
const addBtn = locked ? '' : `<span class="protokoll-gruppe-add" onclick="fuegePunktHinzu('${esc(gruppe)}')">+ Punkt</span>`;
// "Ohne Gruppe" bleibt fest der erste Abschnitt (siehe gruppenReihenfolge()) —
// nur echte Gruppen lassen sich gegeneinander verschieben.
let moveBtns = '';
if(gruppe && !locked){
const gPos = gruppenNamen.indexOf(gruppe);
const hochDisabled = gPos <= 0 ? 'disabled' : '';
const runterDisabled = gPos >= gruppenNamen.length - 1 ? 'disabled' : '';
moveBtns = `<span class="protokoll-gruppe-move">
<button type="button" class="secondary small" ${hochDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', -1)" title="Gruppe nach vorn">↑</button>
<button type="button" class="secondary small" ${runterDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button>
</span>`;
}
const zeilen = punkteDerGruppe.map(({ punkt, idx }, pos) => renderPunktZeileHtml(punkt, idx, nummern[idx], pos === 0, pos === punkteDerGruppe.length - 1)).join('');
return `<div class="protokoll-gruppe">
<div class="protokoll-gruppe-head"><span class="protokoll-gruppe-name${gruppe ? '' : ' is-leer'}">${titel}</span>${moveBtns}${addBtn}</div>
${zeilen || '<span class="hint">Keine Punkte in dieser Gruppe.</span>'}
</div>`;
}).join('');
const datalist = `<datalist id="protokollGruppenListe">${gruppenNamen.map(g => `<option value="${esc(g)}">`).join('')}</datalist>`;
const addOhneGruppe = locked ? '' : `<div class="add-row-btn" onclick="fuegePunktHinzu('')">+ Punkt (ohne Gruppe)</div>`;
return `<div class="protokoll-block-head"><h4>Punkte</h4></div>
${gruppenHtml || '<span class="hint">Noch keine Punkte.</span>'}
${datalist}
${addOhneGruppe}
<div class="legend">
<span class="legend-item"><span class="legend-swatch neu"></span>Neu in diesem Protokoll</span>
<span class="legend-item"><span class="legend-swatch fortgeschrieben"></span>Aus vorigem Protokoll fortgeschrieben</span>
<span class="legend-item"><span class="legend-swatch erledigt"></span>Erledigt (läuft nicht weiter mit)</span>
</div>`;
}
function renderPunktZeileHtml(punkt, idx, nummer, istErste, istLetzte){
const locked = app.isLocked();
const terminDisplay = formatDateDE(parseDateISO(punkt.termin)) || '';
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
const moveBtns = locked ? '' : `<span class="protokoll-punkt-move">
<button type="button" class="secondary small" ${istErste ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
<button type="button" class="secondary small" ${istLetzte ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
</span>`;
return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${punkt.erledigt ? ' is-erledigt' : ''}" id="protokollPunkt_${idx}">
<div class="protokoll-punkt-top-row">
<span class="protokoll-punkt-nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummer)}</span>
${moveBtns}
<input type="text" class="protokoll-punkt-gruppe" list="protokollGruppenListe" value="${esc(punkt.gruppe)}"
placeholder="Gruppe (z. B. ELT, HLS) …" ${locked ? 'disabled' : ''}
onchange="currentProtokollSetzePunktGruppe(${idx}, this.value)">
${neuChip}
<input type="text" class="protokoll-punkt-thema" value="${esc(punkt.thema)}" placeholder="Thema" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
<label class="protokoll-punkt-erledigt-toggle"><input type="checkbox" ${punkt.erledigt ? 'checked' : ''} ${locked ? 'disabled' : ''}
onchange="currentProtokollSetzePunktErledigt(${idx}, this.checked)"> erledigt</label>
${locked ? '' : `<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`}
</div>
<textarea class="protokoll-punkt-text" rows="2" placeholder="Text / Vereinbarung" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'text', this.value)">${esc(punkt.text)}</textarea>
<div class="protokoll-punkt-foot">
<label>Zuständig <input type="text" value="${esc(punkt.zustaendig)}" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)"></label>
<label>Termin
<input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="—" readonly
onclick="${locked ? '' : `openDatePicker({ anchor: this, inputId: 'protokollPunktTermin_${idx}', displayId: 'protokollPunktTerminDisplay_${idx}', onApply: function(){ syncProtokollPunktTermin(${idx}); } })`}">
<input type="hidden" id="protokollPunktTermin_${idx}" value="${esc(punkt.termin)}">
</label>
<span class="protokoll-punkt-erstellt" title="Datum, an dem dieser Punkt zum ersten Mal erfasst wurde — bleibt beim Fortschreiben unverändert">Erstellt ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}</span>
</div>
</div>`;
}
// ---------- Feld-Mutationen (ohne Neu-Rendern, siehe Datei-Kopfkommentar) ----------
window.currentProtokollSetzeFeld = function(feld, wert){
if(!currentProtokoll) return;
currentProtokoll[feld] = wert;
};
window.syncProtokollDatum = function(){
if(!currentProtokoll) return;
currentProtokoll.datum = document.getElementById('protokollDatum').value;
};
window.currentProtokollSetzeTeilnehmerFeld = function(idx, feld, wert){
if(!currentProtokoll || !currentProtokoll.teilnehmer[idx]) return;
currentProtokoll.teilnehmer[idx][feld] = wert;
};
window.currentProtokollSetzePunktFeld = function(idx, feld, wert){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx][feld] = wert;
};
window.syncProtokollPunktTermin = function(idx){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
const el = document.getElementById('protokollPunktTermin_' + idx);
if(el) currentProtokoll.punkte[idx].termin = el.value;
};
window.currentProtokollSetzePunktErledigt = function(idx, checked){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx].erledigt = checked;
const row = document.getElementById('protokollPunkt_' + idx);
if(row) row.classList.toggle('is-erledigt', checked);
};
// currentProtokollSetzePunktGruppe: einzige Punkt-Feldänderung, die neu
// rendert (nur den Punkte-Block, siehe renderPunkteBlockHtml) — ändert
// sich die Gruppe, muss der Punkt sichtbar in den passenden Abschnitt
// wandern. Läuft über onchange (Blur/Enter), nicht oninput, damit während
// des Tippens nicht bei jedem Zeichen neu gerendert wird.
window.currentProtokollSetzePunktGruppe = function(idx, wert){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx].gruppe = wert;
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
// ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ----------
window.fuegeTeilnehmerHinzu = function(){
if(!currentProtokoll || app.isLocked()) return;
currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true });
renderProtokollDetail();
};
window.entferneTeilnehmer = function(idx){
if(!currentProtokoll || app.isLocked()) return;
currentProtokoll.teilnehmer.splice(idx, 1);
renderProtokollDetail();
};
window.fuegePunktHinzu = function(gruppe){
if(!currentProtokoll || app.isLocked()) 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
// immer, was der Server beim Speichern setzt (server.js, PUT .../:id).
currentProtokoll.punkte.push({
id: null, nummer: '', gruppe: gruppe || '', neu: true,
thema: '', text: '', zustaendig: '', termin: '', erledigt: false,
erstelltAm: currentProtokoll.datum || ''
});
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
window.entfernePunkt = function(idx){
if(!currentProtokoll || app.isLocked()) return;
currentProtokoll.punkte.splice(idx, 1);
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
// ---------- Speichern / Löschen ----------
// speichereProtokoll: PUT /project-protokolle/:id — ersetzt Teilnehmer und
// Punkte komplett (siehe server.js). Ausdrückliches Speichern, kein
// Auto-Save, wie die Board-Kommentare.
window.speichereProtokoll = function(){
if(!currentProtokoll || app.isLocked()) 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 —
// siehe berechneProtokollNummern()).
const nummern = window.berechneProtokollNummern(currentProtokoll.punkte);
currentProtokoll.punkte.forEach((p, idx) => { p.nummer = nummern[idx]; });
const body = {
datum: currentProtokoll.datum,
betreff: currentProtokoll.betreff,
ort: currentProtokoll.ort,
teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()),
punkte: currentProtokoll.punkte
};
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), {
method: 'PUT',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body)
})
.then(r => r.json().then(d => ({ status: r.status, ok: r.ok, d })))
.then(({ ok, d }) => {
if(!ok || !d.ok){ alert('Protokoll konnte nicht gespeichert werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
currentProtokoll = d.protokoll;
renderProtokollDetail();
ladeProtokollListe();
})
.catch(err => {
console.error('Protokoll speichern fehlgeschlagen:', err);
alert('Protokoll konnte nicht gespeichert werden: ' + (err.message || err));
});
};
window.loescheProtokoll = function(){
if(!currentProtokoll || app.isLocked()) 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(); })
.then(d => {
if(!d.ok){ alert('Protokoll konnte nicht gelöscht werden.'); return; }
currentProtokoll = null;
ladeProtokollListe();
})
.catch(err => {
console.error('Protokoll löschen fehlgeschlagen:', err);
alert('Protokoll konnte nicht gelöscht werden: ' + (err.message || err));
});
};
// renderPlanProtokolle: zentrale Render-Funktion, von showPlanSection()
// (plan-sections.js) beim Wechsel in den Bereich aufgerufen — zeigt je
// nach Zustand Liste oder Detail (rendert dabei nichts neu, nur aus dem
// vorhandenen protokolleCache/currentProtokoll).
window.renderPlanProtokolle = function(){
if(currentProtokoll) renderProtokollDetail();
else renderProtokolleListe();
};
})();
+11 -2
View File
@@ -167,7 +167,13 @@
depEnd = parseDateISO(depTodo.end);
} else {
const depMs = milestoneMap.get(depId);
if(depMs) depEnd = parseDateISO(depMs.date);
if(depMs){
// Den Meilenstein ZUERST einplanen: Vorher wurde gegen sein ALTES
// Datum gerechnet, und weil er erst nach allen Aufgaben verschoben
// wurde, konnte eine Aufgabe vor ihrem eigenen Vorgänger beginnen.
scheduleMilestone(depMs);
depEnd = parseDateISO(depMs.date);
}
}
if(!depEnd) return;
const candidate = addDays(depEnd, 1);
@@ -222,8 +228,11 @@
processed.add(mId);
}
todos.forEach(t => scheduleTodo(t));
// Meilensteine zuerst: scheduleTodo() ruft scheduleMilestone() für seine
// Vorgänger zwar selbst auf, aber ein Meilenstein OHNE Nachfolger-Aufgabe
// würde sonst erst am Ende verschoben.
milestones.forEach(m => scheduleMilestone(m));
todos.forEach(t => scheduleTodo(t));
if(moved.size > 0){
app.savePlan();
renderPlanGantt();
+192
View File
@@ -0,0 +1,192 @@
// -----------------------------
// Projektbereich — Bereichs-Navigation & Dashboard
// -----------------------------
// Der Projektbereich ist der EIGENE BEREICH eines Projekts — seit dem Umbau
// kein eigenes Dokument mehr, sondern der Bereich #plan der Hauptseite
// Test.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die
// Leiste .plan-nav umschaltet (Markup in Test.html):
//
// dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen-
// Fortschritt, nächste Termine, Team, Kompakt-Karten
// terminplan — GANTT mit Werkzeugleiste und Ressourcen-Auslastung
// aufgaben — Aufgaben- und Meilensteinliste (js/plan/plan-tasks.js)
// leistung — Leistungsstand & Abrechnung (js/plan/plan-leistung.js)
// dateien — Datei-Upload/-Freigabe (js/plan/plan-files.js)
// protokolle — Besprechungsprotokolle (js/plan/plan-protokoll.js)
// einstellungen — Projektangaben, Honorar, beauftragte Leistungsphasen,
// Beteiligte (js/plan/plan-settings.js)
//
// Alle Bereiche liegen gleichzeitig im DOM und werden nur über die Klasse
// .is-active ein- bzw. ausgeblendet. Das ist Absicht: die vorhandenen
// Render-Funktionen (renderPlanInfo, renderResourceLoad, renderProjectFiles …)
// befüllen ihre Elemente unabhängig davon, ob der Bereich gerade sichtbar ist,
// und brauchten für den Umbau keine Änderung.
//
// Der gewählte Bereich steht im URL-Fragment (z. B. …/Projektplan/Haus%20A
// ?weekLabel=…&projNr=…#aufgaben), damit ein Link auf einen bestimmten Bereich
// verschickt werden kann. Ein unbekanntes Fragment fällt auf "dashboard"
// zurück.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
// Navigationsleiste. Muss zu den data-section-Attributen in
// Test.html passen.
const PLAN_SECTIONS = ['dashboard', 'terminplan', '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
// beim Terminplan zwingend: die Abhängigkeitslinien im GANTT werden aus
// Pixel-Positionen berechnet, die in einem ausgeblendeten Container alle 0
// wären. Parameter: key (Bereichsschlüssel). Keine Rückgabe.
window.showPlanSection = function(key){
if(PLAN_SECTIONS.indexOf(key) === -1) key = 'dashboard';
app.section = key;
PLAN_SECTIONS.forEach(k => {
const sec = document.getElementById('planSection-' + k);
if(sec) sec.classList.toggle('is-active', k === key);
});
document.querySelectorAll('#planNav .plan-nav-item').forEach(btn => {
btn.classList.toggle('is-active', btn.dataset.section === key);
});
// Bereich im URL-Fragment festhalten (teilbarer Link), ohne einen
// History-Eintrag je Klick zu erzeugen.
try {
history.replaceState(null, '', location.pathname + location.search + '#' + key);
} catch(e) { /* z. B. file:// — dann bleibt die Adresse eben unverändert */ }
if(!app.project) return;
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings();
if(key === 'dashboard' && typeof window.renderPlanDashboard === 'function') window.renderPlanDashboard();
};
// initPlanSections: wählt beim Seitenaufbau den Bereich aus dem
// URL-Fragment (sonst "dashboard"). Wird von renderPlanPage()
// (plan-state.js) aufgerufen, also erst wenn Woche und Projekt geladen
// sind. Keine Parameter, keine Rückgabe.
function initPlanSections(){
const fromHash = (location.hash || '').replace(/^#/, '');
// Ein Link auf #dateien/#leistung/#protokolle eines Sandbox-Plans (dessen
// Knöpfe ausgeblendet sind, siehe renderPlanPage(), plan-state.js) fiele
// sonst auf einen Bereich zurück, der zwar existiert, aber keinen
// sichtbaren Weg mehr dorthin hätte. Protokolle hängen wie Dateien an
// project.nr, den ein Sandbox-Plan nicht hat.
const gesperrtFuerSandbox = app.isSandboxPlan()
&& (fromHash === 'dateien' || fromHash === 'leistung' || fromHash === 'protokolle');
window.showPlanSection(!gesperrtFuerSandbox && PLAN_SECTIONS.indexOf(fromHash) !== -1 ? fromHash : 'dashboard');
}
// hidePlanNav: blendet die Bereichsleiste aus. Wird von plan-state.js
// gerufen, wenn gar kein Projekt geladen werden konnte (fehlende
// URL-Parameter, unbekannte Sitzung, Ladefehler) — dann führte jeder
// Bereich nur in eine leere Maske. Keine Parameter, keine Rückgabe.
function hidePlanNav(){
const nav = document.getElementById('planNav');
if(nav) nav.style.display = 'none';
}
// showPlanNav: Gegenstück zu hidePlanNav(). Nötig, seit der Projektbereich
// ein Bereich der Hauptseite ist und nacheinander mehrere Projekte zeigen
// kann: Nach einem Fehlschlag (hidePlanNav) muss die Leiste beim nächsten,
// erfolgreichen openPlanView() wieder erscheinen. Auf der früheren
// eigenständigen Seite war das unnötig, weil jeder Aufruf eine frische
// Seite lud. Keine Parameter, keine Rückgabe.
function showPlanNav(){
const nav = document.getElementById('planNav');
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 }.
function upcomingEntries(){
const plan = app.ensurePlanLocal();
const today = new Date();
today.setHours(0, 0, 0, 0);
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.milestones || []).forEach(m => {
const d = parseDateISO(m.date);
if(!d) return;
rows.push({ kind: 'Meilenstein', title: m.name || 'Meilenstein', date: d, overdue: d < today });
});
rows.sort((a, b) => a.date - b.date);
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.
window.renderPlanDashboard = function(){
if(!app.project) return;
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 teamEl = document.getElementById('planDashTeam');
if(teamEl){
const assignees = projectAssignees(app.project, app.week);
if(assignees.length === 0){
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>
</div>`;
}).join('') + '</div>';
}
}
};
// Wird das Fragment von außen geändert (Vor-/Zurück-Taste, von Hand
// getippter Anker, Link auf dieselbe Seite mit anderem Bereich), dann
// mitziehen. showPlanSection() selbst benutzt history.replaceState() und
// löst dieses Ereignis daher nicht aus.
window.addEventListener('hashchange', () => {
if(currentTab !== 'plan') return;
const key = (location.hash || '').replace(/^#/, '');
if(PLAN_SECTIONS.indexOf(key) !== -1 && key !== app.section) window.showPlanSection(key);
});
app.initPlanSections = initPlanSections;
app.hidePlanNav = hidePlanNav;
app.showPlanNav = showPlanNav;
})();
+271
View File
@@ -0,0 +1,271 @@
// -----------------------------
// Projektbereich — Einstellungen
// -----------------------------
// Bereich "Einstellungen" des Projektbereichs (siehe js/plan/plan-sections.js).
// Hier stehen die Angaben, die das Projekt als Ganzes betreffen, an einer
// Stelle zusammen:
//
// * Projektnummer, -name und Schlagwörter (hier bearbeitbar — der frühere
// Projekte-Tab, in dem dieselben Felder ein zweites Mal standen, ist in
// die Projektliste aufgegangen und zeigt sie nur noch an)
// * LP-Aufwand (die Gewichtungsmatrix, vormals ebenfalls im Projekte-Tab)
// * Gruppe und amtierende Leistungsphase (Auswahlfelder; gefüllt und
// gespeichert von renderPlanInfo()/setPlanGroup()/setPlanActiveLp() in
// js/plan/plan-info.js — dieses Modul rührt sie nicht an)
// * Honorar (netto) und die BEAUFTRAGTEN LEISTUNGSPHASEN
// * Beteiligte (Rollen-/Personen-Matrix, ebenfalls aus plan-info.js)
//
// Honorar und beauftragte Leistungsphasen standen früher ausschließlich im
// Dialog "Terminplan aus Honorar" (js/plan/plan-honorar.js). Sie sind
// Stammdaten des Projekts und gehören deshalb in die Projekteinstellungen;
// im Rechen-Dialog stehen sie weiterhin mit, damit dieser für sich allein
// bedienbar bleibt. Beide Masken schreiben dieselben Projektfelder
// (p.honorar, p.beauftragteLP) über setHonorarBetrag()/toggleBeauftragteLp()
// aus plan-honorar.js und rendern einander nach jeder Änderung neu, sodass
// sie nicht auseinanderlaufen können.
//
// Gespeichert wird über den normalen Weg app.savePlan() (die Felder sind Teil
// des Wochen-JSON, Tabellen projects.honorar bzw. project_beauftragte_lph) —
// es gibt dafür keinen eigenen Endpunkt.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// renderPlanSettings: hält den Einstellungen-Bereich auf dem Stand des
// Projekts. Wird beim Seitenaufbau (renderPlanPage(), plan-state.js), beim
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) und nach
// jeder Änderung an Honorar oder beauftragten Leistungsphasen aufgerufen.
// Keine Parameter, keine Rückgabe.
window.renderPlanSettings = function(){
const p = app.project;
if(!p) return;
const locked = app.isLocked();
const sandbox = app.isSandboxPlan();
const lockHint = document.getElementById('planSettingsLockHint');
if(lockHint) lockHint.style.display = locked ? '' : 'none';
// 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
// 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']
.forEach(id => {
const el = document.getElementById(id);
const row = el && el.closest('.form-row');
if(row) row.style.display = sandbox ? 'none' : '';
});
// Projektnummer und -name: das fokussierte Feld nicht überschreiben
// (dieselbe Vorsichtsmaßnahme wie beim Honorar weiter unten).
const nrEl = document.getElementById('settingsProjNr');
if(nrEl && document.activeElement !== nrEl){
nrEl.value = p.nr || '';
nrEl.disabled = locked;
}
const nameEl = document.getElementById('settingsProjName');
if(nameEl && document.activeElement !== nameEl){
nameEl.value = p.name || '';
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){
const tags = Array.isArray(p.tags) ? p.tags : [];
tagsEl.innerHTML = tags.map(t => `<span class="tag-chip" data-tag="${esc(t)}">${esc(t)}${locked ? '' :
` <span class="tag-remove" title="Schlagwort entfernen" onclick="planRemoveTag(this.closest('.tag-chip').dataset.tag)">×</span>`}</span>`).join('')
+ (locked ? '' : `<input type="text" class="tag-input" placeholder="+ Schlagwort" onkeydown="planTagKeydown(event)">`);
}
// Gewerke: bestimmen, in welcher Gewerke-Besprechung des Wochenberichts
// dieses Projekt erscheint (berichtProjekte(),
// js/wochenbericht/wochenbericht.js). Bis zur Einführung dieses Felds
// ließ sich das Gewerk nur beim Anlegen im Assistenten setzen; ältere
// Projekte wurden einmalig aus ihrem Abteilungs-Schlagwort übernommen
// (migrateBackfillProjectGewerkeAusTags(), server.js) — hier lässt sich
// das jederzeit nachträglich korrigieren.
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('');
}
// Auftragsstatus: Das Auswahlfeld kommt fertig aus js/auftrag.js, damit
// Beschriftungen und Schreibweg an einer Stelle stehen (dieselbe Maske
// nutzt der Dialog "Neues Projekt").
const statusEl = document.getElementById('settingsAuftragsstatus');
if(statusEl && typeof auftragsstatusSelectHtml === 'function'){
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).
const honorarEl = document.getElementById('settingsHonorar');
if(honorarEl){
if(document.activeElement !== honorarEl){
honorarEl.value = Number(p.honorar) > 0 ? p.honorar : '';
}
honorarEl.disabled = locked;
}
// Beauftragte Leistungsphasen: dieselbe Kreuzchen-Liste wie im
// Honorar-Dialog, 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('');
}
};
// setPlanProjNr: ändert die Projektnummer.
//
// Die Nummer ist mehr als eine Beschriftung: Hochgeladene Dateien hängen an
// (Abteilung, Projektnummer) — nicht an der Projekt-ID, weil die bei jedem
// Klonen einer Sitzung neu vergeben wird (siehe "File uploads" in
// CLAUDE.md). Wird die Nummer geändert, findet das Projekt seine bisherigen
// 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;
const neuVal = (val || '').trim();
if(neuVal === (p.nr || '')) return;
if(p.nr && !confirm('Die Projektnummer "' + p.nr + '" wird zu "' + neuVal + '".\n\n'
+ 'Bereits hochgeladene Dateien sind der alten Nummer zugeordnet und erscheinen danach nicht mehr in diesem Projekt.\n\nFortfahren?')){
renderPlanSettings();
return;
}
p.nr = neuVal;
app.savePlan();
renderPlanSettings();
};
// setPlanProjName: ändert die Projektbezeichnung. Parameter: val.
window.setPlanProjName = function(val){
const p = app.project;
if(!p || app.isLocked()) return;
p.name = (val || '').trim();
app.savePlan();
// Die Bezeichnung steht an drei weiteren Stellen: in der Kopfzeile des
// Projektbereichs, im Eintrag der Seitenleiste und in der Adresse.
// renderPlanPage() würde alle drei erneuern, ist aber modulintern in
// plan-state.js und von hier nicht erreichbar — deshalb gezielt.
const titelEl = document.getElementById('planTitle');
if(titelEl) titelEl.textContent = p.name;
if(typeof syncPlanNavItem === 'function') syncPlanNavItem();
if(typeof updateRouteUrl === 'function' && typeof currentTab !== 'undefined' && currentTab === 'plan'){
if(window.PlanApp) PlanApp.projName = p.name;
updateRouteUrl('plan');
}
if(typeof renderBoard === 'function') renderBoard();
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;
const tag = (event.target.value || '').trim();
if(!tag) return;
if(!Array.isArray(p.tags)) p.tags = [];
if(!p.tags.includes(tag)) p.tags.push(tag);
event.target.value = '';
app.savePlan();
renderPlanSettings();
};
// planRemoveTag: entfernt ein Schlagwort. Parameter: tag.
window.planRemoveTag = function(tag){
const p = app.project;
if(!p || app.isLocked() || !Array.isArray(p.tags)) return;
p.tags = p.tags.filter(t => t !== tag);
app.savePlan();
renderPlanSettings();
};
// togglePlanGewerk: nimmt ein Gewerk in die Liste der bearbeitenden
// Fachbereiche auf bzw. entfernt es. Bestimmt, in welcher Gewerke-
// Besprechung des Wochenberichts dieses Projekt erscheint
// (berichtProjekte(), js/wochenbericht/wochenbericht.js). Reihenfolge wie
// 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;
const p = app.project;
const set = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
if(checked) set.add(gewerk); else set.delete(gewerk);
p.gewerke = Object.keys(GEWERK_LABELS).filter(g => set.has(g));
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);
};
})();
+147
View File
@@ -0,0 +1,147 @@
// -----------------------------
// Projektbereich — Aufgaben- und Meilensteinliste
// -----------------------------
// Bereich "Aufgaben" des Projektbereichs (siehe js/plan/plan-sections.js):
// dieselben Daten wie im GANTT, nur als Liste — für alle, die Aufgaben lieber
// abarbeiten als sie zu terminieren. Es gibt hier bewusst KEINEN eigenen
// Datenbestand und keine eigenen Dialoge: eine Zeile öffnet denselben Dialog
// wie ein Klick im GANTT (openTodoModal/openMilestoneModal aus
// js/plan/plan-modals.js), das Häkchen ruft toggleTodoDone() auf, und neue
// Aufgaben entstehen über dieselben Schaltflächen wie im Terminplan
// (addTodo, addPlanMilestone, openTemplateModal, openHonorarModal).
//
// Die Reihenfolge entspricht der Reihenfolge im GANTT (plan.todos bzw.
// plan.milestones), damit der Index in der Liste und der im Dialog
// erwartete Index übereinstimmen und beide Ansichten dasselbe Bild zeigen.
//
// renderPlanTasks() wird am Ende von renderPlanGantt() (plan-gantt.js)
// mitgerendert — das ist die zentrale Neu-Render-Funktion, die alle
// Änderungen an Aufgaben und Meilensteinen ohnehin aufrufen.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// todoStatus: leitet den Status einer Aufgabe aus done/progress ab —
// dieselbe Dreiteilung wie das Status-Segment im TODO-Dialog
// (offen/arbeit/fertig, siehe plan-modals.js). Parameter: t (TODO).
// Rückgabe: 'offen' | 'arbeit' | 'fertig'.
function todoStatus(t){
if(t.done) return 'fertig';
return (Number(t.progress) || 0) > 0 ? 'arbeit' : 'offen';
}
// personById: sucht eine Person der aktuellen Sitzung anhand ihrer ID.
// Rückgabe: Personenobjekt oder null.
function personById(id){
return (app.week && Array.isArray(app.week.people) ? app.week.people : []).find(p => p.id === id) || null;
}
// assigneeAvatars: baut den überlappenden Avatar-Stapel der Zuständigen
// einer Aufgabe (dieselben Kreise wie im Projektboard, .board-avatar in
// styles.css). Parameter: ids (Array von Person-IDs). Rückgabe: HTML-String.
function assigneeAvatars(ids){
const chips = (Array.isArray(ids) ? ids : []).map(id => {
const person = personById(id);
if(!person) return '';
const role = Array.isArray(person.role) && person.role.length ? person.role[0] : '';
const title = (person.fullName || person.name) + (role ? ' · ' + role : '');
return `<span class="board-avatar ${app.planRoleClass(role)} state-1" title="${esc(title)}">${esc(person.name)}</span>`;
}).join('');
return chips ? `<span class="plan-task-assignees">${chips}</span>` : '';
}
// dateRangeText: formatiert den Zeitraum einer Aufgabe als "01.02.2026 –
// 05.02.2026" (bzw. nur ein Datum, wenn Start und Ende gleich sind).
// Parameter: t (TODO). Rückgabe: String (leer ohne Termine).
function dateRangeText(t){
const s = parseDateISO(t.start);
const e = parseDateISO(t.end);
if(!s && !e) return '';
if(s && e) return formatDateDE(s) === formatDateDE(e) ? formatDateDE(s) : formatDateDE(s) + ' – ' + formatDateDE(e);
return formatDateDE(s || e);
}
// isOverdue: true, wenn eine noch offene Aufgabe ihren Endtermin
// überschritten hat. Parameter: t (TODO). Rückgabe: Boolescher Wert.
function isOverdue(t){
if(t.done) return false;
const e = parseDateISO(t.end);
if(!e) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
return e < today;
}
// 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.
window.renderPlanTasks = function(){
if(!app.project) return;
const plan = app.ensurePlanLocal();
const locked = app.isLocked();
// ---- Aufgaben ----
const todoEl = document.getElementById('planTodoList');
const todos = Array.isArray(plan.todos) ? plan.todos : [];
if(todoEl){
// In gesperrten Sitzungen sind die Anlege-Schaltflächen ausgeblendet
// (renderPlanPage(), plan-state.js) — dann darf der Leertext auch nicht
// auf sie verweisen.
const leerTextTodos = locked
? 'Für diese Sitzung sind keine Aufgaben hinterlegt.'
: '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) => {
const status = todoStatus(t);
const range = dateRangeText(t);
const overdue = isOverdue(t);
const del = locked ? '' : `<button class="secondary small" title="Aufgabe löschen" onclick="event.stopPropagation(); deleteTodo(${idx})">✕</button>`;
const check = locked ? '' : `<input type="checkbox" ${t.done ? 'checked' : ''} title="Erledigt" onclick="event.stopPropagation(); toggleTodoDone(${idx})">`;
const progress = (!t.done && (Number(t.progress) || 0) > 0) ? `<span class="plan-task-meta">${Math.round(Number(t.progress) || 0)} %</span>` : '';
// In gesperrten Sitzungen weder Klick-Handler noch Einladung zum
// Bearbeiten: openTodoModal() bricht dort sowieso wortlos ab.
const rowAction = locked ? '' : ` onclick="openTodoModal(${idx})" title="Zum Bearbeiten klicken"`;
return `<div class="plan-task-row${t.done ? ' is-done' : ''}${locked ? ' is-locked' : ''}"${rowAction}>
${check}
<span class="plan-task-dot ${status}"></span>
<span class="plan-task-title">${esc(t.title || 'Aufgabe')}</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>
${del}
</div>`;
}).join('');
}
const todoCount = document.getElementById('planTodoCount');
if(todoCount){
const offen = todos.filter(t => !t.done).length;
todoCount.textContent = todos.length === 0 ? '' : `${offen} von ${todos.length} offen`;
}
// ---- Meilensteine ----
const msEl = document.getElementById('planMilestoneList');
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
if(msEl){
msEl.innerHTML = milestones.length === 0
? '<div class="plan-task-empty">' + (locked ? 'Für diese Sitzung sind keine Meilensteine hinterlegt.' : 'Noch keine Meilensteine. Über „+ Meilenstein“ anlegen.') + '</div>'
: milestones.map((m, idx) => {
const d = parseDateISO(m.date);
const today = new Date();
today.setHours(0, 0, 0, 0);
const overdue = d && d < today;
const msAction = locked ? '' : ` onclick="openMilestoneModal(${idx})" title="Zum Bearbeiten klicken"`;
return `<div class="plan-task-row${locked ? ' is-locked' : ''}"${msAction}>
<span class="plan-task-dot"></span>
<span class="plan-task-title">${esc(m.name || 'Meilenstein')}</span>
<span class="plan-task-meta${overdue ? ' overdue' : ''}">${esc(d ? formatDateDE(d) : 'ohne Datum')}</span>
</div>`;
}).join('');
}
const msCount = document.getElementById('planMilestoneCount');
if(msCount) msCount.textContent = milestones.length === 0 ? '' : `${milestones.length} gesamt`;
};
})();
+182
View File
@@ -0,0 +1,182 @@
// -----------------------------
// Projektplan — Aufgaben-Vorlagen je Leistungsphase
// -----------------------------
// 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
// 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
// eigener Schaltfläche "+ Terminplan" — beide teilten sich früher einen Dialog
// mit Modus-Umschalter, was aber bedeutete, dass auf dem Knopf nur eines der
// beiden Verfahren stand.
//
// Datenquelle sind die Routen /lph-templates (server.js, Tabelle
// 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) —
// hier bleibt für Administratoren nur ein Verweis dorthin.
//
// WICHTIG: Eingefügte Aufgaben sind danach ganz normale plan_todos ohne
// Verbindung zur Vorlage. Eine später geänderte Vorlage verändert bereits
// eingefügte Aufgaben also NICHT — ein laufender Projektplan soll sich nicht
// rückwirkend unter der Hand ändern.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// templateCache: zuletzt geladene Vorlagen (flaches Array), damit das
// Umschalten der Leistungsphase im Modal ohne erneuten Serveraufruf geht.
let templateCache = [];
// TEMPLATE_LPHS: Leistungsphasen, für die Vorlagen angelegt werden können —
// dieselbe Liste wie LPHS (js/globals.js) und wie VALID_LPHS in server.js.
const TEMPLATE_LPHS = LPHS;
// openTemplateModal: öffnet das Vorlagen-Modal, lädt die Vorlagen (einmal
// pro Seitenaufruf) und baut Leistungsphasen-Auswahl sowie Startdatum auf.
// Bei gesperrter Sitzung wird gar nicht erst geöffnet (wie bei den anderen
// Bearbeitungs-Dialogen, siehe plan-modals.js).
window.openTemplateModal = function(){
if(app.isLocked()) return;
const lphSelect = document.getElementById('templateLphSelect');
if(lphSelect && !lphSelect.options.length){
// 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('');
}
// Startdatum vorbelegen: heute
const startInput = document.getElementById('templateStart');
const startDisplay = document.getElementById('templateStartDisplay');
if(startInput && !startInput.value){
const today = formatDateISO(new Date());
startInput.value = today;
if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today));
}
document.getElementById('lphTemplateModal').style.display = 'flex';
loadTemplates();
};
window.closeTemplateModal = function(){
document.getElementById('lphTemplateModal').style.display = 'none';
};
// loadTemplates: holt die Vorlagen vom Server und rendert danach die Liste.
// Fehler werden im Listenbereich gemeldet (kein alert, damit das Modal
// benutzbar bleibt).
function loadTemplates(){
const listEl = document.getElementById('templateTaskList');
if(listEl) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>';
fetch(baseUrl() + '/lph-templates')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
templateCache = Array.isArray(data.templates) ? data.templates : [];
renderTemplateList();
})
.catch(err => {
console.error('Vorlagen laden fehlgeschlagen:', err);
if(listEl) listEl.innerHTML = '<span class="hint">Vorlagen konnten nicht geladen werden.</span>';
});
}
// templatesForSelectedLph: die Vorlagen-Schritte der gerade gewählten
// Leistungsphase, in ihrer gepflegten Reihenfolge. Rückgabe: Array.
function templatesForSelectedLph(){
const lphSelect = document.getElementById('templateLphSelect');
const lph = lphSelect ? lphSelect.value : '';
return templateCache.filter(t => t.lph === lph);
}
// renderTemplateList: baut die Auswahlliste der Schritte (Checkbox je
// Schritt, standardmäßig alle angehakt) samt Vorschau der Termine, die sich
// aus dem gewählten Startdatum ergeben. Für Admins wird darunter der
// Pflege-Bereich gerendert. Wird bei jedem Wechsel von Leistungsphase oder
// Startdatum erneut aufgerufen. Keine Parameter, keine Rückgabe.
window.renderTemplateList = function(){
const listEl = document.getElementById('templateTaskList');
if(!listEl) return;
const entries = templatesForSelectedLph();
const startIso = (document.getElementById('templateStart') || {}).value || '';
const startDate = parseDateISO(startIso);
if(entries.length === 0){
listEl.innerHTML = '<span class="hint">Für 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);
}
return `<label class="todo-depends-item">
<input type="checkbox" value="${esc(t.id)}" checked>
<span style="flex:1">${esc(t.title)}</span>
<span class="hint" style="margin:0">${esc(dateHint)}</span>
</label>`;
}).join('');
}
renderTemplateHint();
};
// 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.
// 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';
}
// applyTemplate: erzeugt aus den angehakten Vorlagen-Schritten neue TODOs im
// aktuellen Projektplan. Termine ergeben sich aus dem gewählten Startdatum
// plus offsetDays bzw. durationDays. Die neuen Aufgaben werden ans Ende der
// Aufgabenliste gehängt, ohne Zuständige und ohne Abhängigkeiten — das wird
// anschließend im TODO-Dialog ergänzt.
window.applyTemplate = function(){
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
? Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value)
: [];
if(checkedIds.length === 0){ alert('Bitte mindestens einen Schritt auswählen.'); return; }
const entries = templatesForSelectedLph().filter(t => checkedIds.includes(t.id));
const plan = app.ensurePlanLocal();
entries.forEach(t => {
const s = addDays(startDate, t.offsetDays);
const e = addDays(s, Math.max(0, t.durationDays - 1));
plan.todos.push({
id: uid(),
title: t.title,
start: formatDateISO(s),
end: formatDateISO(e),
done: false,
progress: 0,
dependsOn: [],
assignees: [],
});
});
app.savePlan();
closeTemplateModal();
fitPlanGanttRange();
app.showToast(entries.length + ' Aufgabe(n) aus der Vorlage eingefügt.');
};
})();