// ----------------------------- // Projektübersicht — Daten laden, Speichern, Abteilung wechseln // ----------------------------- // Diese Datei bildet die Schnittstelle zum Server: Sie lädt alle Sitzungen // beim Start, speichert den kompletten Datenbestand (alle Wochen + globale // Personenliste) und verwaltet den Wechsel zwischen Abteilungen (ELT/HLS/ARCH). // Die Daten liegen serverseitig in einer SQLite-Datenbank und werden als // strukturierte JSON-Objekte über die API ausgetauscht (keine CSV-Strings). // Sie wird von `Test.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt // die globalen Funktionen `save()`, `syncAllToServer()`, `switchDepartment()`, // `load()`, `seed()` und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`, // `js/board/board.js`, `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das Speichern // ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms debounced. // save: plant einen (debounced) Sync. Standardmäßig wird die aktuell gewählte // Woche als geändert markiert, da sich die meisten Bearbeitungen auf die // aktuelle Sitzung beziehen. Optionen: // { week:false } – aktuelle Woche NICHT markieren (z. B. reine Personen-Aktionen) // { people:true } – zusätzlich die globale Personenliste als geändert markieren function save(opts = {}){ if(opts.week !== false){ const w = currentWeek(); if(w) dirtyWeeks.add(w.label); } if(opts.people) dirtyPeople = true; scheduleSessionSync(); } // Dirty-Tracking: merkt sich, welche Wochen bzw. ob die globale Personenliste // seit dem letzten Sync lokal geändert wurden. Gespeichert wird nur das, was // sich wirklich geändert hat (statt wie früher immer alle Wochen). let dirtyWeeks = new Set(); let dirtyPeople = false; // In-Flight-Schutz für syncAllToServer: läuft bereits ein Sync, wird kein // zweiter gestartet (sonst senden zwei Syncs dieselbe Woche mit gleicher // Revisions-Basis und der zweite bekäme einen scheinbaren 409-Konflikt). // Stattdessen wird ein Nachlauf-Sync eingereiht und nach Abschluss ausgeführt. let syncInFlight = false; let syncQueued = false; // scheduleSessionSync: plant den Speichervorgang für alle Wochen und Personen // über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender // Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden. function scheduleSessionSync(){ if(saveSyncTimer){ clearTimeout(saveSyncTimer); } saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500); } // syncAllToServer: schreibt nur die seit dem letzten Sync geänderten Wochen // plus – falls markiert – die globale Personenliste zum Server. Konflikte // (409) auf einer Woche werden über handleWeekConflict() gemeldet. Fehlerhaft // gespeicherte Wochen bleiben dirty, damit der nächste Sync sie erneut // versucht; nur erfolgreich gespeicherte werden aus dem Dirty-Satz entfernt. function syncAllToServer(){ if(syncInFlight){ syncQueued = true; return; } syncInFlight = true; // Zuerst globale Personenliste speichern, falls geändert const jobs = []; if(dirtyPeople) jobs.push(saveGlobalPeopleToServer()); const weekLabels = Array.from(dirtyWeeks); weekLabels.forEach(label => { const w = db.weeks.find(x => x.label === label); if(!w){ dirtyWeeks.delete(label); return; } jobs.push(sendWeekToServer(w) .then(res => { if(res && res.ok){ console.log('Sitzung gespeichert:', w.label); // Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves // nicht fälschlich als Konflikt gemeldet werden. if(res.updatedAt) w.updatedAt = res.updatedAt; dirtyWeeks.delete(label); } else { console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler'); } }) .catch(err => { if(err && err.conflict){ dirtyWeeks.delete(label); handleWeekConflict(w, err); } else { console.error('Sitzung konnte nicht gespeichert werden:', w.label, err.message || err); // Label bleibt dirty -> der nächste Sync versucht es erneut. } })); }); Promise.all(jobs).then(() => { syncInFlight = false; if(syncQueued){ syncQueued = false; syncAllToServer(); } }); } // markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für // Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock). function markWeekDirty(label){ if(typeof label === 'string' && label) dirtyWeeks.add(label); } // markPeopleDirty: markiert die globale Personenliste als geändert. function markPeopleDirty(){ dirtyPeople = true; } // handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich // geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu // geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste // bleiben unangetastet und gehen nicht verloren. function handleWeekConflict(week, err){ const label = (week && week.label) || (err && err.label) || ''; alert('Achtung: ' + label + ' wurde zwischenzeitlich von einer anderen Person ' + 'geändert. Ihre Änderungen an dieser Sitzung wurden nicht gespeichert; ' + 'die aktuellen Daten dieser Sitzung werden geladen.'); if(label) reloadWeekFromServer(label); else load(); } // reloadWeekFromServer: lädt die aktuelle Abteilung neu und ersetzt NUR die // übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde). // Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von // syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten. function reloadWeekFromServer(label){ const requestedDepartment = currentDepartment; fetch(baseUrl() + '/load-all' + departmentParam()) .then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }) .then(data => { if(currentDepartment !== requestedDepartment) return; const fresh = (Array.isArray(data.weeks) ? data.weeks : []) .find(w => w && w.label === label); const idx = db.weeks.findIndex(w => w.label === label); if(idx >= 0){ if(fresh){ // Felder absichern (wie in load()), damit keine kaputten Daten crashen if(!Array.isArray(fresh.people)) fresh.people = []; if(!Array.isArray(fresh.projects)) fresh.projects = []; if(!fresh.attendance) fresh.attendance = {}; db.weeks[idx] = fresh; } else { db.weeks.splice(idx, 1); } // Wurde die aktuelle Woche entfernt, auf die letzte verbleibende wechseln if(!db.weeks.some(w => w.id === db.currentWeek)){ db.currentWeek = db.weeks.length ? db.weeks[db.weeks.length - 1].id : null; } } renderAll(); showTab(currentTab); }) .catch(err => { console.error('Neu laden der Sitzung fehlgeschlagen:', err); load(); }); } // saveGlobalPeopleToServer: speichert die globale Personenliste der aktuellen // Abteilung über `sendPeopleToServer()` (JSON-Array). Auch eine leere Liste // wird gespeichert, damit das Löschen der letzten Person erhalten bleibt. Bei // Erfolg wird der Dirty-Status für Personen zurückgesetzt, bei Fehler bleibt // er erhalten (Retry beim nächsten Sync). Rückgabe: Promise. function saveGlobalPeopleToServer(){ const people = db.globalPeople[currentDepartment] || []; return sendPeopleToServer(people) .then(res => { if(res && res.ok){ dirtyPeople = false; console.log('Globale Personenliste gespeichert'); } else { console.error('Globale Personenliste konnte nicht gespeichert werden:', res && res.error ? res.error : 'unbekannter Fehler'); } }) .catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); // dirtyPeople bleibt true -> der nächste Sync versucht es erneut. }); } // switchDepartment: wechselt die aktive Abteilung (z.B. "ELT") und lädt deren // Daten neu. Parameter `dept` ist der Abteilungsschlüssel aus `DEPARTMENTS`. // Aktualisiert Tab-Markierung und Seitentitel und ruft anschließend `load()`. // Gibt nichts zurück. function switchDepartment(dept){ if(!DEPARTMENTS[dept]) return; // Laufenden Speicher-Debounce der alten Abteilung verwerfen, damit dessen // Sync nicht nach dem Reset mit dem neuen currentDepartment feuert und die // Daten der falschen Abteilung schreibt. if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; } dirtyWeeks.clear(); dirtyPeople = false; currentDepartment = dept; saveUiState(); updateRouteUrl(dept, currentTab); // UI-Status der Abteilungs-Tabs aktualisieren document.querySelectorAll('.dept-tab').forEach(btn => { btn.classList.toggle('active', btn.dataset.dept === dept); }); // Seitentitel anpassen const info = DEPARTMENTS[dept]; document.title = 'Projektübersicht ' + info.label; const titleEl = document.getElementById('pageTitle'); if(titleEl) titleEl.textContent = info.icon + ' Projektübersicht ' + info.label; // Datenbank leeren und neu laden db = { weeks: [], currentWeek: null, globalPeople: db.globalPeople || {} }; load(); } // load: liest alle Sitzungen und die globale Personenliste der aktuellen // Abteilung über `/load-all` als JSON vom Server und baut daraus `db.weeks` // auf. Sortiert nach `weekSortKey()`, wendet `applyAutoLock()` an und rendert // die Ansicht. Kein Parameter, keine Rückgabe; bei Fehlern oder fehlenden // Daten werden Beispieldaten über `seed()` geladen. function load(){ // Nach einem (Neu-)Load sind die lokalen Daten serverseitig aktuell: // laufenden Debounce abbrechen und Dirty-Zustand verwerfen. if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; } dirtyWeeks.clear(); dirtyPeople = false; if(!window.fetch){ alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.'); seed(); return; } // Abteilung einfrieren, für die diese Anfrage gestartet wurde. Antworten, // die nach einem zwischenzeitlichen Abteilungswechsel eintreffen, werden // verworfen (Stale-Response), sonst würden Daten der alten Abteilung unter // der neuen Abteilung angezeigt und gespeichert. const requestedDepartment = currentDepartment; const url = baseUrl() + '/load-all' + departmentParam(); console.log('Loading from', url); fetch(url) .then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }) .then(data => { if(currentDepartment !== requestedDepartment) return; // Globale Personenliste laden db.globalPeople[currentDepartment] = Array.isArray(data.people) ? data.people : []; db.weeks = (Array.isArray(data.weeks) ? data.weeks : []) .filter(w => w && typeof w.label === 'string') .map(w => { // Felder absichern, damit alte/kaputte Dokumente nicht crashen if(!Array.isArray(w.people)) w.people = []; if(!Array.isArray(w.projects)) w.projects = []; if(!w.attendance) w.attendance = {}; return w; }); if(db.weeks.length === 0){ console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.'); seed(); } else { db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); applyAutoLock(); const latest = db.weeks[db.weeks.length - 1]; if(!db.currentWeek || !db.weeks.some(w => w.id === db.currentWeek)){ db.currentWeek = latest.id; } console.log(`Server-Load erfolgreich: ${db.weeks.length} Sitzung(en).`); } // Geschützte Tabs ('projects'/'people') dürfen nur mit Passwort angezeigt // werden. showTab() öffnet dafür automatisch das Passwort-Modal – auch // wenn der Tab per Deep-Link-URL (z. B. "/ELT/Projekte") gewählt wurde. renderAll(); showTab(currentTab); }) .catch(err => { if(currentDepartment !== requestedDepartment) return; console.error('Laden vom Server fehlgeschlagen:', err); const serverUrl = baseUrl(); const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' + 'Mögliche Ursachen:\n' + '- Der Server wurde nicht gestartet\n' + '- Der Server läuft auf einem anderen Port (Standard: 3003)\n' + '- Firewall/Antivirus blockiert ' + serverUrl + '\n\n' + 'Es werden jetzt die Beispieldaten angezeigt.'; alert(msg); seed(); renderAll(); }); } // seed: initialisiert Beispiel-Daten (Personen + eine Woche mit Projekten). // Wird als Fallback verwendet, wenn der Server nicht erreichbar ist oder keine // Sitzungsdateien liefert. function seed(){ const people = [ {id:personIdForName('AP'), name:"AP", fullName:"Anton Peters", role:["Planung"]}, {id:personIdForName('SK'), name:"SK", fullName:"Stefan Krause", role:["Planung"]}, {id:personIdForName('AK'), name:"AK", fullName:"Alexander Klein", role:["Planung"]}, {id:personIdForName('ED'), name:"ED", fullName:"Eva Dreyer", role:["Planung"]}, {id:personIdForName('SE'), name:"SE", fullName:"Stefan Eschweiler", role:["Planung"]}, {id:personIdForName('THK'), name:"THK", fullName:"Thorsten H. Klein", role:["Projektleitung"]}, {id:personIdForName('SP'), name:"SP", fullName:"Sven Peschke", role:["Projektleitung"]}, {id:personIdForName('SG'), name:"SG", fullName:"Sandra Gloag", role:["Projektleitung"]}, {id:personIdForName('TW'), name:"TW", fullName:"Thorsten Wörster", role:["Projektleitung"]}, {id:personIdForName('RR'), name:"RR", fullName:"Robert Rahn", role:["Bauüberwachung"]}, {id:personIdForName('MK'), name:"MK", fullName:"Martin Kluge", role:["Bauüberwachung"]}, {id:personIdForName('HR'), name:"HR", fullName:"Hartwig Rauch", role:["Bauüberwachung"]}, ]; db.globalPeople[currentDepartment] = JSON.parse(JSON.stringify(people)); const wid = uid(); db.weeks = [{ id: wid, label:"KW 30 / 2026", date:"2026-07-24", attendance:{}, people, projects: [ demoProj("18-4007","Janusz-Korczak-Gesamtsch. Bottrop","Aktive Projekte","LPH5 als Vorabzug raus. NW-Räume vorauss. August.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":95}), demoProj("21-4030","NSHV Köln Immobilienprüfung in Hof","Aktive Projekte","Thorsten W. klärt und übernimmt Schriftverkehr. LV liegt zur Sichtung bei T.W.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100}), demoProj("22-4056","Gymnasium Hemer","Aktive Projekte","",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":90}), demoProj("22-4066","DSW 21 – Umbau E-Mobilität","Aktive Projekte","Elt-Montagen ab KW 33.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":20}), demoProj("24-4043","S-K ODL-Heim Sanierung Krefeld","Aktive Projekte","Voraussichtlich LV-Abgabe.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100}), ] }]; db.currentWeek = wid; save(); } // demoProj: Helfer zum Erzeugen eines Projekt-Objekts (für seed/Tests) function demoProj(nr,name,group,status,lph){ return {id:uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null, aufwandProLP: undefined, aktuelleLP: undefined}; }