Protokoll-Import: KI-Erkennung (Gruppe, Teilnehmer, Status, Mehrfachpunkte), Ladeanzeige, Erstellungsdatum je Punkt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Sonnet 5 committed 2026-10-01 10:54:43 +02:00
1 parent 6f18848f86
commit 11eabe42ae
275 files changed
+71952 -3029

No files matched your search

+93 -27
View File
@@ -32,8 +32,8 @@
}
// onGanttTodoMouseDown: startet das Zeilen-Ziehen, wenn der Greif-Handler
// (⋮⋮) einer TODO-/Meilenstein-Zeile gedrückt wird, und merkt sich Zeile
// und Startposition in app.todoDrag.
// (Icon "grip", .gantt-todo-drag) einer TODO-/Meilenstein-Zeile gedrückt
// wird, und merkt sich Zeile und Startposition in app.todoDrag.
function onGanttTodoMouseDown(e){
if(app.isLocked()) return;
const handle = e.target.closest('.gantt-todo-drag');
@@ -113,7 +113,11 @@
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const todoIds = todos.map(t => t.id);
// Nur terminierte Aufgaben (Start UND Ende gesetzt) haben überhaupt eine
// eigene GANTT-Zeile (siehe plan-build-gantt.js) und damit einen
// Ziehgriff — Aufgaben ohne Termin bleiben an ihrer Stelle
// in plan.todos stehen, sie können hier gar nicht bewegt worden sein.
const scheduledIds = new Set(todos.filter(t => t.start && t.end).map(t => t.id));
// Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)];
@@ -125,17 +129,19 @@
// Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
// die positionierten zählen.
if(todoRowIds.length !== todoIds.length) return;
if(todoRowIds.length !== scheduledIds.size) return;
const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id);
if(milestoneRowIds.length !== positionedIds.length) return;
// TODOs neu ordnen
const newTodos = [];
todoRowIds.forEach(id => {
const t = todos.find(x => x.id === id);
if(t) newTodos.push(t);
// TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden
// gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne
// Termin behalten exakt ihren bisherigen Platz in der Liste.
let nextRowIdIdx = 0;
plan.todos = todos.map(t => {
if(!scheduledIds.has(t.id)) return t;
const id = todoRowIds[nextRowIdIdx++];
return todos.find(x => x.id === id) || t;
});
plan.todos = newTodos;
// Meilenstein-Positionen neu berechnen
// Regeln:
@@ -382,9 +388,12 @@
const totalDays = daysBetween(rangeStart, rangeEnd) + 1;
const dayOffset = Math.max(0, Math.min(totalDays - 1, Math.round(x / rect.width * totalDays)));
const date = formatDateISO(addDays(rangeStart, dayOffset));
openTodoModal(null);
document.getElementById('todoEditStart').value = date;
document.getElementById('todoEditEnd').value = date;
// Über prefill statt nachträglich in die versteckten Felder schreiben:
// openTodoModal() setzt damit auch die sichtbare Zeitraum-Anzeige, das
// Dauer-Kennzeichen und den Vergleichsstand für "ungespeicherte
// Änderungen" (sonst stand im Feld "Start – Ende wählen…", und Esc fragte
// grundlos nach dem Verwerfen).
openTodoModal(null, { start: date, end: date });
}
// updateProjectDates: setzt Start und Ende des Projekt-Plans und speichert.
@@ -415,23 +424,80 @@
}
}
// updateVacationDates: schreibt die verschobenen Urlaubsdaten sowohl in die
// lokale Sitzungs-Kopie der Person als auch in die globale Personenliste
// und speichert beides.
// updateVacationDates: übernimmt die per Drag verschobenen Urlaubsdaten
// sofort optimistisch in lokale Sitzungs-Kopie und globale Personenliste
// (damit das GANTT unmittelbar danach die neue Position zeichnet), und
// persistiert sie asynchron über die /users/:id/absences-Endpunkte — es
// gibt seit der Ablösung von people/person_roles/person_absences keinen
// /save-people-Schreibpfad und kein PATCH auf Abwesenheiten mehr, daher:
// alte Abwesenheit löschen, neue mit den verschobenen Daten anlegen (dabei
// vergibt der Server eine neue ID, die hier übernommen wird). Kein
// Benutzerkonto für die Person gefunden -> klare Fehlermeldung statt
// stillem Fehlschlag.
function updateVacationDates(personId, absenceId, start, end){
const localPerson = (app.week.people || []).find(p => p.id === personId);
if(localPerson && localPerson.absences){
const a = localPerson.absences.find(x => x.id === absenceId);
if(a){ a.start = start; a.end = end; }
}
const globalPeople = db.globalPeople[app.department] || [];
const localAbsence = localPerson && Array.isArray(localPerson.absences)
? localPerson.absences.find(x => x.id === absenceId) : null;
const globalPeople = db.globalPeople || [];
const globalPerson = globalPeople.find(p => p.id === personId);
if(globalPerson && globalPerson.absences){
const a = globalPerson.absences.find(x => x.id === absenceId);
if(a){ a.start = start; a.end = end; }
}
app.savePlan();
app.saveGlobalPeople();
const globalAbsence = globalPerson && Array.isArray(globalPerson.absences)
? globalPerson.absences.find(x => x.id === absenceId) : null;
if(!globalAbsence) return;
const type = globalAbsence.type || '';
const note = globalAbsence.note || '';
// Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende
// Berechtigung), wird die optimistisch verschobene Anzeige wieder auf den
// Server-Stand zurückgesetzt, statt etwas zu zeigen, das dort nicht gilt.
const vorher = { start: globalAbsence.start, end: globalAbsence.end };
const zuruecksetzen = () => {
if(localAbsence){ localAbsence.start = vorher.start; localAbsence.end = vorher.end; }
globalAbsence.start = vorher.start; globalAbsence.end = vorher.end;
renderPlanGantt();
};
if(localAbsence){ localAbsence.start = start; localAbsence.end = end; }
globalAbsence.start = start; globalAbsence.end = end;
app.getUserIdForPersonId(personId).then(userId => {
if(!userId){
app.showToast('Für diese Person existiert kein Benutzerkonto – Abwesenheit konnte nicht gespeichert werden.');
zuruecksetzen();
return;
}
// Ergebnis des Löschens prüfen, BEVOR neu angelegt wird: Schlug das
// Anlegen danach fehl (Netz, 400), war die alte Abwesenheit bereits weg
// — die Anzeige zeigte sie aber weiter, und nach dem nächsten Neuladen
// fehlte sie ersatzlos.
return fetch(baseUrl() + '/users/' + userId + '/absences/' + encodeURIComponent(absenceId), { method: 'DELETE' })
.then(r => {
if(!r.ok){
// 403 heißt: Der Eintrag stammt von jemand anderem (siehe
// DELETE /users/:id/absences/:absenceId in server.js).
return r.json().catch(() => ({})).then(json => {
throw new Error((json && json.error) || ('Alte Abwesenheit konnte nicht entfernt werden (HTTP ' + r.status + ')'));
});
}
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){
app.showToast('Abwesenheit konnte nicht gespeichert werden: ' + ((json && json.error) || 'unbekannter Fehler')
+ ' — bitte die Seite neu laden.');
return;
}
globalAbsence.id = json.absence.id;
if(localAbsence) localAbsence.id = json.absence.id;
renderPlanGantt();
});
}).catch(err => {
console.error('Abwesenheit verschieben fehlgeschlagen:', err);
app.showToast('Abwesenheit konnte nicht verschoben werden: ' + (err.message || err));
zuruecksetzen();
});
}
// Drag-Funktionalität erst nach geladenem DOM registrieren
+20 -9
View File
@@ -348,7 +348,7 @@
// 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,
// 30 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){
@@ -584,19 +584,27 @@
<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>
<span class="legend-item"><span class="legend-swatch erledigt"></span>Erledigt oder Info (läuft nicht weiter mit)</span>
</div>`;
}
// PROTOKOLL_PUNKT_STATUS_LABEL: Anzeigetext je Status im <select> unten —
// einzige Stelle, die die drei Werte kennt, damit ein vierter Status
// (sollte es den je geben) nur hier ergänzt werden müsste.
const PROTOKOLL_PUNKT_STATUS_LABEL = { offen: 'Offen', erledigt: 'Erledigt', info: 'Info' };
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 status = punkt.status || 'offen';
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}">
const statusOptions = Object.keys(PROTOKOLL_PUNKT_STATUS_LABEL)
.map(s => `<option value="${s}" ${s === status ? 'selected' : ''}>${PROTOKOLL_PUNKT_STATUS_LABEL[s]}</option>`).join('');
return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${status === 'erledigt' ? ' is-erledigt' : ''}${status === 'info' ? ' is-info' : ''}" 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}
@@ -606,8 +614,8 @@
${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>
<select class="protokoll-punkt-status" ${locked ? 'disabled' : ''}
onchange="currentProtokollSetzePunktStatus(${idx}, this.value)">${statusOptions}</select>
${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' : ''}
@@ -648,11 +656,14 @@
const el = document.getElementById('protokollPunktTermin_' + idx);
if(el) currentProtokoll.punkte[idx].termin = el.value;
};
window.currentProtokollSetzePunktErledigt = function(idx, checked){
window.currentProtokollSetzePunktStatus = function(idx, status){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx].erledigt = checked;
currentProtokoll.punkte[idx].status = status;
const row = document.getElementById('protokollPunkt_' + idx);
if(row) row.classList.toggle('is-erledigt', checked);
if(row){
row.classList.toggle('is-erledigt', status === 'erledigt');
row.classList.toggle('is-info', status === 'info');
}
};
// currentProtokollSetzePunktGruppe: einzige Punkt-Feldänderung, die neu
// rendert (nur den Punkte-Block, siehe renderPunkteBlockHtml) — ändert
@@ -686,7 +697,7 @@
// 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,
thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
erstelltAm: currentProtokoll.datum || ''
});
const block = document.getElementById('protokollPunkteBlock');
+287 -141
View File
@@ -1,23 +1,38 @@
// -----------------------------
// Projektplan — Zustand, Initialisierung & Daten laden
// Projektbereich — Zustand, Einstieg & Projektauswahl
// -----------------------------
// Dieses Modul wird als erstes der plan-*-Module von public/project-plan.html
// geladen und definiert window.PlanApp als gemeinsamen Zustand aller
// Projektplan-Module. Es liest die URL-Route (Abteilung + Projektname, z. B.
// "/HLS/Projektplan/Gymnasium%20Hemer") und die URL-Parameter (weekLabel,
// projNr), über die das zu bearbeitende Projekt adressiert wird.
// Nach DOMContentLoaded werden Theme, Toolbar-Buttons und die Sitzungsdaten
// über die Server-API geladen. Die hier definierten Funktionen (savePlan,
// isLocked, showToast u. a.) werden über app.* den anderen plan-Modulen
// bereitgestellt, die alle in einem IIFE mit window.PlanApp laufen.
// Dieses Modul wird als erstes der plan-*-Module von public/Test.html geladen
// und definiert window.PlanApp als gemeinsamen Zustand aller Module des
// Projektbereichs.
//
// WICHTIG — geändert gegenüber der früheren Fassung: Der Projektbereich war
// einmal eine eigene Seite (public/project-plan.html) mit eigenem
// Einstiegspunkt. Sie las Sitzung und Projekt aus der URL und lud sich ihren
// Datenbestand über GET /load-all selbst. Beides ist entfallen: Der
// Projektbereich ist jetzt ein Bereich der Hauptseite (#plan in Test.html),
// der über showTab('plan') eingeblendet wird wie jeder andere. Er arbeitet
// deshalb auf dem bereits geladenen In-Memory-Bestand db.weeks (js/store.js)
// und braucht weder einen eigenen Ladevorgang noch eine eigene
// Anmeldeprüfung.
//
// Einstieg sind zwei Schritte: setPlanTarget(weekLabel, projNr, projName)
// merkt sich das Ziel, openPlanView() sucht es in db.weeks und rendert den
// Bereich. Getrennt sind sie, weil showTab('plan') (js/tabs-auth.js) aus dem
// Ziel zuerst die Adresszeile schreibt und den Bereich einblendet — das GANTT
// kann sich erst vermessen, wenn sein Container sichtbar ist. Gesetzt wird
// das Ziel von openProjectPlan() (js/gantt.js) und beim Seitenstart aus einem
// Deep-Link "/Projektplan/<Projektname>?weekLabel=…&projNr=…"
// (applyUrlRoute() in js/ui.js, ausgewertet in js/init.js).
// Die hier definierten Funktionen (savePlan, isLocked, showToast u. a.)
// stellen sich über app.* den anderen plan-Modulen bereit, die alle in einem
// IIFE mit window.PlanApp laufen.
// PlanApp: gemeinsamer, modulübergreifender Zustand der Projektplan-Seite.
// PlanApp: gemeinsamer, modulübergreifender Zustand des Projektbereichs.
window.PlanApp = {
params: new URLSearchParams(location.search),
department: '',
weekLabel: '',
projNr: '',
projName: '',
sandboxPlanId: '', // gesetzt von openSandboxPlan() — Sandbox-Pläne werden über id adressiert, nicht über nr/name
week: null, // planWeek
project: null, // planProject
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
@@ -34,88 +49,117 @@ window.PlanApp = {
(function(){
const app = window.PlanApp;
// Abteilung und Projektname aus dem Deep-Link-Pfad
// "/<Abteilung>/Projektplan/<Projektname>" lesen. Fallback auf die früheren
// Query-Parameter ?department=/&projName= für alte project-plan.html-Links.
const routeMatch = window.location.pathname.match(/^\/([^/]+)\/(?:Projektplan|project-plan\.html)(?:\/(.+))?\/?$/);
app.department = routeMatch ? routeMatch[1] : (app.params.get('department') || 'ELT');
app.weekLabel = app.params.get('weekLabel');
app.projNr = app.params.get('projNr') || '';
let projNameFromRoute = '';
if(routeMatch && routeMatch[2]){
try { projNameFromRoute = decodeURIComponent(routeMatch[2]); }
catch(e) { projNameFromRoute = app.params.get('projName') || ''; }
}
app.projName = projNameFromRoute || (app.params.get('projName') || '');
// init: Einstiegspunkt nach DOMContentLoaded. Übernimmt das Theme aus
// localStorage/System, prüft die URL-Parameter und bricht mit Fehlermeldung
// ab, falls kein Projekt ausgewählt wurde. Sonst bindet es die Toolbar und
// startet das Laden der Daten.
function init(){
loadTheme(); // Theme aus localStorage / System übernehmen
if(!app.weekLabel || !app.projName){
document.getElementById('planTitle').textContent = 'Fehler';
document.getElementById('planSubtitle').textContent = 'Kein Projekt ausgewählt.';
return;
// planFail: zeigt einen Fehler in der Kopfzeile des Projektbereichs an und
// blendet die Bereichsleiste aus, weil ohne Projekt jeder Unterbereich nur
// in eine leere Maske führte. Parameter: title, subtitle (Strings).
function planFail(title, subtitle){
const t = document.getElementById('planTitle');
const sub = document.getElementById('planSubtitle');
if(t) t.textContent = title;
if(sub) sub.textContent = subtitle;
const badge = document.getElementById('planIdBadge');
if(badge) badge.textContent = '';
app.project = null;
app.hidePlanNav();
}
// setPlanTarget: merkt sich, welches Projekt der Bereich zeigen soll —
// adressiert über Sitzungs-Label und Projekt-Nr./-Bezeichnung, NICHT über
// project.id, die beim Duplizieren einer Sitzung (cloneWeek(), js/weeks.js)
// neu vergeben wird. Bewusst getrennt vom Rendern: showTab('plan')
// (js/tabs-auth.js) schreibt aus diesen Werten zuerst die Adresszeile und
// blendet den Bereich ein, und erst der sichtbare Bereich lässt sich
// vermessen — das GANTT braucht die Containerbreite. Parameter:
// weekLabel, projNr, projName (Strings). Keine Rückgabe.
window.setPlanTarget = function(weekLabel, projNr, projName){
app.weekLabel = weekLabel || '';
app.projNr = projNr || '';
app.projName = projName || '';
app.sandboxPlanId = '';
};
// openSandboxPlan: das Gegenstück zu setPlanTarget()+showTab('plan') für
// einen Terminplan der Planung-Sandbox (js/planung/planung.js). Sandbox-
// Pläne haben keine Projektnummer und werden nie geklont (cloneWeek()
// betrifft nur echte Wochen) — ihre id bleibt also stabil und ist die
// einzige verlässliche Adressierung, anders als bei echten Projekten
// (dort nr/name, siehe setPlanTarget()). Parameter: planId. Keine Rückgabe.
window.openSandboxPlan = function(planId){
app.weekLabel = SANDBOX_WEEK_LABEL;
app.projNr = '';
app.projName = '';
app.sandboxPlanId = planId || '';
showTab('plan');
};
// openPlanView: sucht Sitzung und Projekt im bereits geladenen Bestand
// db.weeks (js/store.js) und rendert den Bereich. Einen eigenen
// Ladevorgang gibt es nicht mehr — der Projektbereich teilt sich den
// Datenbestand mit allen anderen Bereichen der Seite. Wird von
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
// gefunden und gerendert wurde.
window.openPlanView = function(){
app.showPlanNav();
// Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
// und Adressierung über die stabile Plan-id statt über nr/name.
if(app.weekLabel === SANDBOX_WEEK_LABEL){
if(!app.sandboxPlanId){
planFail('Kein Terminplan gewählt', 'Diesen Bereich öffnet ein Klick auf einen Terminplan im Bereich „Planung".');
return false;
}
app.week = db.sandboxWeek || null;
app.project = (app.week && Array.isArray(app.week.projects))
? app.week.projects.find(p => p.id === app.sandboxPlanId) || null
: null;
if(!app.project){
planFail('Nicht gefunden', 'Dieser Terminplan wurde nicht gefunden — er wurde möglicherweise gelöscht.');
return false;
}
app.projNr = '';
app.projName = app.project.name || '';
renderPlanPage();
return true;
}
currentDepartment = app.department; // global aus globals.js
bindToolbarButtons();
loadPlanData();
}
// bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen,
// Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der
// anderen plan-Module. Die Buttons binden ihre Handler bereits über das
// onclick-Attribut in project-plan.html; hier werden nur Lücken gefüllt,
// falls ein Button-Element fehlt.
function bindToolbarButtons(){
}
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der
// Abteilung über die Server-API (JSON-Dokumente), sortiert die Wochen
// chronologisch und sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt
// Fehlermeldungen der Reihenfolge nach im Seitenkopf an und startet sonst
// renderPlanPage().
function loadPlanData(){
if(!window.fetch){
document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.';
return;
if(!app.weekLabel || (!app.projNr && !app.projName)){
planFail('Kein Projekt gewählt', 'Diesen Bereich öffnet ein Klick auf ein Projekt im Projektboard oder im Tab „Projekte".');
return false;
}
const url = baseUrl() + '/load-all?department=' + encodeURIComponent(app.department);
fetch(url)
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
db = { weeks: [], currentWeek: null, globalPeople: {} };
db.globalPeople[app.department] = Array.isArray(data.people) ? data.people : [];
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string')
.map(w => {
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return w;
});
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
app.week = db.weeks.find(w => w.label === app.weekLabel);
if(!app.week){
document.getElementById('planTitle').textContent = 'Nicht gefunden';
document.getElementById('planSubtitle').textContent = 'Sitzung "' + esc(app.weekLabel) + '" konnte nicht geladen werden.';
return;
}
app.project = app.week.projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName);
if(!app.project){
document.getElementById('planTitle').textContent = 'Nicht gefunden';
document.getElementById('planSubtitle').textContent = 'Projekt nicht in dieser Sitzung vorhanden.';
return;
}
renderPlanPage();
})
.catch(err => {
document.getElementById('planTitle').textContent = 'Fehler beim Laden';
document.getElementById('planSubtitle').textContent = err.message;
});
}
// Vor dem ersten erfolgreichen load() ist db.weeks noch leer (Deep-Link
// direkt auf "/Projektplan/…"). Dann ist das kein Fehler, sondern nur
// noch nicht so weit: load() ruft showTab(currentTab) erneut auf.
if(!Array.isArray(db.weeks) || db.weeks.length === 0){
const t = document.getElementById('planTitle');
const sub = document.getElementById('planSubtitle');
if(t) t.textContent = app.projName || 'Projektbereich';
if(sub) sub.textContent = 'Sitzungsdaten werden geladen…';
return false;
}
app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
if(!app.week){
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
return false;
}
// Projekt-Nr. ist der stabile Schlüssel; die Bezeichnung dient nur als
// Rückfallebene für Projekte, die (noch) keine Nummer haben.
const projects = app.week.projects || [];
app.project = projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName)
|| (app.projNr ? projects.find(p => (p.nr || '') === app.projNr) : null)
|| projects.find(p => (p.name || '') === app.projName)
|| null;
if(!app.project){
planFail('Nicht gefunden', 'Projekt nicht in dieser Sitzung vorhanden.');
return false;
}
app.projNr = app.project.nr || '';
app.projName = app.project.name || '';
renderPlanPage();
return true;
};
// ensurePlanLocal: stellt sicher, dass das Plan-Objekt des Projekts die
// erwartete Struktur hat (inkl. Migration alter Einzel-Abhängigkeiten).
@@ -139,21 +183,98 @@ window.PlanApp = {
return !!app.week.locked;
}
// renderPlanPage: setzt Titel und Untertitel der Seite aus Projekt und Woche,
// blendet den Meilenstein-Button bei gesperrter Woche aus und startet die
// GANTT-Anzeige über fitPlanGanttRange().
// isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur
// Planung-Sandbox gehört statt zu einem echten Projekt. Zentraler Merker
// für alle plan-*-Module, die dort Abschnitte ausblenden, die nur bei
// einem echten Projekt Sinn ergeben (Dateien, Leistung, Auftragsstatus,
// Projektnummer, Gewerke, Projektgröße, Kommentar/Verlauf — siehe CLAUDE.md
// "Planung — projektunabhängige Terminplan-Sandbox"). Rückgabe: Boolescher Wert.
function isSandboxPlan(){
return !!app.week && app.week.label === SANDBOX_WEEK_LABEL;
}
// renderPlanPage: setzt die kompakte, einzeilige Titelzeile aus Projekt und
// Woche (Nr.-Badge, Name, Tags, Sitzungslabel, Beteiligte als
// überlappender Avatar-Stapel — siehe renderProjectAssignees()), baut das
// Projektinfo-Panel auf (renderPlanInfo(), js/plan/plan-info.js: Status,
// Gruppe, amtierende Leistungsphase, LPH-Prozentliste) sowie den
// Dateien-Bereich (initPlanFiles(), js/plan/plan-files.js: Upload, Freigabe,
// Kommentare) und den Einstellungen-Bereich (renderPlanSettings(),
// js/plan/plan-settings.js: Honorar, beauftragte Leistungsphasen), blendet
// die Schaltflächen zum Anlegen neuer Aufgaben bei gesperrter Woche aus,
// startet die GANTT-Anzeige über fitPlanGanttRange() und schaltet zuletzt
// auf den sichtbaren Bereich (app.initPlanSections(), js/plan/plan-sections.js).
function renderPlanPage(){
const p = app.project;
const plan = ensurePlanLocal();
document.getElementById('planTitle').textContent = (p.nr ? p.nr + ' – ' : '') + p.name;
document.getElementById('planSubtitle').textContent = app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : '');
const sandbox = isSandboxPlan();
const planIdBadge = document.getElementById('planIdBadge');
if(planIdBadge) planIdBadge.textContent = p.nr || '';
document.getElementById('planTitle').textContent = p.name;
// Für die Sandbox NICHT das rohe week.label ("__sandbox__") anzeigen —
// das ist ein interner Marker, kein für Nutzer bestimmter Sitzungsname.
document.getElementById('planSubtitle').textContent = sandbox
? 'Planung — unabhängig von einem laufenden Projekt'
: app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : '');
// GANTT inkl. TODOs
const addMilestoneBtn = document.getElementById('addMilestoneBtn');
const planPeopleList = document.getElementById('planPeopleList');
if(planPeopleList) planPeopleList.innerHTML = renderProjectAssignees();
const tags = Array.isArray(p.tags) ? p.tags : [];
const planTagsList = document.getElementById('planTagsList');
if(planTagsList){
planTagsList.innerHTML = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
}
// Projektinfo-Panel (Status, Gruppe, amtierende Leistungsphase, LPH-
// Prozentliste) — siehe js/plan/plan-info.js.
renderPlanInfo();
// Dateien-Bereich (Upload, Freigabe, Kommentare) — siehe js/plan/plan-files.js.
initPlanFiles();
// Protokolle-Bereich (Besprechungsprotokolle) — siehe js/plan/plan-protokoll.js.
// Wie initPlanFiles() unbedingt aufgerufen (nicht erst beim Öffnen des
// Bereichs), lädt bei vorhandener Projektnummer die Protokollliste.
if(typeof initPlanProtokoll === 'function') initPlanProtokoll();
// Verlauf über alle Sitzungen (Kommentar-Historie) — siehe js/plan/plan-history.js.
renderPlanHistory();
// Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen,
// Projektangaben) — siehe js/plan/plan-settings.js.
renderPlanSettings();
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe Test.html.
const locked = isLocked();
if(addMilestoneBtn) addMilestoneBtn.style.display = locked ? 'none' : '';
['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'autoScheduleBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'autoScheduleBtnList']
.forEach(id => {
const btn = document.getElementById(id);
if(btn) btn.style.display = locked ? 'none' : '';
});
// "Dateien" (keine Projektnummer, kein sinnvoller Prüf-Workflow),
// "Leistung" (echte Abrechnung/Auftragsstatus-abhängig) und "Protokolle"
// (hängt wie Dateien an project.nr) ergeben für einen Terminplan ohne
// echtes Projekt keinen Sinn — Bereichsknöpfe ausblenden.
document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]')
.forEach(btn => { btn.style.display = sandbox ? 'none' : ''; });
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gibt es nur für
// Sandbox-Pläne der Planung — ein echtes Projekt hat kein Äquivalent.
const sandboxPdfBtn = document.getElementById('sandboxPdfBtn');
if(sandboxPdfBtn) sandboxPdfBtn.style.display = sandbox ? '' : 'none';
// GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit)
fitPlanGanttRange();
// Sichtbaren Bereich wählen (URL-Fragment, sonst Dashboard) — siehe
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
// Bereiche befüllt sind, wird einer davon eingeblendet.
app.initPlanSections();
}
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
@@ -166,58 +287,84 @@ window.PlanApp = {
});
}
// Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen Liste
// planRoleClass: Kopie von roleClass() aus js/board/board.js (dort nicht
// geladen) — erzeugt dieselben CSS-Klassen wie im Board, damit die
// Avatar-Kreise hier dieselben Rollenfarben (.board-avatar in styles.css)
// nutzen. Parameter: role (String). Rückgabe: Klassen-String.
function planRoleClass(role){
return 'role-' + String(role || '').toLowerCase().replace(/\s+/g, '-');
}
// Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen
// Liste, als überlappender Avatar-Stapel für die kompakte Titelzeile
// (dieselben Kreise/Farben wie im Projektboard-Detailpanel, siehe
// .board-avatar in styles.css). Voller Name + Rolle stehen im Tooltip.
function renderProjectAssignees(){
const assignees = projectAssignees(app.project, app.week);
if(assignees.length === 0) return '<span class="hint">Keine Zuordnungen im Projektboard.</span>';
if(assignees.length === 0) return '';
return assignees.map(a => {
const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : '';
return `<span class="todo-assignee ${cls}">${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}</span>`;
const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
return `<span class="board-avatar ${planRoleClass(a.role)} state-${a.state}" title="${esc(title)}">${esc(a.person.name)}</span>`;
}).join('');
}
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben.
// In-Flight-Schutz: läuft bereits ein Save, wird kein zweiter POST mit
// identischer Revisions-Basis abgeschickt (der würde fälschlich als 409-
// Konflikt gemeldet). Stattdessen wird ein Folgesave eingereiht, der nach
// Abschluss des laufenden Saves mit der dann frischen Revision nachzieht.
let savePlanRunning = false;
let savePlanQueued = false;
// savePlan: merkt die Sitzung des Projektbereichs als geändert vor und
// stößt den gewöhnlichen, entprellten Speicherlauf an (js/store.js).
//
// Früher schrieb der Projektbereich mit einem eigenen sendWeekToServer()
// direkt zum Server, weil er eine eigene Seite war und store.js dort gar
// nicht geladen wurde. In einer Seite wären daraus zwei Schreiber auf
// dieselbe Sitzung geworden: beide senden dieselbe Revisions-Basis, und der
// langsamere bekäme einen scheinbaren 409-Konflikt gemeldet. Deshalb geht
// jetzt auch der Projektbereich durch syncAllToServer() — mitsamt dessen
// In-Flight-Schutz, Wiederholung nach Netzfehlern, Konfliktbehandlung und
// Anmelde-Prüfung. markWeekDirty() bekommt bewusst app.week.label statt
// save() zu rufen: save() markiert die AKTUELL gewählte Sitzung, und ein
// Deep-Link kann den Projektbereich auf eine andere gestellt haben.
function savePlan(){
if(!app.week || !app.project) return;
if(savePlanRunning){ savePlanQueued = true; return; }
savePlanRunning = true;
sendWeekToServer(app.week)
.then(res => {
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden.
if(res && res.updatedAt) app.week.updatedAt = res.updatedAt;
})
.catch(err => {
if(err && err.conflict){
showToast('Konflikt: Die Sitzung wurde zwischenzeitlich geändert. Bitte Seite neu laden.');
console.error('Speichern fehlgeschlagen (Konflikt):', err.message || err);
} else {
console.error('Speichern fehlgeschlagen:', err);
}
})
.finally(() => {
savePlanRunning = false;
if(savePlanQueued){ savePlanQueued = false; savePlan(); }
});
markWeekDirty(app.week.label);
scheduleSessionSync();
}
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
// JSON-Array auf den Server. Auch eine leere Liste wird gespeichert, damit
// das Löschen der letzten Person erhalten bleibt.
function saveGlobalPeople(){
const people = db.globalPeople[app.department] || [];
sendPeopleToServer(people).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
// userIdByPersonIdPromise: Cache der Zuordnung Person-ID (String, z. B.
// "p_max_mustermann") -> numerische users.id. Seit der Ablösung von
// people/person_roles/person_absences gibt es keinen /save-people-Schreibpfad
// mehr; Abwesenheiten werden direkt gegen /users/:id/absences geschrieben
// (siehe plan-modals.js/plan-drag.js), wofür die numerische User-ID benötigt
// wird. Lazy geladen über GET /users (liefert seit Phase 1 auch personId)
// und für die Lebensdauer der Seite zwischengespeichert.
let userIdByPersonIdPromise = null;
function loadUserIdByPersonId(){
if(!userIdByPersonIdPromise){
userIdByPersonIdPromise = fetch(baseUrl() + '/users')
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
const map = {};
(Array.isArray(data.users) ? data.users : []).forEach(u => {
if(u.personId) map[u.personId] = u.id;
});
return map;
})
.catch(err => {
// Cache zurücksetzen, damit ein späterer Aufruf es erneut versucht.
userIdByPersonIdPromise = null;
throw err;
});
}
return userIdByPersonIdPromise;
}
// getUserIdForPersonId: liefert die numerische users.id für eine Personen-ID
// oder null, falls keine passende (aktive) Benutzer-Zuordnung existiert (z. B.
// eine Person ohne Login-Konto). Rückgabe: Promise.
function getUserIdForPersonId(personId){
return loadUserIdByPersonId().then(map => (map && Object.prototype.hasOwnProperty.call(map, personId)) ? map[personId] : null);
}
// Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
function findGlobalPerson(personId){
return (db.globalPeople[app.department] || []).find(p => p.id === personId) || null;
return (db.globalPeople || []).find(p => p.id === personId) || null;
}
// countPersonAbsences: zählt die Abwesenheiten einer Person anhand der
@@ -247,14 +394,13 @@ window.PlanApp = {
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
app.ensurePlanLocal = ensurePlanLocal;
app.isLocked = isLocked;
app.isSandboxPlan = isSandboxPlan;
app.removeDependencyRefs = removeDependencyRefs;
app.renderProjectAssignees = renderProjectAssignees;
app.planRoleClass = planRoleClass;
app.savePlan = savePlan;
app.saveGlobalPeople = saveGlobalPeople;
app.getUserIdForPersonId = getUserIdForPersonId;
app.findGlobalPerson = findGlobalPerson;
app.countPersonAbsences = countPersonAbsences;
app.showToast = showToast;
// Seitenstart: erst initialisieren, wenn das DOM geladen ist
document.addEventListener('DOMContentLoaded', init);
})();