Files
projektuebersicht/public/js/store.js
T
erikandClaude Sonnet 5 db63a29365 Wandtafel-Dashboard, Dateibereinigung und Auslastungs-Vereinfachung
Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je
Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige
Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit
sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte.
Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt.
Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 15:14:47 +02:00

391 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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;
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;
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 => {
if(err && err.conflict){
dirtyWeeks.delete(label);
handleWeekConflict(w, err);
} else if(err && err.status === 401){
// Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still
// weiterzuarbeiten und alles Weitere zu verlieren.
problem = 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.';
if(typeof showLoginOverlay === 'function') showLoginOverlay();
console.error('Speichern abgelehnt (nicht angemeldet):', w.label);
} else {
problem = 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.';
console.error('Sitzung konnte nicht gespeichert werden:', w.label, (err && err.message) || err);
// Label bleibt dirty -> der nächste Sync versucht es erneut.
}
}));
});
Promise.all(jobs).then(() => {
syncInFlight = false;
showSyncProblem(problem || null);
if(problem) scheduleSyncRetry();
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) return;
dirtyWeeks.add(label);
weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1);
}
// 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 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){
fetch(baseUrl() + '/load-all')
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
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 => {
// 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;
}
const url = baseUrl() + '/load-all';
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(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. "/Projekte") gewählt wurde.
renderAll();
showTab(currentTab);
})
.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};
}