code auf einzelne Datein aufgeteilt

This commit is contained in:
erik committed 2026-08-13 16:07:29 +02:00
1 parent 7451e0bf78
commit 8c94fc91f6
30 files changed
+5765 -27

No files matched your search

+50 -16
View File
@@ -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 `<script>`; **all functions are
globals on `window`** and shared across both pages. Edit functions in place;
the file is one large script with German section-header comments.
- The frontend is split into small classic non-module `<script>` files under
`public/js/`; **all functions are globals on `window`**. Two HTML files include
them via `<script src>` tags; load order in the HTML defines dependencies,
since there is no bundler:
- `Test.html` loads `globals → utils → ui → date-picker → csv → server →
store → weeks → board → people → projects → load → gantt → showmode →
tabs-auth → pdf → init`. See the exact list in `Test.html`.
- `project-plan.html` loads only `globals → utils → ui → date-picker → csv →
server → gantt` plus the `js/plan/*` modules. It must NOT load modules that
touch main-only page elements (`store`, `weeks`, `board`, …, `init`).
- Shared state object `window.PlanApp` (`js/plan/plan-state.js`) holds all
project-plan page state (week, project, zoom level, drag/`modal`-state),
`db`/`currentDepartment` live in `js/globals.js`.
- State: `let db = { weeks, currentWeek, globalPeople }` (in-memory only).
- Project IDs are regenerated on every CSV load, so projects are addressed
cross-page by `department` + week `label` + `projNr` + `projName` via URL
@@ -93,7 +127,7 @@ docker compose up --build # Docker variant (compose file is at repo root)
- Person IDs are stable across reloads via `personIdForName(name)` →
`p_<lowercased alnum>`.
- `save()` debounces 500 ms then `syncAllToServer()` writes **all** weeks plus
`_people.csv` — not just the changed week. Exception: `project-plan.js`
`_people.csv` — not just the changed week. Exception: `js/plan/plan-state.js`
`savePlan()` bypasses `save()` and writes only the current week's CSV directly
via `sendCsvToServer()`.
- Weeks are sorted by calendar week extracted from the label (`KW XX / YYYY`)
@@ -108,12 +142,12 @@ docker compose up --build # Docker variant (compose file is at repo root)
- The `projects` and `people` tabs, week deletion, and week unlock are gated by
a password modal comparing `CryptoJS.SHA256(input)` to
`PROTECTED_PASSWORD_HASH` (`main.v2.js`). Client-side only — not real
security. CryptoJS and pdfmake (PDF export) are loaded from
`cdnjs.cloudflare.com` in `Test.html`, so those features need internet access
and won't work offline. `project-plan.html` loads neither.
`PROTECTED_PASSWORD_HASH` (`js/globals.js`, modal in `js/tabs-auth.js`).
Client-side only — not real security. CryptoJS and pdfmake (PDF export) are
loaded from `cdnjs.cloudflare.com` in `Test.html`, so those features need
internet access and won't work offline. `project-plan.html` loads neither.
- User-provided status HTML is sanitized on render via a small whitelist
(`sanitizeStatusHtml` in `main.v2.js`) to mitigate XSS.
(`sanitizeStatusHtml` in `js/board.js`) to mitigate XSS.
## Docker
+16 -7
View File
@@ -3,7 +3,7 @@
## Übersicht
Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrotechnischer Projekte und Sitzungsübersichten. Die Anwendung besteht aus:
- einem Frontend in `Test.html`, `styles.css` und `main.v2.js`
- einem Frontend in `Test.html`, `styles.css` und den JS-Modulen unter `public/js/`
- einem einfachen Node.js-Server in `server.js`
- einem Datenspeicher in CSV-Dateien unter `sessions/`
- einem Docker-Setup unter `Docker/`
@@ -14,7 +14,7 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
- `server.js`
- Node.js/Express-Server
- dient als API für das Frontend
- stellt statische Dateien (`Test.html`, `main.v2.js`, `styles.css`, etc.) bereit
- stellt statische Dateien (`Test.html`, die Module unter `js/`, `styles.css`, etc.) bereit
- implementiert Endpunkte:
- `GET /` → liefert `Test.html`
- `GET /load-all` → lädt alle CSV-Dateien aus dem aktiven `sessions/<DEPARTMENT>/`-Ordner inkl. `_people.csv`
@@ -31,8 +31,12 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
- `package-lock.json`
- wird für reproduzierbare Docker-Builds verwendet (`npm ci`)
- `main.v2.js`
- komplette Client-Logik der Anwendung
- `public/js/` (Module)
- Die Client-Logik ist in kleine klassische Script-Dateien aufgeteilt (alle Funktionen global auf `window`):
`globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `csv.js`, `server.js`, `store.js`, `weeks.js`,
`board.js`, `people.js`, `projects.js`, `load.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`,
`init.js` sowie die Plan-Module unter `js/plan/` (`plan-state`, `plan-build-gantt`, `plan-gantt`,
`plan-drag`, `plan-modals`, `plan-schedule`).
- rendert das Projektboard, die Projekt- und Personenverwaltung sowie die Auslastungsansicht
- lädt Daten über den Server und speichert Änderungen zurück als CSV
- verwendet `currentDepartment` zur Trennung von Abteilungen (`ELT`, `HLS`, `ARCH`)
@@ -45,9 +49,14 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
- `Test.html`
- die Benutzeroberfläche der Anwendung
- lädt `styles.css` und `main.v2.js`
- lädt `styles.css` und die JS-Module aus `js/` in fester Reihenfolge (siehe `AGENTS.md`)
- enthält die Abteilungsauswahl, Sitzungsnavigation, Tab-Navigation und das Passwort-Modal
- `project-plan.html`
- die Planungsansicht (GANTT/TODOs/Meilensteine) pro Projekt
- öffnet sich in einem neuen Tab über `openProjectPlan()` (`js/gantt.js`)
- lädt nur die gemeinsamen Helfermodule plus `js/plan/*`
- `styles.css`
- Layout und Styling der Oberfläche
@@ -98,7 +107,7 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
### Wo werden die Dateien gespeichert?
- Das Frontend schreibt über den Server in das Dateisystem.
- In `main.v2.js` ruft die Anwendung beim Speichern `/save-csv` auf.
- In `js/store.js` ruft die Anwendung beim Speichern `/save-csv` auf.
- Der Server legt CSV-Dateien unter `sessions/<DEPARTMENT>/` ab.
- Beispiel:
- `sessions/ELT/KW_32___2026.csv`
@@ -134,6 +143,6 @@ http://localhost:3003
## Wichtige Hinweise
- Der Server benötigt Lese-/Schreibzugriff auf `sessions/`.
- Die Docker-Compose-Konfiguration mountet `./sessions` in den Container. Achte darauf, dass der relative Pfad im Kontext korrekt ist, wenn du `docker compose` aus einem anderen Verzeichnis startest.
- `main.v2.js` arbeitet mit Abteilungsparametern und speichert Daten abteilungsweise in separaten Unterordnern.
- `js/store.js` und `js/server.js` arbeiten mit Abteilungsparametern und speichern Daten abteilungsweise in separaten Unterordnern.
- Die Passwortfunktion ist nur ein einfacher Client-Mechanismus im Frontend und kein echter Sicherheitsmechanismus.
- Globale Personen werden in `_people.csv` verwaltet. Wird eine Person global gelöscht, bleibt sie in bereits existierenden Sitzungen sichtbar.
+29 -1
View File
@@ -1,4 +1,16 @@
<!DOCTYPE html>
<!--
HAUPTSEITE der Projektübersicht (wird vom Express-Server unter "/" ausgeliefert).
Die Seite zeigt die vier Tabs Projektboard, Auslastung, Projekte und Projektbeteiligte
und verwaltet wöchentliche Sitzungen (Sitzungswahl, -anlage, -duplikat und -löschung im
seitlichen Menü) je Abteilung (ELT, HLS, Architektur). Sie lädt das gemeinsame Stylesheet
styles.css sowie der Reihe nach die Module js/globals.js, js/utils.js, js/ui.js,
js/date-picker.js, js/csv.js, js/server.js, js/store.js, js/weeks.js, js/board.js,
js/people.js, js/projects.js, js/load.js, js/gantt.js, js/showmode.js, js/tabs-auth.js,
js/pdf.js und js/init.js; crypto-js und pdfmake kommen von einem CDN. Über openProjectPlan()
aus js/gantt.js öffnet diese Seite die Projektplan-Ansicht project-plan.html in einem
neuen Tab, die nur die gemeinsamen Module plus die js/plan/*-Module lädt.
-->
<html lang="de">
<head>
<meta charset="UTF-8">
@@ -209,6 +221,22 @@
</div>
</div>
<script src="main.v2.js"></script>
<script src="js/globals.js?v=5"></script>
<script src="js/utils.js?v=5"></script>
<script src="js/ui.js?v=5"></script>
<script src="js/date-picker.js?v=5"></script>
<script src="js/csv.js?v=5"></script>
<script src="js/server.js?v=5"></script>
<script src="js/store.js?v=5"></script>
<script src="js/weeks.js?v=5"></script>
<script src="js/board.js?v=5"></script>
<script src="js/people.js?v=5"></script>
<script src="js/projects.js?v=5"></script>
<script src="js/load.js?v=5"></script>
<script src="js/gantt.js?v=5"></script>
<script src="js/showmode.js?v=5"></script>
<script src="js/tabs-auth.js?v=5"></script>
<script src="js/pdf.js?v=5"></script>
<script src="js/init.js?v=5"></script>
</body>
</html>
BIN
View File
Binary file not shown.
+347
View File
@@ -0,0 +1,347 @@
// -----------------------------
// Projektübersicht — Board (Haupttabelle)
// -----------------------------
// Diese Datei rendert das Projektboard (Tab "Projektboard" in `Test.html`),
// die zentrale Tabelle mit Projekten, Status-Kommentaren, Personen-Spalten
// (Anwesenheit/Zuordnung) und Leistungsphasen in Prozent. Sie wird von
// `Test.html` nach `js/weeks.js` (vor `js/people.js`) geladen und stellt
// globale Funktionen wie `renderBoard()`, `statusFocus()`, `cycleAssign()`,
// `editLph()` und `delProj()` bereit. Sie nutzt `currentWeek()`/
// `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load.js`,
// `assignKey()` aus `js/utils.js` und `openProjectPlan()` aus `js/gantt.js`.
// currentStatusEditor: aktuell fokussiertes contenteditable-Element des
// Status-Editors; wird für die Formatierungs-Toolbar (`formatStatus`) benötigt.
let currentStatusEditor = null;
// currentStatusPid: Projekt-ID der gerade fokussierten Status-Zelle; wird beim
// Speichern des Status (`statusBlur`) verwendet.
let currentStatusPid = null;
// statusFocus: merkt sich das fokussierte Status-Element und die zugehörige
// Projekt-ID (`el`, `pid`), damit die Toolbar und das Blur-Speichern sie nutzen.
function statusFocus(el, pid){
currentStatusEditor = el;
currentStatusPid = pid;
}
// statusBlur: übernimmt den Inhalt des Status-Editors (`el`) als neuen Status
// des Projekts `pid` und räumt die Editor-Merker wieder ab, wenn `el` das
// zuletzt fokussierte Element war.
function statusBlur(el, pid){
const val = statusValueFromHtml(el.innerHTML);
editProj(pid, 'status', val);
if(currentStatusEditor === el){ currentStatusEditor = null; currentStatusPid = null; }
}
// statusValueFromHtml: normalisiert den HTML-Inhalt eines Status-Editors.
// Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String
// (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String.
function statusValueFromHtml(html){
const tmp = document.createElement('div');
tmp.innerHTML = html;
const text = (tmp.textContent || tmp.innerText || '').replace(/\u00A0/g, ' ');
return text.trim() === '' ? '' : html;
}
// formatStatus: wendet einen document.execCommand-Befehl (z.B. 'bold',
// 'foreColor') auf das fokussierte Status-Element an. Parameter: `command`,
// optionaler `value`; keine Rückgabe.
function formatStatus(command, value){
if(!currentStatusEditor) return;
currentStatusEditor.focus();
document.execCommand(command, false, value || null);
}
// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt.
// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle
// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung.
function peopleByRole(target){
const m = {}; ROLES.forEach(r=>m[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 = `<tr><th rowspan="2">Pr.-Nr.</th><th rowspan="2">Bezeichnung</th><th rowspan="2">Status / Kommentar</th>`;
ROLES.forEach(r=>{ h1 += `<th class="${roleClass(r)}" colspan="${Math.max(1,grouped[r].length)}">${r}</th>`; });
h1 += `<th colspan="${LPHS.length}">Leistungsphasen (%)</th><th rowspan="2"></th></tr>`;
let h2 = `<tr>`;
ROLES.forEach(r=>{
if(grouped[r].length===0) h2 += `<th class="rot ${roleClass(r)}">–</th>`;
grouped[r].forEach(entry=> h2 += `<th class="rot ${roleClass(entry.role)}">${entry.person.name}</th>`);
});
LPHS.forEach(l=> h2 += `<th class="rot">${l}</th>`);
h2 += `</tr>`;
// 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 += `<tr class="group-header"><td colspan="${colspan}">${esc(g)}</td></tr>`;
groups[g].forEach(p=>{
body += `<tr>`;
body += `<td class="left">${esc(p.nr)}</td>`;
body += `<td class="left proj-name" style="cursor:pointer" onclick="openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</td>`;
// 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 ? '' : `
<div class="status-toolbar" tabindex="-1">
<button type="button" title="Fett" onmousedown="event.preventDefault();formatStatus('bold')"><b>B</b></button>
<button type="button" title="Rot" class="color-red" onmousedown="event.preventDefault();formatStatus('foreColor','#c53030')">A</button>
<button type="button" title="Grün" class="color-green" onmousedown="event.preventDefault();formatStatus('foreColor','#2f855a')">A</button>
<button type="button" title="Blau" class="color-blue" onmousedown="event.preventDefault();formatStatus('foreColor','#2b6cb0')">A</button>
<button type="button" title="Schwarz" class="color-black" onmousedown="event.preventDefault();formatStatus('foreColor','#1a1a1a')">A</button>
<button type="button" title="Absatz" onmousedown="event.preventDefault();formatStatus('formatBlock','P')">¶</button>
</div>`;
const statusClick = locked ? '' : `onfocus="statusFocus(this,'${p.id}')" onblur="statusBlur(this,'${p.id}')"`;
const safeStatus = sanitizeStatusHtml(p.status || '');
body += `<td class="status ${locked ? 'locked-cell' : ''}">
${statusToolbar}
<div class="status-edit ${statusClass}" contenteditable="${statusEditable}" ${statusClick}>${safeStatus}</div>
</td>`;
// 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 += `<td class="person-cell ${roleClass(entry.role)} ${cls} ${locked ? 'locked-cell' : ''}" ${click}>${txt}</td>`;
});
// 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 += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${v}" ${inputAttr} placeholder="-">`;
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
body += `</td>`;
});
body += `</tr>`;
});
});
let lockNotice = '';
if(locked){
lockNotice = `<div class="lock-notice" style="margin-bottom:10px;padding:8px 12px;background:#fff5f5;border:1px solid #fc8181;border-radius:6px;color:#c53030;font-weight:bold;font-size:12px;">
Diese Sitzung ist gesperrt. Bearbeiten nur nach Passworteingabe möglich.
<button type="button" class="secondary small" style="margin-left:12px;" onclick="unlockCurrentWeek()">Entsperren</button>
</div>`;
}
table.innerHTML = `<thead>${h1}${h2}</thead><tbody>${body}</tbody>`;
// Legende mit vollständigen Namen und Anwesenheitstracking
let legendeHtml = lockNotice + `<div style="margin-bottom:8px; font-weight:bold; color:var(--accent);">Legende Projektkürzel${locked ? '' : ' (klick für Anwesenheit)'}:</div>`;
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 += `<div class="legend-list">` + legendeItems.map(item=>{
const isPresent = weekAttendance[item.id];
const cursor = locked ? 'not-allowed' : 'pointer';
const click = locked ? '' : `onclick="toggleAttendance('${item.id}')"`;
return `<div class="legend-item${isPresent?' present':''}${locked ? ' locked-legend-item' : ''}" ${click}><span>${esc(item.name)}</span> = ${esc(item.fullName)}</div>`;
}).join('') + `</div>`;
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();
}
+357
View File
@@ -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 };
}
+221
View File
@@ -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 = `<div class="range-picker-head">
<button type="button" class="secondary small" onclick="rangePickerNav(-1)">‹</button>
<span class="range-picker-title">${monthNames[st.viewMonth]} ${st.viewYear}</span>
<button type="button" class="secondary small" onclick="rangePickerNav(1)">›</button>
</div>
<div class="range-picker-hint">${hint}</div>
<div class="range-picker-grid">`;
['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`);
for(let i = 0; i < firstWeekday; i++) html += '<div></div>';
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 += `<div class="${classes.join(' ')}" onclick="rangePickerSelect('${iso}')">${day}</div>`;
}
html += '</div>';
html += `<div class="range-picker-footer">
<button type="button" class="secondary small" onclick="closeRangePicker()">Abbrechen</button>
${st.mode !== 'single' && st.start && !st.end ? '<button type="button" class="small" onclick="rangePickerState.end = rangePickerState.start; applyRangePicker()">Nur dieser Tag</button>' : ''}
</div>`;
pop.innerHTML = html;
}
+361
View File
@@ -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 = '<div class="hint">Keine Sitzung ausgewählt.</div>';
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 `<div class="gantt-timeline">${content}</div>`;
}
// 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 `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>`;
}
// Kopfzeile: Monate und Tage
let headerMonths = '<div class="gantt-row gantt-header">';
headerMonths += '<div class="gantt-label">Projekt</div>';
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 += `<div class="gantt-month" style="left:${(m.startIdx/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%">${names[m.month]} ${m.year}</div>`;
});
headerMonths += timeline(monthsHtml) + '</div>';
let headerWeeks = '<div class="gantt-row gantt-days">';
headerWeeks += '<div class="gantt-label"></div>';
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 += `<div class="gantt-day gantt-week" style="left:${(w.startIdx/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%"><span>KW ${w.week}</span></div>`;
});
headerWeeks += timeline(weeksHtml) + '</div>';
// 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 = '<div class="gantt-grid-overlay">';
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 += `<div class="gantt-grid-weekend" style="left:${(weekendStartIdx/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%"></div>`;
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 += `<div class="gantt-grid-today" style="left:${((todayIdx + 0.5)/totalDays*100).toFixed(4)}%" title="Heute: ${formatDateDE(todayMid)}"></div>`;
}
gridHtml += '</div>';
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 += `<div class="gantt-bar vacation"${dragAttrs('vacation',{person: person.id, absence: a.id, start: formatDateISO(s), end: formatDateISO(e)})} style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(a.type || 'Abwesenheit')}: ${formatDateDE(s)}–${formatDateDE(e)}${a.note ? ' – ' + esc(a.note) : ''}">${resizeHandles()}</div>`;
});
if(!barsHtml) return; // keine Abwesenheit im sichtbaren Zeitraum -> keine Zeile
body += `<div class="gantt-row gantt-vacation">`;
body += `<div class="gantt-label" title="${esc(person.fullName || person.name)}"><span class="gantt-vacation-dot"></span> ${esc(person.name)}</div>`;
body += timeline(barsHtml);
body += `</div>`;
});
}
// 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 += `<div class="gantt-row gantt-project${highlightProjectId === p.id ? ' highlighted' : ''}">`;
body += `<div class="gantt-label">${esc(p.nr ? p.nr + ' ' : '')}${esc(p.name)}</div>`;
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 += `<div class="gantt-bar project"${dragAttrs('project',{project: p.id, start: formatDateISO(s), end: formatDateISO(e)})} style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%;background:${color}">`;
rowContent += `${resizeHandles()}<div class="gantt-progress" style="width:${progress}%"></div>`;
rowContent += `<span class="gantt-bar-label">${esc(p.name)}</span>`;
rowContent += `</div>`;
}
}
// 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 += `<div class="gantt-milestone"${dragAttrs('milestone',{project: p.id, index: mIdx, date: formatDateISO(d)})} style="left:${(offset/totalDays*100).toFixed(4)}%" title="${esc(m.name)}: ${formatDateDE(d)}"></div>`;
rowContent += `<div class="gantt-milestone-label" style="left:${(offset/totalDays*100).toFixed(4)}%">${esc(m.name || '')}</div>`;
});
body += timeline(rowContent) + `</div>`;
});
// 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 `<div class="gantt-chart"${chartStyle}>${body}</div>`;
}
// ---------- 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) };
}
+49
View File
@@ -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/<DEPT>/ 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: '' }
};
+68
View File
@@ -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);
}
+352
View File
@@ -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 => `<th>${esc(r)}</th>`).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 `<td><input type="text" inputmode="decimal" id="${prefix}Val${idx}R${ri}" value="${val}" placeholder="1,0"></td>`;
}).join('');
return `<tr><th class="lp-lph">${l}</th>${cells}</tr>`;
}).join('');
return `<table class="lp-matrix">
<thead><tr><th class="lp-lph">Leistungsphase</th>${head}</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// 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 = '<option value="">Keine</option>' + AUFWAND_LPS.map(l => `<option value="${l}">${l}</option>`).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 = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
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 += `<div class="load-row"><div class="load-row-title">${r}</div>`;
if(grouped[r].length===0){
html += `<div class="hint">Keine Personen.</div>`;
}
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=>`<div><span>${esc(k)}</span><b>${s.projects[k]}×</b></div>`).join('') || '<div class="hint">keine Zuordnung</div>';
const anbahnungBadge = (s.anbahnung && s.anbahnung>0) ? `<span style="font-size:11px;color:#3182ce;font-weight:normal;margin-left:0px">(${s.anbahnung} in Anbahnung)</span>` : '';
html += `<div class="load-card">
<h3>${esc(entry.person.fullName)} <span style="font-size:11px;color:#888;font-weight:normal">(${r})</span></h3>
<h3>${anbahnungBadge}</h3>
<div class="load-num">${faktorFormat(avg)}</div>
<div class="bar" style="height:10px;margin-top:6px" title="Skala: 0 – ${faktorFormat(barMax)}"><div style="width:${pct}%;background:${loadAmpelColor(avg)}"></div></div>
<div class="load-detail">${detail}</div>
</div>`;
});
html += `</div>`;
});
document.getElementById('loadGrid').innerHTML=html;
}
+566
View File
@@ -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 (<br>, Block-Ende von <p>/<div>) 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));
}
}
+349
View File
@@ -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 = `<div class="people-sortable">`;
people.forEach(p=>{
const isOpen = personAbsenceOpen[p.id];
html += `<div class="person-card${isOpen ? ' open' : ''}" data-person-id="${p.id}">`;
html += `<div class="person-header" onclick="togglePersonAbsences('${p.id}')">
<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px" onclick="event.stopPropagation()" ondragstart="handlePersonDragStart(event)" draggable="true">⋮⋮</span>
<span class="person-toggle">${isOpen ? '▼' : '▶'}</span>
<input type="text" value="${esc(p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','name',this.value)" style="width:80px;" placeholder="Kürzel">
<input type="text" value="${esc(p.fullName||p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','fullName',this.value)" style="width:180px;" placeholder="Voller Name">
<input type="text" value="${esc(p.email)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','email',this.value)" style="width:180px;" placeholder="E-Mail">
<div class="role-checkboxes" onclick="event.stopPropagation()">${ROLES.map(rr=>`<label><input type="checkbox" onchange="togglePersonRole('${p.id}','${rr}',this.checked)" value="${rr}" ${p.role.includes(rr)?'checked':''}> ${rr}</label>`).join('')}</div>
<button class="danger small" onclick="event.stopPropagation(); delPerson('${p.id}')">Löschen</button>
</div>`;
// Aufklappbare Abwesenheiten
html += `<div class="person-absences" style="display:${isOpen ? 'block' : 'none'}">`;
const absences = p.absences || [];
if(absences.length > 0){
html += `<div class="absence-list">`;
absences.forEach(a => {
html += `<div class="absence-chip">
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
${a.note ? `<small>${esc(a.note)}</small>` : ''}
${locked ? '' : `<button class="danger small" onclick="deleteAbsence('${p.id}','${a.id}')">×</button>`}
</div>`;
});
html += `</div>`;
}
if(!locked){
html += `<div class="absence-form">
<input type="text" id="absenceRange_${p.id}" class="range-picker-display" placeholder="Zeitraum wählen…" readonly
onclick="event.stopPropagation(); openRangePicker({ anchor: this, startInputId: 'absenceStart_${p.id}', endInputId: 'absenceEnd_${p.id}', displayId: 'absenceRange_${p.id}' })">
<input type="hidden" id="absenceStart_${p.id}">
<input type="hidden" id="absenceEnd_${p.id}">
<select id="absenceType_${p.id}">
<option>Urlaub</option>
<option>Krank</option>
<option>Fortbildung</option>
<option>Sonstiges</option>
</select>
<input type="text" id="absenceNote_${p.id}" placeholder="Notiz (optional)" style="width:140px">
<button class="secondary small" onclick="addAbsence('${p.id}')">+ Abwesenheit</button>
</div>`;
}
html += `</div></div>`;
});
html += `</div>`;
if(people.length===0) html = '<div class="hint">Keine Personen vorhanden.</div>';
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();
}
+174
View File
@@ -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 ? `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>` : '';
const isCritical = options.criticalPath && options.criticalPath.includes(todo.id);
const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0));
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
}
}
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 = `<span class="gantt-dep-badge${isConflict ? ' conflict' : ''}" title="Vorgänger: ${esc(names)}">↳ ${preds.length === 1 ? esc(preds[0].title) : esc(preds.length + ' Vorgänger')}</span>`;
}
}
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}">
${editable ? '<span class="gantt-todo-drag">⋮⋮</span>' : ''}
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}${depBadge}</span>
</div>
<div class="gantt-timeline">${barHtml}</div>`;
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 = `<div class="gantt-milestone"${dragAttrs} data-todo-id="m_${esc(m.id)}" data-todo-depends="${deps}" style="left:${left}" title="${esc(m.name)}: ${formatDateDE(d)}"></div>`;
labelStyle = 'left:' + left;
}
}
row.innerHTML = `<div class="gantt-label" style="padding-left:24px;font-weight:400;color:var(--text-muted)">
${editable ? '<span class="gantt-todo-drag">⋮⋮</span>' : ''}
<span class="gantt-milestone-title"${editable ? ' onclick="openMilestoneModal(' + mIdx + ')" style="cursor:pointer"' : ''}>${esc(m.name || 'Meilenstein')}</span>
</div>
<div class="gantt-timeline">${diamond}<div class="gantt-milestone-label" style="${labelStyle}">${esc(m.name || '')}</div></div>`;
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;
};
})();
+433
View File
@@ -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);
})();
+233
View File
@@ -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(`<path class="${pathClass}" d="${d}" marker-end="url(#${markerId})" data-source="${esc(sourceBar.dataset.todoId)}" data-target="${esc(targetBar.dataset.todoId)}" />`);
});
});
if(paths.length === 0) return;
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'gantt-dependencies-svg');
svg.innerHTML = `
<defs>
<marker id="gantt-arrow-normal" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--accent)" />
</marker>
<marker id="gantt-arrow-conflict" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--red)" />
</marker>
<marker id="gantt-arrow-done" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--green)" />
</marker>
</defs>
${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 = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)</div>';
html += '<div class="load-grid plan-resource-grid">';
Object.values(loads).forEach(l => {
const pct = Math.round(l.days / maxDays * 100);
const absCount = app.countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">🏖 ${absCount} Abwesenheit(en)</div>` : '';
let todoListHtml;
if(l.todos.length === 0){
todoListHtml = '<div class="hint">keine TODOs</div>';
} else {
todoListHtml = l.todos.slice(0,3).map(t => `<div><span>${esc(t)}</span></div>`).join('');
if(l.todos.length > 3){
todoListHtml += '<div class="hint">+' + (l.todos.length - 3) + ' weitere</div>';
}
}
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px">${l.days}</div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
${absBadge}
</div>`;
});
html += '</div>';
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);
});
})();
+473
View File
@@ -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 = '<option value="">Oben in der Projektzeile</option>';
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 += `<option value="${esc(beforeVal)}" ${beforeSel}>Vor: ${esc(t.title || 'TODO')}</option>`;
opts += `<option value="${esc(afterVal)}" ${afterSel}>Nach: ${esc(t.title || 'TODO')}</option>`;
});
}
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 += '<div class="todo-depends-group"><b>Meilensteine</b></div>';
plan.milestones.forEach(m => {
if(currentMs && m.id === currentMs.id) return;
const mId = 'm_' + m.id;
const checked = currentDeps.includes(mId) ? 'checked' : '';
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(mId)}" ${checked}> 🚩 ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
});
}
// TODOs als Vorgänger anbieten
if(plan.todos && plan.todos.length > 0){
listHtml += '<div class="todo-depends-group"><b>Aufgaben</b></div>';
(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 += `<label class="todo-depends-item"><input type="checkbox" value="${esc(t.id)}" ${checked}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
});
}
if(listHtml === '') listHtml = '<span class="hint">Keine anderen Aufgaben oder Meilensteine vorhanden.</span>';
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 += '<div class="todo-depends-group"><b>Meilensteine</b></div>';
plan.milestones.forEach(m => {
const mId = 'm_' + m.id;
const checked = currentDeps.includes(mId) ? 'checked' : '';
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(mId)}" ${checked}> 🚩 ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
});
}
// Andere TODOs als Vorgänger anbieten
if(plan.todos && plan.todos.length > 1){
listHtml += '<div class="todo-depends-group"><b>Aufgaben</b></div>';
(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 += `<label class="todo-depends-item"><input type="checkbox" value="${esc(t.id)}" ${checked}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
});
}
if(listHtml === '') listHtml = '<span class="hint">Keine anderen Aufgaben oder Meilensteine vorhanden.</span>';
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 `<label><input type="checkbox" value="${esc(person.id)}" ${checked}> ${esc(person.name)}</label>`;
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
}
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 = '<span class="hint">Keine Abwesenheiten eingetragen.</span>';
} else {
listDiv.innerHTML = '<div class="absence-list">' + absences.map(a => `
<div class="absence-chip">
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
${a.note ? `<small>${esc(a.note)}</small>` : ''}
${locked ? '' : `<button class="danger small" onclick="deletePlanAbsence('${esc(a.id)}')">×</button>`}
</div>`).join('') + '</div>';
}
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();
};
})();
+288
View File
@@ -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;
};
})();
+229
View File
@@ -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 '<span class="hint">Keine Zuordnungen im Projektboard.</span>';
return assignees.map(a => {
const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : '';
return `<span class="todo-assignee ${cls}">${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}</span>`;
}).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);
})();
+173
View File
@@ -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 += `<div class="section-title">${esc(g)}</div>`;
html += `<div class="projects-sortable" data-group="${g}">`;
groups[g].forEach(p=>{
html += `<div class="person-list-item" draggable="true" data-project-id="${p.id}" ondragstart="handleDragStart(event)" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragend="handleDragEnd(event)">
<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px">⋮⋮</span>
<input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr">
<input type="text" value="${esc(p.name)}" onchange="editProjectField('${p.id}','name',this.value)" style="width:200px" placeholder="Bezeichnung">
<select onchange="editProjectField('${p.id}','group',this.value)">
<option ${p.group==='Aktive Projekte'?'selected':''}>Aktive Projekte</option>
<option ${p.group==='Kleine Projekte'?'selected':''}>Kleine Projekte</option>
<option ${p.group==='In Anbahnung'?'selected':''}>In Anbahnung</option>
<option ${p.group==='Allgemein'?'selected':''}>Allgemein</option>
</select>
<select onchange="editProjectActiveLP('${p.id}',this.value)" title="Aktive Leistungsphase wählen">
<option value="" ${lpActiveList(p).length===0?'selected':''}>Aktive LP: keine</option>
${AUFWAND_LPS.map(l => `<option value="${l}" ${lpActiveList(p)[0]===l?'selected':''}>${l}</option>`).join('')}
</select>
<button class="secondary small" onclick="openLpModal('${p.id}')">✎ LP-Aufwand</button>
<button class="danger small" onclick="delProjectFromList('${p.id}')">Löschen</button>
</div>`;
});
html += `</div>`;
if(groups[g].length===0) html+=`<div class="hint">Keine Projekte.</div>`;
});
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();
}
+54
View File
@@ -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 <baseUrl>/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(); });
}
+207
View File
@@ -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 = '<div class="show-mode-loading">Lädt…</div>';
}
// 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();
}
});
+178
View File
@@ -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};
}
+68
View File
@@ -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;
}
+165
View File
@@ -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);
});
}
+122
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// 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);
}
+156
View File
@@ -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=>`<option value="${w.id}" ${w.id===db.currentWeek?'selected':''}>${w.label}</option>`).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;
}
+27 -2
View File
@@ -1,4 +1,18 @@
<!DOCTYPE html>
<!--
PROJEKTPLAN-ANSICHT: zeigt für ein einzelnes Projekt ein GANTT-Diagramm mit TODO- und
Meilenstein-Zeilen, Abhängigkeiten, Zoom, kritischem Pfad, Auto-Schedule, Ressourcen-
Auslastung und Abwesenheiten der Projektbeteiligten. Die Seite wird über openProjectPlan()
in js/gantt.js in einem neuen Tab geöffnet und über URL-Parameter (Abteilung, Woche,
Projekt-Nr./Name) adressiert. Sie lädt styles.css und ausschließlich die gemeinsamen
Module js/globals.js, js/utils.js, js/ui.js, js/date-picker.js, js/csv.js, js/server.js
und js/gantt.js sowie danach die Plan-Module js/plan/plan-state.js,
js/plan/plan-build-gantt.js, js/plan/plan-gantt.js, js/plan/plan-drag.js,
js/plan/plan-modals.js und js/plan/plan-schedule.js. WICHTIG: store, weeks, board, people,
projects, load, showmode, tabs-auth, pdf und init dürfen hier NICHT geladen werden, da sie
Elemente der Hauptseite Test.html voraussetzen. Änderungen werden über savePlan() in
js/plan/plan-state.js nur für die aktuelle Woche direkt an den Server geschrieben.
-->
<html lang="de">
<head>
<meta charset="UTF-8">
@@ -299,7 +313,18 @@
</div>
</div>
<script src="main.v2.js?v=4"></script>
<script src="project-plan.js?v=4"></script>
<script src="js/globals.js?v=5"></script>
<script src="js/utils.js?v=5"></script>
<script src="js/ui.js?v=5"></script>
<script src="js/date-picker.js?v=5"></script>
<script src="js/csv.js?v=5"></script>
<script src="js/server.js?v=5"></script>
<script src="js/gantt.js?v=5"></script>
<script src="js/plan/plan-state.js?v=5"></script>
<script src="js/plan/plan-build-gantt.js?v=5"></script>
<script src="js/plan/plan-gantt.js?v=5"></script>
<script src="js/plan/plan-drag.js?v=5"></script>
<script src="js/plan/plan-modals.js?v=5"></script>
<script src="js/plan/plan-schedule.js?v=5"></script>
</body>
</html>
+19
View File
@@ -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);
+1 -1
View File
@@ -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 <div>- KW 44 Möbel </div>;;;;;;;;;;;;;;;;;;;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 <div>- KW 44 Möbel </div>;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 <div>- LV liegt zur Sichtung bei T.W.</div>;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;2;;;
22-4066;DSW 21, Umbau E-Mobilität;- Elt-Montagen ab <font><b>KW 33</b></font>.;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;6;;;
24-4043;S-K ODL-Heim Sanierung Krefeld;- Vorraussichtlich LV-Abgabe XX.XX<div>- LPH5 mündlich freigegeben <div>- Neuer EG Grundriss mit diversen Änderungen kommt noch von Architekten</div></div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;10;;;
1 WOCHE KW 33 / 2026 2026-08-09
2 PERSONEN AP|Planung|Alexander Plum
3 Pr.-Nr. Bezeichnung Status AP
4 ANWESENHEIT
5 18-4007 Janusz-Korczak-Gesamtsch. Bottrop - KW 35 NW Raum Planung <div>- KW 44 Möbel </div> X
6 21-4030 NSHV Köln Immobilienprüfung in Hof - Thorsten W. klärt und übernimmt Schriftverkehr <div>- LV liegt zur Sichtung bei T.W.</div> X
7 22-4066 DSW 21, Umbau E-Mobilität - Elt-Montagen ab <font><b>KW 33</b></font>.
8 24-4043 S-K ODL-Heim Sanierung Krefeld - Vorraussichtlich LV-Abgabe XX.XX<div>- LPH5 mündlich freigegeben <div>- Neuer EG Grundriss mit diversen Änderungen kommt noch von Architekten</div></div>