From 8c94fc91f6038e7c51914bd522952c29fa6e84ec Mon Sep 17 00:00:00 2001 From: Erik Dunin Date: Thu, 13 Aug 2026 16:07:29 +0200 Subject: [PATCH] code auf einzelne Datein aufgeteilt --- AGENTS.md | 66 +++- README.md | 23 +- public/Test.html | 30 +- public/js/.DS_Store | Bin 0 -> 6148 bytes public/js/board.js | 347 ++++++++++++++++++ public/js/csv.js | 357 ++++++++++++++++++ public/js/date-picker.js | 221 +++++++++++ public/js/gantt.js | 361 ++++++++++++++++++ public/js/globals.js | 49 +++ public/js/init.js | 68 ++++ public/js/load.js | 352 ++++++++++++++++++ public/js/pdf.js | 566 +++++++++++++++++++++++++++++ public/js/people.js | 349 ++++++++++++++++++ public/js/plan/plan-build-gantt.js | 174 +++++++++ public/js/plan/plan-drag.js | 433 ++++++++++++++++++++++ public/js/plan/plan-gantt.js | 233 ++++++++++++ public/js/plan/plan-modals.js | 473 ++++++++++++++++++++++++ public/js/plan/plan-schedule.js | 288 +++++++++++++++ public/js/plan/plan-state.js | 229 ++++++++++++ public/js/projects.js | 173 +++++++++ public/js/server.js | 54 +++ public/js/showmode.js | 207 +++++++++++ public/js/store.js | 178 +++++++++ public/js/tabs-auth.js | 68 ++++ public/js/ui.js | 165 +++++++++ public/js/utils.js | 122 +++++++ public/js/weeks.js | 156 ++++++++ public/project-plan.html | 29 +- server.js | 19 + sessions/ELT/KW_33___2026.csv | 2 +- 30 files changed, 5765 insertions(+), 27 deletions(-) create mode 100644 public/js/.DS_Store create mode 100644 public/js/board.js create mode 100644 public/js/csv.js create mode 100644 public/js/date-picker.js create mode 100644 public/js/gantt.js create mode 100644 public/js/globals.js create mode 100644 public/js/init.js create mode 100644 public/js/load.js create mode 100644 public/js/pdf.js create mode 100644 public/js/people.js create mode 100644 public/js/plan/plan-build-gantt.js create mode 100644 public/js/plan/plan-drag.js create mode 100644 public/js/plan/plan-gantt.js create mode 100644 public/js/plan/plan-modals.js create mode 100644 public/js/plan/plan-schedule.js create mode 100644 public/js/plan/plan-state.js create mode 100644 public/js/projects.js create mode 100644 public/js/server.js create mode 100644 public/js/showmode.js create mode 100644 public/js/store.js create mode 100644 public/js/tabs-auth.js create mode 100644 public/js/ui.js create mode 100644 public/js/utils.js create mode 100644 public/js/weeks.js diff --git a/AGENTS.md b/AGENTS.md index 1224d8f..b48b50c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,9 @@ Two HTML entrypoints, both served statically by Express: - `Test.html` — the main app (tabs: Projektboard, Auslastung, Projekte, Projektbeteiligte). Served at `/`. - `project-plan.html` — per-project Gantt/TODO planning view, opened in a new - tab via `openProjectPlan()` in `main.v2.js:2358`. Loads `main.v2.js` first, - then `project-plan.js`. + tab via `openProjectPlan()` in `js/gantt.js`. Loads the shared helper modules + (`globals`, `utils`, `ui`, `date-picker`, `csv`, `server`, `gantt`) plus the + plan modules under `js/plan/` — NOT the full main module set. All UI strings/comments/CSV content are German. Preserve German in user-facing text. @@ -29,9 +30,32 @@ user-facing text. ├── .vscode/extensions.json ├── public/ │ ├── Test.html # main app (served at /) -│ ├── main.v2.js # entire main client app (~3200 lines) +│ ├── js/ # main app modules (classic scripts, global fns) +│ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS) + db/currentDepartment +│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName, weekSortKey-Quelle +│ │ ├── ui.js # LocalStorage-UI-State, Header-Collapse, Theme +│ │ ├── date-picker.js # Datums-/Bereichspicker-Overlays +│ │ ├── csv.js # CSV-Parser/Serializer + weekSortKey, filenameForWeek +│ │ ├── server.js # baseUrl, sendCsvToServer, deleteCsvFromServer, blobToDataUrl +│ │ ├── store.js # save/syncAllToServer, switchDepartment, load, seed +│ │ ├── weeks.js # Wochen-Auswahl, -Erzeugung, -Klonen, Auto-Lock, -Löschung +│ │ ├── board.js # Projektboard-Rendering, Status-Editor, Anwesenheit, Zuordnung +│ │ ├── people.js # Personen-Tab-Rendering + Drag-and-Drop + Abwesenheiten +│ │ ├── projects.js # Projekte-Tab-Rendering + Drag-and-Drop +│ │ ├── load.js # Auslastungs-Tab, LP-Modal, Neues-Projekt-Modal +│ │ ├── gantt.js # GANTT-Diagramm (buildGanttHtml, openProjectPlan), geshared +│ │ ├── showmode.js # Show-Mode (Präsentationsmodus) +│ │ ├── tabs-auth.js # showTab, confirmPassword (SHA256-Passwortschutz) +│ │ ├── pdf.js # PDF-Export (pdfmake, A3 quer) +│ │ └── init.js # renderAll/initUiState + Seiteneinstieg (nur Test.html) │ ├── project-plan.html # Gantt/TODO planning view -│ ├── project-plan.js # planning logic (~1500 lines) +│ ├── js/plan/ # planning modules (nur von project-plan.html geladen) +│ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts +│ │ ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen) +│ │ ├── plan-gantt.js # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load +│ │ ├── plan-drag.js # GANTT-Balken-Drag/Resize + TODO-/Meilenstein-Zeilen-Drag +│ │ ├── plan-modals.js # TODO-, Meilenstein- und Abwesenheits-Modals +│ │ └── plan-schedule.js # Kritischer Pfad, Auto-Schedule, Mail-Link │ ├── styles.css │ └── dein_bild.ico └── sessions/ # data; bind-mounted into Docker container @@ -47,7 +71,7 @@ docker compose up --build # Docker variant (compose file is at repo root) ``` - PORT env var overrides default 3003 in `server.js`. -- Frontend must be served over HTTP: `baseUrl()` in `main.v2.js` uses +- Frontend must be served over HTTP: `baseUrl()` in `js/server.js` uses `window.location.origin` or falls back to `http://localhost:3003` when opened via `file://` (then only seed/demo data loads). - Check `/health` returns `{ok:true}` after starting. @@ -61,7 +85,7 @@ docker compose up --build # Docker variant (compose file is at repo root) - `POST /delete-csv {filename, department}`. - Valid departments hard-coded as `ELT|HLS|ARCH` in `server.js` (`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in - `DEPARTMENTS` in `main.v2.js`. + `DEPARTMENTS` in `js/globals.js`. - `safeName()` strictly validates filenames (path traversal protection); static assets get `Cache-Control: no-store`. - JSON body limit is 2 MB. @@ -79,13 +103,23 @@ docker compose up --build # Docker variant (compose file is at repo root) into the current week. Historical weeks keep their own person copies, so deleting a global person leaves old sessions unaffected no matter what. - Don't change the column layout without updating both `weekToCsv()` and - `parseSessionCsv()` (`main.v2.js`), which must stay inverse of each other. + `parseSessionCsv()` (`js/csv.js`), which must stay inverse of each other. ## Frontend architecture -- `main.v2.js` is loaded as a classic non-module ` + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/js/.DS_Store b/public/js/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..97c93998a204f1caeaddff1f5b0f0f849e5ee27d GIT binary patch literal 6148 zcmeHKJ5Izv47DLeBnq}dlyil>!LSMiqTCC>vJwRGbij6XBUE8yPI+K&)seo!{w2#a6L$~W^nTH)#TS+gi z+{^Q%UT<#4v0c|Ro3`7)SHHh}oPS=tT}|WLU*rAL=-KoykBD0+WPl8i0Wv@aj)wvC zY?Jydkd_RP0W$EzfPEhl)G!wu0{zng<0Amje&b!R_E`d$%mK^=hd_8B8l}J})iPpe zl*6A&UM@HUM!BfpjC0D(S|$|r+u={1E}8>r$p9IcGH@E(h4uey=z;lvTErb0AOlCm zfL6`AS>csRZ=JoI_1XlzgMJHgJsrkdG0m[r]=[]); + const people = []; + if(Array.isArray(target)){ + target.forEach(w => { if(w && Array.isArray(w.people)) people.push(...w.people); }); + } else if(target && Array.isArray(target.people)){ + people.push(...target.people); + } else if(Array.isArray(currentWeek()?.people)){ + people.push(...currentWeek().people); + } + const seen = new Set(); + people.forEach(p=>{ + const personRoles = Array.isArray(p.role) ? p.role : [p.role]; + personRoles.forEach(role=>{ + if(!m[role]) m[role]=[]; + const key = `${p.id}|${role}`; + if(!seen.has(key)){ + seen.add(key); + m[role].push({person:p, role}); + } + }); + }); + return m; +} + +// roleClass: erzeugt CSS-Klassen für eine Rolle bzw. ein Rollen-Array +// (Rollenname kleingeschrieben, Leerzeichen → Bindestrich). Parameter: `role` +// (String oder Array). Rückgabe: Klassen-String, leer bei undefined. +function roleClass(role){ + if(Array.isArray(role)){ + return role.map(r=>'role-'+r.toLowerCase().replace(/\s+/g,'-')).join(' '); + } + return 'role-' + role.toLowerCase().replace(/\s+/g,'-'); +} + +// renderBoard: baut die HTML-Tabelle für das Board auf (Kopf, Zeilen, Zellen). +// Liest die aktuelle Woche, gruppiert Projekte nach Gruppe, sortiert sie nach +// `order` und erzeugt die Status-, Personen- und LPH-Zellen. Bei gesperrten +// Sitzungen werden alle Bearbeitungs-Handler deaktiviert und ein Hinweis samt +// Entsperren-Button angezeigt. Aktualisiert außerdem die Legende mit den +// vollständigen Namen und Anwesenheiten. Keine Parameter, keine Rückgabe. +function renderBoard(){ + const w = currentWeek(); + const locked = isWeekLocked(w); + const table = document.getElementById('boardTable'); + const grouped = peopleByRole(w); + + // Kopfzeilen: Rollen und Leistungsphasen + let h1 = `Pr.-Nr.BezeichnungStatus / Kommentar`; + ROLES.forEach(r=>{ h1 += `${r}`; }); + h1 += `Leistungsphasen (%)`; + + let h2 = ``; + ROLES.forEach(r=>{ + if(grouped[r].length===0) h2 += `–`; + grouped[r].forEach(entry=> h2 += `${entry.person.name}`); + }); + LPHS.forEach(l=> h2 += `${l}`); + h2 += ``; + + // Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen + const groups = {}; + w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); }); + + // Projekte nach ihrer Reihenfolge sortieren + Object.keys(groups).forEach(g=>{ + groups[g].sort((a, b) => (a.order || 0) - (b.order || 0)); + }); + + let body = ""; + const allPeople = []; + ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry))); + + // Jede Gruppe als Abschnitt, dann Zeilen für Projekte + sortGroups(Object.keys(groups)).forEach(g=>{ + const colspan = 3 + allPeople.length + LPHS.length + 1; // Spaltenanzahl für Gruppentitel + body += `${esc(g)}`; + groups[g].forEach(p=>{ + body += ``; + body += `${esc(p.nr)}`; + body += `${esc(p.name)}`; + // Status-Editor: contenteditable mit kleiner Toolbar (Fett, Farben, Absätze mit Enter) + const statusClass = p.statusState === 'new' ? 'status-new' : p.statusState === 'duplicated' ? 'status-duplicated' : ''; + const statusEditable = locked ? 'false' : 'true'; + const statusToolbar = locked ? '' : ` +
+ + + + + + +
`; + const statusClick = locked ? '' : `onfocus="statusFocus(this,'${p.id}')" onblur="statusBlur(this,'${p.id}')"`; + const safeStatus = sanitizeStatusHtml(p.status || ''); + body += ` + ${statusToolbar} +
${safeStatus}
+ `; + + // Personen-Spalten: Klick wechselt Zustand (nicht, optional, Hauptverantwortung) + allPeople.forEach(entry=>{ + const state = p.assign[assignKey(entry.person.id, entry.role)] || 0; // 0=keine,1=Haupt,2=optional + const cls = state===1?'on':state===2?'optional':''; + const txt = state===1?'X':state===2?'(X)':''; + const click = locked ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`; + body += `${txt}`; + }); + + // LPH-Spalten mit Prozentwerten und Fortschrittsbalken; + // die amtierende Leistungsphase (Tab "Projekte") wird farblich markiert. + const aktLp = lpActiveList(p)[0]; + LPHS.forEach(l=>{ + const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : ""; + const aktiv = l === aktLp; + const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`; + body += ``; + if(v!=""){ const pct = Math.min(100, parseFloat(v)||0); + body += `
`; } + body += ``; + }); + body += ``; + }); + + }); + + let lockNotice = ''; + if(locked){ + lockNotice = `
+ Diese Sitzung ist gesperrt. Bearbeiten nur nach Passworteingabe möglich. + +
`; + } + + table.innerHTML = `${h1}${h2}${body}`; + + // Legende mit vollständigen Namen und Anwesenheitstracking + let legendeHtml = lockNotice + `
Legende Projektkürzel${locked ? '' : ' (klick für Anwesenheit)'}:
`; + const weekAttendance = w?.attendance || {}; + const legendeItems = []; + allPeople.forEach(entry=>{ + if(!legendeItems.some(item=>item.id===entry.person.id)){ + legendeItems.push({id:entry.person.id, name:entry.person.name, fullName:entry.person.fullName||entry.person.name}); + } + }); + legendeHtml += `
` + legendeItems.map(item=>{ + const isPresent = weekAttendance[item.id]; + const cursor = locked ? 'not-allowed' : 'pointer'; + const click = locked ? '' : `onclick="toggleAttendance('${item.id}')"`; + return `
${esc(item.name)} = ${esc(item.fullName)}
`; + }).join('') + `
`; + document.getElementById('boardLegend').innerHTML = legendeHtml; + adjustStickyOffsets(); +} + +// sanitizeStatusHtml: bereinigt übergebenen Status-HTML. Erlaubt nur eine kleine +// Whitelist an HTML-Tags/Attributen (Formatierung), damit keine Skripte +// eingeschleust werden können; im style-Attribut sind nur color/font-weight +// erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei +// leerem Eingabewert). +function sanitizeStatusHtml(html){ + if(!html) return ''; + const div = document.createElement('div'); + div.innerHTML = html; + const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']); + const allowedAttrs = new Set(['style']); + const walk = (node) => { + for(let i = node.childNodes.length - 1; i >= 0; i--){ + const child = node.childNodes[i]; + if(child.nodeType === 3) continue; + if(child.nodeType === 1){ + if(!allowedTags.has(child.tagName.toUpperCase())){ + while(child.firstChild) node.insertBefore(child.firstChild, node); + node.removeChild(child); + continue; + } + for(let j = child.attributes.length - 1; j >= 0; j--){ + const attr = child.attributes[j].name; + if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr); + } + if(child.hasAttribute('style')){ + // Erlaubt nur color und font-weight im style-Attribut + const style = child.getAttribute('style'); + const safe = (style || '').split(';').map(s => s.trim()).filter(s => { + const prop = s.split(':')[0].trim().toLowerCase(); + return prop === 'color' || prop === 'font-weight'; + }).join('; '); + if(safe) child.setAttribute('style', safe); + else child.removeAttribute('style'); + } + walk(child); + } + } + }; + walk(div); + return div.innerHTML; +} + + +// getProgressColor: erzeugt eine HSL-Farbe basierend auf Prozentwert +// (grün → gelb → rot). Parameter `pct`: Zahl 0–100 (wird geklemmt). +// Rückgabe: Farb-String für den Fortschrittsbalken. +function getProgressColor(pct){ + const hue = Math.round(Math.max(0, Math.min(100, pct)) * 1.2); + return `hsl(${hue},70%,40%)`; +} + +// adjustStickyOffsets: berechnet die reale Höhe der ersten Kopfzeile +// der Board-Tabelle und setzt den top-Wert der zweiten sticky-Kopfzeile +// entsprechend, damit beide Zeilen exakt aneinander anschließen +// (keine Lücke/Überlappung beim Scrollen). Keine Parameter, keine Rückgabe. +function adjustStickyOffsets(){ + const firstRow = document.querySelector('#boardTable thead tr:nth-child(1)'); + if(!firstRow) return; + const h = firstRow.getBoundingClientRect().height; + document.querySelectorAll('#boardTable thead tr:nth-child(2) th').forEach(th=>{ + th.style.top = h + 'px'; + }); +} + +// toggleAttendance: togglet den Anwesenheitsstatus (boolean in `w.attendance`) +// eines Projektbeteiligten für die aktuelle Sitzung und rendert das Board neu. +// Parameter `personId`: stabile Personen-ID. Keine Rückgabe; bei gesperrter +// Woche über `guardWeekLocked()` abgebrochen. +function toggleAttendance(personId){ + if(guardWeekLocked()) return; + const w = currentWeek(); + if(!w) return; + if(!w.attendance) w.attendance = {}; + w.attendance[personId] = !w.attendance[personId]; + save(); + renderBoard(); +} + +// cycleAssign: wechselt die Zuweisung einer Person zu einem Projekt zyklisch +// durch die Zustände 0=keine → 1=Haupt → 2=optional → 0. Parameter `pid` +// (Projekt-ID), `personId`, `role`; bei Zustand 0 wird der Eintrag gelöscht. +// Keine Rückgabe. +function cycleAssign(pid, personId, role){ + if(guardWeekLocked()) return; + const p = currentWeek().projects.find(x=>x.id===pid); + const key = assignKey(personId, role); + const cur = p.assign[key]||0; + const next = (cur+1)%3; + if(next===0) delete p.assign[key]; else p.assign[key]=next; + save(); renderBoard(); +} + +// editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein +// Projekt. Parameter: `pid` (Projekt-ID), `lph` (Leistungsphasen-Schlüssel), +// `val` (Eingabewert). Leere/`%`-Werte werden bereinigt; keine Rückgabe. +function editLph(pid,lph,val){ + if(guardWeekLocked()) return; + const p = currentWeek().projects.find(x=>x.id===pid); + val = val.trim(); + if(val==="") delete p.lph[lph]; + else p.lph[lph]=val.replace('%',''); + save(); renderBoard(); +} + +// editProj: aktualisiert ein Feld eines Projekts; für Status wird zusätzlich +// ein flag `statusState` gesetzt (='new' beim Anlegen eines neuen Kommentars), +// wenn zuvor noch kein Status existierte. Parameter: `pid` (Projekt-ID), +// `field` (Feldname), `val` (neuer Wert). Keine Rückgabe. +function editProj(pid,field,val){ + if(guardWeekLocked()) return; + const p = currentWeek().projects.find(x=>x.id===pid); + if(!p) return; + if(field==='status'){ + const before = (p.status||"").trim(); + const after = val.trim(); + if(before === "" && after !== ""){ + p.statusState = 'new'; + } else if(after === ""){ + delete p.statusState; + } + } + p[field]=val; save(); +} + +// delProj: löscht ein Projekt (per Bestätigungsdialog) aus der aktuellen +// Sitzung. Parameter `pid`: Projekt-ID. Keine Rückgabe; bei gesperrter Woche +// abgebrochen. +function delProj(pid){ + if(guardWeekLocked()) return; + if(!confirm("Projekt aus dieser Sitzung löschen?")) return; + const w = currentWeek(); + w.projects = w.projects.filter(x=>x.id!==pid); + save(); renderBoard(); +} \ No newline at end of file diff --git a/public/js/csv.js b/public/js/csv.js new file mode 100644 index 0000000..013d029 --- /dev/null +++ b/public/js/csv.js @@ -0,0 +1,357 @@ +// ----------------------------- +// Projektübersicht — CSV-Serialisierung & Parsing +// Hinweis: weekToCsv() und parseSessionCsv() sind invers zueinander. +// Das Spaltenlayout darf nicht geändert werden, ohne beide Funktionen anzupassen. +// ----------------------------- +// Diese Datei enthält das gesamte CSV-Handling (Serialisieren und Parsen) und +// wird nach js/date-picker.js von beiden HTML-Einstiegspunkten geladen. Sie +// stellt die globalen Funktionen weekToCsv/parseSessionCsv, das Personen-CSV +// (globalPeopleToCsv/parseGlobalPeopleCsv) sowie Helfer wie weekSortKey und +// decodeWeekLabel bereit. Hauptnutzer sind js/store.js (Laden/Speichern aller +// Sitzungen), js/weeks.js (Wochen-Klonen) und js/plan/plan-state.js (savePlan), +// die die CSV-Zeichenketten über js/server.js austauschen. + +// filenameForWeek: leitet aus dem Wochenlabel einen Dateinamen ab +// (alle Nicht-Wort-Zeichen werden durch _ ersetzt) und hängt .csv an. +// Parameter: label (String). Rückgabe: String wie 'KW_30___2026.csv'. +function filenameForWeek(label){ + return (label.replace(/[^\w]/g,'_'))+'.csv'; +} + +// csvEscape: maskiert ein CSV-Feld, falls es Sonderzeichen (;, ", Zeilenumbruch) enthält +// Parameter: v (beliebiger Wert). Rückgabe: String, bei Bedarf in Anführungszeichen. +function csvEscape(v){ + const s = (v === null || v === undefined ? '' : String(v)); + if(/[;"\n\r]/.test(s)) return '"' + s.replace(/"/g,'""') + '"'; + return s; +} + +// globalPeopleToCsv: serialisiert die globalen Personen (aller Abteilungen) als CSV-Text +// Parameter: people (Array von Person-Objekten). Rückgabe: UTF-8-BOM-CSV-String. +function globalPeopleToCsv(people){ + const rows = [['NAME', 'Rollen', 'Voller Name', 'E-Mail', 'Abwesenheiten']]; + people.slice().sort((a,b) => a.name.localeCompare(b.name)).forEach(p => { + const absences = Array.isArray(p.absences) ? JSON.stringify(p.absences) : ''; + rows.push([p.name, (p.role || []).join(','), p.fullName || '', p.email || '', absences]); + }); + return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n'); +} + +// parseGlobalPeopleCsv: parst den _people.csv-Inhalt zurück in Person-Objekte +// Parameter: text (String). Rückgabe: Array von Person-Objekten (mit stabiler ID). +function parseGlobalPeopleCsv(text){ + if(!text) return []; + const rows = parseCsvText(text); + if(!rows.length) return []; + const people = []; + rows.slice(1).forEach(row => { + const name = (row[0] || '').trim(); + if(!name) return; + const roles = String(row[1] || 'Planung').split(',').map(x => x.trim()).filter(Boolean); + const fullName = (row[2] || '').trim() || name; + const email = (row[3] || '').trim(); + let absences = []; + if(row[4]){ + try { + const parsed = JSON.parse(row[4]); + if(Array.isArray(parsed)) absences = parsed; + } catch(e) { + // Ignoriere fehlerhafte JSON-Daten + } + } + people.push({ id: personIdForName(name), name, fullName, email, role: roles, absences }); + }); + return people; +} + +// weekToCsv: serialisiert eine komplette Sitzung (Woche) in das feste CSV-Spaltenlayout +// Parameter: w (Wochen-Objekt mit label, date, locked, people, attendance, projects). +// Rückgabe: UTF-8-BOM-CSV-String. Invers zu parseSessionCsv(). +function weekToCsv(w){ + // allPeople enthält für das Board jede (Person, Rolle)-Kombination einmal. + const grouped = peopleByRole(w); + const allPeople = []; + ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry))); + const rows = []; + // Metadaten-Zeile: Label, Datum und Sperrstatus der Sitzung + rows.push(['WOCHE', w.label || '', w.date || '', w.locked ? '1' : '']); + // PERSONEN-Zeile: Pro Spalte Name|Rolle|Voller Name, damit beim Laden + // die Zuordnung pro Rolle eindeutig bleibt und der volle Name erhalten ist. + // Der volle Name wird immer als dritter Teil gespeichert (auch leer), + // damit er beim Bearbeiten nicht versehentlich verloren geht. + const peopleMeta = allPeople.map(e => `${e.person.name}|${e.role}|${e.person.fullName || ''}`); + rows.push(['PERSONEN', '', '', ...peopleMeta, ...LPHS.map(() => ''), '','','','','']); + rows.push(['Pr.-Nr.','Bezeichnung','Status', ...allPeople.map(e => e.person.name), ...LPHS, 'Gruppe','Index','Plan','Aufwand','Aktive Phasen']); + rows.push(['ANWESENHEIT','', ...allPeople.map(e => w.attendance && w.attendance[e.person.id] ? 'X' : ''), ...LPHS.map(() => ''),'','','','','']); + w.projects.forEach(p => { + const arr = [p.nr || '', p.name || '', p.status || '']; + allPeople.forEach(entry => { + const s = p.assign[assignKey(entry.person.id, entry.role)]; + arr.push(s === 1 ? 'X' : s === 2 ? '(X)' : ''); + }); + LPHS.forEach(l => { + const v = p.lph[l]; + arr.push(v !== undefined && v !== '' ? String(v).replace(/%/g,'') + '%' : ''); + }); + arr.push(p.group || ''); + arr.push(p.order !== undefined ? String(p.order) : ''); + arr.push(p.plan ? JSON.stringify(p.plan) : ''); + arr.push(p.aufwandProLP ? JSON.stringify(p.aufwandProLP) : ''); + arr.push(p.aktuelleLP ? JSON.stringify(p.aktuelleLP) : ''); + rows.push(arr); + }); + return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n'); +} + + +// parseCsvText: entfernt ein (mehrfach kodiertes) BOM und zerlegt den Text in Zeilen-Arrays +// Parameter: text (String). Rückgabe: Array aus Zeilen, jede ein Feld-Array. +function parseCsvText(text){ + // Manche ältere CSV-Dateien enthalten ein UTF-8-BOM gefolgt von einem + // literalen "" (dreifach kodiert). Beides am Anfang entfernen. + return text.replace(/^[\ufeff\u00EF\u00BB\u00BF]+/,'').split(/\r?\n/).filter(line => line.trim() !== '').map(parseCsvLine); +} + +// Toleranter CSV-Zeilenparser. Versteht sowohl sauberes CSV als auch +// das ältere Format, bei dem Felder nur ein öffnendes Anführungszeichen +// hatten und das Semikolon innerhalb eines quoted Feldes gleichzeitig +// als Trennzeichen diente. +// parseCsvLine: zerlegt eine einzelne CSV-Zeile in ein Feld-Array +// Parameter: line (String). Rückgabe: Array der Felder. +function parseCsvLine(line){ + const result = []; + let current = ''; + let inQuotes = false; + let hasUnclosedQuotes = false; + let i = 0; + while(i < line.length){ + const c = line[i]; + const n = line[i+1]; + if(inQuotes){ + if(c === '"' && n === '"'){ + current += '"'; i += 2; + } else if(c === ';' && n === '"'){ + // Altes Trennzeichen \";\\" innerhalb eines quoted Feldes + result.push(current); + current = ''; + inQuotes = false; + i += 1; + } else if(c === '"' && (n === ';' || n === undefined)){ + result.push(current); + current = ''; + inQuotes = false; + i += (n === ';' ? 2 : 1); + } else { + current += c; i++; + } + } else { + if(c === ';'){ + result.push(current); + current = ''; i++; + } else if(c === '"'){ + inQuotes = true; i++; + } else { + current += c; i++; + } + } + } + if(inQuotes) hasUnclosedQuotes = true; + result.push(current); + if(hasUnclosedQuotes){ + console.warn('CSV-Zeile enthält nicht geschlossene Anführungszeichen:', line.slice(0,80)); + } + return result; +} + +// decodeWeekLabel: rekonstruiert das Wochenlabel aus einem Dateinamen +// (ersetzt _ durch Leerzeichen und ergänzt das Layout "KW XX / YYYY"). +// Parameter: filename (String). Rückgabe: Label-String. +function decodeWeekLabel(filename){ + const base = filename.replace(/\.csv$/i,''); + let label = base.replace(/_+/g,' ').trim(); + if(/^KW \d+ \d+$/.test(label)){ + label = label.replace(/^KW (\d+) (\d+)$/, 'KW $1 / $2'); + } + return label || base; +} + +// Erzeugt einen Sortierschlüssel aus einem Wochenlabel, sodass z.B. +// "KW 9 / 2026" vor "KW 10 / 2026" kommt. +// weekSortKey: liefert einen nach Jahr und Kalenderwoche sortierbaren Schlüssel +// Parameter: label (String). Rückgabe: String (Jahr-KW) oder kleingeschriebenes Label. +function weekSortKey(label){ + const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i); + if(m){ + const kw = m[1].padStart(2,'0'); + return `${m[2]}-${kw}`; + } + return String(label || '').toLowerCase(); +} + + +// parseSessionCsv: parst die CSV-Zeilen einer Sitzung zurück in ein Wochen-Objekt +// Parameter: filename (String), rows (Array von Feld-Arrays aus parseCsvText). +// Rückgabe: Wochen-Objekt {id, label, date, people, projects, attendance, locked} +// oder null bei leerer Eingabe. Invers zu weekToCsv(). +function parseSessionCsv(filename, rows){ + if(!rows || rows.length === 0) return null; + let label, date, headerRowIdx = 0, attendanceRowIdx = 1, projectStartIdx = 2; + let locked = false; + let metaPeopleRow = null; + if(rows[0][0] === 'WOCHE'){ + label = rows[0][1] || decodeWeekLabel(filename); + date = rows[0][2] || ''; + locked = (rows[0][3] || '').trim() === '1'; + if(rows[1] && rows[1][0] === 'PERSONEN'){ + metaPeopleRow = rows[1]; + headerRowIdx = 2; attendanceRowIdx = 3; + } else { + headerRowIdx = 1; attendanceRowIdx = 2; + } + } else { + label = decodeWeekLabel(filename); + date = ''; + } + projectStartIdx = attendanceRowIdx + 1; + + const header = rows[headerRowIdx]; + if(!header || header.length < 4) return { id: uid(), label, date, people: [], projects: [], attendance: {} }; + + const lphStart = header.indexOf('LPH 1'); + const groupIdx = header.indexOf('Gruppe'); + const orderIdx = header.indexOf('Index'); + const planIdx = header.indexOf('Plan'); + const aufwandIdx = header.indexOf('Aufwand'); + const aktiveIdx = header.indexOf('Aktive Phasen'); + const peopleCols = header.slice(3, lphStart === -1 ? header.length : lphStart); + + const people = []; + const colMap = []; + const personMeta = metaPeopleRow ? metaPeopleRow.slice(3, lphStart === -1 ? metaPeopleRow.length : lphStart) : []; + const personByName = {}; + const nameOccurrences = {}; + peopleCols.forEach((name, idx) => { + const n = (name || '').trim(); + if(!n){ colMap.push(null); return; } + + let roles = ['Planung']; + let fullName = n; + if(personMeta[idx]){ + const parts = String(personMeta[idx]).split('|'); + const rawRoles = parts[1] || ''; + const parsedRoles = rawRoles.split(',').map(x => x.trim()).filter(x => ROLES.includes(x)); + if(parsedRoles.length > 0) roles = parsedRoles; + // Voller Name ist alles ab dem zweiten Pipe; damit sind auch Pipe-Zeichen + // im Namen möglich. Leere Strings werden bewusst übernommen. + if(parts.length >= 3) fullName = parts.slice(2).join('|').trim(); + } else { + const occurrence = nameOccurrences[n] || 0; + roles = [ROLES[Math.min(occurrence, ROLES.length - 1)]]; + nameOccurrences[n] = occurrence + 1; + } + + // Gleiche Person (gleiches Kürzel) wird im Board pro Rolle mehrfach + // dargestellt, soll aber im Datenmodell nur einmal existieren. + let person = personByName[n]; + if(!person){ + person = { id: personIdForName(n), name: n, fullName: fullName, role: [] }; + personByName[n] = person; + people.push(person); + } + // Überschreibe den vollen Namen mit dem Wert aus der CSV; die CSV ist + // hier die führende Datenquelle für den vollständigen Namen. + person.fullName = fullName; + // Sammle alle bekannten Rollen der Person; die aktuelle Spaltenrolle + // wird für die Zuordnung über colMap verwendet. + roles.forEach(r => { if(!person.role.includes(r)) person.role.push(r); }); + + colMap.push({ person, role: roles[0] }); + }); + + const projects = []; + for(let rIdx = projectStartIdx; rIdx < rows.length; rIdx++){ + const row = rows[rIdx]; + if(row.length < 4) continue; + const nr = row[0] || ''; + const name = row[1] || ''; + const status = row[2] || ''; + if(!nr.trim() && !name.trim()) continue; + + const assign = {}; + peopleCols.forEach((_, i) => { + const entry = colMap[i]; + if(!entry) return; + const v = (row[3+i] || '').trim(); + const key = assignKey(entry.person.id, entry.role); + if(v === 'X') assign[key] = 1; + else if(v === '(X)') assign[key] = 2; + }); + + const lph = {}; + if(lphStart !== -1){ + LPHS.forEach((l, j) => { + const raw = row[lphStart + j]; + if(raw !== undefined && raw !== ''){ + const num = String(raw).replace(/%/g,''); + if(!isNaN(parseFloat(num)) && isFinite(num)) lph[l] = num; + } + }); + } + + let group = 'Aktive Projekte'; + if(groupIdx >= 0 && row[groupIdx]) group = row[groupIdx].trim() || group; + else if(row.length > 3) group = row[row.length - 1].trim() || group; + + let order = rIdx - projectStartIdx; + if(orderIdx >= 0 && row[orderIdx] !== undefined && row[orderIdx] !== ''){ + const parsed = parseInt(row[orderIdx], 10); + if(!isNaN(parsed)) order = parsed; + } + + let plan = null; + if(planIdx >= 0 && row[planIdx]){ + try { + const parsed = JSON.parse(row[planIdx]); + if(parsed && typeof parsed === 'object') plan = parsed; + } catch(e) { + console.warn('Plan-Daten für Projekt konnten nicht geparst werden:', nr, name, e); + } + } + + let aufwandProLP; + if(aufwandIdx >= 0 && row[aufwandIdx]){ + try { + const parsed = JSON.parse(row[aufwandIdx]); + if(parsed && typeof parsed === 'object') aufwandProLP = parsed; + } catch(e) { + console.warn('LP-Aufwand für Projekt konnten nicht geparst werden:', nr, name, e); + } + } + + let aktuelleLP; + if(aktiveIdx >= 0 && row[aktiveIdx]){ + try { + const parsed = JSON.parse(row[aktiveIdx]); + if(Array.isArray(parsed)) aktuelleLP = parsed; + else if(typeof parsed === 'string') aktuelleLP = [parsed]; + } catch(e) { + console.warn('Aktive Phasen für Projekt konnten nicht geparst werden:', nr, name, e); + } + } + + projects.push({ id: uid(), nr, name, status, group, lph, assign, order, plan, aufwandProLP, aktuelleLP }); + } + + const attendance = {}; + const attRow = rows[attendanceRowIdx]; + if(attRow && attRow[0] === 'ANWESENHEIT'){ + peopleCols.forEach((_, i) => { + const entry = colMap[i]; + if(entry && (attRow[2+i] || '').trim() === 'X'){ + attendance[entry.person.id] = true; + } + }); + } + + return { id: uid(), label, date, people, projects, attendance, locked }; +} \ No newline at end of file diff --git a/public/js/date-picker.js b/public/js/date-picker.js new file mode 100644 index 0000000..b7ea271 --- /dev/null +++ b/public/js/date-picker.js @@ -0,0 +1,221 @@ +// ----------------------------- +// Projektübersicht — Datumsbereich-Picker +// Ein Kalender-Popup für Von+Bis: erster Klick setzt das Startdatum, +// zweiter Klick das Enddatum (bei Klick vor dem Start wird getauscht). +// Schreibt ISO-Werte in zwei (versteckte) Inputs und aktualisiert optional +// ein Anzeige-Element. Verwendung: openRangePicker({ anchor, startInputId, endInputId, displayId, onApply }) +// ----------------------------- +// Diese Datei wird nach js/utils.js von beiden HTML-Einstiegspunkten geladen +// und stellt den Kalender-Picker (Einzeldatum "openDatePicker" und Bereich +// "openRangePicker") bereit. Der Zustand des offenen Popups liegt in der +// Modulvariablen rangePickerState. Genutzt wird der Picker v. a. von +// js/load.js (LP-Bereiche), js/people.js (Abwesenheiten) und den +// Plan-Modulen unter js/plan/* (z. B. GANTT-Datumsangaben in plan-modals.js). + +// rangePickerState: Zustand des aktuell geöffneten Picker-Popups (Optionen, +// Start-/Enddatum, Ansichtsmonat); null solange kein Popup offen ist. +let rangePickerState = null; + +// openDatePicker: öffnet den Kalender zur Auswahl eines einzelnen Datums +// Parameter: opts mit inputId, optional anchor, displayId, onApply. Rückgabe: keine. +function openDatePicker(opts){ + closeRangePicker(); + const input = document.getElementById(opts.inputId); + if(!input) return; + const preValue = parseDateISO(input.value); + const today = new Date(); + rangePickerState = { + opts: Object.assign({}, opts, { mode: 'single' }), + start: preValue, + end: preValue, + mode: 'single', + // Ansichtsmonat: vorhandener Wert, sonst heutiger Monat + viewYear: (preValue || today).getFullYear(), + viewMonth: (preValue || today).getMonth() + }; + + const pop = document.createElement('div'); + pop.id = 'rangePickerPopup'; + pop.className = 'range-picker'; + document.body.appendChild(pop); + + const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 }; + pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px'; + pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px'; + + renderRangePicker(); + + setTimeout(() => { + document.addEventListener('mousedown', rangePickerOutsideClick); + }, 0); +} + +// openRangePicker: öffnet den Kalender zur Auswahl eines Von-Bis-Datumsbereichs +// (oder eines Einzeldatums bei mode:'single'). Parameter: opts mit startInputId, +// endInputId, optional anchor, displayId, mode, onApply. Rückgabe: keine. +function openRangePicker(opts){ + closeRangePicker(); + const isSingle = (opts.mode || 'range') === 'single'; + const startInput = document.getElementById(isSingle ? (opts.inputId || opts.startInputId) : opts.startInputId); + const endInput = isSingle ? null : document.getElementById(opts.endInputId); + if(!startInput || (!isSingle && !endInput)) return; + + const preStart = parseDateISO(startInput.value); + const preEnd = isSingle ? preStart : parseDateISO(endInput.value); + const today = new Date(); + rangePickerState = { + opts: Object.assign({}, opts, { mode: isSingle ? 'single' : 'range' }), + start: preStart, + end: preEnd, + mode: isSingle ? 'single' : 'range', + // Ansichtsmonat: vorhandener Start, sonst heutiger Monat + viewYear: (preStart || today).getFullYear(), + viewMonth: (preStart || today).getMonth() + }; + + const pop = document.createElement('div'); + pop.id = 'rangePickerPopup'; + pop.className = 'range-picker'; + document.body.appendChild(pop); + + // Position: unterhalb des Ankers, innerhalb des Viewports halten + const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 }; + pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px'; + pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px'; + + renderRangePicker(); + + // Klick außerhalb schließt das Popup (ohne Übernahme) + setTimeout(() => { + document.addEventListener('mousedown', rangePickerOutsideClick); + }, 0); +} + +// rangePickerOutsideClick: schließt das Popup, wenn außerhalb davon geklickt wird +// Parameter: e (Mousedown-Event). Rückgabe: keine. +function rangePickerOutsideClick(e){ + const pop = document.getElementById('rangePickerPopup'); + if(pop && !pop.contains(e.target)) closeRangePicker(); +} + +// closeRangePicker: entfernt das Popup wieder und setzt rangePickerState zurück +// Parameter: keine. Rückgabe: keine. +function closeRangePicker(){ + const pop = document.getElementById('rangePickerPopup'); + if(pop) pop.remove(); + document.removeEventListener('mousedown', rangePickerOutsideClick); + rangePickerState = null; +} + +// rangePickerNav: blättert im Kalender um einen Monat vor/zurück und rendert neu +// Parameter: delta (Number, -1 oder 1). Rückgabe: keine. +function rangePickerNav(delta){ + if(!rangePickerState) return; + let m = rangePickerState.viewMonth + delta; + let y = rangePickerState.viewYear; + if(m < 0){ m = 11; y--; } + if(m > 11){ m = 0; y++; } + rangePickerState.viewMonth = m; + rangePickerState.viewYear = y; + renderRangePicker(); +} + +// rangePickerSelect: übernimmt einen Klick auf einen Kalendertag; im Bereichsmodus +// wird beim ersten Klick der Start und beim zweiten das Ende (ggf. getauscht) gesetzt +// Parameter: iso (String, ausgewähltes Datum). Rückgabe: keine. +function rangePickerSelect(iso){ + if(!rangePickerState) return; + const d = parseDateISO(iso); + if(!d) return; + const st = rangePickerState; + if(st.mode === 'single'){ + st.start = d; + st.end = d; + applyRangePicker(); + return; + } + if(!st.start || (st.start && st.end)){ + // Neuen Bereich beginnen + st.start = d; + st.end = null; + } else { + // Zweiter Klick: Ende setzen, bei Bedarf tauschen + if(d < st.start){ st.end = st.start; st.start = d; } + else st.end = d; + applyRangePicker(); + return; + } + renderRangePicker(); +} + +// applyRangePicker: schreibt die gewählten Daten in die (versteckten) Inputs bzw. +// das Anzeige-Element, schließt das Popup und ruft den onApply-Callback auf +// Parameter: keine. Rückgabe: keine. +function applyRangePicker(){ + if(!rangePickerState) return; + const { opts, start, end } = rangePickerState; + const isSingle = (opts.mode || 'range') === 'single'; + const startInput = document.getElementById(isSingle ? (opts.inputId || opts.startInputId) : opts.startInputId); + const endInput = isSingle ? null : document.getElementById(opts.endInputId); + const selected = start || end; + if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : ''); + if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : ''); + if(opts.displayId){ + const disp = document.getElementById(opts.displayId); + if(disp){ + const from = start ? formatDateDE(start) : ''; + const to = end ? formatDateDE(end) : from; + disp.value = isSingle ? (from || '') : (from ? (from + ' – ' + to) : ''); + } + } + const cb = opts.onApply; + closeRangePicker(); + if(typeof cb === 'function') cb(); +} + +// renderRangePicker: baut die Kalender-HTML für den aktuellen Ansichtsmonat und +// zeigt Start/Ende-Auswahl markiert an. Parameter: keine. Rückgabe: keine. +function renderRangePicker(){ + const pop = document.getElementById('rangePickerPopup'); + if(!pop || !rangePickerState) return; + const st = rangePickerState; + const monthNames = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; + const first = new Date(st.viewYear, st.viewMonth, 1); + // Wochentag des Monatsersten, Montag = 0 + const firstWeekday = (first.getDay() + 6) % 7; + const daysInMonth = new Date(st.viewYear, st.viewMonth + 1, 0).getDate(); + const today = new Date(); + const todayMid = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + + let hint; + if(st.mode === 'single') hint = 'Datum wählen'; + else if(!st.start || (st.start && st.end)) hint = 'Startdatum wählen'; + else hint = 'Enddatum wählen (Start: ' + formatDateDE(st.start) + ')'; + + let html = `
+ + ${monthNames[st.viewMonth]} ${st.viewYear} + +
+
${hint}
+
`; + ['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `
${w}
`); + for(let i = 0; i < firstWeekday; i++) html += '
'; + for(let day = 1; day <= daysInMonth; day++){ + const d = new Date(st.viewYear, st.viewMonth, day); + const iso = formatDateISO(d); + const classes = ['range-picker-day']; + if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend'); + if(d.getTime() === todayMid.getTime()) classes.push('today'); + if(st.start && d.getTime() === st.start.getTime()) classes.push('sel-start'); + if(st.end && d.getTime() === st.end.getTime()) classes.push('sel-end'); + if(st.start && st.end && d > st.start && d < st.end) classes.push('in-range'); + html += `
${day}
`; + } + html += '
'; + html += ``; + pop.innerHTML = html; +} \ No newline at end of file diff --git a/public/js/gantt.js b/public/js/gantt.js new file mode 100644 index 0000000..67e7748 --- /dev/null +++ b/public/js/gantt.js @@ -0,0 +1,361 @@ +// ----------------------------- +// Projektübersicht — GANTT-Diagramm (gemeinsam genutzt) +// ----------------------------- +// Gemeinsames Modul für das GANTT-Diagramm: Es wird von Test.html (nach +// js/load.js) UND von project-plan.html geladen – dort zusammen mit globals, +// utils, ui, date-picker, csv, server und den js/plan/*-Modulen, aber OHNE +// js/store.js, js/weeks.js, js/board.js usw. Es darf daher auf der Plan-Seite +// keine Hauptseiten-spezifischen Funktionen voraussetzen; Funktionen, die +// currentWeek()/isWeekLocked() aus js/weeks.js brauchen (renderGantt, +// fitGanttRange, openProjectPlan), werden nur vom Hauptfenster aufgerufen. +// Das Modul stellt u. a. buildGanttHtml(), renderGantt(), fitGanttRange(), +// projectAssignees(), ganttRangeFromProjects() sowie openProjectPlan()/ +// findProjectForPlan() bereit. Die Plan-Seite erweitert buildGanttHtml() über +// js/plan/plan-build-gantt.js und bezieht Beteiligte/Range über +// projectAssignees()/ganttRangeFromProjects() aus diesem Modul. + +// ganttModalProjectId: reservierte Projekt-ID für GANTT-Modal-Dialoge (zurzeit ungenutzt). +let ganttModalProjectId = null; + +// ensurePlan: stellt sicher, dass ein Projekt ein gültiges Plan-Objekt besitzt +// (Start/Ende, todos, Meilensteine, Personen, Fortschritt). Parameter: p = +// Projektobjekt; Rückgabe: das Plan-Objekt von p. +function ensurePlan(p){ + if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 }; + // Alte Meilensteine bleiben erhalten; neu sind todos die primären Planungselemente. + if(!Array.isArray(p.plan.todos)) p.plan.todos = []; + if(!Array.isArray(p.plan.milestones)) p.plan.milestones = []; + return p.plan; +} + +// Beteiligte eines Projekts aus dem Board ableiten (Haupt + Optional) +// week optional: wenn die Funktion nicht im Kontext der aktuellen Hauptseite +// aufgerufen wird (z.B. Projektplan-Seite), muss die passende Woche übergeben +// werden, damit die Personenliste korrekt aufgelöst wird. +function projectAssignees(p, week = null){ + const w = week || currentWeek(); + const people = w ? (w.people || []) : []; + const globalList = db.globalPeople[currentDepartment] || []; + const result = []; + people.forEach(person => { + const globalPerson = globalList.find(gp => gp.id === person.id); + const email = globalPerson ? globalPerson.email : ''; + + (person.role || ['Planung']).forEach(role => { + const key = assignKey(person.id, role); + if(p.assign && p.assign[key]){ + result.push({ person, role, state: p.assign[key], email}); + } + }); + }); + return result; +} + +// projectColor: wählt die Balkenfarbe eines Projekts aus GANTT_COLORS +// deterministisch anhand der Projekt-ID. Parameter: p = Projektobjekt; +// Rückgabe: Hex-Farbstring. +function projectColor(p){ + const idx = Math.abs(String(p.id).split('').reduce((a,c)=>a+c.charCodeAt(0),0)) % GANTT_COLORS.length; + return GANTT_COLORS[idx]; +} + +// ganttRangeFromProjects: berechnet den sichtbaren Zeitraum aus allen +// Projekt-/TODO-/Meilenstein-Terminen sowie den Abwesenheiten der globalen +// Personen, jeweils erweitert um padDays Tage (Standard 7). Parameter: +// projects = Projekt-Array, padDays = Puffer; Rückgabe: { start, end } als Date. +function ganttRangeFromProjects(projects, padDays = 7){ + let min = null, max = null; + projects.forEach(p => { + const plan = p.plan; + if(!plan) return; + [plan.start, plan.end].forEach(d => { + const date = parseDateISO(d); + if(!date) return; + if(!min || date < min) min = date; + if(!max || date > max) max = date; + }); + (plan.milestones || []).forEach(m => { + const date = parseDateISO(m.date); + if(!date) return; + if(!min || date < min) min = date; + if(!max || date > max) max = date; + }); + (plan.todos || []).forEach(t => { + [t.start, t.end].forEach(d => { + const date = parseDateISO(d); + if(!date) return; + if(!min || date < min) min = date; + if(!max || date > max) max = date; + }); + }); + }); + // Urlaub berücksichtigen + (db.globalPeople[currentDepartment] || []).forEach(person => { + (person.absences || []).forEach(a => { + const s = parseDateISO(a.start), e = parseDateISO(a.end); + if(s){ if(!min || s < min) min = s; if(!max || s > max) max = s; } + if(e){ if(!min || e < min) min = e; if(!max || e > max) max = e; } + }); + }); + if(!min || !max){ + const today = new Date(); + min = new Date(today.getFullYear(), today.getMonth(), 1); + max = addDays(min, 90); + } + return { start: addDays(min, -padDays), end: addDays(max, padDays) }; +} + +// fitGanttRange: setzt die Datumsfelder (ganttStart/ganttEnd) auf den +// automatisch berechneten Projekt-Zeitrahmen und rendert das Diagramm neu. +function fitGanttRange(){ + const w = currentWeek(); if(!w) return; + const range = ganttRangeFromProjects(w.projects, 7); + document.getElementById('ganttStart').value = formatDateISO(range.start); + document.getElementById('ganttEnd').value = formatDateISO(range.end); + renderGantt(); +} + +// renderGantt: rendert das GANTT-Diagramm der aktuellen Woche in +// ganttContainer. Nutzt die Datumsfelder bzw. fällt auf den Projekt-Zeitrahmen +// zurück; bei gesperrter Woche ist die Ansicht nicht editierbar. +function renderGantt(){ + const w = currentWeek(); + const container = document.getElementById('ganttContainer'); + if(!w || !container){ + if(container) container.innerHTML = '
Keine Sitzung ausgewählt.
'; + return; + } + let start = parseDateISO(document.getElementById('ganttStart').value); + let end = parseDateISO(document.getElementById('ganttEnd').value); + if(!start || !end){ + const range = ganttRangeFromProjects(w.projects, 7); + start = range.start; end = range.end; + document.getElementById('ganttStart').value = formatDateISO(start); + document.getElementById('ganttEnd').value = formatDateISO(end); + } + const showVacation = document.getElementById('ganttShowVacation').checked; + container.innerHTML = buildGanttHtml(w.projects, start, end, { showVacation, editable: !isWeekLocked(w) }); +} + +// buildGanttHtml: erzeugt das HTML des GANTT-Diagramms (Kopf mit Monaten/ +// KW-Zellen, Wochenend-Hintergrund, Heute-Linie, Urlaubs- und Projektzeilen +// inkl. Fortschritt, Meilensteinen und Drag/Resize-Markern). Parameter: +// projects = Projekt-Array, start/end = sichtbarer Zeitraum, options = +// { showVacation, editable, highlightProjectId, pxPerDay, showTodos, +// vacationPeople }. Rückgabe: HTML-String für .gantt-chart. +function buildGanttHtml(projects, start, end, options = {}){ + const { showVacation = true, editable = false, highlightProjectId = null, pxPerDay = null } = options; + const totalDays = Math.max(1, daysBetween(start, end) + 1); + + // timeline: umhüllt den Zeileninhalt einer GANTT-Zeile mit der Tagesspuren-Containerklasse. + function timeline(content){ + return `
${content}
`; + } + + // dragAttrs: liefert die data-gantt-*-Attribute für Drag & Drop, leer wenn + // nicht editierbar. type = Elementtyp, attrs = zusätzliche Attribute. + function dragAttrs(type, attrs){ + if(!editable) return ''; + let s = ` data-gantt-type="${type}" draggable="false"`; + for(const [k,v] of Object.entries(attrs)){ + s += ` data-gantt-${k}="${esc(String(v))}"`; + } + return s; + } + + // resizeHandles: liefert die beiden Ziehgriffe links/rechts eines Balkens, + // sofern die Ansicht editierbar ist. + function resizeHandles(){ + if(!editable) return ''; + return ``; + } + + // Kopfzeile: Monate und Tage + let headerMonths = '
'; + headerMonths += '
Projekt
'; + let monthsHtml = ''; + let current = new Date(start.getTime()); + let lastMonth = -1; + let monthCells = []; + for(let i = 0; i < totalDays; i++){ + if(current.getMonth() !== lastMonth){ + monthCells.push({ month: current.getMonth(), year: current.getFullYear(), startIdx: i }); + lastMonth = current.getMonth(); + } + current = addDays(current, 1); + } + monthCells.forEach((m, idx) => { + const next = monthCells[idx + 1]; + const span = next ? next.startIdx - m.startIdx : totalDays - m.startIdx; + const names = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; + monthsHtml += `
${names[m.month]} ${m.year}
`; + }); + headerMonths += timeline(monthsHtml) + '
'; + + let headerWeeks = '
'; + headerWeeks += '
'; + let weeksHtml = ''; + current = new Date(start.getTime()); + let lastWeek = -1; + let lastYear = -1; + let weekStartIdx = 0; + const weekCells = []; + for(let i = 0; i < totalDays; i++){ + const cw = getWeekNumber(current); + const cy = current.getFullYear(); + if(cw !== lastWeek || cy !== lastYear){ + if(lastWeek !== -1){ + weekCells.push({ week: lastWeek, year: lastYear, startIdx: weekStartIdx, endIdx: i - 1 }); + } + weekStartIdx = i; + lastWeek = cw; + lastYear = cy; + } + current = addDays(current, 1); + } + if(lastWeek !== -1){ + weekCells.push({ week: lastWeek, year: lastYear, startIdx: weekStartIdx, endIdx: totalDays - 1 }); + } + weekCells.forEach(w => { + const span = w.endIdx - w.startIdx + 1; + weeksHtml += `
KW ${w.week}
`; + }); + headerWeeks += timeline(weeksHtml) + '
'; + + // Raster-Overlay: Wochenenden schattieren und Heute-Linie markieren. + // Liegt als eine Ebene hinter allen Zeilen (erste Chart-Kindnode), + // damit nicht jede Zeile eigene Tageszellen braucht. + let gridHtml = '
'; + current = new Date(start.getTime()); + let weekendStartIdx = null; + for(let i = 0; i <= totalDays; i++){ + const isWeekend = i < totalDays && (current.getDay() === 0 || current.getDay() === 6); + if(isWeekend && weekendStartIdx === null) weekendStartIdx = i; + if(!isWeekend && weekendStartIdx !== null){ + const span = i - weekendStartIdx; + gridHtml += `
`; + weekendStartIdx = null; + } + current = addDays(current, 1); + } + const today = new Date(); + const todayMid = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + if(todayMid >= start && todayMid <= end){ + const todayIdx = daysBetween(start, todayMid); + gridHtml += `
`; + } + gridHtml += '
'; + + let body = gridHtml + headerMonths + headerWeeks; + + // Urlaubszeilen + if(showVacation){ + let allPeople = db.globalPeople[currentDepartment] || []; + // Optional: nur Urlaub bestimmter Personen anzeigen + if(options.vacationPeople && Array.isArray(options.vacationPeople)){ + const allowed = new Set(options.vacationPeople); + allPeople = allPeople.filter(person => allowed.has(person.id)); + } + // Pro Person eine Zeile: alle Abwesenheiten als Balken in derselben Timeline + allPeople.forEach(person => { + let barsHtml = ''; + (person.absences || []).forEach(a => { + const s = parseDateISO(a.start), e = parseDateISO(a.end); + if(!s || !e || e < start || s > end) return; + const rowStart = s < start ? start : s; + const rowEnd = e > end ? end : e; + const offset = daysBetween(start, rowStart); + const span = daysBetween(rowStart, rowEnd) + 1; + barsHtml += `
${resizeHandles()}
`; + }); + if(!barsHtml) return; // keine Abwesenheit im sichtbaren Zeitraum -> keine Zeile + body += `
`; + body += `
${esc(person.name)}
`; + body += timeline(barsHtml); + body += `
`; + }); + } + + // Projektzeilen + projects.slice().sort((a,b) => (a.order||0)-(b.order||0)).forEach(p => { + const plan = p.plan; + const color = projectColor(p); + const milestones = plan && Array.isArray(plan.milestones) ? plan.milestones : []; + body += `
`; + body += `
${esc(p.nr ? p.nr + ' ' : '')}${esc(p.name)}
`; + + let rowContent = ''; + + // Projekt-Balken + const s = plan ? parseDateISO(plan.start) : null, e = plan ? parseDateISO(plan.end) : null; + if(s && e){ + const barStart = s < start ? start : s; + const barEnd = e > end ? end : e; + if(barEnd >= start && barStart <= end){ + const offset = daysBetween(start, barStart); + const span = daysBetween(barStart, barEnd) + 1; + const progress = Math.max(0, Math.min(100, plan && parseFloat(plan.progress) ? parseFloat(plan.progress) : 0)); + rowContent += `
`; + rowContent += `${resizeHandles()}
`; + rowContent += `${esc(p.name)}`; + rowContent += `
`; + } + } + + // Meilensteine: nur die nicht positionierten (keine position) in der Projektzeile anzeigen. + // Positionierte Meilensteine werden vom Aufrufer (z.B. Projektplan) als separate Zeilen eingefügt. + milestones.forEach((m, mIdx) => { + if(options.showTodos && m.position) return; + const d = parseDateISO(m.date); + if(!d || d < start || d > end) return; + const offset = daysBetween(start, d); + rowContent += `
`; + rowContent += `
${esc(m.name || '')}
`; + }); + + body += timeline(rowContent) + `
`; + }); + + // pxPerDay setzt eine feste Breite pro Tag, damit das Diagramm bei hohem + // Zoom breiter als der Container wird und horizontal gescrollt werden kann. + let chartStyle = ''; + if(pxPerDay){ + chartStyle = ` style="min-width:${(220 + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`; + } + return `
${body}
`; +} + +// ---------- Projektplan-Navigation ---------- + +// openProjectPlan: öffnet project-plan.html in einem neuen Tab. Weil +// Projekt-IDs nicht persistent sind, werden department, Wochen-Label, Proj.-Nr. +// und -Name als URL-Parameter übergeben. Parameter: pid = Projekt-ID der +// aktuellen Woche. Kein Rückgabewert. +function openProjectPlan(pid){ + const w = currentWeek(); if(!w) return; + const p = w.projects.find(x => x.id === pid); if(!p) return; + // IDs sind nicht persistent (werden bei jedem CSV-Laden neu erzeugt). + // Daher werden stabile Werte (Label, Projekt-Nr., Name) in der URL übergeben. + const url = new URL('project-plan.html', window.location.href); + url.searchParams.set('department', currentDepartment); + url.searchParams.set('weekLabel', w.label); + url.searchParams.set('projNr', p.nr || ''); + url.searchParams.set('projName', p.name || ''); + window.open(url.toString(), '_blank'); +} + +// Diese Funktion wird von project-plan.html aufgerufen, um das aktuelle +// Wochen-/Projekt-Objekt im Hauptfenster zu finden. +// Parameter: department, weekId, projectId; Rückgabe: { week, project } oder +// null, wenn die Woche/Abteilung nicht (synchron) auflösbar ist. +function findProjectForPlan(department, weekId, projectId){ + if(currentDepartment !== department){ + const targetWeek = db.weeks.find(w => w.id === weekId); + if(!targetWeek) return null; + // temporär umschalten ist unpraktisch, daher versuchen wir über load-all + return null; + } + const w = db.weeks.find(x => x.id === weekId); + if(!w) return null; + return { week: w, project: w.projects.find(x => x.id === projectId) }; +} \ No newline at end of file diff --git a/public/js/globals.js b/public/js/globals.js new file mode 100644 index 0000000..627eea8 --- /dev/null +++ b/public/js/globals.js @@ -0,0 +1,49 @@ +// ----------------------------- +// Projektübersicht — globale Konstanten & Zustand +// ----------------------------- +// Diese Datei wird als Erstes von Test.html und project-plan.html geladen und +// legt alle zentralen Konstanten (Leistungsphasen, Rollen, Gruppen, Abteilungen, +// Farben, Passwort-Hash) sowie den globalen In-Memory-Zustand (db, +// currentDepartment, currentTab) fest. Sie stellt keine Funktionen, sondern nur +// Daten bereit, auf die alle weiteren Module (js/store.js, js/board.js, +// js/gantt.js, js/plan/*, ...) zugreifen. Auf dem Plan-Gantt lädt zusätzlich +// js/plan/plan-state.js Zustand für die Planungsseite. + +// Konstanten: Leistungsphasen, Rollen und LocalStorage-Key +const LPHS = ["LPH 1","LPH 2","LPH 3","LPH 4","LPH 5","LPH 6","LPH 7","LPH 8", "W+M"]; // Spalten für Leistungsphasen +const ROLES = ["Planung","Projektleitung","Bauüberwachung"]; // Gruppierung der Personen +const GROUP_ORDER = ["Aktive Projekte","Kleine Projekte","In Anbahnung","Allgemein"]; // Reihenfolge der Projektgruppen +const STORAGE = "projuebersicht_v1"; // Durch lokale UI-State-Persistenz genutzter Key +// DEFAULT_DEPARTMENT: Fallback-Abteilung beim ersten Start +const DEFAULT_DEPARTMENT = 'ELT'; +// DEFAULT_TAB: Tab, der beim ersten Start angezeigt wird ('board' = Projektboard) +const DEFAULT_TAB = 'board'; + +// Farbpalette für GANTT-Balken (eine pro Projekt, wiederholend) +const GANTT_COLORS = ['#0071e3','#248a3d','#b25000','#7c3aed','#d70015','#0ca678','#d9480f','#5f3dc4']; + +// Passwortschutz: SHA256-Hash des erlaubten Passworts. +// Wenn Sie das Passwort ändern möchten, generieren Sie den SHA256-Hash (https://emn178.github.io/online-tools/sha256.html) +// des neuen Passworts und ersetzen Sie den Wert hier. +const PROTECTED_PASSWORD_HASH = "107bab579e8500e459690cf48ba77de32be43cadb8421bbe79fad20a55b39b6f"; + +// requestedAction speichert die Aktion, die nach erfolgreicher Passwort-Eingabe ausgeführt wird. +let requestedAction = null; +// saveSyncTimer: Handle für den debounced Speicher-Timer (Sync auf Server) +let saveSyncTimer = null; +// isAuthenticated: Merkt sich, ob das Passwort-Schutz-Modal bereits erfolgreich bestätigt wurde +let isAuthenticated = false; + +// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung +let db = { weeks: [], currentWeek: null, globalPeople: {} }; + +// Aktuelle Abteilung (ELT, HLS, ARCH). Daten werden in sessions// getrennt gespeichert. +let currentDepartment = DEFAULT_DEPARTMENT; +// currentTab: aktuell aktiver Tab der Hauptseite (z. B. 'board') +let currentTab = DEFAULT_TAB; +// DEPARTMENTS: Auflistung der gültigen Abteilungen mit Anzeigename und Icon +const DEPARTMENTS = { + ELT: { label: 'Elektro', icon: '' }, + HLS: { label: 'HLS', icon: '' }, + ARCH: { label: 'Architektur', icon: '' } +}; \ No newline at end of file diff --git a/public/js/init.js b/public/js/init.js new file mode 100644 index 0000000..3c43ab6 --- /dev/null +++ b/public/js/init.js @@ -0,0 +1,68 @@ +// ----------------------------- +// Projektübersicht — Init / Start +// ----------------------------- +// Einstiegsmodul der Hauptseite: wird als letztes Skript ausschließlich von +// Test.html geladen und startet direkt beim Laden den Initialisierungsablauf +// (automatische Ausführung unten in der Datei, nur wenn ein 'board'-Element +// existiert – auf project-plan.html fehlt es). Es stellt renderAll(), +// initUiState() und syncRangeDisplay() bereit und nutzt loadUiState()/ +// loadTheme()/loadHeaderState() aus js/ui.js, switchDepartment() aus +// js/store.js, showTab() aus js/tabs-auth.js sowie die Render-Funktionen +// renderBoard()/renderLoad()/renderProjects()/renderPeople() aus js/board.js, +// js/load.js, js/projects.js, js/people.js. + +// renderAll: Convenience um UI-Elemente zu initialisieren +function renderAll(){ + refreshWeekSelect(); + updateWeekDate(); + renderBoard(); + const activeTab = document.querySelector('.tab.active')?.dataset.tab || currentTab; + if(activeTab === 'load') renderLoad(); + else if(activeTab === 'projects') renderProjects(); + else if(activeTab === 'people') renderPeople(); +} + +// initUiState: stellt beim Start den UI-Zustand her (LocalStorage-UI-State, +// Theme, Header-Status), setzt Titel und aktive Abteilungs-Schaltfläche und +// zeigt den ersten Tab an – passwortgeschützte Tabs ('projects'/'people') +// werden beim Start nie direkt aktiviert. Kein Rückgabewert. +function initUiState(){ + loadUiState(); + loadTheme(); + loadHeaderState(); + document.querySelectorAll('.dept-tab').forEach(btn => { + btn.classList.toggle('active', btn.dataset.dept === currentDepartment); + }); + const info = DEPARTMENTS[currentDepartment]; + document.title = 'Projektübersicht ' + info.label; + const titleEl = document.getElementById('pageTitle'); + if(titleEl) titleEl.textContent = info.icon + ' Projektübersicht ' + info.label; + + const tab = ['board','load','projects','people'].includes(currentTab) ? currentTab : DEFAULT_TAB; + // Beim initialen Start niemals einen passwortgeschützten Tab direkt anzeigen. + const safeTab = (tab === 'projects' || tab === 'people') ? DEFAULT_TAB : tab; + showTab(safeTab, true); +} + +// syncRangeDisplay: schreibt den aus zwei Datumsfeldern (startId/endId) +// berechneten Zeitraum als deutschen Text (z. B. "05.04.2026 – 11.04.2026") +// in das Anzeigefeld displayId. Parameter: die drei Element-IDs; kein Rückgabewert. +function syncRangeDisplay(displayId, startId, endId){ + const disp = document.getElementById(displayId); + if(!disp) return; + const sEl = document.getElementById(startId); + const eEl = document.getElementById(endId); + const s = sEl ? parseDateISO(sEl.value) : null; + const e = eEl ? parseDateISO(eEl.value) : null; + const from = s ? formatDateDE(s) : ''; + const to = e ? formatDateDE(e) : from; + disp.value = from ? (from + ' – ' + to) : ''; +} + +// Automatische Initialisierung nur auf der Hauptseite (Projektboard). +// Andere Seiten (z.B. Projektplan) laden diese Module nicht oder nur als +// Hilfsfunktionen und haben ihre eigene Seitenlogik (js/plan/*). +if(document.getElementById('board')){ + initUiState(); + switchDepartment(currentDepartment); +} \ No newline at end of file diff --git a/public/js/load.js b/public/js/load.js new file mode 100644 index 0000000..0b6a9f6 --- /dev/null +++ b/public/js/load.js @@ -0,0 +1,352 @@ +// ----------------------------- +// Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase +// ----------------------------- +// Modul für den Auslastungs-Tab der Hauptseite (Test.html, Ladereihenfolge: +// globals → utils → ui → date-picker → csv → server → store → weeks → board → +// people → projects → load → gantt → …). Es stellt renderLoad(), das LP- +// Aufwand-Modal und das Neues-Projekt-Modal bereit. Genutzt werden LPHS/ROLES +// aus js/globals.js, currentWeek()/save() aus js/store.js, guardWeekLocked() +// aus js/weeks.js sowie peopleByRole()/assignKey() aus js/board.js/js/utils.js. +// Die Auslastungs-Logik (berechneAuslastung, lpActiveList, faktorFormat, +// pdfLoadAmpelColor, LOAD_BAR_MAX) wird zusätzlich von js/pdf.js für den +// PDF-Export wiederverwendet. +// +// Datenmodell je Projekt (rollen- und leistungsphasenspezifisch): +// pr.aufwandProLP = { +// 'LPH 1': { Planung: 0.4, Projektleitung: 0.6, Bauüberwachung: 0.0 }, +// ... +// } // Gewichtungsfaktor je Rolle (0.0–1.0) +// pr.aktuelleLP = ['LPH 6'] // aktuell amtierende Phase (max. 1) +// +// Es zählt genau eine amtierende Phase je Projekt ("Nur amtierende Einzelphase", +// Absprache mit dem Auftraggeber). Altdaten mit mehreren aktiven Phasen +// verwenden deterministisch die erste aktive Phase der Liste. +// +// Berechnungslogik: +// Auslastung(Mitarbeiter) = Σ Faktor der amtierenden LP je Projekt, +// in dem der Mitarbeiter (in seiner Rolle) zugewiesen ist. +// Der Faktor zählt 1:1 – die Zuordnungsart (Haupt/Optional) skaliert nicht. +// Leere Matrix-Felder zählen bei gesetzter aktiver LP mit dem Standard-Faktor +// 1,0; ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet). +// "In Anbahnung" wird nicht eingerechnet. +// +// HINWEIS (Fachplaner-Fragen, bewusst NICHT eigenständig entschieden): +// - Ampel-Schwellen (50 % / 75 % des Maximalwerts) sind PLATZHALTER und müssen +// fachlich abgestimmt werden. +// - Mitarbeiterkapazität (Teilzeit/Vollzeit) existiert nicht – offener Punkt. + +// AUFWAND_LPS: Phasen des Aufwandsmodells (LPH 1–8; "W+M" ist bewusst nicht +// enthalten, entspricht der Entscheidung "LP-Schema: lp1-8"). +const AUFWAND_LPS = LPHS.slice(0, 8); + +// LP_AUFWAND_VORLAGE: Standardwerte der Maske "Aufwand je Leistungsphase". +// Vorbefüllung beim Anlegen neuer Projekte sowie beim Öffnen der Maske für +// bestehende Projekte, die in einzelnen Feldern keine Werte haben – beim +// Speichern werden sie übernommen. Werte 0.0–1.0 je Rolle; 0 = kein Aufwand. +// Nicht gelistete Phasen/Rollen bleiben leer und zählen als Standard-Faktor 1,0. +// Hier im Code anpassen. +const LP_AUFWAND_VORLAGE = { + 'LPH 1': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 }, + 'LPH 2': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 }, + 'LPH 3': { Planung: 1.0, Projektleitung: 0.3, Bauüberwachung: 0 }, + 'LPH 4': { Planung: 0.0, Projektleitung: 0.3, Bauüberwachung: 0 }, + 'LPH 5': { Planung: 1.0, Projektleitung: 0.4, Bauüberwachung: 0 }, + 'LPH 6': { Planung: 0.2, Projektleitung: 0.7, Bauüberwachung: 0.3 }, + 'LPH 7': { Planung: 0.1, Projektleitung: 0.3, Bauüberwachung: 0.5 }, + 'LPH 8': { Planung: 0, Projektleitung: 0.2, Bauüberwachung: 1.0 } +}; + +// PLATZHALTER! Ampel-Schwellen als Anteil des Maximalwerts LOAD_BAR_MAX +// (gelb ab 50 %, rot ab 75 %). Müssen vom Fachplaner bestätigt werden. +const THRESHOLD_RATIO_CRITICAL = 0.75; +const THRESHOLD_RATIO_ELEVATED = 0.50; + +// lpActiveList: liefert die amtierenden Leistungsphasen eines Projekts, +// gefiltert auf das Aufwandsmodell (LPH 1–8). Parameter: pr = Projektobjekt. +// Rückgabe: Array von LP-Namen (leer, wenn keine aktive Phase passt). +function lpActiveList(pr){ + const a = pr && pr.aktuelleLP; + if(Array.isArray(a)) return a.filter(l => AUFWAND_LPS.includes(l)); + if(typeof a === 'string' && AUFWAND_LPS.includes(a)) return [a]; + return []; +} + +// aufwandMatrixWert: Rollenwert eines Projekts für eine Leistungsphase +// (Gewichtungsfaktor, 0,0–1,0). Liefert null, wenn keine (rollenspezifische) Matrix +// bzw. kein Wert für diese Phase/Rolle hinterlegt ist. 0 ist ein gültiger Wert. +function aufwandMatrixWert(projekt, lp, rolle){ + const m = projekt && projekt.aufwandProLP; + const entry = (m && typeof m === 'object') ? m[lp] : undefined; + if(!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const v = parseFloat(entry[rolle]); + return isNaN(v) ? null : v; +} + +// berechneAuslastung: zentrale Auslastungsberechnung einer Zuordnung +// (Person/Rolle) zu einem Projekt für die amtierende Leistungsphase. +// Ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet). +// Mit aktiver LP: Liegt aufwandProLP[aktuelleLP][rolle] vor, zählt dieser +// Faktor 1:1 – die Zuordnungsart (Haupt/Optional) skaliert bewusst nicht mehr. +// Fehlt der Matrix-Wert, gilt der Standard-Faktor 1,0. +// "In Anbahnung" wird nicht eingerechnet. +function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status){ + if(!projekt || projekt.group === 'In Anbahnung') return { load: 0, anbahnung: true }; + if(!aktuelleLP) return { load: 0 }; + const wert = aufwandMatrixWert(projekt, aktuelleLP, rolle); + if(wert !== null) return { load: wert }; + return { load: 1.0 }; +} + +// faktorFormat: formatiert einen Auslastungs-Faktor in deutscher Notation +// (Komma, max. 2 Nachkommastellen), z. B. 1.1 → "1,10". 1,0 = 100 % Kapazität. +function faktorFormat(x){ + const n = Math.round((x || 0) * 100) / 100; + return n.toFixed(2).replace('.', ','); +} + +// loadAmpelColor: Ampel-Farbe relativ zum Maximalwert (gelb ab 50 %, +// rot ab 75 % von LOAD_BAR_MAX; PLATZHALTER). +function loadAmpelColor(x){ + return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#c53030' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#d69e2e' : '#2f855a'; +} +// pdfLoadAmpelColor: Ampel-Farbe für den PDF-Export (js/pdf.js) mit gleichen +// Schwellen wie loadAmpelColor, aber dunkleren, druckfreundlichen Farbtönen. +function pdfLoadAmpelColor(x){ + return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#D70015' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#B25000' : '#248A3D'; +} + +// ---------- LP-Aufwand Eingabemaske ---------- + +// lpModalProjectId: Projekt-ID des aktuell offenen LP-Aufwand-Modals (null = geschlossen). +let lpModalProjectId = null; + +// lpRowsHtml: erzeugt die Aufwand-Maske als Matrix-Tabelle. +// Zeilen = Leistungsphasen (LPH 1–8), Spalten = Rollen (Planung/Projektleitung/ +// Bauüberwachung). Die amtierende Leistungsphase wird pro Projekt im Tab +// "Projekte" gewählt (renderProjects / editProjectActiveLP). +// prefix: 'lp' = Bearbeiten-Modal, 'np' = Neues-Projekt-Modal (eindeutige IDs). +// Leere Felder werden mit den Standardwerten aus LP_AUFWAND_VORLAGE vorbefüllt. +// Die Feld-IDs bleiben identisch, damit collectLpRows() unverändert funkioniert. +function lpRowsHtml(p, prefix){ + const head = ROLES.map(r => `${esc(r)}`).join(''); + const rows = AUFWAND_LPS.map(l => { + const idx = l.replace(/[^\d]/g,''); + const row = (p.aufwandProLP && typeof p.aufwandProLP[l] === 'object') ? p.aufwandProLP[l] : {}; + const cells = ROLES.map((r, ri) => { + const raw = row[r]; + const vorlage = LP_AUFWAND_VORLAGE && LP_AUFWAND_VORLAGE[l] ? LP_AUFWAND_VORLAGE[l][r] : undefined; + const base = (raw !== undefined && raw !== null && raw !== '') ? raw + : (vorlage !== undefined && vorlage !== null && vorlage !== '') ? vorlage : ''; + const val = base !== '' ? String(parseFloat(base)).replace('.', ',') : ''; + return ``; + }).join(''); + return `${l}${cells}`; + }).join(''); + return ` + ${head} + ${rows} +
Leistungsphase
`; +} + +// collectLpRows: liest die Aufwand-Maske aus und validiert die Werte (0–1). +// Rückgabe: { aufwand, hasValue } oder null bei Eingabefehler. +function collectLpRows(prefix){ + const aufwand = {}; + let hasValue = false; + for(const l of AUFWAND_LPS){ + const idx = l.replace(/[^\d]/g,''); + const entry = {}; + let rowHasValue = false; + let invalid = false; + for(let ri = 0; ri < ROLES.length; ri++){ + const r = ROLES[ri]; + const input = document.getElementById(prefix + 'Val' + idx + 'R' + ri); + if(!input) continue; + const raw = input.value.trim().replace(',', '.'); + if(raw === '') continue; + const v = parseFloat(raw); + if(isNaN(v) || v < 0 || v > 1){ + alert('Bitte Werte zwischen 0 und 1 eintragen (z. B. 0,4) – oder das Feld leer lassen. (' + l + ', ' + r + ')'); + invalid = true; + break; + } + entry[r] = v; + rowHasValue = true; + } + if(invalid) return null; + if(rowHasValue){ aufwand[l] = entry; hasValue = true; } + } + return { aufwand, hasValue }; +} + +// applyLpRows: übernimmt das Ergebnis von collectLpRows auf ein Projekt. +// Die Maske ist mit den Standardwerten (LP_AUFWAND_VORLAGE) vorbefüllt; +// Felder ohne Vorbefüllung, die leer bleiben, zählen bei gesetzter aktiver LP +// später als Standard-Faktor 1,0; ohne aktive LP zählt das Projekt 0. +// Die amtierende Leistungsphase wird separat über den Tab "Projekte" gesetzt. +function applyLpRows(p, lp){ + if(!lp) return false; + p.aufwandProLP = lp.hasValue ? lp.aufwand : undefined; + return true; +} + +// openLpModal: öffnet das Modal "Aufwand je Leistungsphase" für ein Projekt. +// Parameter: pid = Projekt-ID; gesperrte Wochen werden abgewiesen. Kein Rückgabewert. +function openLpModal(pid){ + if(guardWeekLocked()) return; + const p = currentWeek().projects.find(x=>x.id===pid); + if(!p) return; + lpModalProjectId = pid; + const titleEl = document.getElementById('lpModalProject'); + if(titleEl) titleEl.textContent = (p.nr ? p.nr + ' – ' : '') + (p.name || ''); + const rowsEl = document.getElementById('lpModalRows'); + if(rowsEl) rowsEl.innerHTML = lpRowsHtml(p, 'lp'); + document.getElementById('lpModal').style.display = 'flex'; +} + +// closeLpModal: blendet das LP-Aufwand-Modal aus und setzt lpModalProjectId zurück. +function closeLpModal(){ + document.getElementById('lpModal').style.display = 'none'; + lpModalProjectId = null; +} + +// saveLpModal: liest die LP-Maske aus, übernimmt sie auf das Projekt, speichert +// und rendert Board, Projekte und Auslastung neu; danach schließt sich das Modal. +function saveLpModal(){ + if(guardWeekLocked()) return; + const p = currentWeek().projects.find(x=>x.id===lpModalProjectId); + if(!p) return; + const lp = collectLpRows('lp'); + if(lp === null) return; + if(applyLpRows(p, lp)) save(); + renderBoard(); renderProjects(); renderLoad(); + closeLpModal(); +} + +// ---------- Neues Projekt (mit LP-Aufwand beim Anlegen) ---------- + +// openNewProjModal: öffnet das Modal "Neues Projekt" mit geleerten Eingaben, +// der Auswahl der aktiven LP und einer leeren Aufwand-Maske (prefix 'np'). +function openNewProjModal(){ + if(guardWeekLocked()) return; + document.getElementById('newProjNr').value = ''; + document.getElementById('newProjName').value = ''; + document.getElementById('newProjGroup').value = 'Aktive Projekte'; + const lpSel = document.getElementById('newProjActiveLP'); + if(lpSel){ + lpSel.innerHTML = '' + AUFWAND_LPS.map(l => ``).join(''); + lpSel.value = ''; + } + const rowsEl = document.getElementById('newProjLpRows'); + if(rowsEl) rowsEl.innerHTML = lpRowsHtml({ aufwandProLP: {}, aktuelleLP: [] }, 'np'); + document.getElementById('newProjModal').style.display = 'flex'; +} + +// closeNewProjModal: blendet das Neues-Projekt-Modal aus. +function closeNewProjModal(){ + document.getElementById('newProjModal').style.display = 'none'; +} + +// saveNewProj: legt ein neues Projekt an (Nr., Name, Gruppe, aktive LP, +// optionaler LP-Aufwand), berechnet seine Reihenfolge in der Gruppe, speichert +// und rendert Projekte und Board neu. Kein Rückgabewert. +function saveNewProj(){ + if(guardWeekLocked()) return; + const nr = document.getElementById('newProjNr').value.trim(); + const name = document.getElementById('newProjName').value.trim(); + if(!name){ alert("Bitte Projektbezeichnung eingeben."); return; } + const group = document.getElementById('newProjGroup').value; + const activeLP = document.getElementById('newProjActiveLP').value; + const lp = collectLpRows('np'); + if(lp === null) return; + const w = currentWeek(); + const nextOrder = w.projects + .filter(p=>p.group===group) + .reduce((max,p)=> Math.max(max, p.order!==undefined ? p.order : 0), -1) + 1; + const pr = {id:uid(),nr,name,group,status:'',lph:{},assign:{}, statusState: undefined, order: nextOrder, plan: null}; + if(activeLP) pr.aktuelleLP = [activeLP]; + applyLpRows(pr, lp); + w.projects.push(pr); + save(); renderProjects(); renderBoard(); + closeNewProjModal(); +} + +// LOAD_BAR_MAX: Maximalwert der Balken-Skala im Auslastungs-Tab (und im +// PDF-Export). 1,0 = 100 % Kapazität; Werte > 1,0 = Überlastung (rot). +const LOAD_BAR_MAX = 8; + +// renderLoad: berechnet die Belastung der Personen auf Basis des +// rollenspezifischen LP-Gewichtungsfaktors je amtierender Phase (0,0–1,0, +// Standard 1,0). Leere Matrix-Werte zählen bei gesetzter aktiver LP als 1,0; +// ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet). +// Ampel: Schwellen relativ zum Maximalwert LOAD_BAR_MAX +// (gelb ab 50 %, rot ab 75 % – PLATZHALTER). +// Balken: Skala auf das feste Maximum LOAD_BAR_MAX (Standard 8). +function renderLoad(){ + const scope = document.getElementById('loadScope').value; + const barMax = LOAD_BAR_MAX; + const selectedWeek = currentWeek(); + const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []); + if(weeks.length === 0){ + document.getElementById('loadGrid').innerHTML = '
Keine Sitzung ausgewählt oder die Daten werden noch geladen.
'; + return; + } + const grouped = scope==='all' ? peopleByRole(db.weeks) : peopleByRole(selectedWeek); + const stats = {}; + ROLES.forEach(r=>{ + grouped[r].forEach(entry=>{ + stats[assignKey(entry.person.id, r)] = {load:0,projects:{},anbahnung:0}; + }); + }); + + weeks.forEach(w=>{ + w.projects.forEach(pr=>{ + const aktivePhase = lpActiveList(pr)[0]; + Object.keys(pr.assign).forEach(aKey=>{ + const s = stats[aKey]; + if(!s) return; + const st = pr.assign[aKey]; + const rolle = aKey.slice(aKey.indexOf('|') + 1); + const al = berechneAuslastung(null, pr, aktivePhase, rolle, st); + if(al.anbahnung){ + if(st===1 || st===2) s.anbahnung++; + return; + } + if(al.load > 0){ + s.load += al.load; + s.projects[pr.name] = (s.projects[pr.name]||0)+1; + } + }); + }); + }); + + // Hochgerechnete Auslastung: durchschnittliche Wochenlast als Faktor + // der Kapazität (1,0 = 100 %). Werte > 1,0 = Überlastung. + const nWeeks = Math.max(1, weeks.length); + const weekAvg = k => stats[k].load / nWeeks; + let html=""; + ROLES.forEach(r=>{ + html += `
${r}
`; + if(grouped[r].length===0){ + html += `
Keine Personen.
`; + } + grouped[r].forEach(entry=>{ + const s = stats[assignKey(entry.person.id, r)]; + const avg = weekAvg(assignKey(entry.person.id, r)); + // Balken relativ zur einstellbaren Skala (Standard 8); Überlastung > 1,0 + // bleibt über die Ampel-Farbe sichtbar, der Zahlenwert bleibt ungedeckelt. + const pct = Math.min(100, Math.round(avg / barMax * 100)); + const detail = Object.keys(s.projects).map(k=>`
${esc(k)}${s.projects[k]}×
`).join('') || '
keine Zuordnung
'; + const anbahnungBadge = (s.anbahnung && s.anbahnung>0) ? `(${s.anbahnung} in Anbahnung)` : ''; + html += `
+

${esc(entry.person.fullName)} (${r})

+

${anbahnungBadge}

+
${faktorFormat(avg)}
+
+
${detail}
+
`; + }); + html += `
`; + }); + document.getElementById('loadGrid').innerHTML=html; +} \ No newline at end of file diff --git a/public/js/pdf.js b/public/js/pdf.js new file mode 100644 index 0000000..289861a --- /dev/null +++ b/public/js/pdf.js @@ -0,0 +1,566 @@ +// ----------------------------- +// Projektübersicht — Export PDF (pdfmake) +// Erzeugt ein Vektor-PDF: Deckblatt, Projektübersicht nach Gruppen, +// Anwesenheit und Auslastung. Format durchgehend A3 quer. +// Ersetzt die frühere jsPDF/html2canvas-Rasterlösung. +// ----------------------------- +// Modul für den PDF-Export der Hauptseite (nur von Test.html geladen, direkt +// vor js/init.js); exportPDF() hängt am Export-Button von Test.html und nutzt +// die per CDN eingebundene Bibliothek pdfmake (offline nicht verfügbar). Es +// liest die aktuelle Woche über currentWeek() aus js/store.js, Personen über +// peopleByRole() aus js/board.js, Helfer wie assignKey()/sortGroups()/ +// formatWeekDate() aus js/utils.js sowie die Auslastungs-Berechnung +// (berechneAuslastung, lpActiveList, faktorFormat, pdfLoadAmpelColor, +// LOAD_BAR_MAX) aus js/load.js. Das Modul stellt ausschließlich exportPDF() +// bereit. + +// Farbkonstanten des PDF-Layouts: Rollen-Hintergründe, neutrale bzw. +// alternierende Zeilenfarben, Rahmenfarbe und die Kennzeichnungsfarben für +// Haupt-Zuordnung (X) und Optionale Zuordnung ((X)). +const PDF_ROLE_BG = { + 'Planung': '#D4F5E0', + 'Projektleitung': '#FFDBDB', + 'Bauüberwachung': '#CBEEFF' +}; +const PDF_NEUTRAL_BG = '#F0F0F2'; +const PDF_ALT_ROW_BG = '#FAFAFC'; +const PDF_BORDER = '#a6a6ad'; +const PDF_ASSIGN_MAIN = '#248A3D'; +const PDF_ASSIGN_OPT = '#D18B00'; + + +// --- Seitengeometrie (A3 quer, in pt) --- +const PDF_PAGE_W = 1190.55; // A3 quer, vorher A4: 841.89 +const PDF_MARGIN_X = 20; +const PDF_PAD_X = 2; +const PDF_LINE_W = 0.8; +const PDF_USABLE_W = PDF_PAGE_W - PDF_MARGIN_X * 2; // 1150,55 + + +// PDF_STYLES: wiederverwendbare Textstile (Überschriften, Tabellen-/Zellformat) +// für die pdfmake-Dokumentdefinition +const PDF_STYLES = { + h1: { fontSize: 20, bold: true, color: '#1D1D1F' }, + h2: { fontSize: 10, bold: true, color: '#1D1D1F' }, + th: { fontSize: 7, bold: true, color: '#1D1D1F', fillColor: PDF_NEUTRAL_BG }, + thSub: { fontSize: 6.5, bold: true, color: '#1D1D1F' }, + td: { fontSize: 7, color: '#1D1D1F' }, + tdCenter: { fontSize: 7, color: '#1D1D1F', alignment: 'center' } +}; + +// pdfRoleBg: liefert die Hintergrundfarbe einer Rolle aus PDF_ROLE_BG bzw. den +// übergebenen Fallback. Parameter: role = Rollenname, fallback = Ersatzfarbe; +// Rückgabe: Hex-Farbstring. +function pdfRoleBg(role, fallback){ + return PDF_ROLE_BG[role] || fallback || '#FFFFFF'; +} + +// pdfTableLayout: erzeugt das pdfmake-Tabellenlayout (feine Rahmen, kleines +// Zell-Padding in den PDF-Farben). Rückgabe: Layout-Objekt für pdfmake. +function pdfTableLayout(){ + return { + hLineWidth: () => PDF_LINE_W, + vLineWidth: () => PDF_LINE_W, + hLineColor: () => PDF_BORDER, + vLineColor: () => PDF_BORDER, + paddingLeft: () => PDF_PAD_X, + paddingRight: () => PDF_PAD_X, + paddingTop: () => 2, + paddingBottom: () => 2 + }; +} + +// pdfStatusText: wandelt das Status-HTML in eine pdfmake-Textstruktur um. +// Erhält Zeilenumbrüche (
, Block-Ende von

/

) sowie die vom +// Sanitizing erlaubten Auszeichnungen (bold, italic, underline, color). +// Rückgabe: Objekt mit text-Array, direkt als Zellinhalt verwendbar. +function pdfStatusText(htmlStatus){ + if(!htmlStatus) return { text: '', style: 'td' }; + + const root = document.createElement('div'); + root.innerHTML = htmlStatus; + + const parts = []; + + // pushNewline: fügt einen Umbruch ein, vermeidet aber führende + // und doppelte Leerzeilen. + function pushNewline(){ + if(parts.length === 0) return; + const last = parts[parts.length - 1]; + if(typeof last.text === 'string' && last.text.endsWith('\n')) return; + parts.push({ text: '\n' }); + } + + // BLOCK: Block-Tags, deren Ende einen Zeilenumbruch erzeugt + const BLOCK = new Set(['P', 'DIV']); + + // walk: durchläuft den DOM-Baum rekursiv und sammelt Textfragmente samt + // geerbter Auszeichnungen (bold/italics/underline/color) als pdfmake-Inlines. + // node = Knoten, inh = von den Eltern geerbte Stil-Attribute. + function walk(node, inh){ + for(const child of node.childNodes){ + + // Textknoten + if(child.nodeType === 3){ + // Nur Zeichen normalisieren, die im HTML keine Bedeutung haben. + // Echte Umbrüche kommen ausschliesslich aus BR/Blockenden. + const t = child.nodeValue + .replace(/\u00A0/g, ' ') + .replace(/[\r\n\t]+/g, ' '); + if(t === '') continue; + // Reine Whitespace-Knoten zwischen Blöcken überspringen + if(/^\s+$/.test(t) && (parts.length === 0 + || (typeof parts[parts.length-1].text === 'string' + && parts[parts.length-1].text.endsWith('\n')))) continue; + parts.push(Object.assign({ text: t }, inh)); + continue; + } + + if(child.nodeType !== 1) continue; + + const tag = child.tagName.toUpperCase(); + + if(tag === 'BR'){ + parts.push({ text: '\n' }); + continue; + } + + // Auszeichnungen vererben + const own = Object.assign({}, inh); + if(tag === 'B' || tag === 'STRONG') own.bold = true; + if(tag === 'I' || tag === 'EM') own.italics = true; + if(tag === 'U') own.decoration = 'underline'; + + // Farbe / Fettung aus style bzw. font color + const st = child.getAttribute && child.getAttribute('style'); + if(st){ + const mCol = st.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i); + if(mCol){ + const c = pdfNormalizeColor(mCol[1].trim()); + if(c) own.color = c; + } + const mFw = st.match(/(?:^|;)\s*font-weight\s*:\s*([^;]+)/i); + if(mFw){ + const v = mFw[1].trim().toLowerCase(); + if(v === 'bold' || v === 'bolder' || parseInt(v, 10) >= 600) own.bold = true; + if(v === 'normal' || parseInt(v, 10) < 600) own.bold = false; + } + } + if(tag === 'FONT' && child.getAttribute('color')){ + const c = pdfNormalizeColor(child.getAttribute('color')); + if(c) own.color = c; + } + + if(BLOCK.has(tag)){ + pushNewline(); + walk(child, own); + pushNewline(); + } else { + walk(child, own); + } + } + } + + walk(root, {}); + + // Führende und schliessende Umbrüche entfernen + while(parts.length && parts[0].text === '\n') parts.shift(); + while(parts.length && parts[parts.length-1].text === '\n') parts.pop(); + + if(parts.length === 0) return { text: '', style: 'td' }; + return { text: parts, style: 'td' }; +} + +// pdfNormalizeColor: pdfmake akzeptiert Hex und benannte Farben, +// aber kein rgb(). Wandelt rgb()/rgba() in Hex um. +function pdfNormalizeColor(v){ + if(!v) return null; + v = String(v).trim(); + const m = v.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if(m){ + const hex = n => Math.max(0, Math.min(255, parseInt(n,10))) + .toString(16).padStart(2,'0'); + return '#' + hex(m[1]) + hex(m[2]) + hex(m[3]); + } + if(/^#[0-9a-f]{3}$/i.test(v)){ + return '#' + v[1]+v[1] + v[2]+v[2] + v[3]+v[3]; + } + if(/^#[0-9a-f]{6}$/i.test(v)) return v; + // Benannte Farben durchlassen (pdfmake kennt die CSS-Namen) + if(/^[a-z]+$/i.test(v)) return v.toLowerCase(); + return null; +} + +// loadLogoPng: lädt dein_bild.ico und konvertiert es via Canvas nach PNG. +// pdfmake unterstützt nur JPEG und PNG, kein ICO. +// Gibt null zurück, wenn Laden oder Dekodieren fehlschlägt. +async function loadLogoPng(size){ + size = size || 256; + try { + const res = await fetch(baseUrl() + '/dein_bild.ico'); + if(!res.ok) return null; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + try { + const img = await new Promise((resolve, reject) => { + const i = new Image(); + i.onload = () => resolve(i); + i.onerror = () => reject(new Error('Icon nicht dekodierbar')); + i.src = url; + }); + const cv = document.createElement('canvas'); + cv.width = cv.height = size; + const ctx = cv.getContext('2d'); + const scale = Math.min(size / img.width, size / img.height); + const dw = img.width * scale, dh = img.height * scale; + ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh); + return cv.toDataURL('image/png'); + } finally { + URL.revokeObjectURL(url); + } + } catch(e){ + console.warn('Logo nicht ladbar:', e); + return null; + } +} + +// pdfHeaderRows: die zwei Kopfzeilen der Projekttabelle als pdfmake-Zellenarrays. +// Achtung: Nach colSpan:n folgen exakt n-1 leere {}; unter rowSpan-Zellen +// steht in der Folgezeile ebenfalls {}. Verstöße brechen das Layout still. +function pdfHeaderRows(grouped){ + const h1 = [ + { text: 'Pr.-Nr.', rowSpan: 2, style: 'th' }, + { text: 'Bezeichnung', rowSpan: 2, style: 'th' }, + { text: 'Status', rowSpan: 2, style: 'th' } + ]; + const h2 = [ {}, {}, {} ]; + + ROLES.forEach(r => { + const list = grouped[r] || []; + const span = Math.max(1, list.length); + h1.push({ text: r, colSpan: span, style: 'th', + fillColor: pdfRoleBg(r, PDF_NEUTRAL_BG), alignment: 'center' }); + for(let i = 1; i < span; i++) h1.push({}); + + if(list.length === 0){ + h2.push({ text: '–', style: 'thSub', alignment: 'center', + fillColor: pdfRoleBg(r, PDF_ALT_ROW_BG) }); + } else { + list.forEach(e => h2.push({ + text: e.person.name, style: 'thSub', alignment: 'center', + fillColor: pdfRoleBg(r, PDF_ALT_ROW_BG) + })); + } + }); + + h1.push({ text: 'Leistungsphasen (%)', colSpan: LPHS.length, + style: 'th', alignment: 'center' }); + for(let i = 1; i < LPHS.length; i++) h1.push({}); + LPHS.forEach(l => h2.push({ text: String(l), style: 'thSub', + alignment: 'center', fillColor: PDF_ALT_ROW_BG })); + + return [h1, h2]; +} + +// pdfProjectRow: eine Projektzeile als pdfmake-Zellenarray. +function pdfProjectRow(p, allPeople, alt){ + const base = alt ? PDF_ALT_ROW_BG : null; + const row = [ + { text: p.nr || '', style: 'td', fillColor: base, noWrap: true }, + { text: p.name || '', style: 'td', fillColor: base }, + Object.assign(pdfStatusText(p.status), { fillColor: base }) + ]; + + allPeople.forEach(entry => { + const s = (p.assign || {})[assignKey(entry.person.id, entry.role)] || 0; + const txt = s === 1 ? 'X' : s === 2 ? '(X)' : ''; + const bg = s === 1 ? PDF_ASSIGN_MAIN + : s === 2 ? PDF_ASSIGN_OPT + : pdfRoleBg(entry.role, base || '#FFFFFF'); + row.push({ + text: txt, style: 'tdCenter', fillColor: bg, + color: (s === 1 || s === 2) ? '#FFFFFF' : '#1D1D1F', + bold: s === 1 + }); + }); + + LPHS.forEach(l => { + const raw = (p.lph && p.lph[l] !== undefined && p.lph[l] !== '') + ? String(p.lph[l]).replace(/%/g, '') : ''; + row.push({ text: raw ? raw + '%' : '', style: 'tdCenter', fillColor: base }); + }); + + return row; +} + +// pdfGroupTitleRow: Gruppenüberschrift über die volle Tabellenbreite. +function pdfGroupTitleRow(groupName, colCount){ + const row = [{ + text: groupName || '', colSpan: colCount, bold: true, + fillColor: PDF_NEUTRAL_BG, fontSize: 8, color: '#1D1D1F', + margin: [3, 3, 3, 3] + }]; + for(let i = 1; i < colCount; i++) row.push({}); + return row; +} + +// pdfColumnWidths: verteilt die tatsächlich verfügbare Breite. +// Berücksichtigt Padding und Linienstärke, die pdfmake ZUSÄTZLICH +// zur angegebenen Spaltenbreite rechnet. Gibt ausschliesslich +// numerische Breiten zurück - kein '*', damit nichts überläuft. +function pdfColumnWidths(allPeople){ + const nPeople = allPeople.length; + const nLph = LPHS.length; + const nCols = 3 + nPeople + nLph; + + // Overhead, den pdfmake on top der Spaltenbreiten aufschlägt + const overhead = nCols * PDF_PAD_X * 2 + (nCols + 1) * PDF_LINE_W; + let budget = PDF_USABLE_W - overhead; + + // Schmale Spalten: schrumpfen bei Bedarf, aber nicht unter Minimum + let wNr = 40; + let wPerson = 15; + let wLph = 19; + + const fixed = () => wNr + nPeople * wPerson + nLph * wLph; + + // Zielraum für Bezeichnung + Status + const TEXT_MIN = 150; + + if(budget - fixed() < TEXT_MIN){ + // 1. Personen- und LPH-Spalten stufenweise verschmälern + while(budget - fixed() < TEXT_MIN && (wPerson > 11 || wLph > 15)){ + if(wLph > 15) wLph--; + if(wPerson > 11) wPerson -= 0.5; + } + if(budget - fixed() < TEXT_MIN && wNr > 30) wNr = 30; + } + + let textSpace = budget - fixed(); + if(textSpace < 60){ + console.warn('PDF: Zu viele Spalten für A4 quer. ' + + 'Verbleibend für Bezeichnung/Status: ' + Math.round(textSpace) + ' pt.'); + textSpace = 60; + } + + // Bezeichnung erhält 60 %, Status 40 % + const wName = Math.floor(textSpace * 0.40); + const wStatus = Math.floor(textSpace * 0.60); + + return [wNr, wName, wStatus] + .concat(new Array(nPeople).fill(wPerson)) + .concat(new Array(nLph).fill(wLph)); +} + +// pdfCheckWidth: prüft, ob die Tabelle in den Satzspiegel passt. +function pdfCheckWidth(allPeople){ + const widths = pdfColumnWidths(allPeople); + const nCols = widths.length; + const overhead = nCols * PDF_PAD_X * 2 + (nCols + 1) * PDF_LINE_W; + const total = widths.reduce((a, b) => a + b, 0) + overhead; + if(total > PDF_USABLE_W + 0.5){ + console.warn('PDF: Tabellenbreite ' + Math.round(total) + + ' pt > verfügbar ' + Math.round(PDF_USABLE_W) + ' pt.'); + return false; + } + return true; +} + +// pdfAttendanceBlock: Anwesenheitsübersicht. +function pdfAttendanceBlock(w){ + const att = w.attendance || {}; + const fmt = p => p.name + ' (' + (p.fullName || p.name) + ')'; + const present = (w.people || []).filter(p => att[p.id]).map(fmt).join(', '); + const absent = (w.people || []).filter(p => !att[p.id]).map(fmt).join(', '); + return [ + { text: 'Anwesenheit', style: 'h2', margin: [0, 0, 0, 6] }, + { text: [{ text: 'Anwesend: ', bold: true }, present || '—'], + fontSize: 9, margin: [0, 0, 0, 3] }, + { text: [{ text: 'Abwesend: ', bold: true }, absent || '—'], fontSize: 9 } + ]; +} + +// pdfLoadSection: Auslastungsübersicht inkl. Balken (als Canvas-Rechtecke). +function pdfLoadSection(w, grouped){ + const stats = {}; + ROLES.forEach(r => (grouped[r] || []).forEach(e => { + stats[assignKey(e.person.id, r)] = { load:0, anbahnung:0, projects:{} }; + })); + + (w.projects || []).forEach(pr => { + const aktivePhase = lpActiveList(pr)[0]; + Object.keys(pr.assign || {}).forEach(aKey => { + const s = stats[aKey]; + if(!s) return; + const st = pr.assign[aKey]; + const rolle = aKey.slice(aKey.indexOf('|') + 1); + const al = berechneAuslastung(null, pr, aktivePhase, rolle, st); + if(al.anbahnung){ + s.anbahnung++; + return; + } + if(al.load > 0){ + s.load += al.load; + s.projects[pr.name] = (s.projects[pr.name] || 0) + 1; + } + }); + }); + + const BAR_W = 140; + const barMax = LOAD_BAR_MAX; + const body = [[ + { text: 'Rolle', style: 'th' }, + { text: 'Kürzel', style: 'th' }, + { text: 'Auslastung', style: 'th' }, + { text: 'Auslastung (Faktor)',style: 'th' }, + { text: 'Projektdetails', style: 'th' } + ]]; + + ROLES.forEach(r => (grouped[r] || []).forEach(entry => { + const s = stats[assignKey(entry.person.id, r)]; + const load = s.load; // aktuelle Sitzung = 1 Woche + // Balken relativ zur einstellbaren Skala (Standard 8); die Ampel-Farbe + // und der Zahlenwert bleiben ungedeckelt (Überlastung > 1,0). + const col = pdfLoadAmpelColor(load); + const det = Object.keys(s.projects) + .map(k => k + ' (' + s.projects[k] + '\u00D7)').join(', ') || '—'; + + body.push([ + { text: r, style: 'td' }, + { text: entry.person.name, style: 'td', bold: true }, + { text: faktorFormat(load), style: 'tdCenter' }, + { stack: [ + { canvas: [ + { type:'rect', x:0, y:0, w:BAR_W, h:7, color:'#F0F0F0' }, + { type:'rect', x:0, y:0, w:Math.max(0, BAR_W * Math.min(1, load / barMax)), + h:7, color:col } + ]}, + { text: faktorFormat(load), fontSize: 6, color: '#6E6E73', + alignment: 'right', width: BAR_W, margin: [0, 1, 0, 0] } + ], margin: [3, 3, 3, 3] }, + { text: s.anbahnung + ? [ det, { text: '\nIn Anbahnung: ' + s.anbahnung, color: '#0071E3' } ] + : det, + style: 'td', fontSize: 6.5 } + ]); + })); + + return [ + { text: 'Auslastung der Projektbeteiligten (aktuelle Sitzung)', + style: 'h2', margin: [0, 0, 0, 6] }, + { table: { headerRows: 1, widths: [80, 40, 45, BAR_W + 6, '*'], body }, + layout: pdfTableLayout() }, + { text: 'Rollenbezogener Gewichtungsfaktor je Leistungsphase. Aktueller Wert: ' + + faktorFormat(LOAD_BAR_MAX), + fontSize: 6.5, color: '#6E6E73', margin: [0, 4, 0, 0] } + ]; +} + +// exportPDF: Hauptfunktion. Erzeugt und lädt das PDF. +async function exportPDF(){ + if(!window.pdfMake){ + alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. ' + + 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.'); + return; + } + const w = currentWeek(); + if(!w) return; + + try { + const grouped = peopleByRole(w); + const allPeople = []; + ROLES.forEach(r => (grouped[r] || []).forEach(e => { + // role explizit setzen, falls peopleByRole sie nicht mitliefert + allPeople.push({ person: e.person, role: e.role || r }); + })); + + pdfCheckWidth(allPeople); + + const logo = await loadLogoPng(); + const colCount = 3 + allPeople.length + LPHS.length; + const widths = pdfColumnWidths(allPeople); + + // --- Deckblatt --- + const cover = []; + if(logo){ + cover.push({ image: logo, width: 130, alignment: 'center', + margin: [0, 0, 0, 24] }); + } + cover.push( + { text: 'Projektübersicht ' + DEPARTMENTS[currentDepartment].label, + style: 'h1', alignment: 'center', margin: [0, 0, 0, 10] }, + { text: w.label || '', fontSize: 13, color: '#6E6E73', + alignment: 'center', margin: [0, 0, 0, 30] } + ); + if(w.date){ + cover.push({ text: 'Datum: ' + formatWeekDate(w.date), fontSize: 9, + color: '#6E6E73', alignment: 'center', margin: [0, 0, 0, 8] }); + } + cover.push({ text: 'Erstellt am ' + new Date().toLocaleDateString('de-DE'), + fontSize: 8, color: '#6E6E73', alignment: 'center', + margin: [0, 30, 0, 0] }); + + const content = [{ stack: cover, margin: [0, 170, 0, 0], pageBreak: 'after' }]; + + // --- Projektgruppen --- + const groups = {}; + (w.projects || []).forEach(p => { + (groups[p.group] || (groups[p.group] = [])).push(p); + }); + Object.keys(groups).forEach(g => + groups[g].sort((a, b) => (a.order || 0) - (b.order || 0))); + + sortGroups(Object.keys(groups)).forEach(groupName => { + const projects = groups[groupName]; + if(!projects || !projects.length) return; + + // Kopfzeilen MÜSSEN pro Tabelle neu erzeugt werden: + // pdfmake mutiert Zellobjekte (rowSpan/colSpan, _span, _inlines). + // Wiederverwendung derselben Referenzen führt zu leeren Kopfzeilen + // in allen Tabellen ab der zweiten. + const header = pdfHeaderRows(grouped); + + const body = [ pdfGroupTitleRow(groupName, colCount), header[0], header[1] ]; + projects.forEach((p, i) => body.push(pdfProjectRow(p, allPeople, i % 2 === 0))); + + content.push({ + table: { headerRows: 3, widths: widths, body: body, dontBreakRows: true }, + layout: pdfTableLayout(), + margin: [0, 0, 0, 14], + pageBreak: 'after' + }); + }); + + // --- Anwesenheit / Auslastung --- + //content.push({ text: '', pageBreak: 'before' }); + pdfAttendanceBlock(w).forEach(b => content.push(b)); + content.push({ text: '', margin: [0, 14, 0, 0] }); + pdfLoadSection(w, grouped).forEach(b => content.push(b)); + + const doc = { + pageSize: 'A3', + pageOrientation: 'landscape', + pageMargins: [PDF_MARGIN_X, 25, PDF_MARGIN_X, 30], + defaultStyle: { font: 'Roboto', fontSize: 7, color: '#1D1D1F' }, + styles: PDF_STYLES, + footer: (page, count) => page === 1 ? '' : { + text: (w.label || '') + ' · Seite ' + page + ' von ' + count, + alignment: 'right', fontSize: 6.5, color: '#6E6E73', + margin: [0, 8, PDF_MARGIN_X, 0] + }, + info: { + title: 'Projektübersicht ' + (w.label || ''), + creator: 'Projektboard' + }, + content: content + }; + + const filename = 'Projektuebersicht_' + + String(w.label || 'Sitzung').replace(/[^\w]/g, '_') + '.pdf'; + pdfMake.createPdf(doc).download(filename); + + } catch(err){ + console.error('PDF-Export fehlgeschlagen:', err); + alert('PDF-Export fehlgeschlagen: ' + (err.message || err)); + } +} \ No newline at end of file diff --git a/public/js/people.js b/public/js/people.js new file mode 100644 index 0000000..2912153 --- /dev/null +++ b/public/js/people.js @@ -0,0 +1,349 @@ +// ----------------------------- +// Projektübersicht — Personen-Verwaltung (global pro Abteilung) +// ----------------------------- +// Diese Datei rendert den Personen-Tab (Tab "Projektbeteiligte" in `Test.html`) +// und verwaltet die globale Personenliste pro Abteilung: Anlegen, Bearbeiten, +// Löschen, Rollen-Auswahl, Drag-and-Drop-Sortierung sowie Urlaub/Abwesenheiten. +// Sie wird von `Test.html` nach `js/board.js` (vor `js/projects.js`) geladen +// und stellt globale Funktionen wie `addPerson()`, `delPerson()`, +// `editPerson()`, `renderPeople()` und `addAbsence()` bereit. Genutzt wird sie +// u.a. von `js/board.js` (`renderBoard`), `js/gantt.js` und +// `js/plan/plan-modals.js`; die Sortier-Hilfe `getDragAfterElement()` liegt in +// `js/projects.js`. + +// addPerson: fügt eine neue Person der globalen Liste hinzu und +// synchronisiert sie in die aktuelle Sitzung. +function addPerson(){ + if(guardWeekLocked()) return; + const w = currentWeek(); + if(!w) return; + const name = document.getElementById('newPersonName').value.trim(); + const fullName = document.getElementById('newPersonFullName').value.trim(); + const email = document.getElementById('newPersonEmail').value.trim(); + if(!name){ alert("Kürzel eingeben."); return; } + const roles = [...document.querySelectorAll('input[name="newPersonRoles"]:checked')].map(el=>el.value); + if(roles.length===0){ alert("Bitte mindestens eine Rolle auswählen."); return; } + + const person = {id:personIdForName(name), name, fullName:fullName||name, email:email, role:roles}; + + // Globale Liste aktualisieren + if(!Array.isArray(db.globalPeople[currentDepartment])) db.globalPeople[currentDepartment] = []; + const existing = db.globalPeople[currentDepartment].find(p => p.id === person.id); + if(existing){ + alert('Eine Person mit diesem Kürzel existiert bereits.'); + return; + } + db.globalPeople[currentDepartment].push(person); + + // Aktuelle Sitzung aktualisieren + if(!Array.isArray(w.people)) w.people = []; + if(!w.people.find(p => p.id === person.id)){ + w.people.push(JSON.parse(JSON.stringify(person))); + } + + document.getElementById('newPersonName').value=''; + document.getElementById('newPersonFullName').value=''; + document.getElementById('newPersonEmail').value=''; + document.querySelectorAll('input[name="newPersonRoles"]').forEach(el=>el.checked=false); + save(); renderPeople(); renderBoard(); +} + +// delPerson: entfernt eine Person aus der globalen Liste. Bereits in +// historischen Sitzungen verwendete Personen bleiben dort sichtbar, da +// jede Sitzung ihre eigene Kopie der Personendaten speichert. +function delPerson(id){ + if(!confirm("Person global löschen? Sie bleibt in bereits existierenden Sitzungen sichtbar, wird aber in neuen Sitzungen nicht mehr angeboten.")) return; + + // Aus globaler Liste entfernen + if(Array.isArray(db.globalPeople[currentDepartment])){ + db.globalPeople[currentDepartment] = db.globalPeople[currentDepartment].filter(p=>p.id!==id); + } + + // Aus aktueller Sitzung entfernen (nur wenn nicht in Zuordnungen genutzt) + const w = currentWeek(); + if(w){ + const inUse = w.projects.some(p => Object.keys(p.assign).some(k => k.startsWith(id + '|'))); + if(inUse){ + alert('Die Person ist in der aktuellen Sitzung noch Projekten zugeordnet. Bitte zuerst die Zuordnungen entfernen.'); + return; + } + w.people = (w.people || []).filter(p=>p.id!==id); + } + + save(); renderPeople(); renderBoard(); +} + +// editPerson: ändert Name/Vollname/Rolle sowohl global als auch in der +// aktuellen Sitzung. Historische Sitzungen bleiben unverändert. +// Bei Feld `name` wird die ID migriert (`personIdForName`) und die +// Zuordnungen/Anwesenheiten werden umgeschrieben. Parameter: `id` +// (Personen-ID), `field` (Feldname), `val` (neuer Wert). Keine Rückgabe. +function editPerson(id,field,val){ + if(guardWeekLocked()) return; + const w = currentWeek(); + if(!w) return; + + // Global aktualisieren + const globalPeople = db.globalPeople[currentDepartment] || []; + const globalPerson = globalPeople.find(p => p.id === id); + + if(field === 'name'){ + const newName = val.trim(); + if(!newName) return; + const newId = personIdForName(newName); + if(newId !== id && globalPeople.some(p => p.id === newId)){ + alert('Dieses Kürzel wird bereits verwendet.'); + return; + } + if(globalPerson){ + globalPerson.name = newName; + globalPerson.id = newId; + } + // Aktuelle Sitzung aktualisieren und Zuordnungen migrieren + const localPerson = (w.people || []).find(p => p.id === id); + if(localPerson){ + localPerson.name = newName; + localPerson.id = newId; + } + w.projects.forEach(p => { + const newAssign = {}; + Object.keys(p.assign).forEach(k => { + const [oldId, role] = k.split('|'); + if(oldId === id){ + newAssign[assignKey(newId, role)] = p.assign[k]; + } else { + newAssign[k] = p.assign[k]; + } + }); + p.assign = newAssign; + if(p.plan && Array.isArray(p.plan.people)){ + p.plan.people = p.plan.people.map(pid => pid === id ? newId : pid); + } + }); + if(w.attendance){ + if(w.attendance[id]){ + w.attendance[newId] = true; + delete w.attendance[id]; + } + } + save(); renderPeople(); renderBoard(); + return; + } + + if(field === 'role'){ + const roles = Array.isArray(val) ? [...new Set(val)] : val ? [val] : []; + if(globalPerson) globalPerson.role = roles; + const localPerson = (w.people || []).find(p => p.id === id); + if(localPerson) localPerson.role = roles; + } else { + if(globalPerson) globalPerson[field] = val; + const localPerson = (w.people || []).find(p => p.id === id); + if(localPerson) localPerson[field] = val; + } + save(); renderPeople(); renderBoard(); +} + +// togglePersonRole: fügt eine Rolle (`role`) zur Person `id` hinzu bzw. entfernt +// sie (je nach Checkbox-Zustand `checked`), sowohl global als auch in der +// aktuellen Sitzung. Keine Rückgabe. +function togglePersonRole(id, role, checked){ + if(guardWeekLocked()) return; + const w = currentWeek(); + if(!w) return; + + const globalPeople = db.globalPeople[currentDepartment] || []; + const globalPerson = globalPeople.find(p => p.id === id); + const localPerson = (w.people || []).find(p => p.id === id); + + function updateRoles(person){ + person.role = Array.isArray(person.role) ? [...person.role] : person.role ? [person.role] : []; + if(checked){ + if(!person.role.includes(role)) person.role.push(role); + } else { + person.role = person.role.filter(r=>r!==role); + } + } + + if(globalPerson) updateRoles(globalPerson); + if(localPerson) updateRoles(localPerson); + save(); renderPeople(); renderBoard(); +} + +// renderPeople: baut die HTML-Liste der Personen für den Personen-Tab auf +// (Karten mit Bearbeitungsfeldern, Rollen-Checkboxen, aufklappbaren +// Abwesenheiten und Drag-Handle). Bei gesperrter Sitzung wird das +// Abwesenheits-Formular ausgeblendet. Keine Parameter, keine Rückgabe. +function renderPeople(){ + const people = db.globalPeople[currentDepartment] || []; + const locked = isWeekLocked(currentWeek()); + let html = `
`; + people.forEach(p=>{ + const isOpen = personAbsenceOpen[p.id]; + html += `
`; + html += `
+ ⋮⋮ + ${isOpen ? '▼' : '▶'} + + + +
${ROLES.map(rr=>``).join('')}
+ +
`; + + // Aufklappbare Abwesenheiten + html += `
`; + const absences = p.absences || []; + if(absences.length > 0){ + html += `
`; + absences.forEach(a => { + html += `
+ ${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))} + ${a.note ? `${esc(a.note)}` : ''} + ${locked ? '' : ``} +
`; + }); + html += `
`; + } + if(!locked){ + html += `
+ + + + + + +
`; + } + html += `
`; + }); + html += `
`; + if(people.length===0) html = '
Keine Personen vorhanden.
'; + document.getElementById('peopleList').innerHTML=html; +} + +// personAbsenceOpen: UI-Zustand, welche Personen-Karte ihre Abwesenheiten +// aufgeklappt darstellt (Key = Personen-ID, Wert = boolean). +let personAbsenceOpen = {}; + +// togglePersonAbsences: klappt die Abwesenheits-Liste einer Person auf bzw. zu +// (Umschalten des Eintrags in `personAbsenceOpen`) und rendert neu. +function togglePersonAbsences(personId){ + personAbsenceOpen[personId] = !personAbsenceOpen[personId]; + renderPeople(); +} + +// draggedPerson: die während eines Drag-and-Drop-Vorgangs verschobene +// Personen-Karte (DOM-Element) bzw. null nach dem Loslassen. +let draggedPerson = null; + +// getPersonCard: liefert die nächstgelegene Personen-Karte (`.person-card`). +// Parameter `el`: Event-Ziel-Element. Rückgabe: Karten-Element oder null. +function getPersonCard(el){ + return el && el.closest ? el.closest('.person-card') : null; +} + +// handlePersonDragStart: startet das Ziehen einer Personen-Karte, merkt sich +// diese in `draggedPerson` und verringert ihre Deckkraft. Rückgabe: keine. +function handlePersonDragStart(event){ + draggedPerson = getPersonCard(event.target); + if(!draggedPerson) return; + event.dataTransfer.effectAllowed = 'move'; + draggedPerson.style.opacity = '0.5'; +} + +// handlePersonDragOver: erlaubt das Ablegen während des Überfahrens +// (verhindert den Standard-Drop-Typ und setzt `dropEffect='move'`). +function handlePersonDragOver(event){ + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; +} + +// handlePersonDrop: legt die gezogene Personen-Karte an der Zielposition +// innerhalb `.people-sortable` ab (per `getDragAfterElement`) und speichert +// die neue Reihenfolge über `reorderPeople()`. +function handlePersonDrop(event){ + event.preventDefault(); + const targetCard = getPersonCard(event.target); + if(!draggedPerson || draggedPerson === targetCard) return; + const sortable = document.querySelector('.people-sortable'); + if(!sortable) return; + const afterElement = getDragAfterElement(sortable, event.clientY); + if(afterElement == null){ + sortable.appendChild(draggedPerson); + } else { + sortable.insertBefore(draggedPerson, afterElement); + } + reorderPeople(); +} + +// handlePersonDragEnd: stellt die Deckkraft der Karte wieder her und setzt +// `draggedPerson` zurück (Ende des Drag-Vorgangs). +function handlePersonDragEnd(event){ + const card = getPersonCard(event.target); + if(card) card.style.opacity = '1'; + draggedPerson = null; +} + +// reorderPeople: übernimmt die im DOM vorgefundene Reihenfolge der +// Personen-Karten in die globale Personenliste der Abteilung und speichert sie. +// Keine Parameter, keine Rückgabe; abgebrochen bei gesperrter Sitzung. +function reorderPeople(){ + if(guardWeekLocked()) return; + const sortable = document.querySelector('.people-sortable'); + if(!sortable) return; + const people = db.globalPeople[currentDepartment] || []; + if(people.length === 0) return; + const newPeople = []; + [...sortable.querySelectorAll('[data-person-id]')].forEach(item => { + const person = people.find(p => p.id === item.dataset.personId); + if(person) newPeople.push(person); + }); + if(newPeople.length === people.length){ + db.globalPeople[currentDepartment] = newPeople; + save(); + } +} + +// ---------- Urlaub / Abwesenheiten ---------- + +// addAbsence: legt für eine Person eine neue Abwesenheit an, die aus dem +// Bereichspicker (Start-/Enddatum), Typ und Notiz des Formulars gelesen wird. +// Prüft Datumsfolge, sortiert die Liste nach Startdatum und speichert. +// Parameter `personId`: Personen-ID. Keine Rückgabe. +function addAbsence(personId){ + if(guardWeekLocked()) return; + const people = db.globalPeople[currentDepartment] || []; + const person = people.find(p => p.id === personId); + if(!person) return; + const start = document.getElementById('absenceStart_' + personId).value; + const end = document.getElementById('absenceEnd_' + personId).value; + const type = document.getElementById('absenceType_' + personId).value; + const note = document.getElementById('absenceNote_' + personId).value.trim(); + if(!start || !end){ alert('Bitte Start- und Enddatum angeben.'); return; } + if(parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; } + if(!Array.isArray(person.absences)) person.absences = []; + person.absences.push({ id: uid(), start, end, type, note }); + person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start)); + save(); + renderPeople(); + if(currentTab === 'gantt') renderGantt(); +} + +// deleteAbsence: entfernt eine Abwesenheit (`absenceId`) aus der Liste der +// Person (`personId`), speichert und rendert neu. Keine Rückgabe. +function deleteAbsence(personId, absenceId){ + if(guardWeekLocked()) return; + const people = db.globalPeople[currentDepartment] || []; + const person = people.find(p => p.id === personId); + if(!person || !person.absences) return; + person.absences = person.absences.filter(a => a.id !== absenceId); + save(); + renderPeople(); + if(currentTab === 'gantt') renderGantt(); +} \ No newline at end of file diff --git a/public/js/plan/plan-build-gantt.js b/public/js/plan/plan-build-gantt.js new file mode 100644 index 0000000..1961a9f --- /dev/null +++ b/public/js/plan/plan-build-gantt.js @@ -0,0 +1,174 @@ +// ----------------------------- +// Projektplan — buildGanttHtml-Erweiterung (TODOs & Meilensteine im GANTT) +// ----------------------------- +// Dieses Modul erweitert die in gantt.js definierte Funktion buildGanttHtml, +// damit im Projektplan TODOs und Meilensteine als eigene Zeilen unter der +// Projektzeile dargestellt werden. Mit der Option showTodos wird zuerst das +// Basis-GANTT erzeugt und dann per DOM-Bearbeitung um die Zusatzzeilen +// ergänzt. Die Abhängigkeitslinien zeichnet danach plan-gantt.js +// (renderGanttDependencies), die Drag-/Resize- und Klick-Attribute der +// Balken nutzen plan-drag.js und plan-modals.js. +// +// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp +// (plan-state.js). + +(function(){ + const app = window.PlanApp; + + // Referenz auf die Original-Funktion aus gantt.js, die für das Basis-GANTT + // weiterhin verwendet wird. + const originalBuildGanttHtml = window.buildGanttHtml; + + // window.buildGanttHtml (überschrieben): Erweiterte Version der Funktion + // aus gantt.js. Ohne Option showTodos wird unverändert die Original-Funktion + // aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die + // TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als + // HTML-String zurückgegeben. Parameter: projects (Array), start/end (ISO- + // Datum), options (Optionen inkl. editable und criticalPath). Rückgabe: + // String mit dem kompletten GANTT-HTML. + window.buildGanttHtml = function(projects, start, end, options = {}){ + if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options); + const baseHtml = originalBuildGanttHtml(projects, start, end, options); + const temp = document.createElement('div'); + temp.innerHTML = baseHtml; + const chart = temp.querySelector('.gantt-chart'); + if(!chart) return baseHtml; + const totalDays = Math.max(1, daysBetween(start, end) + 1); + const editable = options.editable; + + projects.forEach(p => { + const plan = p.plan || {}; + const todos = Array.isArray(plan.todos) ? plan.todos : []; + const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; + + // Projektzeile finden, unter der TODOs und Meilensteine eingehängt werden + const projectRow = chart.querySelector('.gantt-row.gantt-project.highlighted') || + Array.from(chart.querySelectorAll('.gantt-row.gantt-project')).find(r => { + const label = r.querySelector('.gantt-label'); + return label && label.textContent.includes(p.name); + }); + + // buildTodoRow: baut die DOM-Zeile einer TODO-Aufgabe mit Label, ggf. + // Abhängigkeits-Badge und (im Bearbeitungsmodus) verschiebbarem Balken + // mit Resize-Griffen und Fortschrittsanzeige. Vergibt fehlende IDs beim + // ersten Rendern. Parameter: todo (Aufgabenobjekt), tIdx (Index in der + // Aufgabenliste). Rückgabe: Zeilen-DOM-Element. + function buildTodoRow(todo, tIdx){ + if(!todo.id) todo.id = uid(); + const s = parseDateISO(todo.start), e = parseDateISO(todo.end); + const row = document.createElement('div'); + row.className = 'gantt-row gantt-todo' + (todo.done ? ' done' : ''); + row.dataset.todoId = todo.id; + row.dataset.todoIndex = tIdx; + + let barHtml = ''; + if(s && e){ + const barStart = s < start ? start : s; + const barEnd = e > end ? end : e; + if(barEnd >= start && barStart <= end){ + const offset = daysBetween(start, barStart); + const span = daysBetween(barStart, barEnd) + 1; + const dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : ''; + const handles = editable ? `` : ''; + const isCritical = options.criticalPath && options.criticalPath.includes(todo.id); + const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0)); + barHtml = `
${handles}
`; + } + } + + let depBadge = ''; + if(Array.isArray(todo.dependsOn) && todo.dependsOn.length > 0){ + // Vorgänger können TODOs oder Meilensteine (Prefix "m_") sein + const preds = todo.dependsOn.map(id => { + const t = todos.find(x => x.id === id); + if(t) return { title: t.title || 'TODO', end: t.end }; + const m = milestones.find(x => 'm_' + x.id === id); + if(m) return { title: '🚩 ' + (m.name || 'Meilenstein'), end: m.date }; + return null; + }).filter(Boolean); + if(preds.length > 0){ + const isConflict = preds.some(pred => pred.end && todo.start && parseDateISO(todo.start) <= parseDateISO(pred.end)); + const names = preds.map(pr => pr.title).join(', '); + depBadge = `↳ ${preds.length === 1 ? esc(preds[0].title) : esc(preds.length + ' Vorgänger')}`; + } + } + + row.innerHTML = `
+ ${editable ? '⋮⋮' : ''} + ${esc(todo.title || 'TODO')}${depBadge} +
+
${barHtml}
`; + return row; + } + + // buildMilestoneRow: baut die DOM-Zeile eines Meilensteins mit Rauten-Symbol + // an der Datumsposition, Label und ggf. Datumskennzeichnung. Parameter: + // m (Meilensteinobjekt), mIdx (Index in der Meilensteinliste). Rückgabe: + // Zeilen-DOM-Element. + function buildMilestoneRow(m, mIdx){ + const d = parseDateISO(m.date); + const row = document.createElement('div'); + row.className = 'gantt-row gantt-milestone-row' + (m.position ? ' positioned' : ''); + row.dataset.milestoneId = m.id; + row.dataset.milestoneIndex = mIdx; + let diamond = '', labelStyle = 'display:none'; + if(d){ + if(d >= start && d <= end){ + const offset = daysBetween(start, d); + const left = (offset / totalDays * 100).toFixed(4) + '%'; + const dragAttrs = editable ? ` data-gantt-type="milestone" data-gantt-project="${esc(p.id)}" data-gantt-index="${mIdx}" data-gantt-date="${formatDateISO(d)}" draggable="false"` : ''; + const deps = esc((m.dependsOn || []).join(',')); + diamond = `
`; + labelStyle = 'left:' + left; + } + } + row.innerHTML = `
+ ${editable ? '⋮⋮' : ''} + ${esc(m.name || 'Meilenstein')} +
+
${diamond}
${esc(m.name || '')}
`; + return row; + } + + if(!projectRow) return; + let insertAfter = projectRow; + const insertedMilestoneIds = new Set(); + + todos.forEach((todo, tIdx) => { + // Meilensteine, die VOR dieser Aufgabe positioniert sind + milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => { + const mIdx = milestones.indexOf(m); + const row = buildMilestoneRow(m, mIdx); + insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); + insertAfter = row; + }); + + // Aufgabenzeile + const todoRow = buildTodoRow(todo, tIdx); + insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling); + insertAfter = todoRow; + + // Meilensteine, die NACH dieser Aufgabe positioniert sind + milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => { + const mIdx = milestones.indexOf(m); + const row = buildMilestoneRow(m, mIdx); + insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); + insertAfter = row; + insertedMilestoneIds.add(m.id); + }); + }); + + // Positionierte Meilensteine, deren referenzierte Aufgabe nicht mehr existiert, + // am Ende der Aufgabenliste anhängen, damit sie nicht verloren gehen. + milestones.filter(m => m.position && !insertedMilestoneIds.has(m.id)).forEach(m => { + const mIdx = milestones.indexOf(m); + const row = buildMilestoneRow(m, mIdx); + insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); + insertAfter = row; + }); + + }); + + return temp.innerHTML; + }; +})(); \ No newline at end of file diff --git a/public/js/plan/plan-drag.js b/public/js/plan/plan-drag.js new file mode 100644 index 0000000..4b635c5 --- /dev/null +++ b/public/js/plan/plan-drag.js @@ -0,0 +1,433 @@ +// ----------------------------- +// Projektplan — Drag-and-Drop (GANTT-Balken & TODO/Meilenstein-Zeilen) +// ----------------------------- +// Dieses Modul macht das Projektplan-GANTT interaktiv: TODO- und +// Meilenstein-Zeilen lassen sich per Greif-Handler umsortieren und GANTT- +// Balken, Meilenstein-Rauten sowie Urlaubsblöcke per Mausziehen verschieben +// bzw. an den Rändern resizen. Die neuen Daten schreibt es über app.savePlan +// (plan-state.js) und lässt das Diagramm über renderPlanGantt +// (plan-gantt.js) neu zeichnen; Doppelklicks öffnen die Modals aus +// plan-modals.js. Bei gesperrter Woche (app.isLocked) ist alles deaktiviert. +// +// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp +// (plan-state.js). + +(function(){ + const app = window.PlanApp; + + // Selektor für alle per Zeilen-Drag umsortierbaren Zeilen (TODO und + // Meilenstein) im GANTT. + const DRAGGABLE_GANTT_ROW_SELECTOR = '.gantt-row.gantt-todo, .gantt-row.gantt-milestone-row'; + + // ---------- Drag-and-Drop für TODO- und Meilenstein-Zeilen direkt im GANTT ---------- + + // initGanttTodoDrag: registriert die Maus-Events (mousedown/mousemove/mouseup) + // für das Umsortieren von TODO- und Meilenstein-Zeilen am GANTT-Container. + function initGanttTodoDrag(){ + const container = document.getElementById('planGanttContainer'); + if(!container) return; + container.addEventListener('mousedown', onGanttTodoMouseDown); + document.addEventListener('mousemove', onGanttTodoMouseMove); + document.addEventListener('mouseup', onGanttTodoMouseUp); + } + + // onGanttTodoMouseDown: startet das Zeilen-Ziehen, wenn der Greif-Handler + // (⋮⋮) einer TODO-/Meilenstein-Zeile gedrückt wird, und merkt sich Zeile + // und Startposition in app.todoDrag. + function onGanttTodoMouseDown(e){ + if(app.isLocked()) return; + const handle = e.target.closest('.gantt-todo-drag'); + if(!handle) return; + const row = handle.closest(DRAGGABLE_GANTT_ROW_SELECTOR); + if(!row) return; + + e.preventDefault(); + e.stopPropagation(); + + app.todoDrag = { + row, + startY: e.clientY, + moved: false + }; + row.classList.add('dragging'); + } + + // onGanttTodoMouseMove: verschiebt bei aktiver Ziehung (app.todoDrag) die + // Zeile live im Chart – entweder ans Ende oder vor das dem Mauszeiger + // nächstliegende Element (getGanttRowDragAfterElement). + function onGanttTodoMouseMove(e){ + if(!app.todoDrag) return; + const chart = app.todoDrag.row.closest('.gantt-chart'); + if(!chart) return; + + const dy = e.clientY - app.todoDrag.startY; + if(Math.abs(dy) > 3) app.todoDrag.moved = true; + if(!app.todoDrag.moved) return; + + const afterElement = getGanttRowDragAfterElement(chart, e.clientY); + if(app.todoDrag.row && afterElement == null){ + chart.appendChild(app.todoDrag.row); + } else if(app.todoDrag.row && afterElement && afterElement !== app.todoDrag.row){ + chart.insertBefore(app.todoDrag.row, afterElement); + } + } + + // onGanttTodoMouseUp: beendet das Zeilen-Ziehen. Bei einer echten Bewegung + // (moved) wird die neue Reihenfolge über reorderPlanRows übernommen. + function onGanttTodoMouseUp(e){ + if(!app.todoDrag) return; + const drag = app.todoDrag; + app.todoDrag = null; + drag.row.classList.remove('dragging'); + if(drag.moved){ + reorderPlanRows(); + } + } + + // getGanttRowDragAfterElement: ermittelt unter allen nicht gezogenen + // Zeilen jenes Element, das der übergebenen y-Position am nächsten liegt. + // Rückgabe: Element oder undefined (dann wäre die Zeile am Chart-Ende). + function getGanttRowDragAfterElement(chart, y){ + const draggableElements = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR + ':not(.dragging)')]; + return draggableElements.reduce((closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if(offset < 0 && offset > closest.offset){ + return { offset: offset, element: child }; + } else { + return closest; + } + }, { offset: Number.NEGATIVE_INFINITY }).element; + } + + // reorderPlanRows: übernimmt die im DOM gefundene Zeilen-Reihenfolge in den + // Plan: TODO-Liste wird umsortiert, Meilenstein-Positionen (before:/after: + // einer TODO) werden gemäß der Regeln im Code neu berechnet. Anschließend + // Speichern über app.savePlan und Neu-Rendern über renderPlanGantt. + function reorderPlanRows(){ + if(app.isLocked()) return; + const chart = document.querySelector('#planGanttContainer .gantt-chart'); + if(!chart) return; + const plan = app.ensurePlanLocal(); + const todos = Array.isArray(plan.todos) ? plan.todos : []; + const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; + const todoIds = todos.map(t => t.id); + const milestoneIds = milestones.map(m => m.id); + + // Alle beweglichen Zeilen in aktueller DOM-Reihenfolge + const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)]; + const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean); + const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean); + + // Sicherheitscheck: Anzahl muss stimmen + if(todoRowIds.length !== todoIds.length) return; + if(milestoneRowIds.length !== milestoneIds.length) return; + + // TODOs neu ordnen + const newTodos = []; + todoRowIds.forEach(id => { + const t = todos.find(x => x.id === id); + if(t) newTodos.push(t); + }); + plan.todos = newTodos; + + // Meilenstein-Positionen neu berechnen + // Regeln: + // - Steht der Meilenstein direkt vor einer TODO -> before:todoId + // - Steht er direkt nach einer TODO -> after:todoId + // - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (oben in Projektzeile) + // - Steht er ganz am Ende (nach letzter TODO) -> after:lastTodoId + rows.forEach((row, idx) => { + if(!row.classList.contains('gantt-milestone-row')) return; + const msId = row.dataset.milestoneId; + const ms = milestones.find(m => m.id === msId); + if(!ms) return; + + let position = ''; + const prevRow = rows[idx - 1]; + const nextRow = rows[idx + 1]; + + if(prevRow && prevRow.classList.contains('gantt-todo')){ + position = 'after:' + prevRow.dataset.todoId; + } else if(nextRow && nextRow.classList.contains('gantt-todo')){ + position = 'before:' + nextRow.dataset.todoId; + } else if(prevRow && prevRow.classList.contains('gantt-milestone-row')){ + // Zwei Meilensteine hintereinander: beziehe dich auf die nächste TODO danach oder letzte davor + // Suche nächste TODO nach diesem Block + let nextTodoId = null; + for(let i = idx + 1; i < rows.length; i++){ + if(rows[i].classList.contains('gantt-todo')){ nextTodoId = rows[i].dataset.todoId; break; } + } + if(nextTodoId){ + position = 'before:' + nextTodoId; + } else { + // Keine TODO danach -> letzte TODO davor suchen + let prevTodoId = null; + for(let i = idx - 1; i >= 0; i--){ + if(rows[i].classList.contains('gantt-todo')){ prevTodoId = rows[i].dataset.todoId; break; } + } + if(prevTodoId) position = 'after:' + prevTodoId; + } + } + ms.position = position; + }); + + app.savePlan(); + renderPlanGantt(); + } + + // ---------- GANTT Drag-Editing (Balken, Meilensteine, Urlaub) ---------- + + // initGanttDrag: registriert die Maus- und Doppelklick-Events für das + // Verschieben/Resizen von GANTT-Balken, Meilensteinen und Urlaubsblöcken. + // Ein Klick ohne Bewegung wird in onGanttMouseUp behandelt (kein eigener + // click-Handler, um doppeltes Öffnen zu vermeiden). + function initGanttDrag(){ + const container = document.getElementById('planGanttContainer'); + if(!container) return; + container.addEventListener('mousedown', onGanttMouseDown); + container.addEventListener('dblclick', onGanttDoubleClick); + document.addEventListener('mousemove', onGanttMouseMove); + document.addEventListener('mouseup', onGanttMouseUp); + // Klick ohne Bewegung wird bereits in onGanttMouseUp behandelt (Modal öffnen). + // Ein zusätzlicher click-Handler würde das Modal doppelt bzw. nach einem + // Drag ungewollt öffnen. + } + + // onGanttMouseDown: startet den Drag auf einem Balken (data-gantt-type). + // Erkennt anhand der Resize-Griffe, ob verschoben oder links/rechts + // resized wird, und speichert alle Ausgangsdaten inkl. Original-Daten in + // app.ganttDrag. + function onGanttMouseDown(e){ + if(app.isLocked()) return; + const bar = e.target.closest('[data-gantt-type]'); + if(!bar) return; + const timeline = bar.closest('.gantt-timeline'); + if(!timeline) return; + + const rangeStart = parseDateISO(document.getElementById('planGanttStart').value); + const rangeEnd = parseDateISO(document.getElementById('planGanttEnd').value); + if(!rangeStart || !rangeEnd) return; + + const resize = e.target.closest('.gantt-resize'); + const rect = timeline.getBoundingClientRect(); + + app.ganttDrag = { + bar, + timeline, + type: bar.dataset.ganttType, + action: resize ? resize.dataset.resize : 'move', + startX: e.clientX, + startY: e.clientY, + timelineWidth: rect.width, + rangeStart, + rangeEnd, + moved: false, + originalStart: parseDateISO(bar.dataset.ganttStart || bar.dataset.ganttDate), + originalEnd: parseDateISO(bar.dataset.ganttEnd), + personId: bar.dataset.ganttPerson, + absenceId: bar.dataset.ganttAbsence, + projectId: bar.dataset.ganttProject, + milestoneIndex: bar.dataset.ganttType === 'milestone' && bar.dataset.ganttIndex !== undefined ? parseInt(bar.dataset.ganttIndex, 10) : null, + todoIndex: bar.dataset.ganttType === 'todo' && bar.dataset.ganttIndex !== undefined ? parseInt(bar.dataset.ganttIndex, 10) : null + }; + + e.preventDefault(); + e.stopPropagation(); + } + + // onGanttMouseMove: aktualisiert während des Ziehens live die Position + // (left) bzw. Größe (left/width) des Balkens oder die Position der + // Meilenstein-Raute samt zugehörigem Label anhand des Tagesversatzes. + function onGanttMouseMove(e){ + if(!app.ganttDrag) return; + const totalDays = daysBetween(app.ganttDrag.rangeStart, app.ganttDrag.rangeEnd) + 1; + const pxPerDay = app.ganttDrag.timelineWidth / totalDays; + const dx = e.clientX - app.ganttDrag.startX; + const dayDelta = Math.round(dx / pxPerDay); + + if(Math.abs(dx) > 2) app.ganttDrag.moved = true; + + if(app.ganttDrag.type === 'milestone'){ + const newDate = addDays(app.ganttDrag.originalStart, dayDelta); + const offset = daysBetween(app.ganttDrag.rangeStart, newDate); + const left = (offset / totalDays * 100).toFixed(4) + '%'; + app.ganttDrag.bar.style.left = left; + // Zugehöriges Label (nächstes Geschwister-Element) mitbewegen + const label = app.ganttDrag.bar.nextElementSibling; + if(label && label.classList.contains('gantt-milestone-label')) label.style.left = left; + return; + } + + let newStart, newEnd; + if(app.ganttDrag.action === 'move'){ + newStart = addDays(app.ganttDrag.originalStart, dayDelta); + newEnd = addDays(app.ganttDrag.originalEnd, dayDelta); + } else if(app.ganttDrag.action === 'left'){ + newStart = addDays(app.ganttDrag.originalStart, dayDelta); + newEnd = app.ganttDrag.originalEnd; + if(newStart > newEnd) newStart = newEnd; + } else if(app.ganttDrag.action === 'right'){ + newStart = app.ganttDrag.originalStart; + newEnd = addDays(app.ganttDrag.originalEnd, dayDelta); + if(newEnd < newStart) newEnd = newStart; + } + + const barStart = newStart < app.ganttDrag.rangeStart ? app.ganttDrag.rangeStart : newStart; + const barEnd = newEnd > app.ganttDrag.rangeEnd ? app.ganttDrag.rangeEnd : newEnd; + const offset = daysBetween(app.ganttDrag.rangeStart, barStart); + const span = daysBetween(barStart, barEnd) + 1; + app.ganttDrag.bar.style.left = (offset / totalDays * 100).toFixed(4) + '%'; + app.ganttDrag.bar.style.width = (span / totalDays * 100).toFixed(4) + '%'; + } + + // onGanttMouseUp: beendet den Drag. Ein Klick ohne Bewegung öffnet das + // zugehörige Modal (TODO oder Meilenstein); sonst wird der Tagesversatz + // berechnet und je nach Typ die neuen Daten gespeichert (Projekt, TODO, + // Meilenstein, Urlaub). Liegt das Ergebnis außerhalb des Datumsbereichs, + // wird fitPlanGanttRange aufgerufen, sonst neu gerendert. + function onGanttMouseUp(e){ + if(!app.ganttDrag) return; + const drag = app.ganttDrag; + app.ganttDrag = null; + + if(!drag.moved && drag.type === 'milestone'){ + openMilestoneModal(drag.milestoneIndex); + return; + } + if(!drag.moved && drag.type === 'todo'){ + openTodoModal(drag.todoIndex); + return; + } + + const totalDays = daysBetween(drag.rangeStart, drag.rangeEnd) + 1; + const pxPerDay = drag.timelineWidth / totalDays; + const dx = e.clientX - drag.startX; + const dayDelta = Math.round(dx / pxPerDay); + if(dayDelta === 0){ + renderPlanGantt(); + return; + } + + let newStartStr, newEndStr; + let needsRefit = false; + + if(drag.type === 'milestone'){ + const newDate = addDays(drag.originalStart, dayDelta); + newStartStr = formatDateISO(newDate); + updateMilestoneDate(drag.projectId, drag.milestoneIndex, newStartStr); + if(newDate < drag.rangeStart || newDate > drag.rangeEnd) needsRefit = true; + } else { + let newStart, newEnd; + if(drag.action === 'move'){ + newStart = addDays(drag.originalStart, dayDelta); + newEnd = addDays(drag.originalEnd, dayDelta); + } else if(drag.action === 'left'){ + newStart = addDays(drag.originalStart, dayDelta); + newEnd = drag.originalEnd; + if(newStart > newEnd) newStart = newEnd; + } else { + newStart = drag.originalStart; + newEnd = addDays(drag.originalEnd, dayDelta); + if(newEnd < newStart) newEnd = newStart; + } + newStartStr = formatDateISO(newStart); + newEndStr = formatDateISO(newEnd); + if(newStart < drag.rangeStart || newEnd > drag.rangeEnd) needsRefit = true; + + if(drag.type === 'project'){ + updateProjectDates(drag.projectId, newStartStr, newEndStr); + } else if(drag.type === 'vacation'){ + updateVacationDates(drag.personId, drag.absenceId, newStartStr, newEndStr); + } else if(drag.type === 'todo'){ + updateTodoDates(drag.todoIndex, newStartStr, newEndStr); + } + } + + if(needsRefit) fitPlanGanttRange(); + else renderPlanGantt(); + } + + // updateTodoDates: schreibt neue Start-/Enddaten in die TODO an Position + // idx und speichert den Plan. + function updateTodoDates(idx, start, end){ + const plan = app.ensurePlanLocal(); + if(!plan.todos[idx]) return; + plan.todos[idx].start = start; + plan.todos[idx].end = end; + app.savePlan(); + } + + // onGanttDoubleClick: öffnet bei einem Doppelklick auf eine freie Stelle der + // Timeline das TODO-Modal für eine neue Aufgabe und übernimmt das + // angeklickte Datum in Start- und Endfeld. + function onGanttDoubleClick(e){ + if(app.isLocked()) return; + const timeline = e.target.closest('.gantt-timeline'); + if(!timeline) return; + // Nicht auslösen, wenn auf einen Balken geklickt wurde + if(e.target.closest('[data-gantt-type]')) return; + const rect = timeline.getBoundingClientRect(); + const x = e.clientX - rect.left; + const rangeStart = parseDateISO(document.getElementById('planGanttStart').value); + const rangeEnd = parseDateISO(document.getElementById('planGanttEnd').value); + if(!rangeStart || !rangeEnd) return; + const totalDays = daysBetween(rangeStart, rangeEnd) + 1; + const dayOffset = Math.max(0, Math.min(totalDays - 1, Math.round(x / rect.width * totalDays))); + const date = formatDateISO(addDays(rangeStart, dayOffset)); + openTodoModal(null); + document.getElementById('todoEditStart').value = date; + document.getElementById('todoEditEnd').value = date; + } + + // updateProjectDates: setzt Start und Ende des Projekt-Plans und speichert. + function updateProjectDates(projectId, start, end){ + const p = app.week.projects.find(x => x.id === projectId); + if(!p) return; + ensurePlan(p); + p.plan.start = start; + p.plan.end = end; + app.savePlan(); + } + + // updateMilestoneDate: setzt das Datum des Meilensteins am übergebenen Index + // und speichert. Sind TODOs von diesem Meilenstein abhängig, werden sie + // automatisch neu terminiert (autoScheduleTodos aus plan-schedule.js). + function updateMilestoneDate(projectId, index, date){ + const p = app.week.projects.find(x => x.id === projectId); + if(!p) return; + ensurePlan(p); + if(!Array.isArray(p.plan.milestones)) p.plan.milestones = []; + if(p.plan.milestones[index]){ + p.plan.milestones[index].date = date; + app.savePlan(); + // Optional: Abhängige TODOs automatisch nachziehen + const msId = 'm_' + p.plan.milestones[index].id; + const affected = (p.plan.todos || []).filter(t => Array.isArray(t.dependsOn) && t.dependsOn.includes(msId)); + if(affected.length > 0) autoScheduleTodos(); + } + } + + // updateVacationDates: schreibt die verschobenen Urlaubsdaten sowohl in die + // lokale Sitzungs-Kopie der Person als auch in die globale Personenliste + // und speichert beides. + function updateVacationDates(personId, absenceId, start, end){ + const localPerson = (app.week.people || []).find(p => p.id === personId); + if(localPerson && localPerson.absences){ + const a = localPerson.absences.find(x => x.id === absenceId); + if(a){ a.start = start; a.end = end; } + } + const globalPeople = db.globalPeople[app.department] || []; + const globalPerson = globalPeople.find(p => p.id === personId); + if(globalPerson && globalPerson.absences){ + const a = globalPerson.absences.find(x => x.id === absenceId); + if(a){ a.start = start; a.end = end; } + } + app.savePlan(); + app.saveGlobalPeople(); + } + + // Drag-Funktionalität erst nach geladenem DOM registrieren + document.addEventListener('DOMContentLoaded', initGanttDrag); + document.addEventListener('DOMContentLoaded', initGanttTodoDrag); +})(); \ No newline at end of file diff --git a/public/js/plan/plan-gantt.js b/public/js/plan/plan-gantt.js new file mode 100644 index 0000000..9c8b64c --- /dev/null +++ b/public/js/plan/plan-gantt.js @@ -0,0 +1,233 @@ +// ----------------------------- +// Projektplan — GANTT-Rendering & Zoom +// ----------------------------- +// Dieses Modul rendert das GANTT-Diagramm der Projektplan-Seite inklusive +// der TODO- und Meilensteinzeilen, die buildGanttHtml aus plan-build-gantt.js +// liefert, und zeichnet darauf die Abhängigkeitslinien zwischen den Aufgaben. +// Es verwaltet außerdem Zoom (pxPerDay in PlanApp), das Anpassen des +// Datumsbereichs (fitPlanGanttRange) sowie die Anzeige der Ressourcen- +// Auslastung. renderPlanGantt ist die zentrale Neu-Render-Funktion und wird +// nach Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js +// erneut aufgerufen. +// +// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp +// (plan-state.js). + +(function(){ + const app = window.PlanApp; + + // renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts + // neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den + // Projektzeitraum), berücksichtigt Urlaub, kritischen Pfad, Sperrstatus und + // Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die + // Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert. + window.renderPlanGantt = function(){ + const container = document.getElementById('planGanttContainer'); + if(!app.project || !container) return; + const prevScroll = container.scrollLeft; + let start = parseDateISO(document.getElementById('planGanttStart').value); + let end = parseDateISO(document.getElementById('planGanttEnd').value); + if(!start || !end){ + const range = ganttRangeFromProjects([app.project], 7); + start = range.start; end = range.end; + document.getElementById('planGanttStart').value = formatDateISO(start); + document.getElementById('planGanttEnd').value = formatDateISO(end); + } + const showVacation = document.getElementById('planShowVacation').checked; + const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id); + const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : []; + container.innerHTML = buildGanttHtml([app.project], start, end, { showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath }); + container.scrollLeft = prevScroll; + requestAnimationFrame(() => renderGanttDependencies(container)); + renderResourceLoad(); + }; + + // renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die + // Abhängigkeits-Pfade als SVG-Kurven mit Pfeilspitzen in das Diagramm. + // Bereits vorhandene Linien werden zuvor entfernt; Konflikt- und + // erledigte-Kanten erhalten eigene Farben. Parameter: container (optional, + // GANTT-Container). Rückgabe: keine. + window.renderGanttDependencies = function(container){ + const c = container || document.getElementById('planGanttContainer'); + if(!c) return; + const chart = c.querySelector('.gantt-chart'); + if(!chart) return; + + let svg = chart.querySelector('.gantt-dependencies-svg'); + if(svg) svg.remove(); + + const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]')); + if(nodeBars.length === 0) return; + + const chartRect = chart.getBoundingClientRect(); + const barMap = new Map(); + nodeBars.forEach(b => { + if(b.dataset.todoId) barMap.set(b.dataset.todoId, b); + }); + + const paths = []; + nodeBars.forEach(targetBar => { + const dependsOnStr = targetBar.dataset.todoDepends; + if(!dependsOnStr) return; + const depIds = dependsOnStr.split(',').map(s => s.trim()).filter(Boolean); + depIds.forEach(depId => { + const sourceBar = barMap.get(depId); + if(!sourceBar) return; + + const sourceRect = sourceBar.getBoundingClientRect(); + const targetRect = targetBar.getBoundingClientRect(); + + const x1 = sourceRect.right - chartRect.left; + const y1 = sourceRect.top + sourceRect.height / 2 - chartRect.top; + const x2 = targetRect.left - chartRect.left; + const y2 = targetRect.top + targetRect.height / 2 - chartRect.top; + + const isConflict = x2 < x1; + const isDone = targetBar.classList.contains('done') && sourceBar.classList.contains('done'); + + let d = ''; + if(x2 >= x1 + 10){ + const midX = x1 + (x2 - x1) / 2; + d = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`; + } else { + const r = 16; + d = `M ${x1} ${y1} C ${x1 + r} ${y1}, ${x1 + r} ${(y1 + y2)/2}, ${(x1 + x2)/2} ${(y1 + y2)/2} C ${x2 - r} ${(y1 + y2)/2}, ${x2 - r} ${y2}, ${x2} ${y2}`; + } + + let pathClass = 'gantt-dep-path'; + let markerId = 'gantt-arrow-normal'; + if(isConflict){ pathClass += ' conflict'; markerId = 'gantt-arrow-conflict'; } + else if(isDone){ pathClass += ' done'; markerId = 'gantt-arrow-done'; } + + paths.push(``); + }); + }); + + if(paths.length === 0) return; + + svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'gantt-dependencies-svg'); + svg.innerHTML = ` + + + + + + + + + + + + ${paths.join('')} + `; + + chart.appendChild(svg); + }; + + // fitPlanGanttRange: setzt die Datumsfelder der Seite auf den Projektzeitraum + // (inkl. 7 Tage Puffer) zurück und rendert das Diagramm neu. Wird auch nach + // Drags außerhalb des sichtbaren Bereichs von plan-drag.js genutzt. + window.fitPlanGanttRange = function(){ + const range = ganttRangeFromProjects([app.project], 7); + document.getElementById('planGanttStart').value = formatDateISO(range.start); + document.getElementById('planGanttEnd').value = formatDateISO(range.end); + renderPlanGantt(); + }; + + // zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms. + // Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit). + window.zoomPlanGantt = function(dir){ + const container = document.getElementById('planGanttContainer'); + let start = parseDateISO(document.getElementById('planGanttStart').value); + let end = parseDateISO(document.getElementById('planGanttEnd').value); + if(!start || !end) return; + const totalDays = Math.max(1, daysBetween(start, end) + 1); + const cw = container ? Math.max(200, container.clientWidth - 250) : 900; + const fitPx = Math.max(3, cw / totalDays); + const base = app.pxPerDay || fitPx; + let next; + if(dir > 0){ + next = Math.min(120, Math.round(base * 1.35)); + } else { + next = Math.round(base / 1.35); + if(next <= fitPx * 0.7) next = null; + } + app.pxPerDay = next; + renderPlanGantt(); + }; + + // ---------- Ressourcen-Auslastung ---------- + + // renderResourceLoad: berechnet die Auslastung der Projektbeteiligten in + // Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs und + // rendert pro Person eine Auslastungskarte mit Balken und Abwesenheits- + // Hinweis. Ein Klick auf eine Karte öffnet das Abwesenheits-Modal aus + // plan-modals.js. + window.renderResourceLoad = function(){ + const container = document.getElementById('resourceLoadContainer'); + if(!container || !app.project) return; + const plan = app.ensurePlanLocal(); + const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : []; + const assignees = projectAssignees(app.project, app.week); + if(assignees.length === 0){ + container.innerHTML = ''; + return; + } + + const loads = {}; + assignees.forEach(a => { + if(!loads[a.person.id]){ + loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [] }; + } + if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role); + }); + + todos.forEach(t => { + if(!Array.isArray(t.assignees) || t.assignees.length === 0) return; + const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end)); + t.assignees.forEach(pid => { + if(!loads[pid]) return; + loads[pid].days += days; + loads[pid].todos.push(t.title); + }); + }); + + const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0)); + + let html = '
Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)
'; + html += '
'; + Object.values(loads).forEach(l => { + const pct = Math.round(l.days / maxDays * 100); + const absCount = app.countPersonAbsences(l.person.id); + const absBadge = absCount > 0 ? `
🏖 ${absCount} Abwesenheit(en)
` : ''; + + let todoListHtml; + if(l.todos.length === 0){ + todoListHtml = '
keine TODOs
'; + } else { + todoListHtml = l.todos.slice(0,3).map(t => `
${esc(t)}
`).join(''); + if(l.todos.length > 3){ + todoListHtml += '
+' + (l.todos.length - 3) + ' weitere
'; + } + } + + html += `
+

${esc(l.person.name)} (${esc(l.roles.join(' / '))})

+
${l.days}
+
+
${todoListHtml}
+ ${absBadge} +
`; + }); + html += '
'; + container.innerHTML = html; + }; + + // Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen + // Debounce, damit während des Ziehens nicht unnötig neu gerendert wird. + window.addEventListener('resize', () => { + if(app.resizeTimer) clearTimeout(app.resizeTimer); + app.resizeTimer = setTimeout(() => renderGanttDependencies(), 150); + }); +})(); \ No newline at end of file diff --git a/public/js/plan/plan-modals.js b/public/js/plan/plan-modals.js new file mode 100644 index 0000000..7359b54 --- /dev/null +++ b/public/js/plan/plan-modals.js @@ -0,0 +1,473 @@ +// ----------------------------- +// Projektplan — Modals (Meilenstein, TODO, Abwesenheit) +// ----------------------------- +// Dieses Modul stellt die Öffnen-/Schließen-/Speichern-/Löschen-Funktionen +// der drei Dialoge der Projektplan-Seite bereit: Meilenstein, TODO und +// Abwesenheiten. Der Bearbeitungszustand liegt in window.PlanApp +// (plan-state.js): currentMilestoneIndex, currentTodoIndex und +// currentAbsencePersonId. Alle Änderungen werden über app.savePlan bzw. +// app.saveGlobalPeople gespeichert und das GANTT über renderPlanGantt +// (plan-gantt.js) neu gezeichnet. Bei gesperrten Wochen (app.isLocked) sind +// alle Bearbeitungsfunktionen deaktiviert. +// +// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp +// (plan-state.js). + +(function(){ + const app = window.PlanApp; + + // ---------- Meilenstein-Modal ---------- + + // openMilestoneModal: öffnet das Meilenstein-Modal. Parameter: index + // (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum, + // Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und + // TODOs) aus dem Plan. + window.openMilestoneModal = function(index){ + if(app.isLocked()) return; + const plan = app.ensurePlanLocal(); + if(!Array.isArray(plan.milestones)) plan.milestones = []; + // Migration alter Meilensteine + plan.milestones.forEach(m => { if(!Array.isArray(m.dependsOn)) m.dependsOn = []; }); + app.currentMilestoneIndex = index; + + const isNew = index === null || plan.milestones[index] === undefined; + const currentMs = isNew ? null : plan.milestones[index]; + document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || ''); + document.getElementById('milestoneEditDate').value = isNew ? '' : (currentMs.date || ''); + + const positionSelect = document.getElementById('milestoneEditPosition'); + if(positionSelect){ + const currentPosition = currentMs && currentMs.position ? currentMs.position : ''; + const defaultAfterTodo = isNew && plan.todos && plan.todos.length > 0 ? plan.todos[plan.todos.length - 1].id : null; + let opts = ''; + if(plan.todos && plan.todos.length > 0){ + plan.todos.forEach(t => { + const beforeVal = 'before:' + t.id; + const afterVal = 'after:' + t.id; + const beforeSel = currentPosition === beforeVal ? 'selected' : ''; + let afterSel = currentPosition === afterVal ? 'selected' : ''; + if(isNew && defaultAfterTodo === t.id && !currentPosition) afterSel = 'selected'; + opts += ``; + opts += ``; + }); + } + positionSelect.innerHTML = opts; + } + + const dependsOnList = document.getElementById('milestoneEditDependsOn'); + if(dependsOnList){ + const currentDeps = currentMs && Array.isArray(currentMs.dependsOn) ? currentMs.dependsOn : []; + let listHtml = ''; + + // Andere Meilensteine als Vorgänger anbieten + if(plan.milestones.length > 1){ + listHtml += '
Meilensteine
'; + plan.milestones.forEach(m => { + if(currentMs && m.id === currentMs.id) return; + const mId = 'm_' + m.id; + const checked = currentDeps.includes(mId) ? 'checked' : ''; + listHtml += ``; + }); + } + + // TODOs als Vorgänger anbieten + if(plan.todos && plan.todos.length > 0){ + listHtml += '
Aufgaben
'; + (plan.todos || []).forEach(t => { + const checked = currentDeps.includes(t.id) ? 'checked' : ''; + const datesStr = (t.start || t.end) ? ` (${formatDateDE(parseDateISO(t.start))}–${formatDateDE(parseDateISO(t.end))})` : ''; + listHtml += ``; + }); + } + if(listHtml === '') listHtml = 'Keine anderen Aufgaben oder Meilensteine vorhanden.'; + dependsOnList.innerHTML = listHtml; + } + + document.getElementById('milestoneModal').style.display = 'flex'; + document.getElementById('milestoneEditName').focus(); + }; + + // closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den + // Bearbeitungsindex zurück. + window.closeMilestoneModal = function(){ + document.getElementById('milestoneModal').style.display = 'none'; + app.currentMilestoneIndex = null; + }; + + // saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position, + // Vorgänger) in den Plan – als neuer Eintrag oder als Update des + // aktuell bearbeiteten Meilensteins. Sortiert danach nach Datum, speichert + // und rendert neu. + window.saveMilestoneEdit = function(){ + if(app.isLocked()) return; + const name = document.getElementById('milestoneEditName').value.trim(); + const date = document.getElementById('milestoneEditDate').value; + if(!name || !date){ alert('Bitte Name und Datum angeben.'); return; } + + const dependsOnList = document.getElementById('milestoneEditDependsOn'); + const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : []; + + const positionSelect = document.getElementById('milestoneEditPosition'); + const position = positionSelect ? positionSelect.value : ''; + + const plan = app.ensurePlanLocal(); + if(!Array.isArray(plan.milestones)) plan.milestones = []; + + if(app.currentMilestoneIndex === null){ + plan.milestones.push({ id: uid(), name, date, dependsOn, position }); + } else if(plan.milestones[app.currentMilestoneIndex]){ + const m = plan.milestones[app.currentMilestoneIndex]; + m.name = name; + m.date = date; + m.dependsOn = dependsOn; + m.position = position; + } + plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date)); + app.savePlan(); + closeMilestoneModal(); + renderPlanGantt(); + }; + + // deleteMilestoneEdit: löscht nach Bestätigung den aktuell bearbeiteten + // Meilenstein samt seiner Vorgänger-Verweise (app.removeDependencyRefs), + // speichert und rendert neu. + window.deleteMilestoneEdit = function(){ + if(app.isLocked()) return; + if(app.currentMilestoneIndex === null){ closeMilestoneModal(); return; } + if(!confirm('Meilenstein löschen?')) return; + const plan = app.ensurePlanLocal(); + if(plan.milestones && plan.milestones[app.currentMilestoneIndex]){ + const deletedId = 'm_' + plan.milestones[app.currentMilestoneIndex].id; + plan.milestones.splice(app.currentMilestoneIndex, 1); + app.removeDependencyRefs(plan, deletedId); + app.savePlan(); + } + closeMilestoneModal(); + renderPlanGantt(); + }; + + // addPlanMilestone: öffnet das Meilenstein-Modal für einen neuen Eintrag. + window.addPlanMilestone = function(){ + openMilestoneModal(null); + }; + + // ---------- TODO-Modal ---------- + + // openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe + // oder null für eine neue). Befüllt Titel, Start/Ende, Erledigt-Status, + // Fortschritt, Vorgänger-Checkboxen und die aus dem Projektboard + // stammenden Bearbeiter-Auswahl. + window.openTodoModal = function(index){ + if(app.isLocked()) return; + const plan = app.ensurePlanLocal(); + if(!Array.isArray(plan.todos)) plan.todos = []; + app.currentTodoIndex = index; + + const isNew = index === null || plan.todos[index] === undefined; + const currentTodo = !isNew ? plan.todos[index] : null; + + document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || ''); + document.getElementById('todoEditStart').value = isNew ? '' : (currentTodo.start || ''); + document.getElementById('todoEditEnd').value = isNew ? '' : (currentTodo.end || ''); + document.getElementById('todoEditDone').checked = isNew ? false : !!currentTodo.done; + const progress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0); + document.getElementById('todoEditProgress').value = progress; + document.getElementById('todoEditProgressVal').textContent = progress + '%'; + + const dependsOnList = document.getElementById('todoEditDependsOn'); + if(dependsOnList){ + const currentDeps = currentTodo && Array.isArray(currentTodo.dependsOn) ? currentTodo.dependsOn : []; + let listHtml = ''; + + // Meilensteine als Vorgänger anbieten + if(Array.isArray(plan.milestones) && plan.milestones.length > 0){ + listHtml += '
Meilensteine
'; + plan.milestones.forEach(m => { + const mId = 'm_' + m.id; + const checked = currentDeps.includes(mId) ? 'checked' : ''; + listHtml += ``; + }); + } + + // Andere TODOs als Vorgänger anbieten + if(plan.todos && plan.todos.length > 1){ + listHtml += '
Aufgaben
'; + (plan.todos || []).forEach(t => { + if(currentTodo && t.id === currentTodo.id) return; + const checked = currentDeps.includes(t.id) ? 'checked' : ''; + const datesStr = (t.start || t.end) ? ` (${formatDateDE(parseDateISO(t.start))}–${formatDateDE(parseDateISO(t.end))})` : ''; + listHtml += ``; + }); + } + if(listHtml === '') listHtml = 'Keine anderen Aufgaben oder Meilensteine vorhanden.'; + dependsOnList.innerHTML = listHtml; + } + + const assigneesDiv = document.getElementById('todoEditAssignees'); + if(assigneesDiv){ + const assignees = projectAssignees(app.project, app.week); + const currentAssignees = currentTodo && Array.isArray(currentTodo.assignees) ? currentTodo.assignees : []; + const seenPeople = new Set(); + const uniquePeople = []; + assignees.forEach(a => { + if(!seenPeople.has(a.person.id)){ + seenPeople.add(a.person.id); + uniquePeople.push(a.person); + } + }); + assigneesDiv.innerHTML = uniquePeople.map(person => { + const checked = currentAssignees.includes(person.id) ? 'checked' : ''; + return ``; + }).join('') || 'Keine Projektzuordnungen im Board.'; + } + + document.getElementById('todoModal').style.display = 'flex'; + document.getElementById('todoEditTitle').focus(); + }; + + // closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex + // zurück. + window.closeTodoModal = function(){ + document.getElementById('todoModal').style.display = 'none'; + app.currentTodoIndex = null; + }; + + // saveTodoEdit: übernimmt die Modal-Eingaben in den Plan – als neuer TODO + // oder Update des aktuell bearbeiteten. Fehlende End-/Startwerte werden + // als eintägige Aufgabe ergänzt, „Erledigt“ setzt den Fortschritt auf + // 100 %. Speichert und rendert neu. + window.saveTodoEdit = function(){ + if(app.isLocked()) return; + const title = document.getElementById('todoEditTitle').value.trim(); + let start = document.getElementById('todoEditStart').value; + let end = document.getElementById('todoEditEnd').value; + // Nur ein Datum angegeben: als eintägige Aufgabe behandeln, + // sonst würde kein Balken im GANTT erscheinen. + if(start && !end) end = start; + if(end && !start) start = end; + const done = document.getElementById('todoEditDone').checked; + let progress = parseInt(document.getElementById('todoEditProgress').value, 10) || 0; + // "Erledigt" impliziert 100 % Fortschritt + if(done && progress < 100) progress = 100; + + const dependsOnList = document.getElementById('todoEditDependsOn'); + const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : []; + + const assigneesDiv = document.getElementById('todoEditAssignees'); + const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('input:checked')).map(cb => cb.value) : []; + + if(!title){ alert('Bitte einen Titel eingeben.'); return; } + if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; } + + const plan = app.ensurePlanLocal(); + if(!Array.isArray(plan.todos)) plan.todos = []; + + if(app.currentTodoIndex === null){ + plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees }); + } else if(plan.todos[app.currentTodoIndex]){ + const t = plan.todos[app.currentTodoIndex]; + t.title = title; + t.start = start; + t.end = end; + t.done = done; + t.progress = progress; + t.dependsOn = dependsOn; + t.assignees = assignees; + } + app.savePlan(); + closeTodoModal(); + renderPlanGantt(); + }; + + // deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe, + // entfernt ihre Vorgänger-Verweise und setzt die relative Position + // betroffener Meilensteine zurück. Speichert und rendert neu. + window.deleteTodoEdit = function(){ + if(app.isLocked()) return; + if(app.currentTodoIndex === null){ closeTodoModal(); return; } + if(!confirm('TODO löschen?')) return; + const plan = app.ensurePlanLocal(); + if(plan.todos && plan.todos[app.currentTodoIndex]){ + const deletedId = plan.todos[app.currentTodoIndex].id; + plan.todos.splice(app.currentTodoIndex, 1); + app.removeDependencyRefs(plan, deletedId); + // Meilensteine, die relativ zu dieser TODO positioniert waren, zurücksetzen + (plan.milestones || []).forEach(m => { + if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = ''; + }); + app.savePlan(); + } + closeTodoModal(); + renderPlanGantt(); + }; + + // addTodoFromList: legt eine neue Aufgabe aus den Eingabefeldern der + // TODO-Liste an (Titel, Start/Ende), leert die Felder und rendert neu. + window.addTodoFromList = function(){ + if(app.isLocked()) return; + const title = document.getElementById('newTodoTitle').value.trim(); + let start = document.getElementById('newTodoStart').value; + let end = document.getElementById('newTodoEnd').value; + if(start && !end) end = start; + if(end && !start) start = end; + if(!title){ alert('Bitte einen Titel eingeben.'); return; } + if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; } + const plan = app.ensurePlanLocal(); + plan.todos.push({ id: uid(), title, start, end, done: false, progress: 0, dependsOn: [], assignees: [] }); + document.getElementById('newTodoTitle').value = ''; + document.getElementById('newTodoStart').value = ''; + document.getElementById('newTodoEnd').value = ''; + app.savePlan(); + renderPlanGantt(); + }; + + // addTodo: öffnet das TODO-Modal für eine neue Aufgabe. + window.addTodo = function(){ + openTodoModal(null); + }; + + // updateTodo: setzt ein einzelnes Feld (z. B. done) einer Aufgabe direkt + // und speichert sowie rendert neu. Parameter: idx (Index), field + // (Feldname), value (neuer Wert). + window.updateTodo = function(idx, field, value){ + if(app.isLocked()) return; + const plan = app.ensurePlanLocal(); + if(!plan.todos[idx]) return; + plan.todos[idx][field] = value; + app.savePlan(); + renderPlanGantt(); + }; + + // toggleTodoDone: schaltet den Erledigt-Status der Aufgabe am Index um, + // speichert und rendert neu. + window.toggleTodoDone = function(idx){ + if(app.isLocked()) return; + const plan = app.ensurePlanLocal(); + if(!plan.todos[idx]) return; + plan.todos[idx].done = !plan.todos[idx].done; + app.savePlan(); + renderPlanGantt(); + }; + + // deleteTodo: löscht nach Bestätigung die Aufgabe am Index, bereinigt die + // Vorgänger-Verweise und die relative Meilenstein-Position. Speichert und + // rendert neu. + window.deleteTodo = function(idx){ + if(app.isLocked()) return; + if(!confirm('TODO löschen?')) return; + const plan = app.ensurePlanLocal(); + if(!plan.todos[idx]) return; + const deletedId = plan.todos[idx].id; + plan.todos.splice(idx, 1); + app.removeDependencyRefs(plan, deletedId); + (plan.milestones || []).forEach(m => { + if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = ''; + }); + app.savePlan(); + renderPlanGantt(); + }; + + // ---------- Abwesenheits-Modal (Urlaube der Projektbeteiligten) ---------- + + // openAbsenceModal: öffnet das Abwesenheits-Modal für die übergebene + // Person-ID, setzt den Titel, leert die Formularfelder und rendert die + // Liste der bestehenden Abwesenheiten. + window.openAbsenceModal = function(personId){ + const person = app.findGlobalPerson(personId); + if(!person) return; + app.currentAbsencePersonId = personId; + document.getElementById('absenceModalTitle').textContent = 'Abwesenheiten – ' + (person.fullName || person.name); + // Formularfelder zurücksetzen + const startInput = document.getElementById('absenceEditStart'); + const endInput = document.getElementById('absenceEditEnd'); + const rangeDisplay = document.getElementById('absenceEditRange'); + if(startInput) startInput.value = ''; + if(endInput) endInput.value = ''; + if(rangeDisplay) rangeDisplay.value = ''; + renderAbsenceModalList(); + document.getElementById('absenceModal').style.display = 'flex'; + }; + + // closeAbsenceModal: blendet das Abwesenheits-Modal aus und setzt die + // aktuelle Person-ID zurück. + window.closeAbsenceModal = function(){ + document.getElementById('absenceModal').style.display = 'none'; + app.currentAbsencePersonId = null; + }; + + // renderAbsenceModalList: rendert die Liste der Abwesenheiten der aktuellen + // Person im Modal (jeweils Typ, Zeitraum, Notiz und Lösch-Button). Bei + // gesperrter Woche wird das Formular ausgeblendet. + function renderAbsenceModalList(){ + const listDiv = document.getElementById('absenceModalList'); + const person = app.findGlobalPerson(app.currentAbsencePersonId); + if(!listDiv || !person) return; + const absences = Array.isArray(person.absences) ? person.absences : []; + const locked = app.isLocked(); + if(absences.length === 0){ + listDiv.innerHTML = 'Keine Abwesenheiten eingetragen.'; + } else { + listDiv.innerHTML = '
' + absences.map(a => ` +
+ ${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))} + ${a.note ? `${esc(a.note)}` : ''} + ${locked ? '' : ``} +
`).join('') + '
'; + } + const form = document.getElementById('absenceModalForm'); + if(form) form.style.display = locked ? 'none' : ''; + } + + // addPlanAbsence: legt aus den Formularfeldern eine neue Abwesenheit für die + // aktuelle Person an – in der globalen Personenliste und in der lokalen + // Sitzungs-Kopie. Speichert beides und rendert Liste sowie GANTT neu. + window.addPlanAbsence = function(){ + if(app.isLocked()) return; + const person = app.findGlobalPerson(app.currentAbsencePersonId); + if(!person) return; + const start = document.getElementById('absenceEditStart').value; + const end = document.getElementById('absenceEditEnd').value; + const type = document.getElementById('absenceEditType').value; + const note = document.getElementById('absenceEditNote').value.trim(); + if(!start || !end){ alert('Bitte Start- und Enddatum angeben.'); return; } + if(parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; } + if(!Array.isArray(person.absences)) person.absences = []; + const absence = { id: uid(), start, end, type, note }; + person.absences.push(absence); + person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start)); + // Auch die Sitzungs-lokale Kopie aktualisieren, damit die Anzeige konsistent bleibt + const localPerson = (app.week.people || []).find(p => p.id === app.currentAbsencePersonId); + if(localPerson){ + if(!Array.isArray(localPerson.absences)) localPerson.absences = []; + localPerson.absences.push({ ...absence }); + localPerson.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start)); + } + app.saveGlobalPeople(); + app.savePlan(); + document.getElementById('absenceEditStart').value = ''; + document.getElementById('absenceEditEnd').value = ''; + const rangeDisplay = document.getElementById('absenceEditRange'); + if(rangeDisplay) rangeDisplay.value = ''; + document.getElementById('absenceEditNote').value = ''; + renderAbsenceModalList(); + renderPlanGantt(); + }; + + // deletePlanAbsence: entfernt die Abwesenheit mit der übergebenen ID aus + // der globalen Personenliste und der lokalen Sitzungs-Kopie, speichert + // beides und rendert Liste sowie GANTT neu. + window.deletePlanAbsence = function(absenceId){ + if(app.isLocked()) return; + const person = app.findGlobalPerson(app.currentAbsencePersonId); + if(!person || !Array.isArray(person.absences)) return; + person.absences = person.absences.filter(a => a.id !== absenceId); + const localPerson = (app.week.people || []).find(p => p.id === app.currentAbsencePersonId); + if(localPerson && Array.isArray(localPerson.absences)){ + localPerson.absences = localPerson.absences.filter(a => a.id !== absenceId); + } + app.saveGlobalPeople(); + app.savePlan(); + renderAbsenceModalList(); + renderPlanGantt(); + }; +})(); \ No newline at end of file diff --git a/public/js/plan/plan-schedule.js b/public/js/plan/plan-schedule.js new file mode 100644 index 0000000..9746783 --- /dev/null +++ b/public/js/plan/plan-schedule.js @@ -0,0 +1,288 @@ +// ----------------------------- +// Projektplan — Kritischer Pfad, Auto-Schedule & Mail-Link +// ----------------------------- +// Dieses Modul berechnet über computeCriticalPath die Aufgabe(n) auf dem +// kritischen Pfad (längste Vorgänger-Kette) und hebt sie im GANTT hervor, +// verschiebt bei autoScheduleTodos TODOs und Meilensteine automatisch hinter +// ihre Vorgänger und erstellt bei todoMail einen mailto-Link an alle +// zugeordneten Personen. Die Hervorhebung übergibt renderPlanGantt als +// Option criticalPath an buildGanttHtml (plan-build-gantt.js); den Schalter +// criticalPathActive hält der Zustand in window.PlanApp (plan-state.js). +// +// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp +// (plan-state.js). + +(function(){ + const app = window.PlanApp; + + // ---------- Kritischer Pfad & Auto-Schedule ---------- + + // computeCriticalPath: liefert Array der TODO-IDs, die auf einem kritischen Pfad liegen + // (längste Kette von Vorgänger-Abhängigkeiten, End-to-Start) + window.computeCriticalPath = function(project){ + if(!project || !project.plan) return []; + const todos = (project.plan.todos || []).filter(t => t.start && t.end); + const milestones = (project.plan.milestones || []).filter(m => m.date); + if(todos.length === 0 && milestones.length === 0) return []; + + // Einheitliche Knoten: TODOs und Meilensteine (Dauer 1) + const nodes = todos.map(t => ({ type: 'todo', id: t.id, obj: t, start: parseDateISO(t.start), end: parseDateISO(t.end), duration: Math.max(1, daysBetween(parseDateISO(t.start), parseDateISO(t.end)) + 1) })); + milestones.forEach(m => { + const d = parseDateISO(m.date); + nodes.push({ type: 'milestone', id: 'm_' + m.id, obj: m, start: d, end: d, duration: 1 }); + }); + const nodeMap = new Map(nodes.map(n => [n.id, n])); + + // depsOf: liefert die Vorgänger-Liste eines Knotens (leeres Array, falls + // keine vorhanden). + function depsOf(n){ + return Array.isArray(n.obj.dependsOn) ? n.obj.dependsOn : []; + } + + // Frühester Start (ES) und frühestes Ende (EF) berechnen. + // visiting-Set schützt vor Endlos-Rekursion bei zyklischen Abhängigkeiten. + const es = {}, ef = {}; + const esVisiting = new Set(); + // computeES: berechnet rekursiv den frühesten Start (ES) und frühestes + // Ende (EF) eines Knotens aus seinen Vorgängern. Liefert ES oder null + // bei Zyklen (visiting-Set). + function computeES(n){ + if(es[n.id] !== undefined) return es[n.id]; + if(esVisiting.has(n.id)){ es[n.id] = null; ef[n.id] = null; return null; } // Zyklus + if(!n.start || !n.end){ es[n.id] = null; ef[n.id] = null; return null; } + esVisiting.add(n.id); + const deps = depsOf(n); + let maxEF = null; + deps.forEach(depId => { + const dep = nodeMap.get(depId); + if(!dep) return; + const depStart = computeES(dep); + if(!depStart) return; + const depEnd = addDays(depStart, dep.duration - 1); + if(maxEF === null || depEnd > maxEF) maxEF = depEnd; + }); + esVisiting.delete(n.id); + es[n.id] = maxEF === null ? n.start : addDays(maxEF, 1); + ef[n.id] = addDays(es[n.id], n.duration - 1); + return es[n.id]; + } + nodes.forEach(n => computeES(n)); + + const validNodes = nodes.filter(n => es[n.id]); + if(validNodes.length === 0) return []; + const maxEF = validNodes.reduce((max, n) => { const d = ef[n.id]; return !max || d > max ? d : max; }, null); + + // Spätestes Ende (LF) und spätester Start (LS) rückwärts berechnen. + // Wichtig: LF eines Knotens ist der Tag VOR dem spätesten Start (LS) + // seines frühesten Nachfolgers – nicht vor dessen LF. + const lf = {}, ls = {}; + const lfVisiting = new Set(); + // computeLF: berechnet rekursiv den spätesten Start (LS) und das späteste + // Ende (LF) eines Knotens rückwärts aus seinen Nachfolgern. Liefert LF + // oder null bei Zyklen (visiting-Set). + function computeLF(n){ + if(lf[n.id] !== undefined) return lf[n.id]; + if(lfVisiting.has(n.id)){ lf[n.id] = null; ls[n.id] = null; return null; } // Zyklus + if(!n.start || !n.end){ lf[n.id] = null; ls[n.id] = null; return null; } + lfVisiting.add(n.id); + const successors = validNodes.filter(succ => depsOf(succ).includes(n.id)); + let minLF = null; + successors.forEach(succ => { + if(!computeLF(succ)) return; + const succLS = ls[succ.id]; + if(!succLS) return; + const candidate = addDays(succLS, -1); + if(minLF === null || candidate < minLF) minLF = candidate; + }); + lfVisiting.delete(n.id); + lf[n.id] = minLF === null ? maxEF : minLF; + ls[n.id] = addDays(lf[n.id], -(n.duration - 1)); + return lf[n.id]; + } + validNodes.forEach(n => computeLF(n)); + + return validNodes.filter(n => n.type === 'todo' && es[n.id] && ls[n.id] && daysBetween(es[n.id], ls[n.id]) === 0).map(n => n.id); + }; + + // toggleCriticalPath: schaltet die Hervorhebung des kritischen Pfads im + // GANTT ein/aus (Wert in app.criticalPathActive), aktualisiert den + // Toolbar-Button und zeigt über einen Toast an, wie viele Aufgaben auf + // dem kritischen Pfad liegen. + window.toggleCriticalPath = function(){ + app.criticalPathActive = !app.criticalPathActive; + const btn = document.getElementById('criticalPathBtn'); + if(btn) btn.classList.toggle('active', app.criticalPathActive); + renderPlanGantt(); + if(app.criticalPathActive){ + const path = computeCriticalPath(app.project); + if(path.length === 0){ + app.showToast('Kein kritischer Pfad ermittelt (zu wenig Aufgaben oder Abhängigkeiten).'); + } else { + app.showToast(path.length + ' Aufgabe(n) auf dem kritischen Pfad hervorgehoben.'); + } + } + }; + + // autoScheduleTodos: verschiebt alle TODOs und Meilensteine, die von + // anderen Elementen abhängen, automatisch auf den frühesten möglichen + // Termin (einen Tag nach dem Ende des spätesten Vorgängers). Erkennt + // zyklische Abhängigkeiten und verhindert Endlos-Rekursion; danach + // Speichern, Neu-Rendern und Hinweis per Toast. + window.autoScheduleTodos = function(){ + if(app.isLocked()) return; + const plan = app.ensurePlanLocal(); + if(!Array.isArray(plan.todos) || plan.todos.length === 0) return; + const todos = plan.todos.filter(t => t.start && t.end); + const todoMap = new Map(todos.map(t => [t.id, t])); + const milestones = Array.isArray(plan.milestones) ? plan.milestones.filter(m => m.date) : []; + const milestoneMap = new Map(milestones.map(m => ['m_' + m.id, m])); + + // duration: liefert die Dauer einer TODO in Tagen (mindestens 1 Tag). + function duration(t){ const s = parseDateISO(t.start), e = parseDateISO(t.end); return s && e ? Math.max(1, daysBetween(s, e) + 1) : 1; } + + // processed: bereits fertig eingeplante Knoten + // visiting: aktuell im Rekursionspfad -> schützt vor Endlos-Rekursion bei Zyklen + // moved: Knoten, deren Datum sich tatsächlich geändert hat + const processed = new Set(); + const visiting = new Set(); + const moved = new Set(); + let hasCycle = false; + + // scheduleTodo: plant eine TODO rekursiv ein – nutzt den spätesten + // Vorgänger-Termin (TODO oder Meilenstein) als frühesten Start und + // verschiebt Start/Ende entsprechend (Eintrag in moved bei Änderung). + function scheduleTodo(t){ + if(processed.has(t.id)) return; + if(visiting.has(t.id)){ hasCycle = true; return; } + const tStart = parseDateISO(t.start); + if(!tStart){ processed.add(t.id); return; } + visiting.add(t.id); + const deps = Array.isArray(t.dependsOn) ? t.dependsOn : []; + let earliest = tStart; + deps.forEach(depId => { + let depEnd = null; + const depTodo = todoMap.get(depId); + if(depTodo){ + scheduleTodo(depTodo); + depEnd = parseDateISO(depTodo.end); + } else { + const depMs = milestoneMap.get(depId); + if(depMs) depEnd = parseDateISO(depMs.date); + } + if(!depEnd) return; + const candidate = addDays(depEnd, 1); + if(candidate > earliest) earliest = candidate; + }); + visiting.delete(t.id); + const dur = duration(t); + const newStart = formatDateISO(earliest); + const newEnd = formatDateISO(addDays(earliest, dur - 1)); + if(newStart !== t.start || newEnd !== t.end){ + t.start = newStart; + t.end = newEnd; + moved.add(t.id); + } + processed.add(t.id); + } + + // scheduleMilestone: plant einen Meilenstein ein, sofern er von anderen + // TODOs/Meilensteinen abhängig ist, und verschiebt sein Datum auf den + // frühestmöglichen Termin (Eintrag in moved bei Änderung). + function scheduleMilestone(m){ + const mId = 'm_' + m.id; + if(processed.has(mId)) return; + if(visiting.has(mId)){ hasCycle = true; return; } + const deps = Array.isArray(m.dependsOn) ? m.dependsOn : []; + let earliest = parseDateISO(m.date); + if(!earliest){ processed.add(mId); return; } + visiting.add(mId); + deps.forEach(depId => { + let depEnd = null; + const depTodo = todoMap.get(depId); + if(depTodo){ + scheduleTodo(depTodo); + depEnd = parseDateISO(depTodo.end); + } else { + const depMs = milestoneMap.get(depId); + if(depMs && depMs.id !== m.id){ + scheduleMilestone(depMs); + depEnd = parseDateISO(depMs.date); + } + } + if(!depEnd) return; + const candidate = addDays(depEnd, 1); + if(candidate > earliest) earliest = candidate; + }); + visiting.delete(mId); + const newDate = formatDateISO(earliest); + if(newDate !== m.date){ + m.date = newDate; + moved.add(mId); + } + processed.add(mId); + } + + todos.forEach(t => scheduleTodo(t)); + milestones.forEach(m => scheduleMilestone(m)); + if(moved.size > 0){ + app.savePlan(); + renderPlanGantt(); + app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Achtung: zyklische Abhängigkeit gefunden!' : '')); + } else if(hasCycle){ + app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.'); + } else { + app.showToast('Keine Änderungen nötig – alle Termine passen zu den Abhängigkeiten.'); + } + }; + + // ---------- Mail-Link für alle zugeordneten Personen ---------- + + // todoMail: baut aus den E-Mail-Adressen der zugeordneten Personen und + // Projektinformationen einen mailto-Link zusammen und öffnet ihn. Zeigt + // Warnungen, wenn keine E-Mail-Adressen vorhanden sind oder der Link sehr + // lang wird. + window.todoMail = function(){ + if(!app.project || !app.week){ + console.warn('Kein aktuelles Projekt/keine aktuelle Woche gefunden – Mail kann nicht erstellt werden.'); + return; + } + + const assignees = projectAssignees(app.project, app.week); + + const empfaengerListe = assignees + .map(a => a.email || (a.person && a.person.email) || '') + .filter(email => email && email.trim() !== ''); + + const eindeutigeEmpfaenger = [...new Set(empfaengerListe)]; + + if(eindeutigeEmpfaenger.length === 0){ + console.warn('Keine gültigen E-Mail-Adressen der zugeordneten Personen gefunden.'); + return; + } + + const empfaenger = eindeutigeEmpfaenger.join(','); + + const titleEl = document.getElementById('planTitle'); + const projektname = titleEl ? titleEl.textContent.trim() : 'Projekt'; + const seitenLink = window.location.href; + const betreff = 'Neue Aufgaben im Projekt ' + projektname; + + const nachricht = + 'Guten Tag,\n\n' + + 'es wurden neue Aufgaben im Projekt ' + projektname + ' hinzugefügt.\n\n' + + 'Link zum Projekt:\n' + + seitenLink + '\n\n' + + 'Mit freundlichen Grüßen'; + + const mailtoLink = + 'mailto:' + encodeURIComponent(empfaenger).replace(/%2C/g, ',') + + '?subject=' + encodeURIComponent(betreff) + + '&body=' + encodeURIComponent(nachricht); + + if(mailtoLink.length > 1800){ + console.warn('mailto-Link ist sehr lang (' + mailtoLink.length + ' Zeichen) – manche Mail-Clients könnten ihn abschneiden.'); + } + + window.location.href = mailtoLink; + }; +})(); \ No newline at end of file diff --git a/public/js/plan/plan-state.js b/public/js/plan/plan-state.js new file mode 100644 index 0000000..d3dc0e1 --- /dev/null +++ b/public/js/plan/plan-state.js @@ -0,0 +1,229 @@ +// ----------------------------- +// Projektplan — Zustand, Initialisierung & Daten laden +// ----------------------------- +// Dieses Modul wird als erstes der plan-*-Module von public/project-plan.html +// geladen und definiert window.PlanApp als gemeinsamen Zustand aller +// Projektplan-Module. Es liest die URL-Parameter (department, weekLabel, +// projNr, projName), über die das zu bearbeitende Projekt adressiert wird. +// Nach DOMContentLoaded werden Theme, Toolbar-Buttons und die Sitzungsdaten +// über die Server-API geladen. Die hier definierten Funktionen (savePlan, +// isLocked, showToast u. a.) werden über app.* den anderen plan-Modulen +// bereitgestellt, die alle in einem IIFE mit window.PlanApp laufen. + +// PlanApp: gemeinsamer, modulübergreifender Zustand der Projektplan-Seite. +window.PlanApp = { + params: new URLSearchParams(location.search), + department: '', + weekLabel: '', + projNr: '', + projName: '', + week: null, // planWeek + project: null, // planProject + pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen) + criticalPathActive: false, // Hervorhebung des kritischen Pfads im GANTT aktiv + // Drag-Zustände + todoDrag: null, // Drag-and-Drop von TODO/Meilenstein-Zeilen + ganttDrag: null, // Balken/Meilenstein/Urlaub verschieben + currentMilestoneIndex: null, // Index des gerade bearbeiteten Meilensteins (Modal) + currentTodoIndex: null, // Index der gerade bearbeiteten TODO (Modal) + currentAbsencePersonId: null, // Person-ID des offenen Abwesenheits-Modals + toastTimer: null, // Timer der Toast-Benachrichtigung (plan-state.js) + resizeTimer: null // Debounce-Timer für Fenster-Resize (plan-gantt.js) +}; + +(function(){ + const app = window.PlanApp; + app.department = app.params.get('department') || 'ELT'; + app.weekLabel = app.params.get('weekLabel'); + app.projNr = app.params.get('projNr') || ''; + app.projName = app.params.get('projName') || ''; + + // init: Einstiegspunkt nach DOMContentLoaded. Übernimmt das Theme aus + // localStorage/System, prüft die URL-Parameter und bricht mit Fehlermeldung + // ab, falls kein Projekt ausgewählt wurde. Sonst bindet es die Toolbar und + // startet das Laden der Daten. + function init(){ + loadTheme(); // Theme aus localStorage / System übernehmen + if(!app.weekLabel || !app.projName){ + document.getElementById('planTitle').textContent = 'Fehler'; + document.getElementById('planSubtitle').textContent = 'Kein Projekt ausgewählt.'; + return; + } + currentDepartment = app.department; // global aus globals.js + bindToolbarButtons(); + loadPlanData(); + } + + // bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen, + // Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der + // anderen plan-Module. + function bindToolbarButtons(){ + const addTodoBtn = document.getElementById('addTodoBtn'); + if(addTodoBtn) addTodoBtn.addEventListener('click', () => addTodo()); + const autoScheduleBtn = document.getElementById('autoScheduleBtn'); + if(autoScheduleBtn) autoScheduleBtn.addEventListener('click', () => autoScheduleTodos()); + const criticalPathBtn = document.getElementById('criticalPathBtn'); + if(criticalPathBtn) criticalPathBtn.addEventListener('click', () => toggleCriticalPath()); + } + + // loadPlanData: lädt alle Sitzungen und die globale Personenliste der + // Abteilung über die Server-API, sortiert die Wochen chronologisch und + // sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt Fehlermeldungen + // der Reihenfolge nach im Seitenkopf an und startet sonst renderPlanPage(). + function loadPlanData(){ + if(!window.fetch){ + document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.'; + return; + } + const url = baseUrl() + '/load-all?department=' + encodeURIComponent(app.department); + fetch(url) + .then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }) + .then(data => { + db = { weeks: [], currentWeek: null, globalPeople: {} }; + db.globalPeople[app.department] = parseGlobalPeopleCsv(data.people); + const parsed = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) })); + parsed.forEach(s => { + const w = parseSessionCsv(s.filename, s.rows); + if(w) db.weeks.push(w); + }); + db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); + app.week = db.weeks.find(w => w.label === app.weekLabel); + if(!app.week){ + document.getElementById('planTitle').textContent = 'Nicht gefunden'; + document.getElementById('planSubtitle').textContent = 'Sitzung "' + esc(app.weekLabel) + '" konnte nicht geladen werden.'; + return; + } + app.project = app.week.projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName); + if(!app.project){ + document.getElementById('planTitle').textContent = 'Nicht gefunden'; + document.getElementById('planSubtitle').textContent = 'Projekt nicht in dieser Sitzung vorhanden.'; + return; + } + renderPlanPage(); + }) + .catch(err => { + document.getElementById('planTitle').textContent = 'Fehler beim Laden'; + document.getElementById('planSubtitle').textContent = err.message; + }); + } + + // ensurePlanLocal: stellt sicher, dass das Plan-Objekt des Projekts die + // erwartete Struktur hat (inkl. Migration alter Einzel-Abhängigkeiten). + function ensurePlanLocal(){ + if(!app.project.plan) app.project.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 }; + if(!Array.isArray(app.project.plan.todos)) app.project.plan.todos = []; + // Migration alter Einzel-Abhängigkeiten zu Array + app.project.plan.todos.forEach(t => { + if(!Array.isArray(t.dependsOn)){ + t.dependsOn = t.dependsOn ? [t.dependsOn] : []; + } + if(!Array.isArray(t.assignees)) t.assignees = []; + if(t.progress === undefined) t.progress = t.done ? 100 : 0; + }); + return app.project.plan; + } + + // isLocked: liefert true, wenn die aktuelle Woche gesperrt ist (dann sind + // Bearbeitungen im Plan gesperrt). Rückgabe: Boolescher Wert. + function isLocked(){ + return !!app.week.locked; + } + + // renderPlanPage: setzt Titel und Untertitel der Seite aus Projekt und Woche, + // blendet den Meilenstein-Button bei gesperrter Woche aus und startet die + // GANTT-Anzeige über fitPlanGanttRange(). + function renderPlanPage(){ + const p = app.project; + const plan = ensurePlanLocal(); + document.getElementById('planTitle').textContent = (p.nr ? p.nr + ' – ' : '') + p.name; + document.getElementById('planSubtitle').textContent = app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : ''); + + // GANTT inkl. TODOs + const addMilestoneBtn = document.getElementById('addMilestoneBtn'); + const locked = isLocked(); + if(addMilestoneBtn) addMilestoneBtn.style.display = locked ? 'none' : ''; + + fitPlanGanttRange(); + } + + // Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine) + function removeDependencyRefs(plan, deletedId){ + (plan.todos || []).forEach(t => { + if(Array.isArray(t.dependsOn)) t.dependsOn = t.dependsOn.filter(id => id !== deletedId); + }); + (plan.milestones || []).forEach(m => { + if(Array.isArray(m.dependsOn)) m.dependsOn = m.dependsOn.filter(id => id !== deletedId); + }); + } + + // Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen Liste + function renderProjectAssignees(){ + const assignees = projectAssignees(app.project, app.week); + if(assignees.length === 0) return 'Keine Zuordnungen im Projektboard.'; + return assignees.map(a => { + const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : ''; + return `${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}`; + }).join(''); + } + + // Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben + function savePlan(){ + if(!app.week || !app.project) return; + const filename = filenameForWeek(app.week.label); + const csv = weekToCsv(app.week); + sendCsvToServer(filename, csv).catch(err => console.error('Speichern fehlgeschlagen:', err)); + } + + // saveGlobalPeople: schreibt die globale Personenliste der Abteilung als + // _people.csv auf den Server, aber nur wenn sie nicht leer ist. + function saveGlobalPeople(){ + const people = db.globalPeople[app.department] || []; + if(people.length > 0){ + const peopleCsv = globalPeopleToCsv(people); + sendCsvToServer('_people.csv', peopleCsv).catch(err => console.error('Speichern Personen fehlgeschlagen:', err)); + } + } + + // Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt) + function findGlobalPerson(personId){ + return (db.globalPeople[app.department] || []).find(p => p.id === personId) || null; + } + + // countPersonAbsences: zählt die Abwesenheiten einer Person anhand der + // globalen Liste. Rückgabe: Anzahl (0 bei fehlender Person). + function countPersonAbsences(personId){ + const p = findGlobalPerson(personId); + return p && Array.isArray(p.absences) ? p.absences.length : 0; + } + + // Toast-Benachrichtigung + function showToast(message){ + let toast = document.getElementById('planToast'); + if(!toast){ + toast = document.createElement('div'); + toast.id = 'planToast'; + toast.className = 'plan-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('show'); + // Vorherigen Timer abbrechen, sonst verschwindet ein neuer Toast zu früh + if(app.toastTimer) clearTimeout(app.toastTimer); + app.toastTimer = setTimeout(() => { toast.classList.remove('show'); app.toastTimer = null; }, 2500); + } + + // Export der Hilfsfunktionen auf PlanApp, damit die anderen plan-Module + // (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können. + app.ensurePlanLocal = ensurePlanLocal; + app.isLocked = isLocked; + app.removeDependencyRefs = removeDependencyRefs; + app.renderProjectAssignees = renderProjectAssignees; + app.savePlan = savePlan; + app.saveGlobalPeople = saveGlobalPeople; + app.findGlobalPerson = findGlobalPerson; + app.countPersonAbsences = countPersonAbsences; + app.showToast = showToast; + + // Seitenstart: erst initialisieren, wenn das DOM geladen ist + document.addEventListener('DOMContentLoaded', init); +})(); \ No newline at end of file diff --git a/public/js/projects.js b/public/js/projects.js new file mode 100644 index 0000000..8d494c5 --- /dev/null +++ b/public/js/projects.js @@ -0,0 +1,173 @@ +// ----------------------------- +// Projektübersicht — Projekte (Listen-Ansicht) +// ----------------------------- +// Diese Datei rendert den Projekte-Tab (Tab "Projekte" in `Test.html`) als +// sortierbare Listenansicht: Projekte werden nach Gruppe ("Aktive Projekte", +// "Kleine Projekte" usw.) gruppiert, inline bearbeitbar und per +// Drag-and-Drop umsortiert. Sie wird von `Test.html` nach `js/people.js` +// (vor `js/load.js`) geladen und stellt globale Funktionen wie +// `renderProjects()`, `editProjectField()`, `editProjectActiveLP()` sowie die +// Drag-and-Drop-Handler und die Sortier-Hilfe `getDragAfterElement()` bereit. +// Letztere wird auch von `js/people.js` (Personen-Sortierung) genutzt; +// `renderBoard()` aus `js/board.js` wird nach Änderungen aktualisiert. + +// delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der +// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter +// Sitzung abgebrochen. +function delProjectFromList(id){ + if(guardWeekLocked()) return; + if(!confirm("Projekt aus dieser Sitzung löschen?")) return; + const w = currentWeek(); + w.projects = w.projects.filter(p=>p.id!==id); + save(); renderProjects(); renderBoard(); +} + +// editProjectField: ändert ein Feld eines Projekts (z.B. Name oder Gruppe). +// Parameter: `id` (Projekt-ID), `field` (Feldname), `val` (neuer Wert). +// Keine Rückgabe. +function editProjectField(id,field,val){ + if(guardWeekLocked()) return; + const p = currentWeek().projects.find(x=>x.id===id); + if(p){ p[field]=val; save(); renderProjects(); renderBoard(); } +} + +// editProjectActiveLP: setzt bzw. hebt die amtierende Leistungsphase eines +// Projekts auf (`p.aktuelleLP`). Parameter: `id` (Projekt-ID), `value` +// (LPH-Name oder leer). Aktualisiert Projekte, Board und Auslastung. +function editProjectActiveLP(id, value){ + if(guardWeekLocked()) return; + const p = currentWeek().projects.find(x=>x.id===id); + if(!p) return; + if(value) p.aktuelleLP = [value]; + else delete p.aktuelleLP; + save(); renderProjects(); renderBoard(); renderLoad(); +} + +// renderProjects: baut die HTML-Liste der Projekte auf, gruppiert nach `group` +// und sortiert je Gruppe nach `order`. Jedes Projekt erhält Felder für Nr., +// Name, Gruppe, aktive LP sowie Drag-and-Drop-Handler. Keine Parameter, keine +// Rückgabe. +function renderProjects(){ + const w = currentWeek(); + if(!w) return; + let html=""; + const groups = {}; + w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); }); + Object.keys(groups).forEach(g=>{ + groups[g].sort((a,b)=>(a.order||0)-(b.order||0)); + }); + + sortGroups(Object.keys(groups)).forEach(g=>{ + html += `
${esc(g)}
`; + html += `
`; + groups[g].forEach(p=>{ + html += `
+ ⋮⋮ + + + + + + +
`; + }); + html += `
`; + if(groups[g].length===0) html+=`
Keine Projekte.
`; + }); + document.getElementById('projectsList').innerHTML=html; +} + +// Drag-and-Drop Handling für Projekte + +// draggedProject: die während eines Drag-and-Drop-Vorgangs gezogene +// Projekt-Zeile (DOM-Element) bzw. null nach dem Loslassen. +let draggedProject = null; + +// handleDragStart: startet das Ziehen einer Projekt-Zeile, merkt sich das +// Element in `draggedProject` und verringert seine Deckkraft. +function handleDragStart(event){ + draggedProject = event.currentTarget; + event.dataTransfer.effectAllowed = 'move'; + event.currentTarget.style.opacity = '0.5'; +} + +// handleDragOver: erlaubt das Ablegen während des Überfahrens eines Ziels +// (verhindert Standardverhalten, setzt `dropEffect='move'`). +function handleDragOver(event){ + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; +} + +// handleDrop: legt die gezogene Projekt-Zeile an der Position ab, die +// `getDragAfterElement` relativ zur Mausposition ermittelt, und speichert +// die neue Reihenfolge über `reorderProjects()`. +function handleDrop(event){ + event.preventDefault(); + if(!draggedProject || draggedProject === event.currentTarget) return; + const sortable = event.currentTarget.parentElement; + const afterElement = getDragAfterElement(sortable, event.clientY); + if(afterElement == null){ + sortable.appendChild(draggedProject); + } else { + sortable.insertBefore(draggedProject, afterElement); + } + reorderProjects(); +} + +// handleDragEnd: stellt die Deckkraft der gezogenen Zeile wieder her und +// setzt `draggedProject` zurück (Ende des Drag-Vorgangs). +function handleDragEnd(event){ + event.currentTarget.style.opacity = '1'; + draggedProject = null; +} + +// getDragAfterElement: ermittelt das Element in `container`, vor dem die +// gezogene Zeile bei Mausposition `y` abgelegt werden soll (Suche nach dem +// Element mit der kleinsten negativen Distanz zur Mitte). Rückgabe: +// Ziel-Element oder undefined (→ ans Ende anfügen). Wird auch von +// `js/people.js` für die Personen-Sortierung verwendet. +function getDragAfterElement(container, y){ + const draggableElements = [...container.querySelectorAll('.person-list-item:not([style*="opacity"])')]; + return draggableElements.reduce((closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if(offset < 0 && offset > closest.offset){ + return { offset: offset, element: child }; + } else { + return closest; + } + }, { offset: Number.NEGATIVE_INFINITY }).element; +} + +// reorderProjects: übernimmt die im DOM vorgefundene Reihenfolge der +// Projekt-Zeilen in die Daten (je Gruppe `order`-Werte) und aktualisiert +// Gruppe/Zuordnung pro Projekt. Keine Parameter, keine Rückgabe; bei +// gesperrter Sitzung abgebrochen. +function reorderProjects(){ + if(guardWeekLocked()) return; + const w = currentWeek(); + const sortables = document.querySelectorAll('.projects-sortable'); + sortables.forEach(sortable => { + const group = sortable.dataset.group; + const items = sortable.querySelectorAll('[data-project-id]'); + items.forEach((item, index) => { + const projectId = item.dataset.projectId; + const project = w.projects.find(p => p.id === projectId); + if(project){ + project.order = index; + project.group = group; + } + }); + }); + save(); + renderProjects(); + renderBoard(); +} \ No newline at end of file diff --git a/public/js/server.js b/public/js/server.js new file mode 100644 index 0000000..5359d1c --- /dev/null +++ b/public/js/server.js @@ -0,0 +1,54 @@ +// ----------------------------- +// Projektübersicht — Server-Kommunikation (CSV-API) +// ----------------------------- +// Diese Datei kapselt die HTTP-Kommunikation mit dem Express-Server (siehe +// server.js im Projekt-Root) und wird nach js/csv.js von beiden +// HTML-Einstiegspunkten geladen. Sie stellt baseUrl, blobToDataUrl, +// sendCsvToServer und deleteCsvFromServer bereit. Genutzt wird sie von +// js/store.js (syncAllToServer, Speichern/Löschen aller Wochen), +// js/weeks.js (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt +// direkt die aktuelle Woche über sendCsvToServer). + +// baseUrl: dynamische Server-URL, damit die App auch von anderen Geraeten im Netzwerk erreichbar ist. +// Fallback auf localhost:3003, wenn die Seite als lokale Datei geladen wurde. +// Parameter: keine. Rückgabe: String mit Basis-URL. +function baseUrl(){ + const origin = window.location.origin; + if(typeof origin === 'string' && /^https?:\/\//.test(origin)){ + return origin; + } + return 'http://localhost:3003'; +} + +// blobToDataUrl: liest einen Blob als Data-URL ein (z. B. für Bilddaten) +// Parameter: blob (Blob). Rückgabe: Promise, das die Data-URL auflöst. +function blobToDataUrl(blob){ + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +// sendCsvToServer: POSTet eine Speicherwoche im CSV-Format an /save-csv +// Parameter: filename (String), csvContent (String). Rückgabe: Promise mit Server-Antwort. +function sendCsvToServer(filename, csvContent){ + if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar')); + return fetch(baseUrl() + '/save-csv',{ + method:'POST', + headers:{'Content-Type':'application/json; charset=utf-8'}, + body:JSON.stringify({filename,csv:csvContent,department:currentDepartment}) + }).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }); +} + +// deleteCsvFromServer: fordert das Löschen einer Speicherwoche über POST /delete-csv an +// Parameter: filename (String). Rückgabe: Promise mit Server-Antwort. +function deleteCsvFromServer(filename){ + if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar')); + return fetch(baseUrl() + '/delete-csv', { + method: 'POST', + headers: {'Content-Type':'application/json; charset=utf-8'}, + body: JSON.stringify({ filename, department: currentDepartment }) + }).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }); +} \ No newline at end of file diff --git a/public/js/showmode.js b/public/js/showmode.js new file mode 100644 index 0000000..3058495 --- /dev/null +++ b/public/js/showmode.js @@ -0,0 +1,207 @@ +// ----------------------------- +// Projektübersicht — Show Mode +// ----------------------------- +// Präsentationsmodus für die Hauptseite (nur von Test.html geladen, nach +// js/gantt.js, vor js/tabs-auth.js). Er durchläuft automatisch die in +// SHOW_MODE_SEQUENCE hinterlegten Abteilungs-/Ansichts-Kombinationen (Board +// und Auslastung) und zeigt sie in einem Vollbild-Overlay. Genutzt werden +// showTab() aus js/tabs-auth.js, switchDepartment() aus js/store.js, +// setHeaderCollapsed() aus js/ui.js sowie renderBoard()/renderLoad() aus +// js/board.js/js/load.js. Der gesamte Lauf-Zustand liegt in den +// Modulvariablen am Dateikopf. + +// showModeActive: true, solange der Show Mode läuft +let showModeActive = false; +// showModeTimer: Timer-Handle für den Wechsel zur nächsten Folie +let showModeTimer = null; +// showModeResumeTimer: Timer-Handle für die automatische Fortsetzung nach einer Pause +let showModeResumeTimer = null; +// showModePaused: true, wenn der Durchlauf gerade pausiert ist +let showModePaused = false; +// showModeOriginal: Abteilung und Tab vor dem Start, wird beim Beenden wiederhergestellt +let showModeOriginal = { department: DEFAULT_DEPARTMENT, tab: DEFAULT_TAB }; +const SHOW_MODE_SLIDE_DURATION = 10000; // 10 Sekunden pro Slide +const SHOW_MODE_PAUSE_DURATION = 60000; // 1 Minute Pause +// SHOW_MODE_SEQUENCE: Reihenfolge der Slides (Abteilung + Ansicht), die +// zyklisch durchlaufen wird +const SHOW_MODE_SEQUENCE = [ + { dept: 'ELT', view: 'board' }, + { dept: 'ELT', view: 'load' }, + { dept: 'HLS', view: 'board' }, + { dept: 'HLS', view: 'load' }, + { dept: 'ARCH', view: 'board' }, + { dept: 'ARCH', view: 'load' } +]; +// showModeIndex: Position innerhalb der SHOW_MODE_SEQUENCE +let showModeIndex = 0; + +// startShowMode: startet den Präsentationsmodus (falls nicht schon aktiv): +// merkt sich Abteilung/Tab, zeigt das Overlay, kollabiert das Menü und beginnt +// den Slide-Durchlauf. Kein Rückgabewert. +function startShowMode(){ + if(showModeActive) return; + showModeActive = true; + showModePaused = false; + showModeOriginal = { department: currentDepartment, tab: currentTab }; + showModeIndex = 0; + + const overlay = document.getElementById('showModeOverlay'); + if(overlay) overlay.style.display = 'flex'; + + // Menü schließen, falls offen + setHeaderCollapsed(true, false); + + showModeNextSlide(); +} + +// stopShowMode: beendet den Präsentationsmodus: stoppt alle Timer, verbirgt +// das Overlay und stellt Abteilung und Tab des ursprünglichen Zustands wieder +// her (renderAll danach). Kein Rückgabewert. +function stopShowMode(){ + showModeActive = false; + showModePaused = false; + clearTimeout(showModeTimer); + clearTimeout(showModeResumeTimer); + + const overlay = document.getElementById('showModeOverlay'); + if(overlay) overlay.style.display = 'none'; + + // Ursprünglichen Zustand wiederherstellen + currentTab = showModeOriginal.tab; + if(currentDepartment !== showModeOriginal.department){ + switchDepartment(showModeOriginal.department); + } else { + showTab(currentTab, true); + renderAll(); + } +} + +// pauseShowMode: pausiert den Durchlauf (Anzeige "Pausiert") und startet die +// automatische Fortsetzung nach SHOW_MODE_PAUSE_DURATION; bei erneutem Aufruf +// im pausierten Zustand wird sofort fortgesetzt. Kein Rückgabewert. +function pauseShowMode(){ + if(!showModeActive) return; + if(showModePaused){ + // bereits pausiert -> sofort fortsetzen + resumeShowMode(); + return; + } + showModePaused = true; + clearTimeout(showModeTimer); + clearTimeout(showModeResumeTimer); + const pausedEl = document.getElementById('showModePaused'); + if(pausedEl) pausedEl.style.display = 'block'; + showModeResumeTimer = setTimeout(() => { + if(showModeActive && showModePaused) resumeShowMode(); + }, SHOW_MODE_PAUSE_DURATION); +} + +// resumeShowMode: setzt einen pausierten Durchlauf fort (Timer aufräumen, +// Hinweis ausblenden) und zeigt die nächste Folie. Kein Rückgabewert. +function resumeShowMode(){ + if(!showModeActive) return; + showModePaused = false; + clearTimeout(showModeResumeTimer); + const pausedEl = document.getElementById('showModePaused'); + if(pausedEl) pausedEl.style.display = 'none'; + showModeNextSlide(); +} + +// showModeNextSlide: stellt die nächste Folie der SHOW_MODE_SEQUENCE dar: +// setzt Titel und Ladehinweis, wechselt ggf. per switchDepartment die Abteilung +// (mit waitForLoadThenRender) und kopiert danach die aktuelle Ansicht ins +// Overlay. Kein Rückgabewert. +function showModeNextSlide(){ + if(!showModeActive || showModePaused) return; + + const slide = SHOW_MODE_SEQUENCE[showModeIndex % SHOW_MODE_SEQUENCE.length]; + showModeIndex++; + + const titleEl = document.getElementById('showModeTitle'); + if(titleEl){ + const deptLabel = DEPARTMENTS[slide.dept] ? DEPARTMENTS[slide.dept].label : slide.dept; + titleEl.textContent = `${deptLabel} – ${slide.view === 'load' ? 'Auslastung' : 'Projektboard'}`; + } + + // Ladehinweis anzeigen, damit der alte Inhalt nicht kurz sichtbar flackert + const content = document.getElementById('showModeContent'); + if(content){ + content.innerHTML = '
Lädt…
'; + } + + // Wechsle Department und View in der Hauptansicht, dann kopiere ins Overlay. + // Da switchDepartment asynchron lädt, müssen wir auf den Abschluss warten. + if(currentDepartment !== slide.dept){ + // Temporär currentTab setzen, damit nach dem Laden der richtige Tab aktiv ist. + currentTab = slide.view; + switchDepartment(slide.dept); + // Warte bis Daten geladen sind, dann rendern und kopieren + waitForLoadThenRender(slide.view); + } else { + showTab(slide.view, true); + renderAll(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + copyCurrentViewToShowMode(); + scheduleNextShowModeSlide(); + }); + }); + } +} + +// waitForLoadThenRender: wartet nach dem asynchronen Abteilungswechsel, bis +// Wochen geladen sind (Polling alle 200 ms, Abbruch nach 30 Versuchen), und +// rendert dann die gewünschte Ansicht, kopiert sie ins Show-Mode-Overlay und +// plant die nächste Folie. Parameter: view = 'board' oder 'load'. +function waitForLoadThenRender(view){ + let attempts = 0; + const check = setInterval(() => { + attempts++; + // Wenn Wochen geladen sind oder nach Seed gefallen ist + if(db.weeks.length > 0 || attempts > 30){ + clearInterval(check); + showTab(view, true); + renderAll(); + // Kurze Verzögerung, damit das DOM aktualisiert ist, bevor kopiert wird + requestAnimationFrame(() => { + requestAnimationFrame(() => { + copyCurrentViewToShowMode(); + scheduleNextShowModeSlide(); + }); + }); + } + }, 200); +} + +// scheduleNextShowModeSlide: plant den Wechsel zur nächsten Folie nach +// SHOW_MODE_SLIDE_DURATION (außer im pausierten/inaktiven Zustand). +function scheduleNextShowModeSlide(){ + if(!showModeActive || showModePaused) return; + clearTimeout(showModeTimer); + showModeTimer = setTimeout(() => { + showModeNextSlide(); + }, SHOW_MODE_SLIDE_DURATION); +} + +// copyCurrentViewToShowMode: kopiert das gerenderte HTML des aktuellen Tabs +// (Board oder Auslastung) in das Show-Mode-Overlay; in der Auslastungs-Ansicht +// werden dabei Titel und Toolbar ausgeblendet. Kein Rückgabewert. +function copyCurrentViewToShowMode(){ + const content = document.getElementById('showModeContent'); + const sourceId = currentTab === 'load' ? 'load' : 'board'; + const source = document.getElementById(sourceId); + if(content && source){ + content.innerHTML = source.innerHTML; + // Im Show Mode sollen in der Auslastung Überschrift und Steuerelemente ausgeblendet werden + if(currentTab === 'load'){ + content.querySelectorAll('.section-title, .toolbar').forEach(el => el.remove()); + } + } +} + +// ESC-Taste beendet den Show Mode (globaler Keydown-Listener). +document.addEventListener('keydown', function(e){ + if(showModeActive && e.key === 'Escape'){ + stopShowMode(); + } +}); \ No newline at end of file diff --git a/public/js/store.js b/public/js/store.js new file mode 100644 index 0000000..cb9d5f8 --- /dev/null +++ b/public/js/store.js @@ -0,0 +1,178 @@ +// ----------------------------- +// Projektübersicht — Daten laden, Speichern, Abteilung wechseln +// ----------------------------- +// Diese Datei bildet die Schnittstelle zum Server: Sie lädt alle Sitzungs-CSVs +// beim Start, speichert den kompletten Datenbestand (alle Wochen + globale +// Personenliste) und verwaltet den Wechsel zwischen Abteilungen (ELT/HLS/ARCH). +// Sie wird von `Test.html` nach `js/csv.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.js`, `js/people.js`, `js/projects.js` und `js/load.js`; das Speichern +// ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms debounced. + +// save: synchronisiert die aktuellen Daten als CSV-Dateien zum Server. +// Jede Sitzung wird als eigene CSV-Datei gespeichert. Personen und Rollen werden +// in der Sitzungscsv selbst abgelegt. +function save(){ + scheduleSessionSync(); +} + +// 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 die gesamte Datenbank als CSV-Dateien zum Server. +// Zuerst wird die globale Personenliste (`_people.csv`) gespeichert, danach jede +// Sitzung einzeln über `filenameForWeek()`/`weekToCsv()` und `sendCsvToServer()`. +// Gibt nichts zurück; Fehler werden nur per console.error protokolliert. +function syncAllToServer(){ + // Zuerst globale Personenliste speichern + saveGlobalPeopleToServer(); + // Dann jede Sitzung speichern + db.weeks.forEach(w => { + const filename = filenameForWeek(w.label); + const csv = weekToCsv(w); + sendCsvToServer(filename, csv) + .then(res => { if(res && res.ok) console.log('Sitzung gespeichert:', filename); }) + .catch(err => { console.error('Sitzung konnte nicht gespeichert werden:', filename, err.message || err); }); + }); +} + +// saveGlobalPeopleToServer: serialisiert die globale Personenliste der aktuellen +// Abteilung per `globalPeopleToCsv()` und speichert sie als `_people.csv`. +// Leere Personenlisten werden übersprungen (kein Schreibvorgang). +function saveGlobalPeopleToServer(){ + const people = db.globalPeople[currentDepartment] || []; + if(people.length === 0) return; + const csv = globalPeopleToCsv(people); + sendCsvToServer('_people.csv', csv) + .then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); }) + .catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); }); +} + +// 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; + currentDepartment = dept; + saveUiState(); + + // 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 CSV-Dateien der aktuellen Abteilung über `/load-all` vom +// Server ein, parst Personen und Sitzungen (mit `parseSessionCsv()`) 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(){ + if(!window.fetch){ + alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.'); + seed(); return; + } + 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 => { + // Globale Personenliste laden + db.globalPeople[currentDepartment] = parseGlobalPeopleCsv(data.people); + + const parsedSessions = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) })); + + db.weeks = []; + parsedSessions.forEach(s => { + const w = parseSessionCsv(s.filename, s.rows); + if(w) db.weeks.push(w); + }); + + if(db.weeks.length === 0){ + console.warn('Server lieferte 0 Sitzungsdateien. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob der Ordner sessions/ mitkopiert wurde.'); + 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 dürfen nicht ohne Passwort angezeigt werden, auch wenn + // sie im UI-State gespeichert waren. + if(currentTab === 'projects' || currentTab === 'people') currentTab = DEFAULT_TAB; + renderAll(); + showTab(currentTab, true); + }) + .catch(err => { + 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}; +} \ No newline at end of file diff --git a/public/js/tabs-auth.js b/public/js/tabs-auth.js new file mode 100644 index 0000000..1d04c06 --- /dev/null +++ b/public/js/tabs-auth.js @@ -0,0 +1,68 @@ +// ----------------------------- +// Projektübersicht — Tabs (Navigation) & Passwort-Schutz +// ----------------------------- +// Modul für die Tab-Navigation und den Passwortschutz der Hauptseite (nur von +// Test.html geladen, nach js/showmode.js, vor js/pdf.js). showTab() wechselt +// zwischen Board, Auslastung, Projekte und Projektbeteiligte und fordert für +// die geschützten Tabs 'projects'/'people' das Passwort an; confirmPassword() +// prüft den SHA256-Hash gegen PROTECTED_PASSWORD_HASH (js/globals.js). Die +// ausstehende Aktion wird in requestedAction (js/globals.js) gemerkt – davon +// profitieren auch die Passwort-Gates in js/weeks.js (Löschen/Entsperren von +// Sitzungen). Gerendert werden beim Tab-Wechsel renderBoard()/renderLoad()/ +// renderProjects()/renderPeople() aus js/board.js, js/load.js, js/projects.js, +// js/people.js. + +// showTab: wechselt zwischen Ansichten; für sensible Tabs wird ein Passwort verlangt +function showTab(t, skipAuth = false){ + // Für die Tabs 'projects' und 'people' ist ein Passwortschutz vorgesehen + if((t==='projects' || t==='people') && !skipAuth && !isAuthenticated){ + requestedAction = { type: 'tab', tab: t }; + document.getElementById('passwordModal').style.display='flex'; + document.getElementById('passwordInput').value=''; + document.getElementById('passwordInput').focus(); + return; + } + currentTab = t; + saveUiState(); + + ['board','projects','load','people'].forEach(x=>{ + const el = document.getElementById(x); + if(el) el.style.display=(x===t?'block':'none'); + }); + document.querySelectorAll('.tab').forEach(el=>el.classList.toggle('active',el.dataset.tab===t)); + if(t==='board' && currentWeek()) renderBoard(); + if(t==='projects') renderProjects(); + if(t==='load') renderLoad(); + if(t==='people') renderPeople(); +} + +// ---------- Passwort-Schutz Implementierung ---------- + +// confirmPassword: prüft das eingegebene Passwort gegen den gespeicherten Hash +function confirmPassword(){ + const pwd = document.getElementById('passwordInput').value; + if(!pwd) { alert("Bitte Passwort eingeben!"); return; } + + const pwdHash = CryptoJS.SHA256(pwd).toString(); + + if(pwdHash === PROTECTED_PASSWORD_HASH){ + const action = requestedAction; + requestedAction = null; + document.getElementById('passwordModal').style.display='none'; + setTimeout(() => { + if(action?.type==='tab') showTab(action.tab, true); + else if(action?.type==='deleteWeek') deleteWeek(true); + else if(action?.type==='unlockWeek') unlockCurrentWeek(true); + }, 100); + } else { + alert("Falsches Passwort!"); + document.getElementById('passwordInput').value=''; + document.getElementById('passwordInput').focus(); + } +} +// closePasswordModal: schließt das Passwort-Modal ohne Ausführung und +// verwirft die gemerkte Aktion (requestedAction = null). +function closePasswordModal(){ + document.getElementById('passwordModal').style.display='none'; + requestedAction = null; +} \ No newline at end of file diff --git a/public/js/ui.js b/public/js/ui.js new file mode 100644 index 0000000..a317f2d --- /dev/null +++ b/public/js/ui.js @@ -0,0 +1,165 @@ +// ----------------------------- +// Projektübersicht — UI-Zustand (LocalStorage, Theme, Header) +// ----------------------------- +// Diese Datei verwaltet den reinen UI-Zustand: gespeicherte Abteilung in +// LocalStorage, den kollabierbaren Header sowie das Light/Dark-Theme. Sie wird +// nach js/globals.js von beiden HTML-Einstiegspunkten geladen (Test.html und +// project-plan.html). Die bereitgestellten Funktionen werden von js/init.js +// beim Start sowie von js/tabs-auth.js (Tab-Wechsel, showTab) und js/store.js +// (Abteilungswechsel, switchDepartment) aufgerufen; im Theme-Umschalter der +// Seite werden toggleTheme/applyTheme direkt eingebunden. + +// saveUiState: speichert Abteilung und aktiven Tab in den LocalStorage +// Parameter: keine. Rückgabe: keine. +function saveUiState(){ + try { + const state = { department: currentDepartment, tab: currentTab }; + window.localStorage.setItem(STORAGE, JSON.stringify(state)); + } catch (err) { + console.warn('UI-State kann nicht gespeichert werden:', err); + } +} + +// loadUiState: stellt die zuletzt gewählte Abteilung aus dem LocalStorage wieder her +// Parameter: keine. Rückgabe: keine (setzt currentDepartment aus js/globals.js). +function loadUiState(){ + try { + const state = JSON.parse(window.localStorage.getItem(STORAGE) || '{}'); + if(state.department && DEPARTMENTS[state.department]) currentDepartment = state.department; + // Tab wird nicht aus dem localStorage wiederhergestellt, damit nach dem + // Neuladen immer das Projektboard angezeigt wird. + } catch (err) { + console.warn('UI-State kann nicht geladen werden:', err); + } +} + +// ---------- Kollabierbarer Header ---------- + +// HEADER_COLLAPSED_KEY: LocalStorage-Key für den Einklapp-Zustand des Header-Menüs +const HEADER_COLLAPSED_KEY = 'projuebersicht-header-collapsed'; + +// loadHeaderState: liest den gespeicherten Header-Zustand aus dem LocalStorage +// Parameter: keine. Rückgabe: keine (wendet den Zustand per setHeaderCollapsed an). +function loadHeaderState(){ + let collapsed = true; // Default: Menü ist beim ersten Start geschlossen + try { + const stored = window.localStorage.getItem(HEADER_COLLAPSED_KEY); + if(stored !== null){ + collapsed = stored === 'true'; + } + } catch (err) {} + setHeaderCollapsed(collapsed, false); +} + +// saveHeaderState: speichert den Einklapp-Zustand des Headers im LocalStorage +// Parameter: collapsed (Boolean). Rückgabe: keine. +function saveHeaderState(collapsed){ + try { + window.localStorage.setItem(HEADER_COLLAPSED_KEY, collapsed ? 'true' : 'false'); + } catch (err) {} +} + +// setHeaderCollapsed: klappt das Header-Menü auf/zu und aktualisiert Icon und aria-Attribute +// Parameter: collapsed (Boolean), persist (Boolean, ob in LocalStorage gespeichert wird). Rückgabe: keine. +function setHeaderCollapsed(collapsed, persist){ + const menu = document.getElementById('headerMenu'); + const btn = document.querySelector('.header-bar .header-toggle'); + const icon = document.querySelector('.header-bar .header-toggle-icon'); + if(!menu) return; + if(collapsed){ + menu.classList.remove('open'); + menu.setAttribute('aria-hidden', 'true'); + if(icon) icon.textContent = '☰'; + if(btn) btn.setAttribute('aria-expanded', 'false'); + } else { + menu.classList.add('open'); + menu.setAttribute('aria-hidden', 'false'); + if(icon) icon.textContent = '✕'; + if(btn) btn.setAttribute('aria-expanded', 'true'); + } + if(persist) saveHeaderState(collapsed); +} + +// toggleHeader: klappt das Header-Menü zwischen offen und geschlossen um +// Parameter: keine. Rückgabe: keine (persistiert den neuen Zustand). +function toggleHeader(){ + const menu = document.getElementById('headerMenu'); + const isOpen = menu && menu.classList.contains('open'); + setHeaderCollapsed(isOpen, true); +} + +// ---------- Theme (Light/Dark) ---------- + +// applyThemeIcon: setzt das Sonne-/Mond-Icon passend zum effektiven Theme +// Parameter: theme (String 'dark'/'light'). Rückgabe: keine. +function applyThemeIcon(theme){ + const icon = document.getElementById('themeIcon'); + if(!icon) return; + icon.textContent = theme === 'dark' ? '☀️' : '🌙'; +} + +// loadTheme: liest das gespeicherte Theme aus dem LocalStorage und wendet es an +// Parameter: keine. Rückgabe: keine. +function loadTheme(){ + let theme = 'auto'; + try { + theme = window.localStorage.getItem('projuebersicht-theme') || 'auto'; + } catch (err) { + console.warn('Theme kann nicht geladen werden:', err); + } + applyTheme(theme, false); +} + +// saveTheme: speichert das gewählte Theme im LocalStorage +// Parameter: theme (String 'auto'/'light'/'dark'). Rückgabe: keine. +function saveTheme(theme){ + try { + window.localStorage.setItem('projuebersicht-theme', theme); + } catch (err) { + console.warn('Theme kann nicht gespeichert werden:', err); + } +} + +// effectiveTheme: ermittelt das tatsächlich verwendete Theme ('light'/'dark') +// Parameter: theme (String 'auto'/'light'/'dark'). Rückgabe: 'dark' oder 'light' +// (bei 'auto' anhand der Systemeinstellung). +function effectiveTheme(theme){ + if(theme === 'dark') return 'dark'; + if(theme === 'light') return 'light'; + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +// applyTheme: setzt das data-theme-Attribut am html-Element und aktualisiert das Icon +// Parameter: theme (String), persist (Boolean, ob in LocalStorage gespeichert wird). Rückgabe: keine. +function applyTheme(theme, persist){ + const html = document.documentElement; + const eff = effectiveTheme(theme); + if(theme === 'dark') html.setAttribute('data-theme', 'dark'); + else if(theme === 'light') html.setAttribute('data-theme', 'light'); + else html.removeAttribute('data-theme'); + applyThemeIcon(eff); + if(persist) saveTheme(theme); +} + +// toggleTheme: wechselt zwischen hellem und dunklem Theme und speichert die Auswahl +// Parameter: keine. Rückgabe: keine. +function toggleTheme(){ + const html = document.documentElement; + const currentAttr = html.getAttribute('data-theme'); + const currentTheme = currentAttr || 'auto'; + const currentEffective = effectiveTheme(currentTheme); + const next = currentEffective === 'dark' ? 'light' : 'dark'; + applyTheme(next, true); +} + +// Beim Wechsel der System-Farbschema-Einstellung wird das Theme im +// "Auto"-Modus sofort neu angewendet (kein Neuladen nötig). +if(window.matchMedia){ + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(){ + let theme = 'auto'; + try { + theme = window.localStorage.getItem('projuebersicht-theme') || 'auto'; + } catch (err) {} + applyTheme(theme, false); + }); +} \ No newline at end of file diff --git a/public/js/utils.js b/public/js/utils.js new file mode 100644 index 0000000..92205fa --- /dev/null +++ b/public/js/utils.js @@ -0,0 +1,122 @@ +// ----------------------------- +// Projektübersicht — allgemeine Helferfunktionen +// ----------------------------- +// Diese Datei bündelt kleine, allgemein verwendbare Hilfsfunktionen (Escape-, +// ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden +// HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen +// ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen, +// u. a. js/csv.js, js/board.js, js/load.js sowie den Plan-Modulen unter js/plan/* +// (z. B. für ISO-Datumsformate und Werktage) und von js/gantt.js. + +// departmentParam: gibt ein Query-String-Fragment fuer die aktuelle Abteilung zurueck. +// Parameter: keine. Rückgabe: String wie '?department=ELT'. +function departmentParam(){ + return '?department=' + encodeURIComponent(currentDepartment); +} + +// esc: einfache Escape-Funktion für Ausgabe in HTML +// Parameter: s (String). Rückgabe: HTML-sicherer String (&, <, >, " ersetzt). +function esc(s){ return (s||"").replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + +// uid: generiert eine einfache, ausreichend eindeutige ID +// Parameter: keine. Rückgabe: String aus Zeitstempel und Zufallsteil. +function uid(){ return Date.now().toString(36)+Math.random().toString(36).slice(2,7); } + +// assignKey: Schlüssel für Projektzuordnungen (Person + Rolle getrennt) +// Parameter: personId (String), role (String). Rückgabe: kombinierter Schlüssel 'personId|role'. +function assignKey(personId, role){ return personId + '|' + role; } + +// sortGroups: sortiert Gruppen-Namen nach GROUP_ORDER, unbekannte ans Ende +// Parameter: arr (Array von Gruppennamen, wird in-place sortiert). Rückgabe: dasselbe Array. +function sortGroups(arr){ + return arr.sort((a,b)=>{ + const ia=GROUP_ORDER.indexOf(a), ib=GROUP_ORDER.indexOf(b); + return (ia===-1?999:ia)-(ib===-1?999:ib); + }); +} + +// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und +// Zuordnungen auch nach einem Neuladen stabil bleiben. +// Parameter: name (String). Rückgabe: String wie 'p_ab'. +function personIdForName(name){ + return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_'); +} + +// formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um +// Parameter: dateString (String). Rückgabe: formatierter String oder leer. +function formatWeekDate(dateString){ + if(!dateString) return ''; + const parts = dateString.split('-'); + if(parts.length!==3) return dateString; + return `${parts[2]}.${parts[1]}.${parts[0]}`; +} + +// ---------- Datumshilfsfunktionen (GANTT) ---------- + +// parseDateISO: parst einen ISO-Datumsstring (YYYY-MM-DD) in ein Date-Objekt (lokal) +// Parameter: d (String). Rückgabe: Date-Objekt oder null bei ungültigem Format. +function parseDateISO(d){ + if(!d) return null; + const m = String(d).match(/^(\d{4})-(\d{2})-(\d{2})$/); + if(!m) return null; + const date = new Date(parseInt(m[1]), parseInt(m[2])-1, parseInt(m[3])); + if(isNaN(date.getTime())) return null; + return date; +} + +// formatDateISO: formatiert ein Date-Objekt als ISO-Datum (YYYY-MM-DD) +// Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum. +function formatDateISO(d){ + if(!d || isNaN(d.getTime())) return ''; + const y = d.getFullYear(); + const m = String(d.getMonth()+1).padStart(2,'0'); + const day = String(d.getDate()).padStart(2,'0'); + return `${y}-${m}-${day}`; +} + +// addDays: addiert n Tage zu einem Datum (ohne das Original zu verändern) +// Parameter: d (Date), n (Anzahl Tage). Rückgabe: neues Date-Objekt. +function addDays(d, n){ + const res = new Date(d.getTime()); + res.setDate(res.getDate() + n); + return res; +} + +// daysBetween: Anzahl der Kalendertage zwischen zwei Daten +// Parameter: a, b (Date). Rückgabe: ganzzahlige Tagesdifferenz (b - a). +function daysBetween(a, b){ + const ms = b.getTime() - a.getTime(); + return Math.round(ms / (1000 * 60 * 60 * 24)); +} + +// workdaysBetween: Anzahl der Werktage (Mo–Fr) im Zeitraum inkl. Start- und Endtag. +// Wochenendtage (Sa/So) werden nicht mitgezählt – z.B. für die Auslastung. +// Parameter: a, b (Date). Rückgabe: Anzahl der Werktage oder 0 bei ungültigem Zeitraum. +function workdaysBetween(a, b){ + if(!a || !b || b < a) return 0; + let count = 0; + let current = new Date(a.getTime()); + while(current <= b){ + const day = current.getDay(); + if(day !== 0 && day !== 6) count++; + current = addDays(current, 1); + } + return count; +} + +// formatDateDE: formatiert ein Date-Objekt als deutsches Datum (DD.MM.YYYY) +// Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum. +function formatDateDE(d){ + if(!d || isNaN(d.getTime())) return ''; + return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`; +} + +// ISO-Kalenderwoche nach https://en.wikipedia.org/wiki/ISO_week_date +// Parameter: d (Date). Rückgabe: Kalenderwochennummer (1-53). +function getWeekNumber(d){ + const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const dayNum = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + return Math.ceil((((date - yearStart) / 86400000) + 1) / 7); +} \ No newline at end of file diff --git a/public/js/weeks.js b/public/js/weeks.js new file mode 100644 index 0000000..fa21493 --- /dev/null +++ b/public/js/weeks.js @@ -0,0 +1,156 @@ +// ----------------------------- +// Projektübersicht — Sitzungs-/Wochen-Verwaltung +// ----------------------------- +// Diese Datei verwaltet die Sitzungen (Wochen): Auswahl, Anzeige des Datums, +// Erzeugung neuer Sitzungen, Duplizieren, Sperren (Auto-Lock) und Löschen. +// Sie wird von `Test.html` nach `js/store.js` geladen und stellt globale +// Funktionen wie `currentWeek()`, `switchWeek()`, `newWeek()`, `cloneWeek()`, +// `applyAutoLock()`, `guardWeekLocked()` und `deleteWeek()` bereit. Genutzt wird +// sie u.a. von `js/board.js` (`renderBoard`, `isWeekLocked`), `js/store.js` +// (`load`) und `js/tabs-auth.js` (Passwort-Aktionen für Entsperren/Löschen). + +// refreshWeekSelect: aktualisiert das Dropdown `weekSelect` mit allen Sitzungen +// der aktuellen Abteilung und markiert die aktuell ausgewählte. Kein Parameter, +// keine Rückgabe. +function refreshWeekSelect(){ + const sel = document.getElementById('weekSelect'); + sel.innerHTML = db.weeks.map(w=>``).join(''); +} + +// updateWeekDate: zeigt das Datum der aktuellen Sitzung im Display +// `weekDateDisplay` an (formatiert über `formatWeekDate()`). Kein Parameter, +// keine Rückgabe; leer, wenn keine Woche gewählt ist. +function updateWeekDate(){ + const disp = document.getElementById('weekDateDisplay'); + const w = currentWeek(); + if(!disp) return; + disp.textContent = w?.date ? `Datum: ${formatWeekDate(w.date)}` : ''; +} + +// currentWeek: liefert das Woche-Objekt zur aktuellen Auswahl aus `db.weeks`. +// Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined. +function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); } + +// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`), speichert und +// rendert die Ansicht neu. Keine Rückgabe. +function switchWeek(id){ db.currentWeek = id; save(); renderAll(); } + +// newWeek: erzeugt über zwei prompt-Dialoge eine neue leere Sitzung (Bezeichnung +// und Datum). Die neue Woche übernimmt die globale Personenliste der Abteilung, +// wird angehängt, per `applyAutoLock()` gesperrt und als aktuelle Woche gesetzt. +// Keine Parameter, keine Rückgabe. +function newWeek(){ + const label = prompt("Bezeichnung der neuen Sitzung (z.B. KW 31 / 2026):","KW / 2026"); + if(!label) return; + const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || ""; + // Neue Woche übernimmt die aktuell globale Personenliste + const people = JSON.parse(JSON.stringify(db.globalPeople[currentDepartment] || [])); + const w = {id:uid(), label, date, people, projects:[], attendance:{}, locked:false}; + db.weeks.push(w); + applyAutoLock(); + db.currentWeek=w.id; save(); renderAll(); +} + +// cloneWeek: dupliziert die aktuelle Sitzung inkl. Projekten (Dialog für +// Bezeichnung und Datum). Projekte erhalten neue IDs; Status-Markierungen +// werden auf `statusState='duplicated'` gesetzt. Keine Parameter, keine Rückgabe. +function cloneWeek(){ + const cur = currentWeek(); if(!cur) return; + const label = prompt("Bezeichnung der duplizierten Sitzung:", cur.label+" (Kopie)"); + if(!label) return; + const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || ""; + const w = JSON.parse(JSON.stringify(cur)); + w.id=uid(); w.label=label; w.date=date; w.attendance={}; w.locked=false; + // Neue IDs für duplizierte Projekte und Kennzeichnung + w.projects.forEach(p=>{ + p.id = uid(); + if(p.status && p.status.trim() !== "") p.statusState = 'duplicated'; + else delete p.statusState; + }); + db.weeks.push(w); + applyAutoLock(); + db.currentWeek=w.id; save(); renderAll(); +} + +// -------- Sperren (Auto-Lock) und Entsperren -------- + +// applyAutoLock: sperrt alle Sitzungen bis auf die chronologisch +// letzte (aktuellste). Wird beim Laden, Erstellen und Duplizieren verwendet, +// damit historische Sitzungen nicht mehr unbeabsichtigt bearbeitet werden. +function applyAutoLock(){ + if(db.weeks.length === 0) return; + const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); + const latestId = sorted[sorted.length - 1].id; + db.weeks.forEach(w => { + w.locked = (w.id !== latestId); + }); +} + +// isWeekLocked: prüft, ob eine Sitzung gesperrt ist. Parameter `w`: Woche-Objekt. +// Rückgabe: `true` bei gesperrter bzw. fehlender Woche, sonst `false`. +function isWeekLocked(w){ + return !!w && !!w.locked; +} + +// guardWeekLocked: zeigt eine Warnung, wenn die aktuelle Sitzung gesperrt ist. +// Wird von Bearbeitungsfunktionen verwendet, um versehentliche Änderungen an +// historischen Sitzungen zu verhindern. Rückgabe: `true` bei Sperre (Abbruch), +// sonst `false`. +function guardWeekLocked(){ + const w = currentWeek(); + if(isWeekLocked(w)){ + alert('Diese Sitzung ist gesperrt. Bitte entsperren Sie sie zuerst.'); + return true; + } + return false; +} + +// unlockCurrentWeek: Entsperrt die aktuelle Sitzung nach Passwortprüfung. +// Parameter `skipAuth=true` überspringt die Passwortabfrage (Aufruf nach +// erfolgreicher Authentifizierung). Keine Rückgabe. +function unlockCurrentWeek(skipAuth = false){ + const w = currentWeek(); + if(!w || !w.locked) return; + if(!skipAuth && !isAuthenticated){ + requestedAction = { type: 'unlockWeek' }; + document.getElementById('passwordModal').style.display='flex'; + document.getElementById('passwordInput').value=''; + document.getElementById('passwordInput').focus(); + return; + } + w.locked = false; + save(); renderAll(); + requestedAction = null; +} + +// deleteWeek: löscht die aktuell ausgewählte Sitzung (nur wenn mehr als 1 +// vorhanden) inkl. ihrer CSV-Datei auf dem Server. Parameter `skipAuth=true` +// überspringt die Passwortabfrage. Danach wird die letzte verbleibende Woche +// aktiviert und neu gerendert. Keine Rückgabe. +function deleteWeek(skipAuth = false){ + if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; } + if(!skipAuth && !isAuthenticated){ + requestedAction = { type: 'deleteWeek' }; + document.getElementById('passwordModal').style.display='flex'; + document.getElementById('passwordInput').value=''; + document.getElementById('passwordInput').focus(); + return; + } + if(!confirm("Diese Sitzung wirklich löschen?")) { requestedAction = null; return; } + const current = currentWeek(); + const filename = current ? filenameForWeek(current.label) : null; + if(filename){ + console.log('Lösche CSV-Datei auf Server:', filename); + deleteCsvFromServer(filename).then(res=>{ + if(res && res.ok){ console.log('CSV-Datei gelöscht:', filename); } + else { alert('CSV-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler')); } + }).catch(err=>{ alert('CSV-Löschung fehlgeschlagen: ' + (err.message || err)); }); + } + db.weeks = db.weeks.filter(w=>w.id!==db.currentWeek); + db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); + db.currentWeek = db.weeks[db.weeks.length - 1].id; + applyAutoLock(); + save(); + renderAll(); + requestedAction = null; +} \ No newline at end of file diff --git a/public/project-plan.html b/public/project-plan.html index 2510a50..3ec2da7 100644 --- a/public/project-plan.html +++ b/public/project-plan.html @@ -1,4 +1,18 @@ + @@ -299,7 +313,18 @@
- - + + + + + + + + + + + + + diff --git a/server.js b/server.js index 04fa6a0..78dca6b 100644 --- a/server.js +++ b/server.js @@ -1,12 +1,23 @@ +/* + * Express-Server der Projektübersicht: liefert die statischen Frontend-Dateien aus + * public/ (Test.html unter "/") aus und stellt den Frontend-Modulen unter public/js/ + * eine CSV-API zum Laden und Speichern der Sitzungsdaten in sessions/ bereit. + * Standardport ist 3003, überschreibbar über die Umgebungsvariable PORT. + * Endpunkte: GET /, GET /health, GET /load-all, POST /save-csv, POST /delete-csv. + * Die Daten werden als UTF-8-CSV mit BOM geschrieben; ungültige Abteilungen fallen + * auf ELT zurück, Dateinamen werden über safeName() streng validiert. + */ const express = require('express'); const fs = require('fs'); const path = require('path'); const app = express(); +// Wichtige Konstanten: UTF-8-BOM für CSV-Dateien, Serverport (PORT-Env) und die gültigen Abteilungsordner const BOM = Buffer.from([0xEF, 0xBB, 0xBF]); const PORT = process.env.PORT || 3003; const VALID_DEPARTMENTS = new Set(['ELT','HLS','ARCH']); +// Globale Middleware: CORS, JSON-Body-Limit (2 MB) und statisches Serving von public/ ohne Cache für Skripte/Styles app.use(require('cors')()); app.use(express.json({ limit: '2mb' })); app.use(express.static(path.join(__dirname, 'public'), { @@ -19,6 +30,7 @@ app.use(express.static(path.join(__dirname, 'public'), { } })); +// Datenverzeichnis sessions/ bei Bedarf anlegen const SESSIONS_DIR = path.join(__dirname, 'sessions'); if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true }); @@ -34,6 +46,7 @@ function safeName(name){ return safe; } +// Liefert den Zielordner für eine Abteilung (nur ELT|HLS|ARCH, sonst ELT) und legt ihn bei Bedarf an function departmentDir(department){ const dept = (typeof department === 'string' && VALID_DEPARTMENTS.has(department)) ? department : 'ELT'; const dir = path.join(SESSIONS_DIR, dept); @@ -41,6 +54,7 @@ function departmentDir(department){ return dir; } +// Route: Speichert eine CSV-Datei (mit UTF-8-BOM) für eine Abteilung im sessions/-Ordner app.post('/save-csv', (req, res) => { const { filename, csv, department } = req.body || {}; if(!filename || typeof csv !== 'string') return res.status(400).json({ ok: false, error: 'Missing filename or csv' }); @@ -56,6 +70,7 @@ app.post('/save-csv', (req, res) => { }); }); +// Route: Löscht eine CSV-Datei einer Abteilung aus dem sessions/-Ordner (fehlende Datei wird toleriert) app.post('/delete-csv', (req, res) => { const { filename, department } = req.body || {}; if(!filename) return res.status(400).json({ ok: false, error: 'Missing filename' }); @@ -77,6 +92,7 @@ app.post('/delete-csv', (req, res) => { }); }); +// Route: Lädt alle Sitzungs-CSV-Dateien einer Abteilung plus _people.csv (nach Dateiname sortiert) app.get('/load-all', (req, res) => { const dir = departmentDir(req.query.department); fs.readdir(dir, (err, files) => { @@ -97,12 +113,15 @@ app.get('/load-all', (req, res) => { }); }); +// Route: Healthcheck-Endpunkt für Docker/Überwachung app.get('/health', (req,res)=> res.json({ok:true})); +// Route: Liefert die Hauptseite Test.html app.get('/', (req,res)=>{ res.sendFile(path.join(__dirname, 'public', 'Test.html')); }); +// Serverstart mit Fehlerbehandlung (z.B. Port bereits belegt) const server = app.listen(PORT, ()=> console.log(`CSV-Session server running on http://localhost:${PORT}`)); server.on('error', (err) => { console.error('Server konnte nicht starten:', err.message); diff --git a/sessions/ELT/KW_33___2026.csv b/sessions/ELT/KW_33___2026.csv index b503e16..63827de 100644 --- a/sessions/ELT/KW_33___2026.csv +++ b/sessions/ELT/KW_33___2026.csv @@ -2,7 +2,7 @@ PERSONEN;;;AP|Planung|Alexander Plum;AK|Planung|Alex Kenne;ED|Planung|Erik Dunin;SK|Planung|Simon Kunigk;SE|Planung|Steven Esche;WS|Planung|Winfried Sältzer;ED|Projektleitung|Erik Dunin;SP|Projektleitung|Sebastian Pöttinger;THK|Projektleitung|Thorsten Kramp;RR|Projektleitung|Robert Röllecke;MK|Projektleitung|Maximilian Kriener;TW|Projektleitung|Thorsten Werner;SG|Projektleitung|Steffen Gaida;ED|Bauüberwachung|Erik Dunin;SK|Bauüberwachung|Simon Kunigk;SP|Bauüberwachung|Sebastian Pöttinger;THK|Bauüberwachung|Thorsten Kramp;HR|Bauüberwachung|Hermann Rollecke;;;;;;;;;;;;;; Pr.-Nr.;Bezeichnung;Status;AP;AK;ED;SK;SE;WS;ED;SP;THK;RR;MK;TW;SG;ED;SK;SP;THK;HR;LPH 1;LPH 2;LPH 3;LPH 4;LPH 5;LPH 6;LPH 7;LPH 8;W+M;Gruppe;Index;Plan;Aufwand;Aktive Phasen ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -18-4007;Janusz-Korczak-Gesamtsch. Bottrop;- KW 35 NW Raum Planung
- KW 44 Möbel
;;;;;;;;;;;;;;;;;;;100%;100%;100%;;90%;;;;;Aktive Projekte;0;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";"{""LPH 5"":{""Planung"":0.6},""LPH 8"":{""Planung"":0,""Projektleitung"":0.2}}"; +18-4007;Janusz-Korczak-Gesamtsch. Bottrop;- KW 35 NW Raum Planung
- KW 44 Möbel
;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;90%;;;;;Aktive Projekte;0;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";"{""LPH 5"":{""Planung"":0.6},""LPH 8"":{""Planung"":0,""Projektleitung"":0.2}}"; 21-4030;NSHV Köln Immobilienprüfung in Hof;- Thorsten W. klärt und übernimmt Schriftverkehr
- LV liegt zur Sichtung bei T.W.
;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;2;;; 22-4066;DSW 21, Umbau E-Mobilität;- Elt-Montagen ab KW 33.;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;6;;; 24-4043;S-K ODL-Heim Sanierung Krefeld;- Vorraussichtlich LV-Abgabe XX.XX
- LPH5 mündlich freigegeben
- Neuer EG Grundriss mit diversen Änderungen kommt noch von Architekten
;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;10;;;