code auf einzelne Datein aufgeteilt
This commit is contained in:
1 parent
7451e0bf78
commit
8c94fc91f6
30 files changed
+5765
-27
No files matched your search
@@ -11,8 +11,9 @@ Two HTML entrypoints, both served statically by Express:
|
||||
- `Test.html` — the main app (tabs: Projektboard, Auslastung, Projekte,
|
||||
Projektbeteiligte). Served at `/`.
|
||||
- `project-plan.html` — per-project Gantt/TODO planning view, opened in a new
|
||||
tab via `openProjectPlan()` in `main.v2.js:2358`. Loads `main.v2.js` first,
|
||||
then `project-plan.js`.
|
||||
tab via `openProjectPlan()` in `js/gantt.js`. Loads the shared helper modules
|
||||
(`globals`, `utils`, `ui`, `date-picker`, `csv`, `server`, `gantt`) plus the
|
||||
plan modules under `js/plan/` — NOT the full main module set.
|
||||
|
||||
All UI strings/comments/CSV content are German. Preserve German in
|
||||
user-facing text.
|
||||
@@ -29,9 +30,32 @@ user-facing text.
|
||||
├── .vscode/extensions.json
|
||||
├── public/
|
||||
│ ├── Test.html # main app (served at /)
|
||||
│ ├── main.v2.js # entire main client app (~3200 lines)
|
||||
│ ├── js/ # main app modules (classic scripts, global fns)
|
||||
│ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS) + db/currentDepartment
|
||||
│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName, weekSortKey-Quelle
|
||||
│ │ ├── ui.js # LocalStorage-UI-State, Header-Collapse, Theme
|
||||
│ │ ├── date-picker.js # Datums-/Bereichspicker-Overlays
|
||||
│ │ ├── csv.js # CSV-Parser/Serializer + weekSortKey, filenameForWeek
|
||||
│ │ ├── server.js # baseUrl, sendCsvToServer, deleteCsvFromServer, blobToDataUrl
|
||||
│ │ ├── store.js # save/syncAllToServer, switchDepartment, load, seed
|
||||
│ │ ├── weeks.js # Wochen-Auswahl, -Erzeugung, -Klonen, Auto-Lock, -Löschung
|
||||
│ │ ├── board.js # Projektboard-Rendering, Status-Editor, Anwesenheit, Zuordnung
|
||||
│ │ ├── people.js # Personen-Tab-Rendering + Drag-and-Drop + Abwesenheiten
|
||||
│ │ ├── projects.js # Projekte-Tab-Rendering + Drag-and-Drop
|
||||
│ │ ├── load.js # Auslastungs-Tab, LP-Modal, Neues-Projekt-Modal
|
||||
│ │ ├── gantt.js # GANTT-Diagramm (buildGanttHtml, openProjectPlan), geshared
|
||||
│ │ ├── showmode.js # Show-Mode (Präsentationsmodus)
|
||||
│ │ ├── tabs-auth.js # showTab, confirmPassword (SHA256-Passwortschutz)
|
||||
│ │ ├── pdf.js # PDF-Export (pdfmake, A3 quer)
|
||||
│ │ └── init.js # renderAll/initUiState + Seiteneinstieg (nur Test.html)
|
||||
│ ├── project-plan.html # Gantt/TODO planning view
|
||||
│ ├── project-plan.js # planning logic (~1500 lines)
|
||||
│ ├── js/plan/ # planning modules (nur von project-plan.html geladen)
|
||||
│ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts
|
||||
│ │ ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen)
|
||||
│ │ ├── plan-gantt.js # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load
|
||||
│ │ ├── plan-drag.js # GANTT-Balken-Drag/Resize + TODO-/Meilenstein-Zeilen-Drag
|
||||
│ │ ├── plan-modals.js # TODO-, Meilenstein- und Abwesenheits-Modals
|
||||
│ │ └── plan-schedule.js # Kritischer Pfad, Auto-Schedule, Mail-Link
|
||||
│ ├── styles.css
|
||||
│ └── dein_bild.ico
|
||||
└── sessions/ # data; bind-mounted into Docker container
|
||||
@@ -47,7 +71,7 @@ docker compose up --build # Docker variant (compose file is at repo root)
|
||||
```
|
||||
|
||||
- PORT env var overrides default 3003 in `server.js`.
|
||||
- Frontend must be served over HTTP: `baseUrl()` in `main.v2.js` uses
|
||||
- Frontend must be served over HTTP: `baseUrl()` in `js/server.js` uses
|
||||
`window.location.origin` or falls back to `http://localhost:3003` when opened
|
||||
via `file://` (then only seed/demo data loads).
|
||||
- Check `/health` returns `{ok:true}` after starting.
|
||||
@@ -61,7 +85,7 @@ docker compose up --build # Docker variant (compose file is at repo root)
|
||||
- `POST /delete-csv {filename, department}`.
|
||||
- Valid departments hard-coded as `ELT|HLS|ARCH` in `server.js`
|
||||
(`VALID_DEPARTMENTS`); anything else silently falls back to `ELT`. Same set in
|
||||
`DEPARTMENTS` in `main.v2.js`.
|
||||
`DEPARTMENTS` in `js/globals.js`.
|
||||
- `safeName()` strictly validates filenames (path traversal protection);
|
||||
static assets get `Cache-Control: no-store`.
|
||||
- JSON body limit is 2 MB.
|
||||
@@ -79,13 +103,23 @@ docker compose up --build # Docker variant (compose file is at repo root)
|
||||
into the current week. Historical weeks keep their own person copies, so
|
||||
deleting a global person leaves old sessions unaffected no matter what.
|
||||
- Don't change the column layout without updating both `weekToCsv()` and
|
||||
`parseSessionCsv()` (`main.v2.js`), which must stay inverse of each other.
|
||||
`parseSessionCsv()` (`js/csv.js`), which must stay inverse of each other.
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
- `main.v2.js` is loaded as a classic non-module `<script>`; **all functions are
|
||||
globals on `window`** and shared across both pages. Edit functions in place;
|
||||
the file is one large script with German section-header comments.
|
||||
- The frontend is split into small classic non-module `<script>` files under
|
||||
`public/js/`; **all functions are globals on `window`**. Two HTML files include
|
||||
them via `<script src>` tags; load order in the HTML defines dependencies,
|
||||
since there is no bundler:
|
||||
- `Test.html` loads `globals → utils → ui → date-picker → csv → server →
|
||||
store → weeks → board → people → projects → load → gantt → showmode →
|
||||
tabs-auth → pdf → init`. See the exact list in `Test.html`.
|
||||
- `project-plan.html` loads only `globals → utils → ui → date-picker → csv →
|
||||
server → gantt` plus the `js/plan/*` modules. It must NOT load modules that
|
||||
touch main-only page elements (`store`, `weeks`, `board`, …, `init`).
|
||||
- Shared state object `window.PlanApp` (`js/plan/plan-state.js`) holds all
|
||||
project-plan page state (week, project, zoom level, drag/`modal`-state),
|
||||
`db`/`currentDepartment` live in `js/globals.js`.
|
||||
- State: `let db = { weeks, currentWeek, globalPeople }` (in-memory only).
|
||||
- Project IDs are regenerated on every CSV load, so projects are addressed
|
||||
cross-page by `department` + week `label` + `projNr` + `projName` via URL
|
||||
@@ -93,7 +127,7 @@ docker compose up --build # Docker variant (compose file is at repo root)
|
||||
- Person IDs are stable across reloads via `personIdForName(name)` →
|
||||
`p_<lowercased alnum>`.
|
||||
- `save()` debounces 500 ms then `syncAllToServer()` writes **all** weeks plus
|
||||
`_people.csv` — not just the changed week. Exception: `project-plan.js`
|
||||
`_people.csv` — not just the changed week. Exception: `js/plan/plan-state.js`
|
||||
`savePlan()` bypasses `save()` and writes only the current week's CSV directly
|
||||
via `sendCsvToServer()`.
|
||||
- Weeks are sorted by calendar week extracted from the label (`KW XX / YYYY`)
|
||||
@@ -108,12 +142,12 @@ docker compose up --build # Docker variant (compose file is at repo root)
|
||||
|
||||
- The `projects` and `people` tabs, week deletion, and week unlock are gated by
|
||||
a password modal comparing `CryptoJS.SHA256(input)` to
|
||||
`PROTECTED_PASSWORD_HASH` (`main.v2.js`). Client-side only — not real
|
||||
security. CryptoJS and pdfmake (PDF export) are loaded from
|
||||
`cdnjs.cloudflare.com` in `Test.html`, so those features need internet access
|
||||
and won't work offline. `project-plan.html` loads neither.
|
||||
`PROTECTED_PASSWORD_HASH` (`js/globals.js`, modal in `js/tabs-auth.js`).
|
||||
Client-side only — not real security. CryptoJS and pdfmake (PDF export) are
|
||||
loaded from `cdnjs.cloudflare.com` in `Test.html`, so those features need
|
||||
internet access and won't work offline. `project-plan.html` loads neither.
|
||||
- User-provided status HTML is sanitized on render via a small whitelist
|
||||
(`sanitizeStatusHtml` in `main.v2.js`) to mitigate XSS.
|
||||
(`sanitizeStatusHtml` in `js/board.js`) to mitigate XSS.
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Übersicht
|
||||
Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrotechnischer Projekte und Sitzungsübersichten. Die Anwendung besteht aus:
|
||||
|
||||
- einem Frontend in `Test.html`, `styles.css` und `main.v2.js`
|
||||
- einem Frontend in `Test.html`, `styles.css` und den JS-Modulen unter `public/js/`
|
||||
- einem einfachen Node.js-Server in `server.js`
|
||||
- einem Datenspeicher in CSV-Dateien unter `sessions/`
|
||||
- einem Docker-Setup unter `Docker/`
|
||||
@@ -14,7 +14,7 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
||||
- `server.js`
|
||||
- Node.js/Express-Server
|
||||
- dient als API für das Frontend
|
||||
- stellt statische Dateien (`Test.html`, `main.v2.js`, `styles.css`, etc.) bereit
|
||||
- stellt statische Dateien (`Test.html`, die Module unter `js/`, `styles.css`, etc.) bereit
|
||||
- implementiert Endpunkte:
|
||||
- `GET /` → liefert `Test.html`
|
||||
- `GET /load-all` → lädt alle CSV-Dateien aus dem aktiven `sessions/<DEPARTMENT>/`-Ordner inkl. `_people.csv`
|
||||
@@ -31,8 +31,12 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
||||
- `package-lock.json`
|
||||
- wird für reproduzierbare Docker-Builds verwendet (`npm ci`)
|
||||
|
||||
- `main.v2.js`
|
||||
- komplette Client-Logik der Anwendung
|
||||
- `public/js/` (Module)
|
||||
- Die Client-Logik ist in kleine klassische Script-Dateien aufgeteilt (alle Funktionen global auf `window`):
|
||||
`globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `csv.js`, `server.js`, `store.js`, `weeks.js`,
|
||||
`board.js`, `people.js`, `projects.js`, `load.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`,
|
||||
`init.js` sowie die Plan-Module unter `js/plan/` (`plan-state`, `plan-build-gantt`, `plan-gantt`,
|
||||
`plan-drag`, `plan-modals`, `plan-schedule`).
|
||||
- rendert das Projektboard, die Projekt- und Personenverwaltung sowie die Auslastungsansicht
|
||||
- lädt Daten über den Server und speichert Änderungen zurück als CSV
|
||||
- verwendet `currentDepartment` zur Trennung von Abteilungen (`ELT`, `HLS`, `ARCH`)
|
||||
@@ -45,9 +49,14 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
||||
|
||||
- `Test.html`
|
||||
- die Benutzeroberfläche der Anwendung
|
||||
- lädt `styles.css` und `main.v2.js`
|
||||
- lädt `styles.css` und die JS-Module aus `js/` in fester Reihenfolge (siehe `AGENTS.md`)
|
||||
- enthält die Abteilungsauswahl, Sitzungsnavigation, Tab-Navigation und das Passwort-Modal
|
||||
|
||||
- `project-plan.html`
|
||||
- die Planungsansicht (GANTT/TODOs/Meilensteine) pro Projekt
|
||||
- öffnet sich in einem neuen Tab über `openProjectPlan()` (`js/gantt.js`)
|
||||
- lädt nur die gemeinsamen Helfermodule plus `js/plan/*`
|
||||
|
||||
- `styles.css`
|
||||
- Layout und Styling der Oberfläche
|
||||
|
||||
@@ -98,7 +107,7 @@ Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrote
|
||||
|
||||
### Wo werden die Dateien gespeichert?
|
||||
- Das Frontend schreibt über den Server in das Dateisystem.
|
||||
- In `main.v2.js` ruft die Anwendung beim Speichern `/save-csv` auf.
|
||||
- In `js/store.js` ruft die Anwendung beim Speichern `/save-csv` auf.
|
||||
- Der Server legt CSV-Dateien unter `sessions/<DEPARTMENT>/` ab.
|
||||
- Beispiel:
|
||||
- `sessions/ELT/KW_32___2026.csv`
|
||||
@@ -134,6 +143,6 @@ http://localhost:3003
|
||||
## Wichtige Hinweise
|
||||
- Der Server benötigt Lese-/Schreibzugriff auf `sessions/`.
|
||||
- Die Docker-Compose-Konfiguration mountet `./sessions` in den Container. Achte darauf, dass der relative Pfad im Kontext korrekt ist, wenn du `docker compose` aus einem anderen Verzeichnis startest.
|
||||
- `main.v2.js` arbeitet mit Abteilungsparametern und speichert Daten abteilungsweise in separaten Unterordnern.
|
||||
- `js/store.js` und `js/server.js` arbeiten mit Abteilungsparametern und speichern Daten abteilungsweise in separaten Unterordnern.
|
||||
- Die Passwortfunktion ist nur ein einfacher Client-Mechanismus im Frontend und kein echter Sicherheitsmechanismus.
|
||||
- Globale Personen werden in `_people.csv` verwaltet. Wird eine Person global gelöscht, bleibt sie in bereits existierenden Sitzungen sichtbar.
|
||||
+29
-1
@@ -1,4 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
HAUPTSEITE der Projektübersicht (wird vom Express-Server unter "/" ausgeliefert).
|
||||
Die Seite zeigt die vier Tabs Projektboard, Auslastung, Projekte und Projektbeteiligte
|
||||
und verwaltet wöchentliche Sitzungen (Sitzungswahl, -anlage, -duplikat und -löschung im
|
||||
seitlichen Menü) je Abteilung (ELT, HLS, Architektur). Sie lädt das gemeinsame Stylesheet
|
||||
styles.css sowie der Reihe nach die Module js/globals.js, js/utils.js, js/ui.js,
|
||||
js/date-picker.js, js/csv.js, js/server.js, js/store.js, js/weeks.js, js/board.js,
|
||||
js/people.js, js/projects.js, js/load.js, js/gantt.js, js/showmode.js, js/tabs-auth.js,
|
||||
js/pdf.js und js/init.js; crypto-js und pdfmake kommen von einem CDN. Über openProjectPlan()
|
||||
aus js/gantt.js öffnet diese Seite die Projektplan-Ansicht project-plan.html in einem
|
||||
neuen Tab, die nur die gemeinsamen Module plus die js/plan/*-Module lädt.
|
||||
-->
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -209,6 +221,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="main.v2.js"></script>
|
||||
<script src="js/globals.js?v=5"></script>
|
||||
<script src="js/utils.js?v=5"></script>
|
||||
<script src="js/ui.js?v=5"></script>
|
||||
<script src="js/date-picker.js?v=5"></script>
|
||||
<script src="js/csv.js?v=5"></script>
|
||||
<script src="js/server.js?v=5"></script>
|
||||
<script src="js/store.js?v=5"></script>
|
||||
<script src="js/weeks.js?v=5"></script>
|
||||
<script src="js/board.js?v=5"></script>
|
||||
<script src="js/people.js?v=5"></script>
|
||||
<script src="js/projects.js?v=5"></script>
|
||||
<script src="js/load.js?v=5"></script>
|
||||
<script src="js/gantt.js?v=5"></script>
|
||||
<script src="js/showmode.js?v=5"></script>
|
||||
<script src="js/tabs-auth.js?v=5"></script>
|
||||
<script src="js/pdf.js?v=5"></script>
|
||||
<script src="js/init.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
BIN
Binary file not shown.
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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: '' }
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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();
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(); });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — allgemeine Helferfunktionen
|
||||
// -----------------------------
|
||||
// Diese Datei bündelt kleine, allgemein verwendbare Hilfsfunktionen (Escape-,
|
||||
// ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden
|
||||
// HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen
|
||||
// ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen,
|
||||
// u. a. js/csv.js, js/board.js, js/load.js sowie den Plan-Modulen unter js/plan/*
|
||||
// (z. B. für ISO-Datumsformate und Werktage) und von js/gantt.js.
|
||||
|
||||
// departmentParam: gibt ein Query-String-Fragment fuer die aktuelle Abteilung zurueck.
|
||||
// Parameter: keine. Rückgabe: String wie '?department=ELT'.
|
||||
function departmentParam(){
|
||||
return '?department=' + encodeURIComponent(currentDepartment);
|
||||
}
|
||||
|
||||
// esc: einfache Escape-Funktion für Ausgabe in HTML
|
||||
// Parameter: s (String). Rückgabe: HTML-sicherer String (&, <, >, " ersetzt).
|
||||
function esc(s){ return (s||"").replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
// uid: generiert eine einfache, ausreichend eindeutige ID
|
||||
// Parameter: keine. Rückgabe: String aus Zeitstempel und Zufallsteil.
|
||||
function uid(){ return Date.now().toString(36)+Math.random().toString(36).slice(2,7); }
|
||||
|
||||
// assignKey: Schlüssel für Projektzuordnungen (Person + Rolle getrennt)
|
||||
// Parameter: personId (String), role (String). Rückgabe: kombinierter Schlüssel 'personId|role'.
|
||||
function assignKey(personId, role){ return personId + '|' + role; }
|
||||
|
||||
// sortGroups: sortiert Gruppen-Namen nach GROUP_ORDER, unbekannte ans Ende
|
||||
// Parameter: arr (Array von Gruppennamen, wird in-place sortiert). Rückgabe: dasselbe Array.
|
||||
function sortGroups(arr){
|
||||
return arr.sort((a,b)=>{
|
||||
const ia=GROUP_ORDER.indexOf(a), ib=GROUP_ORDER.indexOf(b);
|
||||
return (ia===-1?999:ia)-(ib===-1?999:ib);
|
||||
});
|
||||
}
|
||||
|
||||
// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und
|
||||
// Zuordnungen auch nach einem Neuladen stabil bleiben.
|
||||
// Parameter: name (String). Rückgabe: String wie 'p_ab'.
|
||||
function personIdForName(name){
|
||||
return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_');
|
||||
}
|
||||
|
||||
// formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
|
||||
// Parameter: dateString (String). Rückgabe: formatierter String oder leer.
|
||||
function formatWeekDate(dateString){
|
||||
if(!dateString) return '';
|
||||
const parts = dateString.split('-');
|
||||
if(parts.length!==3) return dateString;
|
||||
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||
}
|
||||
|
||||
// ---------- Datumshilfsfunktionen (GANTT) ----------
|
||||
|
||||
// parseDateISO: parst einen ISO-Datumsstring (YYYY-MM-DD) in ein Date-Objekt (lokal)
|
||||
// Parameter: d (String). Rückgabe: Date-Objekt oder null bei ungültigem Format.
|
||||
function parseDateISO(d){
|
||||
if(!d) return null;
|
||||
const m = String(d).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if(!m) return null;
|
||||
const date = new Date(parseInt(m[1]), parseInt(m[2])-1, parseInt(m[3]));
|
||||
if(isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
// formatDateISO: formatiert ein Date-Objekt als ISO-Datum (YYYY-MM-DD)
|
||||
// Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum.
|
||||
function formatDateISO(d){
|
||||
if(!d || isNaN(d.getTime())) return '';
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth()+1).padStart(2,'0');
|
||||
const day = String(d.getDate()).padStart(2,'0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// addDays: addiert n Tage zu einem Datum (ohne das Original zu verändern)
|
||||
// Parameter: d (Date), n (Anzahl Tage). Rückgabe: neues Date-Objekt.
|
||||
function addDays(d, n){
|
||||
const res = new Date(d.getTime());
|
||||
res.setDate(res.getDate() + n);
|
||||
return res;
|
||||
}
|
||||
|
||||
// daysBetween: Anzahl der Kalendertage zwischen zwei Daten
|
||||
// Parameter: a, b (Date). Rückgabe: ganzzahlige Tagesdifferenz (b - a).
|
||||
function daysBetween(a, b){
|
||||
const ms = b.getTime() - a.getTime();
|
||||
return Math.round(ms / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// workdaysBetween: Anzahl der Werktage (Mo–Fr) im Zeitraum inkl. Start- und Endtag.
|
||||
// Wochenendtage (Sa/So) werden nicht mitgezählt – z.B. für die Auslastung.
|
||||
// Parameter: a, b (Date). Rückgabe: Anzahl der Werktage oder 0 bei ungültigem Zeitraum.
|
||||
function workdaysBetween(a, b){
|
||||
if(!a || !b || b < a) return 0;
|
||||
let count = 0;
|
||||
let current = new Date(a.getTime());
|
||||
while(current <= b){
|
||||
const day = current.getDay();
|
||||
if(day !== 0 && day !== 6) count++;
|
||||
current = addDays(current, 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// formatDateDE: formatiert ein Date-Objekt als deutsches Datum (DD.MM.YYYY)
|
||||
// Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum.
|
||||
function formatDateDE(d){
|
||||
if(!d || isNaN(d.getTime())) return '';
|
||||
return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
// ISO-Kalenderwoche nach https://en.wikipedia.org/wiki/ISO_week_date
|
||||
// Parameter: d (Date). Rückgabe: Kalenderwochennummer (1-53).
|
||||
function getWeekNumber(d){
|
||||
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
const dayNum = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
PROJEKTPLAN-ANSICHT: zeigt für ein einzelnes Projekt ein GANTT-Diagramm mit TODO- und
|
||||
Meilenstein-Zeilen, Abhängigkeiten, Zoom, kritischem Pfad, Auto-Schedule, Ressourcen-
|
||||
Auslastung und Abwesenheiten der Projektbeteiligten. Die Seite wird über openProjectPlan()
|
||||
in js/gantt.js in einem neuen Tab geöffnet und über URL-Parameter (Abteilung, Woche,
|
||||
Projekt-Nr./Name) adressiert. Sie lädt styles.css und ausschließlich die gemeinsamen
|
||||
Module js/globals.js, js/utils.js, js/ui.js, js/date-picker.js, js/csv.js, js/server.js
|
||||
und js/gantt.js sowie danach die Plan-Module js/plan/plan-state.js,
|
||||
js/plan/plan-build-gantt.js, js/plan/plan-gantt.js, js/plan/plan-drag.js,
|
||||
js/plan/plan-modals.js und js/plan/plan-schedule.js. WICHTIG: store, weeks, board, people,
|
||||
projects, load, showmode, tabs-auth, pdf und init dürfen hier NICHT geladen werden, da sie
|
||||
Elemente der Hauptseite Test.html voraussetzen. Änderungen werden über savePlan() in
|
||||
js/plan/plan-state.js nur für die aktuelle Woche direkt an den Server geschrieben.
|
||||
-->
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -299,7 +313,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="main.v2.js?v=4"></script>
|
||||
<script src="project-plan.js?v=4"></script>
|
||||
<script src="js/globals.js?v=5"></script>
|
||||
<script src="js/utils.js?v=5"></script>
|
||||
<script src="js/ui.js?v=5"></script>
|
||||
<script src="js/date-picker.js?v=5"></script>
|
||||
<script src="js/csv.js?v=5"></script>
|
||||
<script src="js/server.js?v=5"></script>
|
||||
<script src="js/gantt.js?v=5"></script>
|
||||
<script src="js/plan/plan-state.js?v=5"></script>
|
||||
<script src="js/plan/plan-build-gantt.js?v=5"></script>
|
||||
<script src="js/plan/plan-gantt.js?v=5"></script>
|
||||
<script src="js/plan/plan-drag.js?v=5"></script>
|
||||
<script src="js/plan/plan-modals.js?v=5"></script>
|
||||
<script src="js/plan/plan-schedule.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,12 +1,23 @@
|
||||
/*
|
||||
* Express-Server der Projektübersicht: liefert die statischen Frontend-Dateien aus
|
||||
* public/ (Test.html unter "/") aus und stellt den Frontend-Modulen unter public/js/
|
||||
* eine CSV-API zum Laden und Speichern der Sitzungsdaten in sessions/ bereit.
|
||||
* Standardport ist 3003, überschreibbar über die Umgebungsvariable PORT.
|
||||
* Endpunkte: GET /, GET /health, GET /load-all, POST /save-csv, POST /delete-csv.
|
||||
* Die Daten werden als UTF-8-CSV mit BOM geschrieben; ungültige Abteilungen fallen
|
||||
* auf ELT zurück, Dateinamen werden über safeName() streng validiert.
|
||||
*/
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const app = express();
|
||||
|
||||
// Wichtige Konstanten: UTF-8-BOM für CSV-Dateien, Serverport (PORT-Env) und die gültigen Abteilungsordner
|
||||
const BOM = Buffer.from([0xEF, 0xBB, 0xBF]);
|
||||
const PORT = process.env.PORT || 3003;
|
||||
const VALID_DEPARTMENTS = new Set(['ELT','HLS','ARCH']);
|
||||
|
||||
// Globale Middleware: CORS, JSON-Body-Limit (2 MB) und statisches Serving von public/ ohne Cache für Skripte/Styles
|
||||
app.use(require('cors')());
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public'), {
|
||||
@@ -19,6 +30,7 @@ app.use(express.static(path.join(__dirname, 'public'), {
|
||||
}
|
||||
}));
|
||||
|
||||
// Datenverzeichnis sessions/ bei Bedarf anlegen
|
||||
const SESSIONS_DIR = path.join(__dirname, 'sessions');
|
||||
if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
||||
|
||||
@@ -34,6 +46,7 @@ function safeName(name){
|
||||
return safe;
|
||||
}
|
||||
|
||||
// Liefert den Zielordner für eine Abteilung (nur ELT|HLS|ARCH, sonst ELT) und legt ihn bei Bedarf an
|
||||
function departmentDir(department){
|
||||
const dept = (typeof department === 'string' && VALID_DEPARTMENTS.has(department)) ? department : 'ELT';
|
||||
const dir = path.join(SESSIONS_DIR, dept);
|
||||
@@ -41,6 +54,7 @@ function departmentDir(department){
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Route: Speichert eine CSV-Datei (mit UTF-8-BOM) für eine Abteilung im sessions/-Ordner
|
||||
app.post('/save-csv', (req, res) => {
|
||||
const { filename, csv, department } = req.body || {};
|
||||
if(!filename || typeof csv !== 'string') return res.status(400).json({ ok: false, error: 'Missing filename or csv' });
|
||||
@@ -56,6 +70,7 @@ app.post('/save-csv', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Route: Löscht eine CSV-Datei einer Abteilung aus dem sessions/-Ordner (fehlende Datei wird toleriert)
|
||||
app.post('/delete-csv', (req, res) => {
|
||||
const { filename, department } = req.body || {};
|
||||
if(!filename) return res.status(400).json({ ok: false, error: 'Missing filename' });
|
||||
@@ -77,6 +92,7 @@ app.post('/delete-csv', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Route: Lädt alle Sitzungs-CSV-Dateien einer Abteilung plus _people.csv (nach Dateiname sortiert)
|
||||
app.get('/load-all', (req, res) => {
|
||||
const dir = departmentDir(req.query.department);
|
||||
fs.readdir(dir, (err, files) => {
|
||||
@@ -97,12 +113,15 @@ app.get('/load-all', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Route: Healthcheck-Endpunkt für Docker/Überwachung
|
||||
app.get('/health', (req,res)=> res.json({ok:true}));
|
||||
|
||||
// Route: Liefert die Hauptseite Test.html
|
||||
app.get('/', (req,res)=>{
|
||||
res.sendFile(path.join(__dirname, 'public', 'Test.html'));
|
||||
});
|
||||
|
||||
// Serverstart mit Fehlerbehandlung (z.B. Port bereits belegt)
|
||||
const server = app.listen(PORT, ()=> console.log(`CSV-Session server running on http://localhost:${PORT}`));
|
||||
server.on('error', (err) => {
|
||||
console.error('Server konnte nicht starten:', err.message);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
PERSONEN;;;AP|Planung|Alexander Plum;AK|Planung|Alex Kenne;ED|Planung|Erik Dunin;SK|Planung|Simon Kunigk;SE|Planung|Steven Esche;WS|Planung|Winfried Sältzer;ED|Projektleitung|Erik Dunin;SP|Projektleitung|Sebastian Pöttinger;THK|Projektleitung|Thorsten Kramp;RR|Projektleitung|Robert Röllecke;MK|Projektleitung|Maximilian Kriener;TW|Projektleitung|Thorsten Werner;SG|Projektleitung|Steffen Gaida;ED|Bauüberwachung|Erik Dunin;SK|Bauüberwachung|Simon Kunigk;SP|Bauüberwachung|Sebastian Pöttinger;THK|Bauüberwachung|Thorsten Kramp;HR|Bauüberwachung|Hermann Rollecke;;;;;;;;;;;;;;
|
||||
Pr.-Nr.;Bezeichnung;Status;AP;AK;ED;SK;SE;WS;ED;SP;THK;RR;MK;TW;SG;ED;SK;SP;THK;HR;LPH 1;LPH 2;LPH 3;LPH 4;LPH 5;LPH 6;LPH 7;LPH 8;W+M;Gruppe;Index;Plan;Aufwand;Aktive Phasen
|
||||
ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
18-4007;Janusz-Korczak-Gesamtsch. Bottrop;- KW 35 NW Raum Planung <div>- KW 44 Möbel </div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;90%;;;;;Aktive Projekte;0;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";"{""LPH 5"":{""Planung"":0.6},""LPH 8"":{""Planung"":0,""Projektleitung"":0.2}}";
|
||||
18-4007;Janusz-Korczak-Gesamtsch. Bottrop;- KW 35 NW Raum Planung <div>- KW 44 Möbel </div>;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;90%;;;;;Aktive Projekte;0;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";"{""LPH 5"":{""Planung"":0.6},""LPH 8"":{""Planung"":0,""Projektleitung"":0.2}}";
|
||||
21-4030;NSHV Köln Immobilienprüfung in Hof;- Thorsten W. klärt und übernimmt Schriftverkehr <div>- LV liegt zur Sichtung bei T.W.</div>;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;2;;;
|
||||
22-4066;DSW 21, Umbau E-Mobilität;- Elt-Montagen ab <font><b>KW 33</b></font>.;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;6;;;
|
||||
24-4043;S-K ODL-Heim Sanierung Krefeld;- Vorraussichtlich LV-Abgabe XX.XX<div>- LPH5 mündlich freigegeben <div>- Neuer EG Grundriss mit diversen Änderungen kommt noch von Architekten</div></div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;10;;;
|
||||
|
||||
|
Reference in new issue
Block a user