All new
alle new
This commit is contained in:
1 parent
da64472251
commit
5b20adee48
1605 files changed
+396517
-1674
No files matched your search
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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.');
|
||||
};
|
||||
})();
|
||||
@@ -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.
|
||||
})();
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -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();
|
||||
};
|
||||
})();
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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);
|
||||
};
|
||||
})();
|
||||
@@ -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`;
|
||||
};
|
||||
})();
|
||||
@@ -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.');
|
||||
};
|
||||
})();
|
||||
Reference in new issue
Block a user