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, - `Test.html` — the main app (tabs: Projektboard, Auslastung, Projekte,
Projektbeteiligte). Served at `/`. Projektbeteiligte). Served at `/`.
- `project-plan.html` — per-project Gantt/TODO planning view, opened in a new - `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, tab via `openProjectPlan()` in `js/gantt.js`. Loads the shared helper modules
then `project-plan.js`. (`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 All UI strings/comments/CSV content are German. Preserve German in
user-facing text. user-facing text.
@@ -29,9 +30,32 @@ user-facing text.
├── .vscode/extensions.json ├── .vscode/extensions.json
├── public/ ├── public/
│ ├── Test.html # main app (served at /) │ ├── 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.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 │ ├── styles.css
│ └── dein_bild.ico │ └── dein_bild.ico
└── sessions/ # data; bind-mounted into Docker container └── 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`. - 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 `window.location.origin` or falls back to `http://localhost:3003` when opened
via `file://` (then only seed/demo data loads). via `file://` (then only seed/demo data loads).
- Check `/health` returns `{ok:true}` after starting. - 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}`. - `POST /delete-csv {filename, department}`.
- Valid departments hard-coded as `ELT|HLS|ARCH` in `server.js` - Valid departments hard-coded as `ELT|HLS|ARCH` in `server.js`
(`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in (`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); - `safeName()` strictly validates filenames (path traversal protection);
static assets get `Cache-Control: no-store`. static assets get `Cache-Control: no-store`.
- JSON body limit is 2 MB. - 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 into the current week. Historical weeks keep their own person copies, so
deleting a global person leaves old sessions unaffected no matter what. deleting a global person leaves old sessions unaffected no matter what.
- Don't change the column layout without updating both `weekToCsv()` and - 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 ## Frontend architecture
- `main.v2.js` is loaded as a classic non-module `<script>`; **all functions are - The frontend is split into small classic non-module `<script>` files under
globals on `window`** and shared across both pages. Edit functions in place; `public/js/`; **all functions are globals on `window`**. Two HTML files include
the file is one large script with German section-header comments. 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). - State: `let db = { weeks, currentWeek, globalPeople }` (in-memory only).
- Project IDs are regenerated on every CSV load, so projects are addressed - Project IDs are regenerated on every CSV load, so projects are addressed
cross-page by `department` + week `label` + `projNr` + `projName` via URL 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)` → - Person IDs are stable across reloads via `personIdForName(name)` →
`p_<lowercased alnum>`. `p_<lowercased alnum>`.
- `save()` debounces 500 ms then `syncAllToServer()` writes **all** weeks plus - `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 `savePlan()` bypasses `save()` and writes only the current week's CSV directly
via `sendCsvToServer()`. via `sendCsvToServer()`.
- Weeks are sorted by calendar week extracted from the label (`KW XX / YYYY`) - 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 - The `projects` and `people` tabs, week deletion, and week unlock are gated by
a password modal comparing `CryptoJS.SHA256(input)` to a password modal comparing `CryptoJS.SHA256(input)` to
`PROTECTED_PASSWORD_HASH` (`main.v2.js`). Client-side only — not real `PROTECTED_PASSWORD_HASH` (`js/globals.js`, modal in `js/tabs-auth.js`).
security. CryptoJS and pdfmake (PDF export) are loaded from Client-side only — not real security. CryptoJS and pdfmake (PDF export) are
`cdnjs.cloudflare.com` in `Test.html`, so those features need internet access loaded from `cdnjs.cloudflare.com` in `Test.html`, so those features need
and won't work offline. `project-plan.html` loads neither. internet access and won't work offline. `project-plan.html` loads neither.
- User-provided status HTML is sanitized on render via a small whitelist - 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 ## Docker
+16 -7
View File
@@ -3,7 +3,7 @@
## Übersicht ## Übersicht
Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrotechnischer Projekte und Sitzungsübersichten. Die Anwendung besteht aus: 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 einfachen Node.js-Server in `server.js`
- einem Datenspeicher in CSV-Dateien unter `sessions/` - einem Datenspeicher in CSV-Dateien unter `sessions/`
- einem Docker-Setup unter `Docker/` - einem Docker-Setup unter `Docker/`
@@ -14,7 +14,7 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
- `server.js` - `server.js`
- Node.js/Express-Server - Node.js/Express-Server
- dient als API für das Frontend - 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: - implementiert Endpunkte:
- `GET /` → liefert `Test.html` - `GET /` → liefert `Test.html`
- `GET /load-all` → lädt alle CSV-Dateien aus dem aktiven `sessions/<DEPARTMENT>/`-Ordner inkl. `_people.csv` - `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` - `package-lock.json`
- wird für reproduzierbare Docker-Builds verwendet (`npm ci`) - wird für reproduzierbare Docker-Builds verwendet (`npm ci`)
- `main.v2.js` - `public/js/` (Module)
- komplette Client-Logik der Anwendung - 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 - rendert das Projektboard, die Projekt- und Personenverwaltung sowie die Auslastungsansicht
- lädt Daten über den Server und speichert Änderungen zurück als CSV - lädt Daten über den Server und speichert Änderungen zurück als CSV
- verwendet `currentDepartment` zur Trennung von Abteilungen (`ELT`, `HLS`, `ARCH`) - 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` - `Test.html`
- die Benutzeroberfläche der Anwendung - 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 - 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` - `styles.css`
- Layout und Styling der Oberfläche - 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? ### Wo werden die Dateien gespeichert?
- Das Frontend schreibt über den Server in das Dateisystem. - 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. - Der Server legt CSV-Dateien unter `sessions/<DEPARTMENT>/` ab.
- Beispiel: - Beispiel:
- `sessions/ELT/KW_32___2026.csv` - `sessions/ELT/KW_32___2026.csv`
@@ -134,6 +143,6 @@ http://localhost:3003
## Wichtige Hinweise ## Wichtige Hinweise
- Der Server benötigt Lese-/Schreibzugriff auf `sessions/`. - 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. - 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. - 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. - 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> <!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"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -209,6 +221,22 @@
</div> </div>
</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> </body>
</html> </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> <!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"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -299,7 +313,18 @@
</div> </div>
</div> </div>
<script src="main.v2.js?v=4"></script> <script src="js/globals.js?v=5"></script>
<script src="project-plan.js?v=4"></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> </body>
</html> </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 express = require('express');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const app = express(); 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 BOM = Buffer.from([0xEF, 0xBB, 0xBF]);
const PORT = process.env.PORT || 3003; const PORT = process.env.PORT || 3003;
const VALID_DEPARTMENTS = new Set(['ELT','HLS','ARCH']); 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(require('cors')());
app.use(express.json({ limit: '2mb' })); app.use(express.json({ limit: '2mb' }));
app.use(express.static(path.join(__dirname, 'public'), { 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'); const SESSIONS_DIR = path.join(__dirname, 'sessions');
if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true }); if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
@@ -34,6 +46,7 @@ function safeName(name){
return safe; return safe;
} }
// Liefert den Zielordner für eine Abteilung (nur ELT|HLS|ARCH, sonst ELT) und legt ihn bei Bedarf an
function departmentDir(department){ function departmentDir(department){
const dept = (typeof department === 'string' && VALID_DEPARTMENTS.has(department)) ? department : 'ELT'; const dept = (typeof department === 'string' && VALID_DEPARTMENTS.has(department)) ? department : 'ELT';
const dir = path.join(SESSIONS_DIR, dept); const dir = path.join(SESSIONS_DIR, dept);
@@ -41,6 +54,7 @@ function departmentDir(department){
return dir; return dir;
} }
// Route: Speichert eine CSV-Datei (mit UTF-8-BOM) für eine Abteilung im sessions/-Ordner
app.post('/save-csv', (req, res) => { app.post('/save-csv', (req, res) => {
const { filename, csv, department } = req.body || {}; const { filename, csv, department } = req.body || {};
if(!filename || typeof csv !== 'string') return res.status(400).json({ ok: false, error: 'Missing filename or csv' }); 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) => { app.post('/delete-csv', (req, res) => {
const { filename, department } = req.body || {}; const { filename, department } = req.body || {};
if(!filename) return res.status(400).json({ ok: false, error: 'Missing filename' }); 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) => { app.get('/load-all', (req, res) => {
const dir = departmentDir(req.query.department); const dir = departmentDir(req.query.department);
fs.readdir(dir, (err, files) => { 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})); app.get('/health', (req,res)=> res.json({ok:true}));
// Route: Liefert die Hauptseite Test.html
app.get('/', (req,res)=>{ app.get('/', (req,res)=>{
res.sendFile(path.join(__dirname, 'public', 'Test.html')); 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}`)); const server = app.listen(PORT, ()=> console.log(`CSV-Session server running on http://localhost:${PORT}`));
server.on('error', (err) => { server.on('error', (err) => {
console.error('Server konnte nicht starten:', err.message); 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;;;;;;;;;;;;;; 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 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 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;;; 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;;; 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;;; 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>