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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 15:14:47 +02:00
GitButler bc30f2900b GitButler Workspace Commit
This is placeholder commit and will be replaced by a merge of your virtual branches.

Due to GitButler managing multiple virtual branches, you cannot switch back and
forth between git branches and virtual branches easily. 

If you switch to another branch, GitButler will need to be reinitialized.
If you commit on this branch, GitButler will throw it away.

For more information about what we're doing here, check out our docs:
https://docs.gitbutler.com/features/branch-management/integration-branch
2026-10-02 07:47:57 +02:00
erik 18d04e2c4a Zwischen Commit 2026-10-02 07:47:57 +02:00
erik 4fc5acfebb Claude change 2026-09-14 18:10:49 +02:00
erik 1c176ea621 DB Import und Export sowie Backup und kleinere Fehlerbeseitigung 2026-08-18 09:16:59 +02:00
erik 91cc898ec7 Legende Projektkürzel im dark mode jetzt auch dunkel 2026-08-14 15:15:22 +02:00
erik 4e083bee73 Unnötige Datein gelöscht. DB normalisiert 2026-08-14 15:03:24 +02:00
75 changed files with 17926 additions and 8497 deletions

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
# SQLite-Datenbank (Laufzeitartefakt in sessions/)
sessions/*.db
sessions/*.db-wal
sessions/*.db-shm
# macOS-Artefakte
.DS_Store
sessions/*.txt
Binary file not shown.
-198
View File
@@ -1,198 +0,0 @@
# AGENTS.md
## Overview
Single-page app for tracking engineering projects across weekly sessions
("Sitzungen"). Vanilla JS/HTML/CSS frontend + Node.js/Express server. No build
step, no bundler, no tests. Data is persisted as semicolon-separated CSVs
under `sessions/`.
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 `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.
## File structure
```
.
├── server.js # Express server + CSV API
├── package.json # deps: express, cors only
├── docker-compose.yml # at repo ROOT; build context ., dockerfile Docker/Dockerfile
├── Docker/Dockerfile # node:20-alpine
├── .dockerignore
├── .vscode/extensions.json
├── public/
│ ├── Test.html # main app (served at /)
│ ├── js/ # shared modules (classic scripts, global fns)
│ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS) + db/currentDepartment
│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName, peopleByRole, assignKey, 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
│ │ ├── 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)
│ │ ├── board/ # tab-specific: Projektboard
│ │ │ └── board.js # Projektboard-Rendering, Status-Editor, Anwesenheit, Zuordnung
│ │ ├── people/ # tab-specific: Projektbeteiligte
│ │ │ └── people.js # Personen-Tab-Rendering + Drag-and-Drop + Abwesenheiten
│ │ ├── projects/ # tab-specific: Projekte
│ │ │ └── projects.js # Projekte-Tab-Rendering + Drag-and-Drop
│ │ ├── load/ # tab-specific: Auslastung
│ │ │ └── load.js # Auslastungs-Tab, LP-Modal, Neues-Projekt-Modal
│ ├── project-plan.html # Gantt/TODO planning view
│ ├── 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
├── ELT/ HLS/ ARCH/ # one folder per department
```
## Running
```bash
npm install
npm start # http://localhost:3003, or PORT=3004 if occupied
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 `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.
## Server API (`server.js`)
- `GET /` → `Test.html`; `GET /health`.
- Deep links per department + tab: `GET /<Dept>/<Tab>` → `Test.html`, e.g.
`/HLS/Auslastung` or `/ELT/Projektübersicht`. Tab slugs (`TAB_SLUGS` in
`js/globals.js`) are `Projektübersicht|Auslastung|Projekte|Projektbeteiligte`;
anything else returns 404. Exception: `GET /<Dept>/Projektplan(/<ProjName>)?`
(`PLAN_SLUG`) serves `project-plan.html`, the GANTT view, with
`weekLabel|projNr` as query params (the project name is a readable path
segment only; identification happens via weekLabel + projNr). The frontend
reads/writes these routes via `applyUrlRoute()`/`updateRouteUrl()` in
`js/ui.js` and all static assets in the HTML use **absolute** paths
(`/js/...`, `/styles.css`) so they resolve on nested routes.
- `GET /load-all?department=` → `{sessions:[{filename,content}], people}` with
`_people.csv` content in `people`; sessions sorted by filename.
- `POST /save-csv {filename, csv, department}` → writes UTF-8 BOM CSV.
- `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 `js/globals.js`.
- `safeName()` strictly validates filenames (path traversal protection);
static assets get `Cache-Control: no-store`.
- JSON body limit is 2 MB.
## CSV format
- Delimiter `;`, UTF-8 with BOM. Hand-editing is possible but risky; the parser
reconstructs objects from the fixed column layout.
- Week files `<Label>.csv` (e.g. `KW_30___2026.csv` — `filenameForWeek()`
replaces non-word chars with `_`). Row layout is positional:
`WOCHE` (label, date, locked flag) → `PERSONEN` (`name|role|fullName` per
column) → header row → `ANWESENHEIT` → one row per project.
- `_people.csv` per department: `NAME;Rollen;Voller Name;E-Mail;Abwesenheiten`
(absences as JSON). People are global per department; new people are copied
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()` (`js/csv.js`), which must stay inverse of each other.
## Frontend architecture
- 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` (shared modules), then the tab-specific folders `board →
people → projects → load`, finally `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` (+ readable projName path
segment) via URL params: `openProjectPlan()` builds
`/HLS/Projektplan/<ProjName>?weekLabel=…&projNr=…`, `findProjectForPlan`
resolves them.
- 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: `js/plan/plan-state.js`
`savePlan()` bypasses `save()` and writes only the current week's CSV directly
via `sendCsvToServer()`.
- `switchDepartment()` cancels a pending save debounce before switching, and
`load()` discards stale `/load-all` responses after a department switch
(frozen `requestedDepartment`), so late responses can never render or persist
data under the wrong department.
- Person drag-and-drop in the people tab: drag handle (`.drag-handle`) binds
`ondragstart`/`ondragend`, the `.people-sortable` container binds
`ondragover`/`ondrop`; insertion is computed by `getDragAfterElement(container,
y, selector)` (`js/projects/projects.js`, used with `.person-card` here and
`.person-list-item` in the projects tab). `reorderPeople()` updates both
`db.globalPeople` and the current week's person copy, then `save()`. No drag
handles are rendered on locked weeks.
- Weeks are sorted by calendar week extracted from the label (`KW XX / YYYY`)
via `weekSortKey()`, not alphabetically/by filename.
- `applyAutoLock()` locks every week except the chronologically last one after
load/clone/new-week; `localStorage` UI state is not restored for locked tabs.
- `localStorage` keys: `projuebersicht_v1` (department only — tab is never
restored from storage; at startup the tab comes from the deep-link URL if
present, otherwise `board`), `projuebersicht-theme`, header-collapse key. Do
**not** store data in `localStorage`; it is UI state only.
- Deep-link URLs (`/<Dept>/<Tab>`) are shared via the address bar:
`updateRouteUrl()` rewrites the path via `history.replaceState` on tab and
department switches; protected tabs keep the URL but open the password modal.
## Auth / security quirk
- The `projects` and `people` tabs, week deletion, and week unlock are gated by
a password modal comparing `CryptoJS.SHA256(input)` to
`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 `js/board/board.js`) to mitigate XSS.
## Docker
- `docker compose up --build` from the repo root. Compose maps `3003:3003`,
bind-mounts `./sessions:/app/sessions`, `restart: unless-stopped`.
- Dockerfile: `node:20-alpine`, `npm ci --production`, runs as non-root `node`,
HEALTHCHECK on `/health`. `.dockerignore` excludes `Docker/*` (except
`Docker/Dockerfile`), `node_modules/`, `.git`, `npm-debug.log`.
## Conventions & verification
- Git repo; commits are German (e.g. "PDF Export von A4 auf A3 …"). Remote:
`http://192.168.3.187:3000/erik/projektuebersicht.git`.
- No linter, formatter, typecheck, or test suite. Manual verification only:
start server, open `http://localhost:3003`, exercises the relevant tab,
confirm CSV changes in `sessions/`.
+3217
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -1,5 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:20-alpine # node:22-alpine, weil node:sqlite (SQLite-Persistenz in server.js) erst ab
# Node 22.13 (ohne Flag) stabil verfügbar ist.
FROM node:22-alpine
WORKDIR /app WORKDIR /app
@@ -11,7 +13,7 @@ RUN npm ci --production
COPY server.js ./ COPY server.js ./
COPY public ./public COPY public ./public
# Sessions-Ordner fuer die CSV-Daten sicherstellen # Sessions-Ordner fuer die SQLite-Datenbank sicherstellen
RUN mkdir -p /app/sessions && chown -R node:node /app RUN mkdir -p /app/sessions && chown -R node:node /app
USER node USER node
+110 -129
View File
@@ -1,148 +1,129 @@
# Projektübersicht — README # Projektübersicht
## Übersicht Werkzeug für ein Ingenieurbüro der technischen Gebäudeausrüstung (TGA), um
Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrotechnischer Projekte und Sitzungsübersichten. Die Anwendung besteht aus: Projekte über wöchentliche Sitzungen hinweg zu verfolgen: Leistungsphasen nach
HOAI, Termine, Personaleinsatz, die wöchentliche Teambesprechung und die
Dokumentenfreigabe. Single-Page-App ohne Build-Schritt: Vanilla JS/HTML/CSS
im Frontend, Node.js/Express im Backend, Daten in SQLite.
- einem Frontend in `Test.html`, `styles.css` und den JS-Modulen unter `public/js/` ## Technologie
- einem einfachen Node.js-Server in `server.js`
- einem Datenspeicher in CSV-Dateien unter `sessions/`
- einem Docker-Setup unter `Docker/`
## Datei- und Komponentenübersicht - **Frontend**: klassische, unmodulare `<script>`-Dateien unter `public/js/`
— alle Funktionen liegen global auf `window`, kein Bundler, kein Framework.
**Eine** HTML-Datei, `public/App.html`, bildet die gesamte Oberfläche ab
(Seitenleiste + alle Bereiche inklusive des Projektbereichs eines einzelnen
Projekts).
- **Backend**: Node.js (≥ 22.13, wegen `node:sqlite`) + Express, alles in
einer einzigen `server.js`.
- **Datenbank**: SQLite über das eingebaute `node:sqlite`-Modul (WAL-Modus,
vollständig relational, keine Einzeldatei je Woche), Datei unter
`sessions/projektuebersicht.db`.
- Kein Linter, kein Formatter, keine Testsuite. Verifikation läuft manuell:
Server starten, Bereich im Browser ausprobieren, SQLite-Datei prüfen.
### Wichtige Laufzeit-Dateien ## Starten
- `server.js`
- Node.js/Express-Server
- dient als API für das Frontend
- 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`
- `POST /save-csv` → speichert eine CSV-Datei (Dateiname wird validiert)
- `POST /delete-csv` → löscht eine CSV-Datei
- `GET /health` → Gesundheitscheck
- legt das `sessions`-Verzeichnis beim Start an, falls es nicht existiert
- beendet sich mit aussagekräftiger Fehlermeldung, wenn der Port belegt ist
- `package.json` ### Lokal (Node.js ≥ 22.13)
- definiert den Node.js-Startbefehl `npm start`
- enthält Produktionsabhängigkeiten `express` und `cors`
- `package-lock.json`
- wird für reproduzierbare Docker-Builds verwendet (`npm ci`)
- `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`)
- speichert:
- `sessions/<DEPT>/<WEEK>.csv`
- `sessions/<DEPT>/_people.csv` (globale Personenliste pro Abteilung)
- sortiert Sitzungen nach Kalenderwoche, nicht alphabetisch
- sperrt historische Sitzungen automatisch (bis auf die chronologisch letzte)
- Personen sind global pro Abteilung verwaltet; historische Sitzungen behalten ihre lokalen Personenkopien
- `Test.html`
- die Benutzeroberfläche der Anwendung
- 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
### Docker-bezogene Dateien
- `Docker/Dockerfile`
- Basis-Image: `node:20-alpine`
- kopiert `package.json` und `package-lock.json`, installiert Produktionsabhängigkeiten mit `npm ci`
- kopiert den Rest des Projekts in `/app`
- legt sicherheitshalber `/app/sessions` an
- läuft als nicht-root-User `node`
- enthält einen `HEALTHCHECK` auf `/health`
- startet `node server.js`
- `docker-compose.yml`
- definiert den Service `projektuebersicht`
- baut das Image aus dem Projektstamm mit `Docker/Dockerfile`
- mappt Port `3003:3003`
- bindet das Host-Verzeichnis `./sessions` in den Container nach `/app/sessions`
- setzt `restart: unless-stopped`
- definiert einen `healthcheck`
- `.dockerignore`
- schließt unnötige Dateien aus dem Docker-Build aus: `Docker/`, `node_modules/`, `npm-debug.log`, `.git`, `.gitignore`
### Sonstige Dateien
- `dein_bild.ico`
- optionales Icon, wird vom Server statisch ausgeliefert, wenn angefragt
- `AGENTS.md`
- Projektnotizen / Dokumentation, aber nicht Teil des Anwendungslaufs
## Speicherorte und Datenfluss
### Speicherung
- Alle persistierten Daten werden als CSV-Dateien in `sessions/` gespeichert.
- Innerhalb von `sessions/` existieren Abteilungsordner:
- `sessions/ELT/`
- `sessions/HLS/`
- `sessions/ARCH/`
- Jede Abteilung enthält mindestens:
- `_people.csv` → globale Personenliste mit Rollen und vollständigem Namen
- `KW_XX___YYYY.csv` oder vergleichbare Sitzungsdateien → Sitzungsdaten mit Projekten, Zuordnungen und Anwesenheit
### Wo wird der Server erstellt?
- Der Server wird in `server.js` erstellt.
- `server.js` startet einen Express-Server auf Port `3003` oder dem Wert von `process.env.PORT`.
- Das Frontend ruft diesen Server über `window.location.origin` an.
### Wo werden die Dateien gespeichert?
- Das Frontend schreibt über den Server in das Dateisystem.
- 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`
- `sessions/ELT/_people.csv`
## Betrieb
### Lokal mit Node.js
```bash ```bash
npm install npm install
npm start npm start
``` ```
Danach ist die Anwendung erreichbar unter `http://localhost:3003`. Erreichbar unter `http://localhost:3003`. Anderer Port:
Falls der Port belegt ist:
```bash ```bash
PORT=3004 npm start PORT=3004 npm start
``` ```
### Mit Docker ### Mit Docker
Im Projektstamm ausführen:
```bash ```bash
docker compose -f Docker/docker-compose.yml up --build docker compose up --build
``` ```
`docker-compose.yml` liegt im Projektstamm, baut aus `Docker/Dockerfile`
(`node:22-alpine`), mappt Port `3003` und bindet `./sessions` in den
Container nach `/app/sessions` — die Datenbank und alle Uploads bleiben damit
auf dem Host erhalten. `TZ=Europe/Berlin` ist gesetzt, weil mehrere
Datumsvergleiche im Server von der lokalen Zeitzone abhängen.
Danach ist die Anwendung erreichbar unter: ### Erste Anmeldung
Die gesamte Anwendung verlangt eine Anmeldung. Ist die `users`-Tabelle beim
ersten Start leer, legt der Server automatisch ein Admin-Konto
(`admin`) mit einem zufälligen Passwort an, gibt es einmalig auf der Konsole
aus und schreibt es zusätzlich nach `sessions/INITIAL_ADMIN_PASSWORD.txt`
(liegt außerhalb von `public/`, wird also nie ausgeliefert). Das Konto muss
sein Passwort beim ersten Login ändern.
``` ## Bereiche der Anwendung
http://localhost:3003
```
## Wichtige Hinweise Seitenleiste, in dieser Reihenfolge:
- 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. - **Meine Aufgaben** — Dashboard und Startansicht nach dem Login: offene
- `js/store.js` und `js/server.js` arbeiten mit Abteilungsparametern und speichern Daten abteilungsweise in separaten Unterordnern. persönliche Aufgaben, Termine der eigenen Projekte, ausstehende
- Die Passwortfunktion ist nur ein einfacher Client-Mechanismus im Frontend und kein echter Sicherheitsmechanismus. Datei-Freigaben, Abwesenheiten von Kollegen.
- Globale Personen werden in `_people.csv` verwaltet. Wird eine Person global gelöscht, bleibt sie in bereits existierenden Sitzungen sichtbar. - **Projekte** — die eine Projektliste (Board), gruppiert, per Drag-and-Drop
sortierbar.
- **Wochenbericht** — die Tagesordnung der wöchentlichen Teambesprechung, je
Gewerk (ELT/HLS/ARCH) gefiltert: jedes Projekt dieses Gewerks wird
angesehen, mit Vergleich zur Vorwoche, Anwesenheitserfassung und PDF-Export.
- **Auslastung** — Personalauslastung in tatsächlichen Wochenstunden,
aufgeteilt nach Projekten (Lastband).
- **Projektbeteiligte** — schreibgeschützte Übersicht der aktiven
Benutzerkonten; Teammitglieder pflegt ausschließlich ein Administrator
(Admin → Benutzerverwaltung).
- **Planung** — ein von einem konkreten Projekt unabhängiger
Terminplan-Sandkasten, z. B. um einen Zeitplan vorab durchzurechnen.
- **Einstellungen** — das eigene Konto: Stammdaten, E-Mail, Passwort, eigene
KI-Zugangsdaten für die Protokoll-Erkennung (siehe unten).
- **Admin** *(nur Administratoren)* — Benutzerverwaltung, Honorar-Vorgaben,
Auslastungs-Schwellen, Sitzungsverwaltung, Feiertage, Aufgaben-Vorlagen,
Betrieb & Sicherheit, Datenbank-Backup/-Import.
### Projektbereich
Aus der Projektliste heraus öffnet sich zu einem Projekt ein eigener Bereich
mit sieben Abschnitten: Dashboard, Terminplan (GANTT mit Aufgaben und
Meilensteinen), Aufgaben, Leistung (erbrachter Leistungswert je Phase),
Dateien, Protokolle (Besprechungsprotokolle) und Einstellungen
(Projektstammdaten, Honorar, beauftragte Leistungsphasen).
## Datenhaltung
- Vollständig relationales SQLite-Schema statt eines JSON-Dokuments je Woche;
Projekt- und Personen-IDs bleiben über Neustarts stabil.
- Eine Sitzung ("KW XX / YYYY") entspricht einer Kalenderwoche. Der Wechsel
in eine neue Sitzung läuft automatisch im Hintergrund (stündliche Prüfung)
— es gibt keine "Neue Sitzung"-Schaltfläche mehr.
- Der Server legt bei jedem Start automatisch einen Zeitstempel-Snapshot
unter `sessions/backups/` an und behält nur die neuesten (Anzahl einstellbar
unter Admin → Betrieb & Sicherheit). Ein Admin kann zusätzlich jederzeit
ein Backup herunterladen oder eine Sicherung zurückspielen.
- Hochgeladene Dateien (Freigabe-Workflow, importierte Protokolle) liegen
unter `sessions/uploads/<uuid>.<endung>`, referenziert über die Datenbank.
## Authentifizierung & Rollen
Echte serverseitige Sitzungen (Cookie, `scrypt`-gehashte Passwörter) — kein
clientseitiger Passwortschutz. Zwei Rollen: `admin` und `member`; nur
wirklich destruktive bzw. administrative Aktionen (Benutzerverwaltung,
Sitzung löschen/entsperren, DB-Import/-Backup) sind serverseitig auf Admins
beschränkt. Zu viele Fehlversuche sperren ein Konto vorübergehend.
## Besonderheiten
- **Keine Abteilungstrennung mehr**: Alle Sitzungen, Projekte und Personen
liegen in einer gemeinsamen Liste. `ELT`/`HLS`/`ARCH` bezeichnen heute nur
noch die Fachrichtung eines Benutzerkontos bzw. die Gewerke eines Projekts.
- **Protokoll-Import** kann ein hochgeladenes PDF/Word-Dokument
KI-gestützt (eigener OpenAI- oder Claude-Schlüssel je Konto, auch ein
lokal laufendes Modell möglich) in Besprechungspunkte umwandeln und fällt
ohne Schlüssel oder bei einem Fehler automatisch auf eine
Text-Heuristik zurück.
- Der PDF-Export lädt `pdfmake` von `cdnjs.cloudflare.com` — benötigt
Internetzugang.
- Feiertage werden serverseitig berechnet (keine externe Abhängigkeit), die
KI-gestützte Protokoll-Erkennung braucht dagegen Internetzugang bzw. ein
erreichbares lokales Modell.
## Weiterführende Dokumentation
`CLAUDE.md` im Projektstamm enthält die ausführliche technische
Dokumentation: Server-API, vollständiges Datenmodell, Architekturentscheidungen
und ihre Begründung. Ausgangspunkt für tiefere Änderungen am Code.
+291
View File
@@ -0,0 +1,291 @@
# Agent
<!--introduced_in=v3.2.0-->
<!--type=module-->
<!-- source_link=lib/dispatcher/agent.js -->
> Stability: 2 - Stable
An `Agent` dispatches requests against multiple different origins, creating and
reusing a per-origin [`Pool`][] (or [`Client`][]) on demand. It is the default
dispatcher used by [`request`][], [`stream`][], and [`fetch`][] when no explicit
dispatcher is supplied.
Requests are not guaranteed to be dispatched in their order of invocation.
```mjs
import { Agent, setGlobalDispatcher } from 'undici'
const agent = new Agent({ connections: 10 })
setGlobalDispatcher(agent)
```
## Class: `Agent`
<!-- YAML
added: v3.2.0
-->
* Extends: {Dispatcher}
`Agent` keeps an internal map of origins to dispatchers. The first request to a
given origin lazily creates a dispatcher through the configured `factory`, and
subsequent requests to the same origin reuse it. Idle dispatchers are closed
automatically once they have no open connections and are no longer busy.
### `new Agent([options])`
<!-- YAML
added: v3.2.0
changes:
- version: v7.16.0
pr-url: https://github.com/nodejs/undici/pull/4365
description: Added the `maxOrigins` option to cap the number of origins.
-->
* `options` {AgentOptions} (optional) Extends {PoolOptions}.
* `factory` {Function} Builds the dispatcher used for a given origin. When the
resolved options contain `connections: 1`, the default factory creates a
{Client}; otherwise it creates a {Pool}. **Default:** `(origin, opts) => new Pool(origin, opts)`.
* `origin` {URL|string} The origin to create a dispatcher for.
* `opts` {Object} The resolved options for the new dispatcher.
* Returns: {Dispatcher}
* `maxOrigins` {number} Limits the total number of distinct origins that may
receive requests at the same time. A {MaxOriginsReachedError} is thrown when
a request targets a new origin once the limit is reached. When set to
`Infinity`, no limit is enforced. Must be a number greater than `0`.
**Default:** `Infinity`.
`Agent` inherits all {PoolOptions} (and therefore all {ClientOptions}). Each
origin gets a separate {Pool}, with `connections` acting as the maximum number
of clients that pool may create.
> [!NOTE]
> For an h2-capable HTTPS origin, the per-origin pool waits for the first TLS
> connection to finish ALPN negotiation. If the server selects h2, concurrent
> requests share that session up to `maxConcurrentStreams`. If it selects
> HTTP/1.1, normal connection fan-out resumes up to `connections`. See
> {PoolOptions} and {ClientOptions} for inherited options such as `allowH2`
> (default `true`) and `maxConcurrentStreams` (default `100`).
### `agent.closed`
<!-- YAML
added: v3.2.0
-->
* Type: {boolean}
`true` after [`agent.close()`][] has been called.
### `agent.destroyed`
<!-- YAML
added: v3.2.0
-->
* Type: {boolean}
`true` after [`agent.destroy()`][] has been called, or after [`agent.close()`][]
has been called and the shutdown has completed.
### `agent.stats`
<!-- YAML
added: v7.9.0
-->
* Type: {Record<string, ClientStats|PoolStats>}
Aggregate statistics for the agent, keyed by origin. Each value is the
[`ClientStats`][] or [`PoolStats`][] object reported by the dispatcher currently
serving that origin. Origins whose dispatcher does not expose `stats` are
omitted.
```mjs
import { Agent } from 'undici'
const agent = new Agent()
await agent.request({ origin: 'http://localhost:3000', path: '/', method: 'GET' })
console.log(agent.stats)
// { 'http://localhost:3000': PoolStats { ... } }
```
### `agent.dispatch(options, handler)`
<!-- YAML
added: v3.2.0
-->
* `options` {AgentDispatchOptions} Extends {DispatchOptions}.
* `origin` {string|URL} The origin to dispatch the request against. Required.
* `handler` {DispatchHandler}
* Returns: {boolean} `false` if the dispatcher is busy and the caller should
wait for the [`'drain'`][] event before dispatching again.
Routes the request to the dispatcher for `options.origin`, creating one through
the `factory` if needed. Throws an {InvalidArgumentError} when `options.origin`
is not a non-empty string or {URL}, and a {MaxOriginsReachedError} when a new
origin would exceed `maxOrigins`. Implements [`Dispatcher.dispatch()`][].
### `agent.close([callback])`
<!-- YAML
added: v3.2.0
-->
* `callback` {Function} (optional) Invoked once every per-origin dispatcher has
closed. When omitted, a {Promise} is returned.
* Returns: {Promise|void}
Gracefully closes the agent and every dispatcher it owns, waiting for in-flight
requests to complete. Implements [`Dispatcher.close()`][].
### `agent.destroy([error[, callback]])`
<!-- YAML
added: v3.2.0
-->
* `error` {Error} (optional) The error to abort pending requests with.
* `callback` {Function} (optional) Invoked once every per-origin dispatcher has
been destroyed. When omitted, a {Promise} is returned.
* Returns: {Promise|void}
Forcefully destroys the agent and every dispatcher it owns, aborting all pending
requests. Implements [`Dispatcher.destroy()`][].
### `agent.connect(options[, callback])`
<!-- YAML
added: v3.2.0
-->
See [`Dispatcher.connect()`][]. The request is routed to the dispatcher for
`options.origin`.
### `agent.pipeline(options, handler)`
<!-- YAML
added: v3.2.0
-->
See [`Dispatcher.pipeline()`][]. The request is routed to the dispatcher for
`options.origin`.
### `agent.request(options[, callback])`
<!-- YAML
added: v3.2.0
-->
See [`Dispatcher.request()`][]. The request is routed to the dispatcher for
`options.origin`.
```mjs
import { Agent } from 'undici'
const agent = new Agent()
const { statusCode, body } = await agent.request({
origin: 'http://localhost:3000',
path: '/',
method: 'GET',
})
console.log('response received', statusCode)
console.log('data', await body.text())
```
### `agent.stream(options, factory[, callback])`
<!-- YAML
added: v3.2.0
-->
See [`Dispatcher.stream()`][]. The request is routed to the dispatcher for
`options.origin`.
### `agent.upgrade(options[, callback])`
<!-- YAML
added: v3.2.0
-->
See [`Dispatcher.upgrade()`][]. The request is routed to the dispatcher for
`options.origin`.
### Event: `'connect'`
<!-- YAML
added: v6.6.0
-->
* `origin` {URL}
* `targets` {Array<Dispatcher>} The dispatchers involved, starting with this
`Agent` followed by the per-origin dispatcher chain.
Emitted when a socket has been created for an origin and is ready to receive
requests. Forwarded from the underlying dispatcher's [`'connect'`][] event.
### Event: `'disconnect'`
<!-- YAML
added: v6.6.0
-->
* `origin` {URL}
* `targets` {Array<Dispatcher>} The dispatchers involved, starting with this
`Agent`.
* `error` {Error}
Emitted when a socket for an origin has disconnected. Forwarded from the
underlying dispatcher's [`'disconnect'`][] event.
### Event: `'connectionError'`
<!-- YAML
added: v6.6.0
-->
* `origin` {URL}
* `targets` {Array<Dispatcher>} The dispatchers involved, starting with this
`Agent`.
* `error` {Error}
Emitted when a connection attempt for an origin fails. Forwarded from the
underlying dispatcher's [`'connectionError'`][] event.
### Event: `'drain'`
<!-- YAML
added: v6.6.0
-->
* `origin` {URL}
* `targets` {Array<Dispatcher>} The dispatchers involved, starting with this
`Agent`.
Emitted when an origin's dispatcher is no longer busy and can accept more
requests. Forwarded from the underlying dispatcher's [`'drain'`][] event.
[`'connect'`]: Dispatcher.md#event-connect
[`'connectionError'`]: Dispatcher.md#event-connectionerror
[`'disconnect'`]: Dispatcher.md#event-disconnect
[`'drain'`]: Dispatcher.md#event-drain
[`Client`]: Client.md#class-client
[`ClientStats`]: ClientStats.md
[`Dispatcher.close()`]: Dispatcher.md#dispatcherclosecallback-promise
[`Dispatcher.connect()`]: Dispatcher.md#dispatcherconnectoptions-callback
[`Dispatcher.destroy()`]: Dispatcher.md#dispatcherdestroyerror-callback-promise
[`Dispatcher.dispatch()`]: Dispatcher.md#dispatcherdispatchoptions-handler
[`Dispatcher.pipeline()`]: Dispatcher.md#dispatcherpipelineoptions-handler
[`Dispatcher.request()`]: Dispatcher.md#dispatcherrequestoptions-callback
[`Dispatcher.stream()`]: Dispatcher.md#dispatcherstreamoptions-factory-callback
[`Dispatcher.upgrade()`]: Dispatcher.md#dispatcherupgradeoptions-callback
[`Pool`]: Pool.md#class-pool
[`PoolStats`]: PoolStats.md
[`agent.close()`]: #agentclosecallback
[`agent.destroy()`]: #agentdestroyerror-callback
[`fetch`]: Fetch.md
[`request`]: Dispatcher.md#dispatcherrequestoptions-callback
[`stream`]: Dispatcher.md#dispatcherstreamoptions-factory-callback
+11 -1
View File
@@ -11,7 +11,8 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"mammoth": "^1.13.0", "mammoth": "^1.13.0",
"pdfjs-dist": "^6.3.289" "pdfjs-dist": "^6.3.289",
"undici": "^8.11.2"
} }
}, },
"node_modules/@napi-rs/canvas": { "node_modules/@napi-rs/canvas": {
@@ -1304,6 +1305,15 @@
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz",
"integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+2 -1
View File
@@ -10,6 +10,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"mammoth": "^1.13.0", "mammoth": "^1.13.0",
"pdfjs-dist": "^6.3.289" "pdfjs-dist": "^6.3.289",
"undici": "^8.11.2"
} }
} }
BIN
View File
Binary file not shown.
+1673
View File
File diff suppressed because it is too large. Load diff
-244
View File
@@ -1,244 +0,0 @@
<!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, danach die
tab-spezifischen Module js/board/board.js, js/people/people.js,
js/projects/projects.js, js/load/load.js sowie 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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projektübersicht Elektro</title>
<link rel="stylesheet" href="/styles.css?v=3">
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/pdfmake.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/vfs_fonts.js"></script>
</head>
<body>
<div class="dept-tabs">
<button class="menu-corner-btn" onclick="toggleHeader()" title="Menü öffnen/schließen" aria-expanded="false" aria-controls="headerMenu">
<span class="header-toggle-icon">☰</span>
</button>
<div class="dept-tabs-center">
<button class="dept-tab active" data-dept="ELT" onclick="switchDepartment('ELT')">ELT</button>
<button class="dept-tab" data-dept="HLS" onclick="switchDepartment('HLS')">HLS</button>
<button class="dept-tab" data-dept="ARCH" onclick="switchDepartment('ARCH')">Architektur</button>
</div>
</div>
<header>
<div class="header-bar">
<h1 id="pageTitle">Projektübersicht Elektro</h1>
<img src="/dein_bild.ico" alt="Logo" class="header-logo">
</div>
</header>
<div id="headerMenu" class="side-menu" aria-hidden="true">
<div class="side-menu-header">
<span>Menü</span>
<div class="side-menu-header-actions">
<button class="theme-toggle-circle" id="themeToggle" onclick="toggleTheme()" title="Hell/Dunkel umschalten">
<span id="themeIcon">🌙</span>
</button>
<button class="header-toggle secondary" onclick="toggleHeader()" title="Menü schließen">
<span class="header-toggle-icon">✕</span>
</button>
</div>
</div>
<div class="side-menu-content">
<div class="menu-section">
<div class="menu-label">Sitzung (KW)</div>
<select id="weekSelect" onchange="switchWeek(this.value)"></select>
<div id="weekDateDisplay" class="week-date"></div>
</div>
<hr class="menu-divider">
<div class="menu-section">
<div class="menu-label">Sitzungen verwalten</div>
<button onclick="newWeek()">+ Neue Sitzung</button>
<button class="secondary" onclick="cloneWeek()">Sitzung duplizieren</button>
<button class="danger" onclick="deleteWeek()">Sitzung löschen</button>
</div>
<hr class="menu-divider">
<div class="menu-section">
<div class="menu-label">Präsentation</div>
<button class="secondary" onclick="exportPDF()">PDF Export</button>
<button class="secondary" onclick="startShowMode()">Show Mode</button>
</div>
</div>
</div>
<!-- Show Mode Overlay -->
<div id="showModeOverlay" class="show-mode-overlay" style="display:none;" onclick="pauseShowMode()">
<div class="show-mode-header">
<span id="showModeTitle">Show Mode</span>
<button class="secondary" onclick="stopShowMode()">Beenden</button>
</div>
<div id="showModeContent" class="show-mode-content"></div>
<div id="showModePaused" class="show-mode-paused" style="display:none;">Pausiert – berühre zum Fortsetzen</div>
</div>
<div id="sideMenuBackdrop" class="side-menu-backdrop" onclick="toggleHeader()"></div>
<div class="tabs">
<button class="tab active" data-tab="board" onclick="showTab('board')">Projektboard</button>
<button class="tab" data-tab="load" onclick="showTab('load')">Auslastung</button>
<button class="tab" data-tab="projects" onclick="showTab('projects')">Projekte</button>
<button class="tab" data-tab="people" onclick="showTab('people')">Projektbeteiligte</button>
</div>
<div class="container">
<!-- BOARD -->
<div id="board">
<div class="legend">
<span><span class="swatch" style="background:#2f855a"></span> Beteiligt X</span>
<span><span class="swatch" style="background:#d69e2e"></span> Optional (X)</span>
</div>
<div class="table-wrap">
<table id="boardTable"></table>
</div>
<div id="boardLegend" style="margin-top:16px; padding:12px; background:#f7fafc; border-radius:6px; font-size:12px;"></div>
</div>
<!-- LOAD -->
<div id="load" style="display:none">
<div class="section-title">Auslastung der Projektbeteiligten</div>
<div class="toolbar" style="margin-bottom:12px">
<label>Auswertung:</label>
<select id="loadScope" onchange="renderLoad()">
<option value="current">Aktuelle Sitzung</option>
<option value="all">Alle Sitzungen (Historie)</option>
</select>
<span class="hint">Auswertung über den rollenspezifischen Gewichtungsfaktor je Leistungsphase.</span>
</div>
<div class="load-grid" id="loadGrid"></div>
</div>
<!-- PROJECTS -->
<div id="projects" style="display:none">
<div class="section-title">Projekte verwalten</div>
<div class="toolbar" style="margin-bottom:12px">
<button onclick="openNewProjModal()">+ Neues Projekt</button>
</div>
<div id="projectsList"></div>
</div>
<!-- PEOPLE -->
<div id="people" style="display:none">
<div class="section-title">Projektbeteiligte verwalten</div>
<div class="toolbar" style="margin-bottom:12px; align-items: flex-start;">
<input type="text" id="newPersonName" placeholder="Kürzel (z.B. AP)" style="width:100px">
<input type="text" id="newPersonFullName" placeholder="Voller Name (z.B. Anton Peters)">
<input type="text" id="newPersonEmail" placeholder="E-Mail (z.B. anton.peters@rauschenberg-ing.de)">
<div class="role-checkboxes">
<label><input type="checkbox" name="newPersonRoles" value="Planung"> Planung</label>
<label><input type="checkbox" name="newPersonRoles" value="Projektleitung"> Projektleitung</label>
<label><input type="checkbox" name="newPersonRoles" value="Bauüberwachung"> Bauüberwachung</label>
</div>
<button onclick="addPerson()">+ Person hinzufügen</button>
</div>
<div id="peopleList"></div>
</div>
</div>
<!-- Passwort Modal -->
<div class="modal-bg" id="passwordModal">
<div class="modal">
<h2>Zugriff erforderlich</h2>
<p style="color:#666;margin-bottom:12px">Bitte geben Sie das Passwort ein, um auf diesen Bereich zuzugreifen.</p>
<div class="form-row">
<label>Passwort</label>
<input type="password" id="passwordInput" placeholder="Passwort eingeben" onkeypress="if(event.key==='Enter') confirmPassword()">
</div>
<div class="toolbar">
<button onclick="confirmPassword()">Bestätigen</button>
<button class="secondary" onclick="closePasswordModal()">Abbrechen</button>
</div>
</div>
</div>
<!-- Aufwand je Leistungsphase Modal -->
<div class="modal-bg" id="lpModal" style="display:none">
<div class="modal">
<h2>Aufwand je Leistungsphase</h2>
<p style="color:#888;margin-bottom:6px" id="lpModalProject"></p>
<p class="hint" style="margin-bottom:10px">
Rollenbezogene Gewichtungsfaktoren je Leistungsphase (Standardwerte laut
Vorlage im Code). Die amtierende Leistungsphase wird pro Projekt im Tab
„Projekte" gewählt.
</p>
<div id="lpModalRows"></div>
<div class="toolbar" style="margin-top:12px">
<button onclick="saveLpModal()">Speichern</button>
<button class="secondary" onclick="closeLpModal()">Abbrechen</button>
</div>
</div>
</div>
<!-- Neues Projekt Modal (inkl. LP-Aufwand) -->
<div class="modal-bg" id="newProjModal" style="display:none">
<div class="modal">
<h2>Neues Projekt</h2>
<div class="form-row">
<label>Projekt-Nr.</label>
<input type="text" id="newProjNr" placeholder="z. B. 26-4001" style="width:100%">
</div>
<div class="form-row">
<label>Bezeichnung</label>
<input type="text" id="newProjName" placeholder="Projektbezeichnung" style="width:100%">
</div>
<div class="form-row">
<label>Gruppe</label>
<select id="newProjGroup" style="width:100%">
<option>Aktive Projekte</option>
<option>Kleine Projekte</option>
<option>In Anbahnung</option>
<option>Allgemein</option>
</select>
</div>
<div class="form-row">
<label>Aktive Leistungsphase</label>
<select id="newProjActiveLP" style="width:100%"></select>
</div>
<p class="hint" style="margin:8px 0 10px">
Rollenbezogener Gewichtungsfaktor je Leistungsphase (Standardwerte laut
Vorlage im Code).
</p>
<div id="newProjLpRows"></div>
<div class="toolbar" style="margin-top:12px">
<button onclick="saveNewProj()">Anlegen</button>
<button class="secondary" onclick="closeNewProjModal()">Abbrechen</button>
</div>
</div>
</div>
<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/board.js?v=6"></script>
<script src="/js/people/people.js?v=6"></script>
<script src="/js/projects/projects.js?v=6"></script>
<script src="/js/load/load.js?v=6"></script>
<script src="/js/gantt.js?v=5"></script>
<script src="/js/showmode.js?v=5"></script>
<script src="/js/tabs-auth.js?v=5"></script>
<script src="/js/pdf.js?v=5"></script>
<script src="/js/init.js?v=5"></script>
</body>
</html>
BIN
View File
Binary file not shown.
+21 -3
View File
@@ -69,6 +69,21 @@
return Array.from(document.querySelectorAll('#' + containerId + ' input[type="checkbox"]:checked')).map(cb => cb.value); return Array.from(document.querySelectorAll('#' + containerId + ' input[type="checkbox"]:checked')).map(cb => cb.value);
} }
// onUserRoleChange: wird beim Wechsel der Systemrolle in einem der beiden
// Formulare aufgerufen (which = 'new'|'edit'). Ein Kiosk-Konto kann keine
// Fachrolle haben (server.js weist das sonst mit 400 zurück) — die
// Checkboxen werden deshalb schon hier abgehakt entfernt und gesperrt,
// statt den Fehler erst nach dem Absenden zu zeigen.
window.onUserRoleChange = function(which){
const istKiosk = document.getElementById(which + 'UserRole').value === 'kiosk';
const hint = document.getElementById(which + 'UserRoleHint');
if(hint) hint.style.display = istKiosk ? '' : 'none';
document.querySelectorAll('#' + which + 'UserRoleCheckboxes input[type="checkbox"]').forEach(cb => {
cb.disabled = istKiosk;
if(istKiosk) cb.checked = false;
});
};
// formulareVorbereitet: Abteilungs-Auswahl und Rollen-Ankreuzfelder muessen // formulareVorbereitet: Abteilungs-Auswahl und Rollen-Ankreuzfelder muessen
// nur einmal aufgebaut werden; renderAdminUsers() laeuft aber bei jedem // nur einmal aufgebaut werden; renderAdminUsers() laeuft aber bei jedem
// Wechsel in den Abschnitt. // Wechsel in den Abschnitt.
@@ -137,7 +152,7 @@
<td>${esc(u.username)}</td> <td>${esc(u.username)}</td>
<td>${esc(u.displayName)}</td> <td>${esc(u.displayName)}</td>
<td>${esc(u.email || '')}</td> <td>${esc(u.email || '')}</td>
<td>${u.role === 'admin' ? 'Administrator' : 'Mitglied'}</td> <td>${u.role === 'admin' ? 'Administrator' : u.role === 'kiosk' ? 'Kiosk' : 'Mitglied'}</td>
<td>${deptLabel}</td> <td>${deptLabel}</td>
<td>${esc(u.initials || '')}</td> <td>${esc(u.initials || '')}</td>
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td> <td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
@@ -247,8 +262,10 @@
// Ohne Fachrolle ist der Benutzer in der ganzen Anwendung unsichtbar: // Ohne Fachrolle ist der Benutzer in der ganzen Anwendung unsichtbar:
// peopleByRole() (js/utils.js) gruppiert ausschließlich über die Rollen — // peopleByRole() (js/utils.js) gruppiert ausschließlich über die Rollen —
// das Konto erschiene weder als Board-Spalte noch in der Auslastung noch // das Konto erschiene weder als Board-Spalte noch in der Auslastung noch
// im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe. // im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe. Für ein
if(!roles || roles.length === 0){ // Kiosk-Konto gilt das nicht — es soll gerade NICHT in Boards/Auslastung
// auftauchen, server.js weist eine gesetzte Fachrolle dort sogar zurück.
if(role !== 'kiosk' && (!roles || roles.length === 0)){
errEl.textContent = 'Bitte mindestens eine Rolle wählen (Planung, Projektleitung oder Bauüberwachung) — sonst erscheint der Benutzer in keinem Projektboard.'; errEl.textContent = 'Bitte mindestens eine Rolle wählen (Planung, Projektleitung oder Bauüberwachung) — sonst erscheint der Benutzer in keinem Projektboard.';
return; return;
} }
@@ -301,6 +318,7 @@
document.querySelectorAll('#editUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { document.querySelectorAll('#editUserRoleCheckboxes input[type="checkbox"]').forEach(cb => {
cb.checked = userRoles.includes(cb.value); cb.checked = userRoles.includes(cb.value);
}); });
onUserRoleChange('edit');
document.getElementById('editUserError').textContent = ''; document.getElementById('editUserError').textContent = '';
document.getElementById('editUserAbsenceError').textContent = ''; document.getElementById('editUserAbsenceError').textContent = '';
document.getElementById('editUserAbsenceStart').value = ''; document.getElementById('editUserAbsenceStart').value = '';
+1 -1
View File
@@ -48,7 +48,7 @@
// ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die // ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die
// EINE Stelle, an der ein Abschnitt definiert wird — Schlüssel (zugleich // EINE Stelle, an der ein Abschnitt definiert wird — Schlüssel (zugleich
// URL-Fragment) und Überschrift. Die Zuordnung zum Markup läuft über das // URL-Fragment) und Überschrift. Die Zuordnung zum Markup läuft über das
// Attribut data-admin-section, sowohl an der Karte in Test.html als auch am // Attribut data-admin-section, sowohl an der Karte in App.html als auch am
// Unterpunkt in der Seitenleiste; ein neuer Abschnitt braucht damit nur einen // Unterpunkt in der Seitenleiste; ein neuer Abschnitt braucht damit nur einen
// Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters // Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters
// auf — die Benutzertabelle hat zehn Spalten und scrollte sonst quer. // auf — die Benutzertabelle hat zehn Spalten und scrollte sonst quer.
+1 -1
View File
@@ -11,7 +11,7 @@
// (p.beauftragteLP). Deshalb erfasst der Dialog "Beauftragung" beides in EINEM // (p.beauftragteLP). Deshalb erfasst der Dialog "Beauftragung" beides in EINEM
// Schritt — direkt danach ist der Terminplan rechenbar. // Schritt — direkt danach ist der Terminplan rechenbar.
// //
// Geladen von Test.html als gemeinsames Modul (klassisches Skript, alle // Geladen von App.html als gemeinsames Modul (klassisches Skript, alle
// Funktionen liegen dadurch auf window). Genutzt werden LPHS aus // Funktionen liegen dadurch auf window). Genutzt werden LPHS aus
// js/globals.js, esc() aus js/utils.js, currentWeek()/save()/markWeekDirty() // js/globals.js, esc() aus js/utils.js, currentWeek()/save()/markWeekDirty()
// aus js/store.js, isWeekLocked() aus js/weeks.js und openProjectPlan() aus // aus js/store.js, isWeekLocked() aus js/weeks.js und openProjectPlan() aus
+4 -3
View File
@@ -1,12 +1,12 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Authentifizierung (Login-Overlay, Session, Logout) // Projektübersicht — Authentifizierung (Login-Overlay, Session, Logout)
// ----------------------------- // -----------------------------
// Wird von Test.html direkt nach // Wird von App.html direkt nach
// js/server.js geladen, da checkAuthAndInit() baseUrl() benötigt. Ersetzt den // js/server.js geladen, da checkAuthAndInit() baseUrl() benötigt. Ersetzt den
// früheren rein clientseitigen Passwortschutz (js/tabs-auth.js) durch eine // früheren rein clientseitigen Passwortschutz (js/tabs-auth.js) durch eine
// echte serverseitige Session (server.js: /login, /logout, /whoami). Ohne // echte serverseitige Session (server.js: /login, /logout, /whoami). Ohne
// gültige Session zeigt checkAuthAndInit() das blockierende Login-Overlay // gültige Session zeigt checkAuthAndInit() das blockierende Login-Overlay
// (#loginOverlay/#loginForm, siehe Test.html); nach // (#loginOverlay/#loginForm, siehe App.html); nach
// erfolgreichem Login läuft der eigentliche Seiteneinstieg (per Callback // erfolgreichem Login läuft der eigentliche Seiteneinstieg (per Callback
// übergeben) wie gewohnt weiter. // übergeben) wie gewohnt weiter.
// //
@@ -33,7 +33,8 @@ let currentUser = null;
function renderUserBar(){ function renderUserBar(){
const info = document.getElementById('userBarInfo'); const info = document.getElementById('userBarInfo');
if(info && currentUser){ if(info && currentUser){
const roleLabel = currentUser.role === 'admin' ? 'Administrator' : 'Mitglied'; const roleLabel = currentUser.role === 'admin' ? 'Administrator'
: currentUser.role === 'kiosk' ? 'Kiosk' : 'Mitglied';
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')'; info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
} }
const navAdminSection = document.getElementById('navAdminSection'); const navAdminSection = document.getElementById('navAdminSection');
+41
View File
@@ -0,0 +1,41 @@
// -----------------------------
// Projektübersicht — Datenbank-Backup (Download)
// -----------------------------
// Stellt downloadBackup() bereit: lädt einen konsistenten Snapshot der
// SQLite-Datenbank über GET /backup als .db-Datei herunter (VACUUM INTO,
// WAL-sicher). Hängt im Seitenmenü von App.html am Button "DB Backup" und
// nutzt baseUrl() aus js/server.js. Ergänzt das automatische Backup, das der
// Server beim Start in sessions/backups/ ablegt.
// downloadBackup: fordert den DB-Snapshot an und startet den Download.
// Kein Parameter, keine Rückgabe; Fehler werden über ein Alert gemeldet.
async function downloadBackup(){
try {
const res = await fetch(baseUrl() + '/backup');
if(!res.ok){
let msg = 'Server HTTP ' + res.status;
try {
const json = await res.json();
if(json && json.error) msg = json.error;
} catch(e){ /* Antwort war kein JSON */ }
throw new Error(msg);
}
const blob = await res.blob();
// heuteIso() (js/utils.js) statt toISOString(): Letzteres rechnet nach
// UTC um und datierte das Backup zwischen 0 und 2 Uhr Ortszeit auf den
// Vortag.
const filename = 'projektuebersicht_backup_' + heuteIso() + '.db';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch(err){
console.error('DB-Backup fehlgeschlagen:', err);
alert('DB-Backup fehlgeschlagen: ' + (err.message || err));
}
}
+607 -256
View File
@@ -1,59 +1,73 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Board (Haupttabelle) // Projektübersicht — Board (Listenansicht)
// ----------------------------- // -----------------------------
// Diese Datei rendert das Projektboard (Tab "Projektboard" in `Test.html`), // Diese Datei rendert das Projektboard (Tab "Projektboard" in `App.html`) als
// die zentrale Tabelle mit Projekten, Status-Kommentaren, Personen-Spalten // schlanke, aufklappbare Liste statt der früheren breiten Matrix (eine Spalte
// (Anwesenheit/Zuordnung) und Leistungsphasen in Prozent. Sie wird von // je Person/Leistungsphase): jede Zeile zeigt auf einen Blick Kürzel-Avatare
// `Test.html` nach `js/weeks.js` (vor `js/people/people.js`) geladen und stellt // der zugeordneten Personen, die aktuelle Leistungsphase samt Prozentwert und
// globale Funktionen wie `renderBoard()`, `statusFocus()`, `cycleAssign()`, // eine einzeilige Status-Vorschau; ein Klick auf die Zeile klappt darunter das
// `editLph()` und `delProj()` bereit. Sie nutzt `currentWeek()`/ // Detailpanel auf (voller Status-Editor, vollständige Rollen-Zuordnung, alle
// LPH-Balken). Sie wird von `App.html` nach `js/weeks.js` (vor
// `js/people/people.js`) geladen und stellt globale Funktionen wie
// `renderBoard()`, `toggleBoardRow()`, `cycleAssign()`,
// und `editLph()` bereit. Sie nutzt `currentWeek()`/
// `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`, // `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`,
// `assignKey()` aus `js/utils.js` und `openProjectPlan()` aus `js/gantt.js`. // `assignKey()`, `statusFocus()`, `sanitizeStatusHtml()` und
// `getProgressColor()` aus `js/utils.js` sowie `openProjectPlan()` aus
// `js/gantt.js` — navigiert bewusst im selben Tab (kein window.open() mehr),
// ein Klick auf den Rest der Zeile klappt stattdessen nur das Detailpanel
// auf/zu (toggleBoardRow()). editLph()/cycleAssign() rufen zusätzlich
// renderWochenbericht() (js/wochenbericht/wochenbericht.js) auf, da dessen
// Karten dieselben renderBoardDrawer()-Bedienelemente nutzen, aber in einem
// eigenen DOM-Container (#wochenberichtContent) liegen.
// currentStatusEditor: aktuell fokussiertes contenteditable-Element des // boardRowOpen: UI-Zustand, welche Projekt-Zeile ihr Detailpanel aufgeklappt
// Status-Editors; wird für die Formatierungs-Toolbar (`formatStatus`) benötigt. // darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt
let currentStatusEditor = null; // außerhalb von renderBoard() und übersteht daher dessen Neuaufbauten (z. B.
// currentStatusPid: Projekt-ID der gerade fokussierten Status-Zelle; wird beim // nach einer Zuordnungs- oder Prozent-Änderung im offenen Panel). Nicht
// Speichern des Status (`statusBlur`) verwendet. // persistiert — jede Sitzung startet mit eingeklappten Zeilen.
let currentStatusPid = null; let boardRowOpen = {};
// statusFocus: merkt sich das fokussierte Status-Element und die zugehörige // toggleBoardRow: klappt das Detailpanel einer Projekt-Zeile auf bzw. zu.
// Projekt-ID (`el`, `pid`), damit die Toolbar und das Blur-Speichern sie nutzen. // Parameter: pid (Projekt-ID). Keine Rückgabe.
function statusFocus(el, pid){ function toggleBoardRow(pid){
currentStatusEditor = el; boardRowOpen[pid] = !boardRowOpen[pid];
currentStatusPid = pid; renderBoard();
// Der Wochenbericht und die Wandtafel rendern dieselben Projektzeilen in
// einem eigenen Container. Ohne diesen Aufruf merkten sie sich den Klick
// zwar, zeigten die Zeile aber erst nach einem Tab-Wechsel auf- bzw.
// zugeklappt.
if(typeof renderWochenbericht === 'function') renderWochenbericht();
if(typeof renderWandboard === 'function') renderWandboard();
} }
// statusBlur: übernimmt den Inhalt des Status-Editors (`el`) als neuen Status // statusPreviewText: liefert eine einzeilige Klartext-Vorschau eines
// des Projekts `pid` und räumt die Editor-Merker wieder ab, wenn `el` das // Status-HTML-Werts (Tags entfernt, überflüssiger Leerraum/Zeilenumbrüche zu
// zuletzt fokussierte Element war. // einem Leerzeichen zusammengefasst) für die eingeklappte Zeile — die volle
function statusBlur(el, pid){ // Formatierung bleibt dem aufgeklappten Editor vorbehalten. Parameter: html
const val = statusValueFromHtml(el.innerHTML); // (String). Rückgabe: String.
editProj(pid, 'status', val); function statusPreviewText(html){
if(currentStatusEditor === el){ currentStatusEditor = null; currentStatusPid = null; } if(!html) return '';
}
// 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'); const tmp = document.createElement('div');
tmp.innerHTML = html; // Bereinigen, BEVOR der Wert ins DOM geht: Auch ein nicht eingehängtes
const text = (tmp.textContent || tmp.innerText || '').replace(/\u00A0/g, ' '); // Element lädt Ressourcen und führt deren Fehler-Handler aus, ungefiltertes
return text.trim() === '' ? '' : html; // innerHTML wäre hier also ein Einfallstor.
tmp.innerHTML = sanitizeStatusHtml(html);
// Zeilen- und Absatzgrenzen zuerst in Leerzeichen überführen: textContent
// ignoriert sie, sonst klebte das letzte Wort einer Zeile am ersten der
// nächsten ("… KW 40.Freigabe steht aus").
tmp.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode(' ')));
tmp.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode(' ')));
return (tmp.textContent || tmp.innerText || '').replace(/\s+/g, ' ').trim();
} }
// formatStatus: wendet einen document.execCommand-Befehl (z.B. 'bold', // HINWEIS: statusBlur() ist entfallen. Seit der Kommentar-Editor ein
// 'foreColor') auf das fokussierte Status-Element an. Parameter: `command`, // <textarea> ist (renderCommentEditor/saveBoardComment), gab es keinen Aufrufer
// optionaler `value`; keine Rückgabe. // mehr; die Funktion schrieb ungefiltertes innerHTML in p.status und wäre beim
function formatStatus(command, value){ // erneuten Verdrahten ein Einfallstor gewesen.
if(!currentStatusEditor) return;
currentStatusEditor.focus();
document.execCommand(command, false, value || null);
}
// peopleByRole liegt jetzt im gemeinsamen Modul js/utils.js (benötigt auch // peopleByRole liegt jetzt im gemeinsamen Modul js/utils.js (benötigt auch
// csv.js für die Projektplan-Seite, die board.js nicht lädt). // die Projektplan-Seite, die board.js nicht lädt).
// roleClass: erzeugt CSS-Klassen für eine Rolle bzw. ein Rollen-Array // roleClass: erzeugt CSS-Klassen für eine Rolle bzw. ein Rollen-Array
// (Rollenname kleingeschrieben, Leerzeichen → Bindestrich). Parameter: `role` // (Rollenname kleingeschrieben, Leerzeichen → Bindestrich). Parameter: `role`
@@ -65,33 +79,537 @@ function roleClass(role){
return 'role-' + role.toLowerCase().replace(/\s+/g,'-'); return 'role-' + role.toLowerCase().replace(/\s+/g,'-');
} }
// renderBoard: baut die HTML-Tabelle für das Board auf (Kopf, Zeilen, Zellen). // renderBoardRow: baut die eingeklappte Zeile eines Projekts (Nr., Name +
// Liest die aktuelle Woche, gruppiert Projekte nach Gruppe, sortiert sie nach // Tags, Status-Vorschau, Avatar-Stapel der Zugeordneten, Phasen-Badge) sowie
// `order` und erzeugt die Status-, Personen- und LPH-Zellen. Bei gesperrten // bei Bedarf das aufgeklappte Detailpanel darunter. Parameter: p (Projekt),
// Sitzungen werden alle Bearbeitungs-Handler deaktiviert und ein Hinweis samt // allPeople (Array `{person, role}`, alle Rollen), locked (Boolean).
// Entsperren-Button angezeigt. Aktualisiert außerdem die Legende mit den // Rückgabe: HTML-String.
// vollständigen Namen und Anwesenheiten. Keine Parameter, keine Rückgabe. // BOARD_OPTS: die Einstellungen der Projektliste. Sie zeigt wie der
// Wochenbericht nur die beauftragten Leistungsphasen (Rückfall auf alle, wenn
// keine hinterlegt sind — p.beauftragteLP leer, siehe renderBoardDrawer()),
// nur in der ausführlichen statt der dichten Fassung, und nichts davon ist
// hier bearbeitbar (nurAnsicht).
const BOARD_OPTS = { nurAnsicht: true, nurBeauftragteLph: true };
function renderBoardRow(p, allPeople, locked, weekLabel, opts){
const isOpen = !!boardRowOpen[p.id];
const tags = Array.isArray(p.tags) ? p.tags : [];
const tagsHtml = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
const previewText = statusPreviewText(p.status);
const isNew = p.statusState === 'new';
// Avatar-Stapel der eingeklappten Zeile: nur tatsächlich zugeordnete
// Personen (Zustand 1 = Haupt oder 2 = optional), volle Rollen-Matrix nur
// im aufgeklappten Detailpanel (siehe renderBoardDrawer).
const stackHtml = allPeople.map(entry=>{
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0;
if(state === 0) return '';
return `<span class="board-avatar ${roleClass(entry.role)} state-${state}" title="${esc(entry.person.fullName || entry.person.name)} · ${esc(entry.role)}">${esc(entry.person.name)}</span>`;
}).join('');
const aktLp = lpActiveList(p)[0];
const aktPct = aktLp && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? p.lph[aktLp] : null;
const phaseHtml = aktLp
? `<span class="phase-pill">${esc(aktLp)}${aktPct !== null ? ' · ' + esc(aktPct) + '%' : ''}</span>`
: `<span class="phase-pill muted">Keine aktive LP</span>`;
// Ziehbar und mit Griff: Die Reihenfolge innerhalb einer Gruppe und der
// Wechsel zwischen Gruppen wurden zuvor im eigenen Tab "Projekte" gepflegt,
// der dieselben Projekte ein zweites Mal auflistete. Beides geschieht jetzt
// hier, in der einen Projektliste.
let html = `<div class="board-row${isOpen ? ' open' : ''}" data-project-id="${p.id}" ${locked ? '' : 'draggable="true" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"'}>`;
html += `<div class="board-row-main" onclick="toggleBoardRow('${p.id}')">`;
html += locked ? '' : `<span class="drag-handle" title="Verschieben" onclick="event.stopPropagation()">${icon('grip')}</span>`;
html += `<span class="board-chev">${icon('chevronRight')}</span>`;
html += `<span class="board-nr">${esc(p.nr) || '<span class="hint" style="margin:0">ohne Nr.</span>'}</span>`;
html += `<div class="board-name-wrap">
<span class="board-name" onclick="event.stopPropagation();openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</span>
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
${tagsHtml}
<div class="board-status-line">${isNew ? '<span class="board-newdot" title="Neuer Kommentar"></span>' : ''}${previewText ? esc(previewText) : '<span class="hint" style="margin:0">Kein Kommentar</span>'}</div>
</div>`;
html += `<div class="board-avatar-stack">${stackHtml}</div>`;
html += phaseHtml;
// Listenoperationen am Zeilenende (aus dem früheren Projekte-Tab). Das
// Bearbeiten der Projektangaben führt in den Projektbereich, wo sie ihren
// einen Ort haben — hier steht nur, was die LISTE betrifft.
html += `<div class="board-row-actions" onclick="event.stopPropagation()">
<button type="button" class="secondary small" onclick="openProjectPlan('${p.id}')" title="Projektbereich öffnen">Öffnen</button>
${locked ? '' : `<button type="button" class="danger small" onclick="delProjectFromList('${p.id}')" title="Projekt aus dieser Sitzung löschen">Löschen</button>`}
</div>`;
html += `</div>`; // board-row-main
if(isOpen) html += renderBoardDrawer(p, allPeople, locked, weekLabel, opts);
html += `</div>`; // board-row
return html;
}
// ---------- Kommentar-Editor (Board-Schublade und Wochenbericht) ----------
// Der Kommentar eines Projekts zu einer Sitzung (p.status) wird nicht mehr in
// einem contenteditable-Feld mit document.execCommand-Leiste bearbeitet,
// sondern in einem normalen Textfeld mit ausdrücklichem Speichern. Dazu
// kommen Verfasser und Zeitstempel (p.statusAuthor/p.statusAt, Spalten
// status_author/status_at in der Tabelle projects) sowie der scrollbare
// Verlauf der vorangegangenen Sitzungen aus projectStatusHistory()
// (js/utils.js), der bisher nur auf der Projektplan-Seite sichtbar war.
//
// Zusammenspiel mit cloneWeek() (js/weeks.js): Beim Duplizieren einer Sitzung
// wird der Kommentartext NICHT mehr mitkopiert — man schreibt den Kommentar
// dieser Woche, statt den der Vorwoche zu überschreiben. Wenn sich nichts
// geändert hat, übernimmt der Knopf "übernehmen" am jeweiligen Verlaufs-
// Eintrag dessen Text in das Textfeld.
// boardCommentBaseline: der zuletzt gespeicherte Kommentartext je Projekt-ID,
// Vergleichsbasis für die "ungespeichert"-Anzeige und für "Verwerfen".
// Wird bei jedem Aufbau der Schublade neu gesetzt.
let boardCommentBaseline = {};
// commentAuthorLine: Kopfzeile des Editors — wer den gespeicherten Kommentar
// verfasst hat und wann. Bestandskommentare ohne Verfasser (vor der
// Einführung von status_author) werden als "unbekannt" ausgewiesen, statt
// stillschweigend die aktuell angemeldete Person zu behaupten.
// Parameter: p (Projekt). Rückgabe: HTML-String.
function commentAuthorLine(p){
const hasText = statusPreviewText(p.status) !== '';
if(!hasText){
const me = (currentUser && currentUser.displayName) || '';
return me
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(me))}</span>${esc(me)}</span><span class="comment-sep">·</span><span>neuer Eintrag</span>`
: `<span>neuer Eintrag</span>`;
}
const author = p.statusAuthor || '';
const at = p.statusAt ? formatCommentStamp(p.statusAt) : '';
const who = author
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(author))}</span>${esc(author)}</span>`
: `<span class="comment-who comment-unknown">Verfasser unbekannt</span>`;
return who + (at ? `<span class="comment-sep">·</span><span>${esc(at)}</span>` : '');
}
// formatCommentStamp: ISO-Zeitstempel als "TT.MM.JJJJ, HH:MM". Parameter:
// iso (String). Rückgabe: String (leer bei ungültigem Wert).
function formatCommentStamp(iso){
const d = new Date(iso);
if(isNaN(d.getTime())) return '';
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth()+1)}.${d.getFullYear()}, ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// renderCommentEditor: der vollständige Kommentarbereich der Schublade —
// Editor mit Kopf-/Fußzeile und darunter der eingeklappte, scrollbare
// Verlauf.
//
// In der dichten Fassung (dicht = true, nur Wochenbericht) entfallen zwei
// Elemente, die dort je Karte rund 60 px kosteten, ohne etwas beizutragen:
// * Die eigene KOPFZEILE mit Verfasser und Tastenkürzel. Der Verfasser
// rückt in die Fußzeile neben die Knöpfe — dieselbe Angabe, eine Zeile
// weniger. Die Tastenkürzel stehen als Titel am Textfeld.
// * Der Aufklapper "FRÜHERE SITZUNGEN". Er ist seit dem Verlauf eine
// Doppelung: Der zeigt dieselben Kommentare und mehr. Seine einzige
// eigene Funktion — einen früheren Text übernehmen — hat der Verlauf
// übernommen (renderProjektVerlauf(), js/utils.js).
// Parameter: p (Projekt), weekLabel (String), locked (Boolean),
// dicht (Boolean). Rückgabe: HTML-String.
function renderCommentEditor(p, weekLabel, locked, dicht){
const html = sanitizeStatusHtml(p.status || '');
boardCommentBaseline[p.id] = html;
if(locked){
return `<div class="comment-block" data-pid="${esc(p.id)}">
<div class="comment-head-line">${commentAuthorLine(p)}</div>
<div class="comment-readonly">${html || '<span class="hint">Kein Kommentar erfasst.</span>'}</div>
</div>`;
}
const kopf = dicht ? '' : `<div class="comment-editor-head">
${commentAuthorLine(p)}
<span class="comment-kbd">Strg+↵ speichern · Esc verwerfen</span>
</div>`;
const verfasserImFuss = dicht ? `<span class="comment-foot-author">${commentAuthorLine(p)}</span>` : '';
// Der frühere Aufklapper "Frühere Sitzungen" ist entfallen. Er zeigte nur
// den Kommentartext und auch den nur für Sitzungen mit tatsächlicher
// Änderung — eine dritte, engere Auswahl neben dem Verlauf im
// Wochenbericht und dem Dialog im Projektbereich. Alle drei Stellen nutzen
// jetzt renderProjektVerlauf() (js/utils.js) und zeigen damit dasselbe.
const verlaufAufklapper = '';
// richtextToolbarHtml() (js/utils.js): Fett/Kursiv/Farbe für den Kommentar
// (Eriks Wunsch) — anders als kopf (der Autor-/Tastenkürzel-Kopf) IMMER
// gezeigt, auch im dichten Modus: "dicht" ist hier die reguläre, zweispaltig
// kompakte Wochenbericht-Kartenansicht (nicht eine lange Liste vieler
// Karten), genau die Stelle, an der der Kommentar normalerweise bearbeitet
// wird — die Werkzeugleiste ist klein genug, um dort nicht zu stören.
const formatLeiste = richtextToolbarHtml('.comment-field');
const feldAttrs = `${dicht ? 'title="Strg+↵ speichern · Esc verwerfen" ' : ''}`
+ `oninput="markCommentDirty(this)" onkeydown="boardCommentKey(event, this)" onblur="boardCommentBlur(this)"`;
return `<div class="comment-block" data-pid="${esc(p.id)}">
<div class="comment-editor">
${kopf}
<div class="richtext-wrap">
${formatLeiste}
${richtextFeldHtml('comment-field', html, 'Was gibt es diese Woche zu diesem Projekt?', feldAttrs)}
</div>
<div class="comment-editor-foot">
<button type="button" class="small" onmousedown="event.preventDefault()" onclick="saveBoardComment(this)">Speichern</button>
<button type="button" class="secondary small"
onmousedown="event.preventDefault();discardBoardComment(this)">Verwerfen</button>
${verfasserImFuss}
<span class="comment-foot-spacer"></span>
<span class="comment-dirty" hidden>● ungespeichert</span>
</div>
</div>
${verlaufAufklapper}
</div>`;
}
// pendingCommentFocus: Projekt-ID des Kommentarfelds, in das der Nutzer gerade
// klickt. Hintergrund: Ein Klick in ein anderes Kommentarfeld löst zuerst den
// Fokusverlust des bisherigen aus; dessen Blur-Netz speichert und rendert
// Board UND Wochenbericht neu — das angeklickte Feld ist dann längst durch ein
// neues Element ersetzt, der Klick geht ins Leere und die nächsten
// Tastenanschläge landen nirgends. Die Absicht wird deshalb in der
// Capture-Phase festgehalten und nach dem Neu-Rendern wiederhergestellt.
let pendingCommentFocus = null;
document.addEventListener('mousedown', function(e){
const field = e.target && e.target.closest ? e.target.closest('.comment-field') : null;
const block = field && field.closest('.comment-block');
pendingCommentFocus = block ? block.dataset.pid : null;
}, true);
// restorePendingCommentFocus: setzt den Fokus nach einem Neu-Rendern in das
// Kommentarfeld, das der Nutzer anklicken wollte, und stellt den Cursor ans
// Textende. Ohne gemerkte Absicht oder ohne Treffer passiert nichts.
// Keine Parameter, keine Rückgabe.
function restorePendingCommentFocus(){
const pid = pendingCommentFocus;
pendingCommentFocus = null;
if(!pid) return;
const sel = '.comment-block[data-pid="' + ((window.CSS && CSS.escape) ? CSS.escape(pid) : pid) + '"] .comment-field';
// Board und Wochenbericht rendern dieselben Projekte; das sichtbare Feld
// gewinnt (offsetParent ist null, solange der Tab ausgeblendet ist).
const kandidaten = Array.from(document.querySelectorAll(sel));
const field = kandidaten.find(f => f.offsetParent !== null) || kandidaten[0];
if(!field) return;
richtextCursorAnsEnde(field);
}
// commentParts: löst von einem beliebigen Bedienelement aus dessen
// Kommentar-Block auf und liefert Projekt-ID, Textfeld und Hinweis-Element.
//
// Warum nicht über Element-IDs: Projektboard und Wochenbericht rendern
// dieselben Projekte gleichzeitig (der Bericht nutzt renderBoardDrawer()
// wieder). Feste IDs wie "bc_text_<pid>" wären dann doppelt vergeben, und
// document.getElementById() liefert immer den ersten Treffer — im
// Wochenbericht getippter Text wurde dadurch beim Speichern durch den Inhalt
// des (unsichtbaren) Board-Felds ersetzt. Über closest() trifft jede Aktion
// garantiert den Block, in dem sie ausgelöst wurde.
// Parameter: el (Element innerhalb des Blocks). Rückgabe: Objekt oder null.
function commentParts(el){
if(!el || !el.closest) return null;
// Erst der eigene Block; findet sich keiner, der Block derselben Karte.
// Gebraucht seit der Verlauf (js/wochenbericht/wochenbericht.js) einen
// "übernehmen"-Knopf trägt: Er steht UNTER dem Spaltenraster und damit
// ausserhalb von .comment-block, gehört aber zum selben Projekt.
const block = el.closest('.comment-block')
|| (el.closest('.report-card') || document).querySelector('.comment-block');
if(!block) return null;
return {
block,
pid: block.dataset.pid,
field: block.querySelector('.comment-field'),
dirty: block.querySelector('.comment-dirty')
};
}
// markCommentDirty: blendet den "ungespeichert"-Hinweis ein bzw. aus, indem
// der Feldinhalt mit dem zuletzt gespeicherten Stand verglichen wird.
// Parameter: el (Element im Kommentar-Block). Keine Rückgabe.
function markCommentDirty(el){
const c = commentParts(el);
if(!c || !c.field || !c.dirty) return;
c.dirty.hidden = c.field.innerHTML === (boardCommentBaseline[c.pid] || '');
}
// saveBoardComment: schreibt den Feldinhalt als neuen Kommentar des Projekts,
// samt Verfasser (angemeldete Person) und Zeitstempel, und rendert neu.
// Wird nichts geändert, passiert nichts — ein unverändertes Feld soll weder
// den Zeitstempel noch den Verfasser überschreiben. Parameter: el (Element im
// Kommentar-Block). Rückgabe: keine.
function saveBoardComment(el){
if(guardWeekLocked()) return;
const c = commentParts(el);
if(!c || !c.field) return;
const html = sanitizeStatusHtml(c.field.innerHTML);
if(html === (boardCommentBaseline[c.pid] || '')) return;
const p = currentWeek().projects.find(x => x.id === c.pid);
if(!p) return;
const before = (p.status || '').trim();
if(before === '' && html !== '') p.statusState = 'new';
else if(html === '') delete p.statusState;
p.status = html;
if(html === ''){
p.statusAuthor = '';
p.statusAt = '';
} else {
p.statusAuthor = (currentUser && currentUser.displayName) || '';
p.statusAt = new Date().toISOString();
}
boardCommentBaseline[c.pid] = html;
save();
renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
// Beide Ansichten wurden komplett neu aufgebaut — den Fokus in das Feld
// zurückholen, das der Nutzer gerade anklicken wollte.
restorePendingCommentFocus();
}
// discardBoardComment: setzt das Textfeld auf den zuletzt gespeicherten Stand
// zurück. Parameter: el (Element im Kommentar-Block). Keine Rückgabe.
function discardBoardComment(el){
const c = commentParts(el);
if(!c || !c.field) return;
c.field.innerHTML = boardCommentBaseline[c.pid] || '';
markCommentDirty(c.field);
richtextCursorAnsEnde(c.field);
}
// boardCommentKey: Tastaturbedienung des Kommentarfelds — Strg/Cmd+Enter
// speichert, Escape verwirft. Ein einfaches Enter bleibt der Zeilenumbruch.
// Parameter: e (KeyboardEvent), el (das Textfeld). Keine Rückgabe.
function boardCommentKey(e, el){
if(e.key === 'Enter' && (e.ctrlKey || e.metaKey)){
saveBoardComment(el);
e.preventDefault();
} else if(e.key === 'Escape'){
discardBoardComment(el);
e.preventDefault();
}
}
// boardCommentBlur: Sicherheitsnetz — wer tippt und dann woanders hin klickt,
// verliert seinen Text nicht. Entspricht dem früheren Verhalten von
// statusBlur(). Bedienelemente, bei denen der Fokusverlust stören würde
// ("Verwerfen", "übernehmen", die Anwesenheits-Kacheln), unterdrücken ihn per
// onmousedown/preventDefault. Parameter: el (das Textfeld). Keine Rückgabe.
function boardCommentBlur(el){
saveBoardComment(el);
}
// takeCommentFromHistory: übernimmt den Kommentartext einer früheren Sitzung
// in das Textfeld der aktuellen — für den häufigen Fall "gegenüber der
// Vorwoche unverändert". Gespeichert wird erst durch Speichern (oder das
// Blur-Netz), sodass der Text vorher noch angepasst werden kann. Parameter:
// el (der geklickte Knopf), weekLabel (Bezeichnung der Quell-Sitzung).
// Keine Rückgabe.
function takeCommentFromHistory(el, weekLabel){
if(guardWeekLocked()) return;
const c = commentParts(el);
if(!c || !c.field) return;
const p = currentWeek().projects.find(x => x.id === c.pid);
if(!p || !p.projectUid) return;
// Zwei Quellen, weil zwei Aufrufer: Der Aufklapper "Frühere Sitzungen"
// listet projectStatusHistory() — also nur Sitzungen, in denen sich der
// Text tatsächlich GEÄNDERT hat. Der Verlauf im Wochenbericht zeigt dagegen
// JEDE frühere Sitzung. Verwies der Knopf dort auf eine Woche ohne
// Textänderung, fand find() nichts und das Übernehmen blieb wirkungslos.
const entry = projectStatusHistory(p.projectUid).find(e => e.week.label === weekLabel);
const html = entry
? entry.html
: (v => v ? sanitizeStatusHtml(v.project.status || '') : null)(
projectVersionsForUid(p.projectUid).find(v => v.week.label === weekLabel));
if(html === null || html === undefined) return;
c.field.innerHTML = sanitizeStatusHtml(html);
markCommentDirty(c.field);
richtextCursorAnsEnde(c.field);
}
// renderBoardDrawer: baut das aufgeklappte Detailpanel einer Projekt-Zeile —
// links der volle Status-Editor (unverändert gegenüber der früheren Matrix-
// Zelle) plus alle LPH-Balken, rechts die vollständige Rollen-/Personen-
// Zuordnung als klickbare Avatar-Chips. Parameter: p (Projekt), allPeople
// (Array `{person, role}`), locked (Boolean). Rückgabe: HTML-String.
function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
// opts.dicht: die dichte Fassung der Karte (Wochenbericht). Sie zeigt
// dieselben Angaben und dieselben Bedienelemente wie die normale, nur
// enger gesetzt: die Leistungsphasen stehen als EINE Leiste nebeneinander
// statt als bis zu zehn Zeilen untereinander, und die Beteiligten rücken
// darunter statt in eine eigene Spalte. Die Zeilenform bestimmte bisher die
// Höhe der ganzen Karte — bei 29 Projekten ergab das rund 17
// Bildschirmseiten, durch die man sich im Teammeeting hindurchscrollen
// musste. Im Projektboard, wo die Karte nur beim Aufklappen einer Zeile
// erscheint, bleibt es bei der ausführlichen Fassung.
//
// nurBeauftragteLph: sowohl im Wochenbericht als auch im Projektboard
// werden nur die Leistungsphasen gezeigt, für die das Projekt beauftragt
// ist (p.beauftragteLP, gesetzt beim Anlegen oder im Honorar-Dialog). Ist
// nichts hinterlegt — was auf ältere Bestandsprojekte zutrifft —, bleibt es
// bei der vollständigen Liste, sonst stünde dort gar nichts.
// opts.nurAnsicht: die Karte zeigt, sie erfasst nicht.
//
// Kommentar, Fortschritt je Leistungsphase und die Zuordnung der
// Beteiligten entstehen in der wöchentlichen Besprechung und werden deshalb
// allein im Wochenbericht gepflegt. Zuvor waren dieselben drei Angaben an
// drei Stellen bearbeitbar (Projektboard, Wochenbericht, Projektbereich) —
// niemand wusste, welche die richtige ist, und zwei Personen konnten
// dasselbe Feld an verschiedenen Stellen ändern. Dieselbe Karte, dieselben
// Werte, nur ohne Eingabefelder und mit einem Verweis dorthin, wo sie
// gepflegt werden. Eine gesperrte Sitzung wirkt weiterhin zusätzlich.
const ansicht = !!(opts && opts.nurAnsicht);
const starr = ansicht || locked;
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
const nurBeauftragte = !!(opts && opts.nurBeauftragteLph) && beauftragt.length > 0;
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
const aktLp = lpActiveList(p)[0];
// lphLeiste: die Leistungsphasen nebeneinander statt gestapelt — seit
// Variante A (Eriks Wahl aus dem Layout-Vergleich) die EINE Darstellung für
// Leistungsphasen in dieser Schublade, nicht mehr nur die dichte
// Wochenbericht-Karte. Jede Phase ist ein schmales Feld mit der
// Phasennummer und ihrem Eingabefeld; der Füllstand steckt als senkrechter
// Verlauf im Feld-Hintergrund. Die amtierende Phase bleibt hervorgehoben.
const lphLeiste = sichtbareLphs.map(l=>{
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
const aktiv = l === aktLp;
const inputAttr = starr ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
const pct = v !== "" ? Math.min(100, parseFloat(v)||0) : 0;
// Deckkraft 4d (30 %): Bei 33 (20 %) war der Füllstand auf dem dunklen
// Feld-Hintergrund praktisch nicht zu sehen, und damit hätte die Leiste
// den Balken der Zeilenform ersatzlos verloren.
const fuellung = pct > 0
? ` style="background:linear-gradient(to top, ${getProgressColor(pct)}4d ${pct}%, transparent ${pct}%)"`
: '';
return `<div class="board-lph-cell${aktiv ? ' lp-aktiv' : ''}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}"${fuellung} title="${esc(l)}${aktiv ? ' · amtierende Leistungsphase' : ''}">
<span class="board-lph-cell-label">${esc(l.replace('LPH ', ''))}</span>
<input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">
</div>`;
}).join('');
const roleRows = ROLES.map(r=>{
const entries = allPeople.filter(e=>e.role===r);
const chips = entries.length ? entries.map(entry=>{
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0;
const click = starr ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`;
return `<span class="board-avatar ${roleClass(entry.role)} state-${state}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}" ${click} title="${esc(entry.person.fullName || entry.person.name)}">${esc(entry.person.name)}</span>`;
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`;
return `<div class="board-role-row"><span class="board-role-label ${roleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
}).join('');
// Dichte Fassung: zwei Spalten — links der Kommentar, rechts die
// Leistungsphasen-Leiste und darunter die Beteiligten.
//
// Zwischenzeitlich waren es drei Spalten, weil die gestapelten Beteiligten
// die Karte höher machten und damals 29 Karten untereinander standen. Seit
// der Wochenbericht eine Tagesordnung ist, ist immer nur EIN Projekt
// geöffnet — die Höhe der Karte kostet nichts mehr, während die Breite
// knapp ist: Neben der Projektliste blieben für drei Spalten nur noch
// 137 px für die Beteiligten, zu wenig für Rollenname und Kürzel.
//
// Die Beteiligten behalten ihre Zeile je Rolle — anders als im Entwurf, wo
// sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in
// mehreren Rollen vorkommen, und cycleAssign() braucht die Rolle. Ohne sie
// wäre beim Klicken nicht erkennbar, welche Zuordnung man durchschaltet.
if(opts && opts.dicht){
return `<div class="board-drawer board-drawer-dicht">
<div class="board-drawer-grid">
<div class="drawer-col drawer-col-comment">
<h4>Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}</h4>
${renderCommentEditor(p, weekLabel, locked, true)}
</div>
<div class="drawer-col drawer-col-kompakt">
<h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
<div class="board-lph-leiste">${lphLeiste}</div>
<h4 class="drawer-h4-zweit">Beteiligte</h4>
<div class="board-role-mini">${roleRows}</div>
</div>
</div>
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
</div>`;
}
// Die drei Blöcke stehen als gleichrangige Geschwister im Raster, damit der
// aufrufende Kontext ihre Anordnung allein über CSS bestimmen kann: das
// Projektboard stellt Kommentar und Leistungsphasen übereinander in die
// linke Spalte (grid-template-areas in styles.css), der Wochenbericht
// nebeneinander in drei Spalten. Inhalt und Reihenfolge sind in beiden
// Fällen identisch.
return `<div class="board-drawer">
<div class="board-drawer-grid">
<div class="drawer-col drawer-col-comment">
<h4>Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}</h4>
${ansicht ? renderKommentarAnsicht(p, locked) : renderCommentEditor(p, weekLabel, locked)}
</div>
<div class="drawer-col drawer-col-lph">
<h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
<div class="board-lph-leiste">${lphLeiste}</div>
</div>
<div class="drawer-col drawer-col-people">
<h4>Beteiligte</h4>
<div class="board-role-mini">${roleRows}</div>
</div>
</div>
${ansicht ? renderBerichtVerweis(p, locked) : ''}
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
</div>`;
}
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
function renderKommentarAnsicht(p, locked){
const text = sanitizeStatusHtml(p.status || '');
return `<div class="status-edit is-ansicht locked-cell">${text ||
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
}
// renderBerichtVerweis: der Weg zur Bearbeitung. Der Verweis springt in den
// Wochenbericht UND stellt dort dieses Projekt ein — ohne das müsste man es
// in einer Liste von dreißig wiederfinden.
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
function renderBerichtVerweis(p, locked){
if(locked) return '<div class="drawer-verweis"><span class="hint" style="margin:0">Die Sitzung ist gesperrt.</span></div>';
const uid = esc(p.projectUid || '');
return `<div class="drawer-verweis">
<span class="hint" style="margin:0">Kommentar, Fortschritt und Zuordnung werden in der Besprechung erfasst.</span>
<button type="button" class="plan-verweis" onclick="springZumWochenbericht('${uid}')">Im Wochenbericht bearbeiten →</button>
</div>`;
}
// renderBoard: baut die Listenansicht des Boards auf (eine Bubble-Card je
// Projektgruppe mit den Projekten als Zeilen, siehe renderBoardRow()). Liest
// die aktuelle Woche, gruppiert Projekte nach Gruppe und sortiert sie nach
// `order`. 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(){ function renderBoard(){
const w = currentWeek(); const w = currentWeek();
const container = document.getElementById('boardTable');
// Ohne geladene Sitzung (Ladefehler, oder die letzte Sitzung wurde von
// jemand anderem gelöscht) griff der Code ungeprüft auf w.projects zu — der
// TypeError riss renderAll() mit und ließ die Oberfläche halb gerendert
// stehen. Alle Schwester-Renderer prüfen das bereits selbst.
if(!w){
if(container) container.innerHTML = '<div class="hint">Keine Sitzung geladen.</div>';
return;
}
const locked = isWeekLocked(w); const locked = isWeekLocked(w);
const table = document.getElementById('boardTable');
const grouped = peopleByRole(w); const grouped = peopleByRole(w);
const allPeople = [];
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
// Kopfzeilen: Rollen und Leistungsphasen // Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen.
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>`; }); // Alle vier Standardgruppen werden vorbelegt, auch wenn sie leer sind: Sonst
h1 += `<th colspan="${LPHS.length}">Leistungsphasen (%)</th><th rowspan="2"></th></tr>`; // entstünde für eine leer geräumte Gruppe keine Ablagefläche, und ein
// Projekt ließe sich nicht mehr dorthin ziehen. (Aus dem früheren
let h2 = `<tr>`; // Projekte-Tab übernommen, der hierin aufgegangen ist.)
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 = {}; const groups = {};
GROUP_ORDER.forEach(g => { groups[g] = []; });
w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); }); w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); });
// Projekte nach ihrer Reihenfolge sortieren // Projekte nach ihrer Reihenfolge sortieren
@@ -99,169 +617,29 @@ function renderBoard(){
groups[g].sort((a, b) => (a.order || 0) - (b.order || 0)); groups[g].sort((a, b) => (a.order || 0) - (b.order || 0));
}); });
let body = ""; let html = "";
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=>{ sortGroups(Object.keys(groups)).forEach(g=>{
const colspan = 3 + allPeople.length + LPHS.length + 1; // Spaltenanzahl für Gruppentitel html += `<div class="section-title">${esc(g)}</div>`;
body += `<tr class="group-header"><td colspan="${colspan}">${esc(g)}</td></tr>`; // Klasse "projects-sortable": Die Ablage-Handler aus
groups[g].forEach(p=>{ // js/projects/projects.js (handleDrop/reorderProjects) finden ihre
body += `<tr>`; // Container über diesen Selektor. Sie bleiben dort, weil sie auch die
body += `<td class="left">${esc(p.nr)}</td>`; // Reihenfolge-Logik für die Daten enthalten — hier wird nur dieselbe
body += `<td class="left proj-name" style="cursor:pointer" onclick="openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</td>`; // Ablagefläche aufgespannt.
// Status-Editor: contenteditable mit kleiner Toolbar (Fett, Farben, Absätze mit Enter) html += `<div class="board-list projects-sortable" data-group="${esc(g)}" ${locked ? '' : 'ondragover="handleDragOver(event)" ondrop="handleDrop(event)"'}>`;
const statusClass = p.statusState === 'new' ? 'status-new' : p.statusState === 'duplicated' ? 'status-duplicated' : ''; groups[g].forEach(p=>{ html += renderBoardRow(p, allPeople, locked, w.label, BOARD_OPTS); });
const statusEditable = locked ? 'false' : 'true'; if(groups[g].length === 0) html += `<div class="hint" style="padding:6px 2px 12px">Keine Projekte — Projekt hierher ziehen.</div>`;
const statusToolbar = locked ? '' : ` html += `</div>`;
<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>`;
}); });
// Sperr-Hinweis über der Projektliste. Er stand früher im Container der
// LPH-Spalten mit Prozentwerten und Fortschrittsbalken; // Kürzel-Legende unter dem Board; seit die Legende entfallen ist, gehört er
// die amtierende Leistungsphase (Tab "Projekte") wird farblich markiert. // dorthin, wo er gilt — über die Liste, die man nicht bearbeiten kann.
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){ 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;"> html = `<div class="lock-notice">
Diese Sitzung ist gesperrt. Bearbeiten nur nach Passworteingabe möglich. Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich.
<button type="button" class="secondary small" style="margin-left:12px;" onclick="unlockCurrentWeek()">Entsperren</button> <button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button>
</div>`; </div>` + html;
} }
container.innerHTML = html;
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 // cycleAssign: wechselt die Zuweisung einer Person zu einem Projekt zyklisch
@@ -275,7 +653,7 @@ function cycleAssign(pid, personId, role){
const cur = p.assign[key]||0; const cur = p.assign[key]||0;
const next = (cur+1)%3; const next = (cur+1)%3;
if(next===0) delete p.assign[key]; else p.assign[key]=next; if(next===0) delete p.assign[key]; else p.assign[key]=next;
save(); renderBoard(); save(); renderBoard(); renderWochenbericht();
} }
// editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein // editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein
@@ -287,36 +665,9 @@ function editLph(pid,lph,val){
val = val.trim(); val = val.trim();
if(val==="") delete p.lph[lph]; if(val==="") delete p.lph[lph];
else p.lph[lph]=val.replace('%',''); else p.lph[lph]=val.replace('%','');
save(); renderBoard(); save(); renderBoard(); renderWochenbericht();
} }
// editProj: aktualisiert ein Feld eines Projekts; für Status wird zusätzlich // HINWEIS: delProj() ist entfallen — seit der neuen Zeilendarstellung des
// ein flag `statusState` gesetzt (='new' beim Anlegen eines neuen Kommentars), // Boards gibt es dort keinen Löschen-Knopf mehr. Projekte werden im
// wenn zuvor noch kein Status existierte. Parameter: `pid` (Projekt-ID), // Projekte-Tab entfernt (delProjectFromList(), js/projects/projects.js).
// `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();
}
-351
View File
@@ -1,351 +0,0 @@
// -----------------------------
// 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 sauber gequotetes CSV (wie es
// csvEscape/weekToCsv erzeugen). Semikolons innerhalb eines quoted Feldes sind
// Feldinhalt und kein Trennzeichen – so bleibt weekToCsv()/parseSessionCsv()
// auch bei Status-Texten, die mit ";" enden, invers.
// 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 === ';' || 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 };
}
+1 -1
View File
@@ -1,7 +1,7 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Dashboard ("Meine Aufgaben"-Tab) // Projektübersicht — Dashboard ("Meine Aufgaben"-Tab)
// ----------------------------- // -----------------------------
// Modul für den ersten Tab der Hauptseite (Test.html, Ladereihenfolge: // Modul für den ersten Tab der Hauptseite (App.html, Ladereihenfolge:
// ... → load → dashboard → gantt → ...). Das Dashboard ist die Startübersicht // ... → load → dashboard → gantt → ...). Das Dashboard ist die Startübersicht
// nach dem Login; alles darin stammt aus einem einzigen GET /my-dashboard. // nach dem Login; alles darin stammt aus einem einzigen GET /my-dashboard.
// //
+49 -76
View File
@@ -1,19 +1,16 @@
// ----------------------------- // -----------------------------
// Projektübersicht — GANTT-Diagramm (gemeinsam genutzt) // Projektübersicht — GANTT-Diagramm (gemeinsam genutzt)
// ----------------------------- // -----------------------------
// Gemeinsames Modul für das GANTT-Diagramm: Es wird von Test.html (nach // Gemeinsames Modul für das GANTT-Diagramm. Es wird von App.html nach
// js/load/load.js) UND von project-plan.html geladen – dort zusammen mit globals, // js/load/load.js geladen und ist die Grundlage sowohl der Übersichts-
// utils, ui, date-picker, csv, server und den js/plan/*-Modulen, aber OHNE // Diagramme als auch des Terminplans im Projektbereich: die Module unter
// js/store.js, js/weeks.js, js/board/board.js usw. Es darf daher auf der Plan-Seite // js/plan/ setzen darauf auf und müssen deshalb NACH dieser Datei stehen.
// keine Hauptseiten-spezifischen Funktionen voraussetzen; Funktionen, die // Das Modul stellt u. a. buildGanttHtml(), projectAssignees(),
// currentWeek()/isWeekLocked() aus js/weeks.js brauchen (renderGantt, // ganttRangeFromProjects() sowie openProjectPlan()/closePlanPage()/
// fitGanttRange, openProjectPlan), werden nur vom Hauptfenster aufgerufen. // bereit. js/plan/plan-build-gantt.js umhüllt
// Das Modul stellt u. a. buildGanttHtml(), renderGantt(), fitGanttRange(), // buildGanttHtml() und ergänzt sie um TODO- und Meilensteinzeilen; diese
// projectAssignees(), ganttRangeFromProjects() sowie openProjectPlan()/ // Erweiterung greift nur bei gesetzter Option showTodos, die Aufrufe der
// findProjectForPlan() bereit. Die Plan-Seite erweitert buildGanttHtml() über // Übersicht bleiben also unverändert.
// 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). // ganttModalProjectId: reservierte Projekt-ID für GANTT-Modal-Dialoge (zurzeit ungenutzt).
let ganttModalProjectId = null; let ganttModalProjectId = null;
@@ -35,7 +32,7 @@ function ensurePlan(p){
function projectAssignees(p, week = null){ function projectAssignees(p, week = null){
const w = week || currentWeek(); const w = week || currentWeek();
const people = w ? (w.people || []) : []; const people = w ? (w.people || []) : [];
const globalList = db.globalPeople[currentDepartment] || []; const globalList = db.globalPeople || [];
const result = []; const result = [];
people.forEach(person => { people.forEach(person => {
const globalPerson = globalList.find(gp => gp.id === person.id); const globalPerson = globalList.find(gp => gp.id === person.id);
@@ -63,7 +60,12 @@ function projectColor(p){
// Projekt-/TODO-/Meilenstein-Terminen sowie den Abwesenheiten der globalen // Projekt-/TODO-/Meilenstein-Terminen sowie den Abwesenheiten der globalen
// Personen, jeweils erweitert um padDays Tage (Standard 7). Parameter: // Personen, jeweils erweitert um padDays Tage (Standard 7). Parameter:
// projects = Projekt-Array, padDays = Puffer; Rückgabe: { start, end } als Date. // projects = Projekt-Array, padDays = Puffer; Rückgabe: { start, end } als Date.
function ganttRangeFromProjects(projects, padDays = 7){ // Parameter `personIds` (optional): Beschränkt die berücksichtigten
// Abwesenheiten auf diese Personen. Ohne die Einschränkung zog die Elternzeit
// eines völlig unbeteiligten Kollegen den Zeitraum jedes Projektbereichs bis
// weit in die Zukunft — obwohl die zugehörige Urlaubszeile gar nicht gezeichnet
// wird (renderPlanGantt() filtert sie auf die Projektbeteiligten).
function ganttRangeFromProjects(projects, padDays = 7, personIds = null){
let min = null, max = null; let min = null, max = null;
projects.forEach(p => { projects.forEach(p => {
const plan = p.plan; const plan = p.plan;
@@ -89,8 +91,10 @@ function ganttRangeFromProjects(projects, padDays = 7){
}); });
}); });
}); });
// Urlaub berücksichtigen // Urlaub berücksichtigen (nur von Personen, deren Zeilen auch gezeichnet werden)
(db.globalPeople[currentDepartment] || []).forEach(person => { const relevant = personIds ? new Set(personIds) : null;
(db.globalPeople || []).forEach(person => {
if(relevant && !relevant.has(person.id)) return;
(person.absences || []).forEach(a => { (person.absences || []).forEach(a => {
const s = parseDateISO(a.start), e = parseDateISO(a.end); const s = parseDateISO(a.start), e = parseDateISO(a.end);
if(s){ if(!min || s < min) min = s; if(!max || s > max) max = s; } if(s){ if(!min || s < min) min = s; if(!max || s > max) max = s; }
@@ -105,37 +109,11 @@ function ganttRangeFromProjects(projects, padDays = 7){
return { start: addDays(min, -padDays), end: addDays(max, padDays) }; return { start: addDays(min, -padDays), end: addDays(max, padDays) };
} }
// fitGanttRange: setzt die Datumsfelder (ganttStart/ganttEnd) auf den // HINWEIS: Die frühere GANTT-Ansicht der Hauptseite (renderGantt/fitGanttRange)
// automatisch berechneten Projekt-Zeitrahmen und rendert das Diagramm neu. // ist entfallen. Sie griff auf die Elemente #ganttContainer/#ganttStart/
function fitGanttRange(){ // #ganttEnd/#ganttShowVacation zu, die es in keiner der drei HTML-Seiten mehr
const w = currentWeek(); if(!w) return; // gibt — ein Aufruf hätte sofort geworfen. Das GANTT lebt heute ausschließlich
const range = ganttRangeFromProjects(w.projects, 7); // im Projektbereich (renderPlanGantt/fitPlanGanttRange in js/plan/plan-gantt.js).
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/ // buildGanttHtml: erzeugt das HTML des GANTT-Diagramms (Kopf mit Monaten/
// KW-Zellen, Wochenend-Hintergrund, Heute-Linie, Urlaubs- und Projektzeilen // KW-Zellen, Wochenend-Hintergrund, Heute-Linie, Urlaubs- und Projektzeilen
@@ -250,7 +228,7 @@ function buildGanttHtml(projects, start, end, options = {}){
// Urlaubszeilen // Urlaubszeilen
if(showVacation){ if(showVacation){
let allPeople = db.globalPeople[currentDepartment] || []; let allPeople = db.globalPeople || [];
// Optional: nur Urlaub bestimmter Personen anzeigen // Optional: nur Urlaub bestimmter Personen anzeigen
if(options.vacationPeople && Array.isArray(options.vacationPeople)){ if(options.vacationPeople && Array.isArray(options.vacationPeople)){
const allowed = new Set(options.vacationPeople); const allowed = new Set(options.vacationPeople);
@@ -327,36 +305,31 @@ function buildGanttHtml(projects, start, end, options = {}){
// ---------- Projektplan-Navigation ---------- // ---------- Projektplan-Navigation ----------
// openProjectPlan: öffnet project-plan.html in einem neuen Tab. Weil // openProjectPlan: öffnet den Projektbereich EINES Projekts. Das war einmal
// Projekt-IDs nicht persistent sind, werden Wochen-Label und Proj.-Nr. als // eine Navigation auf ein eigenes Dokument (project-plan.html); inzwischen
// URL-Parameter übergeben; Abteilung und Projektname stehen im Pfad nach dem // ist der Projektbereich der Bereich #plan derselben Seite, also genügt ein
// Schema "/<Abteilung>/Projektplan/<Projektname>". Parameter: pid = // gewöhnlicher Bereichswechsel — Menüband und Seitenleiste bleiben stehen,
// Projekt-ID der aktuellen Woche. Kein Rückgabewert. // die Sitzungsdaten sind schon geladen, nichts blitzt weiß auf.
// Weil Projekt-IDs beim Duplizieren einer Sitzung neu vergeben werden,
// merkt sich der Bereich stabile Werte (Sitzungs-Label, Projekt-Nr./-Name);
// aus ihnen baut updateRouteUrl() auch die verschickbare Adresse
// "/Projektplan/<Projektname>?weekLabel=…&projNr=…".
// Parameter: pid = Projekt-ID der aktuellen Sitzung. Kein Rückgabewert.
function openProjectPlan(pid){ function openProjectPlan(pid){
const w = currentWeek(); if(!w) return; const w = currentWeek(); if(!w) return;
const p = w.projects.find(x => x.id === pid); if(!p) return; const p = w.projects.find(x => x.id === pid); if(!p) return;
// IDs sind nicht persistent (werden bei jedem CSV-Laden neu erzeugt). setPlanTarget(w.label, p.nr || '', p.name || '');
// Daher werden stabile Werte (Label, Projekt-Nr.) in der URL übergeben. showTab('plan');
// Absoluter Pfad mit Abteilung und Projektname als Route-Segment, damit der window.scrollTo(0, 0);
// Aufruf auch von Deep-Link-URLs wie "/HLS/Auslastung" funktioniert.
const url = new URL('/' + currentDepartment + '/' + PLAN_SLUG + '/' + encodeURIComponent(p.name || ''), window.location.origin);
url.searchParams.set('weekLabel', w.label);
url.searchParams.set('projNr', p.nr || '');
window.open(url.toString(), '_blank');
} }
// Diese Funktion wird von project-plan.html aufgerufen, um das aktuelle // closePlanPage: Gegenstück zu openProjectPlan() für den "Schließen"-Knopf
// Wochen-/Projekt-Objekt im Hauptfenster zu finden. // in der Kopfzeile des Projektbereichs. Früher ging es hier in der
// Parameter: department, weekId, projectId; Rückgabe: { week, project } oder // Browser-Historie zurück, weil der Projektbereich ein eigenes Dokument war.
// null, wenn die Woche/Abteilung nicht (synchron) auflösbar ist. // Jetzt ist es ein schlichter Bereichswechsel zurück auf das Projektboard
function findProjectForPlan(department, weekId, projectId){ // (bzw. auf den Bereich, aus dem geöffnet wurde). Kein Parameter, keine
if(currentDepartment !== department){ // Rückgabe.
const targetWeek = db.weeks.find(w => w.id === weekId); function closePlanPage(){
if(!targetWeek) return null; const back = (typeof previousTab === 'string' && previousTab && previousTab !== 'plan') ? previousTab : 'board';
// temporär umschalten ist unpraktisch, daher versuchen wir über load-all showTab(back);
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) };
} }
+11 -2
View File
@@ -1,7 +1,7 @@
// ----------------------------- // -----------------------------
// Projektübersicht — globale Konstanten & Zustand // Projektübersicht — globale Konstanten & Zustand
// ----------------------------- // -----------------------------
// Diese Datei wird als Erstes von Test.html geladen und // Diese Datei wird als Erstes von App.html geladen und
// legt alle zentralen Konstanten (Leistungsphasen, Rollen, Gruppen, Abteilungen, // legt alle zentralen Konstanten (Leistungsphasen, Rollen, Gruppen, Abteilungen,
// Farben, Passwort-Hash) sowie den globalen In-Memory-Zustand (db, currentTab) // Farben, Passwort-Hash) sowie den globalen In-Memory-Zustand (db, currentTab)
// fest. Sie stellt keine Funktionen, sondern nur Daten bereit, auf die alle // fest. Sie stellt keine Funktionen, sondern nur Daten bereit, auf die alle
@@ -45,6 +45,14 @@ let APP_SETTINGS = {
vollzeitStundenProWoche: 40, vollzeitStundenProWoche: 40,
auslastungSchwelleGelb: 0.75, auslastungSchwelleGelb: 0.75,
auslastungSchwelleRot: 1.0, auslastungSchwelleRot: 1.0,
// referenzHonorar/verteilzeitraumWochen fehlten hier, obwohl
// berechneAuslastung() (js/load/load.js) beide liest. Die Aufrufstellen
// fingen das mit eigenen Rückfallwerten ab (|| 250000, || 12) — die Vorgabe
// stand damit an drei Stellen, und eine im Admin-Bereich geänderte
// Server-Vorgabe wäre an den Rückfallwerten vorbeigelaufen. Werte identisch
// zu APP_SETTING_DEFS (server.js), wie bei allen Schlüsseln hier.
referenzHonorar: 250000,
verteilzeitraumWochen: 12,
aufwandVorlage: { aufwandVorlage: {
'LPH 1': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 }, 'LPH 1': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
'LPH 2': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 }, 'LPH 2': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
@@ -138,6 +146,7 @@ const TAB_SLUGS = {
people: 'Projektbeteiligte', people: 'Projektbeteiligte',
planung: 'Planung', planung: 'Planung',
dashboard:'MeineAufgaben', dashboard:'MeineAufgaben',
wand: 'Wandtafel',
settings: 'Einstellungen', settings: 'Einstellungen',
admin: 'Admin' admin: 'Admin'
}; };
@@ -148,7 +157,7 @@ const TAB_BY_SLUG = Object.fromEntries(Object.entries(TAB_SLUGS).map(([k,v]) =>
// aber nicht mehr erzeugt, weil er in TAB_SLUGS fehlt. // aber nicht mehr erzeugt, weil er in TAB_SLUGS fehlt.
TAB_BY_SLUG['Projekte'] = 'board'; TAB_BY_SLUG['Projekte'] = 'board';
// PLAN_SLUG: URL-Schlüssel des Projektbereichs (Bereich #plan von Test.html), // PLAN_SLUG: URL-Schlüssel des Projektbereichs (Bereich #plan von App.html),
// im gleichen Deep-Link-Schema wie die Tabs: "/Projektplan/<Projektname>", // im gleichen Deep-Link-Schema wie die Tabs: "/Projektplan/<Projektname>",
// dazu ?weekLabel=…&projNr=… als Parameter. Steht nicht in TAB_SLUGS, weil // dazu ?weekLabel=…&projNr=… als Parameter. Steht nicht in TAB_SLUGS, weil
// die Adresse das geöffnete Projekt mitträgt und deshalb nicht fest ist — // die Adresse das geöffnete Projekt mitträgt und deshalb nicht fest ist —
+64
View File
@@ -0,0 +1,64 @@
// -----------------------------
// Projektübersicht — Datenbank-Import (Backup-Wiederherstellung)
// -----------------------------
// Stellt importDb() bereit: wählt über einen Datei-Dialog eine mit "DB Backup"
// erzeugte .db-Datei aus, lädt sie als rohen Binär-Body an POST /import und
// lädt nach erfolgreicher Wiederherstellung die Seite neu. Da der Import den
// kompletten Datenbestand ersetzt, ist die Route serverseitig admin-only
// abgesichert (requireRole('admin'), server.js); der Button ist im UI
// zusätzlich nur für Admins sichtbar (js/auth.js: renderUserBar()). Zusätzlich
// wird der Import im Browser per Bestätigungsdialog abgesichert.
// importDb: bestätigt die destruktive Aktion und startet danach den
// Datei-Dialog für das Backup. Keine Rückgabe.
function importDb(){
// Genau benennen, was ersetzt wird: Der Server tauscht nur die
// Sitzungstabellen (IMPORT_TABLES in server.js). Benutzerkonten, Rollen,
// Abwesenheiten, hochgeladene Dateien und deren Freigaben bleiben stehen —
// die frühere Formulierung "Alle aktuellen Daten werden ersetzt" führte in
// die Irre, etwa beim Versuch, einen gelöschten Benutzer zurückzuholen.
if(!confirm('Datenbank-Backup wirklich wiederherstellen?\n\n'
+ 'ERSETZT WERDEN: alle Sitzungen mit Projekten, Zuordnungen, Kommentaren, Terminplänen und Aufgaben.\n\n'
+ 'NICHT BETROFFEN: Benutzerkonten, Rollen, Abwesenheiten, hochgeladene Dateien und deren Freigaben.\n\n'
+ 'Der aktuelle Stand wird vorher automatisch als Backup gesichert.')){
return;
}
const input = document.createElement('input');
input.type = 'file';
input.accept = '.db,application/octet-stream,application/x-sqlite3';
input.onchange = () => {
const file = input.files && input.files[0];
if(!file) return;
uploadBackup(file);
};
input.click();
}
// uploadBackup: lädt die gewählte .db-Datei als rohen Binär-Body an
// POST /import und informiert über das Ergebnis. Bei Erfolg wird die Seite neu
// geladen, damit das Frontend den wiederhergestellten Datenbestand zeigt.
async function uploadBackup(file){
try {
const res = await fetch(baseUrl() + '/import', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: file
});
if(!res.ok){
if(res.status === 403){ alert('Nur Administratoren dürfen die Datenbank importieren.'); return; }
let msg = 'Server HTTP ' + res.status;
try {
const json = await res.json();
if(json && json.error) msg = json.error;
} catch(e){ /* Antwort war kein JSON */ }
throw new Error(msg);
}
alert('Sitzungsdaten aus dem Backup wiederhergestellt.\n\n'
+ 'Benutzerkonten, Abwesenheiten und hochgeladene Dateien wurden dabei nicht verändert.\n'
+ 'Die Seite wird neu geladen.');
window.location.reload();
} catch(err){
console.error('DB-Import fehlgeschlagen:', err);
alert('DB-Import fehlgeschlagen: ' + (err.message || err));
}
}
+2 -17
View File
@@ -2,10 +2,10 @@
// Projektübersicht — Init / Start // Projektübersicht — Init / Start
// ----------------------------- // -----------------------------
// Einstiegsmodul der Hauptseite: wird als letztes Skript ausschließlich von // Einstiegsmodul der Hauptseite: wird als letztes Skript ausschließlich von
// Test.html geladen und startet direkt beim Laden den Initialisierungsablauf // App.html geladen und startet direkt beim Laden den Initialisierungsablauf
// (automatische Ausführung unten in der Datei, nur wenn ein 'board'-Element // (automatische Ausführung unten in der Datei, nur wenn ein 'board'-Element
// existiert). Es stellt renderAll(), // existiert). Es stellt renderAll(),
// initUiState() und syncRangeDisplay() bereit und nutzt loadUiState()/ // initUiState() bereit und nutzt loadUiState()/
// loadTheme()/loadHeaderState()/loadSidebarCollapseState() aus js/ui.js, load() aus js/store.js, // loadTheme()/loadHeaderState()/loadSidebarCollapseState() aus js/ui.js, load() aus js/store.js,
// showTab()/TAB_PANELS aus js/tabs-auth.js sowie die Render-Funktionen // showTab()/TAB_PANELS aus js/tabs-auth.js sowie die Render-Funktionen
// renderBoard()/renderWochenbericht()/renderLoad()/renderProjects()/renderPeople() // renderBoard()/renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()
@@ -58,21 +58,6 @@ function initUiState(){
showTab(tab); showTab(tab);
} }
// 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 der Hauptseite — seit Projektbereich und // Automatische Initialisierung der Hauptseite — seit Projektbereich und
// Benutzerverwaltung eigene Bereiche sind, ist sie die einzige Seite. Der eigentliche // Benutzerverwaltung eigene Bereiche sind, ist sie die einzige Seite. Der eigentliche
// Start läuft erst nach erfolgreicher Anmeldung (checkAuthAndInit(), js/auth.js) — // Start läuft erst nach erfolgreicher Anmeldung (checkAuthAndInit(), js/auth.js) —
+8 -27
View File
@@ -1,14 +1,14 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase // Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase
// ----------------------------- // -----------------------------
// Modul für den Auslastungs-Tab der Hauptseite (Test.html, Ladereihenfolge: // Modul für den Auslastungs-Tab der Hauptseite (App.html, Ladereihenfolge:
// globals → utils → ui → date-picker → csv → server → store → weeks → board → // globals → utils → ui → date-picker → csv → server → store → weeks → board →
// people → projects → load → gantt → …). Es stellt renderLoad(), das LP- // people → projects → load → gantt → …). Es stellt renderLoad(), das LP-
// Aufwand-Modal und das Neues-Projekt-Modal bereit. Genutzt werden LPHS/ROLES // 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/globals.js, currentWeek()/save() aus js/store.js, guardWeekLocked()
// aus js/weeks.js sowie peopleByRole()/assignKey() aus js/utils.js. // aus js/weeks.js sowie peopleByRole()/assignKey() aus js/utils.js.
// Die Auslastungs-Logik (berechneAuslastung, lpActiveList, faktorFormat, // Die Auslastungs-Logik (berechneAuslastung, lpActiveList, faktorFormat,
// pdfLoadAmpelColor, stundenFormat) wird zusätzlich von js/pdf.js für den // stundenFormat) wird zusätzlich von js/pdf.js für den
// PDF-Export wiederverwendet. // PDF-Export wiederverwendet.
// //
// Datenmodell je Projekt (rollen- und leistungsphasenspezifisch): // Datenmodell je Projekt (rollen- und leistungsphasenspezifisch):
@@ -313,20 +313,6 @@ function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status, all
return { stunden, load: stunden / vollzeit, quelle: basis.quelle, wochen, phase }; return { stunden, load: stunden / vollzeit, quelle: basis.quelle, wochen, phase };
} }
// groesseText: beschriftet die Projektgröße für die Eingabemasken.
//
// Nur die Zahl, KEIN Eurobetrag daneben. Intern wird die Größe zwar über das
// Referenzhonorar in einen Betrag umgerechnet (projektBasisHonorar()) — die
// Zahl neben dem Feld auszuweisen, gäbe der Schätzung aber eine Genauigkeit,
// die sie nicht hat: "0,40 · rund 100.000 €" liest sich wie ein Honorar,
// obwohl es ein grober Umfang ist.
// Parameter: wert (0–1). Rückgabe: String.
function groesseText(wert){
const n = Math.max(0, Math.min(1, parseFloat(wert) || 0));
if(n <= 0) return '— keine Angabe';
return n.toFixed(2).replace('.', ',');
}
// stundenFormat: Wochenstunden in deutscher Schreibweise, eine // stundenFormat: Wochenstunden in deutscher Schreibweise, eine
// Nachkommastelle. Ab 100 h ohne Nachkomma — die Genauigkeit einer // Nachkommastelle. Ab 100 h ohne Nachkomma — die Genauigkeit einer
// Hochrechnung reicht dort nicht für Zehntel. // Hochrechnung reicht dort nicht für Zehntel.
@@ -359,16 +345,12 @@ function loadAmpelColor(x){
// nicht mehr nur einen Balken, sondern die große Zahl am Zeilenende. Die // nicht mehr nur einen Balken, sondern die große Zahl am Zeilenende. Die
// früheren Festwerte (#c53030/#d69e2e/#2f855a) waren für den hellen Grund // früheren Festwerte (#c53030/#d69e2e/#2f855a) waren für den hellen Grund
// gewählt und standen im dunklen Thema zu dunkel; --red/--amber/--green // gewählt und standen im dunklen Thema zu dunkel; --red/--amber/--green
// sind dort hellere Töne (siehe styles.css). Für den PDF-Export gilt // sind dort hellere Töne (siehe styles.css). Ein Gegenstück mit
// weiterhin pdfLoadAmpelColor() mit druckfreundlichen Festwerten — ein PDF // druckfreundlichen Festwerten gab es für den PDF-Export (pdfLoadAmpelColor);
// hat kein Thema. // es ist mit dem gewerkeübergreifenden Sitzungs-PDF entfallen — die
// verbliebenen Exporte zeigen keine Auslastung mehr.
return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)'; return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)';
} }
// 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 >= schwelleRot() ? '#D70015' : x >= schwelleGelb() ? '#B25000' : '#248A3D';
}
// ---------- LP-Aufwand Eingabemaske ---------- // ---------- LP-Aufwand Eingabemaske ----------
@@ -531,14 +513,13 @@ function projektFarbe(name){
// Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person // Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person
// überhaupt im Teilnehmerkreis stand — nicht durch alle vorhandenen. // überhaupt im Teilnehmerkreis stand — nicht durch alle vorhandenen.
function renderLoad(){ function renderLoad(){
const scope = document.getElementById('loadScope').value;
const selectedWeek = currentWeek(); const selectedWeek = currentWeek();
const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []); const weeks = selectedWeek ? [selectedWeek] : [];
if(weeks.length === 0){ if(weeks.length === 0){
document.getElementById('loadGrid').innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>'; document.getElementById('loadGrid').innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
return; return;
} }
const grouped = scope==='all' ? peopleByRole(db.weeks) : peopleByRole(selectedWeek); const grouped = peopleByRole(selectedWeek);
const stats = {}; const stats = {};
const personen = {}; // fullName -> { person, stunden, rollen:Set } const personen = {}; // fullName -> { person, stunden, rollen:Set }
ROLES.forEach(r=>{ ROLES.forEach(r=>{
+49 -9
View File
@@ -62,7 +62,15 @@ function pdfStatusText(htmlStatus){
if(!htmlStatus) return { text: '', style: 'td' }; if(!htmlStatus) return { text: '', style: 'td' };
const root = document.createElement('div'); const root = document.createElement('div');
root.innerHTML = htmlStatus; // sanitizeStatusHtml() ist hier PFLICHT, nicht Kosmetik: Der Server
// speichert status/text unverändert (keine serverseitige Bereinigung), und
// innerHTML führt Handler auch dann aus, wenn das Element gar nicht im
// Dokument hängt — ein <img onerror=…>, das ein angemeldeter Nutzer über
// die API in einen Kommentar oder Protokollpunkt schreibt, liefe sonst in
// der Sitzung JEDER Person, die daraus ein PDF erzeugt. Die Anzeige am
// Bildschirm bereinigt an ihren eigenen Stellen bereits; nur dieser Pfad
// fehlte. Geprüft: ohne diese Zeile feuert der Handler tatsächlich.
root.innerHTML = sanitizeStatusHtml(htmlStatus);
const parts = []; const parts = [];
@@ -175,13 +183,13 @@ function pdfNormalizeColor(v){
return null; return null;
} }
// loadLogoPng: lädt dein_bild.ico und konvertiert es via Canvas nach PNG. // loadLogoPng: lädt logo.ico und konvertiert es via Canvas nach PNG.
// pdfmake unterstützt nur JPEG und PNG, kein ICO. // pdfmake unterstützt nur JPEG und PNG, kein ICO.
// Gibt null zurück, wenn Laden oder Dekodieren fehlschlägt. // Gibt null zurück, wenn Laden oder Dekodieren fehlschlägt.
async function loadLogoPng(size){ async function loadLogoPng(size){
size = size || 256; size = size || 256;
try { try {
const res = await fetch(baseUrl() + '/dein_bild.ico'); const res = await fetch(baseUrl() + '/logo.ico');
if(!res.ok) return null; if(!res.ok) return null;
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -959,19 +967,51 @@ async function exportProtokollPDF(protokollId){
return cell; return cell;
}; };
const nummer = nummerById.get(p.id) || ''; const nummer = nummerById.get(p.id) || '';
const nummerText = p.neu // Erstellungsdatum steht ausgegraut unter der Nummer statt in
? { text: [nummer, { text: '\nNEU', fontSize: 5.5, bold: true, color: '#8A6D00' }] } // einer eigenen Spalte — auf Eriks Wunsch, damit die Tabelle
: { text: nummer }; // keine zusätzliche Spalte bekommt.
const statusCell = zelle({ text: p.erledigt ? 'erledigt' : 'offen', alignment: 'center' }); const erstelltText = formatDateDE(parseDateISO(p.erstelltAm));
const nummerParts = [nummer];
if(p.neu) nummerParts.push({ text: '\nNEU', fontSize: 5.5, bold: true, color: '#8A6D00' });
if(erstelltText) nummerParts.push({ text: '\n' + erstelltText, fontSize: 5.5, bold: false, color: '#86868B' });
// entfaelltNaechstes: Gnadenrunde eines erledigten Punkts (Eriks
// Wunsch) — derselbe Hinweis wie am Bildschirm
// (.protokoll-chip-entfaellt, plan-protokoll.js), hier unter dem
// Erstellungsdatum statt in einer eigenen Spalte, aus demselben
// Platzgrund wie erstelltText oben.
if(p.entfaelltNaechstes) nummerParts.push({ text: '\nEntfällt im nächsten Protokoll', fontSize: 5.5, bold: true, color: '#D70015' });
const nummerText = { text: nummerParts };
const statusLabel = { offen: 'offen', erledigt: 'erledigt', info: 'Info' }[p.status] || 'offen';
const statusCell = zelle({ text: statusLabel, alignment: 'center' });
// themaGeaendert/textGeaendert: wie am Bildschirm (.ist-geaendert,
// plan-styles.css) markiert nur die Thema/Vereinbarung-Zelle gelb,
// nicht die ganze Zeile wie bei einem echten neuen Punkt (p.neu,
// oben in zelle()) — pdfmake kennt keine Hintergrundfarbe je
// Textlauf innerhalb einer Zelle, nur je ganzer Zelle, deshalb
// bekommt hier ausnahmsweise die ganze Thema-Zelle den Ton, auch
// wenn nur eines der beiden Felder sich geändert hat. Bei p.neu
// erübrigt sich das (die ganze Zeile ist ohnehin schon gelb).
// p.text ist bereits sanitiziertes HTML (contenteditable-Feld mit
// Fett/Kursiv/Farbe, richtextToolbarHtml()/js/utils.js, Eriks
// Wunsch) — dieselbe pdfStatusText() wie der Wochenbericht-
// Kommentar wandelt es direkt in eine pdfmake-Textstruktur mit
// echten Auszeichnungen, ohne Zwischenschritt.
const textLaeufe = p.text ? pdfStatusText(p.text).text : '';
const themaZelle = zelle({ text: [
p.thema ? { text: p.thema, bold: true } : '',
p.thema && p.text ? '\n' : '',
...(Array.isArray(textLaeufe) ? textLaeufe : (textLaeufe ? [{ text: textLaeufe }] : []))
] });
if(!p.neu && (p.themaGeaendert || p.textGeaendert)) themaZelle.fillColor = '#FFF6DB';
body.push([ body.push([
zelle(nummerText, { bold: true }), zelle(nummerText, { bold: true }),
zelle({ text: [p.thema ? { text: p.thema, bold: true } : '', p.text ? { text: (p.thema ? '\n' : '') + p.text } : ''] }), themaZelle,
zelle({ text: p.zustaendig || '' }), zelle({ text: p.zustaendig || '' }),
zelle({ text: formatDateDE(parseDateISO(p.termin)) || '—' }), zelle({ text: formatDateDE(parseDateISO(p.termin)) || '—' }),
statusCell statusCell
]); ]);
}); });
punkteBlock.push({ table: { widths: [28, '*', 72, 42, 30], body }, layout: pdfWbGridLayout(true), margin: [0, 0, 0, 8] }); punkteBlock.push({ table: { widths: [34, '*', 72, 42, 30], body }, layout: pdfWbGridLayout(true), margin: [0, 0, 0, 8] });
}); });
} }
+110 -323
View File
@@ -1,242 +1,98 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Personen-Verwaltung (global pro Abteilung) // Projektübersicht — Personen-Ansicht (global, NUR LESEND)
// ----------------------------- // -----------------------------
// Diese Datei rendert den Personen-Tab (Tab "Projektbeteiligte" in `Test.html`) // Diese Datei rendert den Personen-Tab (Tab "Projektbeteiligte" in `App.html`)
// und verwaltet die globale Personenliste pro Abteilung: Anlegen, Bearbeiten, // als reine Anzeige. Seit der Ablösung von people/person_roles/person_absences
// Löschen, Rollen-Auswahl, Drag-and-Drop-Sortierung sowie Urlaub/Abwesenheiten. // (server.js) werden Projektbeteiligte ausschließlich in der zentralen
// Sie wird von `Test.html` nach `js/board/board.js` (vor `js/projects/projects.js`) geladen // Benutzerverwaltung (Bereich „Admin“) gepflegt; Anlegen/Bearbeiten/Löschen/
// und stellt globale Funktionen wie `addPerson()`, `delPerson()`, // Drag-and-Drop-Sortierung/Abwesenheits-Formulare gibt es hier bewusst nicht
// `editPerson()`, `renderPeople()` und `addAbsence()` bereit. Genutzt wird sie // mehr. EINE Ausnahme ist geblieben bzw. wieder dazugekommen: der
// u.a. von `js/board/board.js` (`renderBoard`), `js/gantt.js` und // Teilnehmerkreis der aktuellen Sitzung (`currentWeek().people`). Er entsteht
// `js/plan/plan-modals.js`; die Sortier-Hilfe `getDragAfterElement()` liegt in // nur beim Anlegen einer neuen Sitzung und wird beim Duplizieren
// `js/projects/projects.js`. // unverändert übernommen — ohne die Schaltflächen hier ("Zur Sitzung
// hinzufügen"/"Aus Sitzung entfernen") wäre ein neu angelegter Benutzer in
// keiner laufenden Sitzung zuordenbar, weder im Board noch in der
// Auslastung noch in der Anwesenheit. Die Personendaten selbst kommen unverändert über `db.globalPeople`
// (befüllt via `GET /load-all`, serverseitig jetzt aus `users`/`user_roles`/
// `user_absences` zusammengesetzt, firmenweit ohne Abteilungstrennung). Sie
// wird von `App.html` nach `js/board/board.js` (vor `js/projects/projects.js`)
// geladen und stellt die globale Funktion `renderPeople()` bereit, die u.a.
// von `js/tabs-auth.js` (`showTab()`) und `js/init.js` aufgerufen wird.
// addPerson: fügt eine neue Person der globalen Liste hinzu und // renderPeople: baut die schreibgeschützte Liste (`.people-readonly`, eine
// synchronisiert sie in die aktuelle Sitzung. // Bubble-Card mit den Personen als Listenzeilen) der Personen für den
function addPerson(){ // Personen-Tab auf (Zeilen mit Kürzel, Vollname, E-Mail, Rollen,
if(guardWeekLocked()) return; // aufklappbaren Abwesenheiten). Kein Formular, keine Lösch-/
const w = currentWeek(); // Bearbeiten-Aktionen, kein Drag-Handle. Zeigt zusätzlich einen Hinweis auf
if(!w) return; // die zentrale Benutzerverwaltung (Link nur für Admins sichtbar). Keine
const name = document.getElementById('newPersonName').value.trim(); // Parameter, keine Rückgabe.
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;
// Erst prüfen, ob die Person in der aktuellen Sitzung noch zugeordnet ist –
// erst danach die globalen Listen verändern, sonst wäre die globale Löschung
// trotz Abbruch bereits durchgeführt (und würde über save() persistiert).
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;
}
}
// 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
if(w){
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(){ function renderPeople(){
const people = db.globalPeople[currentDepartment] || []; const people = db.globalPeople || [];
const locked = isWeekLocked(currentWeek()); const isAdmin = typeof currentUser !== 'undefined' && !!currentUser && currentUser.role === 'admin';
let html = `<div class="people-sortable" const w = currentWeek();
ondragover="handlePersonDragOver(event)" const inWeek = new Set((w && Array.isArray(w.people) ? w.people : []).map(p => p.id));
ondrop="handlePersonDrop(event)">`; const locked = !w || isWeekLocked(w);
people.forEach(p=>{
let html = `<div class="hint" style="margin-bottom:12px">Projektbeteiligte werden zentral in der Benutzerverwaltung gepflegt.`
+ (isAdmin ? ` <a href="/Admin#benutzer" onclick="showAdminSection('benutzer');return false;">Zur Benutzerverwaltung</a>` : '')
+ `</div>`;
html += `<div class="hint" style="margin-bottom:12px">Wer in der aktuellen Sitzung`
+ (w ? ` <b>${esc(w.label)}</b>` : '')
+ ` mitwirkt, erscheint als Spalte im Projektboard, in der Auslastung und in der Anwesenheit.`
+ (locked ? ` Die Sitzung ist gesperrt — der Kreis kann nicht geändert werden.` : '')
+ `</div>`;
if(people.length === 0){
html += '<div class="hint">Keine Personen vorhanden.</div>';
document.getElementById('peopleList').innerHTML = html;
return;
}
html += `<div class="people-readonly">`;
people.forEach(p => {
const isOpen = personAbsenceOpen[p.id]; const isOpen = personAbsenceOpen[p.id];
const roles = Array.isArray(p.role) ? p.role : [];
const dabei = inWeek.has(p.id);
// Die Schaltfläche liegt IM Kopfbereich, der das Aufklappen auslöst —
// stopPropagation verhindert, dass ein Klick darauf zusätzlich die
// Abwesenheiten auf- oder zuklappt.
const weekAction = locked ? '' : (dabei
? `<button type="button" class="secondary small" onclick="event.stopPropagation(); removePersonFromCurrentWeek('${esc(p.id)}')">Aus Sitzung entfernen</button>`
: `<button type="button" class="small" onclick="event.stopPropagation(); addPersonToCurrentWeek('${esc(p.id)}')">Zur Sitzung hinzufügen</button>`);
html += `<div class="person-card${isOpen ? ' open' : ''}" data-person-id="${p.id}">`; html += `<div class="person-card${isOpen ? ' open' : ''}" data-person-id="${p.id}">`;
html += `<div class="person-header" onclick="togglePersonAbsences('${p.id}')"> html += `<div class="person-header" onclick="togglePersonAbsences('${esc(p.id)}')">
${locked ? '' : `<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px" onclick="event.stopPropagation()" ondragstart="handlePersonDragStart(event)" ondragend="handlePersonDragEnd(event)" draggable="true">⋮⋮</span>`} <span class="person-toggle">${icon('chevronRight')}</span>
<span class="person-toggle">${isOpen ? '▼' : '▶'}</span> <strong>${esc(p.name)}</strong>
<input type="text" value="${esc(p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','name',this.value)" style="width:80px;" placeholder="Kürzel"> <span>${esc(p.fullName || '')}</span>
<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"> <span class="hint" style="margin:0">${esc(p.email || '')}</span>
<input type="text" value="${esc(p.email)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','email',this.value)" style="width:180px;" placeholder="E-Mail"> <span>${roles.length ? roles.map(r => esc(r)).join(', ') : '<span class="hint" style="margin:0">Keine Rolle</span>'}</span>
<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> <span class="person-week-state">${dabei ? 'in dieser Sitzung' : '<span class="hint" style="margin:0">nicht in dieser Sitzung</span>'}</span>
<button class="danger small" onclick="event.stopPropagation(); delPerson('${p.id}')">Löschen</button> ${weekAction}
</div>`; </div>`;
// Aufklappbare Abwesenheiten
html += `<div class="person-absences" style="display:${isOpen ? 'block' : 'none'}">`; html += `<div class="person-absences" style="display:${isOpen ? 'block' : 'none'}">`;
const absences = p.absences || []; const absences = Array.isArray(p.absences) ? p.absences : [];
if(absences.length > 0){ if(absences.length > 0){
html += `<div class="absence-list">`; html += `<div class="absence-list">`;
absences.forEach(a => { absences.forEach(a => {
html += `<div class="absence-chip"> html += `<div class="absence-chip">
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span> <span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
${a.note ? `<small>${esc(a.note)}</small>` : ''} ${a.note ? `<small>${esc(a.note)}</small>` : ''}
${locked ? '' : `<button class="danger small" onclick="deleteAbsence('${p.id}','${a.id}')">×</button>`}
</div>`; </div>`;
}); });
html += `</div>`; html += `</div>`;
} } else {
if(!locked){ html += `<div class="hint">Keine Abwesenheiten hinterlegt.</div>`;
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></div>`;
}); });
html += `</div>`; html += `</div>`;
if(people.length===0) html = '<div class="hint">Keine Personen vorhanden.</div>'; document.getElementById('peopleList').innerHTML = html;
document.getElementById('peopleList').innerHTML=html;
} }
// personAbsenceOpen: UI-Zustand, welche Personen-Karte ihre Abwesenheiten // personAbsenceOpen: UI-Zustand, welche Personen-Karte ihre Abwesenheiten
// aufgeklappt darstellt (Key = Personen-ID, Wert = boolean). // aufgeklappt darstellt (Key = Personen-ID, Wert = boolean). Reine Anzeige-
// Steuerung, wird nicht persistiert.
let personAbsenceOpen = {}; let personAbsenceOpen = {};
// togglePersonAbsences: klappt die Abwesenheits-Liste einer Person auf bzw. zu // togglePersonAbsences: klappt die Abwesenheits-Liste einer Person auf bzw. zu
@@ -246,122 +102,53 @@ function togglePersonAbsences(personId){
renderPeople(); renderPeople();
} }
// draggedPerson: die während eines Drag-and-Drop-Vorgangs verschobene // addPersonToCurrentWeek: nimmt eine Person aus der globalen Liste in den
// Personen-Karte (DOM-Element) bzw. null nach dem Loslassen. // Teilnehmerkreis der aktuellen Sitzung auf. Nötig, weil der Kreis sonst nur
let draggedPerson = null; // beim Anlegen einer neuen Sitzung entsteht: Ein später angelegter Benutzer
// tauchte in laufenden (und in allen daraus duplizierten) Sitzungen nirgends
// getPersonCard: liefert die nächstgelegene Personen-Karte (`.person-card`). // auf. Geschrieben wird über den normalen save()-Weg — `week.people` ist Teil
// Parameter `el`: Event-Ziel-Element. Rückgabe: Karten-Element oder null. // des Wochen-Datensatzes, den /save-week ohnehin schreibt.
function getPersonCard(el){ // Parameter: personId (String). Keine Rückgabe.
return el && el.closest ? el.closest('.person-card') : null; function addPersonToCurrentWeek(personId){
}
// 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, '.person-card:not([style*="opacity"])');
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 die
// Personen-Kopie der aktuellen Woche (letztere bestimmt die Spaltenreihenfolge
// im Projektboard), dann speichern. Keine Parameter, keine Rückgabe.
function reorderPeople(){
if(guardWeekLocked()) return; 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;
// Wochen-Kopie auf dieselbe Reihenfolge bringen, damit das Board sie nutzt
const w = currentWeek(); const w = currentWeek();
if(w && Array.isArray(w.people)){ if(!w) return;
const byId = {}; if(!Array.isArray(w.people)) w.people = [];
w.people.forEach(p => { byId[p.id] = p; }); if(w.people.some(p => p.id === personId)) return;
const newWeekPeople = []; const person = (db.globalPeople || []).find(p => p.id === personId);
newPeople.forEach(p => { if(byId[p.id]) newWeekPeople.push(byId[p.id]); }); if(!person){ alert('Diese Person ist nicht mehr in der Benutzerverwaltung vorhanden.'); return; }
if(newWeekPeople.length === w.people.length) w.people = newWeekPeople; // Tiefe Kopie: Der Teilnehmerkreis friert Name und Rollen zum Zeitpunkt der
} // Sitzung ein (genauso wie newWeek() es tut) und darf sich später nicht
// rückwirkend mit der globalen Liste ändern.
w.people.push(JSON.parse(JSON.stringify(person)));
save(); save();
} renderAll();
} }
// ---------- Urlaub / Abwesenheiten ---------- // removePersonFromCurrentWeek: nimmt eine Person wieder aus dem
// Teilnehmerkreis der aktuellen Sitzung heraus. Verweigert wird das, solange
// addAbsence: legt für eine Person eine neue Abwesenheit an, die aus dem // sie noch einem Projekt dieser Sitzung zugeordnet ist — sonst bliebe eine
// Bereichspicker (Start-/Enddatum), Typ und Notiz des Formulars gelesen wird. // Zuordnung ohne zugehörige Spalte zurück. Parameter: personId (String).
// Prüft Datumsfolge, sortiert die Liste nach Startdatum und speichert. function removePersonFromCurrentWeek(personId){
// Parameter `personId`: Personen-ID. Keine Rückgabe.
function addAbsence(personId){
if(guardWeekLocked()) return; if(guardWeekLocked()) return;
const people = db.globalPeople[currentDepartment] || []; const w = currentWeek();
const person = people.find(p => p.id === personId); if(!w || !Array.isArray(w.people)) return;
const person = w.people.find(p => p.id === personId);
if(!person) return; 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 const zugeordnet = (w.projects || []).filter(proj => {
// Person (`personId`), speichert und rendert neu. Keine Rückgabe. const assign = proj.assign || {};
function deleteAbsence(personId, absenceId){ return Object.keys(assign).some(key => key === personId || key.indexOf(personId + '|') === 0);
if(guardWeekLocked()) return; });
const people = db.globalPeople[currentDepartment] || []; if(zugeordnet.length > 0){
const person = people.find(p => p.id === personId); alert('"' + (person.fullName || person.name) + '" ist in dieser Sitzung noch ' + zugeordnet.length
if(!person || !person.absences) return; + ' Projekt(en) zugeordnet:\n\n' + zugeordnet.map(p => '· ' + (p.name || p.nr)).join('\n')
person.absences = person.absences.filter(a => a.id !== absenceId); + '\n\nBitte zuerst die Zuordnungen im Projektboard entfernen.');
return;
}
w.people = w.people.filter(p => p.id !== personId);
if(w.attendance) delete w.attendance[personId];
save(); save();
renderPeople(); renderAll();
if(currentTab === 'gantt') renderGantt();
} }
+99 -29
View File
@@ -32,8 +32,8 @@
} }
// onGanttTodoMouseDown: startet das Zeilen-Ziehen, wenn der Greif-Handler // onGanttTodoMouseDown: startet das Zeilen-Ziehen, wenn der Greif-Handler
// (⋮⋮) einer TODO-/Meilenstein-Zeile gedrückt wird, und merkt sich Zeile // (Icon "grip", .gantt-todo-drag) einer TODO-/Meilenstein-Zeile gedrückt
// und Startposition in app.todoDrag. // wird, und merkt sich Zeile und Startposition in app.todoDrag.
function onGanttTodoMouseDown(e){ function onGanttTodoMouseDown(e){
if(app.isLocked()) return; if(app.isLocked()) return;
const handle = e.target.closest('.gantt-todo-drag'); const handle = e.target.closest('.gantt-todo-drag');
@@ -113,25 +113,35 @@
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : []; const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const todoIds = todos.map(t => t.id); // Nur terminierte Aufgaben (Start UND Ende gesetzt) haben überhaupt eine
const milestoneIds = milestones.map(m => m.id); // eigene GANTT-Zeile (siehe plan-build-gantt.js) und damit einen
// Ziehgriff — Aufgaben ohne Termin bleiben an ihrer Stelle
// in plan.todos stehen, sie können hier gar nicht bewegt worden sein.
const scheduledIds = new Set(todos.filter(t => t.start && t.end).map(t => t.id));
// Alle beweglichen Zeilen in aktueller DOM-Reihenfolge // Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)]; 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 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); const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean);
// Sicherheitscheck: Anzahl muss stimmen // Sicherheitscheck: Anzahl muss stimmen. Nicht jedes Meilenstein wird als
if(todoRowIds.length !== todoIds.length) return; // eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten
if(milestoneRowIds.length !== milestoneIds.length) return; // Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
// die positionierten zählen.
if(todoRowIds.length !== scheduledIds.size) return;
const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id);
if(milestoneRowIds.length !== positionedIds.length) return;
// TODOs neu ordnen // TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden
const newTodos = []; // gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne
todoRowIds.forEach(id => { // Termin behalten exakt ihren bisherigen Platz in der Liste.
const t = todos.find(x => x.id === id); let nextRowIdIdx = 0;
if(t) newTodos.push(t); plan.todos = todos.map(t => {
if(!scheduledIds.has(t.id)) return t;
const id = todoRowIds[nextRowIdIdx++];
return todos.find(x => x.id === id) || t;
}); });
plan.todos = newTodos;
// Meilenstein-Positionen neu berechnen // Meilenstein-Positionen neu berechnen
// Regeln: // Regeln:
@@ -378,9 +388,12 @@
const totalDays = daysBetween(rangeStart, rangeEnd) + 1; const totalDays = daysBetween(rangeStart, rangeEnd) + 1;
const dayOffset = Math.max(0, Math.min(totalDays - 1, Math.round(x / rect.width * totalDays))); const dayOffset = Math.max(0, Math.min(totalDays - 1, Math.round(x / rect.width * totalDays)));
const date = formatDateISO(addDays(rangeStart, dayOffset)); const date = formatDateISO(addDays(rangeStart, dayOffset));
openTodoModal(null); // Über prefill statt nachträglich in die versteckten Felder schreiben:
document.getElementById('todoEditStart').value = date; // openTodoModal() setzt damit auch die sichtbare Zeitraum-Anzeige, das
document.getElementById('todoEditEnd').value = date; // Dauer-Kennzeichen und den Vergleichsstand für "ungespeicherte
// Änderungen" (sonst stand im Feld "Start – Ende wählen…", und Esc fragte
// grundlos nach dem Verwerfen).
openTodoModal(null, { start: date, end: date });
} }
// updateProjectDates: setzt Start und Ende des Projekt-Plans und speichert. // updateProjectDates: setzt Start und Ende des Projekt-Plans und speichert.
@@ -411,23 +424,80 @@
} }
} }
// updateVacationDates: schreibt die verschobenen Urlaubsdaten sowohl in die // updateVacationDates: übernimmt die per Drag verschobenen Urlaubsdaten
// lokale Sitzungs-Kopie der Person als auch in die globale Personenliste // sofort optimistisch in lokale Sitzungs-Kopie und globale Personenliste
// und speichert beides. // (damit das GANTT unmittelbar danach die neue Position zeichnet), und
// persistiert sie asynchron über die /users/:id/absences-Endpunkte — es
// gibt seit der Ablösung von people/person_roles/person_absences keinen
// /save-people-Schreibpfad und kein PATCH auf Abwesenheiten mehr, daher:
// alte Abwesenheit löschen, neue mit den verschobenen Daten anlegen (dabei
// vergibt der Server eine neue ID, die hier übernommen wird). Kein
// Benutzerkonto für die Person gefunden -> klare Fehlermeldung statt
// stillem Fehlschlag.
function updateVacationDates(personId, absenceId, start, end){ function updateVacationDates(personId, absenceId, start, end){
const localPerson = (app.week.people || []).find(p => p.id === personId); const localPerson = (app.week.people || []).find(p => p.id === personId);
if(localPerson && localPerson.absences){ const localAbsence = localPerson && Array.isArray(localPerson.absences)
const a = localPerson.absences.find(x => x.id === absenceId); ? localPerson.absences.find(x => x.id === absenceId) : null;
if(a){ a.start = start; a.end = end; } const globalPeople = db.globalPeople || [];
}
const globalPeople = db.globalPeople[app.department] || [];
const globalPerson = globalPeople.find(p => p.id === personId); const globalPerson = globalPeople.find(p => p.id === personId);
if(globalPerson && globalPerson.absences){ const globalAbsence = globalPerson && Array.isArray(globalPerson.absences)
const a = globalPerson.absences.find(x => x.id === absenceId); ? globalPerson.absences.find(x => x.id === absenceId) : null;
if(a){ a.start = start; a.end = end; } if(!globalAbsence) return;
const type = globalAbsence.type || '';
const note = globalAbsence.note || '';
// Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende
// Berechtigung), wird die optimistisch verschobene Anzeige wieder auf den
// Server-Stand zurückgesetzt, statt etwas zu zeigen, das dort nicht gilt.
const vorher = { start: globalAbsence.start, end: globalAbsence.end };
const zuruecksetzen = () => {
if(localAbsence){ localAbsence.start = vorher.start; localAbsence.end = vorher.end; }
globalAbsence.start = vorher.start; globalAbsence.end = vorher.end;
renderPlanGantt();
};
if(localAbsence){ localAbsence.start = start; localAbsence.end = end; }
globalAbsence.start = start; globalAbsence.end = end;
app.getUserIdForPersonId(personId).then(userId => {
if(!userId){
app.showToast('Für diese Person existiert kein Benutzerkonto – Abwesenheit konnte nicht gespeichert werden.');
zuruecksetzen();
return;
} }
app.savePlan(); // Ergebnis des Löschens prüfen, BEVOR neu angelegt wird: Schlug das
app.saveGlobalPeople(); // Anlegen danach fehl (Netz, 400), war die alte Abwesenheit bereits weg
// — die Anzeige zeigte sie aber weiter, und nach dem nächsten Neuladen
// fehlte sie ersatzlos.
return fetch(baseUrl() + '/users/' + userId + '/absences/' + encodeURIComponent(absenceId), { method: 'DELETE' })
.then(r => {
if(!r.ok){
// 403 heißt: Der Eintrag stammt von jemand anderem (siehe
// DELETE /users/:id/absences/:absenceId in server.js).
return r.json().catch(() => ({})).then(json => {
throw new Error((json && json.error) || ('Alte Abwesenheit konnte nicht entfernt werden (HTTP ' + r.status + ')'));
});
}
return fetch(baseUrl() + '/users/' + userId + '/absences', {
method: 'POST',
headers: {'Content-Type':'application/json; charset=utf-8'},
body: JSON.stringify({ start, end, type, note })
});
})
.then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
if(!ok || !json || !json.ok){
app.showToast('Abwesenheit konnte nicht gespeichert werden: ' + ((json && json.error) || 'unbekannter Fehler')
+ ' — bitte die Seite neu laden.');
return;
}
globalAbsence.id = json.absence.id;
if(localAbsence) localAbsence.id = json.absence.id;
renderPlanGantt();
});
}).catch(err => {
console.error('Abwesenheit verschieben fehlgeschlagen:', err);
app.showToast('Abwesenheit konnte nicht verschoben werden: ' + (err.message || err));
zuruecksetzen();
});
} }
// Drag-Funktionalität erst nach geladenem DOM registrieren // Drag-Funktionalität erst nach geladenem DOM registrieren
+1 -1
View File
@@ -175,7 +175,7 @@
// renderFilesSummary: füllt die Kompakt-Vorschau der "Dateien & Freigaben"- // renderFilesSummary: füllt die Kompakt-Vorschau der "Dateien & Freigaben"-
// Karte unter dem GANTT (Cockpit-Layout, #planFilesSummary in // Karte unter dem GANTT (Cockpit-Layout, #planFilesSummary in
// Test.html) aus filesCache — Name der zuletzt hochgeladenen Datei // App.html) aus filesCache — Name der zuletzt hochgeladenen Datei
// (Server liefert /project-files bereits nach created_at DESC sortiert, // (Server liefert /project-files bereits nach created_at DESC sortiert,
// filesCache[0] ist also die neueste) sowie einen Hinweis auf noch // filesCache[0] ist also die neueste) sowie einen Hinweis auf noch
// ausstehende Prüfungen. // ausstehende Prüfungen.
+2 -2
View File
@@ -91,7 +91,7 @@
} }
// renderPlanWarnings: füllt das Hinweisband über dem GANTT // renderPlanWarnings: füllt das Hinweisband über dem GANTT
// (#planWarnings in Test.html) mit den Urlaubs-Kollisionen des // (#planWarnings in App.html) mit den Urlaubs-Kollisionen des
// Projekts — Aufgaben, deren Zeitraum in die Abwesenheit einer zugeordneten // Projekts — Aufgaben, deren Zeitraum in die Abwesenheit einer zugeordneten
// Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js // Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js
// (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste // (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste
@@ -131,7 +131,7 @@
} }
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout, // renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
// #planKpiRow in Test.html) mit vier Kennzahlen, die alle aus den // #planKpiRow in App.html) mit vier Kennzahlen, die alle aus den
// bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle): // bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle):
// Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl. // Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl.
// überfällige), nächster anstehender Meilenstein und Team-Auslastung // überfällige), nächster anstehender Meilenstein und Team-Auslastung
+1 -1
View File
@@ -94,7 +94,7 @@
} }
// renderPlanHistory: füllt die Kompakt-Karte "Verlauf" (#planHistorySummary // renderPlanHistory: füllt die Kompakt-Karte "Verlauf" (#planHistorySummary
// in Test.html) — Anzahl der Einträge und die Sitzung des letzten // in App.html) — Anzahl der Einträge und die Sitzung des letzten
// Kommentarwechsels. Wird von renderPlanPage() (plan-state.js) nach dem // Kommentarwechsels. Wird von renderPlanPage() (plan-state.js) nach dem
// Laden aufgerufen. Keine Parameter, keine Rückgabe. // Laden aufgerufen. Keine Parameter, keine Rückgabe.
window.renderPlanHistory = function(){ window.renderPlanHistory = function(){
+1 -1
View File
@@ -42,7 +42,7 @@
// alle echten Leistungsphasen (inkl. LPH 9), aber ohne die Sammelkategorie // alle echten Leistungsphasen (inkl. LPH 9), aber ohne die Sammelkategorie
// "W+M". Entspricht AUFWAND_LPS in js/load/load.js, hier separat definiert, // "W+M". Entspricht AUFWAND_LPS in js/load/load.js, hier separat definiert,
// weil load.js auf der Projektplan-Seite bewusst nicht geladen wird (siehe // weil load.js auf der Projektplan-Seite bewusst nicht geladen wird (siehe
// Kopfkommentar in Test.html). Bewusst per Filter statt per // Kopfkommentar in App.html). Bewusst per Filter statt per
// Index-Schnitt, damit eine spätere Ergänzung von LPHS nicht stillschweigend // Index-Schnitt, damit eine spätere Ergänzung von LPHS nicht stillschweigend
// durchs Raster fällt. // durchs Raster fällt.
const PLAN_ACTIVE_LPS = LPHS.filter(l => l !== 'W+M'); const PLAN_ACTIVE_LPS = LPHS.filter(l => l !== 'W+M');
-12
View File
@@ -615,18 +615,6 @@
openTodoModal(null); 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, // toggleTodoDone: schaltet den Erledigt-Status der Aufgabe am Index um,
// speichert und rendert neu. // speichert und rendert neu.
window.toggleTodoDone = function(idx){ window.toggleTodoDone = function(idx){
+352 -89
View File
@@ -144,17 +144,20 @@
}; };
// gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der // gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order) — // Reihenfolge ihres ersten Auftretens unter den Punkten (array_order).
// "Ohne Gruppe" (leerer String) immer zuerst, wenn vorhanden. // Jeder Punkt gehört jetzt zu einer Gruppe (siehe fuegeNeueGruppeHinzu()/
// "+ Neue Gruppe" unten) — eine frühere Sonderrolle "Ohne Gruppe" (leerer
// String, immer zuerst) gibt es nicht mehr; ein einzelner Punkt mit leerer
// Gruppe (nur noch aus Altbeständen von vor dieser Umstellung möglich)
// bekommt ganz normal eine eigene, leer betitelte Gruppenzeile wie jede
// andere — kein Sonderfall im Code nötig.
function gruppenReihenfolge(){ function gruppenReihenfolge(){
const namen = []; const namen = [];
let hatOhneGruppe = false;
(currentProtokoll.punkte || []).forEach(p => { (currentProtokoll.punkte || []).forEach(p => {
const g = String(p.gruppe || '').trim(); const g = String(p.gruppe || '').trim();
if(!g){ hatOhneGruppe = true; return; }
if(!namen.includes(g)) namen.push(g); if(!namen.includes(g)) namen.push(g);
}); });
return (hatOhneGruppe ? [''] : []).concat(namen); return namen;
} }
// ---------- Nachträgliches Umordnen ---------- // ---------- Nachträgliches Umordnen ----------
@@ -162,15 +165,13 @@
// Die Reihenfolge in `currentProtokoll.punkte` (= array_order) bestimmt // Die Reihenfolge in `currentProtokoll.punkte` (= array_order) bestimmt
// sowohl die Anzeige-Reihenfolge als auch — über berechneProtokollNummern() // sowohl die Anzeige-Reihenfolge als auch — über berechneProtokollNummern()
// — die Nummern. Zwei getrennte, aufeinander abgestimmte Stellschrauben: // — die Nummern. Zwei getrennte, aufeinander abgestimmte Stellschrauben:
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe (bzw. innerhalb // ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe verschieben (die
// "Ohne Gruppe") verschieben (die Zeile steht dort ohnehin nur im passenden // Zeile steht dort ohnehin nur im passenden Abschnitt), eine ganze Gruppe
// Abschnitt), eine ganze Gruppe lässt sich als Block gegenüber den anderen // lässt sich als Block gegenüber den anderen Gruppen verschieben — das
// Gruppen verschieben — das ändert, welche Gruppe die Nummer "1" bekommt. // ändert, welche Gruppe die Nummer "1" bekommt.
// "Ohne Gruppe" bleibt bewusst immer der erste Abschnitt (siehe
// gruppenReihenfolge()) und ist deshalb hier nicht mitverschiebbar.
// verschiebePunkt: vertauscht einen Punkt mit seinem Vorgänger/Nachfolger // verschiebePunkt: vertauscht einen Punkt mit seinem Vorgänger/Nachfolger
// INNERHALB derselben Gruppe (bzw. "Ohne Gruppe"). Parameter: idx (Index in // INNERHALB derselben Gruppe. Parameter: idx (Index in
// currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten). // currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten).
// Keine Rückgabe; rendert nur den Punkte-Block neu. // Keine Rückgabe; rendert nur den Punkte-Block neu.
window.verschiebePunkt = function(idx, richtung){ window.verschiebePunkt = function(idx, richtung){
@@ -192,24 +193,24 @@
// verschiebeGruppe: verschiebt eine ganze Gruppe (alle ihre Punkte als // verschiebeGruppe: verschiebt eine ganze Gruppe (alle ihre Punkte als
// Block, ihre eigene Reihenfolge bleibt untereinander erhalten) gegenüber // Block, ihre eigene Reihenfolge bleibt untereinander erhalten) gegenüber
// den anderen Gruppen — ändert damit auch die Gruppennummer. Baut die // den anderen Gruppen — ändert damit auch die Gruppennummer. Baut die
// Punkteliste dafür komplett neu auf: "Ohne Gruppe" zuerst (unverändert), // Punkteliste dafür komplett in der neuen Gruppenreihenfolge neu auf.
// danach jede Gruppe in der neuen Reihenfolge. Parameter: gruppe (Name), // Parameter: gruppe (Name), richtung (-1 = nach vorn, +1 = nach hinten).
// richtung (-1 = nach vorn, +1 = nach hinten). Keine Rückgabe. // Keine Rückgabe.
window.verschiebeGruppe = function(gruppe, richtung){ window.verschiebeGruppe = function(gruppe, richtung){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.isLocked()) return;
const punkte = currentProtokoll.punkte; const punkte = currentProtokoll.punkte;
const namen = []; const namen = [];
punkte.forEach(p => { punkte.forEach(p => {
const g = String(p.gruppe || '').trim(); const g = String(p.gruppe || '').trim();
if(g && !namen.includes(g)) namen.push(g); if(!namen.includes(g)) namen.push(g);
}); });
const pos = namen.indexOf(gruppe); const pos = namen.indexOf(String(gruppe || '').trim());
const zielPos = pos + richtung; const zielPos = pos + richtung;
if(pos === -1 || zielPos < 0 || zielPos >= namen.length) return; if(pos === -1 || zielPos < 0 || zielPos >= namen.length) return;
const tmpName = namen[pos]; const tmpName = namen[pos];
namen[pos] = namen[zielPos]; namen[pos] = namen[zielPos];
namen[zielPos] = tmpName; namen[zielPos] = tmpName;
const neu = punkte.filter(p => !String(p.gruppe || '').trim()); const neu = [];
namen.forEach(g => { namen.forEach(g => {
punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p)); punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p));
}); });
@@ -348,7 +349,7 @@
// setzeProtokollImportLadezustand: blendet den Ladehinweis ein/aus und // setzeProtokollImportLadezustand: blendet den Ladehinweis ein/aus und
// sperrt alle Felder + Knöpfe des Dialogs währenddessen — ohne das gab es // sperrt alle Felder + Knöpfe des Dialogs währenddessen — ohne das gab es
// während eines länger laufenden KI-Aufrufs (bis zu KI_TIMEOUT_MS_LOKAL = // während eines länger laufenden KI-Aufrufs (bis zu KI_TIMEOUT_MS_LOKAL =
// 5 Minuten bei einem eigenen Server, server.js) keinerlei Rückmeldung, // 30 Minuten bei einem eigenen Server, server.js) keinerlei Rückmeldung,
// der Dialog wirkte hängengeblieben, und ein zweiter Klick auf // der Dialog wirkte hängengeblieben, und ein zweiter Klick auf
// "Importieren" hätte denselben Import doppelt losgeschickt. // "Importieren" hätte denselben Import doppelt losgeschickt.
function setzeProtokollImportLadezustand(aktiv){ function setzeProtokollImportLadezustand(aktiv){
@@ -538,89 +539,275 @@
</div>`; </div>`;
} }
// renderPunkteBlockHtml: baut nur den Punkte-Block (in Abschnitten je // renderPunkteBlockHtml: baut nur den Punkte-Block — EINE durchgehende
// Gruppe) — eigene Funktion, weil er nach einer Gruppenänderung isoliert // Tabelle (dieselben Spalten wie der PDF-Export, js/pdf.js), mit einer
// vollbreiten Gruppen-Kopfzeile je Abschnitt statt eigener Tabellen je
// Gruppe — eigene Funktion, weil sie nach einer Gruppenänderung isoliert
// neu gerendert wird, ohne den Rest der Detailansicht (und damit den Fokus // neu gerendert wird, ohne den Rest der Detailansicht (und damit den Fokus
// in anderen Feldern) anzufassen. // in anderen Feldern) anzufassen.
// PROTOKOLL_GRUPPEN_FARBEN/protokollGruppeFarbe: der Farbbalken der
// Gruppen-Kopfzeile wird — wie projektFarbe() in js/load/load.js für die
// Auslastung — aus dem Gruppennamen gehasht statt fest zugeordnet, damit
// eine Gruppe ihre Farbe behält, egal an welcher Position sie steht.
// Eigene, von den Status-Farben (Amber/Grün/Teal/Rot) getrennte Palette,
// damit eine Gruppenfarbe nie wie ein Status aussieht.
const PROTOKOLL_GRUPPEN_FARBEN = ['#5b63d6', '#8b5cf6', '#c2419a', '#4f8ef7', '#7a52c7'];
function protokollGruppeFarbe(name){
let hash = 0;
const s = String(name || '');
for(let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) >>> 0;
return PROTOKOLL_GRUPPEN_FARBEN[hash % PROTOKOLL_GRUPPEN_FARBEN.length];
}
// ausgewaehlterPunkt: der gerade im Schreibbereich geöffnete Punkt, als
// OBJEKT-Referenz (nicht als Index). Die Punkte wandern beim Verschieben
// und beim Umsortieren einer ganzen Gruppe durch das Array; eine gemerkte
// Zahl zeigte danach auf einen anderen Punkt, die Referenz überlebt jede
// dieser Umstellungen unverändert. Nur ein Speichern tauscht die Objekte
// aus (der Server liefert frische) — dort wird die Auswahl über ihre
// Position wiederhergestellt, siehe speichereProtokoll().
let ausgewaehlterPunkt = null;
// waehlePunkt: öffnet einen Punkt im Schreibbereich. Parameter: idx (Index
// in currentProtokoll.punkte). Keine Rückgabe.
window.waehlePunkt = function(idx){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
ausgewaehlterPunkt = currentProtokoll.punkte[idx];
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
// listenUnterzeile: die kleine zweite Zeile eines Listeneintrags —
// Zuständig und Termin, so weit vorhanden. Parameter: punkt. Rückgabe:
// String.
function listenUnterzeile(punkt){
const teile = [];
const zust = String(punkt.zustaendig || '').trim();
if(zust) teile.push(zust);
const termin = formatDateDE(parseDateISO(punkt.termin));
if(termin) teile.push(termin);
return teile.join(' · ') || 'noch nichts erfasst';
}
// aktualisiereListenZeile: schreibt Thema, Zuständig/Termin und den
// Status-Punkt eines Listeneintrags neu — gezielt am vorhandenen Element,
// OHNE den Block neu zu rendern. Nötig, weil im Schreibbereich getippter
// Text sofort links in der Liste erscheinen soll, ein Neu-Rendern dabei
// aber den Eingabefokus (und die Schreibmarke) zerstören würde — dieselbe
// Begründung wie im Datei-Kopfkommentar. Parameter: idx. Keine Rückgabe.
function aktualisiereListenZeile(idx){
const punkt = currentProtokoll && currentProtokoll.punkte[idx];
const zeile = document.getElementById('protokollListeZeile_' + idx);
if(!punkt || !zeile) return;
const titel = zeile.querySelector('.titel');
if(titel){
const thema = String(punkt.thema || '').trim();
titel.textContent = thema || 'Ohne Thema';
titel.classList.toggle('ist-leer', !thema);
}
const sub = zeile.querySelector('.sub');
if(sub) sub.textContent = listenUnterzeile(punkt);
const dot = zeile.querySelector('.protokoll-punkt-dot');
if(dot) dot.setAttribute('data-status', punkt.status || 'offen');
}
// renderPunkteBlockHtml: baut den Punkte-Block als ZWEI nebeneinander
// liegende Bereiche (Eriks Auswahl „Liste + Schreibbereich" aus dem
// Protokoll-schreiben-Mockup) statt der früheren durchgehenden Tabelle:
// links eine schmale, überfliegbare Liste aller Punkte (nach Gruppen
// gegliedert, je Zeile Nummer, Thema, Status-Punkt und darunter
// Zuständig/Termin), rechts der Schreibbereich für den EINEN gerade
// ausgewählten Punkt mit großem Thema-Feld, mehrzeiligem Textfeld und
// Zuständig/Termin als ordentlichen Formularfeldern.
//
// Warum weg von der Tabelle: ein Protokollpunkt ist ein kleiner Fließtext,
// keine Tabellenzelle. In der Spalte blieben für den Text rund 60 % der
// Breite bei drei, vier sichtbaren Zeilen; Thema und Text klebten
// übereinander, und die Formatierungsleiste saß eingeklemmt dazwischen.
// Der Schreibbereich gibt dem Text eine lesbare Zeilenlänge und jedem Feld
// eine eigene Beschriftung.
//
// Eigene Funktion, weil sie nach jeder strukturellen Änderung (Punkt/Gruppe
// anlegen, löschen, verschieben, auswählen) isoliert neu gerendert wird,
// ohne den Rest der Detailansicht anzufassen. Reine Feldänderungen rendern
// dagegen NICHT neu, sondern aktualisieren die Liste gezielt über
// aktualisiereListenZeile() oben.
function renderPunkteBlockHtml(){ function renderPunkteBlockHtml(){
const p = currentProtokoll; const p = currentProtokoll;
const locked = app.isLocked(); const locked = app.isLocked();
const gruppen = gruppenReihenfolge(); const gruppen = gruppenReihenfolge();
const gruppenNamen = gruppen.filter(g => g); // ohne '' für die Datalist-Vorschläge
const nummern = window.berechneProtokollNummern(p.punkte); const nummern = window.berechneProtokollNummern(p.punkte);
const gruppenHtml = gruppen.map(gruppe => {
// Auswahl nachziehen: fehlt sie oder zeigt sie auf einen inzwischen
// gelöschten Punkt, rückt der erste vorhandene nach.
let aktivIdx = p.punkte.indexOf(ausgewaehlterPunkt);
if(aktivIdx === -1){
aktivIdx = p.punkte.length ? 0 : -1;
ausgewaehlterPunkt = aktivIdx >= 0 ? p.punkte[aktivIdx] : null;
}
const offeneAnzahl = p.punkte.filter(x => (x.status || 'offen') === 'offen').length;
const kopfText = p.punkte.length
? `${p.punkte.length} ${p.punkte.length === 1 ? 'Punkt' : 'Punkte'} · ${offeneAnzahl} offen`
: 'Noch keine Punkte';
const listeHtml = gruppen.map(gruppe => {
const punkteDerGruppe = p.punkte const punkteDerGruppe = p.punkte
.map((punkt, idx) => ({ punkt, idx })) .map((punkt, idx) => ({ punkt, idx }))
.filter(x => String(x.punkt.gruppe || '').trim() === gruppe); .filter(x => String(x.punkt.gruppe || '').trim() === gruppe);
const titel = gruppe ? esc(gruppe) : 'Ohne Gruppe';
const addBtn = locked ? '' : `<span class="protokoll-gruppe-add" onclick="fuegePunktHinzu('${esc(gruppe)}')">+ Punkt</span>`;
// "Ohne Gruppe" bleibt fest der erste Abschnitt (siehe gruppenReihenfolge()) —
// nur echte Gruppen lassen sich gegeneinander verschieben.
let moveBtns = ''; let moveBtns = '';
if(gruppe && !locked){ if(!locked){
const gPos = gruppenNamen.indexOf(gruppe); const gPos = gruppen.indexOf(gruppe);
const hochDisabled = gPos <= 0 ? 'disabled' : '';
const runterDisabled = gPos >= gruppenNamen.length - 1 ? 'disabled' : '';
moveBtns = `<span class="protokoll-gruppe-move"> moveBtns = `<span class="protokoll-gruppe-move">
<button type="button" class="secondary small" ${hochDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', -1)" title="Gruppe nach vorn">↑</button> <button type="button" class="secondary small" ${gPos <= 0 ? 'disabled' : ''} onclick="verschiebeGruppe('${esc(gruppe)}', -1)" title="Gruppe nach vorn">↑</button>
<button type="button" class="secondary small" ${runterDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button> <button type="button" class="secondary small" ${gPos >= gruppen.length - 1 ? 'disabled' : ''} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button>
</span>`; </span>`;
} }
const zeilen = punkteDerGruppe.map(({ punkt, idx }, pos) => renderPunktZeileHtml(punkt, idx, nummern[idx], pos === 0, pos === punkteDerGruppe.length - 1)).join(''); const kopf = `<div class="protokoll-liste-gruppe" style="--protokoll-gruppe-farbe: ${protokollGruppeFarbe(gruppe)}">
return `<div class="protokoll-gruppe"> <input class="protokoll-gruppe-heading" value="${esc(gruppe)}" placeholder="Gruppenname …" ${locked ? 'disabled' : ''}
<div class="protokoll-gruppe-head"><span class="protokoll-gruppe-name${gruppe ? '' : ' is-leer'}">${titel}</span>${moveBtns}${addBtn}</div> onchange="currentProtokollUmbenenneGruppe('${esc(gruppe)}', this.value)">
${zeilen || '<span class="hint">Keine Punkte in dieser Gruppe.</span>'} <span class="count">${punkteDerGruppe.length}</span>${moveBtns}
</div>`; </div>`;
const zeilen = punkteDerGruppe.map(({ punkt, idx }) =>
renderListenZeileHtml(punkt, idx, nummern[idx], idx === aktivIdx)).join('');
const addZeile = locked ? '' : `<div class="protokoll-liste-neu" onclick="fuegePunktHinzu('${esc(gruppe)}')">+ Punkt</div>`;
return kopf + zeilen + addZeile;
}).join(''); }).join('');
const datalist = `<datalist id="protokollGruppenListe">${gruppenNamen.map(g => `<option value="${esc(g)}">`).join('')}</datalist>`; const leerHinweis = listeHtml ? '' : '<div class="protokoll-liste-leer">Noch keine Punkte erfasst.</div>';
const addOhneGruppe = locked ? '' : `<div class="add-row-btn" onclick="fuegePunktHinzu('')">+ Punkt (ohne Gruppe)</div>`; const neueGruppe = locked ? '' : `<div class="protokoll-liste-neu ist-gruppe" onclick="fuegeNeueGruppeHinzu()">+ Neue Gruppe</div>`;
return `<div class="protokoll-block-head"><h4>Punkte</h4></div> return `<div class="protokoll-block-head"><h4>Punkte</h4></div>
${gruppenHtml || '<span class="hint">Noch keine Punkte.</span>'} <div class="protokoll-split">
${datalist} <div class="protokoll-liste">
${addOhneGruppe} <div class="protokoll-liste-kopf">${esc(kopfText)}</div>
${listeHtml}${leerHinweis}${neueGruppe}
</div>
${renderSchreibbereichHtml(aktivIdx, nummern)}
</div>
<div class="legend"> <div class="legend">
<span class="legend-item"><span class="legend-swatch neu"></span>Neu in diesem Protokoll</span> <span class="legend-item"><span class="legend-swatch neu"></span>Neu in diesem Protokoll</span>
<span class="legend-item"><span class="legend-swatch fortgeschrieben"></span>Aus vorigem Protokoll fortgeschrieben</span> <span class="legend-item"><span class="legend-swatch info"></span>Info (läuft nicht weiter mit)</span>
<span class="legend-item"><span class="legend-swatch erledigt"></span>Erledigt (läuft nicht weiter mit)</span>
</div>`; </div>`;
} }
function renderPunktZeileHtml(punkt, idx, nummer, istErste, istLetzte){ // renderListenZeileHtml: ein Eintrag der linken Liste. Bewusst knapp —
// Nummer, Thema, Status-Punkt und darunter Zuständig/Termin; alles
// Weitere steht im Schreibbereich. Parameter: punkt, idx, nummer,
// istAktiv. Rückgabe: HTML-String.
function renderListenZeileHtml(punkt, idx, nummer, istAktiv){
const thema = String(punkt.thema || '').trim();
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
return `<div class="protokoll-liste-zeile${istAktiv ? ' ist-aktiv' : ''}" id="protokollListeZeile_${idx}" onclick="waehlePunkt(${idx})">
<span class="nr">${esc(nummer)}</span>
<span class="titel${thema ? '' : ' ist-leer'}">${esc(thema || 'Ohne Thema')}</span>
<span class="protokoll-punkt-dot" data-status="${esc(punkt.status || 'offen')}" title="${esc(PROTOKOLL_PUNKT_STATUS_LABEL[punkt.status || 'offen'] || 'Offen')}"></span>
<span class="sub">${esc(listenUnterzeile(punkt))}</span>
${neuChip}
</div>`;
}
// PROTOKOLL_PUNKT_STATUS_LABEL: Anzeigetext je Status im Status-Umschalter
// unten — einzige Stelle, die die drei Werte kennt, damit ein vierter
// Status (sollte es den je geben) nur hier ergänzt werden müsste.
const PROTOKOLL_PUNKT_STATUS_LABEL = { offen: 'Offen', erledigt: 'Erledigt', info: 'Info' };
// renderSchreibbereichHtml: die rechte Hälfte — der EINE gerade
// ausgewählte Punkt, mit echtem Platz zum Schreiben. Parameter: idx (Index
// in currentProtokoll.punkte, -1 = kein Punkt vorhanden), nummern (das
// Ergebnis von berechneProtokollNummern()). Rückgabe: HTML-String.
function renderSchreibbereichHtml(idx, nummern){
const locked = app.isLocked(); const locked = app.isLocked();
if(idx < 0){
return `<div class="protokoll-schreiben ist-leer">
<p>Noch kein Punkt angelegt.</p>
${locked ? '' : '<p class="hint">Links über „+ Neue Gruppe" beginnen — jeder Punkt gehört zu einer Gruppe.</p>'}
</div>`;
}
const punkt = currentProtokoll.punkte[idx];
const terminDisplay = formatDateDE(parseDateISO(punkt.termin)) || ''; const terminDisplay = formatDateDE(parseDateISO(punkt.termin)) || '';
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : ''; const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
const moveBtns = locked ? '' : `<span class="protokoll-punkt-move"> // entfaelltNaechstes: vom Server gesetzt, wenn dieser Punkt beim
<button type="button" class="secondary small" ${istErste ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button> // Fortschreiben aus einem erledigten Punkt des vorigen Protokolls
<button type="button" class="secondary small" ${istLetzte ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button> // entstand — seine Gnadenrunde (Eriks Wunsch: ein erledigter Punkt
</span>`; // taucht noch EIN Protokoll lang auf, bevor er entfällt). Nie vom
return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${punkt.erledigt ? ' is-erledigt' : ''}" id="protokollPunkt_${idx}"> // Client gesetzt (siehe PUT .../:id, server.js).
<div class="protokoll-punkt-top-row"> const entfaelltChip = punkt.entfaelltNaechstes
<span class="protokoll-punkt-nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummer)}</span> ? '<span class="protokoll-chip-entfaellt" title="Dieser Punkt wurde bereits als erledigt fortgeschrieben — im übernächsten Protokoll taucht er nicht mehr auf.">Entfällt im nächsten Protokoll</span>'
${moveBtns} : '';
<input type="text" class="protokoll-punkt-gruppe" list="protokollGruppenListe" value="${esc(punkt.gruppe)}" // themaGeaendert/textGeaendert: vom Server gesetzt (siehe
placeholder="Gruppe (z. B. ELT, HLS) …" ${locked ? 'disabled' : ''} // loadProtokollDetail(), server.js) für einen fortgeschriebenen Punkt
onchange="currentProtokollSetzePunktGruppe(${idx}, this.value)"> // (neu=0), dessen Thema bzw. Text sich gegenüber der Fassung im
${neuChip} // UNMITTELBAR vorangegangenen Protokoll geändert hat — markiert NUR das
<input type="text" class="protokoll-punkt-thema" value="${esc(punkt.thema)}" placeholder="Thema" ${locked ? 'disabled' : ''} // jeweils geänderte Feld, der Punkt selbst ist ja derselbe geblieben.
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)"> const themaGeaendertClass = punkt.themaGeaendert ? ' ist-geaendert' : '';
<label class="protokoll-punkt-erledigt-toggle"><input type="checkbox" ${punkt.erledigt ? 'checked' : ''} ${locked ? 'disabled' : ''} const textGeaendertClass = punkt.textGeaendert ? ' ist-geaendert' : '';
onchange="currentProtokollSetzePunktErledigt(${idx}, this.checked)"> erledigt</label> const status = punkt.status || 'offen';
${locked ? '' : `<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`}
// Verschieben gilt wie bisher nur INNERHALB der eigenen Gruppe — die
// Knöpfe sind an den Rändern dieser Gruppe gesperrt, nicht an denen der
// Gesamtliste.
const gruppe = String(punkt.gruppe || '').trim();
const idxDerGruppe = currentProtokoll.punkte
.map((x, i) => ({ x, i }))
.filter(e => String(e.x.gruppe || '').trim() === gruppe)
.map(e => e.i);
const posInGruppe = idxDerGruppe.indexOf(idx);
const kopfAktionen = locked ? '' : `<span class="protokoll-punkt-move">
<button type="button" class="secondary small" ${posInGruppe <= 0 ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
<button type="button" class="secondary small" ${posInGruppe >= idxDerGruppe.length - 1 ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
</span>
<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`;
const statusButtons = Object.keys(PROTOKOLL_PUNKT_STATUS_LABEL).map(s =>
`<button type="button" class="${s}${s === status ? ' is-active' : ''}" ${locked ? 'disabled' : ''}
onclick="currentProtokollSetzePunktStatus(${idx}, '${s}')">${PROTOKOLL_PUNKT_STATUS_LABEL[s]}</button>`).join('');
return `<div class="protokoll-schreiben">
<div class="protokoll-schreiben-kopf">
<span class="nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummern[idx])}</span>
<span class="gruppe">${esc(gruppe)}</span>
${neuChip}${entfaelltChip}
<span class="rechts">
<div class="protokoll-status-switch ist-waagerecht">${statusButtons}</div>
${kopfAktionen}
</span>
</div> </div>
<textarea class="protokoll-punkt-text" rows="2" placeholder="Text / Vereinbarung" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'text', this.value)">${esc(punkt.text)}</textarea> <div class="protokoll-schreiben-feld">
<div class="protokoll-punkt-foot"> <span class="protokoll-feld-label">Thema</span>
<label>Zuständig <input type="text" value="${esc(punkt.zustaendig)}" ${locked ? 'disabled' : ''} <input type="text" class="protokoll-punkt-thema${themaGeaendertClass}" value="${esc(punkt.thema)}"
oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)"></label> placeholder="Worum geht es?" ${locked ? 'disabled' : ''}
<label>Termin oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
<input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="—" readonly </div>
<div class="richtext-wrap">
${locked ? '' : richtextToolbarHtml('.protokoll-punkt-text')}
<div class="protokoll-schreiben-feld">
<span class="protokoll-feld-label">Vereinbarung · Text</span>
${richtextFeldHtml('protokoll-punkt-text' + textGeaendertClass, sanitizeStatusHtml(punkt.text),
'Was wurde besprochen oder vereinbart?',
`oninput="currentProtokollSetzePunktFeld(${idx}, 'text', this.innerHTML)"`, !locked)}
</div>
</div>
<div class="protokoll-schreiben-raster">
<div>
<span class="protokoll-feld-label">Zuständig</span>
<input type="text" value="${esc(punkt.zustaendig)}" placeholder="Name oder Firma" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)">
</div>
<div>
<span class="protokoll-feld-label">Termin</span>
<input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="— kein Termin" readonly
onclick="${locked ? '' : `openDatePicker({ anchor: this, inputId: 'protokollPunktTermin_${idx}', displayId: 'protokollPunktTerminDisplay_${idx}', onApply: function(){ syncProtokollPunktTermin(${idx}); } })`}"> onclick="${locked ? '' : `openDatePicker({ anchor: this, inputId: 'protokollPunktTermin_${idx}', displayId: 'protokollPunktTerminDisplay_${idx}', onApply: function(){ syncProtokollPunktTermin(${idx}); } })`}">
<input type="hidden" id="protokollPunktTermin_${idx}" value="${esc(punkt.termin)}"> <input type="hidden" id="protokollPunktTermin_${idx}" value="${esc(punkt.termin)}">
</label> </div>
<span class="protokoll-punkt-erstellt" title="Datum, an dem dieser Punkt zum ersten Mal erfasst wurde — bleibt beim Fortschreiben unverändert">Erstellt ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}</span> </div>
<div class="protokoll-schreiben-fuss">
Erstellt am ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}
<span class="hint">— bleibt beim Fortschreiben unverändert</span>
</div> </div>
</div>`; </div>`;
} }
@@ -639,29 +826,47 @@
if(!currentProtokoll || !currentProtokoll.teilnehmer[idx]) return; if(!currentProtokoll || !currentProtokoll.teilnehmer[idx]) return;
currentProtokoll.teilnehmer[idx][feld] = wert; currentProtokoll.teilnehmer[idx][feld] = wert;
}; };
// Thema/Zuständig schlagen sofort auf den Listeneintrag links durch, ohne
// den Block neu zu rendern (sonst wäre der Fokus beim Tippen weg, siehe
// aktualisiereListenZeile() oben).
window.currentProtokollSetzePunktFeld = function(idx, feld, wert){ window.currentProtokollSetzePunktFeld = function(idx, feld, wert){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return; if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx][feld] = wert; currentProtokoll.punkte[idx][feld] = wert;
if(feld === 'thema' || feld === 'zustaendig') aktualisiereListenZeile(idx);
}; };
window.syncProtokollPunktTermin = function(idx){ window.syncProtokollPunktTermin = function(idx){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return; if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
const el = document.getElementById('protokollPunktTermin_' + idx); const el = document.getElementById('protokollPunktTermin_' + idx);
if(el) currentProtokoll.punkte[idx].termin = el.value; if(el) currentProtokoll.punkte[idx].termin = el.value;
aktualisiereListenZeile(idx);
}; };
window.currentProtokollSetzePunktErledigt = function(idx, checked){ window.currentProtokollSetzePunktStatus = function(idx, status){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return; if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx].erledigt = checked; currentProtokoll.punkte[idx].status = status;
const row = document.getElementById('protokollPunkt_' + idx); // Gezielt die beiden betroffenen Stellen nachziehen statt neu zu rendern:
if(row) row.classList.toggle('is-erledigt', checked); // der aktive Knopf im Umschalter und der farbige Punkt im Listeneintrag.
document.querySelectorAll('.protokoll-schreiben .protokoll-status-switch button').forEach(btn => {
btn.classList.toggle('is-active', btn.classList.contains(status));
});
aktualisiereListenZeile(idx);
}; };
// currentProtokollSetzePunktGruppe: einzige Punkt-Feldänderung, die neu // currentProtokollUmbenenneGruppe: ersetzt currentProtokollSetzePunktGruppe
// rendert (nur den Punkte-Block, siehe renderPunkteBlockHtml) — ändert // (bis eben: ein Feld PRO Punkt, das genau diesen einen Punkt umhängte).
// sich die Gruppe, muss der Punkt sichtbar in den passenden Abschnitt // Jetzt ist die Gruppen-Kopfzeile selbst das Eingabefeld (Eriks Wunsch) —
// wandern. Läuft über onchange (Blur/Enter), nicht oninput, damit während // ein Umbenennen wirkt auf ALLE Punkte dieser Gruppe gleichzeitig. Trägt
// des Tippens nicht bei jedem Zeichen neu gerendert wird. // der neue Name den einer bereits bestehenden anderen Gruppe, verschmelzen
window.currentProtokollSetzePunktGruppe = function(idx, wert){ // beide Gruppen schlicht dadurch, dass ab jetzt beide Punktmengen denselben
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return; // gruppe-Wert tragen — kein Sonderfall nötig. Läuft über onchange
currentProtokoll.punkte[idx].gruppe = wert; // (Blur/Enter), nicht oninput, damit während des Tippens nicht bei jedem
// Zeichen neu gerendert wird.
window.currentProtokollUmbenenneGruppe = function(alterName, neuerName){
if(!currentProtokoll) return;
const alt = String(alterName || '').trim();
const neu = String(neuerName || '').trim();
if(alt === neu) return;
currentProtokoll.punkte.forEach(punkt => {
if(String(punkt.gruppe || '').trim() === alt) punkt.gruppe = neu;
});
const block = document.getElementById('protokollPunkteBlock'); const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml(); if(block) block.innerHTML = renderPunkteBlockHtml();
}; };
@@ -684,17 +889,54 @@
// aus Gruppe und Position (siehe berechneProtokollNummern() oben). // aus Gruppe und Position (siehe berechneProtokollNummern() oben).
// erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist // erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist
// immer, was der Server beim Speichern setzt (server.js, PUT .../:id). // immer, was der Server beim Speichern setzt (server.js, PUT .../:id).
currentProtokoll.punkte.push({ const neuerPunkt = {
id: null, nummer: '', gruppe: gruppe || '', neu: true, id: null, nummer: '', gruppe: gruppe || '', neu: true,
thema: '', text: '', zustaendig: '', termin: '', erledigt: false, thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
erstelltAm: currentProtokoll.datum || '' erstelltAm: currentProtokoll.datum || '', entfaelltNaechstes: false
}); };
currentProtokoll.punkte.push(neuerPunkt);
// Den neuen Punkt gleich im Schreibbereich öffnen und ins Thema-Feld
// springen — sonst stünde er zwar links in der Liste, rechts bliebe
// aber der vorige Punkt stehen und man müsste ihn erst anklicken.
ausgewaehlterPunkt = neuerPunkt;
const block = document.getElementById('protokollPunkteBlock'); const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml(); if(block) block.innerHTML = renderPunkteBlockHtml();
const themaFeld = block && block.querySelector('.protokoll-schreiben .protokoll-punkt-thema');
if(themaFeld) themaFeld.focus();
};
// fuegeNeueGruppeHinzu: eigener, ausdrücklicher Weg, eine neue Gruppe
// anzulegen (Eriks Wunsch). Gruppe bleibt weiterhin freier Text ohne eigene
// Tabelle (siehe CLAUDE.md "### Protokolle") — eine Gruppe ohne jeden
// Punkt hätte in diesem Modell nirgends eine Repräsentation, deshalb legt
// dieser Knopf die Gruppe zusammen mit ihrem ersten (leeren) Punkt an. Ein
// nicht kollidierender Platzhaltername ("Neue Gruppe", bei Bedarf
// "Neue Gruppe 2" …) wird direkt danach in der großen Gruppen-Überschrift
// (nicht mehr einem Feld am Punkt, siehe currentProtokollUmbenenneGruppe()
// oben) fokussiert und markiert, damit er sich sofort überschreiben lässt.
window.fuegeNeueGruppeHinzu = function(){
if(!currentProtokoll || app.isLocked()) return;
const bestehende = new Set(gruppenReihenfolge());
let name = 'Neue Gruppe';
let n = 2;
while(bestehende.has(name)){ name = 'Neue Gruppe ' + n; n++; }
fuegePunktHinzu(name);
const block = document.getElementById('protokollPunkteBlock');
const input = block
? Array.from(block.querySelectorAll('.protokoll-gruppe-heading')).find(el => el.value === name)
: null;
if(input){ input.focus(); input.select(); }
}; };
window.entfernePunkt = function(idx){ window.entfernePunkt = function(idx){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.isLocked()) return;
// War der gelöschte Punkt der geöffnete, rückt der an seine Stelle
// getretene nach (bzw. der letzte, wenn es der letzte war) — sonst
// stünde der Schreibbereich leer da, obwohl noch Punkte vorhanden sind.
const warAusgewaehlt = currentProtokoll.punkte[idx] === ausgewaehlterPunkt;
currentProtokoll.punkte.splice(idx, 1); currentProtokoll.punkte.splice(idx, 1);
if(warAusgewaehlt){
const ersatz = Math.min(idx, currentProtokoll.punkte.length - 1);
ausgewaehlterPunkt = ersatz >= 0 ? currentProtokoll.punkte[ersatz] : null;
}
const block = document.getElementById('protokollPunkteBlock'); const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml(); if(block) block.innerHTML = renderPunkteBlockHtml();
}; };
@@ -711,7 +953,16 @@
// Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert — // Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert —
// siehe berechneProtokollNummern()). // siehe berechneProtokollNummern()).
const nummern = window.berechneProtokollNummern(currentProtokoll.punkte); const nummern = window.berechneProtokollNummern(currentProtokoll.punkte);
currentProtokoll.punkte.forEach((p, idx) => { p.nummer = nummern[idx]; }); // text kommt live aus dem contenteditable-Feld (this.innerHTML,
// currentProtokollSetzePunktFeld()) — ein Einfügen von außerhalb könnte
// dort ungefiltertes HTML hinterlassen (contenteditable übernimmt
// eingefügte Formatierung standardmäßig); vor dem Senden deshalb
// dieselbe Bereinigung wie beim Board-Kommentar (saveBoardComment(),
// js/board/board.js).
currentProtokoll.punkte.forEach((p, idx) => {
p.nummer = nummern[idx];
p.text = sanitizeStatusHtml(p.text || '');
});
const body = { const body = {
datum: currentProtokoll.datum, datum: currentProtokoll.datum,
betreff: currentProtokoll.betreff, betreff: currentProtokoll.betreff,
@@ -719,6 +970,11 @@
teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()), teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()),
punkte: currentProtokoll.punkte punkte: currentProtokoll.punkte
}; };
// Die Auswahl hängt an der OBJEKT-Referenz des Punkts (siehe dort) — der
// Server liefert gleich frische Objekte zurück, die Referenz zeigt dann
// ins Leere. Die Reihenfolge bleibt beim Speichern erhalten (array_order),
// also lässt sich die Auswahl über ihre Position wiederherstellen.
const auswahlPos = currentProtokoll.punkte.indexOf(ausgewaehlterPunkt);
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), { fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json; charset=utf-8' }, headers: { 'Content-Type': 'application/json; charset=utf-8' },
@@ -728,6 +984,7 @@
.then(({ ok, d }) => { .then(({ ok, d }) => {
if(!ok || !d.ok){ alert('Protokoll konnte nicht gespeichert werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; } if(!ok || !d.ok){ alert('Protokoll konnte nicht gespeichert werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
currentProtokoll = d.protokoll; currentProtokoll = d.protokoll;
ausgewaehlterPunkt = (auswahlPos >= 0 && currentProtokoll.punkte[auswahlPos]) || null;
renderProtokollDetail(); renderProtokollDetail();
ladeProtokollListe(); ladeProtokollListe();
}) })
@@ -753,6 +1010,12 @@
}); });
}; };
// Die Rückmeldung der Formatierungsleiste (hervorgehoben + "✓ Markiert"
// sobald Text ausgewählt ist, sonst blass + "Erst Text markieren") ist ein
// gemeinsamer, dokumentweiter Lauscher in js/utils.js geworden (gilt jetzt
// auch für den Board-Kommentar) — hier war er ursprünglich fest auf den
// Protokoll-Schreibbereich zugeschnitten.
// renderPlanProtokolle: zentrale Render-Funktion, von showPlanSection() // renderPlanProtokolle: zentrale Render-Funktion, von showPlanSection()
// (plan-sections.js) beim Wechsel in den Bereich aufgerufen — zeigt je // (plan-sections.js) beim Wechsel in den Bereich aufgerufen — zeigt je
// nach Zustand Liste oder Detail (rendert dabei nichts neu, nur aus dem // nach Zustand Liste oder Detail (rendert dabei nichts neu, nur aus dem
+3 -3
View File
@@ -3,8 +3,8 @@
// ----------------------------- // -----------------------------
// Der Projektbereich ist der EIGENE BEREICH eines Projekts — seit dem Umbau // Der Projektbereich ist der EIGENE BEREICH eines Projekts — seit dem Umbau
// kein eigenes Dokument mehr, sondern der Bereich #plan der Hauptseite // kein eigenes Dokument mehr, sondern der Bereich #plan der Hauptseite
// Test.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die // App.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die
// Leiste .plan-nav umschaltet (Markup in Test.html): // Leiste .plan-nav umschaltet (Markup in App.html):
// //
// dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen- // dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen-
// Fortschritt, nächste Termine, Team, Kompakt-Karten // Fortschritt, nächste Termine, Team, Kompakt-Karten
@@ -35,7 +35,7 @@
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der // PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
// Navigationsleiste. Muss zu den data-section-Attributen in // Navigationsleiste. Muss zu den data-section-Attributen in
// Test.html passen. // App.html passen.
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen']; const PLAN_SECTIONS = ['dashboard', 'terminplan', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen // showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
+281 -114
View File
@@ -1,23 +1,38 @@
// ----------------------------- // -----------------------------
// Projektplan — Zustand, Initialisierung & Daten laden // Projektbereich — Zustand, Einstieg & Projektauswahl
// ----------------------------- // -----------------------------
// Dieses Modul wird als erstes der plan-*-Module von public/project-plan.html // Dieses Modul wird als erstes der plan-*-Module von public/App.html geladen
// geladen und definiert window.PlanApp als gemeinsamen Zustand aller // und definiert window.PlanApp als gemeinsamen Zustand aller Module des
// Projektplan-Module. Es liest die URL-Route (Abteilung + Projektname, z. B. // Projektbereichs.
// "/HLS/Projektplan/Gymnasium%20Hemer") und die URL-Parameter (weekLabel, //
// projNr), über die das zu bearbeitende Projekt adressiert wird. // WICHTIG — geändert gegenüber der früheren Fassung: Der Projektbereich war
// Nach DOMContentLoaded werden Theme, Toolbar-Buttons und die Sitzungsdaten // einmal eine eigene Seite (public/project-plan.html) mit eigenem
// über die Server-API geladen. Die hier definierten Funktionen (savePlan, // Einstiegspunkt. Sie las Sitzung und Projekt aus der URL und lud sich ihren
// isLocked, showToast u. a.) werden über app.* den anderen plan-Modulen // Datenbestand über GET /load-all selbst. Beides ist entfallen: Der
// bereitgestellt, die alle in einem IIFE mit window.PlanApp laufen. // Projektbereich ist jetzt ein Bereich der Hauptseite (#plan in App.html),
// der über showTab('plan') eingeblendet wird wie jeder andere. Er arbeitet
// deshalb auf dem bereits geladenen In-Memory-Bestand db.weeks (js/store.js)
// und braucht weder einen eigenen Ladevorgang noch eine eigene
// Anmeldeprüfung.
//
// Einstieg sind zwei Schritte: setPlanTarget(weekLabel, projNr, projName)
// merkt sich das Ziel, openPlanView() sucht es in db.weeks und rendert den
// Bereich. Getrennt sind sie, weil showTab('plan') (js/tabs-auth.js) aus dem
// Ziel zuerst die Adresszeile schreibt und den Bereich einblendet — das GANTT
// kann sich erst vermessen, wenn sein Container sichtbar ist. Gesetzt wird
// das Ziel von openProjectPlan() (js/gantt.js) und beim Seitenstart aus einem
// Deep-Link "/Projektplan/<Projektname>?weekLabel=…&projNr=…"
// (applyUrlRoute() in js/ui.js, ausgewertet in js/init.js).
// Die hier definierten Funktionen (savePlan, isLocked, showToast u. a.)
// stellen sich über app.* den anderen plan-Modulen bereit, die alle in einem
// IIFE mit window.PlanApp laufen.
// PlanApp: gemeinsamer, modulübergreifender Zustand der Projektplan-Seite. // PlanApp: gemeinsamer, modulübergreifender Zustand des Projektbereichs.
window.PlanApp = { window.PlanApp = {
params: new URLSearchParams(location.search),
department: '',
weekLabel: '', weekLabel: '',
projNr: '', projNr: '',
projName: '', projName: '',
sandboxPlanId: '', // gesetzt von openSandboxPlan() — Sandbox-Pläne werden über id adressiert, nicht über nr/name
week: null, // planWeek week: null, // planWeek
project: null, // planProject project: null, // planProject
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen) pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
@@ -34,89 +49,118 @@ window.PlanApp = {
(function(){ (function(){
const app = window.PlanApp; const app = window.PlanApp;
// Abteilung und Projektname aus dem Deep-Link-Pfad
// "/<Abteilung>/Projektplan/<Projektname>" lesen. Fallback auf die früheren
// Query-Parameter ?department=/&projName= für alte project-plan.html-Links.
const routeMatch = window.location.pathname.match(/^\/([^/]+)\/(?:Projektplan|project-plan\.html)(?:\/(.+))?\/?$/);
app.department = routeMatch ? routeMatch[1] : (app.params.get('department') || 'ELT');
app.weekLabel = app.params.get('weekLabel');
app.projNr = app.params.get('projNr') || '';
let projNameFromRoute = '';
if(routeMatch && routeMatch[2]){
try { projNameFromRoute = decodeURIComponent(routeMatch[2]); }
catch(e) { projNameFromRoute = app.params.get('projName') || ''; }
}
app.projName = projNameFromRoute || (app.params.get('projName') || '');
// init: Einstiegspunkt nach DOMContentLoaded. Übernimmt das Theme aus // planFail: zeigt einen Fehler in der Kopfzeile des Projektbereichs an und
// localStorage/System, prüft die URL-Parameter und bricht mit Fehlermeldung // blendet die Bereichsleiste aus, weil ohne Projekt jeder Unterbereich nur
// ab, falls kein Projekt ausgewählt wurde. Sonst bindet es die Toolbar und // in eine leere Maske führte. Parameter: title, subtitle (Strings).
// startet das Laden der Daten. function planFail(title, subtitle){
function init(){ const t = document.getElementById('planTitle');
loadTheme(); // Theme aus localStorage / System übernehmen const sub = document.getElementById('planSubtitle');
if(!app.weekLabel || !app.projName){ if(t) t.textContent = title;
document.getElementById('planTitle').textContent = 'Fehler'; if(sub) sub.textContent = subtitle;
document.getElementById('planSubtitle').textContent = 'Kein Projekt ausgewählt.'; const badge = document.getElementById('planIdBadge');
return; if(badge) badge.textContent = '';
} app.project = null;
currentDepartment = app.department; // global aus globals.js app.hidePlanNav();
bindToolbarButtons();
loadPlanData();
} }
// bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen, // setPlanTarget: merkt sich, welches Projekt der Bereich zeigen soll —
// Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der // adressiert über Sitzungs-Label und Projekt-Nr./-Bezeichnung, NICHT über
// anderen plan-Module. // project.id, die beim Duplizieren einer Sitzung (cloneWeek(), js/weeks.js)
function bindToolbarButtons(){ // neu vergeben wird. Bewusst getrennt vom Rendern: showTab('plan')
const addTodoBtn = document.getElementById('addTodoBtn'); // (js/tabs-auth.js) schreibt aus diesen Werten zuerst die Adresszeile und
if(addTodoBtn) addTodoBtn.addEventListener('click', () => addTodo()); // blendet den Bereich ein, und erst der sichtbare Bereich lässt sich
const autoScheduleBtn = document.getElementById('autoScheduleBtn'); // vermessen — das GANTT braucht die Containerbreite. Parameter:
if(autoScheduleBtn) autoScheduleBtn.addEventListener('click', () => autoScheduleTodos()); // weekLabel, projNr, projName (Strings). Keine Rückgabe.
const criticalPathBtn = document.getElementById('criticalPathBtn'); window.setPlanTarget = function(weekLabel, projNr, projName){
if(criticalPathBtn) criticalPathBtn.addEventListener('click', () => toggleCriticalPath()); app.weekLabel = weekLabel || '';
} app.projNr = projNr || '';
app.projName = projName || '';
app.sandboxPlanId = '';
};
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der // openSandboxPlan: das Gegenstück zu setPlanTarget()+showTab('plan') für
// Abteilung über die Server-API, sortiert die Wochen chronologisch und // einen Terminplan der Planung-Sandbox (js/planung/planung.js). Sandbox-
// sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt Fehlermeldungen // Pläne haben keine Projektnummer und werden nie geklont (cloneWeek()
// der Reihenfolge nach im Seitenkopf an und startet sonst renderPlanPage(). // betrifft nur echte Wochen) — ihre id bleibt also stabil und ist die
function loadPlanData(){ // einzige verlässliche Adressierung, anders als bei echten Projekten
if(!window.fetch){ // (dort nr/name, siehe setPlanTarget()). Parameter: planId. Keine Rückgabe.
document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.'; window.openSandboxPlan = function(planId){
return; app.weekLabel = SANDBOX_WEEK_LABEL;
app.projNr = '';
app.projName = '';
app.sandboxPlanId = planId || '';
showTab('plan');
};
// openPlanView: sucht Sitzung und Projekt im bereits geladenen Bestand
// db.weeks (js/store.js) und rendert den Bereich. Einen eigenen
// Ladevorgang gibt es nicht mehr — der Projektbereich teilt sich den
// Datenbestand mit allen anderen Bereichen der Seite. Wird von
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
// gefunden und gerendert wurde.
window.openPlanView = function(){
app.showPlanNav();
// Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
// und Adressierung über die stabile Plan-id statt über nr/name.
if(app.weekLabel === SANDBOX_WEEK_LABEL){
if(!app.sandboxPlanId){
planFail('Kein Terminplan gewählt', 'Diesen Bereich öffnet ein Klick auf einen Terminplan im Bereich „Planung".');
return false;
} }
const url = baseUrl() + '/load-all?department=' + encodeURIComponent(app.department); app.week = db.sandboxWeek || null;
fetch(url) app.project = (app.week && Array.isArray(app.week.projects))
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }) ? app.week.projects.find(p => p.id === app.sandboxPlanId) || null
.then(data => { : null;
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){ if(!app.project){
document.getElementById('planTitle').textContent = 'Nicht gefunden'; planFail('Nicht gefunden', 'Dieser Terminplan wurde nicht gefunden — er wurde möglicherweise gelöscht.');
document.getElementById('planSubtitle').textContent = 'Projekt nicht in dieser Sitzung vorhanden.'; return false;
return;
} }
app.projNr = '';
app.projName = app.project.name || '';
renderPlanPage(); renderPlanPage();
}) return true;
.catch(err => {
document.getElementById('planTitle').textContent = 'Fehler beim Laden';
document.getElementById('planSubtitle').textContent = err.message;
});
} }
if(!app.weekLabel || (!app.projNr && !app.projName)){
planFail('Kein Projekt gewählt', 'Diesen Bereich öffnet ein Klick auf ein Projekt im Projektboard oder im Tab „Projekte".');
return false;
}
// Vor dem ersten erfolgreichen load() ist db.weeks noch leer (Deep-Link
// direkt auf "/Projektplan/…"). Dann ist das kein Fehler, sondern nur
// noch nicht so weit: load() ruft showTab(currentTab) erneut auf.
if(!Array.isArray(db.weeks) || db.weeks.length === 0){
const t = document.getElementById('planTitle');
const sub = document.getElementById('planSubtitle');
if(t) t.textContent = app.projName || 'Projektbereich';
if(sub) sub.textContent = 'Sitzungsdaten werden geladen…';
return false;
}
app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
if(!app.week){
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
return false;
}
// Projekt-Nr. ist der stabile Schlüssel; die Bezeichnung dient nur als
// Rückfallebene für Projekte, die (noch) keine Nummer haben.
const projects = app.week.projects || [];
app.project = projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName)
|| (app.projNr ? projects.find(p => (p.nr || '') === app.projNr) : null)
|| projects.find(p => (p.name || '') === app.projName)
|| null;
if(!app.project){
planFail('Nicht gefunden', 'Projekt nicht in dieser Sitzung vorhanden.');
return false;
}
app.projNr = app.project.nr || '';
app.projName = app.project.name || '';
renderPlanPage();
return true;
};
// ensurePlanLocal: stellt sicher, dass das Plan-Objekt des Projekts die // ensurePlanLocal: stellt sicher, dass das Plan-Objekt des Projekts die
// erwartete Struktur hat (inkl. Migration alter Einzel-Abhängigkeiten). // erwartete Struktur hat (inkl. Migration alter Einzel-Abhängigkeiten).
function ensurePlanLocal(){ function ensurePlanLocal(){
@@ -139,21 +183,98 @@ window.PlanApp = {
return !!app.week.locked; return !!app.week.locked;
} }
// renderPlanPage: setzt Titel und Untertitel der Seite aus Projekt und Woche, // isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur
// blendet den Meilenstein-Button bei gesperrter Woche aus und startet die // Planung-Sandbox gehört statt zu einem echten Projekt. Zentraler Merker
// GANTT-Anzeige über fitPlanGanttRange(). // für alle plan-*-Module, die dort Abschnitte ausblenden, die nur bei
// einem echten Projekt Sinn ergeben (Dateien, Leistung, Auftragsstatus,
// Projektnummer, Gewerke, Projektgröße, Kommentar/Verlauf — siehe CLAUDE.md
// "Planung — projektunabhängige Terminplan-Sandbox"). Rückgabe: Boolescher Wert.
function isSandboxPlan(){
return !!app.week && app.week.label === SANDBOX_WEEK_LABEL;
}
// renderPlanPage: setzt die kompakte, einzeilige Titelzeile aus Projekt und
// Woche (Nr.-Badge, Name, Tags, Sitzungslabel, Beteiligte als
// überlappender Avatar-Stapel — siehe renderProjectAssignees()), baut das
// Projektinfo-Panel auf (renderPlanInfo(), js/plan/plan-info.js: Status,
// Gruppe, amtierende Leistungsphase, LPH-Prozentliste) sowie den
// Dateien-Bereich (initPlanFiles(), js/plan/plan-files.js: Upload, Freigabe,
// Kommentare) und den Einstellungen-Bereich (renderPlanSettings(),
// js/plan/plan-settings.js: Honorar, beauftragte Leistungsphasen), blendet
// die Schaltflächen zum Anlegen neuer Aufgaben bei gesperrter Woche aus,
// startet die GANTT-Anzeige über fitPlanGanttRange() und schaltet zuletzt
// auf den sichtbaren Bereich (app.initPlanSections(), js/plan/plan-sections.js).
function renderPlanPage(){ function renderPlanPage(){
const p = app.project; const p = app.project;
const plan = ensurePlanLocal(); const plan = ensurePlanLocal();
document.getElementById('planTitle').textContent = (p.nr ? p.nr + ' – ' : '') + p.name; const sandbox = isSandboxPlan();
document.getElementById('planSubtitle').textContent = app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : ''); const planIdBadge = document.getElementById('planIdBadge');
if(planIdBadge) planIdBadge.textContent = p.nr || '';
document.getElementById('planTitle').textContent = p.name;
// Für die Sandbox NICHT das rohe week.label ("__sandbox__") anzeigen —
// das ist ein interner Marker, kein für Nutzer bestimmter Sitzungsname.
document.getElementById('planSubtitle').textContent = sandbox
? 'Planung — unabhängig von einem laufenden Projekt'
: app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : '');
// GANTT inkl. TODOs const planPeopleList = document.getElementById('planPeopleList');
const addMilestoneBtn = document.getElementById('addMilestoneBtn'); if(planPeopleList) planPeopleList.innerHTML = renderProjectAssignees();
const tags = Array.isArray(p.tags) ? p.tags : [];
const planTagsList = document.getElementById('planTagsList');
if(planTagsList){
planTagsList.innerHTML = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
}
// Projektinfo-Panel (Status, Gruppe, amtierende Leistungsphase, LPH-
// Prozentliste) — siehe js/plan/plan-info.js.
renderPlanInfo();
// Dateien-Bereich (Upload, Freigabe, Kommentare) — siehe js/plan/plan-files.js.
initPlanFiles();
// Protokolle-Bereich (Besprechungsprotokolle) — siehe js/plan/plan-protokoll.js.
// Wie initPlanFiles() unbedingt aufgerufen (nicht erst beim Öffnen des
// Bereichs), lädt bei vorhandener Projektnummer die Protokollliste.
if(typeof initPlanProtokoll === 'function') initPlanProtokoll();
// Verlauf über alle Sitzungen (Kommentar-Historie) — siehe js/plan/plan-history.js.
renderPlanHistory();
// Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen,
// Projektangaben) — siehe js/plan/plan-settings.js.
renderPlanSettings();
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html.
const locked = isLocked(); const locked = isLocked();
if(addMilestoneBtn) addMilestoneBtn.style.display = locked ? 'none' : ''; ['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'autoScheduleBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'autoScheduleBtnList']
.forEach(id => {
const btn = document.getElementById(id);
if(btn) btn.style.display = locked ? 'none' : '';
});
// "Dateien" (keine Projektnummer, kein sinnvoller Prüf-Workflow),
// "Leistung" (echte Abrechnung/Auftragsstatus-abhängig) und "Protokolle"
// (hängt wie Dateien an project.nr) ergeben für einen Terminplan ohne
// echtes Projekt keinen Sinn — Bereichsknöpfe ausblenden.
document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]')
.forEach(btn => { btn.style.display = sandbox ? 'none' : ''; });
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gibt es nur für
// Sandbox-Pläne der Planung — ein echtes Projekt hat kein Äquivalent.
const sandboxPdfBtn = document.getElementById('sandboxPdfBtn');
if(sandboxPdfBtn) sandboxPdfBtn.style.display = sandbox ? '' : 'none';
// GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit)
fitPlanGanttRange(); fitPlanGanttRange();
// Sichtbaren Bereich wählen (URL-Fragment, sonst Dashboard) — siehe
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
// Bereiche befüllt sind, wird einer davon eingeblendet.
app.initPlanSections();
} }
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine) // Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
@@ -166,37 +287,84 @@ window.PlanApp = {
}); });
} }
// Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen Liste // planRoleClass: Kopie von roleClass() aus js/board/board.js (dort nicht
// geladen) — erzeugt dieselben CSS-Klassen wie im Board, damit die
// Avatar-Kreise hier dieselben Rollenfarben (.board-avatar in styles.css)
// nutzen. Parameter: role (String). Rückgabe: Klassen-String.
function planRoleClass(role){
return 'role-' + String(role || '').toLowerCase().replace(/\s+/g, '-');
}
// Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen
// Liste, als überlappender Avatar-Stapel für die kompakte Titelzeile
// (dieselben Kreise/Farben wie im Projektboard-Detailpanel, siehe
// .board-avatar in styles.css). Voller Name + Rolle stehen im Tooltip.
function renderProjectAssignees(){ function renderProjectAssignees(){
const assignees = projectAssignees(app.project, app.week); const assignees = projectAssignees(app.project, app.week);
if(assignees.length === 0) return '<span class="hint">Keine Zuordnungen im Projektboard.</span>'; if(assignees.length === 0) return '';
return assignees.map(a => { return assignees.map(a => {
const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : ''; const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
return `<span class="todo-assignee ${cls}">${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}</span>`; return `<span class="board-avatar ${planRoleClass(a.role)} state-${a.state}" title="${esc(title)}">${esc(a.person.name)}</span>`;
}).join(''); }).join('');
} }
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben // savePlan: merkt die Sitzung des Projektbereichs als geändert vor und
// stößt den gewöhnlichen, entprellten Speicherlauf an (js/store.js).
//
// Früher schrieb der Projektbereich mit einem eigenen sendWeekToServer()
// direkt zum Server, weil er eine eigene Seite war und store.js dort gar
// nicht geladen wurde. In einer Seite wären daraus zwei Schreiber auf
// dieselbe Sitzung geworden: beide senden dieselbe Revisions-Basis, und der
// langsamere bekäme einen scheinbaren 409-Konflikt gemeldet. Deshalb geht
// jetzt auch der Projektbereich durch syncAllToServer() — mitsamt dessen
// In-Flight-Schutz, Wiederholung nach Netzfehlern, Konfliktbehandlung und
// Anmelde-Prüfung. markWeekDirty() bekommt bewusst app.week.label statt
// save() zu rufen: save() markiert die AKTUELL gewählte Sitzung, und ein
// Deep-Link kann den Projektbereich auf eine andere gestellt haben.
function savePlan(){ function savePlan(){
if(!app.week || !app.project) return; if(!app.week || !app.project) return;
const filename = filenameForWeek(app.week.label); markWeekDirty(app.week.label);
const csv = weekToCsv(app.week); scheduleSessionSync();
sendCsvToServer(filename, csv).catch(err => console.error('Speichern fehlgeschlagen:', err));
} }
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als // userIdByPersonIdPromise: Cache der Zuordnung Person-ID (String, z. B.
// _people.csv auf den Server, aber nur wenn sie nicht leer ist. // "p_max_mustermann") -> numerische users.id. Seit der Ablösung von
function saveGlobalPeople(){ // people/person_roles/person_absences gibt es keinen /save-people-Schreibpfad
const people = db.globalPeople[app.department] || []; // mehr; Abwesenheiten werden direkt gegen /users/:id/absences geschrieben
if(people.length > 0){ // (siehe plan-modals.js/plan-drag.js), wofür die numerische User-ID benötigt
const peopleCsv = globalPeopleToCsv(people); // wird. Lazy geladen über GET /users (liefert seit Phase 1 auch personId)
sendCsvToServer('_people.csv', peopleCsv).catch(err => console.error('Speichern Personen fehlgeschlagen:', err)); // und für die Lebensdauer der Seite zwischengespeichert.
let userIdByPersonIdPromise = null;
function loadUserIdByPersonId(){
if(!userIdByPersonIdPromise){
userIdByPersonIdPromise = fetch(baseUrl() + '/users')
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
const map = {};
(Array.isArray(data.users) ? data.users : []).forEach(u => {
if(u.personId) map[u.personId] = u.id;
});
return map;
})
.catch(err => {
// Cache zurücksetzen, damit ein späterer Aufruf es erneut versucht.
userIdByPersonIdPromise = null;
throw err;
});
} }
return userIdByPersonIdPromise;
}
// getUserIdForPersonId: liefert die numerische users.id für eine Personen-ID
// oder null, falls keine passende (aktive) Benutzer-Zuordnung existiert (z. B.
// eine Person ohne Login-Konto). Rückgabe: Promise.
function getUserIdForPersonId(personId){
return loadUserIdByPersonId().then(map => (map && Object.prototype.hasOwnProperty.call(map, personId)) ? map[personId] : null);
} }
// Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt) // Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
function findGlobalPerson(personId){ function findGlobalPerson(personId){
return (db.globalPeople[app.department] || []).find(p => p.id === personId) || null; return (db.globalPeople || []).find(p => p.id === personId) || null;
} }
// countPersonAbsences: zählt die Abwesenheiten einer Person anhand der // countPersonAbsences: zählt die Abwesenheiten einer Person anhand der
@@ -226,14 +394,13 @@ window.PlanApp = {
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können. // (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
app.ensurePlanLocal = ensurePlanLocal; app.ensurePlanLocal = ensurePlanLocal;
app.isLocked = isLocked; app.isLocked = isLocked;
app.isSandboxPlan = isSandboxPlan;
app.removeDependencyRefs = removeDependencyRefs; app.removeDependencyRefs = removeDependencyRefs;
app.renderProjectAssignees = renderProjectAssignees; app.renderProjectAssignees = renderProjectAssignees;
app.planRoleClass = planRoleClass;
app.savePlan = savePlan; app.savePlan = savePlan;
app.saveGlobalPeople = saveGlobalPeople; app.getUserIdForPersonId = getUserIdForPersonId;
app.findGlobalPerson = findGlobalPerson; app.findGlobalPerson = findGlobalPerson;
app.countPersonAbsences = countPersonAbsences; app.countPersonAbsences = countPersonAbsences;
app.showToast = showToast; app.showToast = showToast;
// Seitenstart: erst initialisieren, wenn das DOM geladen ist
document.addEventListener('DOMContentLoaded', init);
})(); })();
+110 -24
View File
@@ -1,15 +1,27 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Projekte (Listen-Ansicht) // Projektübersicht — Projektliste (Ablage-Logik)
// ----------------------------- // -----------------------------
// Diese Datei rendert den Projekte-Tab (Tab "Projekte" in `Test.html`) als // Der eigene Tab "Projekte" ist entfallen. Er listete dieselben Projekte ein
// sortierbare Listenansicht: Projekte werden nach Gruppe ("Aktive Projekte", // zweites Mal auf und ließ dieselben Angaben — Nummer, Bezeichnung, Gruppe,
// "Kleine Projekte" usw.) gruppiert, inline bearbeitbar und per // amtierende Leistungsphase, Schlagwörter, Aufwand — ein zweites Mal
// Drag-and-Drop umsortiert. Sie wird von `Test.html` nach `js/people/people.js` // bearbeiten. Diese Angaben haben ihren einen Ort jetzt im Projektbereich
// (vor `js/load/load.js`) geladen und stellt globale Funktionen wie // (js/plan/plan-settings.js, js/plan/plan-info.js); die Liste selbst ist im
// `renderProjects()`, `editProjectField()`, `editProjectActiveLP()` sowie die // Projektboard aufgegangen (renderBoard(), js/board/board.js).
// Drag-and-Drop-Handler und die Sortier-Hilfe `getDragAfterElement()` bereit. //
// Letztere wird auch von `js/people/people.js` (Personen-Sortierung) genutzt; // Was hier bleibt, betrifft die LISTE, nicht die einzelnen Angaben:
// `renderBoard()` aus `js/board/board.js` wird nach Änderungen aktualisiert. //
// * delProjectFromList() — Projekt aus der Sitzung löschen
// * die Drag-and-Drop-Handler + reorderProjects() — Reihenfolge innerhalb
// einer Gruppe und Wechsel zwischen Gruppen. Das Board spannt dieselbe
// Ablagefläche auf (.projects-sortable mit data-group), damit diese
// Handler unverändert weiterarbeiten.
// * getDragAfterElement() — Sortier-Hilfe, auch von js/people/people.js
// genutzt
//
// editProjectField()/editProjectActiveLP()/addProjectTag()/removeProjectTag()
// stehen weiterhin bereit: Der Beauftragungs-Dialog und die Auslastungs-Maske
// rufen sie auf. renderProjects() rendert nur noch, wenn ein Element
// #projectsList vorhanden ist — sonst übernimmt renderBoard().
// delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der // delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der
// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter // Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter
@@ -55,15 +67,67 @@ function editProjectActiveLP(id, value){
save(); renderProjects(); renderBoard(); renderLoad(); save(); renderProjects(); renderBoard(); renderLoad();
} }
// addProjectTag: fügt dem Projekt einen neuen Tag hinzu (getrimmt, nicht
// leer, keine exakten Duplikate). Initialisiert `p.tags`, falls es fehlt
// oder kein Array ist. Parameter: `projectId`, `tag` (roher Eingabetext).
// Keine Rückgabe; bei gesperrter Sitzung abgebrochen.
function addProjectTag(projectId, tag){
if(guardWeekLocked()) return;
const val = String(tag || '').trim();
if(!val) return;
const w = currentWeek();
const p = w.projects.find(x=>x.id===projectId);
if(!p) return;
if(!Array.isArray(p.tags)) p.tags = [];
if(p.tags.includes(val)) return;
p.tags.push(val);
save(); renderProjects(); renderBoard();
}
// removeProjectTag: entfernt einen Tag aus dem Projekt (exakter Textvergleich).
// Parameter: `projectId`, `tag`. Keine Rückgabe; bei gesperrter Sitzung
// abgebrochen.
function removeProjectTag(projectId, tag){
if(guardWeekLocked()) return;
const w = currentWeek();
const p = w.projects.find(x=>x.id===projectId);
if(!p) return;
p.tags = (Array.isArray(p.tags) ? p.tags : []).filter(t => t !== tag);
save(); renderProjects(); renderBoard();
}
// handleProjectTagKeydown: fügt bei Enter oder Komma im Tag-Eingabefeld den
// aktuellen Feldinhalt als neuen Tag hinzu (das Eingabefeld wird ohnehin
// beim anschließenden renderProjects() frisch/leer neu aufgebaut). Parameter:
// `event` (KeyboardEvent), `projectId`. Keine Rückgabe.
function handleProjectTagKeydown(event, projectId){
if(event.key !== 'Enter' && event.key !== ','){
return;
}
event.preventDefault();
addProjectTag(projectId, event.target.value);
}
// renderProjects: baut die HTML-Liste der Projekte auf, gruppiert nach `group` // 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., // 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 // Name, Gruppe, aktive LP sowie Drag-and-Drop-Handler. Keine Parameter, keine
// Rückgabe. // Rückgabe.
function renderProjects(){ function renderProjects(){
// Ohne #projectsList gibt es den eigenen Projekte-Bereich nicht mehr — die
// Liste steht im Projektboard. Der Aufruf bleibt dennoch überall stehen
// (renderAll(), editProjectField(), …), damit kein Pfad ins Leere greift.
const listEl = document.getElementById('projectsList');
if(!listEl) return;
const w = currentWeek(); const w = currentWeek();
if(!w) return; if(!w) return;
const locked = isWeekLocked(w);
let html=""; let html="";
const groups = {}; const groups = {};
// Alle Standardgruppen vorbelegen: Ohne das entsteht für eine leer geräumte
// Gruppe weder Überschrift noch Ablagefläche — ein Projekt ließ sich dann
// nur noch über das Auswahlfeld zurückverschieben, und der Hinweis
// "Keine Projekte." unten war toter Code.
['Aktive Projekte','Kleine Projekte','In Anbahnung','Allgemein'].forEach(g => { groups[g] = []; });
w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); }); w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); });
Object.keys(groups).forEach(g=>{ Object.keys(groups).forEach(g=>{
groups[g].sort((a,b)=>(a.order||0)-(b.order||0)); groups[g].sort((a,b)=>(a.order||0)-(b.order||0));
@@ -71,30 +135,39 @@ function renderProjects(){
sortGroups(Object.keys(groups)).forEach(g=>{ sortGroups(Object.keys(groups)).forEach(g=>{
html += `<div class="section-title">${esc(g)}</div>`; html += `<div class="section-title">${esc(g)}</div>`;
html += `<div class="projects-sortable" data-group="${g}" ondragover="handleDragOver(event)" ondrop="handleDrop(event)">`; html += `<div class="projects-sortable" data-group="${g}" ${locked ? '' : 'ondragover="handleDragOver(event)" ondrop="handleDrop(event)"'}>`;
groups[g].forEach(p=>{ groups[g].forEach(p=>{
html += `<div class="person-list-item" draggable="true" data-project-id="${p.id}" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"> const tags = Array.isArray(p.tags) ? p.tags : [];
<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px">⋮⋮</span> html += `<div class="person-list-item project-item" ${locked ? '' : 'draggable="true" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"'} data-project-id="${p.id}">
<input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr"> ${locked ? '' : `<span class="drag-handle" style="cursor:grab;color:#999">${icon('grip')}</span>`}
<input type="text" value="${esc(p.name)}" onchange="editProjectField('${p.id}','name',this.value)" style="width:200px" placeholder="Bezeichnung"> <input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr" ${locked ? 'disabled' : ''}>
<select onchange="editProjectField('${p.id}','group',this.value)"> <input type="text" value="${esc(p.name)}" onchange="editProjectField('${p.id}','name',this.value)" style="width:200px" placeholder="Bezeichnung" ${locked ? 'disabled' : ''}>
<select onchange="editProjectField('${p.id}','group',this.value)" ${locked ? 'disabled' : ''}>
<option ${p.group==='Aktive Projekte'?'selected':''}>Aktive Projekte</option> <option ${p.group==='Aktive Projekte'?'selected':''}>Aktive Projekte</option>
<option ${p.group==='Kleine Projekte'?'selected':''}>Kleine Projekte</option> <option ${p.group==='Kleine Projekte'?'selected':''}>Kleine Projekte</option>
<option ${p.group==='In Anbahnung'?'selected':''}>In Anbahnung</option> <option ${p.group==='In Anbahnung'?'selected':''}>In Anbahnung</option>
<option ${p.group==='Allgemein'?'selected':''}>Allgemein</option> <option ${p.group==='Allgemein'?'selected':''}>Allgemein</option>
</select> </select>
<select onchange="editProjectActiveLP('${p.id}',this.value)" title="Aktive Leistungsphase wählen"> <select onchange="editProjectActiveLP('${p.id}',this.value)" title="Aktive Leistungsphase wählen" ${locked ? 'disabled' : ''}>
<option value="" ${lpActiveList(p).length===0?'selected':''}>Aktive LP: keine</option> <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('')} ${AUFWAND_LPS.map(l => `<option value="${l}" ${lpActiveList(p)[0]===l?'selected':''}>${l}</option>`).join('')}
</select> </select>
<button class="secondary small" onclick="openLpModal('${p.id}')">✎ LP-Aufwand</button> <div class="project-tags-row">
<button class="danger small" onclick="delProjectFromList('${p.id}')">Löschen</button> <div class="tag-chip-list">
${tags.map(tag => `<span class="tag-chip" data-tag="${esc(tag)}">${esc(tag)}${locked ? '' : ` <span class="tag-remove" title="Tag entfernen" onclick="removeProjectTag('${p.id}', this.closest('.tag-chip').dataset.tag)">×</span>`}</span>`).join('')}
</div>
${locked ? '' : `<input type="text" class="tag-input" placeholder="+ Tag" onkeydown="handleProjectTagKeydown(event,'${p.id}')">`}
</div>
<div class="project-card-footer">
<button class="secondary small" onclick="openLpModal('${p.id}')" ${locked ? 'disabled' : ''}>LP-Aufwand</button>
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked ? 'disabled' : ''}>Löschen</button>
</div>
</div>`; </div>`;
}); });
if(groups[g].length===0) html+=`<div class="hint" style="padding:4px 2px 10px">Keine Projekte — Projekt hierher ziehen.</div>`;
html += `</div>`; html += `</div>`;
if(groups[g].length===0) html+=`<div class="hint">Keine Projekte.</div>`;
}); });
document.getElementById('projectsList').innerHTML=html; listEl.innerHTML = html;
} }
// Drag-and-Drop Handling für Projekte // Drag-and-Drop Handling für Projekte
@@ -134,7 +207,12 @@ function handleDrop(event){
sourceSortable.dataset && sortable.dataset && sourceSortable.dataset && sortable.dataset &&
sourceSortable.dataset.group === sortable.dataset.group; sourceSortable.dataset.group === sortable.dataset.group;
if(isSameGroup){ if(isSameGroup){
const afterElement = getDragAfterElement(sortable, event.clientY); // Die Ablagefläche der Projektliste enthält .board-row-Zeilen, der
// Personen-/Projekte-Aufbau .person-list-item. getDragAfterElement()
// braucht den passenden Selektor, sonst findet es keine Nachbarn und
// hängt jedes Projekt ans Gruppenende.
const sel = sortable.querySelector('.board-row') ? '.board-row:not([style*="opacity"])' : undefined;
const afterElement = getDragAfterElement(sortable, event.clientY, sel);
if(afterElement == null){ if(afterElement == null){
sortable.appendChild(draggedProject); sortable.appendChild(draggedProject);
} else { } else {
@@ -150,7 +228,13 @@ function handleDrop(event){
// handleDragEnd: stellt die Deckkraft der gezogenen Zeile wieder her und // handleDragEnd: stellt die Deckkraft der gezogenen Zeile wieder her und
// setzt `draggedProject` zurück (Ende des Drag-Vorgangs). // setzt `draggedProject` zurück (Ende des Drag-Vorgangs).
function handleDragEnd(event){ function handleDragEnd(event){
event.currentTarget.style.opacity = '1'; // Attribut entfernen statt auf 1 setzen: getDragAfterElement() schließt jedes
// Element mit style="…opacity…" von der Positionsberechnung aus. Nach einem
// abgebrochenen Ziehen (Loslassen außerhalb der Liste) blieb das Attribut
// stehen, und die Zeile wurde beim nächsten Ablegen ignoriert — das Projekt
// landete dann am Gruppenende statt an der gewünschten Stelle.
event.currentTarget.style.removeProperty('opacity');
if(event.currentTarget.getAttribute('style') === '') event.currentTarget.removeAttribute('style');
draggedProject = null; draggedProject = null;
} }
@@ -196,6 +280,8 @@ function reorderProjects(){
}); });
}); });
save(); save();
renderProjects(); // renderProjects() nur, solange der eigene Projekte-Bereich existiert; die
// Projektliste im Board rendert renderBoard().
if(typeof renderProjects === 'function' && document.getElementById('projectsList')) renderProjects();
renderBoard(); renderBoard();
} }
+60 -32
View File
@@ -1,13 +1,18 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Server-Kommunikation (CSV-API) // Projektübersicht — Server-Kommunikation (JSON-API)
// ----------------------------- // -----------------------------
// Diese Datei kapselt die HTTP-Kommunikation mit dem Express-Server (siehe // Diese Datei kapselt die HTTP-Kommunikation mit dem Express-Server (siehe
// server.js im Projekt-Root) und wird nach js/csv.js von beiden // server.js im Projekt-Root) und wird nach js/date-picker.js von beiden
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl, blobToDataUrl, // HTML-Einstiegspunkten geladen. Sie stellt baseUrl,
// sendCsvToServer und deleteCsvFromServer bereit. Genutzt wird sie von // sendWeekToServer und deleteWeekFromServer bereit. Genutzt wird sie von
// js/store.js (syncAllToServer, Speichern/Löschen aller Wochen), // js/store.js (syncAllToServer, Speichern aller Wochen), js/weeks.js
// js/weeks.js (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt // (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt direkt die
// direkt die aktuelle Woche über sendCsvToServer). // aktuelle Woche über sendWeekToServer). Die Daten werden als strukturierte
// JSON-Objekte an die SQLite-API des Servers übergeben (keine CSV-Strings
// mehr). Die globale Personenliste hat seit der Ablösung von
// people/person_roles/person_absences keinen eigenen Schreibpfad mehr
// (Projektbeteiligte werden über die Benutzerverwaltung gepflegt, siehe
// /admin/users bzw. /users/:id/absences).
// baseUrl: dynamische Server-URL, damit die App auch von anderen Geraeten im Netzwerk erreichbar ist. // 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. // Fallback auf localhost:3003, wenn die Seite als lokale Datei geladen wurde.
@@ -20,35 +25,58 @@ function baseUrl(){
return 'http://localhost:3003'; return 'http://localhost:3003';
} }
// blobToDataUrl: liest einen Blob als Data-URL ein (z. B. für Bilddaten) // sendWeekToServer: POSTet ein komplettes Wochen-Objekt (JSON) an
// Parameter: blob (Blob). Rückgabe: Promise, das die Data-URL auflöst. // <baseUrl>/save-week. Parameter: week (Objekt). Rückgabe: Promise mit
function blobToDataUrl(blob){ // Server-Antwort. `baseUpdatedAt` übermittelt die Revision, auf der die
return new Promise((resolve, reject) => { // lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error
const reader = new FileReader(); // mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer
reader.onloadend = () => resolve(reader.result); // den Konflikt anzeigen kann.
reader.onerror = reject; function sendWeekToServer(week){
reader.readAsDataURL(blob); if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-week',{
method:'POST',
headers:{'Content-Type':'application/json; charset=utf-8'},
body:JSON.stringify({
week,
baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined
})
}).then(r=>{
return r.json().then(json => {
if(r.ok && json) return json;
if(json && json.conflict){
const err = new Error(json.error || 'Konflikt: Sitzung wurde zwischenzeitlich geändert');
err.conflict = true;
err.label = (week && week.label) || '';
err.updatedAt = json.updatedAt;
throw err;
}
const err = new Error((json && json.error) || ('Server HTTP '+r.status));
err.status = r.status;
err.label = (week && week.label) || '';
throw err;
});
}); });
} }
// sendCsvToServer: POSTet eine Speicherwoche im CSV-Format an <baseUrl>/save-csv // deleteWeekFromServer: fordert das Löschen einer Woche über POST /delete-week an
// Parameter: filename (String), csvContent (String). Rückgabe: Promise mit Server-Antwort. // Parameter: label (String, das Wochen-Label). Rückgabe: Promise mit Server-Antwort.
function sendCsvToServer(filename, csvContent){ // Bei einer Fehler-Antwort (z. B. 403 „Keine Berechtigung“) trägt der geworfene
// Error zusätzlich `status` (HTTP-Statuscode), damit Aufrufer (js/weeks.js)
// gezielt darauf reagieren können.
function deleteWeekFromServer(label){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar')); if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-csv',{ return fetch(baseUrl() + '/delete-week', {
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', method: 'POST',
headers: {'Content-Type':'application/json; charset=utf-8'}, headers: {'Content-Type':'application/json; charset=utf-8'},
body: JSON.stringify({ filename, department: currentDepartment }) body: JSON.stringify({ label })
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }); }).then(r=>{
if(!r.ok){
return r.json().catch(()=>({})).then(json => {
const err = new Error((json && json.error) || ('Server HTTP '+r.status));
err.status = r.status;
throw err;
});
}
return r.json();
});
} }
-170
View File
@@ -1,170 +0,0 @@
// -----------------------------
// 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 Ansichten (Board und Auslastung) und zeigt
// sie in einem Vollbild-Overlay. Genutzt werden showTab() aus
// js/tabs-auth.js, setHeaderCollapsed() aus js/ui.js sowie renderBoard()/
// renderLoad() aus js/board/board.js/js/load/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: Tab vor dem Start, wird beim Beenden wiederhergestellt
let showModeOriginal = { 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 (Ansicht), die zyklisch
// durchlaufen wird
const SHOW_MODE_SEQUENCE = [
{ view: 'board' },
{ 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 = { 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 den 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';
// Inhalt leeren: Die Kopie enthält dieselben Element-IDs wie das echte Board
// bzw. der Auslastungs-Tab (#boardTable, #loadGrid, #loadScope) und steht im
// Dokument VOR ihnen. Blieb sie stehen, trafen renderBoard()/renderLoad()
// ab dann die tote Kopie im ausgeblendeten Overlay — die sichtbare Ansicht
// fror auf dem Stand vor der Präsentation ein, bis die Seite neu geladen wurde.
const content = document.getElementById('showModeContent');
if(content) content.innerHTML = '';
// Ursprünglichen Zustand wiederherstellen.
currentTab = showModeOriginal.tab;
showTab(currentTab);
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 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){
titleEl.textContent = 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>';
}
showTab(slide.view);
renderAll();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
copyCurrentViewToShowMode();
scheduleNextShowModeSlide();
});
});
}
// 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();
}
});
+305 -104
View File
@@ -1,22 +1,86 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Daten laden, Speichern, Abteilung wechseln // Projektübersicht — Daten laden, Speichern
// ----------------------------- // -----------------------------
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt alle Sitzungs-CSVs // Diese Datei bildet die Schnittstelle zum Server: Sie lädt beim Start alle
// beim Start, speichert den kompletten Datenbestand (alle Wochen + globale // (firmenweit globalen, nicht mehr abteilungsgetrennten) Sitzungen und speichert
// Personenliste) und verwaltet den Wechsel zwischen Abteilungen (ELT/HLS/ARCH). // den kompletten Datenbestand (alle Wochen + globale Personenliste). Die Daten
// Sie wird von `Test.html` nach `js/csv.js` und `js/server.js` geladen und stellt // liegen serverseitig in einer SQLite-Datenbank und werden als strukturierte
// die globalen Funktionen `save()`, `syncAllToServer()`, `switchDepartment()`, // JSON-Objekte über die API ausgetauscht (keine CSV-Strings). Sie wird von
// `load()`, `seed()` und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`, // `App.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
// `js/board/board.js`, `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das Speichern // die globalen Funktionen `save()`, `syncAllToServer()`, `load()`, `seed()`
// ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms debounced. // und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`, `js/board/board.js`,
// `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das
// Speichern ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms
// debounced.
// save: synchronisiert die aktuellen Daten als CSV-Dateien zum Server. // save: plant einen (debounced) Sync. Standardmäßig wird die aktuell gewählte
// Jede Sitzung wird als eigene CSV-Datei gespeichert. Personen und Rollen werden // Woche als geändert markiert, da sich die meisten Bearbeitungen auf die
// in der Sitzungscsv selbst abgelegt. // aktuelle Sitzung beziehen. Optionen:
function save(){ // { week:false } – aktuelle Woche NICHT markieren (z. B. reine Personen-Aktionen)
function save(opts = {}){
if(opts.week !== false){
const w = currentWeek();
if(w) markWeekDirty(w.label);
}
scheduleSessionSync(); scheduleSessionSync();
} }
// Dirty-Tracking: merkt sich, welche Wochen seit dem letzten Sync lokal
// geändert wurden. Gespeichert wird nur das, was sich wirklich geändert hat
// (statt wie früher immer alle Wochen). Die globale Personenliste hat seit der
// Ablösung von people/person_roles/person_absences keinen eigenen Schreibpfad
// mehr (Projektbeteiligte werden über die Benutzerverwaltung gepflegt).
let dirtyWeeks = new Set();
// weekChangeCounter: zählt je Woche die Änderungen mit. syncAllToServer merkt
// sich den Stand beim Absenden und räumt die Dirty-Markierung nur auf, wenn er
// sich bis zur Antwort nicht verändert hat. Ohne diesen Zähler verlor eine
// Änderung, die WÄHREND eines laufenden Speichervorgangs entsteht, ihre
// Markierung: Der eingereihte Nachlauf-Sync fand dann nichts mehr zu senden,
// und das Getippte stand nur noch im Browser.
const weekChangeCounter = new Map();
// syncProblemTimer: Wiederholungsversuch nach einem fehlgeschlagenen Speichern.
let syncProblemTimer = null;
// In-Flight-Schutz für syncAllToServer: läuft bereits ein Sync, wird kein
// zweiter gestartet (sonst senden zwei Syncs dieselbe Woche mit gleicher
// Revisions-Basis und der zweite bekäme einen scheinbaren 409-Konflikt).
// Stattdessen wird ein Nachlauf-Sync eingereiht und nach Abschluss ausgeführt.
let syncInFlight = false;
let syncQueued = false;
// showSyncProblem: blendet unten rechts ein Band ein, wenn das Speichern
// fehlschlägt. Vorher stand der Fehler ausschließlich in der Browser-Konsole —
// der Nutzer arbeitete ahnungslos weiter, während seit Stunden nichts mehr
// gespeichert wurde. Parameter: message (Text) oder null zum Ausblenden.
function showSyncProblem(message){
let el = document.getElementById('syncProblemBar');
if(!message){
if(el) el.style.display = 'none';
return;
}
if(!el){
el = document.createElement('div');
el.id = 'syncProblemBar';
el.className = 'sync-problem-bar';
document.body.appendChild(el);
}
el.textContent = message;
el.style.display = '';
}
// scheduleSyncRetry: versucht ein fehlgeschlagenes Speichern nach 20 Sekunden
// erneut. Ohne das blieb eine Woche nach einem Netzaussetzer dauerhaft
// ungespeichert, bis der Nutzer zufällig etwas anderes bearbeitete.
function scheduleSyncRetry(){
if(syncProblemTimer || dirtyWeeks.size === 0) return;
syncProblemTimer = setTimeout(() => {
syncProblemTimer = null;
if(dirtyWeeks.size > 0) syncAllToServer();
}, 20000);
}
// scheduleSessionSync: plant den Speichervorgang für alle Wochen und Personen // scheduleSessionSync: plant den Speichervorgang für alle Wochen und Personen
// über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender // über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender
// Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden. // Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden.
@@ -25,99 +89,228 @@ function scheduleSessionSync(){
saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500); saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500);
} }
// syncAllToServer: schreibt die gesamte Datenbank als CSV-Dateien zum Server. // syncAllToServer: schreibt nur die seit dem letzten Sync geänderten Wochen
// Zuerst wird die globale Personenliste (`_people.csv`) gespeichert, danach jede // zum Server. Konflikte (409) auf einer Woche werden über
// Sitzung einzeln über `filenameForWeek()`/`weekToCsv()` und `sendCsvToServer()`. // handleWeekConflict() gemeldet. Fehlerhaft gespeicherte Wochen bleiben dirty,
// Gibt nichts zurück; Fehler werden nur per console.error protokolliert. // damit der nächste Sync sie erneut versucht; nur erfolgreich gespeicherte
// werden aus dem Dirty-Satz entfernt.
function syncAllToServer(){ function syncAllToServer(){
// Zuerst globale Personenliste speichern if(syncInFlight){ syncQueued = true; return; }
saveGlobalPeopleToServer(); syncInFlight = true;
// 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 const jobs = [];
// Abteilung per `globalPeopleToCsv()` und speichert sie als `_people.csv`. const weekLabels = Array.from(dirtyWeeks);
// Leere Personenlisten werden übersprungen (kein Schreibvorgang). let problem = '';
function saveGlobalPeopleToServer(){ weekLabels.forEach(label => {
const people = db.globalPeople[currentDepartment] || []; // Die Planung-Sandbox steckt bewusst nicht in db.weeks (siehe
if(people.length === 0) return; // db.sandboxWeek, oben in load()) — ohne diesen Zweig würde jede
const csv = globalPeopleToCsv(people); // Sandbox-Änderung hier lautlos verworfen (dirtyWeeks.delete ohne
sendCsvToServer('_people.csv', csv) // jemals zu speichern), weil db.weeks.find() sie nie findet.
.then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); }) const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : db.weeks.find(x => x.label === label);
.catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); }); if(!w){ dirtyWeeks.delete(label); return; }
} // Stand der Änderungen zum Zeitpunkt des Absendens merken (siehe
// weekChangeCounter): Nur wenn er bei der Antwort unverändert ist, sind
// switchDepartment: wechselt die aktive Abteilung (z.B. "ELT") und lädt deren // wirklich alle Änderungen dieser Woche beim Server angekommen.
// Daten neu. Parameter `dept` ist der Abteilungsschlüssel aus `DEPARTMENTS`. const sentGeneration = weekChangeCounter.get(label) || 0;
// Aktualisiert Tab-Markierung und Seitentitel und ruft anschließend `load()`. jobs.push(sendWeekToServer(w)
// Gibt nichts zurück. .then(res => {
function switchDepartment(dept){ if(res && res.ok){
if(!DEPARTMENTS[dept]) return; console.log('Sitzung gespeichert:', w.label);
// Laufenden Speicher-Debounce der alten Abteilung verwerfen, damit dessen // Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// Sync nicht nach dem Reset mit dem neuen currentDepartment feuert und die // nicht fälschlich als Konflikt gemeldet werden.
// CSV der falschen Abteilung schreibt. if(res.updatedAt) w.updatedAt = res.updatedAt;
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; } if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
currentDepartment = dept; } else {
saveUiState(); problem = 'Sitzung "' + w.label + '" konnte nicht gespeichert werden.';
updateRouteUrl(dept, currentTab); console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
// 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;
} }
// Abteilung einfrieren, für die diese Anfrage gestartet wurde. Antworten, })
// die nach einem zwischenzeitlichen Abteilungswechsel eintreffen, werden .catch(err => {
// verworfen (Stale-Response), sonst würden Daten der alten Abteilung unter if(err && err.conflict){
// der neuen Abteilung angezeigt und gespeichert. dirtyWeeks.delete(label);
const requestedDepartment = currentDepartment; handleWeekConflict(w, err);
const url = baseUrl() + '/load-all' + departmentParam(); } else if(err && err.status === 401){
console.log('Loading from', url); // Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still
fetch(url) // weiterzuarbeiten und alles Weitere zu verlieren.
problem = 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.';
if(typeof showLoginOverlay === 'function') showLoginOverlay();
console.error('Speichern abgelehnt (nicht angemeldet):', w.label);
} else {
problem = 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.';
console.error('Sitzung konnte nicht gespeichert werden:', w.label, (err && err.message) || err);
// Label bleibt dirty -> der nächste Sync versucht es erneut.
}
}));
});
Promise.all(jobs).then(() => {
syncInFlight = false;
showSyncProblem(problem || null);
if(problem) scheduleSyncRetry();
if(syncQueued){ syncQueued = false; syncAllToServer(); }
});
}
// markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für
// Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock).
function markWeekDirty(label){
if(typeof label !== 'string' || !label) return;
dirtyWeeks.add(label);
weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1);
}
// handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich
// geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu
// geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste
// bleiben unangetastet und gehen nicht verloren.
function handleWeekConflict(week, err){
const label = (week && week.label) || (err && err.label) || '';
alert('Achtung: ' + label + ' wurde zwischenzeitlich von einer anderen Person '
+ 'geändert. Ihre Änderungen an dieser Sitzung wurden nicht gespeichert; '
+ 'die aktuellen Daten dieser Sitzung werden geladen.');
if(label) reloadWeekFromServer(label);
else load();
}
// reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
// syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten.
function reloadWeekFromServer(label){
fetch(baseUrl() + '/load-all')
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }) .then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => { .then(data => {
if(currentDepartment !== requestedDepartment) return; const fresh = (Array.isArray(data.weeks) ? data.weeks : [])
// Globale Personenliste laden .find(w => w && w.label === label);
db.globalPeople[currentDepartment] = parseGlobalPeopleCsv(data.people); const idx = db.weeks.findIndex(w => w.label === label);
if(idx >= 0){
if(fresh){
// Felder absichern (wie in load()), damit keine kaputten Daten crashen
if(!Array.isArray(fresh.people)) fresh.people = [];
if(!Array.isArray(fresh.projects)) fresh.projects = [];
if(!fresh.attendance) fresh.attendance = {};
db.weeks[idx] = fresh;
} else {
db.weeks.splice(idx, 1);
}
// Wurde die aktuelle Woche entfernt, auf die letzte verbleibende wechseln
if(!db.weeks.some(w => w.id === db.currentWeek)){
db.currentWeek = db.weeks.length ? db.weeks[db.weeks.length - 1].id : null;
}
}
renderAll();
showTab(currentTab);
})
.catch(err => {
// KEIN load() als Rückfall: Das leert dirtyWeeks und ersetzt alle Wochen,
// also gingen auch die noch ungespeicherten Änderungen an ANDEREN
// Sitzungen verloren — genau das, was diese Funktion vermeiden soll.
console.error('Neu laden der Sitzung fehlgeschlagen:', err);
showSyncProblem('Die Sitzung "' + label + '" konnte nicht neu geladen werden. Bitte die Seite neu laden.');
});
}
const parsedSessions = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) })); // load: liest alle Sitzungen und die globale Personenliste über `/load-all`
// als JSON vom Server und baut daraus `db.weeks` auf. Sortiert nach
// `weekSortKey()`, wendet `applyAutoLock()` an und rendert die Ansicht. Kein
// Parameter, keine Rückgabe; bei Fehlern oder fehlenden Daten werden
// Beispieldaten über `seed()` geladen.
// loadAppSettings: holt die firmenweiten Einstellungen (GET /app-settings)
// und die Feiertage (GET /holidays) und legt sie in APP_SETTINGS/HOLIDAYS ab
// (js/globals.js). Beides wird zum RECHNEN gebraucht — Auslastung, Ampel,
// Terminplan aus dem Honorar —, deshalb sind die Routen für jedes angemeldete
// Konto lesbar; nur das Ändern ist Administratoren vorbehalten.
//
// Scheitert der Abruf, bleiben die Vorgaben aus js/globals.js stehen. Das ist
// Absicht: Die Anwendung rechnet dann wie vor dem Ausbau der Einstellungen
// weiter, statt mit leeren Werten falsche Zahlen zu zeigen.
// Rückgabe: Promise (wird nie abgelehnt).
function loadAppSettings(){
const settings = fetch(baseUrl() + '/app-settings')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => { if(data && data.settings) APP_SETTINGS = Object.assign({}, APP_SETTINGS, data.settings); })
.catch(err => { console.warn('Firmenweite Einstellungen konnten nicht geladen werden, es gelten die Vorgabewerte:', err); });
db.weeks = []; const holidays = fetch(baseUrl() + '/holidays')
parsedSessions.forEach(s => { .then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
const w = parseSessionCsv(s.filename, s.rows); .then(data => {
if(w) db.weeks.push(w); const list = Array.isArray(data.holidays) ? data.holidays : [];
HOLIDAYS = new Set(list.map(h => h.date));
HOLIDAY_NAMES = {};
list.forEach(h => { HOLIDAY_NAMES[h.date] = h.name || ''; });
// Die Bundesländer liefert dieselbe Route mit (siehe server.js); die
// Pflege-Maske im Admin-Bereich baut daraus ihre Auswahl.
if(data.states) GERMAN_STATES = data.states;
})
.catch(err => { console.warn('Feiertage konnten nicht geladen werden:', err); });
// Honorar-Anteile je Leistungsphase: Die Auslastung rechnet den offenen
// Wert einer Phase in Stunden um und braucht dafür deren Anteil am
// Gesamthonorar. Zusammen mit den übrigen Vorgaben geladen, also VOR
// /load-all und damit vor dem ersten renderAll().
const honorar = fetch(baseUrl() + '/honorar-config')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => { HONORAR_ANTEILE = (data && data.anteile) || {}; })
.catch(err => { console.warn('Honorar-Anteile konnten nicht geladen werden, die Auslastung weist sie als fehlend aus:', err); });
return Promise.all([settings, holidays, honorar]);
}
// load: lädt die Sitzungsdaten vom Server neu. Parameter: silent (optional) —
// true für eine stille Hintergrund-Aktualisierung (js/wand/wand.js, alle 60s):
// ein Fehlschlag wird nur geloggt statt per alert() gemeldet, und der
// bisherige Datenstand bleibt stehen statt geleert zu werden. Ohne diesen
// Zweig stapelten sich auf einem unbeaufsichtigten Touch-Display bei jedem
// Fehlschlag blockierende alert()-Dialoge übereinander.
function load(silent){
// Nach einem (Neu-)Load sind die lokalen Daten serverseitig aktuell:
// laufenden Debounce abbrechen und Dirty-Zustand verwerfen.
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
dirtyWeeks.clear();
if(!window.fetch){
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
return;
}
const url = baseUrl() + '/load-all';
console.log('Loading from', url);
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
// Aufwandsvorlage, Arbeitstage). loadAppSettings() lehnt nie ab, ein
// Fehlschlag lässt also nur die Vorgabewerte stehen.
loadAppSettings().then(() => fetch(url)
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
// Globale Personenliste laden (flaches Array, keine Abteilungstrennung)
db.globalPeople = Array.isArray(data.people) ? data.people : [];
// db.sandboxWeek: die Planung-Sandbox (SANDBOX_WEEK_LABEL) — bewusst
// NICHT Teil von db.weeks (siehe /load-all, server.js), sondern ein
// eigenes, synthetisches Wochen-ähnliches Objekt, hier und nur hier
// aufgebaut. Damit sehen renderBoard()/berichtProjekte()/
// berechneAuslastung()/die Sitzungs-PDFs/Admin→Sitzungen sie nie —
// sie iterieren alle ausschließlich db.weeks. people wird aus der
// globalen Personenliste abgeleitet (dieselbe Form wie week.people),
// damit die Beteiligten-Matrix im Projektbereich auch für einen
// Sandbox-Plan Kandidaten zur Auswahl hat.
db.sandboxWeek = {
id: 'sandbox', label: SANDBOX_WEEK_LABEL, date: '',
people: db.globalPeople.map(p => ({ id: p.id, name: p.name, fullName: p.fullName })),
projects: Array.isArray(data.sandboxPlans) ? data.sandboxPlans : [],
attendance: {}, locked: false, updatedAt: data.sandboxUpdatedAt || ''
};
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string')
.map(w => {
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return w;
}); });
if(db.weeks.length === 0){ if(db.weeks.length === 0){
console.warn('Server lieferte 0 Sitzungsdateien. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob der Ordner sessions/ mitkopiert wurde.'); console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.');
seed(); seed();
} }
else { else {
@@ -131,23 +324,29 @@ function load(){
} }
// Geschützte Tabs ('projects'/'people') dürfen nur mit Passwort angezeigt // Geschützte Tabs ('projects'/'people') dürfen nur mit Passwort angezeigt
// werden. showTab() öffnet dafür automatisch das Passwort-Modal – auch // werden. showTab() öffnet dafür automatisch das Passwort-Modal – auch
// wenn der Tab per Deep-Link-URL (z. B. "/ELT/Projekte") gewählt wurde. // wenn der Tab per Deep-Link-URL (z. B. "/Projekte") gewählt wurde.
renderAll(); renderAll();
showTab(currentTab); showTab(currentTab);
}) })
.catch(err => { .catch(err => {
if(currentDepartment !== requestedDepartment) return;
console.error('Laden vom Server fehlgeschlagen:', err); console.error('Laden vom Server fehlgeschlagen:', err);
if(silent) return;
const serverUrl = baseUrl(); const serverUrl = baseUrl();
const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' + const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' +
'Mögliche Ursachen:\n' + 'Mögliche Ursachen:\n' +
'- Der Server wurde nicht gestartet\n' + '- Der Server wurde nicht gestartet\n' +
'- Der Server läuft auf einem anderen Port (Standard: 3003)\n' + '- Der Server läuft auf einem anderen Port (Standard: 3003)\n' +
'- Firewall/Antivirus blockiert ' + serverUrl + '\n\n' + '- Firewall/Antivirus blockiert ' + serverUrl + '\n\n' +
'Es werden jetzt die Beispieldaten angezeigt.'; 'Es werden keine Daten angezeigt. Bitte die Seite neu laden, sobald der Server wieder läuft.';
alert(msg); alert(msg);
seed(); renderAll(); // BEWUSST KEIN seed(): Die Beispieldaten enthalten eine Woche "KW 30 / 2026"
}); // und wurden früher über save() an den Server geschickt, sobald die
// Verbindung zurückkam — sie überschrieben damit die echte gleichnamige
// Sitzung. Bei einem Ladefehler bleibt der Bestand jetzt einfach leer.
db.weeks = [];
db.currentWeek = null;
renderAll();
}));
} }
// seed: initialisiert Beispiel-Daten (Personen + eine Woche mit Projekten). // seed: initialisiert Beispiel-Daten (Personen + eine Woche mit Projekten).
@@ -168,7 +367,7 @@ function seed(){
{id:personIdForName('MK'), name:"MK", fullName:"Martin Kluge", 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"]}, {id:personIdForName('HR'), name:"HR", fullName:"Hartwig Rauch", role:["Bauüberwachung"]},
]; ];
db.globalPeople[currentDepartment] = JSON.parse(JSON.stringify(people)); db.globalPeople = JSON.parse(JSON.stringify(people));
const wid = uid(); const wid = uid();
db.weeks = [{ db.weeks = [{
@@ -184,7 +383,9 @@ function seed(){
save(); save();
} }
// demoProj: Helfer zum Erzeugen eines Projekt-Objekts (für seed/Tests) // demoProj: Helfer zum Erzeugen eines Projekt-Objekts (für seed/Tests).
// projectUid: dauerhafte, wochenunabhängige Identität (siehe saveNewProj()
// in js/load/load.js für die Erklärung, warum sie sich von id unterscheidet).
function demoProj(nr,name,group,status,lph){ function demoProj(nr,name,group,status,lph){
return {id:uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null, aufwandProLP: undefined, aktuelleLP: undefined}; return {id:uid(), projectUid: uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null, aufwandProLP: undefined, aktuelleLP: undefined};
} }
+91 -57
View File
@@ -1,75 +1,109 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Tabs (Navigation) & Passwort-Schutz // Projektübersicht — Tabs (Navigation)
// ----------------------------- // -----------------------------
// Modul für die Tab-Navigation und den Passwortschutz der Hauptseite (nur von // Modul für die Tab-Navigation der Hauptseite (nur von App.html geladen, nach
// Test.html geladen, nach js/showmode.js, vor js/pdf.js). showTab() wechselt // js/wand/wand.js, vor js/pdf.js). showTab() wechselt zwischen Board,
// zwischen Board, Auslastung, Projekte und Projektbeteiligte und fordert für // Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Meine Aufgaben,
// die geschützten Tabs 'projects'/'people' das Passwort an; confirmPassword() // Wandtafel, Einstellungen, Admin und dem Projektbereich eines einzelnen
// prüft den SHA256-Hash gegen PROTECTED_PASSWORD_HASH (js/globals.js). Die // Projekts ('plan', siehe TAB_PANELS).
// ausstehende Aktion wird in requestedAction (js/globals.js) gemerkt – davon // Einstellungen und Admin sind keine eigenen Tabs mehr, sondern aufklappbare
// profitieren auch die Passwort-Gates in js/weeks.js (Löschen/Entsperren von // Bereiche direkt in der Seitenleiste (siehe toggleSidebarAccordion() in
// Sitzungen). Gerendert werden beim Tab-Wechsel renderBoard()/renderLoad()/ // js/ui.js). Gerendert werden beim Tab-Wechsel renderBoard()/
// renderProjects()/renderPeople() aus js/board/board.js, js/load/load.js, js/projects/projects.js, // renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()/
// js/people/people.js. // renderDashboard()/renderWandboard() aus js/board/board.js,
// js/wochenbericht/wochenbericht.js, js/load/load.js, js/projects/projects.js,
// js/people/people.js, js/dashboard/dashboard.js, js/wand/wand.js. Der
// frühere clientseitige Passwortschutz für die Tabs 'projects'/'people' wurde
// entfernt — der Zugriffsschutz läuft jetzt serverseitig über die Session
// (siehe js/auth.js, server.js).
//
// TAB_PANELS: alle Bereiche der Hauptseite. 'plan' ist der Projektbereich
// EINES Projekts (#plan) — früher die eigenständige Seite project-plan.html,
// die in einem eigenen Dokument geöffnet wurde. Seit dem Umbau ist er ein
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
// Dashboard ins Projektboard.
const TAB_PANELS = ['board','wochenbericht','load','people','planung','dashboard','wand','settings','admin','plan'];
// showTab: wechselt zwischen Ansichten; für sensible Tabs wird ein Passwort verlangt function showTab(t){
function showTab(t, skipAuth = false){ // Der Bereich „Admin" steht nur Administratoren offen. Ohne diese Weiche
// Für die Tabs 'projects' und 'people' ist ein Passwortschutz vorgesehen // zeigte ein weitergereichter Deep-Link „/Admin" einem Mitglied eine leere
if((t==='projects' || t==='people') && !skipAuth && !isAuthenticated){ // Seite — die Karten darin rendert renderAdminArea() für Nicht-Admins gar
requestedAction = { type: 'tab', tab: t }; // nicht erst. (Ein UI-Gate, mehr nicht: Die Routen dahinter sind
document.getElementById('passwordModal').style.display='flex'; // serverseitig mit requireRole('admin') abgesichert.)
document.getElementById('passwordInput').value=''; if(t === 'admin' && !(currentUser && currentUser.role === 'admin')) t = DEFAULT_TAB;
document.getElementById('passwordInput').focus(); if(t !== currentTab) previousTab = currentTab;
return;
}
currentTab = t; currentTab = t;
saveUiState(); saveUiState();
updateRouteUrl(currentDepartment, t); updateRouteUrl(t);
['board','projects','load','people'].forEach(x=>{ TAB_PANELS.forEach(x=>{
const el = document.getElementById(x); const el = document.getElementById(x);
if(el) el.style.display=(x===t?'block':'none'); if(el) el.style.display=(x===t?'block':'none');
}); });
// Der Projektbereich steht als einziger Bereich AUSSERHALB von .container
// (er bringt eigene Kopfzeile und Innenabstände mit). Der leere Container
// hinterließe sonst seinen Innenabstand als blinden Streifen über der
// Kopfzeile des Projektbereichs.
const containerEl = document.querySelector('.app-main > .container');
if(containerEl) containerEl.style.display = (t === 'plan') ? 'none' : '';
// Wandtafel: Seitenleiste und Kopfzeile ausblenden (body.wandtafel-aktiv,
// styles.css) — das Kiosk-Konto hat echte Schreibrechte, eine zufällige
// Berührung der Navigation soll nicht in einen bearbeitbaren Bereich
// führen. startWandboard()/stopWandboard() (js/wand/wand.js) starten bzw.
// beenden dabei die Hintergrund-Aktualisierung und die Kachel-Rotation.
document.body.classList.toggle('wandtafel-aktiv', t === 'wand');
if(t === 'wand') startWandboard(); else stopWandboard();
document.querySelectorAll('.tab').forEach(el=>el.classList.toggle('active',el.dataset.tab===t)); document.querySelectorAll('.tab').forEach(el=>el.classList.toggle('active',el.dataset.tab===t));
if(t==='board' && currentWeek()) renderBoard(); if(t==='board' && currentWeek()) renderBoard();
if(t==='projects') renderProjects(); if(t==='wochenbericht') renderWochenbericht();
if(t==='load') renderLoad(); if(t==='load') renderLoad();
if(t==='people') renderPeople(); if(t==='people') renderPeople();
if(t==='planung') renderPlanung();
if(t==='dashboard') renderDashboard();
if(t==='wand') renderWandboard();
if(t==='settings') renderSettings();
if(t==='admin') renderAdminArea();
syncAdminNavState(t);
// Der Projektbereich wird erst NACH dem Einblenden gerendert: das GANTT
// rechnet mit der Containerbreite, die in einem ausgeblendeten Bereich 0
// wäre. Das Ziel (Sitzung + Projekt) steht vorher über setPlanTarget().
if(t==='plan') openPlanView();
syncPlanNavItem();
} }
// ---------- Passwort-Schutz Implementierung ---------- // syncAdminNavState: „Admin" ist in der Seitenleiste kein .tab-Knopf,
// sondern der Kopf einer aufklappbaren Gruppe — die allgemeine
// confirmPassword: prüft das eingegebene Passwort gegen den gespeicherten Hash // .tab-Hervorhebung oben greift dort also nicht. Diese Funktion markiert ihn
function confirmPassword(){ // stattdessen, solange der Bereich offen ist, und klappt die Gruppe dabei
const pwd = document.getElementById('passwordInput').value; // auf, damit der gewählte Abschnitt sichtbar wird.
if(!pwd) { alert("Bitte Passwort eingeben!"); return; } // Parameter: t (aktiver Bereich). Keine Rückgabe.
function syncAdminNavState(t){
const pwdHash = CryptoJS.SHA256(pwd).toString(); const toggle = document.getElementById('adminAccordionToggle');
const subnav = document.getElementById('adminSubnav');
if(pwdHash === PROTECTED_PASSWORD_HASH){ if(!toggle || !subnav) return;
// Sitzungs-Authentifizierung merken, damit Tab-Wechsel und Abteilungs- const aktiv = (t === 'admin');
// wechsel innerhalb dieser Sitzung kein erneutes Passwort verlangen. toggle.classList.toggle('active', aktiv);
isAuthenticated = true; if(aktiv && !subnav.classList.contains('open')){
// Nur die zuletzt angeforderte Aktion ausführen (nicht eine evtl. bereits subnav.classList.add('open');
// durch closePasswordModal verworfenen Aktion erneut starten) – daher die toggle.classList.add('expanded');
// Aktion hier erneut über requestedAction prüfen statt sie festzuhalten. toggle.setAttribute('aria-expanded', 'true');
setTimeout(() => {
const action = requestedAction;
requestedAction = null;
if(action?.type==='tab') showTab(action.tab, true);
else if(action?.type==='deleteWeek') deleteWeek(true);
else if(action?.type==='unlockWeek') unlockCurrentWeek(true);
}, 100);
document.getElementById('passwordModal').style.display='none';
} 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). // syncPlanNavItem: hält den Eintrag des Projektbereichs in der Seitenleiste
function closePasswordModal(){ // aktuell. Er ist der einzige Navigationspunkt, der nicht immer da ist — er
document.getElementById('passwordModal').style.display='none'; // erscheint erst, wenn ein Projekt geöffnet wurde, und trägt dann dessen
requestedAction = null; // Bezeichnung. Damit zeigt die Seitenleiste auch im Projektbereich, wo man
// gerade ist, statt (wie es ohne ihn wäre) gar keinen Punkt hervorzuheben.
// Keine Parameter, keine Rückgabe.
function syncPlanNavItem(){
const item = document.getElementById('planNavItem');
if(!item) return;
const app = window.PlanApp;
const name = app && app.projName ? app.projName : '';
item.style.display = name ? '' : 'none';
const label = document.getElementById('planNavItemLabel');
if(label) label.textContent = name || 'Projektbereich';
item.title = name ? ('Projektbereich: ' + name) : 'Projektbereich';
} }
+3 -3
View File
@@ -3,12 +3,12 @@
// ----------------------------- // -----------------------------
// Diese Datei verwaltet den reinen UI-Zustand: den kollabierbaren Header sowie // Diese Datei verwaltet den reinen UI-Zustand: den kollabierbaren Header sowie
// das Light/Dark-Theme. Sie wird nach js/globals.js von beiden HTML- // das Light/Dark-Theme. Sie wird nach js/globals.js von beiden HTML-
// Einstiegspunkt Test.html geladen. Die // Einstiegspunkt App.html geladen. Die
// bereitgestellten Funktionen werden von js/init.js beim Start sowie von // bereitgestellten Funktionen werden von js/init.js beim Start sowie von
// js/tabs-auth.js (Tab-Wechsel, showTab) aufgerufen; im Theme-Umschalter der // js/tabs-auth.js (Tab-Wechsel, showTab) aufgerufen; im Theme-Umschalter der
// Seite werden toggleTheme/applyTheme direkt eingebunden. // Seite werden toggleTheme/applyTheme direkt eingebunden.
// Seit der Projektbereich eines Projekts kein eigenes Dokument mehr ist, // Seit der Projektbereich eines Projekts kein eigenes Dokument mehr ist,
// sondern der Bereich #plan von Test.html, kennen applyUrlRoute() und // sondern der Bereich #plan von App.html, kennen applyUrlRoute() und
// updateRouteUrl() auch dessen Adresse "/Projektplan/<Projektname>". // updateRouteUrl() auch dessen Adresse "/Projektplan/<Projektname>".
// saveUiState: speichert den aktiven Tab in den LocalStorage // saveUiState: speichert den aktiven Tab in den LocalStorage
@@ -258,7 +258,7 @@ function toggleSidebarCollapse(){
// toggleSidebarAccordion: klappt einen der beiden Aktions-Bereiche in der // toggleSidebarAccordion: klappt einen der beiden Aktions-Bereiche in der
// Seitenleiste (Einstellungen/Admin) auf oder zu. Erwartet die zugehörigen // Seitenleiste (Einstellungen/Admin) auf oder zu. Erwartet die zugehörigen
// Elemente unter den IDs "<key>AccordionToggle" (Kopf-Button) und // Elemente unter den IDs "<key>AccordionToggle" (Kopf-Button) und
// "<key>Subnav" (Liste der Aktionen), siehe Test.html. Kein Persistieren des // "<key>Subnav" (Liste der Aktionen), siehe App.html. Kein Persistieren des
// Zustands — jeder Bereich startet beim Neuladen zugeklappt. // Zustands — jeder Bereich startet beim Neuladen zugeklappt.
// Parameter: key ('settings'|'admin'). Rückgabe: keine. // Parameter: key ('settings'|'admin'). Rückgabe: keine.
function toggleSidebarAccordion(key){ function toggleSidebarAccordion(key){
+237 -35
View File
@@ -5,7 +5,7 @@
// ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden // ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden
// HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen // HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen
// ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen, // ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen,
// u. a. js/csv.js, js/board/board.js, js/load/load.js sowie den Plan-Modulen unter js/plan/* // u. a. js/board/board.js, js/load/load.js sowie den Plan-Modulen unter js/plan/*
// (z. B. für ISO-Datumsformate und Werktage) und von js/gantt.js. // (z. B. für ISO-Datumsformate und Werktage) und von js/gantt.js.
// esc: einfache Escape-Funktion für Ausgabe in HTML // esc: einfache Escape-Funktion für Ausgabe in HTML
@@ -46,8 +46,7 @@ function sortGroups(arr){
// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt. // peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt.
// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle // Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle
// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung. // Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung.
// Liegt im gemeinsamen Modul, weil auch csv.js (weekToCsv für savePlan auf der // Liegt im gemeinsamen Modul, weil board/load/pdf auf sie zugreifen.
// Projektplan-Seite) und pdf.js darauf zugreifen.
function peopleByRole(target){ function peopleByRole(target){
const m = {}; ROLES.forEach(r=>m[r]=[]); const m = {}; ROLES.forEach(r=>m[r]=[]);
const people = []; const people = [];
@@ -73,6 +72,18 @@ function peopleByRole(target){
return m; return m;
} }
// weekSortKey: liefert einen nach Jahr und Kalenderwoche sortierbaren Schlüssel,
// sodass z.B. "KW 9 / 2026" vor "KW 10 / 2026" kommt.
// 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();
}
// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und // Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und
// Zuordnungen auch nach einem Neuladen stabil bleiben. // Zuordnungen auch nach einem Neuladen stabil bleiben.
// Parameter: name (String). Rückgabe: String wie 'p_ab'. // Parameter: name (String). Rückgabe: String wie 'p_ab'.
@@ -235,7 +246,7 @@ function getWeekNumber(d){
// Fett, Farben, Absätze) wird sowohl im Projektboard (js/board/board.js, // Fett, Farben, Absätze) wird sowohl im Projektboard (js/board/board.js,
// Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js) // Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js)
// verwendet — deshalb liegen die reinen Hilfsfunktionen hier im gemeinsamen // verwendet — deshalb liegen die reinen Hilfsfunktionen hier im gemeinsamen
// Modul. Nur statusBlur() (board.js, ruft editProj() auf) bzw. der // Modul. Nur der
// Plan-Gegenpart (planStatusBlur() in plan-info.js) bleiben modul-eigen, da // Plan-Gegenpart (planStatusBlur() in plan-info.js) bleiben modul-eigen, da
// sie unterschiedliche Speicherpfade ansteuern. // sie unterschiedliche Speicherpfade ansteuern.
@@ -278,10 +289,27 @@ function formatStatus(command, value){
// eingeschleust werden können; im style-Attribut sind nur color/font-weight // eingeschleust werden können; im style-Attribut sind nur color/font-weight
// erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei // erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei
// leerem Eingabewert). // leerem Eingabewert).
//
// DOMParser statt `div.innerHTML = html` — das ist der GANZE Sicherheitsgewinn
// dieser Funktion, nicht bloß Geschmackssache: innerHTML lädt eingebettete
// Ressourcen und führt deren Fehler-Handler aus, AUCH wenn das Element gar
// nicht im Dokument hängt. Ein `<img src=x onerror=…>` lief damit schon
// WÄHREND des Bereinigens — also auf jedem Anzeigepfad, der hier
// durchkommt, obwohl das Bild danach korrekt entfernt wurde. Der Server
// speichert Kommentare und Protokolltexte unverändert, ein angemeldetes
// Konto konnte solches HTML also über die API hinterlegen und damit Code in
// der Sitzung aller anderen ausführen. parseFromString() erzeugt dagegen ein
// inertes Dokument: kein Laden, kein Handler. Nachgemessen — mit innerHTML
// feuerte der Handler, mit DOMParser nicht, bei identischem Ergebnisbaum.
function sanitizeStatusHtml(html){ function sanitizeStatusHtml(html){
if(!html) return ''; if(!html) return '';
const div = document.createElement('div'); // Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die
div.innerHTML = html; // Knoten vorher in ein Element des Hauptdokuments übernommen, zöge
// appendChild() ein adoptNode() nach sich — und ein dabei übernommenes
// <img src=…> begänne wieder zu laden, also genau das, was hier
// verhindert werden soll.
const inert = new DOMParser().parseFromString(String(html), 'text/html');
const div = inert.body;
const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']); const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']);
const allowedAttrs = new Set(['style']); const allowedAttrs = new Set(['style']);
// Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden: // Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden:
@@ -334,26 +362,27 @@ function sanitizeStatusHtml(html){
return div.innerHTML; return div.innerHTML;
} }
// statusHtmlToEditText: wandelt einen gespeicherten Kommentar (HTML) in den // statusTextMitZeilen: wandelt Kommentar-HTML in reinen Text und erhält dabei
// Klartext, der im Textfeld des Kommentar-Editors steht. Fettschrift wird zu // die Zeilenumbrüche (<br> und Blockgrenzen werden zu \n). Gegenstück zu
// **Sternchen**, Absätze und <br> werden zu Zeilenumbrüchen, alles andere // statusPlainText() weiter unten, das Umbrüche bewusst zu Leerzeichen
// fällt weg. Gegenstück zu statusEditTextToHtml(). Nötig, weil der Editor im // einebnet — hier werden sie gebraucht, weil der einzige Aufrufer die ERSTE
// Board seit der Kommentar-Überarbeitung ein normales <textarea> ist und kein // ZEILE will: openTodoAusKommentar() (js/wochenbericht/wochenbericht.js)
// contenteditable mehr (document.execCommand ist abgekündigt) — gespeichert // schlägt daraus den Titel einer neuen Aufgabe vor.
// wird aber weiterhin HTML, damit alle vorhandenen Anzeigepfade (Board- //
// Vorschau, Wochenbericht, PDF-Export, Show-Mode, Verlauf) unverändert // Hieß bis zur Umstellung auf contenteditable statusHtmlToEditText() und gab
// funktionieren und Altbestände lesbar bleiben. // Fett als **Sternchen**, Kursiv als *Sternchen* und Farbe als
// [[c:…]]…[[/c]] zurück — die Rückrichtung zum damaligen Markup-Editor.
// Seit der Editor echtes HTML führt (richtextWickeln() unten), hat dieses
// Markup keinen Leser mehr; übrig blieben Sternchen, die unvermittelt im
// vorgeschlagenen Aufgabentitel standen, obwohl sie nie jemand getippt hat.
// Deshalb liefert die Funktion jetzt schlichten Text.
// Parameter: html (String). Rückgabe: String. // Parameter: html (String). Rückgabe: String.
function statusHtmlToEditText(html){ function statusTextMitZeilen(html){
if(!html) return ''; if(!html) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.innerHTML = sanitizeStatusHtml(html); div.innerHTML = sanitizeStatusHtml(html);
// Fett-Auszeichnungen vor dem Auflösen in Sternchen überführen // Block-Grenzen zu Zeilenumbrüchen; alle Auszeichnungen fallen durch
div.querySelectorAll('b, strong').forEach(el => { // textContent ohnehin weg.
const inner = (el.textContent || '').trim();
el.replaceWith(document.createTextNode(inner ? '**' + inner + '**' : ''));
});
// Block-Grenzen zu Zeilenumbrüchen
div.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode('\n'))); div.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode('\n')));
div.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode('\n'))); div.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode('\n')));
return (div.textContent || '') return (div.textContent || '')
@@ -362,19 +391,192 @@ function statusHtmlToEditText(html){
.trim(); .trim();
} }
// statusEditTextToHtml: wandelt den Klartext aus dem Kommentar-Editor in das // ---------- Formatierbare Textfelder: contenteditable statt Markup-Zeichen ----------
// gespeicherte HTML. Escapt zuerst vollständig (esc()), sodass eingetippte //
// spitze Klammern niemals als Markup ankommen, und setzt danach die einzige // Erster Anlauf (Markup-Zeichen **fett**/*kursiv*/[[c:…]] in einem normalen
// erlaubte Auszeichnung **fett** sowie Zeilenumbrüche. Gegenstück zu // <textarea>, inzwischen vollständig entfernt) zeigte während des
// statusHtmlToEditText(). Leerer oder reiner Leerraum-Text ergibt '' — die // Tippens sichtbare Stern-/Klammer-Zeichen (**fett**, [[c:…]]) statt echter
// bisherige Bedeutung von "kein Kommentar" bleibt damit erhalten. // Formatierung — Eriks Rückmeldung: "statt den zwei Sternchen... den Text
// Parameter: text (String). Rückgabe: String (HTML). // richtig fett machen". Diese Felder sind jetzt contenteditable statt
function statusEditTextToHtml(text){ // <textarea>, und richtextWickeln()/richtextFett()/richtextKursiv()/
const clean = String(text || '').trim(); // richtextFarbe() wenden die Auszeichnung direkt auf die Markierung an — über
if(clean === '') return ''; // die Selection/Range-API (range.extractContents()/insertNode()), bewusst
return esc(clean) // KEIN document.execCommand (abgekündigt, derselbe Grund, der die vorige
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>') // contenteditable-Fassung dieser Felder schon einmal verworfen hatte — nur
.replace(/\n/g, '<br>'); // diesmal ohne execCommand). Der Nutzer sieht dadurch sofort echten fetten/
// kursiven/farbigen Text, keine Markup-Zeichen mehr.
// richtextToolbarHtml() liefert die dazugehörige, überall wiederverwendbare
// Werkzeugleiste; sie erwartet, dass das Zielfeld im selben .richtext-wrap
// steht (per closest()+querySelector() gefunden), damit mehrere Instanzen
// auf einer Seite (z. B. je Protokoll-Punkt) einander nicht in die Quere
// kommen. onmousedown mit preventDefault() verhindert, dass der Knopf-Klick
// dem Feld vorher den Fokus (und damit die Markierung) stiehlt.
function richtextWickeln(feld, tagName, styleAttr){
if(!feld) return;
const sel = window.getSelection();
if(!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
if(range.collapsed || !feld.contains(range.commonAncestorContainer)) return;
const wrapper = document.createElement(tagName);
if(styleAttr) wrapper.setAttribute('style', styleAttr);
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
// Markierung auf den neu eingefügten Inhalt setzen — ein zweiter Klick auf
// eine andere Auszeichnung wirkt dadurch sofort auf denselben Text weiter.
sel.removeAllRanges();
const neueRange = document.createRange();
neueRange.selectNodeContents(wrapper);
sel.addRange(neueRange);
feld.dispatchEvent(new Event('input', { bubbles: true }));
}
function richtextFett(feld){ richtextWickeln(feld, 'b'); }
function richtextKursiv(feld){ richtextWickeln(feld, 'i'); }
function richtextFarbe(feld, farbe){ richtextWickeln(feld, 'span', 'color:' + farbe); }
// richtextFeldHtml: liefert ein contenteditable-Feld mit Platzhaltertext
// (:empty:before + data-placeholder, siehe styles.css) als Ersatz für ein
// <textarea>. html MUSS bereits sanitizeStatusHtml()-bereinigt sein (niemals
// escapter Klartext — der Inhalt soll als Markup interpretiert werden, nicht
// als Zeichenkette). extraAttrs hängt aufruferspezifische Event-Handler an.
// editable=false (gesperrte Sitzung) setzt contenteditable="false" statt das
// Feld ganz durch reinen Text zu ersetzen — Auswahl/Kopieren bleibt möglich.
// Parameter: cssClass, html, placeholder, extraAttrs (Strings), editable
// (Boolean, optional, Default true). Rückgabe: HTML-String.
function richtextFeldHtml(cssClass, html, placeholder, extraAttrs, editable){
const bearbeitbar = editable === false ? 'false' : 'true';
return `<div class="${cssClass}" contenteditable="${bearbeitbar}" data-placeholder="${esc(placeholder || '')}" ${extraAttrs || ''}>${html || ''}</div>`;
}
// richtextCursorAnsEnde: setzt den Fokus in ein contenteditable-Feld und den
// Cursor ans Textende — das Gegenstück zu textarea.setSelectionRange(), das
// es für contenteditable nicht gibt. Parameter: feld (Element). Keine
// Rückgabe.
function richtextCursorAnsEnde(feld){
if(!feld) return;
feld.focus();
const range = document.createRange();
range.selectNodeContents(feld);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
// Einfügen bereinigen: Ohne diesen Lauscher übernimmt ein contenteditable-Feld
// beim Einfügen aus Word, einer Webseite oder einer Mail deren vollständige
// Formatierung — Schriftarten, Größen, Tabellen, Bilder. Sichtbar blieb das
// bis zum Speichern; erst dort lief sanitizeStatusHtml() und der Text sprang
// unvermittelt in eine andere Gestalt. Jetzt wird schon beim Einfügen auf
// dieselbe Positivliste reduziert, die auch gespeichert wird — was man sieht,
// ist, was bleibt.
//
// EIN Lauscher am Dokument für alle Felder (gleiche Begründung wie beim
// selectionchange-Lauscher in js/plan/plan-protokoll.js): Die Felder werden
// beim Rendern ständig neu erzeugt, ein Lauscher je Feld müsste bei jedem
// Neuaufbau erneut gesetzt werden.
document.addEventListener('paste', function(e){
const feld = e.target && e.target.closest ? e.target.closest('[contenteditable="true"]') : null;
if(!feld || !e.clipboardData) return;
e.preventDefault();
// text/html nur, wenn vorhanden — reiner Text wird escaped eingesetzt,
// damit darin enthaltene spitze Klammern Zeichen bleiben und nicht als
// Markup ankommen.
const html = e.clipboardData.getData('text/html');
const sauber = html
? sanitizeStatusHtml(html)
: esc(e.clipboardData.getData('text/plain')).replace(/\r\n|\r|\n/g, '<br>');
if(!sauber) return;
const sel = window.getSelection();
if(!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
range.deleteContents();
// Über ein <template> in Knoten verwandeln: dessen Inhalt liegt in einem
// eigenen, inerten Dokumentfragment. `sauber` ist zwar bereits geprüft,
// aber so bleibt auch dieser Schritt frei von Ressourcen-Ladevorgängen.
const vorlage = document.createElement('template');
vorlage.innerHTML = sauber;
const fragment = vorlage.content;
const letzter = fragment.lastChild;
range.insertNode(fragment);
// Schreibmarke hinter das Eingefügte setzen, sonst stünde sie davor.
if(letzter){
const nach = document.createRange();
nach.setStartAfter(letzter);
nach.collapse(true);
sel.removeAllRanges();
sel.addRange(nach);
}
feld.dispatchEvent(new Event('input', { bubbles: true }));
});
// Rückmeldung der Formatierungsleiste: Fett/Kursiv/Farbe wirken nur auf
// MARKIERTEN Text — ohne Markierung passiert beim Klick nichts, und zwar ohne
// jeden Hinweis warum. Die Leiste hebt sich deshalb sichtbar hervor, sobald im
// zugehörigen Feld etwas markiert ist, und trägt daneben einen Hinweis, der
// von "Erst Text markieren" auf die Bestätigung umspringt.
//
// EIN dokumentweiter Lauscher für JEDES Vorkommen von .richtext-wrap, nicht
// nur für den Protokoll-Schreibbereich (wo diese Funktion ursprünglich nur
// dort lebte): der Board-Kommentar nutzt dieselbe Leiste
// (richtextToolbarHtml(), renderCommentEditor() in js/board/board.js), zeigte
// die Markierung aber nie an — die Leiste blieb dort dauerhaft blass, egal ob
// Text markiert war. Anders als im Protokoll (immer höchstens EIN
// Schreibbereich) können im Projektboard mehrere Zeilen gleichzeitig
// aufgeklappt sein, jede mit eigenem Kommentarfeld — der Lauscher geht
// deshalb jedes .richtext-wrap einzeln durch statt nur eines anzunehmen.
document.addEventListener('selectionchange', function(){
const sel = window.getSelection();
const bereich = (sel && sel.rangeCount > 0 && !sel.isCollapsed) ? sel.getRangeAt(0) : null;
document.querySelectorAll('.richtext-wrap').forEach(wrap => {
const leiste = wrap.querySelector('.richtext-toolbar');
const feld = wrap.querySelector('[contenteditable="true"]');
if(!leiste || !feld) return;
const markiert = !!(bereich && feld.contains(bereich.commonAncestorContainer));
leiste.classList.toggle('hat-auswahl', markiert);
const hinweis = leiste.querySelector('.richtext-hinweis');
if(hinweis) hinweis.textContent = markiert ? '✓ Markiert — jetzt formatieren' : 'Erst Text markieren';
});
});
// RICHTEXT_FARBEN: die Palette der Farbknöpfe — bewusst dieselben Werte wie
// --red/--green/--accent/--amber (styles.css), damit eine im Text gewählte
// Farbe dieselbe Bedeutung trägt wie anderswo in der Anwendung (Rot = Achtung,
// Grün = erledigt/positiv, Blau = Hinweis, Orange = offen/Warnung).
const RICHTEXT_FARBEN = [
{ farbe: '#d70015', titel: 'Rot' },
{ farbe: '#248a3d', titel: 'Grün' },
{ farbe: '#0071e3', titel: 'Blau' },
{ farbe: '#b25000', titel: 'Orange' }
];
// richtextToolbarHtml: liefert die Werkzeugleiste (Fett/Kursiv/Farben) für ein
// contenteditable-Feld (richtextFeldHtml()), das im selben .richtext-wrap-
// Container steht. targetSelector wählt das Zielfeld innerhalb dieses
// Containers aus — jeder Aufrufer übergibt seine eigene Feldklasse (z. B.
// '.comment-field'), der Default greift nur, falls das je vergessen wird.
// Parameter: targetSelector (String, optional). Rückgabe: HTML-String.
function richtextToolbarHtml(targetSelector){
const sel = targetSelector || '[contenteditable]';
const ziel = `this.closest('.richtext-wrap').querySelector('${sel}')`;
const farbButtons = RICHTEXT_FARBEN.map(f =>
`<button type="button" class="richtext-farbe-btn" style="background:${f.farbe}" title="${f.titel}"
onmousedown="event.preventDefault()" onclick="richtextFarbe(${ziel}, '${f.farbe}')"></button>`).join('');
// .richtext-hinweis: Fett/Kursiv/Farbe wirken nur auf MARKIERTEN Text —
// ohne Markierung tut ein Klick nichts, und das war bisher nirgends
// ablesbar. Der Hinweis steht deshalb fest in der Leiste; wer ihn
// aktualisieren will, wenn tatsächlich etwas markiert ist, hängt sich an
// selectionchange (so macht es der Protokoll-Schreibbereich,
// js/plan/plan-protokoll.js).
return `<div class="richtext-toolbar">
<span class="richtext-btn-group">
<button type="button" class="richtext-btn" title="Fett" onmousedown="event.preventDefault()" onclick="richtextFett(${ziel})"><b>F</b></button>
<button type="button" class="richtext-btn" title="Kursiv" onmousedown="event.preventDefault()" onclick="richtextKursiv(${ziel})"><i>K</i></button>
</span>
<span class="richtext-divider"></span>
<span class="richtext-farben">${farbButtons}</span>
<span class="richtext-hinweis">Erst Text markieren</span>
</div>`;
} }
// -------- Projekt-Verlauf über mehrere Sitzungen -------- // -------- Projekt-Verlauf über mehrere Sitzungen --------
+836
View File
@@ -0,0 +1,836 @@
// -----------------------------
// Projektübersicht — Wandtafel (Touch-Dashboard)
// -----------------------------
// Modul für den Tab "Wandtafel" (App.html, Ladereihenfolge: ... → load →
// dashboard → wand → settings → ...). Ersetzt den früheren Show Mode: der
// klonte nur die gerade gerenderte Board- bzw. Auslastungs-Ansicht der EINEN
// angemeldeten Sitzung (kein eigener Datenpfad, keine Aggregation über
// Gewerke oder Nutzer hinweg). Gedacht für ein dauerhaft laufendes
// Touch-Display mit einem eigens dafür angelegten Kiosk-Konto — siehe
// CLAUDE.md "Wandtafel".
//
// Zweispaltig (Eriks Wahl "Variante C" aus dem Layout-Vergleich): links drei
// kompakte Kennzahl-Kacheln, rechts der Ereignis-Feed — seit einem zweiten,
// eigenen Vergleich (Feed-Vergleich) nach Art in drei Spalten gruppiert
// (Neu/Überfällig/Termine) statt chronologisch gemischt, siehe
// wandFeedHtml(). Die Kacheln selbst sind NICHT antippbar (kein Auf-/Zuklappen
// mehr, auf Eriks Wunsch) — stattdessen ist jedes einzelne darin dargestellte
// Element für sich antippbar (eine Gewerk-Zeile, eine Kennzahl, ein Kürzel,
// ein Feed-Eintrag) und führt in eine Vollbild-Übersicht bzw. direkt ins
// betroffene Projekt. Jede Kachel/jedes Ereignis ist eine reine Projektion
// bereits geladener Daten (dasselbe db aus GET /load-all, das jeder andere
// Tab auch nutzt) — keine eigene Server-Route, keine schreibenden Aktionen.
// Wiederverwendet werden berechneAuslastung()/loadAmpelColor()/
// kapazitaetsFaktor()/stundenFormat()/lpActiveList() aus js/load/load.js,
// classifyProjectChange()/findPreviousWeek()/springZumWochenbericht() aus
// js/wochenbericht/wochenbericht.js bzw. js/plan/plan-info.js,
// statusPreviewText()/renderBoardRow()/renderBoardDrawer()/BOARD_OPTS aus
// js/board/board.js, relativerTag()/tageBis()/initialsFor()/
// formatWeekDate() aus js/dashboard/dashboard.js bzw. js/utils.js,
// GROUP_ORDER/ROLES aus js/globals.js, sortGroups()/peopleByRole() aus
// js/utils.js, esc()/heuteIso() aus js/utils.js, currentWeek() aus
// js/weeks.js, load()/baseUrl() aus js/store.js/js/server.js,
// showLoginOverlay() aus js/auth.js, openProjectPlan() aus js/gantt.js
// (jeder Sprung ins Projekt verlässt die Wandtafel bewusst in den normalen
// Projektbereich, Seitenleiste und Kopfzeile kommen dabei zurück — wie bei
// jedem anderen Tab-Wechsel auch).
//
// Die Normalansicht ist höher als ein Bildschirm: unterhalb der zwei
// Kennzahl-Spalten folgt die vollständige Projektliste
// (wandProjektlisteHtml()) — auf Eriks ausdrücklichen Wunsch optisch
// identisch mit der echten Projektliste (Tab "Projekte"), nicht eine eigene,
// vereinfachte Zeilenform: Jede Gruppe/Zeile entsteht über denselben
// renderBoardRow()/BOARD_OPTS wie dort, nur mit locked FEST auf true (siehe
// wandProjektlisteHtml()) — die Wandtafel schreibt nie, also gibt es hier
// nie Ziehgriffe oder einen Löschen-Knopf, unabhängig vom echten Sperrstatus
// der Sitzung. Das Display ist ein echter Touch-Monitor — Erik kann
// davortreten und darin scrollen; ohne Berührung kehrt die Ansicht nach
// WAND_IDLE_MS von selbst an den Anfang zurück (wandAktivitaetErkannt()/
// wandBeiUntaetigkeitZuruecksetzen()), damit ein unbeaufsichtigtes Display
// nicht dauerhaft im Zustand der letzten vorbeigehenden Person hängen
// bleibt. Erik wollte diesen Rücksprung nicht nur erleben, sondern vorher
// sehen können: #wandIdleCountdown (neben "← Zur Übersicht", App.html) zeigt
// laufend die verbleibende Zeit (wandCountdownTick(), ein eigenes
// 1-Sekunden-Intervall, siehe startWandboard()/stopWandboard()).
// Dasselbe gilt auch AUSSERHALB der Wandtafel: Ein Klick auf eine
// Projekt-/Gewerk-Fläche verlässt sie ja absichtlich (Projektbereich bzw.
// Wochenbericht) — ein document-weiter, capturing Klick-Lauscher auf
// #wandContent setzt dabei wandUnterwegs, worüber dieselbe globale
// Aktivitätserkennung (wandAktivitaetErkannt()) statt der Untätigkeits- die
// Rückkehr-Uhr bedient; nach WAND_IDLE_MS ohne Berührung dort kehrt
// wandKehreZurueck() von selbst auf die Wandtafel zurück. Der bewusste
// Ausstieg über "← Zur Übersicht" (außerhalb von #wandContent) setzt
// wandUnterwegs NICHT — eine Vorschau durch Personal soll nicht nach einer
// Minute unterbrochen werden.
// WAND_GEWERKE: die drei Fachrichtungen, in dieser Reihenfolge überall auf
// der Wandtafel. Dieselben Werte wie VALID_DEPARTMENTS/project_gewerke.
const WAND_GEWERKE = ['ELT', 'HLS', 'ARCH'];
const WAND_REFRESH_MS = 60000; // Hintergrund-Aktualisierung
const WAND_FEED_MAX = 5; // Höchstzahl Einträge JE SPALTE im Ereignis-Feed (Neu/Überfällig/Termine)
// WAND_IDLE_MS: Erik hat einen echten Touch-Monitor — er darf davortreten und
// in der Projektliste unterhalb der Kacheln scrollen ("ich kann mich vor die
// Tafel stellen und scrollen"). Nach dieser Zeit ohne jede Berührung kehrt
// die Ansicht von selbst in den Normalzustand zurück (oben, keine offene
// Übersicht) — ein unbeaufsichtigtes Display darf nicht dauerhaft in einem
// Zustand hängen bleiben, den zufällig die letzte vorbeigehende Person
// hinterlassen hat.
const WAND_IDLE_MS = 60000;
let wandRefreshTimer = null;
let wandIdleTimer = null;
let wandCountdownTimer = null;
let wandIdleDeadline = 0; // Date.now()-Zeitpunkt, zu dem die Untätigkeits-Uhr auslöst
let wandRueckkehrTimer = null;
// wandUnterwegs: true zwischen einem Klick auf eine Projekt-/Gewerk-Fläche
// DER WANDTAFEL (gesetzt vom Klick-Lauscher auf #wandContent weiter unten)
// und der Rückkehr dorthin — nicht beim Verlassen über "← Zur Übersicht"
// (ein bewusster Ausstieg für eine Vorschau, kein versehentliches Antippen,
// und außerhalb von #wandContent). Nur während dieser Zeit treibt
// wandAktivitaetErkannt() die Rückkehr-Uhr statt der Untätigkeits-Uhr.
let wandUnterwegs = false;
let wandAktuelleWoche = null; // für Übersicht-Klicks gemerkt (re-render ohne erneutes Laden)
// wandUebersicht: null (zweispaltige Normalansicht) oder {typ, gewerk?} —
// welche Vollbild-Übersicht gerade anstelle der Normalansicht steht. gewerk
// ist nur bei typ 'auslastung'/'besprochen' gesetzt (eine Übersicht je
// Gewerk).
let wandUebersicht = null;
// startWandboard: startet die Hintergrund-Aktualisierung, falls nicht schon
// aktiv. Der Schon-aktiv-Schutz ist kein Komfort, sondern notwendig: load()
// ruft an seinem Ende selbst showTab(currentTab) auf — showTab('wand') riefe
// ohne diesen Schutz bei JEDEM eigenen 60s-Takt erneut startWandboard() auf
// und setzte damit sofort einen weiteren, ungeduldigen Takt in Gang (eine
// Rückkopplungsschleife, die praktisch ohne Pause neu lädt). Nach einer
// erneuten Anmeldung (stopWandboard() setzt wandRefreshTimer zuvor auf null,
// siehe wandRefreshTick()) greift der Schutz dagegen nicht und die
// Aktualisierung startet korrekt neu. Keine Parameter, keine Rückgabe.
function startWandboard(){
if(wandRefreshTimer) return;
// Ein frisches Betreten beginnt immer bei der normalen Ansicht, nie in
// einer Übersicht hängen — die läuft nur innerhalb EINES Aufenthalts auf
// der Wandtafel weiter (der 60s-Hintergrund-Takt ruft startWandboard()
// zwar erneut auf, der Schutz oben greift dann aber schon vorher, sodass
// dieser Reset NICHT bei jedem Takt läuft). Eine evtl. noch laufende
// Rückkehr-Uhr endet hier ebenfalls — sie ist erfüllt, sobald die
// Wandtafel wieder aktiv ist, egal ob durch sie selbst ausgelöst oder
// durch eine andere Navigation dorthin.
wandUebersicht = null;
wandUnterwegs = false;
clearTimeout(wandRueckkehrTimer); wandRueckkehrTimer = null;
wandRefreshTick();
wandRefreshTimer = setInterval(wandRefreshTick, WAND_REFRESH_MS);
wandAktivitaetErkannt(); // startet die Untätigkeits-Uhr für diesen Aufenthalt
wandCountdownTick();
wandCountdownTimer = setInterval(wandCountdownTick, 1000);
}
// stopWandboard: beendet den Hintergrund-Takt der Wandtafel. Wird von
// showTab() beim Verlassen des Tabs aufgerufen (NACH dem Entfernen von
// body.wandtafel-aktiv, siehe tabs-auth.js). Wenn das Verlassen durch einen
// Klick INNERHALB von #wandContent ausgelöst wurde (wandUnterwegs, siehe der
// document-weite Klick-Lauscher unten), wird hier sofort die Rückkehr-Uhr
// scharf gestellt — wandAktivitaetErkannt() nimmt wegen des bereits
// entfernten wandtafel-aktiv automatisch den passenden Zweig. Ein Verlassen
// OHNE vorherigen Klick (etwa stopWandboard() aus wandRefreshTick() bei
// abgelaufener Sitzung) lässt wandUnterwegs unberührt — dort gibt es nichts,
// wohin zurückzukehren wäre. Keine Parameter, keine Rückgabe.
function stopWandboard(){
clearInterval(wandRefreshTimer); wandRefreshTimer = null;
clearTimeout(wandIdleTimer); wandIdleTimer = null;
clearInterval(wandCountdownTimer); wandCountdownTimer = null;
const el = document.getElementById('wandIdleCountdown');
if(el) el.textContent = '';
if(wandUnterwegs) wandAktivitaetErkannt();
}
// wandAktivitaetErkannt: setzt je nach Lage entweder die Untätigkeits-Uhr
// (auf der Wandtafel selbst) oder die Rückkehr-Uhr (unterwegs, siehe
// wandUnterwegs) zurück. Horcht global auf Berührung/Maus/Tastatur/Scroll —
// so kann der Listener einmalig beim Laden des Skripts registriert werden,
// statt bei jedem Betreten/Verlassen an- und abgemeldet zu werden, und ist
// überall sonst (kein wandtafel-aktiv, nicht wandUnterwegs) ein reines
// No-op. Keine Parameter, keine Rückgabe.
function wandAktivitaetErkannt(){
if(document.body.classList.contains('wandtafel-aktiv')){
clearTimeout(wandIdleTimer);
wandIdleTimer = setTimeout(wandBeiUntaetigkeitZuruecksetzen, WAND_IDLE_MS);
wandIdleDeadline = Date.now() + WAND_IDLE_MS;
wandCountdownTick();
} else if(wandUnterwegs){
clearTimeout(wandRueckkehrTimer);
wandRueckkehrTimer = setTimeout(wandKehreZurueck, WAND_IDLE_MS);
}
}
// wandKehreZurueck: nach WAND_IDLE_MS ohne Berührung AUSSERHALB der
// Wandtafel — ausgelöst durch einen Klick auf eine ihrer eigenen
// Projekt-/Gewerk-Flächen (wandUnterwegs, siehe dort) — zurück zur
// Wandtafel. Erik: "auch wenn ich eine der Flächen/Projekte auf der Tafel
// anklicke soll nach 1 min das Bild wieder zurückspringen" — ein kurzer
// Blick ins Projekt darf das unbeaufsichtigte Display nicht dauerhaft
// woanders hinterlassen. Keine Parameter, keine Rückgabe.
function wandKehreZurueck(){
wandRueckkehrTimer = null;
showTab('wand');
}
// wandCountdownTick: schreibt die verbleibende Zeit bis zum automatischen
// Rücksprung in #wandIdleCountdown (Kopfzeile neben "← Zur Übersicht", siehe
// App.html) — Erik wollte sehen können, wann das passiert, statt vom Sprung
// überrascht zu werden. Läuft über ein eigenes 1-Sekunden-Intervall statt
// über wandIdleTimer selbst, da der nur EIN MAL (beim Auslösen) feuert.
// Keine Parameter, keine Rückgabe.
function wandCountdownTick(){
const el = document.getElementById('wandIdleCountdown');
if(!el) return;
const rest = Math.max(0, Math.ceil((wandIdleDeadline - Date.now()) / 1000));
el.textContent = rest > 0 ? ('Rücksprung in ' + rest + ' s') : '';
}
// wandBeiUntaetigkeitZuruecksetzen: nach WAND_IDLE_MS ohne Berührung zurück in
// den Normalzustand — eine offene Übersicht schließt sich, und die
// Projektliste scrollt an den Anfang zurück. Unbedingt (nicht nur wenn gerade
// eine Übersicht offen war): Auch ein bloßes Herunterscrollen in der
// Projektliste soll sich nach einer Minute von selbst zurücksetzen.
// Startet die Uhr am Ende selbst neu (wandAktivitaetErkannt()), statt sie
// auslaufen zu lassen — die Anzeige in #wandIdleCountdown soll durchgehend
// laufen, nicht nach dem ersten Rücksprung stehen bleiben, solange niemand
// die Tafel berührt. Keine Parameter, keine Rückgabe.
function wandBeiUntaetigkeitZuruecksetzen(){
wandUebersicht = null;
renderWandboard({ resetScroll: true });
wandAktivitaetErkannt();
}
['pointerdown', 'touchstart', 'wheel', 'keydown'].forEach(evt =>
document.addEventListener(evt, wandAktivitaetErkannt, { passive: true }));
// 'scroll' bubbelt bei Elementen (anders als bei window/document) nicht —
// ein Listener in der CAPTURING-Phase an document bekommt ihn trotzdem mit,
// da die Capturing-Phase unabhängig vom Bubbling jedes Element auf dem Weg
// zum Ziel durchläuft.
document.addEventListener('scroll', wandAktivitaetErkannt, { passive: true, capture: true });
// Jeder Klick INNERHALB von #wandContent markiert "unterwegs" — unabhängig
// davon, über welche konkrete Funktion er die Wandtafel verlässt
// (openProjectPlan() direkt aus renderBoardRow() in der Projektliste,
// springZumWochenbericht() aus der Besprochen-Übersicht, wandSpringeZuProjekt()
// aus den übrigen Übersichten/dem Feed). Ein einzelner, allgemeiner Lauscher
// statt eigener Wrapper um jede dieser Navigationsfunktionen: Mehrere davon
// werden auch von ganz anderen, nicht-Wandtafel-Stellen der App aufgerufen
// (board.js, plan-info.js) und dürfen dort keine Rückkehr auslösen — hier
// entscheidet allein, WO der Klick stattfand, nicht WELCHE Funktion er
// aufruft. Läuft in der CAPTURING-Phase: Mehrere Klick-Handler in der
// Projektliste rufen event.stopPropagation() auf (z. B. der Projektname in
// renderBoardRow()), was einen Lauscher in der normalen Bubbling-Phase an
// #wandContent gar nicht erst erreichen würde — die Capturing-Phase läuft
// davon unabhängig schon auf dem Weg zum eigentlichen Ziel. Das bewusste
// Verlassen über "← Zur Übersicht" (außerhalb von #wandContent, siehe
// App.html) setzt wandUnterwegs ABSICHTLICH NICHT — eine Vorschau durch
// Personal soll nicht nach einer Minute unterbrochen werden. Die eigentliche
// Uhr wird erst in stopWandboard() scharf gestellt, sobald die Navigation
// tatsächlich stattgefunden hat.
document.addEventListener('click', function(ev){
const wandContent = document.getElementById('wandContent');
if(wandContent && wandContent.contains(ev.target)) wandUnterwegs = true;
}, { capture: true });
// wandRefreshTick: der äußere Hintergrund-Takt. Prüft zuerst GET /whoami statt
// blind load() aufzurufen: Ist die Sitzung abgelaufen, zeigt er das
// bestehende Login-Overlay an Ort und Stelle (dasselbe Formular ist seit dem
// ersten Seitenaufruf schon verdrahtet, siehe checkAuthAndInit(),
// js/auth.js) — eine erneute Anmeldung setzt die Wandtafel über denselben
// onAuthenticated()-Weg fort, ohne dass die Seite neu geladen werden muss.
// Keine Parameter, keine Rückgabe.
async function wandRefreshTick(){
let data;
try {
const res = await fetch(baseUrl() + '/whoami');
data = await res.json();
} catch(err){
console.error('Wandtafel: Anmeldestatus konnte nicht geprüft werden:', err);
return; // nächster Takt versucht es erneut
}
if(!data || !data.user){
stopWandboard();
showLoginOverlay();
return;
}
load(true); // silent — ruft am Ende renderAll()+showTab(currentTab) auf
}
// renderWandboard: baut entweder die zweispaltige Normalansicht (mit der
// vollständigen Projektliste darunter) oder eine offene Vollbild-Übersicht
// auf. Wird von showTab('wand') sowie nach jedem load() (über
// showTab(currentTab)) aufgerufen.
//
// Die Normalansicht ist höher als ein Bildschirm — Erik hat einen echten
// Touch-Monitor und wollte "unterhalb der Wandtafel... weitere Informationen
// verstauen, z. B. die gesamte Projektliste". Nur dieser Teil scrollt
// (`.wand-scrollable` auf #wandContent); eine offene Übersicht bleibt wie
// zuvor genau einen Bildschirm hoch mit ihrer eigenen inneren Liste. Der
// Scroll-Stand bleibt über den 60-Sekunden-Hintergrund-Takt hinweg erhalten
// (sonst risse ein laufender Refresh jemanden, der gerade in der Liste
// scrollt, zurück an den Anfang) — nur wandBeiUntaetigkeitZuruecksetzen()
// verlangt ausdrücklich resetScroll:true. Parameter: opts.resetScroll
// (optional). Keine Rückgabe.
function renderWandboard(opts){
const resetScroll = !!(opts && opts.resetScroll);
const container = document.getElementById('wandContent');
if(!container) return;
const w = currentWeek();
if(!w){
container.innerHTML = '<div class="hint">Keine Sitzung vorhanden.</div>';
return;
}
wandAktuelleWoche = w;
const vorherigerScroll = resetScroll ? 0 : container.scrollTop;
if(wandUebersicht){
container.classList.remove('wand-scrollable');
container.innerHTML = wandUebersichtHtml(w);
return;
}
container.classList.add('wand-scrollable');
container.innerHTML = '<div class="wand-ambient"><div class="wand-c-layout">'
+ '<div class="wand-c-links">' + wandLinkeSpalteHtml(w) + '</div>'
+ '<div class="wand-c-rechts">'
+ '<h3 class="wand-kachel-titel">Aktuell diese Woche</h3>'
+ '<div class="wand-feed-wrap">' + wandFeedHtml(w) + '</div>'
+ renderWandStand()
+ '</div>'
+ '</div></div>'
+ wandProjektlisteHtml(w);
container.scrollTop = vorherigerScroll;
}
// wandProjektlisteHtml: die vollständige Projektliste unterhalb der
// Kennzahl-Kacheln — auf Eriks ausdrücklichen Wunsch ("Die Projektliste soll
// so aussehen wie die Originale") keine eigene, vereinfachte Darstellung,
// sondern dieselbe Zeile wie im echten Tab "Projekte": gleiche Gruppen
// (GROUP_ORDER/sortGroups()), gleiche Sortierung (p.order) und jede Zeile
// über denselben renderBoardRow()+BOARD_OPTS wie renderBoard() selbst
// (js/board/board.js) — Nr., Name, Auftragsstatus-Kennzeichen, Status-
// Vorschau, Avatar-Stapel und Phasen-Badge, aufklappbar zum selben
// Detailpanel (renderBoardDrawer()). Anders als die Auslastung/Besprochen-
// Kacheln zählt hier bewusst auch "In Anbahnung" mit, da es um
// Vollständigkeit geht, nicht um bewertbare Arbeitslast.
//
// `locked` wird FEST auf true übergeben, unabhängig vom tatsächlichen
// Sperrstatus der Sitzung: Die Wandtafel schreibt nie (siehe Dateikopf), und
// renderBoardRow()/renderBoardDrawer() blenden bei locked=true genau die
// Bedienelemente aus, die einen Schreibzugriff auslösen würden (Ziehgriff,
// "Löschen", editierbarer Kommentar) — ein Projekt antippen bleibt möglich
// (öffnet/schließt die Zeile, "Öffnen" springt ins Projekt), ein Kiosk-Konto
// bekäme auf jeden Schreibversuch ohnehin ein serverseitiges 403.
// toggleBoardRow() (board.js) ruft nach jedem Auf-/Zuklappen zusätzlich
// renderWandboard() auf, damit eine hier aufgeklappte Zeile sofort sichtbar
// wird, genau wie es das für renderBoard()/renderWochenbericht() schon tut.
// Parameter: w. Rückgabe: HTML-String.
function wandProjektlisteHtml(w){
const locked = true;
const grouped = peopleByRole(w);
const allPeople = [];
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
const groups = {};
GROUP_ORDER.forEach(g => { groups[g] = []; });
(Array.isArray(w.projects) ? 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)));
const abschnitte = sortGroups(Object.keys(groups)).map(g => {
if(groups[g].length === 0) return '';
const zeilen = groups[g].map(p => renderBoardRow(p, allPeople, locked, w.label, BOARD_OPTS)).join('');
return '<div class="section-title">' + esc(g) + '</div><div class="board-list">' + zeilen + '</div>';
}).join('');
return '<div class="wand-projektliste">'
+ '<h3 class="wand-kachel-titel">Alle Projekte</h3>'
+ abschnitte
+ '</div>';
}
// wandLinkeSpalteHtml: die drei Kennzahl-Kacheln (nicht mehr antippbar —
// nur die Elemente darin sind es). Parameter: w. Rückgabe: HTML-String.
function wandLinkeSpalteHtml(w){
return wandTileAuslastung(w) + wandTileBesprochen(w) + wandTileHeute(w);
}
// wandKachel: die gemeinsame, nicht-interaktive Kachel-Umrahmung. Parameter:
// titel, inhalt (HTML). Rückgabe: HTML-String.
function wandKachel(titel, inhalt){
return '<section class="wand-kachel">'
+ '<h3 class="wand-kachel-titel">' + esc(titel) + '</h3>'
+ '<div class="wand-kachel-body">' + inhalt + '</div>'
+ '</section>';
}
// wandSpringeZuProjekt: verlässt die Wandtafel und öffnet den Projektbereich
// eines Projekts, optional direkt in einem bestimmten Abschnitt — derselbe
// Weg (Hash VOR dem Tab-Wechsel setzen, showTab('plan') übernimmt ihn über
// updateRouteUrl()) wie die Termin-Links im Dashboard
// (js/dashboard/dashboard.js), nur als unmittelbarer Bereichswechsel statt
// eines neuen Tabs — die Wandtafel hat nur die eine Ansicht. Der Hash wird
// IMMER gesetzt (auch leer->'dashboard'), damit ein von einem früheren Sprung
// übrig gebliebener Hash nicht fälschlich übernommen wird. (Die Rückkehr zur
// Wandtafel nach einer Minute Untätigkeit — wandUnterwegs — wird nicht hier,
// sondern generisch über den Klick-Lauscher auf #wandContent ausgelöst, da
// die Projektliste über renderBoardRow() auch ganz ohne diese Funktion direkt
// openProjectPlan() aufruft; siehe dort.) Parameter: pid (Projekt-ID),
// abschnitt (Bereichs-Schlüssel, z. B. 'aufgaben'). Keine Rückgabe.
function wandSpringeZuProjekt(pid, abschnitt){
location.hash = abschnitt || 'dashboard';
openProjectPlan(pid);
}
// ---------- Vollbild-Übersichten (gemeinsamer Rahmen) ----------
// wandUebersichtRahmen: Kopfzeile (Titel + Schließen) plus Inhalt — von
// jeder Übersicht genutzt. Parameter: titel, inhalt (HTML). Rückgabe:
// HTML-String.
function wandUebersichtRahmen(titel, inhalt){
return '<div class="wand-uebersicht">'
+ '<div class="wand-uebersicht-kopf"><h2>' + esc(titel) + '</h2>'
+ '<button type="button" class="secondary small" onclick="wandSchliesseUebersicht()">← Zurück zur Wandtafel</button></div>'
+ inhalt
+ '</div>';
}
// wandUebersichtZeile: eine Zeile im gemeinsamen Dreispalten-Format
// (links/mitte/rechts). onclick ist ein fertiger JS-Ausdruck als String oder
// null für eine nicht anklickbare Zeile (z. B. eine Abwesenheit, die auf kein
// Projekt verweist). Parameter: links, mitte, rechts (bereits escapte
// HTML-Strings), onclick, rechtsKlasse (optional, z. B. 'ist-rot').
// Rückgabe: HTML-String.
function wandUebersichtZeile(links, mitte, rechts, onclick, rechtsKlasse){
return '<div class="wand-uebersicht-zeile' + (onclick ? ' ist-klickbar' : '') + '"'
+ (onclick ? ' onclick="' + onclick + '"' : '') + '>'
+ '<span class="wand-uebersicht-projekt">' + links + '</span>'
+ '<span class="wand-uebersicht-titel">' + mitte + '</span>'
+ '<span class="wand-uebersicht-seit' + (rechtsKlasse ? ' ' + rechtsKlasse : '') + '">' + rechts + '</span>'
+ '</div>';
}
// wandZeigeUebersicht: öffnet eine Vollbild-Übersicht anstelle der
// zweispaltigen Ansicht. Parameter: typ (String), gewerk (optional, nur für
// 'auslastung'/'besprochen'). Keine Rückgabe.
function wandZeigeUebersicht(typ, gewerk){
wandUebersicht = { typ, gewerk: gewerk || null };
renderWandboard();
}
// wandSchliesseUebersicht: zurück zur zweispaltigen Ansicht. Keine
// Parameter, keine Rückgabe.
function wandSchliesseUebersicht(){
wandUebersicht = null;
renderWandboard();
}
// wandUebersichtHtml: baut die gerade angeforderte Übersicht. Parameter: w.
// Rückgabe: HTML-String.
function wandUebersichtHtml(w){
const u = wandUebersicht;
if(u.typ === 'ueberfaellig') return wandUebersichtUeberfaellig(w);
if(u.typ === 'offen') return wandUebersichtOffeneAufgaben(w);
if(u.typ === 'abwesend') return wandUebersichtAbwesend(w);
if(u.typ === 'auslastung') return wandUebersichtAuslastung(w, u.gewerk);
if(u.typ === 'besprochen') return wandUebersichtBesprochen(w, u.gewerk);
return '';
}
// ---------- Kachel 1: Auslastung je Gewerk ----------
// wandAuslastungJeGewerk: Wochenstunden, Kapazität UND die beitragenden
// Projekte je Gewerk der aktuellen Sitzung. Rechnet mit denselben Funktionen
// wie renderLoad() (js/load/load.js), nur nach Gewerk statt nach Person
// summiert — dieselbe Stelle im Projekt-Durchlauf, an der auch renderLoad()
// aggregiert. Die Projektliste je Gewerk ist der Rohstoff für die
// Gewerk-Übersicht (wandUebersichtAuslastung()).
//
// Ein Projekt mit MEHREREN Gewerken zählt seinen vollen Beitrag bei JEDEM
// seiner Gewerke — eine bewusste Dopplung, keine Aufteilung: Es gibt keine
// Regel, wie die Stunden zwischen den Gewerken aufzuteilen wären, und eine
// erfundene Aufteilung wäre irreführender als eine offen sichtbare Dopplung.
// Parameter: w (Wochen-Objekt). Rückgabe: { GEWERK: {stunden, kapazitaet,
// bewertbar, gesamt, projekte:[{id,name,nr,stunden}]} }.
function wandAuslastungJeGewerk(w){
const ergebnis = {};
WAND_GEWERKE.forEach(g => { ergebnis[g] = { stunden: 0, kapazitaet: 0, bewertbar: 0, gesamt: 0, projekte: [] }; });
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
(Array.isArray(w.people) ? w.people : []).forEach(p => {
const global = (db.globalPeople || []).find(x => x.id === p.id)
|| (p.fullName ? (db.globalPeople || []).find(x => x.fullName === p.fullName) : null);
const gewerk = global && global.gewerk;
if(!gewerk || !ergebnis[gewerk]) return;
ergebnis[gewerk].kapazitaet += kapazitaetsFaktor(p) * vollzeit;
});
(Array.isArray(w.projects) ? w.projects : []).forEach(pr => {
if(pr.group === 'In Anbahnung') return;
const gewerke = (Array.isArray(pr.gewerke) ? pr.gewerke : []).filter(g => ergebnis[g]);
if(gewerke.length === 0) return;
gewerke.forEach(g => { ergebnis[g].gesamt++; });
const aktivePhase = lpActiveList(pr)[0];
const probe = berechneAuslastung(null, pr, aktivePhase, ROLES[0], 1, null);
if(!probe.unbewertbar) gewerke.forEach(g => { ergebnis[g].bewertbar++; });
let projektStunden = 0;
Object.keys(pr.assign || {}).forEach(aKey => {
const rolle = aKey.slice(aKey.indexOf('|') + 1);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, pr.assign[aKey], pr.assign);
if(al.stunden > 0) projektStunden += al.stunden;
});
gewerke.forEach(g => {
ergebnis[g].stunden += projektStunden;
if(projektStunden > 0) ergebnis[g].projekte.push({ id: pr.id, name: pr.name, nr: pr.nr, stunden: projektStunden });
});
});
return ergebnis;
}
// wandTileAuslastung: ein Meter je Gewerk, Ampelfarbe wie im Auslastungs-Tab.
// Jede Zeile ist einzeln antippbar und öffnet die Projektliste dieses
// Gewerks (wandZeigeUebersicht('auslastung', g)). Eine Null bekommt KEINE
// grüne Farbe (dieselbe Regel wie in renderLoad()) — "keine Daten" statt
// eines irreführend guten Balkens. Parameter: w. Rückgabe: HTML-String.
function wandTileAuslastung(w){
const daten = wandAuslastungJeGewerk(w);
const inhalt = WAND_GEWERKE.map(g => {
const d = daten[g];
const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g;
const klick = ' onclick="wandZeigeUebersicht(\'auslastung\',\'' + g + '\')"';
if(d.bewertbar === 0 || d.kapazitaet <= 0){
return '<div class="wand-meter-zeile ist-klickbar"' + klick + '>'
+ '<span class="wand-meter-label">' + esc(label) + '</span>'
+ '<div class="wand-meter"><span class="wand-meter-leer">keine Daten</span></div>'
+ '<span class="wand-meter-wert">—</span></div>';
}
const quote = d.stunden / d.kapazitaet;
const ampel = loadAmpelColor(quote);
const breite = Math.min(100, quote * 100);
return '<div class="wand-meter-zeile ist-klickbar"' + klick + '>'
+ '<span class="wand-meter-label">' + esc(label) + '</span>'
+ '<div class="wand-meter"><div class="wand-meter-fill" style="width:' + breite + '%;background:' + ampel + '"></div></div>'
+ '<span class="wand-meter-wert" style="color:' + ampel + '">' + stundenFormat(d.stunden) + '<span class="wand-einheit">h</span></span></div>';
}).join('');
return wandKachel('Auslastung je Gewerk', inhalt);
}
// wandUebersichtAuslastung: alle Projekte, die zu einem Gewerk beitragen,
// nach Stunden absteigend — Antippen springt ins Projekt (Abschnitt
// "Leistung", der dort ohnehin schon errechnete Honorar/Fortschritt-Bezug
// passt inhaltlich am ehesten zur Auslastungs-Frage). Parameter: w, gewerk.
// Rückgabe: HTML-String.
function wandUebersichtAuslastung(w, gewerk){
const daten = wandAuslastungJeGewerk(w)[gewerk];
const label = (DEPARTMENTS[gewerk] && DEPARTMENTS[gewerk].label) || gewerk;
if(!daten || daten.projekte.length === 0){
return wandUebersichtRahmen('Auslastung ' + label, '<div class="wand-leer">Keine Projekte mit Stunden in diesem Gewerk.</div>');
}
const sortiert = daten.projekte.slice().sort((a, b) => b.stunden - a.stunden);
const zeilen = sortiert.map(p =>
wandUebersichtZeile(esc(p.nr || ''), esc(p.name), stundenFormat(p.stunden) + ' h',
'wandSpringeZuProjekt(\'' + esc(p.id) + '\',\'leistung\')')
).join('');
return wandUebersichtRahmen('Auslastung ' + label, '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// ---------- Kachel 2: Wochenbericht besprochen ----------
// wandBesprochenDaten: "X von Y besprochen" je Gewerk plus die noch NICHT
// besprochenen Projekte (Rohstoff der Gewerk-Übersicht). "In Anbahnung"
// zählt nicht mit (dieselbe Ausnahme wie in renderLoad()). Parameter: w.
// Rückgabe: { GEWERK: {besprochen, gesamt, offeneProjekte:
// [{id,uid,name,nr}]} }.
function wandBesprochenDaten(w){
const ergebnis = {};
WAND_GEWERKE.forEach(g => {
const relevante = (w.projects || []).filter(p =>
Array.isArray(p.gewerke) && p.gewerke.includes(g) && p.group !== 'In Anbahnung');
const offeneProjekte = relevante.filter(p => !p.besprochen);
ergebnis[g] = {
besprochen: relevante.length - offeneProjekte.length,
gesamt: relevante.length,
offeneProjekte: offeneProjekte.map(p => ({ id: p.id, uid: p.projectUid, name: p.name, nr: p.nr })),
};
});
return ergebnis;
}
// wandTileBesprochen: ein Meter je Gewerk ("X von Y"). Jede Zeile ist
// einzeln antippbar und öffnet die Liste der noch offenen Projekte dieses
// Gewerks (wandZeigeUebersicht('besprochen', g)). Parameter: w. Rückgabe:
// HTML-String.
function wandTileBesprochen(w){
const daten = wandBesprochenDaten(w);
const inhalt = WAND_GEWERKE.map(g => {
const d = daten[g];
const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g;
if(d.gesamt === 0){
return '<div class="wand-meter-zeile"><span class="wand-meter-label">' + esc(label) + '</span>'
+ '<span class="wand-meter-leer">keine Projekte</span></div>';
}
const quote = d.besprochen / d.gesamt;
const klick = ' onclick="wandZeigeUebersicht(\'besprochen\',\'' + g + '\')"';
return '<div class="wand-meter-zeile ist-klickbar"' + klick + '>'
+ '<span class="wand-meter-label">' + esc(label) + '</span>'
+ '<div class="wand-meter"><div class="wand-meter-fill" style="width:' + (quote * 100) + '%;background:var(--accent)"></div></div>'
+ '<span class="wand-meter-wert">' + d.besprochen + ' von ' + d.gesamt + '</span></div>';
}).join('');
return wandKachel('Wochenbericht besprochen', inhalt);
}
// wandUebersichtBesprochen: die noch nicht besprochenen Projekte eines
// Gewerks — Antippen springt NICHT in den Projektbereich, sondern in den
// Wochenbericht (springZumWochenbericht(), js/plan/plan-info.js): Der
// Kommentar/besprochen-Haken wird ausschließlich dort gepflegt ("Ein Ort je
// Angabe", CLAUDE.md), ein Sprung ins Projekt selbst würde nur auf den
// Verweis-Hinweis dorthin treffen. Parameter: w, gewerk. Rückgabe:
// HTML-String.
function wandUebersichtBesprochen(w, gewerk){
const daten = wandBesprochenDaten(w)[gewerk];
const label = (DEPARTMENTS[gewerk] && DEPARTMENTS[gewerk].label) || gewerk;
if(!daten || daten.offeneProjekte.length === 0){
return wandUebersichtRahmen('Noch offen — ' + label, '<div class="wand-leer">Alle Projekte dieses Gewerks sind besprochen.</div>');
}
const zeilen = daten.offeneProjekte.map(p =>
wandUebersichtZeile(esc(p.nr || ''), esc(p.name), 'noch offen',
p.uid ? 'springZumWochenbericht(\'' + esc(p.uid) + '\')' : null)
).join('');
return wandUebersichtRahmen('Noch offen — ' + label, '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// ---------- Kachel 3: Heute (Aufgaben + Abwesenheiten) ----------
// wandHeuteDaten: offene/überfällige Aufgaben UND die heute abwesenden
// Personen in einer Kachel — beides beantwortet dieselbe Frage ("was ist
// heute los"), getrennte Kacheln dafür wären zwei dünne Kacheln nebeneinander
// gewesen. Unternehmensweit über plan.todos ALLER Projekte, anders als GET
// /my-dashboard (nur die eigenen Aufgaben); alle Personen, nicht "außer mir"
// wie bei /my-dashboard — auf der Wandtafel gibt es kein "mich".
// Parameter: w. Rückgabe: {offen, offeneListe:[{titel,projekt,pid,datum}],
// ueberfaelligeListe:[{titel,projekt,pid,datum}],
// abwesend:[{name,typ,bis}]}.
function wandHeuteDaten(w){
const heute = heuteIso();
const offeneListe = [];
const ueberfaelligeListe = [];
(w.projects || []).forEach(pr => {
const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : [];
todos.forEach(t => {
if(t.done) return;
const eintrag = {
titel: t.title || 'Aufgabe',
projekt: (pr.nr ? pr.nr + ' ' : '') + pr.name,
datum: t.end || '',
pid: pr.id,
};
offeneListe.push(eintrag);
if(t.end && t.end < heute) ueberfaelligeListe.push(eintrag);
});
});
// Überfällig: am längsten überfällig zuerst. Offen: nächste Frist zuerst,
// Aufgaben ohne Termin ans Ende (dieselbe Reihenfolge wie "Meine Aufgaben"
// im Dashboard).
ueberfaelligeListe.sort((a, b) => a.datum.localeCompare(b.datum));
offeneListe.sort((a, b) => (a.datum || '9999') < (b.datum || '9999') ? -1 : (a.datum || '9999') > (b.datum || '9999') ? 1 : 0);
const abwesend = (db.globalPeople || []).filter(p =>
Array.isArray(p.absences) && p.absences.some(a => a.start <= heute && heute <= a.end)
).map(p => {
const a = p.absences.find(a => a.start <= heute && heute <= a.end);
return { name: p.fullName || p.name, typ: (a && a.type) || 'Abwesend', bis: a && a.end };
});
return { offen: offeneListe.length, offeneListe, ueberfaelligeListe, abwesend };
}
// wandTileHeute: zwei Kennzahlen (offen/überfällig) plus Abwesenheits-Kürzel
// — jede einzeln antippbar, kein Auf-/Zuklappen der Kachel mehr. Parameter:
// w. Rückgabe: HTML-String.
function wandTileHeute(w){
const d = wandHeuteDaten(w);
const offenStat = '<div class="wand-stat ist-klickbar" onclick="wandZeigeUebersicht(\'offen\')" title="Alle offenen Aufgaben anzeigen">'
+ '<b>' + d.offen + '</b><span>offene Aufgaben</span></div>';
const ueberfaelligStat = '<div class="wand-stat' + (d.ueberfaelligeListe.length > 0 ? ' ist-rot' : '') + ' ist-klickbar"'
+ ' onclick="wandZeigeUebersicht(\'ueberfaellig\')" title="Alle überfälligen Aufgaben anzeigen">'
+ '<b>' + d.ueberfaelligeListe.length + '</b><span>überfällig</span></div>';
const namenHtml = d.abwesend.length
? '<div class="wand-namen">' + d.abwesend.map(p =>
'<span class="wand-avatar ist-klickbar" title="' + esc(p.name) + ' — Alle Abwesenheiten anzeigen" onclick="wandZeigeUebersicht(\'abwesend\')">'
+ esc(initialsFor(p.name)) + '</span>').join('') + '</div>'
: '<div class="wand-leer-klein">niemand abwesend</div>';
const inhalt = '<div class="wand-stat-reihe">' + offenStat + ueberfaelligStat + '</div>' + namenHtml;
return wandKachel('Heute', inhalt);
}
// wandUebersichtOffeneAufgaben: ALLE offenen Aufgaben (nicht nur
// überfällige), nächste Frist zuerst. Parameter: w. Rückgabe: HTML-String.
function wandUebersichtOffeneAufgaben(w){
const d = wandHeuteDaten(w);
if(d.offeneListe.length === 0){
return wandUebersichtRahmen('Offene Aufgaben', '<div class="wand-leer">Keine offenen Aufgaben.</div>');
}
const zeilen = d.offeneListe.map(t =>
wandUebersichtZeile(esc(t.projekt), '„' + esc(t.titel) + '"', t.datum ? esc(relativerTag(t.datum)) : 'ohne Termin',
'wandSpringeZuProjekt(\'' + esc(t.pid) + '\',\'aufgaben\')')
).join('');
return wandUebersichtRahmen('Offene Aufgaben (' + d.offeneListe.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// wandUebersichtUeberfaellig: alle überfälligen Aufgaben, am längsten
// überfällig zuerst — jede Zeile springt ins Projekt, Abschnitt "Aufgaben".
// Parameter: w. Rückgabe: HTML-String.
function wandUebersichtUeberfaellig(w){
const d = wandHeuteDaten(w);
if(d.ueberfaelligeListe.length === 0){
return wandUebersichtRahmen('Überfällige Aufgaben', '<div class="wand-leer">Keine überfälligen Aufgaben.</div>');
}
const zeilen = d.ueberfaelligeListe.map(t =>
wandUebersichtZeile(esc(t.projekt), '„' + esc(t.titel) + '"', esc(relativerTag(t.datum)),
'wandSpringeZuProjekt(\'' + esc(t.pid) + '\',\'aufgaben\')', 'ist-rot')
).join('');
return wandUebersichtRahmen('Überfällige Aufgaben (' + d.ueberfaelligeListe.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// wandUebersichtAbwesend: alle heute abwesenden Personen mit Art und bis
// wann — nicht anklickbar (eine Abwesenheit gehört zu keinem Projekt, in das
// man springen könnte). Parameter: w. Rückgabe: HTML-String.
function wandUebersichtAbwesend(w){
const d = wandHeuteDaten(w);
if(d.abwesend.length === 0){
return wandUebersichtRahmen('Abwesend heute', '<div class="wand-leer">Niemand abwesend.</div>');
}
const zeilen = d.abwesend.map(p =>
wandUebersichtZeile(esc(p.name), esc(p.typ), p.bis ? 'bis ' + esc(formatWeekDate(p.bis)) : '', null)
).join('');
return wandUebersichtRahmen('Abwesend heute (' + d.abwesend.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// ---------- Ereignis-Feed (rechte Spalte) ----------
// wandAktuelleEreignisse: sammelt drei Ereignisarten aus allen Projekten der
// Sitzung — neue/aktualisierte Kommentare (classifyProjectChange()), frisch
// überfällige Aufgaben und anstehende Meilensteine — als EINE Liste, nach
// zeitlicher Nähe zu heute sortiert (|Tage bis Datum| aufsteigend, nicht nach
// Datum selbst: Ein Ereignis von gestern und eines von morgen sind beide
// "gerade jetzt relevant", eines von vor zwei Wochen oder in zwei Monaten ist
// es nicht — unabhängig davon, ob es in der Vergangenheit oder Zukunft
// liegt). wandFeedHtml() gruppiert diese EINE Liste anschließend nach `art`
// in drei Spalten (Variante C aus dem Feed-Vergleich) — die Sortierung
// bleibt deshalb unverändert, sie gilt nur jetzt INNERHALB jeder Spalte statt
// über alle Arten hinweg. Jeder Eintrag trägt projekt/beschreibung getrennt
// (statt einem fertigen Text) sowie pid/abschnitt für den Sprung ins
// Projekt. Parameter: w. Rückgabe: Array aus {art, projekt (HTML, bereits
// escaped), beschreibung (HTML, bereits escaped), zeit, pid, abschnitt}.
function wandAktuelleEreignisse(w){
const heute = heuteIso();
const prevWeek = (typeof findPreviousWeek === 'function') ? findPreviousWeek(w) : null;
const ereignisse = [];
(Array.isArray(w.projects) ? w.projects : []).forEach(pr => {
if(pr.group === 'In Anbahnung') return;
const projekt = esc(pr.nr || '') + (pr.nr ? ' ' : '') + esc(pr.name || '');
if(typeof classifyProjectChange === 'function'){
const klass = classifyProjectChange(pr, prevWeek);
if(klass.bucket === 'status' || klass.bucket === 'new'){
const vorschauRoh = pr.status ? statusPreviewText(pr.status) : '';
const vorschau = vorschauRoh.slice(0, 70);
ereignisse.push({
art: 'neu',
datum: pr.statusAt || heute,
pid: pr.id, abschnitt: 'dashboard', projekt,
beschreibung: (klass.bucket === 'new' ? 'Neues Projekt' : 'Neuer Kommentar')
+ (vorschau ? ': „' + esc(vorschau) + (vorschauRoh.length > 70 ? '…' : '') + '"' : ''),
});
}
}
const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : [];
todos.forEach(t => {
if(t.done || !t.end || t.end >= heute) return;
ereignisse.push({
art: 'ueberfaellig',
datum: t.end,
pid: pr.id, abschnitt: 'aufgaben', projekt,
beschreibung: '„' + esc(t.title || 'Aufgabe') + '" überfällig',
});
});
const meilensteine = (pr.plan && Array.isArray(pr.plan.milestones)) ? pr.plan.milestones : [];
meilensteine.forEach(m => {
if(!m.date || m.date < heute) return;
ereignisse.push({
art: 'termin',
datum: m.date,
pid: pr.id, abschnitt: 'terminplan', projekt,
beschreibung: 'Meilenstein „' + esc(m.name || 'Meilenstein') + '"',
});
});
});
ereignisse.forEach(e => { e.zeit = relativerTag(e.datum); });
ereignisse.sort((a, b) => Math.abs(tageBis(a.datum)) - Math.abs(tageBis(b.datum)));
return ereignisse;
}
// WAND_FEED_SPALTEN: Reihenfolge und Beschriftung der drei Feed-Spalten.
const WAND_FEED_SPALTEN = [
{ art: 'neu', label: 'Neu' },
{ art: 'ueberfaellig', label: 'Überfällig' },
{ art: 'termin', label: 'Termine' },
];
// wandFeedHtml: gruppiert wandAktuelleEreignisse() nach Art in drei Spalten
// (Eriks Wahl "Variante C" aus dem Feed-Vergleich) — zeigt auf einen Blick,
// wie viele Ereignisse welcher Art anstehen, statt sie chronologisch zu
// mischen. Die Spaltenüberschrift nennt die TATSÄCHLICHE Gesamtzahl dieser
// Art, auch wenn nur WAND_FEED_MAX Einträge angezeigt werden. Jeder Eintrag
// bleibt für sich antippbar und springt direkt ins zugehörige Projekt.
// Parameter: w. Rückgabe: HTML-String.
function wandFeedHtml(w){
const ereignisse = wandAktuelleEreignisse(w);
const spalten = WAND_FEED_SPALTEN.map(s => {
const alle = ereignisse.filter(e => e.art === s.art);
const gezeigt = alle.slice(0, WAND_FEED_MAX);
const eintraegeHtml = gezeigt.length
? gezeigt.map(e =>
'<div class="wand-feed-eintrag" onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + esc(e.abschnitt) + '\')">'
+ '<b>' + e.projekt + '</b><span>' + e.beschreibung + '</span>'
+ '<span class="wand-feed-zeit">' + esc(e.zeit) + '</span>'
+ '</div>'
).join('')
: '<div class="wand-leer-klein">keine</div>';
return '<div class="wand-feed-gruppe">'
+ '<div class="wand-feed-gruppe-kopf">'
+ '<span class="wand-feed-gruppe-punkt ' + s.art + '"></span>'
+ '<span class="wand-feed-gruppe-titel">' + esc(s.label) + '</span>'
+ '<span class="wand-feed-gruppe-anzahl">' + alle.length + '</span>'
+ '</div>'
+ eintraegeHtml
+ '</div>';
}).join('');
return '<div class="wand-feed-gruppen">' + spalten + '</div>';
}
// ---------- Stand ----------
// renderWandStand: reiner Zeitstempel der letzten erfolgreichen
// Aktualisierung — das einzige Lebenszeichen, dass das unbeaufsichtigte
// Display nicht eingefroren ist. Keine Parameter. Rückgabe: HTML-String.
function renderWandStand(){
const jetzt = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return '<div class="wand-stand">Stand: ' + jetzt + ' Uhr</div>';
}
+113 -100
View File
@@ -2,87 +2,59 @@
// Projektübersicht — Sitzungs-/Wochen-Verwaltung // Projektübersicht — Sitzungs-/Wochen-Verwaltung
// ----------------------------- // -----------------------------
// Diese Datei verwaltet die Sitzungen (Wochen): Auswahl, Anzeige des Datums, // Diese Datei verwaltet die Sitzungen (Wochen): Auswahl, Anzeige des Datums,
// Erzeugung neuer Sitzungen, Duplizieren, Sperren (Auto-Lock) und Löschen. // Sperren (Auto-Lock) und Löschen. Sie wird von `App.html` nach `js/store.js`
// Sie wird von `Test.html` nach `js/store.js` geladen und stellt globale // geladen und stellt globale Funktionen wie `currentWeek()`, `switchWeek()`,
// Funktionen wie `currentWeek()`, `switchWeek()`, `newWeek()`, `cloneWeek()`, // `applyAutoLock()`, `guardWeekLocked()`, `unlockWeek()` und `deleteWeekById()`
// `applyAutoLock()`, `guardWeekLocked()` und `deleteWeek()` bereit. Genutzt wird // bereit. Genutzt wird
// sie u.a. von `js/board/board.js` (`renderBoard`, `isWeekLocked`), `js/store.js` // sie u.a. von `js/board/board.js` (`renderBoard`, `isWeekLocked`), `js/store.js`
// (`load`) und `js/tabs-auth.js` (Passwort-Aktionen für Entsperren/Löschen). // (`load`) und `js/tabs-auth.js` (Passwort-Aktionen für Entsperren/Löschen).
//
// refreshWeekSelect: aktualisiert das Dropdown `weekSelect` mit allen Sitzungen // Neue Sitzungen entstehen seit dem automatischen Sitzungswechsel NICHT MEHR
// der aktuellen Abteilung und markiert die aktuell ausgewählte. Kein Parameter, // hier — `ensureCurrentWeekRollover()` (server.js) legt sie serverseitig an,
// keine Rückgabe. // sobald der Kalender in eine neue ISO-Woche wechselt, unabhängig davon, ob
function refreshWeekSelect(){ // irgendjemand die Anwendung gerade geöffnet hat (siehe CLAUDE.md). Die
const sel = document.getElementById('weekSelect'); // früheren Funktionen `newWeek()`/`cloneWeek()`/`validateWeekLabel()` sind
sel.innerHTML = db.weeks.map(w=>`<option value="${w.id}" ${w.id===db.currentWeek?'selected':''}>${w.label}</option>`).join(''); // deshalb entfallen.
}
// 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`. // currentWeek: liefert das Woche-Objekt zur aktuellen Auswahl aus `db.weeks`.
// Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined. // Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined.
function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); } function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); }
// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`), speichert und // switchWeek: setzt die aktuell gewählte Wochen-ID (`id`) und rendert die
// rendert die Ansicht neu. Keine Rückgabe. // Ansicht neu. Es werden keine Daten geändert, daher wird nicht gespeichert
function switchWeek(id){ db.currentWeek = id; save(); renderAll(); } // (eine save() würde die Woche nur als dirty markieren und unnötig senden).
// Keine Rückgabe.
// newWeek: erzeugt über zwei prompt-Dialoge eine neue leere Sitzung (Bezeichnung function switchWeek(id){ db.currentWeek = id; renderAll(); }
// 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 -------- // -------- Sperren (Auto-Lock) und Entsperren --------
// applyAutoLock: sperrt alle Sitzungen bis auf die chronologisch // applyAutoLock: sperrt alle Sitzungen bis auf die chronologisch
// letzte (aktuellste). Wird beim Laden, Erstellen und Duplizieren verwendet, // letzte (aktuellste). Wird beim Laden, Erstellen und Duplizieren verwendet,
// damit historische Sitzungen nicht mehr unbeabsichtigt bearbeitet werden. // damit historische Sitzungen nicht mehr unbeabsichtigt bearbeitet werden.
//
// Firmenweit abschaltbar (Einstellung 'autoLockAktiv'): In manchen Büros wird
// an mehreren Sitzungen parallel gearbeitet, dort steht die automatische
// Sperre im Weg. Ist sie aus, wird NICHT nachträglich entsperrt — bereits
// gesperrte Sitzungen bleiben es, das Entsperren bleibt eine bewusste
// Admin-Handlung (POST /unlock-week).
function applyAutoLock(){ function applyAutoLock(){
if(db.weeks.length === 0) return; if(db.weeks.length === 0) return;
const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); if(!Number(APP_SETTINGS.autoLockAktiv)) return;
// Die Planung-Sandbox (SANDBOX_WEEK_LABEL) landet nie in db.weeks (siehe
// load(), js/store.js) und ist von dieser Schleife also strukturell schon
// ausgenommen — dieser Filter ist ein reines Sicherheitsnetz, falls das
// je nicht mehr zuträfe: sie darf nie als "späteste Woche" zählen oder
// gesperrt werden, sie ist keine echte Sitzung.
const echteWochen = db.weeks.filter(w => w.label !== SANDBOX_WEEK_LABEL);
if(echteWochen.length === 0) return;
const sorted = [...echteWochen].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
const latestId = sorted[sorted.length - 1].id; const latestId = sorted[sorted.length - 1].id;
db.weeks.forEach(w => { echteWochen.forEach(w => {
const wasLocked = !!w.locked;
w.locked = (w.id !== latestId); w.locked = (w.id !== latestId);
// Nur tatsächlich geänderte Lock-Zustände übertragen (beim bloßen Laden
// sind die Werte identisch und es wird nichts gesendet).
if(wasLocked !== w.locked) markWeekDirty(w.label);
}); });
} }
@@ -105,52 +77,93 @@ function guardWeekLocked(){
return false; return false;
} }
// unlockCurrentWeek: Entsperrt die aktuelle Sitzung nach Passwortprüfung. // unlockWeek: entsperrt EINE Sitzung serverseitig (POST /unlock-week,
// Parameter `skipAuth=true` überspringt die Passwortabfrage (Aufruf nach // admin-only) und übernimmt bei Erfolg den Sperr-Zustand sowie die neue
// erfolgreicher Authentifizierung). Keine Rückgabe. // Revision (`updatedAt`) lokal.
function unlockCurrentWeek(skipAuth = false){ //
const w = currentWeek(); // Verallgemeinert gegenüber der früheren Fassung, die nur `currentWeek()`
// kannte: Seit die Sitzungsauswahl aus dem Menüband entfernt wurde
// (Sitzungen dienen nur noch der Speicherung und dem Verlauf), muss sich
// auch eine ANDERE als die gerade offene Sitzung entsperren lassen — die
// Sitzungsliste im Admin-Bereich (renderAdminSitzungen(), js/admin/admin.js)
// braucht genau das.
// Parameter: w (Woche-Objekt aus db.weeks), nachRender (optionale Funktion
// statt renderAll() — die Admin-Liste soll nur sich selbst neu zeichnen,
// nicht die ganze Seite, die ohnehin die entsperrte Sitzung nicht zeigt).
// Keine Rückgabe.
function unlockWeek(w, nachRender){
if(!w || !w.locked) return; if(!w || !w.locked) return;
if(!skipAuth && !isAuthenticated){ fetch(baseUrl() + '/unlock-week', {
requestedAction = { type: 'unlockWeek' }; method: 'POST',
document.getElementById('passwordModal').style.display='flex'; headers: {'Content-Type':'application/json'},
document.getElementById('passwordInput').value=''; body: JSON.stringify({ label: w.label })
document.getElementById('passwordInput').focus(); }).then(r => {
return; if(r.status === 403){ alert('Nur Administratoren dürfen Sitzungen entsperren.'); return null; }
} return r.json().then(json => ({ status: r.status, json }));
}).then(result => {
if(!result) return;
const { status, json } = result;
if(status === 200 && json && json.ok){
w.locked = false; w.locked = false;
save(); renderAll(); if(json.updatedAt) w.updatedAt = json.updatedAt;
requestedAction = null; (nachRender || renderAll)();
} else {
alert('Entsperren fehlgeschlagen: ' + (json && json.error ? json.error : 'unbekannter Fehler'));
}
}).catch(err => {
alert('Entsperren fehlgeschlagen: ' + (err.message || err));
});
} }
// deleteWeek: löscht die aktuell ausgewählte Sitzung (nur wenn mehr als 1 // unlockCurrentWeek: entsperrt die gerade offene Sitzung. Aufrufer ist
// vorhanden) inkl. ihrer CSV-Datei auf dem Server. Parameter `skipAuth=true` // ausschließlich der Sperr-Hinweis auf dem Board/Wochenbericht
// überspringt die Passwortabfrage. Danach wird die letzte verbleibende Woche // (renderBoard()/renderWochenbericht()), der nur erscheint, wenn
// aktiviert und neu gerendert. Keine Rückgabe. // `currentWeek()` selbst gesperrt ist — im Normalbetrieb mit aktiver
function deleteWeek(skipAuth = false){ // Auto-Sperre kommt das praktisch nicht vor, da immer nur die jüngste
// Sitzung offen bleibt. Dünner Wrapper um `unlockWeek()`.
function unlockCurrentWeek(){ unlockWeek(currentWeek()); }
// deleteWeekById: löscht EINE Sitzung (nicht zwingend die aktuell offene)
// mitsamt allen Projekten, Zuordnungen und Plänen dieser Sitzung auf dem
// Server (admin-only, POST /delete-week). Mindestens eine Sitzung muss
// bestehen bleiben. Die lokale Woche wird erst nach erfolgreicher
// Server-Antwort entfernt (sonst würde sie bei fehlender Berechtigung
// lokal verschwinden, obwohl sie auf dem Server weiterhin existiert).
//
// Ersetzt die frühere `deleteWeek()`, die stillschweigend "die im Menüband
// gewählte Sitzung" meinte — ohne diese Auswahl im Menüband ist eine
// explizite Ziel-Sitzung nötig; Aufrufer ist jetzt ausschließlich die
// Sitzungsliste im Admin-Bereich.
// Parameter: weekId, nachDelete (optionale Zusatzfunktion, die NACH dem
// üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich
// darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe.
function deleteWeekById(weekId, nachDelete){
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; } if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
if(!skipAuth && !isAuthenticated){ const w = db.weeks.find(x => x.id === weekId);
requestedAction = { type: 'deleteWeek' }; if(!w) return;
document.getElementById('passwordModal').style.display='flex'; if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return;
document.getElementById('passwordInput').value=''; console.log('Lösche Woche auf Server:', w.label);
document.getElementById('passwordInput').focus(); deleteWeekFromServer(w.label).then(res=>{
if(!(res && res.ok)){
alert('Wochen-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler'));
return; return;
} }
if(!confirm("Diese Sitzung wirklich löschen?")) { requestedAction = null; return; } console.log('Woche gelöscht:', w.label);
const current = currentWeek(); const warAktuell = (weekId === db.currentWeek);
const filename = current ? filenameForWeek(current.label) : null; db.weeks = db.weeks.filter(x=>x.id!==weekId);
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.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
// Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus
// irgendeinem Grund gar keine mehr existierende), auf die jüngste
// verbleibende wechseln — sonst verwiese db.currentWeek ins Leere.
if(warAktuell || !db.weeks.some(x => x.id === db.currentWeek)){
db.currentWeek = db.weeks[db.weeks.length - 1].id; db.currentWeek = db.weeks[db.weeks.length - 1].id;
}
applyAutoLock(); applyAutoLock();
save(); save();
renderAll(); renderAll();
requestedAction = null; if(typeof nachDelete === 'function') nachDelete();
}).catch(err=>{
if(err && err.status === 403){ alert('Nur Administratoren dürfen Sitzungen löschen.'); return; }
alert('Wochen-Löschung fehlgeschlagen: ' + (err.message || err));
});
} }
+19 -30
View File
@@ -18,7 +18,7 @@
// Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu // Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu
// vergeben) nicht bieten konnte. Die Bearbeitung der Projekte selbst // vergeben) nicht bieten konnte. Die Bearbeitung der Projekte selbst
// (Kommentar-Editor, LPH, Beteiligte) läuft vollständig über die bestehenden // (Kommentar-Editor, LPH, Beteiligte) läuft vollständig über die bestehenden
// Funktionen aus js/board/board.js (editProj/editLph/cycleAssign/ // Funktionen aus js/board/board.js (editLph/cycleAssign/
// renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses Modul // renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses Modul
// ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die // ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten). // Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten).
@@ -26,7 +26,7 @@
// diesem Tab die Sitzung abgearbeitet wird. // diesem Tab die Sitzung abgearbeitet wird.
// //
// Läuft wie board.js als klassisches globales Skript (kein Modul/IIFE), nur // Läuft wie board.js als klassisches globales Skript (kein Modul/IIFE), nur
// von Test.html geladen (nach js/board/board.js). // von App.html geladen (nach js/board/board.js).
// findPreviousWeek: liefert die chronologisch vorherige Sitzung zu `week` // findPreviousWeek: liefert die chronologisch vorherige Sitzung zu `week`
// (nach weekSortKey sortiert) oder null, wenn `week` die früheste ist. // (nach weekSortKey sortiert) oder null, wenn `week` die früheste ist.
@@ -89,11 +89,12 @@ function classifyProjectChange(p, prevWeek){
} }
// BERICHT_OPTS: Darstellungs-Optionen, die dieser Tab an die gemeinsamen // BERICHT_OPTS: Darstellungs-Optionen, die dieser Tab an die gemeinsamen
// Render-Funktionen aus js/board/board.js übergibt. Im Wochenbericht werden // Render-Funktionen aus js/board/board.js übergibt. dicht ist das einzige,
// nur die beauftragten Leistungsphasen gezeigt (p.beauftragteLP) — im // was den Wochenbericht noch vom Projektboard unterscheidet (zwei statt drei
// Projektboard bleibt es bei allen. Den Verlauf der Vorwochen hängt // Spalten) — nurBeauftragteLph gilt inzwischen für beide (BOARD_OPTS,
// renderBoardDrawer() inzwischen überall an (renderProjektVerlauf() in // js/board/board.js). Den Verlauf der Vorwochen hängt renderBoardDrawer()
// js/utils.js), er braucht deshalb kein eigenes Kennzeichen mehr. // überall an (renderProjektVerlauf() in js/utils.js), er braucht deshalb kein
// eigenes Kennzeichen mehr.
const BERICHT_OPTS = { nurBeauftragteLph: true, dicht: true }; const BERICHT_OPTS = { nurBeauftragteLph: true, dicht: true };
// renderReportCard: baut die immer aufgeklappte Karte eines Projekts im // renderReportCard: baut die immer aufgeklappte Karte eines Projekts im
@@ -120,27 +121,6 @@ function renderReportCard(p, reason, allPeople, locked, weekLabel, variant){
</div>`; </div>`;
} }
// renderReportSection: baut einen Abschnitt des Berichts (Überschrift mit
// Anzahl, Liste der zugehörigen Projekte). `expanded` steuert, ob die volle
// Karte (renderReportCard) oder die normale einklappbare Board-Zeile
// (renderBoardRow) genutzt wird. Parameter: title, entries (Array
// {p, reason}), allPeople, locked, expanded (Boolean), emptyText. Rückgabe:
// HTML-String.
function renderReportSection(title, entries, allPeople, locked, expanded, emptyText, weekLabel, variant){
let html = `<div class="section-title">${esc(title)} (${entries.length})</div>`;
if(entries.length === 0){
html += `<div class="hint">${esc(emptyText)}</div>`;
return html;
}
html += `<div class="${expanded ? 'report-list' : 'board-list'}">`;
html += entries.map(({p, reason}) => expanded
? renderReportCard(p, reason, allPeople, locked, weekLabel, variant)
: renderBoardRow(p, allPeople, locked, weekLabel, BERICHT_OPTS)
).join('');
html += `</div>`;
return html;
}
// ---------- Anwesenheit der Sitzung ---------- // ---------- Anwesenheit der Sitzung ----------
// Die Anwesenheit hing ursprünglich als Zweitfunktion an der Kürzel-Legende // Die Anwesenheit hing ursprünglich als Zweitfunktion an der Kürzel-Legende
// unter dem Projektboard ("klick für Anwesenheit") — Namenserklärung und // unter dem Projektboard ("klick für Anwesenheit") — Namenserklärung und
@@ -672,8 +652,13 @@ function openTodoAusKommentar(pid){
// Vorschlag aus dem gerade im Feld stehenden Text, nicht aus dem // Vorschlag aus dem gerade im Feld stehenden Text, nicht aus dem
// gespeicherten: Im Meeting tippt man den Beschluss und legt sofort die // gespeicherten: Im Meeting tippt man den Beschluss und legt sofort die
// Aufgabe an, ohne vorher zu speichern. // Aufgabe an, ohne vorher zu speichern.
// feld ist seit der Umstellung auf contenteditable (Eriks Wunsch nach
// echter Fett-/Kursiv-/Farbdarstellung statt sichtbarer Markup-Zeichen,
// js/utils.js) ein <div> ohne .value — statusTextMitZeilen() liefert
// daraus reinen Text mit erhaltenen Zeilenumbrüchen, aus dem unten die
// erste Zeile als Titelvorschlag gezogen wird.
const feld = document.querySelector('#wochenbericht .comment-field'); const feld = document.querySelector('#wochenbericht .comment-field');
const text = feld ? feld.value : statusPlainText(p.status); const text = feld ? statusTextMitZeilen(feld.innerHTML) : statusPlainText(p.status);
const ersteZeile = String(text || '').split('\n').map(z => z.replace(/^[-•*\s]+/, '').trim()).find(z => z) || ''; const ersteZeile = String(text || '').split('\n').map(z => z.replace(/^[-•*\s]+/, '').trim()).find(z => z) || '';
document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120); document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120);
@@ -844,7 +829,11 @@ document.addEventListener('keydown', function(e){
if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return; if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return;
const w = currentWeek(); const w = currentWeek();
if(!w) return; if(!w) return;
const imFeld = e.target && e.target.matches && e.target.matches('input, textarea, select'); // isContentEditable erfasst das Kommentarfeld (seit der Umstellung auf
// contenteditable ein <div>, kein <textarea> mehr, siehe js/utils.js) —
// ohne diese Ergänzung griffen ↓/↑ dort als Projekt-Wechsel statt als
// normale Cursorbewegung im Text.
const imFeld = e.target && ((e.target.matches && e.target.matches('input, textarea, select')) || e.target.isContentEditable);
if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){ if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){
const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null; const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null;
File renamed without changes.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

-3279
View File
File diff suppressed because it is too large. Load diff
+238 -53
View File
@@ -678,11 +678,20 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
/* === Bereich "Protokolle" (js/plan/plan-protokoll.js) === /* === Bereich "Protokolle" (js/plan/plan-protokoll.js) ===
Listenansicht (.protokoll-row je Protokoll) und Detailansicht Listenansicht (.protokoll-row je Protokoll) und Detailansicht
(.protokoll-field-grid Kopf, Teilnehmer- und Punkte-Block). Der (.protokoll-field-grid Kopf, Teilnehmer- und Punkte-Block). Der
Punkte-Block gruppiert in .protokoll-gruppe-Abschnitte (freier Text, Punkte-Block ist ZWEIGETEILT (.protokoll-split): links eine schmale
keine feste Liste, siehe CLAUDE.md "### Protokolle"); neue Punkte tragen Liste aller Punkte nach Gruppen (.protokoll-liste), rechts der
.is-neu (eigenes Gelb-Token --protokoll-neu-bg/-border, siehe styles.css — Schreibbereich für den gerade ausgewählten (.protokoll-schreiben) —
bewusst nicht --band-neu, das im Wochenbericht schon blau "neu Begründung bei .protokoll-split weiter unten. Gruppierung bleibt freier
kommentiert" bedeutet). */ Text, keine feste Liste, siehe CLAUDE.md "### Protokolle".
Das Neu-Kennzeichen ist seit dieser Umstellung ein schlichter Chip
(.protokoll-chip-neu, Gelb-Token --protokoll-neu-bg/-border — bewusst
nicht --band-neu, das im Wochenbericht schon blau "neu kommentiert"
bedeutet) statt einer Einfärbung ganzer Zeilen und Felder; der Status
eines Punkts erscheint als farbiger Punkt im Listeneintrag
(.protokoll-punkt-dot) und als Umschalter im Kopf des Schreibbereichs.
Damit entfällt das frühere Gegeneinander von Zeilen-Gelb und
Feldrahmen. */
.protokoll-list-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } .protokoll-list-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
@@ -727,7 +736,7 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
/* Ladehinweis im Import-Dialog (#protokollImportLoading, Test.html): ohne /* Ladehinweis im Import-Dialog (#protokollImportLoading, Test.html): ohne
ihn wirkte der Dialog während eines länger laufenden KI-Aufrufs (bis zu ihn wirkte der Dialog während eines länger laufenden KI-Aufrufs (bis zu
KI_TIMEOUT_MS_LOKAL = 5 Minuten bei einem eigenen Server, server.js) wie KI_TIMEOUT_MS_LOKAL = 30 Minuten bei einem eigenen Server, server.js) wie
hängengeblieben — Felder und Knöpfe werden während der Anfrage zusätzlich hängengeblieben — Felder und Knöpfe werden während der Anfrage zusätzlich
gesperrt (setzeProtokollImportLadezustand(), plan-protokoll.js), damit gesperrt (setzeProtokollImportLadezustand(), plan-protokoll.js), damit
kein zweiter Import gleichzeitig losgeschickt wird. kein zweiter Import gleichzeitig losgeschickt wird.
@@ -754,7 +763,7 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
.protokoll-field label { font-size: 11.5px; color: var(--text-subtle); text-transform: uppercase; letter-spacing: .04em; } .protokoll-field label { font-size: 11.5px; color: var(--text-subtle); text-transform: uppercase; letter-spacing: .04em; }
.protokoll-block { margin-top: 20px; } .protokoll-block { margin-top: 20px; }
.protokoll-block-head { margin-bottom: 10px; } .protokoll-block-head { margin-bottom: 10px; display: flex; align-items: center; justify-content: space-between; }
.protokoll-block-head h4 { margin: 0; font-size: 13.5px; font-weight: 650; } .protokoll-block-head h4 { margin: 0; font-size: 13.5px; font-weight: 650; }
.protokoll-teilnehmer-row { display: grid; grid-template-columns: 1fr 160px 100px 24px; gap: 10px; align-items: center; .protokoll-teilnehmer-row { display: grid; grid-template-columns: 1fr 160px 100px 24px; gap: 10px; align-items: center;
@@ -765,54 +774,232 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
.protokoll-row-remove { color: var(--text-subtle); text-align: center; cursor: pointer; opacity: .6; flex-shrink: 0; } .protokoll-row-remove { color: var(--text-subtle); text-align: center; cursor: pointer; opacity: .6; flex-shrink: 0; }
.protokoll-row-remove:hover { opacity: 1; color: var(--red); } .protokoll-row-remove:hover { opacity: 1; color: var(--red); }
.protokoll-gruppe { margin-bottom: 18px; } /* ---------- Punkte: links Liste, rechts Schreibbereich ----------
.protokoll-gruppe:last-of-type { margin-bottom: 8px; } Eriks Auswahl „Liste + Schreibbereich" aus dem Protokoll-schreiben-Mockup,
.protokoll-gruppe-head { display: flex; align-items: center; justify-content: space-between; Ablösung der früheren durchgehenden Tabelle (eine Zeile je Punkt mit allen
padding: 2px 2px 8px; border-bottom: 1px solid var(--line-soft); margin-bottom: 10px; } Feldern nebeneinander). Begründung dort und im Kopf von
.protokoll-gruppe-name { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; renderPunkteBlockHtml() (js/plan/plan-protokoll.js): ein Protokollpunkt ist
color: var(--accent); background: var(--accent-soft); padding: 3px 9px; border-radius: var(--r-pill); } ein kleiner Fließtext, keine Tabellenzelle — in der Spalte blieb für den
.protokoll-gruppe-name.is-leer { color: var(--text-subtle); background: var(--surface-sunk); } Text weder Breite noch Höhe, Thema und Text klebten übereinander und die
.protokoll-gruppe-add { font-size: 12px; color: var(--accent); font-weight: 600; cursor: pointer; } Formatierungsleiste saß eingeklemmt dazwischen. */
.protokoll-gruppe-add:hover { color: var(--accent-hover); } .protokoll-split { display: grid; grid-template-columns: 290px minmax(0, 1fr); gap: 14px; align-items: start; }
.protokoll-gruppe-move,
.protokoll-punkt-move { display: inline-flex; gap: 4px; flex-shrink: 0; }
.protokoll-gruppe-move button,
.protokoll-punkt-move button { padding: 2px 7px; line-height: 1.4; }
.protokoll-gruppe-move button:disabled,
.protokoll-punkt-move button:disabled { opacity: .3; cursor: default; }
.protokoll-punkt { position: relative; border: 1px solid var(--line-soft); border-radius: var(--r-md); .protokoll-liste {
padding: 10px 12px; margin-bottom: 8px; background: var(--surface); } background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md);
.protokoll-punkt:last-child { margin-bottom: 0; } overflow: hidden; box-shadow: var(--sh-1);
.protokoll-punkt.is-neu { background: var(--protokoll-neu-bg); border-color: var(--protokoll-neu-border); }
border-left: 3px solid var(--protokoll-neu-border); padding-left: 9px; } .protokoll-liste-kopf {
.protokoll-punkt.is-erledigt { opacity: .6; } padding: 9px 12px; background: var(--surface-alt); border-bottom: 1px solid var(--line);
.protokoll-punkt-top-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--text-subtle);
.protokoll-punkt-nr { display: inline-block; width: 46px; flex-shrink: 0; font-variant-numeric: tabular-nums; }
font-weight: 650; font-size: 12.5px; text-align: center; padding: 5px 4px; color: var(--text-muted); /* Gruppen-Kopfzeile: Farbbalken links aus dem Gruppennamen gehasht
background: var(--surface-sunk); border-radius: var(--r-sm); } (protokollGruppeFarbe(), plan-protokoll.js — dasselbe Prinzip wie
.protokoll-punkt.is-neu .protokoll-punkt-nr { background: rgba(0,0,0,.06); color: var(--text); } projektFarbe() in der Auslastung), damit eine Gruppe ihre Farbe behält,
.protokoll-punkt-gruppe { width: 110px; flex-shrink: 0; font-size: 12px; } egal an welcher Position sie steht. Der Name selbst ist das Eingabefeld:
.protokoll-chip-neu { background: var(--protokoll-neu-border); color: #3d2e00; font-size: 10px; font-weight: 700; umbenennen wirkt auf alle Punkte der Gruppe. */
letter-spacing: .03em; text-transform: uppercase; padding: 2px 7px; border-radius: var(--r-pill); flex-shrink: 0; } .protokoll-liste-gruppe {
.protokoll-punkt-thema { flex: 1; min-width: 160px; font-weight: 600; font-size: 13px; } display: flex; align-items: center; gap: 7px; padding: 8px 10px 8px 11px;
.protokoll-punkt-erledigt-toggle { display: flex; align-items: center; gap: 5px; font-size: 12px; background: var(--surface-sunk); border-bottom: 1px solid var(--line-soft);
color: var(--text-muted); flex-shrink: 0; } border-left: 3px solid var(--protokoll-gruppe-farbe, var(--accent));
.protokoll-punkt-text { width: 100%; resize: vertical; font: inherit; font-size: 12.5px; color: var(--text-muted); }
margin: 0 0 8px; box-sizing: border-box; } .protokoll-liste-gruppe + .protokoll-liste-gruppe,
.protokoll-punkt-foot { display: flex; gap: 18px; flex-wrap: wrap; font-size: 12px; color: var(--text-subtle); } .protokoll-liste-zeile + .protokoll-liste-gruppe,
.protokoll-punkt-foot label { display: flex; align-items: center; gap: 6px; } .protokoll-liste-neu + .protokoll-liste-gruppe { border-top: 1px solid var(--line); }
.protokoll-punkt-foot input[type=text] { font-size: 12px; padding: 5px 8px; } /* Dieselbe !important-Begründung wie bei den übrigen randlosen Feldern
.protokoll-punkt-foot .range-picker-display { width: 100px; font-size: 12px; padding: 5px 8px; } weiter unten: die Dark-Mode-Fassung von input[type=text] (styles.css)
/* Reines Anzeigefeld (kein Input) — erstellt_am ist serverseitig maßgeblich hängt ein html-Element vor den Selektor und gewinnt sonst. */
und beim Fortschreiben unveränderlich, siehe legeProtokollAn()/PUT .protokoll-liste-gruppe input.protokoll-gruppe-heading {
.../:id in server.js; rechtsbündig, damit es nicht wie ein drittes flex: 1; min-width: 0; font-size: 12px; font-weight: 700; letter-spacing: .01em;
Eingabefeld neben Zuständig/Termin wirkt. */ border: none !important; background: none !important; border-radius: 4px !important;
.protokoll-punkt-erstellt { margin-left: auto; align-self: center; white-space: nowrap; } color: var(--text) !important; padding: 2px 3px !important;
}
.protokoll-liste-gruppe input.protokoll-gruppe-heading::placeholder { color: var(--text-subtle); font-weight: 550; }
.protokoll-liste-gruppe input.protokoll-gruppe-heading:focus { outline: none; }
.protokoll-liste-gruppe input.protokoll-gruppe-heading:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft) inset; }
.protokoll-liste-gruppe .count {
font-size: 10px; font-variant-numeric: tabular-nums; color: var(--text-subtle);
background: var(--surface); border: 1px solid var(--line); border-radius: 980px; padding: 0 6px; flex-shrink: 0;
}
.protokoll-liste-zeile {
display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 2px 8px; align-items: baseline;
padding: 8px 11px; cursor: pointer; border-bottom: 1px solid var(--line-soft);
border-left: 3px solid transparent;
}
.protokoll-liste-zeile:hover { background: var(--surface-alt); }
.protokoll-liste-zeile.ist-aktiv { background: var(--accent-soft); border-left-color: var(--accent); }
/* Jedes Kind bekommt seinen Platz im Raster AUSDRÜCKLICH zugewiesen. Mit
bloßer Auto-Platzierung zog der Status-Punkt (der als einziger eine
grid-row trug) die erste Spalte an sich, Nummer und Titel rutschten je
eine Spalte weiter und der Titel landete rechtsbündig in der schmalen
auto-Spalte — die Unterzeile blieb dann auf zwei Zeichen abgeschnitten. */
.protokoll-liste-zeile .nr {
grid-column: 1; grid-row: 1;
font-variant-numeric: tabular-nums; font-size: 11.5px; font-weight: 700; color: var(--text-muted);
}
.protokoll-liste-zeile.ist-aktiv .nr { color: var(--accent); }
.protokoll-liste-zeile .titel {
grid-column: 2; grid-row: 1;
font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.protokoll-liste-zeile .titel.ist-leer { font-weight: 500; font-style: italic; color: var(--text-subtle); }
.protokoll-liste-zeile .sub {
grid-column: 2; grid-row: 2; font-size: 10.5px; color: var(--text-subtle);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Das Neu-Kennzeichen steht rechts unter dem Status-Punkt statt in einer
eigenen dritten Zeile — sonst wäre jeder Eintrag der Liste eine Zeile
höher, nur für einen Chip. */
.protokoll-liste-zeile .protokoll-chip-neu { grid-column: 3; grid-row: 2; justify-self: end; }
.protokoll-punkt-dot {
grid-column: 3; grid-row: 1; justify-self: end; align-self: center;
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.protokoll-punkt-dot[data-status="offen"] { background: var(--status-offen); }
.protokoll-punkt-dot[data-status="erledigt"] { background: var(--status-erledigt); }
.protokoll-punkt-dot[data-status="info"] { background: var(--status-info); }
.protokoll-liste-neu {
padding: 8px 11px 8px 14px; color: var(--accent); font-size: 12px; font-weight: 600; cursor: pointer;
border-bottom: 1px solid var(--line-soft);
}
.protokoll-liste-neu:hover { background: var(--surface-alt); color: var(--accent-hover); }
.protokoll-liste-neu.ist-gruppe { border-bottom: none; border-top: 1px solid var(--line); }
.protokoll-liste-leer { padding: 14px 12px; font-size: 12.5px; color: var(--text-subtle); }
/* ---------- Schreibbereich ---------- */
.protokoll-schreiben {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md);
overflow: hidden; box-shadow: var(--sh-1);
}
.protokoll-schreiben.ist-leer { padding: 28px 22px; color: var(--text-muted); font-size: 13px; }
.protokoll-schreiben.ist-leer p { margin: 0 0 6px; }
.protokoll-schreiben.ist-leer p:last-child { margin-bottom: 0; }
.protokoll-schreiben-kopf {
display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
padding: 9px 16px; background: var(--surface-alt); border-bottom: 1px solid var(--line-soft);
}
.protokoll-schreiben-kopf .nr { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 13px; }
.protokoll-schreiben-kopf .gruppe {
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-subtle);
}
.protokoll-schreiben-kopf .rechts { margin-left: auto; display: flex; align-items: center; gap: 9px; }
.protokoll-feld-label {
display: block; font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em;
color: var(--text-subtle); margin-bottom: 5px;
}
.protokoll-schreiben-feld { padding: 13px 16px; }
.protokoll-schreiben-feld + .protokoll-schreiben-feld { border-top: 1px solid var(--line-soft); }
/* Thema und Text sitzen randlos in ihrem beschrifteten Abschnitt — der
Abschnitt selbst (Linie + Label) sagt schon, wo man schreibt, ein
zusätzlicher Feldrahmen gäbe zwei Kästen umeinander. !important wie
überall hier gegen die Dark-Mode-Fassung von input[type=text]
(html:not([data-theme="light"]) …, styles.css), die sonst an Spezifität
gewinnt und ein abgerundetes Eingabefeld-Rechteck durchscheinen lässt. */
.protokoll-schreiben-feld input.protokoll-punkt-thema {
font: inherit; font-size: 17px; font-weight: 650; letter-spacing: -.01em; width: 100%; line-height: 1.3;
border: none !important; background: none !important; border-radius: 4px !important;
color: var(--text) !important; padding: 1px 2px !important; display: block;
}
.protokoll-schreiben-feld input.protokoll-punkt-thema::placeholder { font-weight: 500; color: var(--text-subtle); }
.protokoll-schreiben-feld .protokoll-punkt-text {
font: inherit; font-size: 13.5px; line-height: 1.65; color: var(--text);
min-height: 130px; max-width: 66ch; padding: 1px 2px; border-radius: 4px;
}
.protokoll-schreiben-feld .protokoll-punkt-text:empty::before { content: attr(data-placeholder); color: var(--text-subtle); }
.protokoll-schreiben-feld input.protokoll-punkt-thema:focus,
.protokoll-schreiben-feld .protokoll-punkt-text:focus { outline: none; }
.protokoll-schreiben-feld input.protokoll-punkt-thema:focus-visible,
.protokoll-schreiben-feld .protokoll-punkt-text:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft) inset; }
/* ist-geaendert: NUR das geänderte Feld eines fortgeschriebenen Punkts gelb
markiert (Eriks Wunsch) — derselbe Gelb-Ton wie das Neu-Kennzeichen, hier
aber feldweise, weil der Punkt selbst ja derselbe geblieben ist, nur sein
Inhalt nicht. */
.protokoll-schreiben-feld input.protokoll-punkt-thema.ist-geaendert,
.protokoll-schreiben-feld .protokoll-punkt-text.ist-geaendert {
background: var(--protokoll-neu-bg) !important; padding: 3px 6px !important; margin: 0 -4px;
}
/* Die Formatierungsleiste sitzt zwischen Thema und Text — sie gehört zum
Textfeld darunter, nicht zum Thema darüber (Thema ist reiner Text ohne
Auszeichnung). */
.protokoll-schreiben .richtext-toolbar { border-top: 1px solid var(--line-soft); }
.protokoll-schreiben-raster {
display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
padding: 13px 16px; border-top: 1px solid var(--line-soft);
}
.protokoll-schreiben-raster input {
font: inherit !important; font-size: 13px !important; width: 100%; min-width: 0 !important;
color: var(--text) !important; background: var(--surface) !important;
border: 1px solid var(--line) !important; border-radius: var(--r-sm) !important; padding: 6px 9px !important;
}
.protokoll-schreiben-raster input:focus {
outline: none; border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--accent-soft);
}
.protokoll-schreiben-fuss {
padding: 9px 16px; border-top: 1px solid var(--line-soft); background: var(--surface-alt);
font-size: 11px; color: var(--text-subtle);
}
.protokoll-schreiben-fuss .hint { font-size: 11px; }
/* ---------- Chips, Verschieben, Entfernen ---------- */
.protokoll-chip-neu {
display: inline-block; width: fit-content; background: var(--protokoll-neu-border); color: #3d2e00;
font-size: 9.5px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; padding: 1px 6px;
border-radius: var(--r-pill);
}
/* Gnadenrunde eines erledigten Punkts — bewusst Fließtext statt
Großbuchstaben-Pille: der Hinweistext ist zu lang dafür. */
.protokoll-chip-entfaellt {
display: inline-block; max-width: 100%;
background: color-mix(in srgb, var(--red) 15%, transparent); color: var(--red);
font-size: 9.5px; font-weight: 700; line-height: 1.3; padding: 2px 7px; border-radius: var(--r-sm);
}
.protokoll-gruppe-move, .protokoll-punkt-move { display: inline-flex; gap: 4px; flex-shrink: 0; }
/* button.secondary (styles.css) bringt eine eigene, leicht deckende Fläche
mit, die hier wie ein grauer Fleck wirkte. !important nötig: die
Dark-Mode-Fassung von button.secondary hängt ein html-Element vor den
Selektor und gewinnt sonst an Spezifität. */
.protokoll-gruppe-move button, .protokoll-punkt-move button {
padding: 1px 6px; line-height: 1.4; font-size: 11px;
background: transparent !important; color: var(--text-subtle) !important;
}
.protokoll-gruppe-move button:hover:not(:disabled),
.protokoll-punkt-move button:hover:not(:disabled) { color: var(--text) !important; }
.protokoll-gruppe-move button:disabled, .protokoll-punkt-move button:disabled { opacity: .3; cursor: default; }
/* ---------- Status-Umschalter ----------
Senkrecht gestapelt in der Grundform; im Kopf des Schreibbereichs ist
Platz, dort liegt er waagerecht. */
.protokoll-status-switch {
display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 7px; padding: 2px;
background: var(--surface-sunk); gap: 1px; width: 56px;
}
.protokoll-status-switch.ist-waagerecht { flex-direction: row; width: auto; border-radius: 980px; }
.protokoll-status-switch button {
appearance: none; border: none; background: none; font: inherit; font-size: 10.5px; font-weight: 700;
letter-spacing: .01em; padding: 4px 0; border-radius: 5px; cursor: pointer; color: var(--text-subtle);
text-align: center; transition: background .12s, color .12s;
}
.protokoll-status-switch.ist-waagerecht button { padding: 3px 11px; border-radius: 980px; }
.protokoll-status-switch button:hover:not(.is-active) { color: var(--text); }
.protokoll-status-switch button:disabled { cursor: default; }
.protokoll-status-switch button:disabled:hover { color: var(--text-subtle); }
.protokoll-status-switch button.offen.is-active { background: var(--status-offen); color: #fff; }
.protokoll-status-switch button.erledigt.is-active { background: var(--status-erledigt); color: #fff; }
.protokoll-status-switch button.info.is-active { background: var(--status-info); color: #fff; }
@media (max-width: 900px){ @media (max-width: 900px){
.protokoll-row { grid-template-columns: 1fr; gap: 4px; } .protokoll-row { grid-template-columns: 1fr; gap: 4px; }
.protokoll-field-grid { grid-template-columns: 1fr; } .protokoll-field-grid { grid-template-columns: 1fr; }
/* Unter 900px stehen Liste und Schreibbereich untereinander — nebeneinander
bliebe für den Text keine lesbare Zeilenlänge mehr. */
.protokoll-split { grid-template-columns: minmax(0, 1fr); }
.protokoll-schreiben-raster { grid-template-columns: 1fr; }
} }
.add-row-btn { display: flex; align-items: center; gap: 6px; color: var(--accent); font-size: 13px; .add-row-btn { display: flex; align-items: center; gap: 6px; color: var(--accent); font-size: 13px;
@@ -821,6 +1008,4 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
.legend-swatch { width: 13px; height: 13px; border-radius: 4px; flex-shrink: 0; display: inline-block; } .legend-swatch { width: 13px; height: 13px; border-radius: 4px; flex-shrink: 0; display: inline-block; }
.legend-swatch.neu { background: var(--protokoll-neu-bg); border: 1.5px solid var(--protokoll-neu-border); } .legend-swatch.neu { background: var(--protokoll-neu-bg); border: 1.5px solid var(--protokoll-neu-border); }
.legend-swatch.fortgeschrieben, .legend-swatch.info { background: var(--status-info-bg); border: 1.5px solid var(--status-info); }
.legend-swatch.erledigt { background: var(--surface); border: 1.5px solid var(--line); }
.legend-swatch.erledigt { opacity: .6; }
-331
View File
@@ -1,331 +0,0 @@
<!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 per Deep-Link-Route
"/<Abteilung>/Projektplan/<Projektname>" adressiert; Woche und Projekt-Nr.
kommen als URL-Parameter (weekLabel, projNr). 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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projektplan</title>
<link rel="stylesheet" href="/styles.css?v=3">
<style>
.plan-page { min-height: 100vh; display: flex; flex-direction: column; }
.plan-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 24px; background: var(--surface); border-bottom: 1px solid var(--line-soft);
position: sticky; top: 0; z-index: 20;
}
.plan-header h1 { font-size: 17px; margin: 0; font-weight: 600; }
.plan-header .subtitle { color: var(--text-muted); font-size: 13px; }
.plan-body { flex: 1; display: flex; gap: 0; overflow: hidden; }
.plan-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); min-width: 0; }
.plan-main-header {
padding: 16px 20px 10px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
.plan-main-content { flex: 1; overflow: auto; padding: 0 20px 20px; min-width: 0; }
.todo-item {
background: var(--surface-alt); border: 1px solid var(--line-soft); border-radius: var(--r-md);
padding: 10px 12px; margin-bottom: 10px;
}
.todo-item.done .todo-title { text-decoration: line-through; opacity: .6; }
.todo-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.todo-row:last-child { margin-bottom: 0; }
.todo-title { flex: 1; font-weight: 500; font-size: 13px; }
.todo-dates { display: flex; gap: 8px; flex-wrap: wrap; font-size: 12px; color: var(--text-muted); }
.todo-dates input[type=date] { padding: 4px 7px; font-size: 12px; }
.todo-assignee-list { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
.todo-assignee {
font-size: 11px; padding: 2px 8px; border-radius: var(--r-pill);
background: var(--surface-sunk); border: 1px solid var(--line-soft); color: var(--text-muted);
}
.todo-assignee.main { background: var(--green); color: #fff; border-color: transparent; }
.todo-assignee.optional { background: #d18b00; color: #fff; border-color: transparent; }
.empty-todos { color: var(--text-subtle); font-size: 13px; text-align: center; padding: 24px 0; }
.plan-people { margin-top: 14px; }
.plan-people-title { font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 6px; }
.plan-people-list { display: flex; flex-wrap: wrap; gap: 6px; }
.plan-people-chip {
font-size: 12px; padding: 4px 10px; border-radius: var(--r-pill);
background: var(--accent-soft); color: var(--text); border: 1px solid var(--line-soft);
}
.milestone-list { display: flex; flex-direction: column; gap: 6px; }
.milestone-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.milestone-row input[type=date] { padding: 4px 7px; font-size: 12px; }
.todo-list-main {
margin-top: 18px;
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: var(--r-lg);
padding: 14px 18px;
}
.todo-list-title {
font-size: 15px; font-weight: 600; margin: 0 0 10px;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.todo-row-item {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
padding: 8px 10px;
background: var(--surface-alt);
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
}
.todo-row-item.done { opacity: .55; text-decoration: line-through; }
.todo-row-item input[type=text] { flex: 1 1 200px; min-width: 140px; }
.todo-row-item input[type=date] { padding: 4px 7px; font-size: 12px; }
.todo-add-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line-soft);
}
.plan-toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--text); color: #fff; padding: 10px 18px; border-radius: var(--r-pill);
font-size: 13px; opacity: 0; pointer-events: none; transition: opacity .25s, transform .25s;
z-index: 1000; box-shadow: var(--sh-3);
}
.plan-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.plan-resource-grid {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(140px, max-content)) !important;
gap: 10px !important;
justify-content: start !important;
}
.plan-resource-card {
padding: 8px 10px !important;
min-height: 0 !important;
width: 140px !important;
max-width: 140px !important;
}
.plan-resource-card h3 {
font-size: 12px !important;
margin-bottom: 2px !important;
}
.plan-resource-clickable { cursor: pointer; transition: box-shadow .15s, transform .15s; }
.plan-resource-clickable:hover { box-shadow: var(--sh-2); transform: translateY(-1px); }
.todo-depends-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow-y: auto;
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
padding: 8px;
background: var(--surface-alt);
}
.todo-depends-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 6px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 12px;
transition: background .15s;
}
.todo-depends-item:hover { background: var(--surface-sunk); }
.todo-depends-item input { flex-shrink: 0; }
.todo-depends-group {
font-size: 11px;
color: var(--text-muted);
margin-top: 6px;
margin-bottom: 2px;
text-transform: uppercase;
letter-spacing: .03em;
}
.todo-depends-group:first-child { margin-top: 0; }
/* Drag-Handle für TODO- und Meilenstein-Zeilen direkt im GANTT */
.gantt-row.gantt-todo,
.gantt-row.gantt-milestone-row { cursor: default; }
.gantt-row.gantt-todo .gantt-label,
.gantt-row.gantt-milestone-row .gantt-label { display: flex; align-items: center; gap: 8px; }
.gantt-todo-drag {
color: var(--text-subtle); cursor: grab; font-size: 13px; line-height: 1;
user-select: none; flex-shrink: 0;
}
.gantt-row.gantt-todo.dragging,
.gantt-row.gantt-milestone-row.dragging { opacity: .5; }
.gantt-todo-drag:active { cursor: grabbing; }
.gantt-row.gantt-milestone-row .gantt-todo-drag { color: var(--text-subtle); }
@media (max-width: 900px) {
.plan-body { flex-direction: column; overflow: auto; }
}
</style>
</head>
<body>
<div class="plan-page">
<div class="plan-header">
<div>
<h1 id="planTitle">Projektplan</h1>
<div class="subtitle" id="planSubtitle"></div>
</div>
<div class="toolbar">
<button class="secondary" onclick="todoMail()">E-Mail erstellen</button>
<button class="secondary" onclick="window.close()">Schließen</button>
</div>
</div>
<div class="plan-body">
<div class="plan-main">
<div class="plan-main-header">
<label>Zeitraum:</label>
<input type="text" id="planGanttRange" class="range-picker-display" placeholder="Zeitraum wählen…" readonly
onclick="openRangePicker({ anchor: this, startInputId: 'planGanttStart', endInputId: 'planGanttEnd', displayId: 'planGanttRange', onApply: renderPlanGantt })">
<input type="hidden" id="planGanttStart">
<input type="hidden" id="planGanttEnd">
<button class="secondary" onclick="fitPlanGanttRange()">Zeitraum anpassen</button>
<label><input type="checkbox" id="planShowVacation" checked onchange="renderPlanGantt()"> Urlaub anzeigen</label>
<button class="secondary" onclick="zoomPlanGantt(-1)" title="Verkleinern">−</button>
<button class="secondary" onclick="zoomPlanGantt(1)" title="Vergrößern">+</button>
<button onclick="addPlanMilestone()" id="addMilestoneBtn">+ Meilenstein</button>
<button onclick="addTodo()" id="addTodoBtn">+ TODO</button>
<button onclick="autoScheduleTodos()" id="autoScheduleBtn" title="Aufgaben nach Abhängigkeiten automatisch anordnen">Auto-Schedule</button>
<button onclick="toggleCriticalPath()" id="criticalPathBtn" title="Kritischen Pfad ein-/ausblenden">Kritischer Pfad</button>
</div>
<div class="plan-main-content plan-main-content-full">
<div id="planGanttContainer" class="gantt-container"></div>
<div id="resourceLoadContainer" style="margin-top:16px"></div>
</div>
</div>
</div>
</div>
<!-- Meilenstein-Modal -->
<div class="modal-bg" id="milestoneModal" style="display:none">
<div class="modal">
<h2>Meilenstein</h2>
<div class="form-row">
<label>Name</label>
<input type="text" id="milestoneEditName" placeholder="Name">
</div>
<div class="form-row">
<label>Datum</label>
<input type="text" id="milestoneEditDate" class="range-picker-display" placeholder="Datum wählen…" readonly onclick="openDatePicker({ anchor: this, inputId: 'milestoneEditDate' })">
</div>
<div class="form-row">
<label>Position</label>
<select id="milestoneEditPosition">
<option value="">Oben in der Projektzeile</option>
</select>
</div>
<div class="form-row">
<label>Vorgänger (mehrfach möglich)</label>
<div id="milestoneEditDependsOn" class="todo-depends-list"></div>
</div>
<div class="toolbar">
<button onclick="saveMilestoneEdit()">Speichern</button>
<button class="danger" onclick="deleteMilestoneEdit()">Löschen</button>
<button class="secondary" onclick="closeMilestoneModal()">Abbrechen</button>
</div>
</div>
</div>
<!-- TODO-Modal -->
<div class="modal-bg" id="todoModal" style="display:none">
<div class="modal">
<h2>TODO</h2>
<div class="form-row">
<label>Titel</label>
<input type="text" id="todoEditTitle" placeholder="Titel">
</div>
<div class="form-row">
<label>Zeitraum</label>
<input type="text" id="todoEditRange" class="range-picker-display" placeholder="Start – Ende wählen…" readonly
onclick="openRangePicker({ anchor: this, startInputId: 'todoEditStart', endInputId: 'todoEditEnd', displayId: 'todoEditRange' })">
<input type="hidden" id="todoEditStart">
<input type="hidden" id="todoEditEnd">
</div>
<div class="form-row">
<label>Vorgänger (mehrfach möglich)</label>
<div id="todoEditDependsOn" class="todo-depends-list">
<label class="todo-depends-item">
<input type="checkbox" value="" checked> (Keine Abhängigkeit)
</label>
</div>
</div>
<div class="form-row">
<label>Zuständige</label>
<div id="todoEditAssignees" class="role-checkboxes"></div>
</div>
<div class="form-row">
<label>Fortschritt (%)</label>
<input type="range" id="todoEditProgress" min="0" max="100" value="0" oninput="document.getElementById('todoEditProgressVal').textContent=this.value+'%'"><span id="todoEditProgressVal">0%</span>
</div>
<div class="form-row">
<label><input type="checkbox" id="todoEditDone"> Erledigt</label>
</div>
<div class="toolbar">
<button onclick="saveTodoEdit()">Speichern</button>
<button class="danger" onclick="deleteTodoEdit()">Löschen</button>
<button class="secondary" onclick="closeTodoModal()">Abbrechen</button>
</div>
</div>
</div>
<!-- Abwesenheits-Modal (Urlaube der Projektbeteiligten) -->
<div class="modal-bg" id="absenceModal" style="display:none">
<div class="modal">
<h2 id="absenceModalTitle">Abwesenheiten</h2>
<div class="form-row">
<div id="absenceModalList"></div>
</div>
<div class="absence-form" id="absenceModalForm">
<input type="text" id="absenceEditRange" class="range-picker-display" placeholder="Zeitraum wählen…" readonly
onclick="openRangePicker({ anchor: this, startInputId: 'absenceEditStart', endInputId: 'absenceEditEnd', displayId: 'absenceEditRange' })">
<input type="hidden" id="absenceEditStart">
<input type="hidden" id="absenceEditEnd">
<select id="absenceEditType">
<option>Urlaub</option>
<option>Krank</option>
<option>Fortbildung</option>
<option>Sonstiges</option>
</select>
<input type="text" id="absenceEditNote" placeholder="Notiz (optional)" style="width:140px">
<button class="secondary small" onclick="addPlanAbsence()">+ Abwesenheit</button>
</div>
<div class="toolbar" style="margin-top:12px">
<button class="secondary" onclick="closeAbsenceModal()">Schließen</button>
</div>
</div>
</div>
<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>
File diff suppressed because it is too large. Load diff
+3396 -626
View File
File diff suppressed because it is too large. Load diff
+5493 -96
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
WOCHE;KW 32 / 2026;2026-08-05;
PERSONEN;;;RS|Planung|Rieke Stilleke;AC|Planung|Atilla Ceylan;CJ|Planung|Carina Jost;SM|Planung|Shahab Mushref;TC|Planung|Tetiana Chevhuz;MK|Projektleitung|Maximilian Kriener;SS|Projektleitung|Sekandar Seradj;MK|Bauüberwachung|Maximilian Kriener;SS|Bauüberwachung|Sekandar Seradj;;;;;;;;;;;
Pr.-Nr.;Bezeichnung;Status;RS;AC;CJ;SM;TC;MK;SS;MK;SS;LPH 1;LPH 2;LPH 3;LPH 4;LPH 5;LPH 6;LPH 7;LPH 8;W+M;Gruppe;Index;Plan
ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;
21-4001;Feuerwehrgeräteh. Rodenkirchen;- Wettbewerbsbeitrag erstellt;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;5;
;Umbau Praxisräume;- Bestandsaufnahme geplant Test schreiben ;;;;;;;;;;100%;;;;;;;;;In Anbahnung;0;
;Denkmalschutzgutachten;- Kurzcheck erstellt;;;;;;;;;;100%;;;;;;;;;In Anbahnung;1;
;Architektur Zeichnungsvorlagen;- Vorlagen aktualisiert;;;;;;;;;;100%;;;;;;;;;Allgemein;0;
1 WOCHE;KW 32 / 2026;2026-08-05;
2 PERSONEN;;;RS|Planung|Rieke Stilleke;AC|Planung|Atilla Ceylan;CJ|Planung|Carina Jost;SM|Planung|Shahab Mushref;TC|Planung|Tetiana Chevhuz;MK|Projektleitung|Maximilian Kriener;SS|Projektleitung|Sekandar Seradj;MK|Bauüberwachung|Maximilian Kriener;SS|Bauüberwachung|Sekandar Seradj;;;;;;;;;;;
3 Pr.-Nr.;Bezeichnung;Status;RS;AC;CJ;SM;TC;MK;SS;MK;SS;LPH 1;LPH 2;LPH 3;LPH 4;LPH 5;LPH 6;LPH 7;LPH 8;W+M;Gruppe;Index;Plan
4 ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;
5 21-4001;Feuerwehrgeräteh. Rodenkirchen;- Wettbewerbsbeitrag erstellt;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;5;
6 ;Umbau Praxisräume;- Bestandsaufnahme geplant Test schreiben ;;;;;;;;;;100%;;;;;;;;;In Anbahnung;0;
7 ;Denkmalschutzgutachten;- Kurzcheck erstellt;;;;;;;;;;100%;;;;;;;;;In Anbahnung;1;
8 ;Architektur Zeichnungsvorlagen;- Vorlagen aktualisiert;;;;;;;;;;100%;;;;;;;;;Allgemein;0;
-8
View File
@@ -1,8 +0,0 @@
NAME;Rollen;Voller Name;E-Mail;Abwesenheiten
AC;Planung;Atilla Ceylan;a.ceylan@rauschenberg-ing.de;[]
CJ;Planung;Carina Jost;c.jost@rauschenberg-ing.de;[]
MK;Projektleitung,Bauüberwachung;Maximilian Kriener;m.kriener@rauschenberg-ing.de;[]
RS;Planung;Rieke Stilleke;r.stilleke@rauschenberg-ing.de;[]
SM;Planung;Shahab Mushref;s.mushref@rauschenberg-ing.de;[]
SS;Projektleitung,Bauüberwachung;Sekandar Seradj;s.seradj@rauschenberg-ing.de;[]
TC;Planung;Tetiana Chevhuz;t.chevhuz@rauschenberg-ing.de;
1 NAME Rollen Voller Name E-Mail Abwesenheiten
2 AC Planung Atilla Ceylan a.ceylan@rauschenberg-ing.de []
3 CJ Planung Carina Jost c.jost@rauschenberg-ing.de []
4 MK Projektleitung,Bauüberwachung Maximilian Kriener m.kriener@rauschenberg-ing.de []
5 RS Planung Rieke Stilleke r.stilleke@rauschenberg-ing.de []
6 SM Planung Shahab Mushref s.mushref@rauschenberg-ing.de []
7 SS Projektleitung,Bauüberwachung Sekandar Seradj s.seradj@rauschenberg-ing.de []
8 TC Planung Tetiana Chevhuz t.chevhuz@rauschenberg-ing.de
-9
View File
@@ -1,9 +0,0 @@
WOCHE;KW 30 / 2026;2026-07-24;1
PERSONEN;;;AP|Planung|AP;SK|Planung|SK;AK|Planung|AK;ED|Planung|ED;SE|Planung|SE;THK|Planung|THK;SP|Planung|SP;SG|Planung|SG;TW|Planung|TW;RR|Planung|RR;MK|Planung|MK;HR|Planung|HR;;;;;;;;;;;;;;
Pr.-Nr.;Bezeichnung;Status;AP;SK;AK;ED;SE;THK;SP;SG;TW;RR;MK;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;LPH5 als Vorabzug raus. NW-Räume vorauss. August.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;0;;;
21-4030;NSHV Köln Immobilienprüfung in Hof;Thorsten W. klärt und übernimmt Schriftverkehr. LV liegt zur Sichtung bei T.W.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;1;;;
22-4056;Gymnasium Hemer;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;2;;;
22-4066;DSW 21 – Umbau E-Mobilität;Elt-Montagen ab KW 33.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;3;;;
24-4043;S-K ODL-Heim Sanierung Krefeld;Voraussichtlich LV-Abgabe.;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;4;;;
1 WOCHE;KW 30 / 2026;2026-07-24;1
2 PERSONEN;;;AP|Planung|AP;SK|Planung|SK;AK|Planung|AK;ED|Planung|ED;SE|Planung|SE;THK|Planung|THK;SP|Planung|SP;SG|Planung|SG;TW|Planung|TW;RR|Planung|RR;MK|Planung|MK;HR|Planung|HR;;;;;;;;;;;;;;
3 Pr.-Nr.;Bezeichnung;Status;AP;SK;AK;ED;SE;THK;SP;SG;TW;RR;MK;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
4 ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;;;;;;
5 18-4007;Janusz-Korczak-Gesamtsch. Bottrop;LPH5 als Vorabzug raus. NW-Räume vorauss. August.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;0;;;
6 21-4030;NSHV Köln Immobilienprüfung in Hof;Thorsten W. klärt und übernimmt Schriftverkehr. LV liegt zur Sichtung bei T.W.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;1;;;
7 22-4056;Gymnasium Hemer;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;2;;;
8 22-4066;DSW 21 – Umbau E-Mobilität;Elt-Montagen ab KW 33.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;3;;;
9 24-4043;S-K ODL-Heim Sanierung Krefeld;Voraussichtlich LV-Abgabe.;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;4;;;
-33
View File
@@ -1,33 +0,0 @@
WOCHE;KW 31 / 2026;2026-07-29;1
PERSONEN;;;AP|Planung|Alexander Plum;AK|Planung|Alexander 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;;X;X;;X;X;X;;X;X;;;;X;;X;X;X;;;;;;;;;;;;;;;
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}";;
21-4030;NSHV Köln Immobilienprüfung in Hof;- Thorsten W. klärt und übernimmt Schriftverkehr - LV liegt zur Sichtung bei T.W.;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;2;;;
22-4066;DSW 21, Umbau E-Mobilität;"- Elt-Montagen ab <font color=""#c53030""><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;;;
21-4001;Feuerwehrgeräteh. Rodenkirchen;"- KNX muß noch programmiert werden. <font color=""#c53030"">Abnahme 27.07</font> <div><font color=""#c53030""><b>- Fertigstellung Ende Juli</b></font><div><font color=""#c53030""><b>- Abnahmen laufen bis Ende KW31</b></font></div><div><font color=""#c53030""><b>- Blackbuilding Test 26.07</b></font></div></div>";;X;;;;;;;;;;;X;;;;;;;;;;100%;100%;100%;;;Aktive Projekte;1;"{""start"":"""",""end"":"""",""milestones"":[{""id"":""mslrv3i1edl08"",""name"":""SV Abnahme"",""date"":""2026-08-18""},{""id"":""mslrvh7hjau1e"",""name"":""Test"",""date"":""2026-09-05""}],""people"":[""p_ap"",""p_ak"",""p_sk"",""p_sg""],""progress"":""0"",""todos"":[{""id"":""msluufbp114g1"",""title"":""Test"",""start"":""2026-08-09"",""end"":""2026-08-13"",""done"":false},{""id"":""mslv6h76cmx8k"",""title"":""iuguzgv"",""start"":""2026-08-23"",""end"":""2026-09-06"",""done"":false},{""id"":""mslw4l3qp1v18"",""title"":""Hallo"",""start"":""2026-11-29"",""end"":""2027-04-30"",""done"":false}]}";;
22-4027;Sporthalle Soest;- SV und VOB Abnahme erfolgt.<div>- Nachprüfung VOB 24.07</div><div>- Keine Infos über Nachprüfung</div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;3;;;
22-4056;Gymnasium Hemer;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;4;;;
22-4056-1;Gymnasium Hemer (Deckensanierung);- Beginn der Arbeiten vorraussichtlich KW 30 (KW 32 Kickoff);;;;;;;;;;;;;;;;;;;;;100%;;100%;100%;;;;Aktive Projekte;5;;;
23-4042;GWK Neufelder Straße;"- Nachweis für E-30 Leitungen notwendig. Rechnungen wurden nicht anerkannt. <div><font color=""#c53030"">- 22.07 Aufschaltung BMA und Amok</font></div><div><font color=""#c53030"">- KW 31 VOB Abnahme</font></div>";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;7;;;
23-4070;Umbau Bürogebäude NEW, MV10;"- neue NSHV anbieten(Warten auf Infos), <div>- 28.07 Sachverständiger Abnahme ELT&amp;SiBe </div><div>- 30.07 Sachverständigen Abnahme BMA</div>";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;8;;;
23-4072;S-K OGS-Kaldenkirchen Schulanbau;"- Feininstallation beginnt, Fertigstellung voraussichtlich <font color=""#c53030"">01.08</font>";;;;;;;;;;;;;;;;;;;;;;;;;;70%;;Aktive Projekte;9;;;
24-4049;BMA Remscheid;"- Feuerwehrabnahme organisiert die Errichterfirma. <div>- SV-Abnahmen 15 &amp; 16.07: nicht abgenommen</div>";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;11;;;
24-4069;S-K Erw. GS Blankenheim Neubau;- nur backup;;;;;;;;;;;;;;;;;;;;;;;100%;100%;;;;Kleine Projekte;0;;;
24-4096;Umbau Wohngebäude Kirchhoff;"- Position HA wird von uns bestimmt. - Notwendiges Leerrohr für Leuchten im Vordach ist zu klären.<div><font color=""#c53030"">- Abgabe Kostenberechnung bis Ende KW29</font></div><div><font color=""#c53030"">- LV Fertig bis Ende KW 32 </font> </div>";;;;(X);;;;;;;;;;;;;;;100%;100%;100%;;95%;85%;0%;;;Aktive Projekte;12;;;
25-4003;Märkische Bank Boele;- Feininstallation<div>- Ab KW30 Einbringung Tresore</div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;80%;;Aktive Projekte;13;;;
25-4028;TenBrinke, Amprion, Wietmarschen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;14;;;
25-4028-1;TenBrinke, Amprion, Meerbusch;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;15;;;
25-4028-2;TenBrinke, Amprion, Gelsenkirchen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;16;;;
26-4018;TUI Immobilien GmbH Salzgitter;;;;;;;;;;;;;;;;;;;;50%;;;;;;;;;Aktive Projekte;17;;;
26-4023;HA Rettungsw. Nahmer Gasst. - Los 1;;;;X;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;0;;;
26-4024;HA Rettungsw. Dahl Am Obergr. - Los 2;;;;X;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;1;;;
26-4025;HA, SeePark Hengstey;- LPH2 erfolgt, weitere Arbeiten folgen.;;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;18;;;
26-4026;Stadt Hemer, Anbau OGS Oesetalschule;"<font color=""#1a1a1a"">- Bearbeitung LPH3 in Absprache mit Achitektur S+D</font>";;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;19;;;
26-4046;AXA Außenbeleuchtung;- Warten auf Unterlagen (Grundlagen & Vorplanung);;;;;;;;;;;;;;;;;;;10%;;;;;;;;;Aktive Projekte;20;;;
;KFW Köln/Bonn Sicherheitskonzept;- Auftrag erhalten;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;2;;;
;Swisstal;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;3;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";;
;Plancodierung/Benennung;- Von Rieke erstellt. Info folgt ? KW31?;;;;;;;;;;;;;;;;;;;;;;;;;;;;Allgemein;0;;;
;Ordnerstruktur;- in Projekthilfen _Ordnerstruktur abgelegt, ist in Anlehnung an Leitfaden aufgebaut;;;;;;;;;;;;;;;;;;;;;;;;;;;;Allgemein;1;;;
26-4042;Zanderstraße 7, PV-Anlage;- 82 kWp Anlage angefragt. Preisschätzung wird angefertigt;;;;;;;;;;;;;;;;;;;100%;80%;;;;80%;;;;Kleine Projekte;1;;;
1 WOCHE KW 31 / 2026 2026-07-29 1
2 PERSONEN AP|Planung|Alexander Plum AK|Planung|Alexander 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
3 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
4 ANWESENHEIT X X X X X X X X X X X
5 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}
6 21-4030 NSHV Köln Immobilienprüfung in Hof - Thorsten W. klärt und übernimmt Schriftverkehr - LV liegt zur Sichtung bei T.W. 100% 100% 100% 100% 100% Aktive Projekte 2
7 22-4066 DSW 21, Umbau E-Mobilität - Elt-Montagen ab <font color="#c53030"><b>KW 33</b></font>. 100% 100% 100% 100% 100% 100% 20% Aktive Projekte 6
8 24-4043 S-K ODL-Heim Sanierung Krefeld - Vorraussichtlich LV-Abgabe XX.XX<div>- LPH5 mündlich freigegeben <div>- Neuer EG Grundriss mit diversen Änderungen kommt noch von Architekten</div></div> 100% 100% 100% 100% Aktive Projekte 10
9 21-4001 Feuerwehrgeräteh. Rodenkirchen - KNX muß noch programmiert werden. <font color="#c53030">Abnahme 27.07</font> <div><font color="#c53030"><b>- Fertigstellung Ende Juli</b></font><div><font color="#c53030"><b>- Abnahmen laufen bis Ende KW31</b></font></div><div><font color="#c53030"><b>- Blackbuilding Test 26.07</b></font></div></div> X X 100% 100% 100% Aktive Projekte 1 {"start":"","end":"","milestones":[{"id":"mslrv3i1edl08","name":"SV Abnahme","date":"2026-08-18"},{"id":"mslrvh7hjau1e","name":"Test","date":"2026-09-05"}],"people":["p_ap","p_ak","p_sk","p_sg"],"progress":"0","todos":[{"id":"msluufbp114g1","title":"Test","start":"2026-08-09","end":"2026-08-13","done":false},{"id":"mslv6h76cmx8k","title":"iuguzgv","start":"2026-08-23","end":"2026-09-06","done":false},{"id":"mslw4l3qp1v18","title":"Hallo","start":"2026-11-29","end":"2027-04-30","done":false}]}
10 22-4027 Sporthalle Soest - SV und VOB Abnahme erfolgt.<div>- Nachprüfung VOB 24.07</div><div>- Keine Infos über Nachprüfung</div> 100% 100% 100% 100% 100% 100% 90% Aktive Projekte 3
11 22-4056 Gymnasium Hemer 100% 100% 100% 100% 100% 100% 90% Aktive Projekte 4
12 22-4056-1 Gymnasium Hemer (Deckensanierung) - Beginn der Arbeiten vorraussichtlich KW 30 (KW 32 Kickoff) 100% 100% 100% Aktive Projekte 5
13 23-4042 GWK Neufelder Straße - Nachweis für E-30 Leitungen notwendig. Rechnungen wurden nicht anerkannt. <div><font color="#c53030">- 22.07 Aufschaltung BMA und Amok</font></div><div><font color="#c53030">- KW 31 VOB Abnahme</font></div> 100% 100% 100% 100% 100% 100% 95% Aktive Projekte 7
14 23-4070 Umbau Bürogebäude NEW, MV10 - neue NSHV anbieten(Warten auf Infos), <div>- 28.07 Sachverständiger Abnahme ELT&amp;SiBe </div><div>- 30.07 Sachverständigen Abnahme BMA</div> 100% 100% 100% 100% 100% 100% 90% Aktive Projekte 8
15 23-4072 S-K OGS-Kaldenkirchen Schulanbau - Feininstallation beginnt, Fertigstellung voraussichtlich <font color="#c53030">01.08</font> 70% Aktive Projekte 9
16 24-4049 BMA Remscheid - Feuerwehrabnahme organisiert die Errichterfirma. <div>- SV-Abnahmen 15 &amp; 16.07: nicht abgenommen</div> 100% 100% 100% 100% 100% 100% 90% Aktive Projekte 11
17 24-4069 S-K Erw. GS Blankenheim Neubau - nur backup 100% 100% Kleine Projekte 0
18 24-4096 Umbau Wohngebäude Kirchhoff - Position HA wird von uns bestimmt. - Notwendiges Leerrohr für Leuchten im Vordach ist zu klären.<div><font color="#c53030">- Abgabe Kostenberechnung bis Ende KW29</font></div><div><font color="#c53030">- LV Fertig bis Ende KW 32 </font> </div> (X) 100% 100% 100% 95% 85% 0% Aktive Projekte 12
19 25-4003 Märkische Bank Boele - Feininstallation<div>- Ab KW30 Einbringung Tresore</div> 100% 100% 100% 100% 100% 100% 80% Aktive Projekte 13
20 25-4028 TenBrinke, Amprion, Wietmarschen 100% 100% 100% 95% Aktive Projekte 14
21 25-4028-1 TenBrinke, Amprion, Meerbusch 100% 100% 100% 95% Aktive Projekte 15
22 25-4028-2 TenBrinke, Amprion, Gelsenkirchen 100% 100% 100% 95% Aktive Projekte 16
23 26-4018 TUI Immobilien GmbH Salzgitter 50% Aktive Projekte 17
24 26-4023 HA Rettungsw. Nahmer Gasst. - Los 1 X In Anbahnung 0
25 26-4024 HA Rettungsw. Dahl Am Obergr. - Los 2 X In Anbahnung 1
26 26-4025 HA, SeePark Hengstey - LPH2 erfolgt, weitere Arbeiten folgen. 100% 100% Aktive Projekte 18
27 26-4026 Stadt Hemer, Anbau OGS Oesetalschule <font color="#1a1a1a">- Bearbeitung LPH3 in Absprache mit Achitektur S+D</font> 100% 100% Aktive Projekte 19
28 26-4046 AXA Außenbeleuchtung - Warten auf Unterlagen (Grundlagen & Vorplanung) 10% Aktive Projekte 20
29 KFW Köln/Bonn Sicherheitskonzept - Auftrag erhalten In Anbahnung 2
30 Swisstal In Anbahnung 3 {"start":"","end":"","milestones":[],"people":[],"progress":0}
31 Plancodierung/Benennung - Von Rieke erstellt. Info folgt ? KW31? Allgemein 0
32 Ordnerstruktur - in Projekthilfen _Ordnerstruktur abgelegt, ist in Anlehnung an Leitfaden aufgebaut Allgemein 1
33 26-4042 Zanderstraße 7, PV-Anlage - 82 kWp Anlage angefragt. Preisschätzung wird angefertigt 100% 80% 80% Kleine Projekte 1
-33
View File
@@ -1,33 +0,0 @@
WOCHE;KW 33 / 2026;2026-08-09;
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>;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;7;;;
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;11;;;
21-4001;Feuerwehrgeräteh. Rodenkirchen;- KNX muß noch programmiert werden. <font>Abnahme 27.07</font> <div><font><b>- Fertigstellung Ende Juli</b></font><div><font><b>- Abnahmen laufen bis Ende KW31</b></font></div><div><font><b>- Blackbuilding Test 26.07</b></font></div></div>;X;;X;;;;X;X;;;;;;X;;;;;100%;100%;100%;;100%;100%;100%;;;Aktive Projekte;1;"{""start"":"""",""end"":"""",""milestones"":[{""id"":""mslrvh7hjau1e"",""name"":""Übergabe"",""date"":""2026-09-12"",""dependsOn"":[""mslv6h76cmx8k""],""position"":""after:mslv6h76cmx8k""}],""people"":[""p_ap"",""p_ak"",""p_sk"",""p_sg""],""progress"":""0"",""todos"":[{""id"":""msluufbp114g1"",""title"":""Test"",""start"":""2026-08-10"",""end"":""2026-08-14"",""done"":false,""dependsOn"":[],""assignees"":[""p_ap"",""p_ed""],""progress"":0},{""id"":""mslv6h76cmx8k"",""title"":""Test 2"",""start"":""2026-08-24"",""end"":""2026-09-04"",""done"":false,""dependsOn"":[""msluufbp114g1""],""assignees"":[""p_ap""],""progress"":41},{""id"":""mslw4l3qp1v18"",""title"":""Test 3"",""start"":""2026-09-14"",""end"":""2026-09-22"",""done"":false,""dependsOn"":[""m_mslrvh7hjau1e""],""assignees"":[],""progress"":0},{""id"":""msoltaa1itx48"",""title"":""Gehen"",""start"":""2026-09-23"",""end"":""2026-09-25"",""done"":false,""progress"":0,""dependsOn"":[""mslw4l3qp1v18""],""assignees"":[""p_ws""]}]}";"{""LPH 1"":{""Planung"":0.8},""LPH 2"":{""Planung"":0.8},""LPH 3"":{""Planung"":0.8},""LPH 4"":{""Planung"":0.8},""LPH 5"":{""Planung"":0.8}}";"[""LPH 5""]"
22-4027;Sporthalle Soest;- SV und VOB Abnahme erfolgt.<div>- Nachprüfung VOB 24.07</div><div>- Keine Infos über Nachprüfung</div>;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;3;;;"[""LPH 5""]"
22-4056;Gymnasium Hemer;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;4;;;
22-4056-1;Gymnasium Hemer (Deckensanierung);- Beginn der Arbeiten vorraussichtlich KW 30 (KW 32 Kickoff);;;;;;;;;;;;;;;;;;;;;100%;;100%;100%;;;;Aktive Projekte;5;;;
23-4042;GWK Neufelder Straße;- Nachweis für E-30 Leitungen notwendig. Rechnungen wurden nicht anerkannt. <div><font>- 22.07 Aufschaltung BMA und Amok</font></div><div><font>- KW 31 VOB Abnahme</font></div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;8;;;
23-4070;Umbau Bürogebäude NEW, MV10;"- neue NSHV anbieten(Warten auf Infos), <div>- 28.07 Sachverständiger Abnahme ELT&amp;SiBe </div><div>- 30.07 Sachverständigen Abnahme BMA</div>";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;9;;;
23-4072;S-K OGS-Kaldenkirchen Schulanbau;- Feininstallation beginnt, Fertigstellung voraussichtlich <font>01.08</font>;;;;;;;;;;;;;;;;;;;;;;;;;;70%;;Aktive Projekte;10;;;
24-4049;BMA Remscheid;"- Feuerwehrabnahme organisiert die Errichterfirma. <div>- SV-Abnahmen 15 &amp; 16.07: nicht abgenommen</div>";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;12;;;
24-4069;S-K Erw. GS Blankenheim Neubau;- nur backup;;;;;;;;;;;;;;;;;;;;;;;100%;100%;;;;Kleine Projekte;0;;;
24-4096;Umbau Wohngebäude Kirchhoff;- Position HA wird von uns bestimmt. <div>- Notwendiges Leerrohr für Leuchten im Vordach ist zu klären.<div><font>- Abgabe Kostenberechnung bis Ende KW29</font></div><div><font>- LV Fertig bis Ende KW 32 </font> </div></div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;85%;0%;;;Aktive Projekte;13;;;
25-4003;Märkische Bank Boele;- Feininstallation<div>- Ab KW30 Einbringung Tresore</div>;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;80%;;Aktive Projekte;14;;;
25-4028;TenBrinke, Amprion, Wietmarschen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;15;;;
25-4028-1;TenBrinke, Amprion, Meerbusch;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;16;;;
25-4028-2;TenBrinke, Amprion, Gelsenkirchen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;17;;;
26-4018;TUI Immobilien GmbH Salzgitter;;;;;;;;;;;;;;;;;;;;50%;;;;;;;;;Aktive Projekte;18;;;
26-4023;HA Rettungsw. Nahmer Gasst. - Los 1;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;0;;;
26-4024;HA Rettungsw. Dahl Am Obergr. - Los 2;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;1;;;
26-4025;HA, SeePark Hengstey;- LPH2 erfolgt, weitere Arbeiten folgen.;;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;19;;;
26-4026;Stadt Hemer, Anbau OGS Oesetalschule;"<font color=""#1a1a1a"">- Bearbeitung LPH3 in Absprache mit Achitektur S+D</font>";;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;20;;;
26-4046;AXA Außenbeleuchtung;- Warten auf Unterlagen (Grundlagen & Vorplanung);;;;;;;;;;;;;;;;;;;10%;;;;;;;;;Aktive Projekte;21;;;
;KFW Köln/Bonn Sicherheitskonzept;- Auftrag erhalten;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;2;;;
;Swisstal;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;3;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";;
;Plancodierung/Benennung;- Von Rieke erstellt. Info folgt ? KW31?;;;;;;;;;;;;;;;;;;;;;;;;;;;;Allgemein;0;;;
;Ordnerstruktur;- in Projekthilfen _Ordnerstruktur abgelegt, ist in Anlehnung an Leitfaden aufgebaut;;;;;;;;;;;;;;;;;;;;;;;;;;;;Allgemein;1;;;
26-4042;Zanderstraße 7, PV-Anlage;- 82 kWp Anlage angefragt. Preisschätzung wird angefertigt;;;;;;;;;;;;;;;;;;;100%;80%;;;;80%;;;;Kleine Projekte;1;;;
1 WOCHE KW 33 / 2026 2026-08-09
2 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
3 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
4 ANWESENHEIT
5 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}}
6 21-4030 NSHV Köln Immobilienprüfung in Hof - Thorsten W. klärt und übernimmt Schriftverkehr <div>- LV liegt zur Sichtung bei T.W.</div> X 100% 100% 100% 100% 100% Aktive Projekte 2
7 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 7
8 24-4043 S-K ODL-Heim Sanierung Krefeld - Vorraussichtlich LV-Abgabe XX.XX<div>- LPH5 mündlich freigegeben <div>- Neuer EG Grundriss mit diversen Änderungen kommt noch von Architekten</div></div> 100% 100% 100% 100% Aktive Projekte 11
9 21-4001 Feuerwehrgeräteh. Rodenkirchen - KNX muß noch programmiert werden. <font>Abnahme 27.07</font> <div><font><b>- Fertigstellung Ende Juli</b></font><div><font><b>- Abnahmen laufen bis Ende KW31</b></font></div><div><font><b>- Blackbuilding Test 26.07</b></font></div></div> X X X X X 100% 100% 100% 100% 100% 100% Aktive Projekte 1 {"start":"","end":"","milestones":[{"id":"mslrvh7hjau1e","name":"Übergabe","date":"2026-09-12","dependsOn":["mslv6h76cmx8k"],"position":"after:mslv6h76cmx8k"}],"people":["p_ap","p_ak","p_sk","p_sg"],"progress":"0","todos":[{"id":"msluufbp114g1","title":"Test","start":"2026-08-10","end":"2026-08-14","done":false,"dependsOn":[],"assignees":["p_ap","p_ed"],"progress":0},{"id":"mslv6h76cmx8k","title":"Test 2","start":"2026-08-24","end":"2026-09-04","done":false,"dependsOn":["msluufbp114g1"],"assignees":["p_ap"],"progress":41},{"id":"mslw4l3qp1v18","title":"Test 3","start":"2026-09-14","end":"2026-09-22","done":false,"dependsOn":["m_mslrvh7hjau1e"],"assignees":[],"progress":0},{"id":"msoltaa1itx48","title":"Gehen","start":"2026-09-23","end":"2026-09-25","done":false,"progress":0,"dependsOn":["mslw4l3qp1v18"],"assignees":["p_ws"]}]} {"LPH 1":{"Planung":0.8},"LPH 2":{"Planung":0.8},"LPH 3":{"Planung":0.8},"LPH 4":{"Planung":0.8},"LPH 5":{"Planung":0.8}} ["LPH 5"]
10 22-4027 Sporthalle Soest - SV und VOB Abnahme erfolgt.<div>- Nachprüfung VOB 24.07</div><div>- Keine Infos über Nachprüfung</div> X 100% 100% 100% 100% 100% 100% 90% Aktive Projekte 3 ["LPH 5"]
11 22-4056 Gymnasium Hemer 100% 100% 100% 100% 100% 100% 90% Aktive Projekte 4
12 22-4056-1 Gymnasium Hemer (Deckensanierung) - Beginn der Arbeiten vorraussichtlich KW 30 (KW 32 Kickoff) 100% 100% 100% Aktive Projekte 5
13 23-4042 GWK Neufelder Straße - Nachweis für E-30 Leitungen notwendig. Rechnungen wurden nicht anerkannt. <div><font>- 22.07 Aufschaltung BMA und Amok</font></div><div><font>- KW 31 VOB Abnahme</font></div> 100% 100% 100% 100% 100% 100% 95% Aktive Projekte 8
14 23-4070 Umbau Bürogebäude NEW, MV10 - neue NSHV anbieten(Warten auf Infos), <div>- 28.07 Sachverständiger Abnahme ELT&amp;SiBe </div><div>- 30.07 Sachverständigen Abnahme BMA</div> 100% 100% 100% 100% 100% 100% 90% Aktive Projekte 9
15 23-4072 S-K OGS-Kaldenkirchen Schulanbau - Feininstallation beginnt, Fertigstellung voraussichtlich <font>01.08</font> 70% Aktive Projekte 10
16 24-4049 BMA Remscheid - Feuerwehrabnahme organisiert die Errichterfirma. <div>- SV-Abnahmen 15 &amp; 16.07: nicht abgenommen</div> 100% 100% 100% 100% 100% 100% 90% Aktive Projekte 12
17 24-4069 S-K Erw. GS Blankenheim Neubau - nur backup 100% 100% Kleine Projekte 0
18 24-4096 Umbau Wohngebäude Kirchhoff - Position HA wird von uns bestimmt. <div>- Notwendiges Leerrohr für Leuchten im Vordach ist zu klären.<div><font>- Abgabe Kostenberechnung bis Ende KW29</font></div><div><font>- LV Fertig bis Ende KW 32 </font> </div></div> 100% 100% 100% 95% 85% 0% Aktive Projekte 13
19 25-4003 Märkische Bank Boele - Feininstallation<div>- Ab KW30 Einbringung Tresore</div> 100% 100% 100% 100% 100% 100% 80% Aktive Projekte 14
20 25-4028 TenBrinke, Amprion, Wietmarschen 100% 100% 100% 95% Aktive Projekte 15
21 25-4028-1 TenBrinke, Amprion, Meerbusch 100% 100% 100% 95% Aktive Projekte 16
22 25-4028-2 TenBrinke, Amprion, Gelsenkirchen 100% 100% 100% 95% Aktive Projekte 17
23 26-4018 TUI Immobilien GmbH Salzgitter 50% Aktive Projekte 18
24 26-4023 HA Rettungsw. Nahmer Gasst. - Los 1 In Anbahnung 0
25 26-4024 HA Rettungsw. Dahl Am Obergr. - Los 2 In Anbahnung 1
26 26-4025 HA, SeePark Hengstey - LPH2 erfolgt, weitere Arbeiten folgen. 100% 100% Aktive Projekte 19
27 26-4026 Stadt Hemer, Anbau OGS Oesetalschule <font color="#1a1a1a">- Bearbeitung LPH3 in Absprache mit Achitektur S+D</font> 100% 100% Aktive Projekte 20
28 26-4046 AXA Außenbeleuchtung - Warten auf Unterlagen (Grundlagen & Vorplanung) 10% Aktive Projekte 21
29 KFW Köln/Bonn Sicherheitskonzept - Auftrag erhalten In Anbahnung 2
30 Swisstal In Anbahnung 3 {"start":"","end":"","milestones":[],"people":[],"progress":0}
31 Plancodierung/Benennung - Von Rieke erstellt. Info folgt ? KW31? Allgemein 0
32 Ordnerstruktur - in Projekthilfen _Ordnerstruktur abgelegt, ist in Anlehnung an Leitfaden aufgebaut Allgemein 1
33 26-4042 Zanderstraße 7, PV-Anlage - 82 kWp Anlage angefragt. Preisschätzung wird angefertigt 100% 80% 80% Kleine Projekte 1
-14
View File
@@ -1,14 +0,0 @@
NAME;Rollen;Voller Name;E-Mail;Abwesenheiten
AK;Planung;Alex Kenne;a.kenne@rauschenberg-ing.de;[]
AP;Planung;Alexander Plum;a.plum@rauschenberg-ing.de;[]
ED;Planung,Projektleitung,Bauüberwachung;Erik Dunin;e.dunin@rauschenberg-ing.de;[]
HR;Planung,Bauüberwachung;Hermann Rollecke;h.roellecke@rauschenberg-ing.de;[]
MK;Planung,Projektleitung;Maximilian Kriener;m.kriener@rauschenberg-ing.de;[]
RR;Planung,Projektleitung;Robert Röllecke;r.roellecke@rauschenberg-ing.de;[]
SE;Planung;Steven Esche;s.esche@rauschenberg-ing.de;[]
SG;Planung,Projektleitung;Steffen Gaida;s.gaida@rauschenberg-ing.de;[]
SK;Planung,Bauüberwachung;Simon Kunigk;s.kunigk@rauschenberg-ing.de;[]
SP;Planung,Projektleitung,Bauüberwachung;Sebastian Pöttinger;s.poettinger@rauschenberg-ing.de;[]
THK;Planung,Projektleitung,Bauüberwachung;Thorsten Kramp;t.kramp@rauschenberg-ing.de;[]
TW;Planung,Projektleitung;Thorsten Werner;t.werner@rauschenberg-ing.de;[]
WS;Planung;Winfried Sältzer;w.sältzer@rauschenberg-ing.de;[]
1 NAME Rollen Voller Name E-Mail Abwesenheiten
2 AK Planung Alex Kenne a.kenne@rauschenberg-ing.de []
3 AP Planung Alexander Plum a.plum@rauschenberg-ing.de []
4 ED Planung,Projektleitung,Bauüberwachung Erik Dunin e.dunin@rauschenberg-ing.de []
5 HR Planung,Bauüberwachung Hermann Rollecke h.roellecke@rauschenberg-ing.de []
6 MK Planung,Projektleitung Maximilian Kriener m.kriener@rauschenberg-ing.de []
7 RR Planung,Projektleitung Robert Röllecke r.roellecke@rauschenberg-ing.de []
8 SE Planung Steven Esche s.esche@rauschenberg-ing.de []
9 SG Planung,Projektleitung Steffen Gaida s.gaida@rauschenberg-ing.de []
10 SK Planung,Bauüberwachung Simon Kunigk s.kunigk@rauschenberg-ing.de []
11 SP Planung,Projektleitung,Bauüberwachung Sebastian Pöttinger s.poettinger@rauschenberg-ing.de []
12 THK Planung,Projektleitung,Bauüberwachung Thorsten Kramp t.kramp@rauschenberg-ing.de []
13 TW Planung,Projektleitung Thorsten Werner t.werner@rauschenberg-ing.de []
14 WS Planung Winfried Sältzer w.sältzer@rauschenberg-ing.de []
-9
View File
@@ -1,9 +0,0 @@
WOCHE;KW 30 / 2026;2026-07-24;1
PERSONEN;;;AP|Planung|AP;SK|Planung|SK;AK|Planung|AK;ED|Planung|ED;SE|Planung|SE;THK|Planung|THK;SP|Planung|SP;SG|Planung|SG;TW|Planung|TW;RR|Planung|RR;MK|Planung|MK;HR|Planung|HR;;;;;;;;;;;;;;
Pr.-Nr.;Bezeichnung;Status;AP;SK;AK;ED;SE;THK;SP;SG;TW;RR;MK;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;LPH5 als Vorabzug raus. NW-Räume vorauss. August.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;0;;;
21-4030;NSHV Köln Immobilienprüfung in Hof;Thorsten W. klärt und übernimmt Schriftverkehr. LV liegt zur Sichtung bei T.W.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;1;;;
22-4056;Gymnasium Hemer;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;2;;;
22-4066;DSW 21 – Umbau E-Mobilität;Elt-Montagen ab KW 33.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;3;;;
24-4043;S-K ODL-Heim Sanierung Krefeld;Voraussichtlich LV-Abgabe.;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;4;;;
1 WOCHE;KW 30 / 2026;2026-07-24;1
2 PERSONEN;;;AP|Planung|AP;SK|Planung|SK;AK|Planung|AK;ED|Planung|ED;SE|Planung|SE;THK|Planung|THK;SP|Planung|SP;SG|Planung|SG;TW|Planung|TW;RR|Planung|RR;MK|Planung|MK;HR|Planung|HR;;;;;;;;;;;;;;
3 Pr.-Nr.;Bezeichnung;Status;AP;SK;AK;ED;SE;THK;SP;SG;TW;RR;MK;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
4 ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;;;;;;
5 18-4007;Janusz-Korczak-Gesamtsch. Bottrop;LPH5 als Vorabzug raus. NW-Räume vorauss. August.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;0;;;
6 21-4030;NSHV Köln Immobilienprüfung in Hof;Thorsten W. klärt und übernimmt Schriftverkehr. LV liegt zur Sichtung bei T.W.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;1;;;
7 22-4056;Gymnasium Hemer;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;2;;;
8 22-4066;DSW 21 – Umbau E-Mobilität;Elt-Montagen ab KW 33.;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;3;;;
9 24-4043;S-K ODL-Heim Sanierung Krefeld;Voraussichtlich LV-Abgabe.;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;4;;;
-11
View File
@@ -1,11 +0,0 @@
WOCHE;KW 32 / 2026;2026-08-05;
PERSONEN;;;MZ|Planung|Markus Zimmermann;FP|Planung|Frank Pietruschka;TK|Planung|Tim Küppers;RR|Projektleitung|Robert Röllecke;AS|Bauüberwachung|Andreas Simon;SL|Bauüberwachung|Steffan Licata;;;;;;;;;;;;;;
Pr.-Nr.;Bezeichnung;Status;MZ;FP;TK;RR;AS;SL;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;;;;;;;;;;;;;;;;;;;;;
24-4101;Krankenhaus Köln, Heizungsmodernisierung;- Austausch Kesselhaus geplant;;;;;;;100%;100%;100%;;100%;100%;;90%;;Aktive Projekte;0;"{""start"":"""",""end"":"""",""todos"":[{""id"":""msmzj493z5gie"",""title"":""Abgabe"",""start"":""2026-08-10"",""end"":""2026-08-28"",""done"":false,""progress"":23,""dependsOn"":[],""assignees"":[""p_fp""]}],""milestones"":[{""id"":""msmzi2pbbsv1s"",""name"":""sxcdv"",""date"":""2026-08-15"",""dependsOn"":[],""position"":""""}],""people"":[],""progress"":0}";"{""LPH 1"":{""Planung"":1}}";"[""LPH 1""]"
24-4102;Bürokomplex Dortmund, Lüftung;- Filterkonzept in Abstimmung;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;1;"{""start"":"""",""end"":"""",""todos"":[],""milestones"":[{""id"":""msmzhc7wo8alc"",""name"":""Test 123"",""date"":""2026-08-10"",""dependsOn"":[],""position"":""""}],""people"":[],""progress"":0}";;
26-4105;Wohnanlage Essen, Regenwasser;- Planung Drainage;;;;;;;100%;100%;;;;;;;;Aktive Projekte;4;;;
;Badmodernisierung Privatkunde;- Angebot erstellt;;;;;;;;;;;100%;;;;;In Anbahnung;0;;;
;Wärmepumpen-Studie;- Erstgespräch durchgeführt;;;;;;;100%;;;;;;;;;In Anbahnung;1;;;
;HLS Planungscheckliste;- Vorlage aktualisiert;;;;;;;100%;;;;;;;;;Allgemein;0;;;
21-4001;Feuerwehrgeräteh. Rodenkirchen;;;;;;;;;;;;;;;;;Aktive Projekte;5;;;
1 WOCHE KW 32 / 2026 2026-08-05
2 PERSONEN MZ|Planung|Markus Zimmermann FP|Planung|Frank Pietruschka TK|Planung|Tim Küppers RR|Projektleitung|Robert Röllecke AS|Bauüberwachung|Andreas Simon SL|Bauüberwachung|Steffan Licata
3 Pr.-Nr. Bezeichnung Status MZ FP TK RR AS SL LPH 1 LPH 2 LPH 3 LPH 4 LPH 5 LPH 6 LPH 7 LPH 8 W+M Gruppe Index Plan Aufwand Aktive Phasen
4 ANWESENHEIT
5 24-4101 Krankenhaus Köln, Heizungsmodernisierung - Austausch Kesselhaus geplant 100% 100% 100% 100% 100% 90% Aktive Projekte 0 {"start":"","end":"","todos":[{"id":"msmzj493z5gie","title":"Abgabe","start":"2026-08-10","end":"2026-08-28","done":false,"progress":23,"dependsOn":[],"assignees":["p_fp"]}],"milestones":[{"id":"msmzi2pbbsv1s","name":"sxcdv","date":"2026-08-15","dependsOn":[],"position":""}],"people":[],"progress":0} {"LPH 1":{"Planung":1}} ["LPH 1"]
6 24-4102 Bürokomplex Dortmund, Lüftung - Filterkonzept in Abstimmung 100% 100% 100% 95% Aktive Projekte 1 {"start":"","end":"","todos":[],"milestones":[{"id":"msmzhc7wo8alc","name":"Test 123","date":"2026-08-10","dependsOn":[],"position":""}],"people":[],"progress":0}
7 26-4105 Wohnanlage Essen, Regenwasser - Planung Drainage 100% 100% Aktive Projekte 4
8 Badmodernisierung Privatkunde - Angebot erstellt 100% In Anbahnung 0
9 Wärmepumpen-Studie - Erstgespräch durchgeführt 100% In Anbahnung 1
10 HLS Planungscheckliste - Vorlage aktualisiert 100% Allgemein 0
11 21-4001 Feuerwehrgeräteh. Rodenkirchen Aktive Projekte 5
-7
View File
@@ -1,7 +0,0 @@
NAME;Rollen;Voller Name;E-Mail;Abwesenheiten
AS;Bauüberwachung;Andreas Simon;[];[]
FP;Planung;Frank Pietruschka;[];[]
MZ;Planung;Markus Zimmermann;[];[]
RR;Planung,Projektleitung;Robert Röllecke;[];[]
SL;Bauüberwachung;Steffan Licata;[];[]
TK;Planung;Tim Küppers;[];[]
1 NAME Rollen Voller Name E-Mail Abwesenheiten
2 AS Bauüberwachung Andreas Simon [] []
3 FP Planung Frank Pietruschka [] []
4 MZ Planung Markus Zimmermann [] []
5 RR Planung,Projektleitung Robert Röllecke [] []
6 SL Bauüberwachung Steffan Licata [] []
7 TK Planung Tim Küppers [] []