321 lines
15 KiB
JavaScript
321 lines
15 KiB
JavaScript
// -----------------------------
|
||
// 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};
|
||
} |