- Neuer Reiter „Notizen“ im Projektbereich: Liste mit Suche und angehefteten Notizen, Editor mit Formatierung, automatisches Speichern, Mail per Drag & Drop oder Strg+V als neue Notiz, live bei allen - Reiter „Leistungsphasen“ entfernt (Phasen weiter im Terminplan) - Behoben: Umlaute in .eml-Dateien ohne Transfer-Kodierung gingen verloren (betraf auch „Termin aus Mail“) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
972 lines
48 KiB
JavaScript
972 lines
48 KiB
JavaScript
// -----------------------------
|
||
// Projektübersicht — Daten laden, Speichern
|
||
// -----------------------------
|
||
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt beim Start alle
|
||
// (firmenweit globalen, nicht mehr abteilungsgetrennten) Sitzungen und speichert
|
||
// den kompletten Datenbestand (alle Wochen + globale Personenliste). Die Daten
|
||
// liegen serverseitig in einer SQLite-Datenbank und werden als strukturierte
|
||
// JSON-Objekte über die API ausgetauscht (keine CSV-Strings). Sie wird von
|
||
// `App.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
|
||
// die globalen Funktionen `save()`, `syncAllToServer()`, `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)
|
||
function save(opts = {}){
|
||
if(opts.week !== false){
|
||
const w = currentWeek();
|
||
if(w) markWeekDirty(w.label);
|
||
}
|
||
scheduleSessionSync();
|
||
}
|
||
|
||
// Dirty-Tracking: merkt sich, welche Wochen 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). Die globale Personenliste hat seit der
|
||
// Ablösung von people/person_roles/person_absences keinen eigenen Schreibpfad
|
||
// mehr (Projektbeteiligte werden über die Benutzerverwaltung gepflegt).
|
||
let dirtyWeeks = new Set();
|
||
|
||
// weekChangeCounter: zählt je Woche die Änderungen mit. syncAllToServer merkt
|
||
// sich den Stand beim Absenden und räumt die Dirty-Markierung nur auf, wenn er
|
||
// sich bis zur Antwort nicht verändert hat. Ohne diesen Zähler verlor eine
|
||
// Änderung, die WÄHREND eines laufenden Speichervorgangs entsteht, ihre
|
||
// Markierung: Der eingereihte Nachlauf-Sync fand dann nichts mehr zu senden,
|
||
// und das Getippte stand nur noch im Browser.
|
||
const weekChangeCounter = new Map();
|
||
|
||
// syncProblemTimer: Wiederholungsversuch nach einem fehlgeschlagenen Speichern.
|
||
let syncProblemTimer = null;
|
||
|
||
// 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;
|
||
|
||
// showSyncProblem: blendet unten rechts ein Band ein, wenn das Speichern
|
||
// fehlschlägt. Vorher stand der Fehler ausschließlich in der Browser-Konsole —
|
||
// der Nutzer arbeitete ahnungslos weiter, während seit Stunden nichts mehr
|
||
// gespeichert wurde. Parameter: message (Text) oder null zum Ausblenden.
|
||
function showSyncProblem(message){
|
||
let el = document.getElementById('syncProblemBar');
|
||
if(!message){
|
||
if(el) el.style.display = 'none';
|
||
return;
|
||
}
|
||
if(!el){
|
||
el = document.createElement('div');
|
||
el.id = 'syncProblemBar';
|
||
el.className = 'sync-problem-bar';
|
||
document.body.appendChild(el);
|
||
}
|
||
el.textContent = message;
|
||
el.style.display = '';
|
||
}
|
||
|
||
// scheduleSyncRetry: versucht ein fehlgeschlagenes Speichern nach 20 Sekunden
|
||
// erneut. Ohne das blieb eine Woche nach einem Netzaussetzer dauerhaft
|
||
// ungespeichert, bis der Nutzer zufällig etwas anderes bearbeitete.
|
||
function scheduleSyncRetry(){
|
||
if(syncProblemTimer || dirtyWeeks.size === 0) return;
|
||
syncProblemTimer = setTimeout(() => {
|
||
syncProblemTimer = null;
|
||
if(dirtyWeeks.size > 0) syncAllToServer();
|
||
}, 20000);
|
||
}
|
||
|
||
// 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
|
||
// 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;
|
||
|
||
const jobs = [];
|
||
const weekLabels = Array.from(dirtyWeeks);
|
||
let problem = '';
|
||
weekLabels.forEach(label => {
|
||
// Die Planung-Sandbox steckt bewusst nicht in db.weeks (siehe
|
||
// db.sandboxWeek, oben in load()) — ohne diesen Zweig würde jede
|
||
// Sandbox-Änderung hier lautlos verworfen (dirtyWeeks.delete ohne
|
||
// jemals zu speichern), weil db.weeks.find() sie nie findet.
|
||
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : db.weeks.find(x => x.label === label);
|
||
if(!w){ dirtyWeeks.delete(label); return; }
|
||
// Stand der Änderungen zum Zeitpunkt des Absendens merken (siehe
|
||
// weekChangeCounter): Nur wenn er bei der Antwort unverändert ist, sind
|
||
// wirklich alle Änderungen dieser Woche beim Server angekommen.
|
||
const sentGeneration = weekChangeCounter.get(label) || 0;
|
||
// Nur die geänderten Projekte schicken, wenn sich an der Sitzung selbst
|
||
// nichts geändert hat (geaenderteProjekte(), siehe unten). Sonst — oder
|
||
// ohne bekannten Server-Stand — wie bisher die ganze Sitzung.
|
||
const delta = geaenderteProjekte(wochenBasis.get(label), w);
|
||
if(delta && delta.length === 0){
|
||
// Nichts zu senden (z. B. eine Änderung, die wieder zurückgenommen wurde).
|
||
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
|
||
return;
|
||
}
|
||
if(delta){
|
||
const gesendeteProjekte = delta.map(p => JSON.parse(JSON.stringify(p)));
|
||
jobs.push(sendProjectsToServer(label, gesendeteProjekte)
|
||
.then(res => {
|
||
console.log('Projekte gespeichert:', label, gesendeteProjekte.length);
|
||
const basis = wochenBasis.get(label);
|
||
gesendeteProjekte.forEach(gp => {
|
||
const rev = (res.projekte || {})[gp.id] || res.updatedAt;
|
||
gp.updatedAt = rev;
|
||
const lokal = (w.projects || []).find(x => x.id === gp.id);
|
||
if(lokal) lokal.updatedAt = rev;
|
||
// Basis nur für die gesendeten Projekte nachziehen — fremde
|
||
// Änderungen, die während des Speicherns eintrafen, stehen dort schon.
|
||
if(basis){
|
||
const i = basis.projects.findIndex(x => x.id === gp.id);
|
||
if(i >= 0) basis.projects[i] = gp;
|
||
}
|
||
});
|
||
revisionUebernehmen(w, res.updatedAt);
|
||
konfliktVersuche.delete(label);
|
||
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
|
||
})
|
||
.catch(err => { problem = speicherFehler(label, w, err) || problem; }));
|
||
return;
|
||
}
|
||
// Genau dieser Stand geht an den Server — nach Erfolg ist er die neue
|
||
// Basis für ein späteres Zusammenführen (wochenBasis).
|
||
const gesendet = wocheKopie(w);
|
||
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;
|
||
gesendet.updatedAt = w.updatedAt;
|
||
// Das ganze Speichern setzt am Server JEDES Projekt der Sitzung auf
|
||
// diese Revision (projects.updated_at) — hier nachziehen, sonst
|
||
// scheiterte das nächste Speichern einzelner Projekte als Konflikt.
|
||
(w.projects || []).forEach(x => { x.updatedAt = w.updatedAt; });
|
||
(gesendet.projects || []).forEach(x => { x.updatedAt = w.updatedAt; });
|
||
wochenBasis.set(label, gesendet);
|
||
konfliktVersuche.delete(label);
|
||
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
|
||
} else {
|
||
problem = 'Sitzung "' + w.label + '" konnte nicht gespeichert werden.';
|
||
console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
|
||
}
|
||
})
|
||
.catch(err => { problem = speicherFehler(label, w, err) || problem; }));
|
||
});
|
||
|
||
Promise.all(jobs).then(() => {
|
||
syncInFlight = false;
|
||
showSyncProblem(problem || null);
|
||
if(problem) scheduleSyncRetry();
|
||
if(syncQueued){ syncQueued = false; syncAllToServer(); }
|
||
});
|
||
}
|
||
|
||
// speicherFehler: gemeinsame Fehlerbehandlung beider Speicherwege. Rückgabe:
|
||
// Text für das Fehlerband oder '' (Konflikt: wird zusammengeführt).
|
||
function speicherFehler(label, w, err){
|
||
if(err && err.conflict){
|
||
// Jemand anderes hat die Sitzung bzw. das Projekt inzwischen gespeichert:
|
||
// nicht verwerfen, sondern zusammenführen und erneut speichern.
|
||
zusammenfuehrenUndSpeichern(label, w, err);
|
||
return '';
|
||
}
|
||
if(err && err.status === 403){
|
||
// Fehlendes Recht (Rollen & Rechte): Ein erneuter Versuch hilft nicht.
|
||
// Änderung verwerfen, Hinweis zeigen und den Stand vom Server holen.
|
||
dirtyWeeks.delete(label);
|
||
zeigeHinweis((err.message || 'Keine Berechtigung.') + ' Die Änderung wurde nicht gespeichert.');
|
||
reloadWeekFromServer(label);
|
||
return '';
|
||
}
|
||
if(err && err.status === 401){
|
||
// Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still
|
||
// weiterzuarbeiten und alles Weitere zu verlieren.
|
||
if(typeof showLoginOverlay === 'function') showLoginOverlay();
|
||
console.error('Speichern abgelehnt (nicht angemeldet):', label);
|
||
return 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.';
|
||
}
|
||
console.error('Sitzung konnte nicht gespeichert werden:', label, (err && err.message) || err);
|
||
// Label bleibt dirty -> der nächste Sync versucht es erneut.
|
||
return 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.';
|
||
}
|
||
|
||
// geaenderteProjekte: vergleicht eine Sitzung mit ihrem zuletzt bekannten
|
||
// Server-Stand. Rückgabe: die geänderten Projekte (Array, evtl. leer), wenn
|
||
// sich NUR Projektinhalte geändert haben; null, wenn die ganze Sitzung
|
||
// gespeichert werden muss — Teilnehmer, Anwesenheit, Sperre, Datum, ein
|
||
// angelegtes, gelöschtes oder umsortiertes Projekt, oder kein Server-Stand.
|
||
function geaenderteProjekte(basis, w){
|
||
if(!basis || !w) return null;
|
||
const rahmen = x => { const k = Object.assign({}, x); delete k.projects; delete k.updatedAt; return k; };
|
||
if(!gleich(rahmen(basis), rahmen(w))) return null;
|
||
const bp = basis.projects || [], wp = w.projects || [];
|
||
if(bp.length !== wp.length || bp.some((p, i) => p.id !== wp[i].id)) return null;
|
||
return wp.filter((p, i) => !gleich(p, bp[i]));
|
||
}
|
||
|
||
// revisionUebernehmen: setzt die Revision einer Sitzung (lokal und Basis),
|
||
// aber nie zurück — Antworten und Ereignisse können sich überholen.
|
||
function revisionUebernehmen(w, rev){
|
||
if(!w || !rev) return;
|
||
if(!w.updatedAt || rev > w.updatedAt) w.updatedAt = rev;
|
||
const basis = wochenBasis.get(w.label);
|
||
if(basis && (!basis.updatedAt || rev > basis.updatedAt)) basis.updatedAt = rev;
|
||
}
|
||
|
||
// 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) return;
|
||
dirtyWeeks.add(label);
|
||
weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Gleichzeitiges Arbeiten: Zusammenführen statt Verwerfen
|
||
// ---------------------------------------------------------------------------
|
||
// Eine Sitzung wird immer als Ganzes gespeichert (POST /save-week mit
|
||
// Revisionsstand). Hat jemand anderes sie inzwischen gespeichert, antwortet
|
||
// der Server mit 409. Früher verwarf der Browser daraufhin die eigenen
|
||
// Änderungen mit einem alert() — bei zwei Personen in derselben Sitzung
|
||
// passierte das ständig, auch wenn sie an verschiedenen Projekten arbeiteten.
|
||
//
|
||
// Jetzt merkt sich der Browser je Sitzung den Stand, den er zuletzt vom
|
||
// Server kennt (wochenBasis). Bei einem 409 holt er den aktuellen Stand und
|
||
// führt drei Fassungen zusammen (dreiwegeZusammenfuehren(): Basis, eigene,
|
||
// fremde): Was nur eine Seite geändert hat, wird übernommen — bis hinunter zu
|
||
// einzelnen Feldern, Projekten, Aufgaben/Meilensteinen (über ihre id),
|
||
// Kommentaren je Gewerk und der Anwesenheit je Person. Nur wenn beide
|
||
// dasselbe Feld unterschiedlich geändert haben, gilt die eigene (jüngste)
|
||
// Fassung, und ein kurzer Hinweis nennt das Projekt. Danach wird erneut
|
||
// gespeichert. Das Ergebnis wird IN die vorhandenen Objekte geschrieben
|
||
// (inPlaceUebernehmen()), damit z. B. der Projektbereich, der eine Referenz
|
||
// auf sein Projekt hält (window.PlanApp), weiter auf dasselbe Objekt zeigt.
|
||
//
|
||
// Zusätzlich gleicht wochenLiveAbgleich() alle 20 Sekunden ab: Hat sich eine
|
||
// Sitzung am Server geändert und gibt es hier nichts Ungespeichertes, werden
|
||
// die fremden Änderungen übernommen — außer während jemand gerade tippt oder
|
||
// ein Dialog offen ist (dann beim nächsten Durchlauf).
|
||
// ===========================================================================
|
||
|
||
const wochenBasis = new Map(); // label -> zuletzt bekannter Server-Stand
|
||
const konfliktVersuche = new Map(); // label -> Zahl der Zusammenführungen in Folge
|
||
|
||
function wocheKopie(w){ return JSON.parse(JSON.stringify(w)); }
|
||
function basisMerken(w){ if(w && w.label) wochenBasis.set(w.label, wocheKopie(w)); }
|
||
|
||
function istObjekt(x){ return !!x && typeof x === 'object' && !Array.isArray(x); }
|
||
function istIdListe(a){ return Array.isArray(a) && a.every(x => istObjekt(x) && x.id !== undefined && x.id !== null && x.id !== ''); }
|
||
// gleich: inhaltlicher Vergleich, unabhängig von der Reihenfolge der Felder
|
||
// (inPlaceUebernehmen() lässt vorhandene Felder an ihrer Stelle und hängt
|
||
// neue an — JSON.stringify allein hielte dann Gleiches für verschieden, und
|
||
// zwei Browser schickten sich über die Live-Ereignisse endlos dieselben
|
||
// Projekte zu). undefined-Felder zählen wie fehlende.
|
||
function stabilerText(x){
|
||
if(Array.isArray(x)) return '[' + x.map(v => v === undefined ? 'null' : stabilerText(v)).join(',') + ']';
|
||
if(x && typeof x === 'object'){
|
||
return '{' + Object.keys(x).filter(k => x[k] !== undefined).sort()
|
||
.map(k => JSON.stringify(k) + ':' + stabilerText(x[k])).join(',') + '}';
|
||
}
|
||
return x === undefined ? 'null' : JSON.stringify(x);
|
||
}
|
||
function gleich(a, b){ return a === b || stabilerText(a) === stabilerText(b); }
|
||
|
||
// dreiwegeZusammenfuehren: Basis, eigene und fremde Fassung → Ergebnis.
|
||
// konflikte sammelt die Stellen, an denen beide Seiten dasselbe
|
||
// unterschiedlich geändert haben (dort gewinnt die eigene Fassung).
|
||
// Parameter: basis, eigen, fremd, pfad (Text für Hinweise), konflikte (Array).
|
||
function dreiwegeZusammenfuehren(basis, eigen, fremd, pfad, konflikte){
|
||
if(gleich(eigen, fremd)) return eigen;
|
||
if(basis !== undefined && gleich(eigen, basis)) return fremd;
|
||
if(basis !== undefined && gleich(fremd, basis)) return eigen;
|
||
if(istObjekt(eigen) && istObjekt(fremd)){
|
||
const b = istObjekt(basis) ? basis : {};
|
||
const ergebnis = {};
|
||
new Set([...Object.keys(fremd), ...Object.keys(eigen)]).forEach(k => {
|
||
const inE = k in eigen, inF = k in fremd, inB = k in b;
|
||
if(inE && inF){ ergebnis[k] = dreiwegeZusammenfuehren(inB ? b[k] : undefined, eigen[k], fremd[k], pfad, konflikte); return; }
|
||
if(inE){ // fremd fehlt: dort gelöscht (wenn in der Basis) oder hier neu
|
||
if(inB && gleich(eigen[k], b[k])) return; // fremd hat gelöscht, hier unverändert
|
||
if(inB) konflikte.push(pfad); // hier geändert, dort gelöscht → behalten
|
||
ergebnis[k] = eigen[k]; return;
|
||
}
|
||
// eigen fehlt
|
||
if(inB && gleich(fremd[k], b[k])) return; // hier gelöscht, dort unverändert
|
||
if(inB){ konflikte.push(pfad); return; } // hier gelöscht, dort geändert → gelöscht lassen
|
||
ergebnis[k] = fremd[k]; // dort neu
|
||
});
|
||
return ergebnis;
|
||
}
|
||
if(istIdListe(eigen) && istIdListe(fremd)){
|
||
const bListe = istIdListe(basis) ? basis : [];
|
||
const bMap = new Map(bListe.map(x => [String(x.id), x]));
|
||
const eMap = new Map(eigen.map(x => [String(x.id), x]));
|
||
const fMap = new Map(fremd.map(x => [String(x.id), x]));
|
||
// Reihenfolge: hat nur diese Seite umsortiert, gilt ihre, sonst die fremde;
|
||
// Neues der anderen Seite wird an seiner Stelle angehängt.
|
||
const ids = l => l.map(x => String(x.id));
|
||
const eigeneReihe = !gleich(ids(eigen), ids(bListe)) && gleich(ids(fremd), ids(bListe));
|
||
const reihe = eigeneReihe ? ids(eigen) : ids(fremd);
|
||
(eigeneReihe ? ids(fremd) : ids(eigen)).forEach(id => { if(!reihe.includes(id)) reihe.push(id); });
|
||
const ergebnis = [];
|
||
reihe.forEach(id => {
|
||
const e = eMap.get(id), f = fMap.get(id), b = bMap.get(id);
|
||
const name = (e || f || {}).name || (e || f || {}).title || id;
|
||
const unterpfad = pfad ? pfad : name;
|
||
if(e && f){ ergebnis.push(dreiwegeZusammenfuehren(b, e, f, unterpfad, konflikte)); return; }
|
||
if(e){ if(b && gleich(e, b)) return; if(b) konflikte.push(unterpfad); ergebnis.push(e); return; }
|
||
if(f){ if(b && gleich(f, b)) return; if(b){ konflikte.push(unterpfad); return; } ergebnis.push(f); }
|
||
});
|
||
return ergebnis;
|
||
}
|
||
konflikte.push(pfad);
|
||
return eigen;
|
||
}
|
||
|
||
// inPlaceUebernehmen: schreibt quelle in das bestehende Objekt ziel — Objekte
|
||
// mit gleicher id (Projekte, Aufgaben, Meilensteine, Personen) bleiben
|
||
// dieselben Objekte, damit offene Ansichten ihre Referenzen behalten.
|
||
function inPlaceUebernehmen(ziel, quelle){
|
||
Object.keys(ziel).forEach(k => { if(!(k in quelle)) delete ziel[k]; });
|
||
Object.keys(quelle).forEach(k => {
|
||
const z = ziel[k], q = quelle[k];
|
||
if(istObjekt(z) && istObjekt(q)) inPlaceUebernehmen(z, q);
|
||
else if(istIdListe(z) && istIdListe(q)){
|
||
const alt = new Map(z.map(x => [String(x.id), x]));
|
||
const neu = q.map(x => { const o = alt.get(String(x.id)); if(o){ inPlaceUebernehmen(o, x); return o; } return x; });
|
||
z.length = 0; neu.forEach(x => z.push(x));
|
||
} else ziel[k] = q;
|
||
});
|
||
}
|
||
|
||
// wocheVomServer: holt den aktuellen Stand EINER Sitzung (über /load-all,
|
||
// die Sandbox steckt dort in sandboxPlans). Rückgabe: Promise<Woche|null>.
|
||
// Seit GET /week nur diese eine Sitzung statt aller über /load-all.
|
||
function wocheVomServer(label){
|
||
return fetch(baseUrl() + '/week?label=' + encodeURIComponent(label))
|
||
.then(r => {
|
||
if(r.status === 404) return null;
|
||
if(!r.ok) throw new Error('Server HTTP ' + r.status);
|
||
return r.json().then(data => {
|
||
const w = data && data.week;
|
||
if(!w) return null;
|
||
if(!Array.isArray(w.people)) w.people = [];
|
||
if(!Array.isArray(w.projects)) w.projects = [];
|
||
if(!w.attendance) w.attendance = {};
|
||
if(label === SANDBOX_WEEK_LABEL) return { label, projects: w.projects, updatedAt: w.updatedAt || '' };
|
||
return w;
|
||
});
|
||
});
|
||
}
|
||
|
||
// zusammenfuehren: führt eine lokale Woche mit einem frischen Server-Stand
|
||
// zusammen und schreibt das Ergebnis in die lokale Woche. Rückgabe: Array
|
||
// der Projekte/Stellen mit echtem Konflikt.
|
||
function zusammenfuehren(lokal, frisch){
|
||
const basis = wochenBasis.get(lokal.label);
|
||
const ohneRev = w => { const k = wocheKopie(w); delete k.updatedAt; return k; };
|
||
const konflikte = [];
|
||
let ergebnis;
|
||
if(lokal.label === SANDBOX_WEEK_LABEL){
|
||
// Die Sandbox: nur die Terminpläne zusammenführen, der Rest ist lokal gebaut.
|
||
ergebnis = Object.assign({}, ohneRev(lokal), {
|
||
projects: dreiwegeZusammenfuehren(basis ? basis.projects : undefined, wocheKopie(lokal.projects), frisch.projects, '', konflikte)
|
||
});
|
||
} else {
|
||
ergebnis = dreiwegeZusammenfuehren(basis ? ohneRev(basis) : undefined, ohneRev(lokal), ohneRev(frisch), '', konflikte);
|
||
}
|
||
inPlaceUebernehmen(lokal, ergebnis);
|
||
lokal.updatedAt = frisch.updatedAt;
|
||
const serverStand = lokal.label === SANDBOX_WEEK_LABEL ? Object.assign(wocheKopie(lokal), { projects: wocheKopie(frisch.projects) }) : wocheKopie(frisch);
|
||
wochenBasis.set(lokal.label, serverStand);
|
||
return [...new Set(konflikte.filter(Boolean))];
|
||
}
|
||
|
||
// zusammenfuehrenUndSpeichern: Antwort auf einen 409 beim Speichern.
|
||
function zusammenfuehrenUndSpeichern(label, lokal, err){
|
||
const n = (konfliktVersuche.get(label) || 0) + 1;
|
||
konfliktVersuche.set(label, n);
|
||
if(n > 3){
|
||
// Ungewöhnlich: dreimal in Folge überholt. Dann der alte, sichere Weg.
|
||
konfliktVersuche.delete(label);
|
||
dirtyWeeks.delete(label);
|
||
handleWeekConflict(lokal, err);
|
||
return;
|
||
}
|
||
wocheVomServer(label).then(frisch => {
|
||
if(!frisch){
|
||
// Die Sitzung wurde am Server gelöscht — hier nicht wiederbeleben.
|
||
dirtyWeeks.delete(label);
|
||
handleWeekConflict(lokal, err);
|
||
return;
|
||
}
|
||
const konflikte = zusammenfuehren(lokal, frisch);
|
||
markWeekDirty(label);
|
||
ansichtAuffrischen();
|
||
if(konflikte.length){
|
||
zeigeHinweis('Gleichzeitig geändert: ' + konflikte.slice(0, 3).join(', ')
|
||
+ (konflikte.length > 3 ? ' …' : '') + '. Ihre Fassung wurde übernommen.');
|
||
}
|
||
scheduleSessionSync();
|
||
}).catch(e => {
|
||
console.error('Zusammenführen fehlgeschlagen:', e);
|
||
showSyncProblem('Speichern fehlgeschlagen — neuer Versuch in 20 Sekunden.');
|
||
scheduleSyncRetry();
|
||
});
|
||
}
|
||
|
||
// ansichtAuffrischen: zeichnet die offene Ansicht nach übernommenen Daten neu.
|
||
function ansichtAuffrischen(){
|
||
if(typeof renderAll === 'function') renderAll();
|
||
if(typeof currentTab !== 'undefined' && currentTab === 'plan'){
|
||
if(typeof renderPlanGantt === 'function') renderPlanGantt();
|
||
} else if(typeof currentTab !== 'undefined' && currentTab === 'wand'){
|
||
if(typeof renderWandboard === 'function') renderWandboard();
|
||
}
|
||
}
|
||
|
||
// zeigeHinweis: kurze, nicht blockierende Meldung unten rechts (8 s).
|
||
let hinweisTimer = null;
|
||
function zeigeHinweis(text){
|
||
let el = document.getElementById('syncHinweis');
|
||
if(!el){
|
||
el = document.createElement('div');
|
||
el.id = 'syncHinweis';
|
||
el.className = 'sync-problem-bar sync-hinweis';
|
||
document.body.appendChild(el);
|
||
}
|
||
el.textContent = text;
|
||
el.style.display = '';
|
||
clearTimeout(hinweisTimer);
|
||
hinweisTimer = setTimeout(() => { el.style.display = 'none'; }, 8000);
|
||
}
|
||
|
||
// wirdGeradeBearbeitet: tippt jemand oder ist ein Dialog offen? Dann keine
|
||
// fremden Änderungen einspielen — ein Neuzeichnen würde Eingaben verwerfen.
|
||
function wirdGeradeBearbeitet(){
|
||
const a = document.activeElement;
|
||
if(a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))) return true;
|
||
return [...document.querySelectorAll('.modal-bg')].some(m => m.style.display && m.style.display !== 'none');
|
||
}
|
||
|
||
// wochenLiveAbgleich: übernimmt Änderungen anderer, ohne dass jemand neu
|
||
// laden muss. Läuft alle 20 Sekunden; die Wandtafel lädt ohnehin selbst.
|
||
let liveAbgleichLaeuft = false;
|
||
function wochenLiveAbgleich(){
|
||
if(liveAbgleichLaeuft || !currentUser || !hatRecht('app')) return;
|
||
if(syncInFlight || dirtyWeeks.size > 0 || wirdGeradeBearbeitet()) return;
|
||
if(!Array.isArray(db.weeks) || db.weeks.length === 0) return;
|
||
liveAbgleichLaeuft = true;
|
||
fetch(baseUrl() + '/week-revisionen')
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(data => {
|
||
if(!data || !data.ok) return;
|
||
const rev = data.revisionen || {};
|
||
const lokal = db.weeks.concat(db.sandboxWeek ? [db.sandboxWeek] : []);
|
||
const geaendert = lokal.filter(w => w.label in rev && rev[w.label] && rev[w.label] !== (w.updatedAt || ''));
|
||
if(!geaendert.length) return;
|
||
return Promise.all(geaendert.map(w => wocheVomServer(w.label).then(frisch => ({ w, frisch })))).then(liste => {
|
||
// Zwischenzeitlich doch etwas bearbeitet? Dann beim nächsten Mal.
|
||
if(dirtyWeeks.size > 0 || syncInFlight || wirdGeradeBearbeitet()) return;
|
||
liste.forEach(({ w, frisch }) => { if(frisch) zusammenfuehren(w, frisch); });
|
||
ansichtAuffrischen();
|
||
});
|
||
})
|
||
.catch(() => { /* still — der nächste Durchlauf versucht es wieder */ })
|
||
.finally(() => { liveAbgleichLaeuft = false; });
|
||
}
|
||
// Nur noch Rückfall: Änderungen kommen sofort über die Live-Ereignisse
|
||
// (liveVerbinden(), unten). Der Abgleich fängt ab, was während einer
|
||
// Verbindungsunterbrechung verpasst wurde, und läuft zusätzlich direkt nach
|
||
// jedem Wiederverbinden.
|
||
setInterval(wochenLiveAbgleich, 60000);
|
||
|
||
// ===========================================================================
|
||
// Live-Ereignisse (GET /ereignisse, Server-Sent Events)
|
||
// ---------------------------------------------------------------------------
|
||
// Sobald jemand speichert, meldet der Server es allen anderen Browsern —
|
||
// bei einzelnen Projekten gleich mit deren neuem Stand, sodass nur diese
|
||
// übertragen werden. Eigene ungespeicherte Änderungen an demselben Projekt
|
||
// bleiben erhalten (Dreiwege-Zusammenführung wie beim Speicherkonflikt).
|
||
// Während jemand tippt oder ein Dialog offen ist, werden die Daten
|
||
// übernommen, aber erst danach neu gezeichnet (ansichtNachBearbeitung()).
|
||
// ===========================================================================
|
||
let liveQuelle = null;
|
||
let liveGetrennt = false;
|
||
let liveNeuzeichnenAusstehend = false;
|
||
|
||
function liveVerbinden(){
|
||
if(liveQuelle || typeof EventSource === 'undefined' || !currentUser) return;
|
||
liveQuelle = new EventSource(baseUrl() + '/ereignisse?client=' + encodeURIComponent(CLIENT_ID));
|
||
liveQuelle.onmessage = ev => {
|
||
let d; try { d = JSON.parse(ev.data); } catch(e){ return; }
|
||
liveEreignis(d);
|
||
};
|
||
liveQuelle.onopen = () => {
|
||
// Nach einer Unterbrechung nachholen, was in der Zeit passiert ist.
|
||
if(liveGetrennt){ liveGetrennt = false; wochenLiveAbgleich(); }
|
||
};
|
||
liveQuelle.onerror = () => { liveGetrennt = true; }; // EventSource verbindet selbst neu
|
||
}
|
||
|
||
function liveTrennen(){
|
||
if(liveQuelle){ liveQuelle.close(); liveQuelle = null; }
|
||
}
|
||
// Beim Verlassen der Seite die Verbindung sauber schließen.
|
||
window.addEventListener('pagehide', liveTrennen);
|
||
|
||
function liveEreignis(d){
|
||
if(!d || !d.typ) return;
|
||
if(d.typ === 'projekte') return liveProjekte(d);
|
||
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
|
||
if(d.typ === 'neu') return liveAllesNeu();
|
||
if(d.typ === 'personen') return personenNachladen();
|
||
if(d.typ === 'notizen' && typeof notizenLiveAenderung === 'function') return notizenLiveAenderung(d.uid);
|
||
}
|
||
|
||
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
|
||
// Abwesenheits-Änderung, hier oder in einem anderen Browser) und neu zeichnen.
|
||
// Rückgabe: Promise.
|
||
function personenNachladen(){
|
||
return fetch(baseUrl() + '/personen', { credentials: 'same-origin' })
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(d => {
|
||
if(!d || !Array.isArray(d.people)) return;
|
||
db.globalPeople = d.people;
|
||
ansichtNachBearbeitung();
|
||
})
|
||
.catch(err => console.error('Personen nachladen fehlgeschlagen:', err));
|
||
}
|
||
|
||
// liveProjekte: übernimmt die mitgeschickten Projekte einer Sitzung.
|
||
function liveProjekte(d){
|
||
const w = d.label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === d.label);
|
||
if(!w) return;
|
||
const basis = wochenBasis.get(d.label);
|
||
const konflikte = [];
|
||
let eigeneOffen = false;
|
||
for(const fp of (d.projekte || [])){
|
||
const lp = (w.projects || []).find(x => x.id === fp.id);
|
||
if(!lp) return liveWocheNachladen(d.label); // Projekt hier unbekannt: ganze Sitzung holen
|
||
const bp = basis ? (basis.projects || []).find(x => x.id === fp.id) : undefined;
|
||
if(bp && gleich(lp, bp)){
|
||
inPlaceUebernehmen(lp, JSON.parse(JSON.stringify(fp)));
|
||
} else {
|
||
// Hier gibt es noch ungespeicherte Änderungen an diesem Projekt.
|
||
const erg = dreiwegeZusammenfuehren(bp, JSON.parse(JSON.stringify(lp)), fp, lp.name || '', konflikte);
|
||
inPlaceUebernehmen(lp, erg);
|
||
lp.updatedAt = fp.updatedAt;
|
||
eigeneOffen = true;
|
||
}
|
||
if(basis){
|
||
const i = basis.projects.findIndex(x => x.id === fp.id);
|
||
if(i >= 0) basis.projects[i] = JSON.parse(JSON.stringify(fp));
|
||
}
|
||
}
|
||
revisionUebernehmen(w, d.updatedAt);
|
||
if(eigeneOffen){ markWeekDirty(d.label); scheduleSessionSync(); }
|
||
if(konflikte.length){
|
||
zeigeHinweis('Gleichzeitig geändert: ' + [...new Set(konflikte.filter(Boolean))].slice(0, 3).join(', ') + '. Ihre Fassung wurde übernommen.');
|
||
}
|
||
ansichtNachBearbeitung();
|
||
}
|
||
|
||
// liveWocheNachladen: eine ganze Sitzung hat sich geändert — nur sie holen.
|
||
function liveWocheNachladen(label, geloescht){
|
||
if(geloescht || !label) return liveAllesNeu();
|
||
// Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach.
|
||
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
|
||
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label);
|
||
if(!w){
|
||
// Eine ältere, hier nicht geladene Sitzung (z. B. entsperrt): nur ihren
|
||
// Eintrag in der Sitzungsliste nachziehen, nicht alles neu laden.
|
||
const kurz = (db.wochenListe || []).find(x => x.label === label);
|
||
if(kurz) return wocheVomServer(label).then(f => { if(f) Object.assign(kurz, sitzungKurz(f)); ansichtNachBearbeitung(); }).catch(() => {});
|
||
return liveAllesNeu();
|
||
}
|
||
wocheVomServer(label).then(frisch => {
|
||
if(!frisch) return liveAllesNeu();
|
||
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
|
||
const hatteEigene = dirtyWeeks.has(label);
|
||
const konflikte = zusammenfuehren(w, frisch);
|
||
if(hatteEigene){ markWeekDirty(label); scheduleSessionSync(); }
|
||
if(konflikte.length){
|
||
zeigeHinweis('Gleichzeitig geändert: ' + konflikte.slice(0, 3).join(', ') + '. Ihre Fassung wurde übernommen.');
|
||
}
|
||
ansichtNachBearbeitung();
|
||
}).catch(e => console.error('Live-Nachladen fehlgeschlagen:', e));
|
||
}
|
||
|
||
// liveAllesNeu: neue Sitzung, gelöschte Sitzung oder eingespieltes Backup —
|
||
// alles neu laden, aber erst, wenn hier nichts mehr ungespeichert ist.
|
||
function liveAllesNeu(){
|
||
if(dirtyWeeks.size > 0 || syncInFlight || wirdGeradeBearbeitet()){
|
||
setTimeout(liveAllesNeu, 2000);
|
||
return;
|
||
}
|
||
load(true);
|
||
}
|
||
|
||
// ansichtNachBearbeitung: neu zeichnen — sofort, oder sobald niemand mehr
|
||
// tippt bzw. kein Dialog mehr offen ist (ein Neuzeichnen verwürfe Eingaben).
|
||
function ansichtNachBearbeitung(){
|
||
if(!wirdGeradeBearbeitet()){ liveNeuzeichnenAusstehend = false; ansichtAuffrischen(); return; }
|
||
if(liveNeuzeichnenAusstehend) return;
|
||
liveNeuzeichnenAusstehend = true;
|
||
const pruefen = () => {
|
||
if(!liveNeuzeichnenAusstehend) return;
|
||
if(wirdGeradeBearbeitet()){ setTimeout(pruefen, 1500); return; }
|
||
liveNeuzeichnenAusstehend = false;
|
||
ansichtAuffrischen();
|
||
};
|
||
setTimeout(pruefen, 1500);
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sitzungen nach Bedarf (Eriks Wunsch: beim Start nur die aktuelle und drei
|
||
// davor; im Verlauf „Weitere laden“ holt je fünf ältere nach)
|
||
// ---------------------------------------------------------------------------
|
||
// db.weeks hält nur die GELADENEN Sitzungen, db.wochenListe ALLE schlank
|
||
// ({id, label, date, locked, updatedAt}, älteste zuerst). Wer eine nicht
|
||
// geladene Sitzung braucht (Admin → Sitzungen, Deep-Link auf ein Projekt
|
||
// einer älteren Sitzung), holt sie über sitzungSicherLaden().
|
||
const SITZUNGEN_START = 4;
|
||
const SITZUNGEN_NACHLADEN = 5;
|
||
|
||
function wocheAbsichern(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;
|
||
}
|
||
function sitzungKurz(w){
|
||
return { id: w.id, label: w.label, date: w.date || '', locked: !!w.locked, updatedAt: w.updatedAt || '' };
|
||
}
|
||
|
||
// sitzungKurzAbgleichen: den schlanken Eintrag einer Sitzung in
|
||
// db.wochenListe auf den Stand des vollen Objekts bringen (Sperre, Revision).
|
||
function sitzungKurzAbgleichen(w){
|
||
const k = (db.wochenListe || []).find(x => x.label === w.label);
|
||
if(k) Object.assign(k, sitzungKurz(w));
|
||
}
|
||
|
||
// wochenEinfuegen: geladene Sitzungen in db.weeks aufnehmen (ohne Doppel),
|
||
// sortieren und als Basis fürs Zusammenführen merken.
|
||
function wochenEinfuegen(liste){
|
||
(liste || []).forEach(w => {
|
||
if(!w || typeof w.label !== 'string' || db.weeks.some(x => x.label === w.label)) return;
|
||
db.weeks.push(wocheAbsichern(w));
|
||
basisMerken(w);
|
||
});
|
||
db.weeks.sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||
}
|
||
|
||
// aeltereSitzungenAnzahl: wie viele Sitzungen älter als die älteste geladene
|
||
// noch nicht geladen sind.
|
||
function aeltereSitzungenAnzahl(){
|
||
const liste = db.wochenListe || [];
|
||
if(!db.weeks.length) return 0;
|
||
const aelteste = db.weeks[0].label;
|
||
const idx = liste.findIndex(w => w.label === aelteste);
|
||
return idx < 0 ? 0 : idx;
|
||
}
|
||
|
||
// weitereSitzungenLaden: holt die nächsten älteren Sitzungen und zeichnet
|
||
// danach neu — die ganze Ansicht, oder nur, was nachher (Name einer globalen
|
||
// Funktion) zeichnet. Der Wochenbericht nutzt das, damit ein angefangener
|
||
// Kommentar nicht durch ein Neuzeichnen verloren geht. Rückgabe: Promise.
|
||
let weitereLaufen = null;
|
||
function weitereSitzungenLaden(anzahl, nachher){
|
||
if(weitereLaufen) return weitereLaufen;
|
||
if(!db.weeks.length || aeltereSitzungenAnzahl() === 0) return Promise.resolve();
|
||
const vor = db.weeks[0].label;
|
||
weitereLaufen = fetch(baseUrl() + '/weeks-aelter?vor=' + encodeURIComponent(vor) + '&anzahl=' + (anzahl || SITZUNGEN_NACHLADEN))
|
||
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||
.then(d => {
|
||
wochenEinfuegen(d.weeks);
|
||
if(nachher && typeof window[nachher] === 'function'){ window[nachher](); return; }
|
||
ansichtAuffrischen();
|
||
// Kommentar-Verlauf im Projekt-Dashboard hängt an renderPlanInfo().
|
||
if(typeof currentTab !== 'undefined' && currentTab === 'plan' && typeof renderPlanInfo === 'function') renderPlanInfo();
|
||
})
|
||
.catch(err => { console.error('Ältere Sitzungen laden fehlgeschlagen:', err); alert('Ältere Sitzungen konnten nicht geladen werden.'); })
|
||
.finally(() => { weitereLaufen = null; });
|
||
return weitereLaufen;
|
||
}
|
||
|
||
// weitereLadenKnopf: Knopf „Weitere laden“ für einen Verlauf — nur, solange
|
||
// es ältere, nicht geladene Sitzungen gibt. Parameter: nachher (optional,
|
||
// siehe weitereSitzungenLaden()). Rückgabe: HTML-String ('' sonst).
|
||
function weitereLadenKnopf(nachher){
|
||
const n = aeltereSitzungenAnzahl();
|
||
if(!n) return '';
|
||
const k = Math.min(n, SITZUNGEN_NACHLADEN);
|
||
return `<button type="button" class="secondary small verlauf-weitere" onmousedown="event.preventDefault()" onclick="event.stopPropagation(); weitereSitzungenLaden(0${nachher ? ", '" + nachher + "'" : ''})"
|
||
title="Noch ${n} ältere Sitzung${n === 1 ? '' : 'en'} am Server">Weitere laden (${k} ${k === 1 ? 'Sitzung' : 'Sitzungen'})</button>`;
|
||
}
|
||
|
||
// sitzungSicherLaden: liefert eine Sitzung, lädt sie bei Bedarf einzeln
|
||
// nach. Rückgabe: Promise<Woche|null>.
|
||
function sitzungSicherLaden(label){
|
||
const da = db.weeks.find(w => w.label === label);
|
||
if(da) return Promise.resolve(da);
|
||
return wocheVomServer(label).then(w => {
|
||
if(!w) return null;
|
||
wochenEinfuegen([w]);
|
||
return db.weeks.find(x => x.label === label) || null;
|
||
});
|
||
}
|
||
|
||
// reloadWeekFromServer: lädt alle Sitzungen 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){
|
||
wocheVomServer(label)
|
||
.then(fresh => {
|
||
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;
|
||
basisMerken(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 => {
|
||
// KEIN load() als Rückfall: Das leert dirtyWeeks und ersetzt alle Wochen,
|
||
// also gingen auch die noch ungespeicherten Änderungen an ANDEREN
|
||
// Sitzungen verloren — genau das, was diese Funktion vermeiden soll.
|
||
console.error('Neu laden der Sitzung fehlgeschlagen:', err);
|
||
showSyncProblem('Die Sitzung "' + label + '" konnte nicht neu geladen werden. Bitte die Seite neu laden.');
|
||
});
|
||
}
|
||
|
||
// load: liest alle Sitzungen und die globale Personenliste ü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.
|
||
// loadAppSettings: holt die firmenweiten Einstellungen (GET /app-settings)
|
||
// und die Feiertage (GET /holidays) und legt sie in APP_SETTINGS/HOLIDAYS ab
|
||
// (js/globals.js). Beides wird zum RECHNEN gebraucht — Auslastung, Ampel,
|
||
// Terminplan aus dem Honorar —, deshalb sind die Routen für jedes angemeldete
|
||
// Konto lesbar; nur das Ändern ist Administratoren vorbehalten.
|
||
//
|
||
// Scheitert der Abruf, bleiben die Vorgaben aus js/globals.js stehen. Das ist
|
||
// Absicht: Die Anwendung rechnet dann wie vor dem Ausbau der Einstellungen
|
||
// weiter, statt mit leeren Werten falsche Zahlen zu zeigen.
|
||
// Rückgabe: Promise (wird nie abgelehnt).
|
||
function loadAppSettings(){
|
||
const settings = fetch(baseUrl() + '/app-settings')
|
||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||
.then(data => { if(data && data.settings) APP_SETTINGS = Object.assign({}, APP_SETTINGS, data.settings); })
|
||
.catch(err => { console.warn('Firmenweite Einstellungen konnten nicht geladen werden, es gelten die Vorgabewerte:', err); });
|
||
|
||
const holidays = fetch(baseUrl() + '/holidays')
|
||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||
.then(data => {
|
||
const list = Array.isArray(data.holidays) ? data.holidays : [];
|
||
HOLIDAYS = new Set(list.map(h => h.date));
|
||
HOLIDAY_NAMES = {};
|
||
list.forEach(h => { HOLIDAY_NAMES[h.date] = h.name || ''; });
|
||
// Die Bundesländer liefert dieselbe Route mit (siehe server.js); die
|
||
// Pflege-Maske im Admin-Bereich baut daraus ihre Auswahl.
|
||
if(data.states) GERMAN_STATES = data.states;
|
||
})
|
||
.catch(err => { console.warn('Feiertage konnten nicht geladen werden:', err); });
|
||
|
||
// Honorar-Anteile je Leistungsphase: Die Auslastung rechnet den offenen
|
||
// Wert einer Phase in Stunden um und braucht dafür deren Anteil am
|
||
// Gesamthonorar. Zusammen mit den übrigen Vorgaben geladen, also VOR
|
||
// /load-all und damit vor dem ersten renderAll().
|
||
const honorar = fetch(baseUrl() + '/honorar-config')
|
||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||
.then(data => { HONORAR_ANTEILE = (data && data.anteile) || {}; })
|
||
.catch(err => { console.warn('Honorar-Anteile konnten nicht geladen werden, die Auslastung weist sie als fehlend aus:', err); });
|
||
|
||
return Promise.all([settings, holidays, honorar]);
|
||
}
|
||
|
||
// load: lädt die Sitzungsdaten vom Server neu. Parameter: silent (optional) —
|
||
// true für eine stille Hintergrund-Aktualisierung (js/wand/wand.js, alle 60s):
|
||
// ein Fehlschlag wird nur geloggt statt per alert() gemeldet, und der
|
||
// bisherige Datenstand bleibt stehen statt geleert zu werden. Ohne diesen
|
||
// Zweig stapelten sich auf einem unbeaufsichtigten Touch-Display bei jedem
|
||
// Fehlschlag blockierende alert()-Dialoge übereinander.
|
||
function load(silent){
|
||
// 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();
|
||
if(!window.fetch){
|
||
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
|
||
return;
|
||
}
|
||
// Nur die jüngsten SITZUNGEN_START Sitzungen (aktuelle + drei davor);
|
||
// ältere holt weitereSitzungenLaden() bei Bedarf (Eriks Wunsch).
|
||
const url = baseUrl() + '/load-all?wochen=' + SITZUNGEN_START;
|
||
console.log('Loading from', url);
|
||
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
|
||
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
|
||
// Aufwandsvorlage, Arbeitstage). loadAppSettings() lehnt nie ab, ein
|
||
// Fehlschlag lässt also nur die Vorgabewerte stehen.
|
||
loadAppSettings().then(() => fetch(url)
|
||
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
||
.then(data => {
|
||
// Globale Personenliste laden (flaches Array, keine Abteilungstrennung)
|
||
db.globalPeople = Array.isArray(data.people) ? data.people : [];
|
||
|
||
// db.sandboxWeek: die Planung-Sandbox (SANDBOX_WEEK_LABEL) — bewusst
|
||
// NICHT Teil von db.weeks (siehe /load-all, server.js), sondern ein
|
||
// eigenes, synthetisches Wochen-ähnliches Objekt, hier und nur hier
|
||
// aufgebaut. Damit sehen renderBoard()/berichtProjekte()/
|
||
// berechneAuslastung()/die Sitzungs-PDFs/Admin→Sitzungen sie nie —
|
||
// sie iterieren alle ausschließlich db.weeks. people wird aus der
|
||
// globalen Personenliste abgeleitet (dieselbe Form wie week.people),
|
||
// damit die Beteiligten-Matrix im Projektbereich auch für einen
|
||
// Sandbox-Plan Kandidaten zur Auswahl hat.
|
||
db.sandboxWeek = {
|
||
id: 'sandbox', label: SANDBOX_WEEK_LABEL, date: '',
|
||
people: db.globalPeople.map(p => ({ id: p.id, name: p.name, fullName: p.fullName })),
|
||
projects: Array.isArray(data.sandboxPlans) ? data.sandboxPlans : [],
|
||
attendance: {}, locked: false, updatedAt: data.sandboxUpdatedAt || ''
|
||
};
|
||
|
||
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
|
||
.filter(w => w && typeof w.label === 'string')
|
||
.map(wocheAbsichern);
|
||
// Alle Sitzungen schlank (Name, Datum, Sperre) — auch die nicht geladenen.
|
||
db.wochenListe = Array.isArray(data.wochenListe) ? data.wochenListe : db.weeks.map(sitzungKurz);
|
||
|
||
// Server-Stand als Basis für späteres Zusammenführen merken.
|
||
wochenBasis.clear();
|
||
db.weeks.forEach(basisMerken);
|
||
basisMerken(db.sandboxWeek);
|
||
|
||
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. "/Projekte") gewählt wurde.
|
||
renderAll();
|
||
showTab(currentTab);
|
||
liveVerbinden();
|
||
})
|
||
.catch(err => {
|
||
console.error('Laden vom Server fehlgeschlagen:', err);
|
||
if(silent) return;
|
||
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 keine Daten angezeigt. Bitte die Seite neu laden, sobald der Server wieder läuft.';
|
||
alert(msg);
|
||
// BEWUSST KEIN seed(): Die Beispieldaten enthalten eine Woche "KW 30 / 2026"
|
||
// und wurden früher über save() an den Server geschickt, sobald die
|
||
// Verbindung zurückkam — sie überschrieben damit die echte gleichnamige
|
||
// Sitzung. Bei einem Ladefehler bleibt der Bestand jetzt einfach leer.
|
||
db.weeks = [];
|
||
db.currentWeek = null;
|
||
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 = 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).
|
||
// projectUid: dauerhafte, wochenunabhängige Identität (siehe saveNewProj()
|
||
// in js/load/load.js für die Erklärung, warum sie sich von id unterscheidet).
|
||
function demoProj(nr,name,group,status,lph){
|
||
return {id:uid(), projectUid: uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null, aufwandProLP: undefined, aktuelleLP: undefined};
|
||
} |