Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db63a29365 | ||
|
|
bc30f2900b | ||
|
|
18d04e2c4a | ||
|
|
4fc5acfebb | ||
|
|
1c176ea621 | ||
|
|
91cc898ec7 | ||
|
|
4e083bee73 |
No files matched your search
@@ -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.
@@ -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/`.
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
# 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
|
||||
|
||||
@@ -11,7 +13,7 @@ RUN npm ci --production
|
||||
COPY server.js ./
|
||||
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
|
||||
|
||||
USER node
|
||||
|
||||
@@ -1,148 +1,129 @@
|
||||
# Projektübersicht — README
|
||||
# Projektübersicht
|
||||
|
||||
## Übersicht
|
||||
Dieses Projekt ist eine lokale Single-Page-Webanwendung zur Verwaltung elektrotechnischer Projekte und Sitzungsübersichten. Die Anwendung besteht aus:
|
||||
Werkzeug für ein Ingenieurbüro der technischen Gebäudeausrüstung (TGA), um
|
||||
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/`
|
||||
- einem einfachen Node.js-Server in `server.js`
|
||||
- einem Datenspeicher in CSV-Dateien unter `sessions/`
|
||||
- einem Docker-Setup unter `Docker/`
|
||||
## Technologie
|
||||
|
||||
## 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
|
||||
- `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
|
||||
## Starten
|
||||
|
||||
- `package.json`
|
||||
- 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
|
||||
### Lokal (Node.js ≥ 22.13)
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
Danach ist die Anwendung erreichbar unter `http://localhost:3003`.
|
||||
|
||||
Falls der Port belegt ist:
|
||||
Erreichbar unter `http://localhost:3003`. Anderer Port:
|
||||
```bash
|
||||
PORT=3004 npm start
|
||||
```
|
||||
|
||||
### Mit Docker
|
||||
Im Projektstamm ausführen:
|
||||
|
||||
```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.
|
||||
|
||||
```
|
||||
http://localhost:3003
|
||||
```
|
||||
## Bereiche der Anwendung
|
||||
|
||||
## Wichtige Hinweise
|
||||
- Der Server benötigt Lese-/Schreibzugriff auf `sessions/`.
|
||||
- Die Docker-Compose-Konfiguration mountet `./sessions` in den Container. Achte darauf, dass der relative Pfad im Kontext korrekt ist, wenn du `docker compose` aus einem anderen Verzeichnis startest.
|
||||
- `js/store.js` und `js/server.js` arbeiten mit Abteilungsparametern und speichern Daten abteilungsweise in separaten Unterordnern.
|
||||
- Die Passwortfunktion ist nur ein einfacher Client-Mechanismus im Frontend und kein echter Sicherheitsmechanismus.
|
||||
- Globale Personen werden in `_people.csv` verwaltet. Wird eine Person global gelöscht, bleibt sie in bereits existierenden Sitzungen sichtbar.
|
||||
Seitenleiste, in dieser Reihenfolge:
|
||||
|
||||
- **Meine Aufgaben** — Dashboard und Startansicht nach dem Login: offene
|
||||
persönliche Aufgaben, Termine der eigenen Projekte, ausstehende
|
||||
Datei-Freigaben, Abwesenheiten von Kollegen.
|
||||
- **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
@@ -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
|
||||
Generated
+11
-1
@@ -11,7 +11,8 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"mammoth": "^1.13.0",
|
||||
"pdfjs-dist": "^6.3.289"
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"undici": "^8.11.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
@@ -1304,6 +1305,15 @@
|
||||
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"mammoth": "^1.13.0",
|
||||
"pdfjs-dist": "^6.3.289"
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"undici": "^8.11.2"
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
+1673
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
Vendored
BIN
Binary file not shown.
@@ -69,6 +69,21 @@
|
||||
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
|
||||
// nur einmal aufgebaut werden; renderAdminUsers() laeuft aber bei jedem
|
||||
// Wechsel in den Abschnitt.
|
||||
@@ -137,7 +152,7 @@
|
||||
<td>${esc(u.username)}</td>
|
||||
<td>${esc(u.displayName)}</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>${esc(u.initials || '')}</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:
|
||||
// peopleByRole() (js/utils.js) gruppiert ausschließlich über die Rollen —
|
||||
// das Konto erschiene weder als Board-Spalte noch in der Auslastung noch
|
||||
// im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe.
|
||||
if(!roles || roles.length === 0){
|
||||
// im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe. Für ein
|
||||
// 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.';
|
||||
return;
|
||||
}
|
||||
@@ -301,6 +318,7 @@
|
||||
document.querySelectorAll('#editUserRoleCheckboxes input[type="checkbox"]').forEach(cb => {
|
||||
cb.checked = userRoles.includes(cb.value);
|
||||
});
|
||||
onUserRoleChange('edit');
|
||||
document.getElementById('editUserError').textContent = '';
|
||||
document.getElementById('editUserAbsenceError').textContent = '';
|
||||
document.getElementById('editUserAbsenceStart').value = '';
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
// ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die
|
||||
// EINE Stelle, an der ein Abschnitt definiert wird — Schlüssel (zugleich
|
||||
// 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
|
||||
// Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters
|
||||
// auf — die Benutzertabelle hat zehn Spalten und scrollte sonst quer.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// (p.beauftragteLP). Deshalb erfasst der Dialog "Beauftragung" beides in EINEM
|
||||
// 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
|
||||
// js/globals.js, esc() aus js/utils.js, currentWeek()/save()/markWeekDirty()
|
||||
// aus js/store.js, isWeekLocked() aus js/weeks.js und openProjectPlan() aus
|
||||
|
||||
+4
-3
@@ -1,12 +1,12 @@
|
||||
// -----------------------------
|
||||
// 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
|
||||
// früheren rein clientseitigen Passwortschutz (js/tabs-auth.js) durch eine
|
||||
// echte serverseitige Session (server.js: /login, /logout, /whoami). Ohne
|
||||
// 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
|
||||
// übergeben) wie gewohnt weiter.
|
||||
//
|
||||
@@ -33,7 +33,8 @@ let currentUser = null;
|
||||
function renderUserBar(){
|
||||
const info = document.getElementById('userBarInfo');
|
||||
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 + ')';
|
||||
}
|
||||
const navAdminSection = document.getElementById('navAdminSection');
|
||||
|
||||
@@ -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
@@ -1,59 +1,73 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Board (Haupttabelle)
|
||||
// Projektübersicht — Board (Listenansicht)
|
||||
// -----------------------------
|
||||
// Diese Datei rendert das Projektboard (Tab "Projektboard" in `Test.html`),
|
||||
// die zentrale Tabelle mit Projekten, Status-Kommentaren, Personen-Spalten
|
||||
// (Anwesenheit/Zuordnung) und Leistungsphasen in Prozent. Sie wird von
|
||||
// `Test.html` nach `js/weeks.js` (vor `js/people/people.js`) geladen und stellt
|
||||
// globale Funktionen wie `renderBoard()`, `statusFocus()`, `cycleAssign()`,
|
||||
// `editLph()` und `delProj()` bereit. Sie nutzt `currentWeek()`/
|
||||
// Diese Datei rendert das Projektboard (Tab "Projektboard" in `App.html`) als
|
||||
// schlanke, aufklappbare Liste statt der früheren breiten Matrix (eine Spalte
|
||||
// je Person/Leistungsphase): jede Zeile zeigt auf einen Blick Kürzel-Avatare
|
||||
// der zugeordneten Personen, die aktuelle Leistungsphase samt Prozentwert und
|
||||
// eine einzeilige Status-Vorschau; ein Klick auf die Zeile klappt darunter das
|
||||
// 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`,
|
||||
// `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
|
||||
// Status-Editors; wird für die Formatierungs-Toolbar (`formatStatus`) benötigt.
|
||||
let currentStatusEditor = null;
|
||||
// currentStatusPid: Projekt-ID der gerade fokussierten Status-Zelle; wird beim
|
||||
// Speichern des Status (`statusBlur`) verwendet.
|
||||
let currentStatusPid = null;
|
||||
// boardRowOpen: UI-Zustand, welche Projekt-Zeile ihr Detailpanel aufgeklappt
|
||||
// darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt
|
||||
// außerhalb von renderBoard() und übersteht daher dessen Neuaufbauten (z. B.
|
||||
// nach einer Zuordnungs- oder Prozent-Änderung im offenen Panel). Nicht
|
||||
// persistiert — jede Sitzung startet mit eingeklappten Zeilen.
|
||||
let boardRowOpen = {};
|
||||
|
||||
// statusFocus: merkt sich das fokussierte Status-Element und die zugehörige
|
||||
// Projekt-ID (`el`, `pid`), damit die Toolbar und das Blur-Speichern sie nutzen.
|
||||
function statusFocus(el, pid){
|
||||
currentStatusEditor = el;
|
||||
currentStatusPid = pid;
|
||||
// toggleBoardRow: klappt das Detailpanel einer Projekt-Zeile auf bzw. zu.
|
||||
// Parameter: pid (Projekt-ID). Keine Rückgabe.
|
||||
function toggleBoardRow(pid){
|
||||
boardRowOpen[pid] = !boardRowOpen[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
|
||||
// des Projekts `pid` und räumt die Editor-Merker wieder ab, wenn `el` das
|
||||
// zuletzt fokussierte Element war.
|
||||
function statusBlur(el, pid){
|
||||
const val = statusValueFromHtml(el.innerHTML);
|
||||
editProj(pid, 'status', val);
|
||||
if(currentStatusEditor === el){ currentStatusEditor = null; currentStatusPid = null; }
|
||||
}
|
||||
|
||||
// statusValueFromHtml: normalisiert den HTML-Inhalt eines Status-Editors.
|
||||
// Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String
|
||||
// (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String.
|
||||
function statusValueFromHtml(html){
|
||||
// statusPreviewText: liefert eine einzeilige Klartext-Vorschau eines
|
||||
// Status-HTML-Werts (Tags entfernt, überflüssiger Leerraum/Zeilenumbrüche zu
|
||||
// einem Leerzeichen zusammengefasst) für die eingeklappte Zeile — die volle
|
||||
// Formatierung bleibt dem aufgeklappten Editor vorbehalten. Parameter: html
|
||||
// (String). Rückgabe: String.
|
||||
function statusPreviewText(html){
|
||||
if(!html) return '';
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
const text = (tmp.textContent || tmp.innerText || '').replace(/\u00A0/g, ' ');
|
||||
return text.trim() === '' ? '' : html;
|
||||
// Bereinigen, BEVOR der Wert ins DOM geht: Auch ein nicht eingehängtes
|
||||
// Element lädt Ressourcen und führt deren Fehler-Handler aus, ungefiltertes
|
||||
// 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',
|
||||
// 'foreColor') auf das fokussierte Status-Element an. Parameter: `command`,
|
||||
// optionaler `value`; keine Rückgabe.
|
||||
function formatStatus(command, value){
|
||||
if(!currentStatusEditor) return;
|
||||
currentStatusEditor.focus();
|
||||
document.execCommand(command, false, value || null);
|
||||
}
|
||||
// HINWEIS: statusBlur() ist entfallen. Seit der Kommentar-Editor ein
|
||||
// <textarea> ist (renderCommentEditor/saveBoardComment), gab es keinen Aufrufer
|
||||
// mehr; die Funktion schrieb ungefiltertes innerHTML in p.status und wäre beim
|
||||
// erneuten Verdrahten ein Einfallstor gewesen.
|
||||
|
||||
// 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
|
||||
// (Rollenname kleingeschrieben, Leerzeichen → Bindestrich). Parameter: `role`
|
||||
@@ -65,33 +79,537 @@ function roleClass(role){
|
||||
return 'role-' + role.toLowerCase().replace(/\s+/g,'-');
|
||||
}
|
||||
|
||||
// renderBoard: baut die HTML-Tabelle für das Board auf (Kopf, Zeilen, Zellen).
|
||||
// Liest die aktuelle Woche, gruppiert Projekte nach Gruppe, sortiert sie nach
|
||||
// `order` und erzeugt die Status-, Personen- und LPH-Zellen. Bei gesperrten
|
||||
// Sitzungen werden alle Bearbeitungs-Handler deaktiviert und ein Hinweis samt
|
||||
// Entsperren-Button angezeigt. Aktualisiert außerdem die Legende mit den
|
||||
// vollständigen Namen und Anwesenheiten. Keine Parameter, keine Rückgabe.
|
||||
// renderBoardRow: baut die eingeklappte Zeile eines Projekts (Nr., Name +
|
||||
// Tags, Status-Vorschau, Avatar-Stapel der Zugeordneten, Phasen-Badge) sowie
|
||||
// bei Bedarf das aufgeklappte Detailpanel darunter. Parameter: p (Projekt),
|
||||
// allPeople (Array `{person, role}`, alle Rollen), locked (Boolean).
|
||||
// Rückgabe: HTML-String.
|
||||
// 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(){
|
||||
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 table = document.getElementById('boardTable');
|
||||
const grouped = peopleByRole(w);
|
||||
const allPeople = [];
|
||||
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
|
||||
|
||||
// Kopfzeilen: Rollen und Leistungsphasen
|
||||
let h1 = `<tr><th rowspan="2">Pr.-Nr.</th><th rowspan="2">Bezeichnung</th><th rowspan="2">Status / Kommentar</th>`;
|
||||
ROLES.forEach(r=>{ h1 += `<th class="${roleClass(r)}" colspan="${Math.max(1,grouped[r].length)}">${r}</th>`; });
|
||||
h1 += `<th colspan="${LPHS.length}">Leistungsphasen (%)</th><th rowspan="2"></th></tr>`;
|
||||
|
||||
let h2 = `<tr>`;
|
||||
ROLES.forEach(r=>{
|
||||
if(grouped[r].length===0) h2 += `<th class="rot ${roleClass(r)}">–</th>`;
|
||||
grouped[r].forEach(entry=> h2 += `<th class="rot ${roleClass(entry.role)}">${entry.person.name}</th>`);
|
||||
});
|
||||
LPHS.forEach(l=> h2 += `<th class="rot">${l}</th>`);
|
||||
h2 += `</tr>`;
|
||||
|
||||
// Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen
|
||||
// Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen.
|
||||
//
|
||||
// Alle vier Standardgruppen werden vorbelegt, auch wenn sie leer sind: Sonst
|
||||
// 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
|
||||
// Projekte-Tab übernommen, der hierin aufgegangen ist.)
|
||||
const groups = {};
|
||||
GROUP_ORDER.forEach(g => { groups[g] = []; });
|
||||
w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); });
|
||||
|
||||
// Projekte nach ihrer Reihenfolge sortieren
|
||||
@@ -99,169 +617,29 @@ function renderBoard(){
|
||||
groups[g].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
});
|
||||
|
||||
let body = "";
|
||||
const allPeople = [];
|
||||
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
|
||||
|
||||
// Jede Gruppe als Abschnitt, dann Zeilen für Projekte
|
||||
let html = "";
|
||||
sortGroups(Object.keys(groups)).forEach(g=>{
|
||||
const colspan = 3 + allPeople.length + LPHS.length + 1; // Spaltenanzahl für Gruppentitel
|
||||
body += `<tr class="group-header"><td colspan="${colspan}">${esc(g)}</td></tr>`;
|
||||
groups[g].forEach(p=>{
|
||||
body += `<tr>`;
|
||||
body += `<td class="left">${esc(p.nr)}</td>`;
|
||||
body += `<td class="left proj-name" style="cursor:pointer" onclick="openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</td>`;
|
||||
// Status-Editor: contenteditable mit kleiner Toolbar (Fett, Farben, Absätze mit Enter)
|
||||
const statusClass = p.statusState === 'new' ? 'status-new' : p.statusState === 'duplicated' ? 'status-duplicated' : '';
|
||||
const statusEditable = locked ? 'false' : 'true';
|
||||
const statusToolbar = locked ? '' : `
|
||||
<div class="status-toolbar" tabindex="-1">
|
||||
<button type="button" title="Fett" onmousedown="event.preventDefault();formatStatus('bold')"><b>B</b></button>
|
||||
<button type="button" title="Rot" class="color-red" onmousedown="event.preventDefault();formatStatus('foreColor','#c53030')">A</button>
|
||||
<button type="button" title="Grün" class="color-green" onmousedown="event.preventDefault();formatStatus('foreColor','#2f855a')">A</button>
|
||||
<button type="button" title="Blau" class="color-blue" onmousedown="event.preventDefault();formatStatus('foreColor','#2b6cb0')">A</button>
|
||||
<button type="button" title="Schwarz" class="color-black" onmousedown="event.preventDefault();formatStatus('foreColor','#1a1a1a')">A</button>
|
||||
<button type="button" title="Absatz" onmousedown="event.preventDefault();formatStatus('formatBlock','P')">¶</button>
|
||||
</div>`;
|
||||
const statusClick = locked ? '' : `onfocus="statusFocus(this,'${p.id}')" onblur="statusBlur(this,'${p.id}')"`;
|
||||
const safeStatus = sanitizeStatusHtml(p.status || '');
|
||||
body += `<td class="status ${locked ? 'locked-cell' : ''}">
|
||||
${statusToolbar}
|
||||
<div class="status-edit ${statusClass}" contenteditable="${statusEditable}" ${statusClick}>${safeStatus}</div>
|
||||
</td>`;
|
||||
|
||||
// Personen-Spalten: Klick wechselt Zustand (nicht, optional, Hauptverantwortung)
|
||||
allPeople.forEach(entry=>{
|
||||
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0; // 0=keine,1=Haupt,2=optional
|
||||
const cls = state===1?'on':state===2?'optional':'';
|
||||
const txt = state===1?'X':state===2?'(X)':'';
|
||||
const click = locked ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`;
|
||||
body += `<td class="person-cell ${roleClass(entry.role)} ${cls} ${locked ? 'locked-cell' : ''}" ${click}>${txt}</td>`;
|
||||
});
|
||||
|
||||
// LPH-Spalten mit Prozentwerten und Fortschrittsbalken;
|
||||
// die amtierende Leistungsphase (Tab "Projekte") wird farblich markiert.
|
||||
const aktLp = lpActiveList(p)[0];
|
||||
LPHS.forEach(l=>{
|
||||
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
|
||||
const aktiv = l === aktLp;
|
||||
const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
|
||||
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${v}" ${inputAttr} placeholder="-">`;
|
||||
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
|
||||
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
|
||||
body += `</td>`;
|
||||
});
|
||||
body += `</tr>`;
|
||||
});
|
||||
|
||||
html += `<div class="section-title">${esc(g)}</div>`;
|
||||
// Klasse "projects-sortable": Die Ablage-Handler aus
|
||||
// js/projects/projects.js (handleDrop/reorderProjects) finden ihre
|
||||
// Container über diesen Selektor. Sie bleiben dort, weil sie auch die
|
||||
// Reihenfolge-Logik für die Daten enthalten — hier wird nur dieselbe
|
||||
// Ablagefläche aufgespannt.
|
||||
html += `<div class="board-list projects-sortable" data-group="${esc(g)}" ${locked ? '' : 'ondragover="handleDragOver(event)" ondrop="handleDrop(event)"'}>`;
|
||||
groups[g].forEach(p=>{ html += renderBoardRow(p, allPeople, locked, w.label, BOARD_OPTS); });
|
||||
if(groups[g].length === 0) html += `<div class="hint" style="padding:6px 2px 12px">Keine Projekte — Projekt hierher ziehen.</div>`;
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
let lockNotice = '';
|
||||
// Sperr-Hinweis über der Projektliste. Er stand früher im Container der
|
||||
// Kürzel-Legende unter dem Board; seit die Legende entfallen ist, gehört er
|
||||
// dorthin, wo er gilt — über die Liste, die man nicht bearbeiten kann.
|
||||
if(locked){
|
||||
lockNotice = `<div class="lock-notice" style="margin-bottom:10px;padding:8px 12px;background:#fff5f5;border:1px solid #fc8181;border-radius:6px;color:#c53030;font-weight:bold;font-size:12px;">
|
||||
Diese Sitzung ist gesperrt. Bearbeiten nur nach Passworteingabe möglich.
|
||||
<button type="button" class="secondary small" style="margin-left:12px;" onclick="unlockCurrentWeek()">Entsperren</button>
|
||||
</div>`;
|
||||
html = `<div class="lock-notice">
|
||||
Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich.
|
||||
<button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button>
|
||||
</div>` + 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();
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 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 next = (cur+1)%3;
|
||||
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
|
||||
@@ -287,36 +665,9 @@ function editLph(pid,lph,val){
|
||||
val = val.trim();
|
||||
if(val==="") delete p.lph[lph];
|
||||
else p.lph[lph]=val.replace('%','');
|
||||
save(); renderBoard();
|
||||
save(); renderBoard(); renderWochenbericht();
|
||||
}
|
||||
|
||||
// editProj: aktualisiert ein Feld eines Projekts; für Status wird zusätzlich
|
||||
// ein flag `statusState` gesetzt (='new' beim Anlegen eines neuen Kommentars),
|
||||
// wenn zuvor noch kein Status existierte. Parameter: `pid` (Projekt-ID),
|
||||
// `field` (Feldname), `val` (neuer Wert). Keine Rückgabe.
|
||||
function editProj(pid,field,val){
|
||||
if(guardWeekLocked()) return;
|
||||
const p = currentWeek().projects.find(x=>x.id===pid);
|
||||
if(!p) return;
|
||||
if(field==='status'){
|
||||
const before = (p.status||"").trim();
|
||||
const after = val.trim();
|
||||
if(before === "" && after !== ""){
|
||||
p.statusState = 'new';
|
||||
} else if(after === ""){
|
||||
delete p.statusState;
|
||||
}
|
||||
}
|
||||
p[field]=val; save();
|
||||
}
|
||||
|
||||
// delProj: löscht ein Projekt (per Bestätigungsdialog) aus der aktuellen
|
||||
// Sitzung. Parameter `pid`: Projekt-ID. Keine Rückgabe; bei gesperrter Woche
|
||||
// abgebrochen.
|
||||
function delProj(pid){
|
||||
if(guardWeekLocked()) return;
|
||||
if(!confirm("Projekt aus dieser Sitzung löschen?")) return;
|
||||
const w = currentWeek();
|
||||
w.projects = w.projects.filter(x=>x.id!==pid);
|
||||
save(); renderBoard();
|
||||
}
|
||||
// HINWEIS: delProj() ist entfallen — seit der neuen Zeilendarstellung des
|
||||
// Boards gibt es dort keinen Löschen-Knopf mehr. Projekte werden im
|
||||
// Projekte-Tab entfernt (delProjectFromList(), js/projects/projects.js).
|
||||
@@ -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,7 +1,7 @@
|
||||
// -----------------------------
|
||||
// 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
|
||||
// nach dem Login; alles darin stammt aus einem einzigen GET /my-dashboard.
|
||||
//
|
||||
|
||||
+49
-76
@@ -1,19 +1,16 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — GANTT-Diagramm (gemeinsam genutzt)
|
||||
// -----------------------------
|
||||
// Gemeinsames Modul für das GANTT-Diagramm: Es wird von Test.html (nach
|
||||
// js/load/load.js) UND von project-plan.html geladen – dort zusammen mit globals,
|
||||
// utils, ui, date-picker, csv, server und den js/plan/*-Modulen, aber OHNE
|
||||
// js/store.js, js/weeks.js, js/board/board.js usw. Es darf daher auf der Plan-Seite
|
||||
// keine Hauptseiten-spezifischen Funktionen voraussetzen; Funktionen, die
|
||||
// currentWeek()/isWeekLocked() aus js/weeks.js brauchen (renderGantt,
|
||||
// fitGanttRange, openProjectPlan), werden nur vom Hauptfenster aufgerufen.
|
||||
// Das Modul stellt u. a. buildGanttHtml(), renderGantt(), fitGanttRange(),
|
||||
// projectAssignees(), ganttRangeFromProjects() sowie openProjectPlan()/
|
||||
// findProjectForPlan() bereit. Die Plan-Seite erweitert buildGanttHtml() über
|
||||
// js/plan/plan-build-gantt.js und bezieht Beteiligte/Range über
|
||||
// projectAssignees()/ganttRangeFromProjects() aus diesem Modul.
|
||||
|
||||
// Gemeinsames Modul für das GANTT-Diagramm. Es wird von App.html nach
|
||||
// js/load/load.js geladen und ist die Grundlage sowohl der Übersichts-
|
||||
// Diagramme als auch des Terminplans im Projektbereich: die Module unter
|
||||
// js/plan/ setzen darauf auf und müssen deshalb NACH dieser Datei stehen.
|
||||
// Das Modul stellt u. a. buildGanttHtml(), projectAssignees(),
|
||||
// ganttRangeFromProjects() sowie openProjectPlan()/closePlanPage()/
|
||||
// bereit. js/plan/plan-build-gantt.js umhüllt
|
||||
// buildGanttHtml() und ergänzt sie um TODO- und Meilensteinzeilen; diese
|
||||
// Erweiterung greift nur bei gesetzter Option showTodos, die Aufrufe der
|
||||
// Übersicht bleiben also unverändert.
|
||||
// ganttModalProjectId: reservierte Projekt-ID für GANTT-Modal-Dialoge (zurzeit ungenutzt).
|
||||
let ganttModalProjectId = null;
|
||||
|
||||
@@ -35,7 +32,7 @@ function ensurePlan(p){
|
||||
function projectAssignees(p, week = null){
|
||||
const w = week || currentWeek();
|
||||
const people = w ? (w.people || []) : [];
|
||||
const globalList = db.globalPeople[currentDepartment] || [];
|
||||
const globalList = db.globalPeople || [];
|
||||
const result = [];
|
||||
people.forEach(person => {
|
||||
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
|
||||
// Personen, jeweils erweitert um padDays Tage (Standard 7). Parameter:
|
||||
// projects = Projekt-Array, padDays = Puffer; Rückgabe: { start, end } als Date.
|
||||
function ganttRangeFromProjects(projects, padDays = 7){
|
||||
// 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;
|
||||
projects.forEach(p => {
|
||||
const plan = p.plan;
|
||||
@@ -89,8 +91,10 @@ function ganttRangeFromProjects(projects, padDays = 7){
|
||||
});
|
||||
});
|
||||
});
|
||||
// Urlaub berücksichtigen
|
||||
(db.globalPeople[currentDepartment] || []).forEach(person => {
|
||||
// Urlaub berücksichtigen (nur von Personen, deren Zeilen auch gezeichnet werden)
|
||||
const relevant = personIds ? new Set(personIds) : null;
|
||||
(db.globalPeople || []).forEach(person => {
|
||||
if(relevant && !relevant.has(person.id)) return;
|
||||
(person.absences || []).forEach(a => {
|
||||
const s = parseDateISO(a.start), e = parseDateISO(a.end);
|
||||
if(s){ if(!min || s < min) min = s; if(!max || s > max) max = s; }
|
||||
@@ -105,37 +109,11 @@ function ganttRangeFromProjects(projects, padDays = 7){
|
||||
return { start: addDays(min, -padDays), end: addDays(max, padDays) };
|
||||
}
|
||||
|
||||
// fitGanttRange: setzt die Datumsfelder (ganttStart/ganttEnd) auf den
|
||||
// automatisch berechneten Projekt-Zeitrahmen und rendert das Diagramm neu.
|
||||
function fitGanttRange(){
|
||||
const w = currentWeek(); if(!w) return;
|
||||
const range = ganttRangeFromProjects(w.projects, 7);
|
||||
document.getElementById('ganttStart').value = formatDateISO(range.start);
|
||||
document.getElementById('ganttEnd').value = formatDateISO(range.end);
|
||||
renderGantt();
|
||||
}
|
||||
|
||||
// renderGantt: rendert das GANTT-Diagramm der aktuellen Woche in
|
||||
// ganttContainer. Nutzt die Datumsfelder bzw. fällt auf den Projekt-Zeitrahmen
|
||||
// zurück; bei gesperrter Woche ist die Ansicht nicht editierbar.
|
||||
function renderGantt(){
|
||||
const w = currentWeek();
|
||||
const container = document.getElementById('ganttContainer');
|
||||
if(!w || !container){
|
||||
if(container) container.innerHTML = '<div class="hint">Keine Sitzung ausgewählt.</div>';
|
||||
return;
|
||||
}
|
||||
let start = parseDateISO(document.getElementById('ganttStart').value);
|
||||
let end = parseDateISO(document.getElementById('ganttEnd').value);
|
||||
if(!start || !end){
|
||||
const range = ganttRangeFromProjects(w.projects, 7);
|
||||
start = range.start; end = range.end;
|
||||
document.getElementById('ganttStart').value = formatDateISO(start);
|
||||
document.getElementById('ganttEnd').value = formatDateISO(end);
|
||||
}
|
||||
const showVacation = document.getElementById('ganttShowVacation').checked;
|
||||
container.innerHTML = buildGanttHtml(w.projects, start, end, { showVacation, editable: !isWeekLocked(w) });
|
||||
}
|
||||
// HINWEIS: Die frühere GANTT-Ansicht der Hauptseite (renderGantt/fitGanttRange)
|
||||
// ist entfallen. Sie griff auf die Elemente #ganttContainer/#ganttStart/
|
||||
// #ganttEnd/#ganttShowVacation zu, die es in keiner der drei HTML-Seiten mehr
|
||||
// gibt — ein Aufruf hätte sofort geworfen. Das GANTT lebt heute ausschließlich
|
||||
// im Projektbereich (renderPlanGantt/fitPlanGanttRange in js/plan/plan-gantt.js).
|
||||
|
||||
// buildGanttHtml: erzeugt das HTML des GANTT-Diagramms (Kopf mit Monaten/
|
||||
// KW-Zellen, Wochenend-Hintergrund, Heute-Linie, Urlaubs- und Projektzeilen
|
||||
@@ -250,7 +228,7 @@ function buildGanttHtml(projects, start, end, options = {}){
|
||||
|
||||
// Urlaubszeilen
|
||||
if(showVacation){
|
||||
let allPeople = db.globalPeople[currentDepartment] || [];
|
||||
let allPeople = db.globalPeople || [];
|
||||
// Optional: nur Urlaub bestimmter Personen anzeigen
|
||||
if(options.vacationPeople && Array.isArray(options.vacationPeople)){
|
||||
const allowed = new Set(options.vacationPeople);
|
||||
@@ -327,36 +305,31 @@ function buildGanttHtml(projects, start, end, options = {}){
|
||||
|
||||
// ---------- Projektplan-Navigation ----------
|
||||
|
||||
// openProjectPlan: öffnet project-plan.html in einem neuen Tab. Weil
|
||||
// Projekt-IDs nicht persistent sind, werden Wochen-Label und Proj.-Nr. als
|
||||
// URL-Parameter übergeben; Abteilung und Projektname stehen im Pfad nach dem
|
||||
// Schema "/<Abteilung>/Projektplan/<Projektname>". Parameter: pid =
|
||||
// Projekt-ID der aktuellen Woche. Kein Rückgabewert.
|
||||
// openProjectPlan: öffnet den Projektbereich EINES Projekts. Das war einmal
|
||||
// eine Navigation auf ein eigenes Dokument (project-plan.html); inzwischen
|
||||
// ist der Projektbereich der Bereich #plan derselben Seite, also genügt ein
|
||||
// gewöhnlicher Bereichswechsel — Menüband und Seitenleiste bleiben stehen,
|
||||
// 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){
|
||||
const w = currentWeek(); if(!w) return;
|
||||
const p = w.projects.find(x => x.id === pid); if(!p) return;
|
||||
// IDs sind nicht persistent (werden bei jedem CSV-Laden neu erzeugt).
|
||||
// Daher werden stabile Werte (Label, Projekt-Nr.) in der URL übergeben.
|
||||
// Absoluter Pfad mit Abteilung und Projektname als Route-Segment, damit der
|
||||
// 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');
|
||||
setPlanTarget(w.label, p.nr || '', p.name || '');
|
||||
showTab('plan');
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// Diese Funktion wird von project-plan.html aufgerufen, um das aktuelle
|
||||
// Wochen-/Projekt-Objekt im Hauptfenster zu finden.
|
||||
// Parameter: department, weekId, projectId; Rückgabe: { week, project } oder
|
||||
// null, wenn die Woche/Abteilung nicht (synchron) auflösbar ist.
|
||||
function findProjectForPlan(department, weekId, projectId){
|
||||
if(currentDepartment !== department){
|
||||
const targetWeek = db.weeks.find(w => w.id === weekId);
|
||||
if(!targetWeek) return null;
|
||||
// temporär umschalten ist unpraktisch, daher versuchen wir über load-all
|
||||
return null;
|
||||
}
|
||||
const w = db.weeks.find(x => x.id === weekId);
|
||||
if(!w) return null;
|
||||
return { week: w, project: w.projects.find(x => x.id === projectId) };
|
||||
// closePlanPage: Gegenstück zu openProjectPlan() für den "Schließen"-Knopf
|
||||
// in der Kopfzeile des Projektbereichs. Früher ging es hier in der
|
||||
// Browser-Historie zurück, weil der Projektbereich ein eigenes Dokument war.
|
||||
// Jetzt ist es ein schlichter Bereichswechsel zurück auf das Projektboard
|
||||
// (bzw. auf den Bereich, aus dem geöffnet wurde). Kein Parameter, keine
|
||||
// Rückgabe.
|
||||
function closePlanPage(){
|
||||
const back = (typeof previousTab === 'string' && previousTab && previousTab !== 'plan') ? previousTab : 'board';
|
||||
showTab(back);
|
||||
}
|
||||
+11
-2
@@ -1,7 +1,7 @@
|
||||
// -----------------------------
|
||||
// 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,
|
||||
// Farben, Passwort-Hash) sowie den globalen In-Memory-Zustand (db, currentTab)
|
||||
// fest. Sie stellt keine Funktionen, sondern nur Daten bereit, auf die alle
|
||||
@@ -45,6 +45,14 @@ let APP_SETTINGS = {
|
||||
vollzeitStundenProWoche: 40,
|
||||
auslastungSchwelleGelb: 0.75,
|
||||
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: {
|
||||
'LPH 1': { 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',
|
||||
planung: 'Planung',
|
||||
dashboard:'MeineAufgaben',
|
||||
wand: 'Wandtafel',
|
||||
settings: 'Einstellungen',
|
||||
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.
|
||||
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>",
|
||||
// dazu ?weekLabel=…&projNr=… als Parameter. Steht nicht in TAB_SLUGS, weil
|
||||
// die Adresse das geöffnete Projekt mitträgt und deshalb nicht fest ist —
|
||||
|
||||
@@ -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
@@ -2,10 +2,10 @@
|
||||
// Projektübersicht — Init / Start
|
||||
// -----------------------------
|
||||
// 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
|
||||
// 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,
|
||||
// showTab()/TAB_PANELS aus js/tabs-auth.js sowie die Render-Funktionen
|
||||
// renderBoard()/renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()
|
||||
@@ -58,21 +58,6 @@ function initUiState(){
|
||||
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
|
||||
// Benutzerverwaltung eigene Bereiche sind, ist sie die einzige Seite. Der eigentliche
|
||||
// Start läuft erst nach erfolgreicher Anmeldung (checkAuthAndInit(), js/auth.js) —
|
||||
|
||||
+8
-27
@@ -1,14 +1,14 @@
|
||||
// -----------------------------
|
||||
// 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 →
|
||||
// people → projects → load → gantt → …). Es stellt renderLoad(), das LP-
|
||||
// Aufwand-Modal und das Neues-Projekt-Modal bereit. Genutzt werden LPHS/ROLES
|
||||
// aus js/globals.js, currentWeek()/save() aus js/store.js, guardWeekLocked()
|
||||
// aus js/weeks.js sowie peopleByRole()/assignKey() aus js/utils.js.
|
||||
// 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.
|
||||
//
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// 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
|
||||
// Nachkommastelle. Ab 100 h ohne Nachkomma — die Genauigkeit einer
|
||||
// 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
|
||||
// früheren Festwerte (#c53030/#d69e2e/#2f855a) waren für den hellen Grund
|
||||
// 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
|
||||
// weiterhin pdfLoadAmpelColor() mit druckfreundlichen Festwerten — ein PDF
|
||||
// hat kein Thema.
|
||||
// sind dort hellere Töne (siehe styles.css). Ein Gegenstück mit
|
||||
// druckfreundlichen Festwerten gab es für den PDF-Export (pdfLoadAmpelColor);
|
||||
// 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)';
|
||||
}
|
||||
// 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 ----------
|
||||
|
||||
@@ -531,14 +513,13 @@ function projektFarbe(name){
|
||||
// Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person
|
||||
// überhaupt im Teilnehmerkreis stand — nicht durch alle vorhandenen.
|
||||
function renderLoad(){
|
||||
const scope = document.getElementById('loadScope').value;
|
||||
const selectedWeek = currentWeek();
|
||||
const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []);
|
||||
const weeks = selectedWeek ? [selectedWeek] : [];
|
||||
if(weeks.length === 0){
|
||||
document.getElementById('loadGrid').innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
|
||||
return;
|
||||
}
|
||||
const grouped = scope==='all' ? peopleByRole(db.weeks) : peopleByRole(selectedWeek);
|
||||
const grouped = peopleByRole(selectedWeek);
|
||||
const stats = {};
|
||||
const personen = {}; // fullName -> { person, stunden, rollen:Set }
|
||||
ROLES.forEach(r=>{
|
||||
|
||||
+49
-9
@@ -62,7 +62,15 @@ function pdfStatusText(htmlStatus){
|
||||
if(!htmlStatus) return { text: '', style: 'td' };
|
||||
|
||||
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 = [];
|
||||
|
||||
@@ -175,13 +183,13 @@ function pdfNormalizeColor(v){
|
||||
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.
|
||||
// Gibt null zurück, wenn Laden oder Dekodieren fehlschlägt.
|
||||
async function loadLogoPng(size){
|
||||
size = size || 256;
|
||||
try {
|
||||
const res = await fetch(baseUrl() + '/dein_bild.ico');
|
||||
const res = await fetch(baseUrl() + '/logo.ico');
|
||||
if(!res.ok) return null;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -959,19 +967,51 @@ async function exportProtokollPDF(protokollId){
|
||||
return cell;
|
||||
};
|
||||
const nummer = nummerById.get(p.id) || '';
|
||||
const nummerText = p.neu
|
||||
? { text: [nummer, { text: '\nNEU', fontSize: 5.5, bold: true, color: '#8A6D00' }] }
|
||||
: { text: nummer };
|
||||
const statusCell = zelle({ text: p.erledigt ? 'erledigt' : 'offen', alignment: 'center' });
|
||||
// Erstellungsdatum steht ausgegraut unter der Nummer statt in
|
||||
// einer eigenen Spalte — auf Eriks Wunsch, damit die Tabelle
|
||||
// keine zusätzliche Spalte bekommt.
|
||||
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([
|
||||
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: formatDateDE(parseDateISO(p.termin)) || '—' }),
|
||||
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] });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+112
-325
@@ -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`)
|
||||
// und verwaltet die globale Personenliste pro Abteilung: Anlegen, Bearbeiten,
|
||||
// Löschen, Rollen-Auswahl, Drag-and-Drop-Sortierung sowie Urlaub/Abwesenheiten.
|
||||
// Sie wird von `Test.html` nach `js/board/board.js` (vor `js/projects/projects.js`) geladen
|
||||
// und stellt globale Funktionen wie `addPerson()`, `delPerson()`,
|
||||
// `editPerson()`, `renderPeople()` und `addAbsence()` bereit. Genutzt wird sie
|
||||
// u.a. von `js/board/board.js` (`renderBoard`), `js/gantt.js` und
|
||||
// `js/plan/plan-modals.js`; die Sortier-Hilfe `getDragAfterElement()` liegt in
|
||||
// `js/projects/projects.js`.
|
||||
// Diese Datei rendert den Personen-Tab (Tab "Projektbeteiligte" in `App.html`)
|
||||
// als reine Anzeige. Seit der Ablösung von people/person_roles/person_absences
|
||||
// (server.js) werden Projektbeteiligte ausschließlich in der zentralen
|
||||
// Benutzerverwaltung (Bereich „Admin“) gepflegt; Anlegen/Bearbeiten/Löschen/
|
||||
// Drag-and-Drop-Sortierung/Abwesenheits-Formulare gibt es hier bewusst nicht
|
||||
// mehr. EINE Ausnahme ist geblieben bzw. wieder dazugekommen: der
|
||||
// Teilnehmerkreis der aktuellen Sitzung (`currentWeek().people`). Er entsteht
|
||||
// nur beim Anlegen einer neuen Sitzung und wird beim Duplizieren
|
||||
// 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
|
||||
// synchronisiert sie in die aktuelle Sitzung.
|
||||
function addPerson(){
|
||||
if(guardWeekLocked()) return;
|
||||
const w = currentWeek();
|
||||
if(!w) return;
|
||||
const name = document.getElementById('newPersonName').value.trim();
|
||||
const fullName = document.getElementById('newPersonFullName').value.trim();
|
||||
const email = document.getElementById('newPersonEmail').value.trim();
|
||||
if(!name){ alert("Kürzel eingeben."); return; }
|
||||
const roles = [...document.querySelectorAll('input[name="newPersonRoles"]:checked')].map(el=>el.value);
|
||||
if(roles.length===0){ alert("Bitte mindestens eine Rolle auswählen."); return; }
|
||||
|
||||
const person = {id:personIdForName(name), name, fullName:fullName||name, email:email, role:roles};
|
||||
|
||||
// Globale Liste aktualisieren
|
||||
if(!Array.isArray(db.globalPeople[currentDepartment])) db.globalPeople[currentDepartment] = [];
|
||||
const existing = db.globalPeople[currentDepartment].find(p => p.id === person.id);
|
||||
if(existing){
|
||||
alert('Eine Person mit diesem Kürzel existiert bereits.');
|
||||
return;
|
||||
}
|
||||
db.globalPeople[currentDepartment].push(person);
|
||||
|
||||
// Aktuelle Sitzung aktualisieren
|
||||
if(!Array.isArray(w.people)) w.people = [];
|
||||
if(!w.people.find(p => p.id === person.id)){
|
||||
w.people.push(JSON.parse(JSON.stringify(person)));
|
||||
}
|
||||
|
||||
document.getElementById('newPersonName').value='';
|
||||
document.getElementById('newPersonFullName').value='';
|
||||
document.getElementById('newPersonEmail').value='';
|
||||
document.querySelectorAll('input[name="newPersonRoles"]').forEach(el=>el.checked=false);
|
||||
save(); renderPeople(); renderBoard();
|
||||
}
|
||||
|
||||
// delPerson: entfernt eine Person aus der globalen Liste. Bereits in
|
||||
// historischen Sitzungen verwendete Personen bleiben dort sichtbar, da
|
||||
// jede Sitzung ihre eigene Kopie der Personendaten speichert.
|
||||
function delPerson(id){
|
||||
if(!confirm("Person global löschen? Sie bleibt in bereits existierenden Sitzungen sichtbar, wird aber in neuen Sitzungen nicht mehr angeboten.")) return;
|
||||
|
||||
// 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.
|
||||
// renderPeople: baut die schreibgeschützte Liste (`.people-readonly`, eine
|
||||
// Bubble-Card mit den Personen als Listenzeilen) der Personen für den
|
||||
// Personen-Tab auf (Zeilen mit Kürzel, Vollname, E-Mail, Rollen,
|
||||
// aufklappbaren Abwesenheiten). Kein Formular, keine Lösch-/
|
||||
// Bearbeiten-Aktionen, kein Drag-Handle. Zeigt zusätzlich einen Hinweis auf
|
||||
// die zentrale Benutzerverwaltung (Link nur für Admins sichtbar). Keine
|
||||
// Parameter, keine Rückgabe.
|
||||
function renderPeople(){
|
||||
const people = db.globalPeople[currentDepartment] || [];
|
||||
const locked = isWeekLocked(currentWeek());
|
||||
let html = `<div class="people-sortable"
|
||||
ondragover="handlePersonDragOver(event)"
|
||||
ondrop="handlePersonDrop(event)">`;
|
||||
people.forEach(p=>{
|
||||
const people = db.globalPeople || [];
|
||||
const isAdmin = typeof currentUser !== 'undefined' && !!currentUser && currentUser.role === 'admin';
|
||||
const w = currentWeek();
|
||||
const inWeek = new Set((w && Array.isArray(w.people) ? w.people : []).map(p => p.id));
|
||||
const locked = !w || isWeekLocked(w);
|
||||
|
||||
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 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-header" onclick="togglePersonAbsences('${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">${isOpen ? '▼' : '▶'}</span>
|
||||
<input type="text" value="${esc(p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','name',this.value)" style="width:80px;" placeholder="Kürzel">
|
||||
<input type="text" value="${esc(p.fullName||p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','fullName',this.value)" style="width:180px;" placeholder="Voller Name">
|
||||
<input type="text" value="${esc(p.email)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','email',this.value)" style="width:180px;" placeholder="E-Mail">
|
||||
<div class="role-checkboxes" onclick="event.stopPropagation()">${ROLES.map(rr=>`<label><input type="checkbox" onchange="togglePersonRole('${p.id}','${rr}',this.checked)" value="${rr}" ${p.role.includes(rr)?'checked':''}> ${rr}</label>`).join('')}</div>
|
||||
<button class="danger small" onclick="event.stopPropagation(); delPerson('${p.id}')">Löschen</button>
|
||||
html += `<div class="person-header" onclick="togglePersonAbsences('${esc(p.id)}')">
|
||||
<span class="person-toggle">${icon('chevronRight')}</span>
|
||||
<strong>${esc(p.name)}</strong>
|
||||
<span>${esc(p.fullName || '')}</span>
|
||||
<span class="hint" style="margin:0">${esc(p.email || '')}</span>
|
||||
<span>${roles.length ? roles.map(r => esc(r)).join(', ') : '<span class="hint" style="margin:0">Keine Rolle</span>'}</span>
|
||||
<span class="person-week-state">${dabei ? 'in dieser Sitzung' : '<span class="hint" style="margin:0">nicht in dieser Sitzung</span>'}</span>
|
||||
${weekAction}
|
||||
</div>`;
|
||||
|
||||
// Aufklappbare Abwesenheiten
|
||||
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){
|
||||
html += `<div class="absence-list">`;
|
||||
absences.forEach(a => {
|
||||
html += `<div class="absence-chip">
|
||||
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
|
||||
${a.note ? `<small>${esc(a.note)}</small>` : ''}
|
||||
${locked ? '' : `<button class="danger small" onclick="deleteAbsence('${p.id}','${a.id}')">×</button>`}
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
if(!locked){
|
||||
html += `<div class="absence-form">
|
||||
<input type="text" id="absenceRange_${p.id}" class="range-picker-display" placeholder="Zeitraum wählen…" readonly
|
||||
onclick="event.stopPropagation(); openRangePicker({ anchor: this, startInputId: 'absenceStart_${p.id}', endInputId: 'absenceEnd_${p.id}', displayId: 'absenceRange_${p.id}' })">
|
||||
<input type="hidden" id="absenceStart_${p.id}">
|
||||
<input type="hidden" id="absenceEnd_${p.id}">
|
||||
<select id="absenceType_${p.id}">
|
||||
<option>Urlaub</option>
|
||||
<option>Krank</option>
|
||||
<option>Fortbildung</option>
|
||||
<option>Sonstiges</option>
|
||||
</select>
|
||||
<input type="text" id="absenceNote_${p.id}" placeholder="Notiz (optional)" style="width:140px">
|
||||
<button class="secondary small" onclick="addAbsence('${p.id}')">+ Abwesenheit</button>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="hint">Keine Abwesenheiten hinterlegt.</div>`;
|
||||
}
|
||||
html += `</div></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
|
||||
// aufgeklappt darstellt (Key = Personen-ID, Wert = boolean).
|
||||
// aufgeklappt darstellt (Key = Personen-ID, Wert = boolean). Reine Anzeige-
|
||||
// Steuerung, wird nicht persistiert.
|
||||
let personAbsenceOpen = {};
|
||||
|
||||
// togglePersonAbsences: klappt die Abwesenheits-Liste einer Person auf bzw. zu
|
||||
@@ -246,122 +102,53 @@ function togglePersonAbsences(personId){
|
||||
renderPeople();
|
||||
}
|
||||
|
||||
// draggedPerson: die während eines Drag-and-Drop-Vorgangs verschobene
|
||||
// Personen-Karte (DOM-Element) bzw. null nach dem Loslassen.
|
||||
let draggedPerson = null;
|
||||
|
||||
// getPersonCard: liefert die nächstgelegene Personen-Karte (`.person-card`).
|
||||
// Parameter `el`: Event-Ziel-Element. Rückgabe: Karten-Element oder null.
|
||||
function getPersonCard(el){
|
||||
return el && el.closest ? el.closest('.person-card') : null;
|
||||
}
|
||||
|
||||
// handlePersonDragStart: startet das Ziehen einer Personen-Karte, merkt sich
|
||||
// diese in `draggedPerson` und verringert ihre Deckkraft. Rückgabe: keine.
|
||||
function handlePersonDragStart(event){
|
||||
draggedPerson = getPersonCard(event.target);
|
||||
if(!draggedPerson) return;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
draggedPerson.style.opacity = '0.5';
|
||||
}
|
||||
|
||||
// handlePersonDragOver: erlaubt das Ablegen während des Überfahrens
|
||||
// (verhindert den Standard-Drop-Typ und setzt `dropEffect='move'`).
|
||||
function handlePersonDragOver(event){
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
// handlePersonDrop: legt die gezogene Personen-Karte an der Zielposition
|
||||
// innerhalb `.people-sortable` ab (per `getDragAfterElement`) und speichert
|
||||
// die neue Reihenfolge über `reorderPeople()`.
|
||||
function handlePersonDrop(event){
|
||||
event.preventDefault();
|
||||
const targetCard = getPersonCard(event.target);
|
||||
if(!draggedPerson || draggedPerson === targetCard) return;
|
||||
const sortable = document.querySelector('.people-sortable');
|
||||
if(!sortable) return;
|
||||
const afterElement = getDragAfterElement(sortable, event.clientY, '.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(){
|
||||
// addPersonToCurrentWeek: nimmt eine Person aus der globalen Liste in den
|
||||
// Teilnehmerkreis der aktuellen Sitzung auf. Nötig, weil der Kreis sonst nur
|
||||
// beim Anlegen einer neuen Sitzung entsteht: Ein später angelegter Benutzer
|
||||
// tauchte in laufenden (und in allen daraus duplizierten) Sitzungen nirgends
|
||||
// auf. Geschrieben wird über den normalen save()-Weg — `week.people` ist Teil
|
||||
// des Wochen-Datensatzes, den /save-week ohnehin schreibt.
|
||||
// Parameter: personId (String). Keine Rückgabe.
|
||||
function addPersonToCurrentWeek(personId){
|
||||
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();
|
||||
if(w && Array.isArray(w.people)){
|
||||
const byId = {};
|
||||
w.people.forEach(p => { byId[p.id] = p; });
|
||||
const newWeekPeople = [];
|
||||
newPeople.forEach(p => { if(byId[p.id]) newWeekPeople.push(byId[p.id]); });
|
||||
if(newWeekPeople.length === w.people.length) w.people = newWeekPeople;
|
||||
}
|
||||
save();
|
||||
}
|
||||
const w = currentWeek();
|
||||
if(!w) return;
|
||||
if(!Array.isArray(w.people)) w.people = [];
|
||||
if(w.people.some(p => p.id === personId)) return;
|
||||
const person = (db.globalPeople || []).find(p => p.id === personId);
|
||||
if(!person){ alert('Diese Person ist nicht mehr in der Benutzerverwaltung vorhanden.'); return; }
|
||||
// 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();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
// ---------- Urlaub / Abwesenheiten ----------
|
||||
|
||||
// addAbsence: legt für eine Person eine neue Abwesenheit an, die aus dem
|
||||
// Bereichspicker (Start-/Enddatum), Typ und Notiz des Formulars gelesen wird.
|
||||
// Prüft Datumsfolge, sortiert die Liste nach Startdatum und speichert.
|
||||
// Parameter `personId`: Personen-ID. Keine Rückgabe.
|
||||
function addAbsence(personId){
|
||||
// removePersonFromCurrentWeek: nimmt eine Person wieder aus dem
|
||||
// Teilnehmerkreis der aktuellen Sitzung heraus. Verweigert wird das, solange
|
||||
// sie noch einem Projekt dieser Sitzung zugeordnet ist — sonst bliebe eine
|
||||
// Zuordnung ohne zugehörige Spalte zurück. Parameter: personId (String).
|
||||
function removePersonFromCurrentWeek(personId){
|
||||
if(guardWeekLocked()) return;
|
||||
const people = db.globalPeople[currentDepartment] || [];
|
||||
const person = people.find(p => p.id === personId);
|
||||
const w = currentWeek();
|
||||
if(!w || !Array.isArray(w.people)) return;
|
||||
const person = w.people.find(p => p.id === personId);
|
||||
if(!person) return;
|
||||
const start = document.getElementById('absenceStart_' + personId).value;
|
||||
const end = document.getElementById('absenceEnd_' + personId).value;
|
||||
const type = document.getElementById('absenceType_' + personId).value;
|
||||
const note = document.getElementById('absenceNote_' + personId).value.trim();
|
||||
if(!start || !end){ alert('Bitte Start- und Enddatum angeben.'); return; }
|
||||
if(parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
|
||||
if(!Array.isArray(person.absences)) person.absences = [];
|
||||
person.absences.push({ id: uid(), start, end, type, note });
|
||||
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
|
||||
save();
|
||||
renderPeople();
|
||||
if(currentTab === 'gantt') renderGantt();
|
||||
}
|
||||
|
||||
// deleteAbsence: entfernt eine Abwesenheit (`absenceId`) aus der Liste der
|
||||
// Person (`personId`), speichert und rendert neu. Keine Rückgabe.
|
||||
function deleteAbsence(personId, absenceId){
|
||||
if(guardWeekLocked()) return;
|
||||
const people = db.globalPeople[currentDepartment] || [];
|
||||
const person = people.find(p => p.id === personId);
|
||||
if(!person || !person.absences) return;
|
||||
person.absences = person.absences.filter(a => a.id !== absenceId);
|
||||
const zugeordnet = (w.projects || []).filter(proj => {
|
||||
const assign = proj.assign || {};
|
||||
return Object.keys(assign).some(key => key === personId || key.indexOf(personId + '|') === 0);
|
||||
});
|
||||
if(zugeordnet.length > 0){
|
||||
alert('"' + (person.fullName || person.name) + '" ist in dieser Sitzung noch ' + zugeordnet.length
|
||||
+ ' Projekt(en) zugeordnet:\n\n' + zugeordnet.map(p => '· ' + (p.name || p.nr)).join('\n')
|
||||
+ '\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();
|
||||
renderPeople();
|
||||
if(currentTab === 'gantt') renderGantt();
|
||||
renderAll();
|
||||
}
|
||||
+100
-30
@@ -32,8 +32,8 @@
|
||||
}
|
||||
|
||||
// onGanttTodoMouseDown: startet das Zeilen-Ziehen, wenn der Greif-Handler
|
||||
// (⋮⋮) einer TODO-/Meilenstein-Zeile gedrückt wird, und merkt sich Zeile
|
||||
// und Startposition in app.todoDrag.
|
||||
// (Icon "grip", .gantt-todo-drag) einer TODO-/Meilenstein-Zeile gedrückt
|
||||
// wird, und merkt sich Zeile und Startposition in app.todoDrag.
|
||||
function onGanttTodoMouseDown(e){
|
||||
if(app.isLocked()) return;
|
||||
const handle = e.target.closest('.gantt-todo-drag');
|
||||
@@ -113,25 +113,35 @@
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
||||
const todoIds = todos.map(t => t.id);
|
||||
const milestoneIds = milestones.map(m => m.id);
|
||||
// Nur terminierte Aufgaben (Start UND Ende gesetzt) haben überhaupt eine
|
||||
// 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
|
||||
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)];
|
||||
const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean);
|
||||
const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean);
|
||||
|
||||
// Sicherheitscheck: Anzahl muss stimmen
|
||||
if(todoRowIds.length !== todoIds.length) return;
|
||||
if(milestoneRowIds.length !== milestoneIds.length) return;
|
||||
// Sicherheitscheck: Anzahl muss stimmen. Nicht jedes Meilenstein wird als
|
||||
// eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten
|
||||
// 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
|
||||
const newTodos = [];
|
||||
todoRowIds.forEach(id => {
|
||||
const t = todos.find(x => x.id === id);
|
||||
if(t) newTodos.push(t);
|
||||
// TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden
|
||||
// gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne
|
||||
// Termin behalten exakt ihren bisherigen Platz in der Liste.
|
||||
let nextRowIdIdx = 0;
|
||||
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
|
||||
// Regeln:
|
||||
@@ -378,9 +388,12 @@
|
||||
const totalDays = daysBetween(rangeStart, rangeEnd) + 1;
|
||||
const dayOffset = Math.max(0, Math.min(totalDays - 1, Math.round(x / rect.width * totalDays)));
|
||||
const date = formatDateISO(addDays(rangeStart, dayOffset));
|
||||
openTodoModal(null);
|
||||
document.getElementById('todoEditStart').value = date;
|
||||
document.getElementById('todoEditEnd').value = date;
|
||||
// Über prefill statt nachträglich in die versteckten Felder schreiben:
|
||||
// openTodoModal() setzt damit auch die sichtbare Zeitraum-Anzeige, das
|
||||
// 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.
|
||||
@@ -411,23 +424,80 @@
|
||||
}
|
||||
}
|
||||
|
||||
// updateVacationDates: schreibt die verschobenen Urlaubsdaten sowohl in die
|
||||
// lokale Sitzungs-Kopie der Person als auch in die globale Personenliste
|
||||
// und speichert beides.
|
||||
// updateVacationDates: übernimmt die per Drag verschobenen Urlaubsdaten
|
||||
// sofort optimistisch in lokale Sitzungs-Kopie und globale Personenliste
|
||||
// (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){
|
||||
const localPerson = (app.week.people || []).find(p => p.id === personId);
|
||||
if(localPerson && localPerson.absences){
|
||||
const a = localPerson.absences.find(x => x.id === absenceId);
|
||||
if(a){ a.start = start; a.end = end; }
|
||||
}
|
||||
const globalPeople = db.globalPeople[app.department] || [];
|
||||
const localAbsence = localPerson && Array.isArray(localPerson.absences)
|
||||
? localPerson.absences.find(x => x.id === absenceId) : null;
|
||||
const globalPeople = db.globalPeople || [];
|
||||
const globalPerson = globalPeople.find(p => p.id === personId);
|
||||
if(globalPerson && globalPerson.absences){
|
||||
const a = globalPerson.absences.find(x => x.id === absenceId);
|
||||
if(a){ a.start = start; a.end = end; }
|
||||
}
|
||||
app.savePlan();
|
||||
app.saveGlobalPeople();
|
||||
const globalAbsence = globalPerson && Array.isArray(globalPerson.absences)
|
||||
? globalPerson.absences.find(x => x.id === absenceId) : null;
|
||||
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;
|
||||
}
|
||||
// Ergebnis des Löschens prüfen, BEVOR neu angelegt wird: Schlug das
|
||||
// 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
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
|
||||
// renderFilesSummary: füllt die Kompakt-Vorschau der "Dateien & Freigaben"-
|
||||
// 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,
|
||||
// filesCache[0] ist also die neueste) sowie einen Hinweis auf noch
|
||||
// ausstehende Prüfungen.
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
}
|
||||
|
||||
// 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
|
||||
// Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js
|
||||
// (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste
|
||||
@@ -131,7 +131,7 @@
|
||||
}
|
||||
|
||||
// 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):
|
||||
// Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl.
|
||||
// überfällige), nächster anstehender Meilenstein und Team-Auslastung
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
|
||||
// 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
|
||||
// Laden aufgerufen. Keine Parameter, keine Rückgabe.
|
||||
window.renderPlanHistory = function(){
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
// alle echten Leistungsphasen (inkl. LPH 9), aber ohne die Sammelkategorie
|
||||
// "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
|
||||
// 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
|
||||
// durchs Raster fällt.
|
||||
const PLAN_ACTIVE_LPS = LPHS.filter(l => l !== 'W+M');
|
||||
|
||||
@@ -615,18 +615,6 @@
|
||||
openTodoModal(null);
|
||||
};
|
||||
|
||||
// updateTodo: setzt ein einzelnes Feld (z. B. done) einer Aufgabe direkt
|
||||
// und speichert sowie rendert neu. Parameter: idx (Index), field
|
||||
// (Feldname), value (neuer Wert).
|
||||
window.updateTodo = function(idx, field, value){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!plan.todos[idx]) return;
|
||||
plan.todos[idx][field] = value;
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// toggleTodoDone: schaltet den Erledigt-Status der Aufgabe am Index um,
|
||||
// speichert und rendert neu.
|
||||
window.toggleTodoDone = function(idx){
|
||||
|
||||
@@ -144,17 +144,20 @@
|
||||
};
|
||||
|
||||
// gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der
|
||||
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order) —
|
||||
// "Ohne Gruppe" (leerer String) immer zuerst, wenn vorhanden.
|
||||
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order).
|
||||
// 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(){
|
||||
const namen = [];
|
||||
let hatOhneGruppe = false;
|
||||
(currentProtokoll.punkte || []).forEach(p => {
|
||||
const g = String(p.gruppe || '').trim();
|
||||
if(!g){ hatOhneGruppe = true; return; }
|
||||
if(!namen.includes(g)) namen.push(g);
|
||||
});
|
||||
return (hatOhneGruppe ? [''] : []).concat(namen);
|
||||
return namen;
|
||||
}
|
||||
|
||||
// ---------- Nachträgliches Umordnen ----------
|
||||
@@ -162,15 +165,13 @@
|
||||
// Die Reihenfolge in `currentProtokoll.punkte` (= array_order) bestimmt
|
||||
// sowohl die Anzeige-Reihenfolge als auch — über berechneProtokollNummern()
|
||||
// — die Nummern. Zwei getrennte, aufeinander abgestimmte Stellschrauben:
|
||||
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe (bzw. innerhalb
|
||||
// "Ohne Gruppe") verschieben (die Zeile steht dort ohnehin nur im passenden
|
||||
// Abschnitt), eine ganze Gruppe lässt sich als Block gegenüber den anderen
|
||||
// Gruppen verschieben — das ändert, welche Gruppe die Nummer "1" bekommt.
|
||||
// "Ohne Gruppe" bleibt bewusst immer der erste Abschnitt (siehe
|
||||
// gruppenReihenfolge()) und ist deshalb hier nicht mitverschiebbar.
|
||||
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe verschieben (die
|
||||
// Zeile steht dort ohnehin nur im passenden Abschnitt), eine ganze Gruppe
|
||||
// lässt sich als Block gegenüber den anderen Gruppen verschieben — das
|
||||
// ändert, welche Gruppe die Nummer "1" bekommt.
|
||||
|
||||
// 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).
|
||||
// Keine Rückgabe; rendert nur den Punkte-Block neu.
|
||||
window.verschiebePunkt = function(idx, richtung){
|
||||
@@ -192,24 +193,24 @@
|
||||
// verschiebeGruppe: verschiebt eine ganze Gruppe (alle ihre Punkte als
|
||||
// Block, ihre eigene Reihenfolge bleibt untereinander erhalten) gegenüber
|
||||
// den anderen Gruppen — ändert damit auch die Gruppennummer. Baut die
|
||||
// Punkteliste dafür komplett neu auf: "Ohne Gruppe" zuerst (unverändert),
|
||||
// danach jede Gruppe in der neuen Reihenfolge. Parameter: gruppe (Name),
|
||||
// richtung (-1 = nach vorn, +1 = nach hinten). Keine Rückgabe.
|
||||
// Punkteliste dafür komplett in der neuen Gruppenreihenfolge neu auf.
|
||||
// Parameter: gruppe (Name), richtung (-1 = nach vorn, +1 = nach hinten).
|
||||
// Keine Rückgabe.
|
||||
window.verschiebeGruppe = function(gruppe, richtung){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
const punkte = currentProtokoll.punkte;
|
||||
const namen = [];
|
||||
punkte.forEach(p => {
|
||||
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;
|
||||
if(pos === -1 || zielPos < 0 || zielPos >= namen.length) return;
|
||||
const tmpName = namen[pos];
|
||||
namen[pos] = namen[zielPos];
|
||||
namen[zielPos] = tmpName;
|
||||
const neu = punkte.filter(p => !String(p.gruppe || '').trim());
|
||||
const neu = [];
|
||||
namen.forEach(g => {
|
||||
punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p));
|
||||
});
|
||||
@@ -348,7 +349,7 @@
|
||||
// setzeProtokollImportLadezustand: blendet den Ladehinweis ein/aus und
|
||||
// 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 =
|
||||
// 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
|
||||
// "Importieren" hätte denselben Import doppelt losgeschickt.
|
||||
function setzeProtokollImportLadezustand(aktiv){
|
||||
@@ -538,89 +539,275 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderPunkteBlockHtml: baut nur den Punkte-Block (in Abschnitten je
|
||||
// Gruppe) — eigene Funktion, weil er nach einer Gruppenänderung isoliert
|
||||
// renderPunkteBlockHtml: baut nur den Punkte-Block — EINE durchgehende
|
||||
// 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
|
||||
// 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(){
|
||||
const p = currentProtokoll;
|
||||
const locked = app.isLocked();
|
||||
const gruppen = gruppenReihenfolge();
|
||||
const gruppenNamen = gruppen.filter(g => g); // ohne '' für die Datalist-Vorschläge
|
||||
|
||||
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
|
||||
.map((punkt, idx) => ({ punkt, idx }))
|
||||
.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 = '';
|
||||
if(gruppe && !locked){
|
||||
const gPos = gruppenNamen.indexOf(gruppe);
|
||||
const hochDisabled = gPos <= 0 ? 'disabled' : '';
|
||||
const runterDisabled = gPos >= gruppenNamen.length - 1 ? 'disabled' : '';
|
||||
if(!locked){
|
||||
const gPos = gruppen.indexOf(gruppe);
|
||||
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" ${runterDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</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" ${gPos >= gruppen.length - 1 ? 'disabled' : ''} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button>
|
||||
</span>`;
|
||||
}
|
||||
const zeilen = punkteDerGruppe.map(({ punkt, idx }, pos) => renderPunktZeileHtml(punkt, idx, nummern[idx], pos === 0, pos === punkteDerGruppe.length - 1)).join('');
|
||||
return `<div class="protokoll-gruppe">
|
||||
<div class="protokoll-gruppe-head"><span class="protokoll-gruppe-name${gruppe ? '' : ' is-leer'}">${titel}</span>${moveBtns}${addBtn}</div>
|
||||
${zeilen || '<span class="hint">Keine Punkte in dieser Gruppe.</span>'}
|
||||
const kopf = `<div class="protokoll-liste-gruppe" style="--protokoll-gruppe-farbe: ${protokollGruppeFarbe(gruppe)}">
|
||||
<input class="protokoll-gruppe-heading" value="${esc(gruppe)}" placeholder="Gruppenname …" ${locked ? 'disabled' : ''}
|
||||
onchange="currentProtokollUmbenenneGruppe('${esc(gruppe)}', this.value)">
|
||||
<span class="count">${punkteDerGruppe.length}</span>${moveBtns}
|
||||
</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('');
|
||||
|
||||
const datalist = `<datalist id="protokollGruppenListe">${gruppenNamen.map(g => `<option value="${esc(g)}">`).join('')}</datalist>`;
|
||||
const addOhneGruppe = locked ? '' : `<div class="add-row-btn" onclick="fuegePunktHinzu('')">+ Punkt (ohne Gruppe)</div>`;
|
||||
const leerHinweis = listeHtml ? '' : '<div class="protokoll-liste-leer">Noch keine Punkte erfasst.</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>
|
||||
${gruppenHtml || '<span class="hint">Noch keine Punkte.</span>'}
|
||||
${datalist}
|
||||
${addOhneGruppe}
|
||||
<div class="protokoll-split">
|
||||
<div class="protokoll-liste">
|
||||
<div class="protokoll-liste-kopf">${esc(kopfText)}</div>
|
||||
${listeHtml}${leerHinweis}${neueGruppe}
|
||||
</div>
|
||||
${renderSchreibbereichHtml(aktivIdx, nummern)}
|
||||
</div>
|
||||
<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 fortgeschrieben"></span>Aus vorigem Protokoll fortgeschrieben</span>
|
||||
<span class="legend-item"><span class="legend-swatch erledigt"></span>Erledigt (läuft nicht weiter mit)</span>
|
||||
<span class="legend-item"><span class="legend-swatch info"></span>Info (läuft nicht weiter mit)</span>
|
||||
</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();
|
||||
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 neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
|
||||
const moveBtns = locked ? '' : `<span class="protokoll-punkt-move">
|
||||
<button type="button" class="secondary small" ${istErste ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
|
||||
<button type="button" class="secondary small" ${istLetzte ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
|
||||
</span>`;
|
||||
return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${punkt.erledigt ? ' is-erledigt' : ''}" id="protokollPunkt_${idx}">
|
||||
<div class="protokoll-punkt-top-row">
|
||||
<span class="protokoll-punkt-nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummer)}</span>
|
||||
${moveBtns}
|
||||
<input type="text" class="protokoll-punkt-gruppe" list="protokollGruppenListe" value="${esc(punkt.gruppe)}"
|
||||
placeholder="Gruppe (z. B. ELT, HLS) …" ${locked ? 'disabled' : ''}
|
||||
onchange="currentProtokollSetzePunktGruppe(${idx}, this.value)">
|
||||
${neuChip}
|
||||
<input type="text" class="protokoll-punkt-thema" value="${esc(punkt.thema)}" placeholder="Thema" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
|
||||
<label class="protokoll-punkt-erledigt-toggle"><input type="checkbox" ${punkt.erledigt ? 'checked' : ''} ${locked ? 'disabled' : ''}
|
||||
onchange="currentProtokollSetzePunktErledigt(${idx}, this.checked)"> erledigt</label>
|
||||
${locked ? '' : `<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`}
|
||||
// entfaelltNaechstes: vom Server gesetzt, wenn dieser Punkt beim
|
||||
// Fortschreiben aus einem erledigten Punkt des vorigen Protokolls
|
||||
// entstand — seine Gnadenrunde (Eriks Wunsch: ein erledigter Punkt
|
||||
// taucht noch EIN Protokoll lang auf, bevor er entfällt). Nie vom
|
||||
// Client gesetzt (siehe PUT .../:id, server.js).
|
||||
const entfaelltChip = punkt.entfaelltNaechstes
|
||||
? '<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>'
|
||||
: '';
|
||||
// themaGeaendert/textGeaendert: vom Server gesetzt (siehe
|
||||
// loadProtokollDetail(), server.js) für einen fortgeschriebenen Punkt
|
||||
// (neu=0), dessen Thema bzw. Text sich gegenüber der Fassung im
|
||||
// UNMITTELBAR vorangegangenen Protokoll geändert hat — markiert NUR das
|
||||
// jeweils geänderte Feld, der Punkt selbst ist ja derselbe geblieben.
|
||||
const themaGeaendertClass = punkt.themaGeaendert ? ' ist-geaendert' : '';
|
||||
const textGeaendertClass = punkt.textGeaendert ? ' ist-geaendert' : '';
|
||||
const status = punkt.status || 'offen';
|
||||
|
||||
// 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>
|
||||
<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-punkt-foot">
|
||||
<label>Zuständig <input type="text" value="${esc(punkt.zustaendig)}" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)"></label>
|
||||
<label>Termin
|
||||
<input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="—" readonly
|
||||
|
||||
<div class="protokoll-schreiben-feld">
|
||||
<span class="protokoll-feld-label">Thema</span>
|
||||
<input type="text" class="protokoll-punkt-thema${themaGeaendertClass}" value="${esc(punkt.thema)}"
|
||||
placeholder="Worum geht es?" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
|
||||
</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}); } })`}">
|
||||
<input type="hidden" id="protokollPunktTermin_${idx}" value="${esc(punkt.termin)}">
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<div class="protokoll-schreiben-fuss">
|
||||
Erstellt am ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}
|
||||
<span class="hint">— bleibt beim Fortschreiben unverändert</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -639,29 +826,47 @@
|
||||
if(!currentProtokoll || !currentProtokoll.teilnehmer[idx]) return;
|
||||
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){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
currentProtokoll.punkte[idx][feld] = wert;
|
||||
if(feld === 'thema' || feld === 'zustaendig') aktualisiereListenZeile(idx);
|
||||
};
|
||||
window.syncProtokollPunktTermin = function(idx){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
const el = document.getElementById('protokollPunktTermin_' + idx);
|
||||
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;
|
||||
currentProtokoll.punkte[idx].erledigt = checked;
|
||||
const row = document.getElementById('protokollPunkt_' + idx);
|
||||
if(row) row.classList.toggle('is-erledigt', checked);
|
||||
currentProtokoll.punkte[idx].status = status;
|
||||
// Gezielt die beiden betroffenen Stellen nachziehen statt neu zu rendern:
|
||||
// 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
|
||||
// rendert (nur den Punkte-Block, siehe renderPunkteBlockHtml) — ändert
|
||||
// sich die Gruppe, muss der Punkt sichtbar in den passenden Abschnitt
|
||||
// wandern. Läuft über onchange (Blur/Enter), nicht oninput, damit während
|
||||
// des Tippens nicht bei jedem Zeichen neu gerendert wird.
|
||||
window.currentProtokollSetzePunktGruppe = function(idx, wert){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
currentProtokoll.punkte[idx].gruppe = wert;
|
||||
// currentProtokollUmbenenneGruppe: ersetzt currentProtokollSetzePunktGruppe
|
||||
// (bis eben: ein Feld PRO Punkt, das genau diesen einen Punkt umhängte).
|
||||
// Jetzt ist die Gruppen-Kopfzeile selbst das Eingabefeld (Eriks Wunsch) —
|
||||
// ein Umbenennen wirkt auf ALLE Punkte dieser Gruppe gleichzeitig. Trägt
|
||||
// der neue Name den einer bereits bestehenden anderen Gruppe, verschmelzen
|
||||
// beide Gruppen schlicht dadurch, dass ab jetzt beide Punktmengen denselben
|
||||
// gruppe-Wert tragen — kein Sonderfall nötig. Läuft über onchange
|
||||
// (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');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
@@ -684,17 +889,54 @@
|
||||
// aus Gruppe und Position (siehe berechneProtokollNummern() oben).
|
||||
// erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist
|
||||
// immer, was der Server beim Speichern setzt (server.js, PUT .../:id).
|
||||
currentProtokoll.punkte.push({
|
||||
const neuerPunkt = {
|
||||
id: null, nummer: '', gruppe: gruppe || '', neu: true,
|
||||
thema: '', text: '', zustaendig: '', termin: '', erledigt: false,
|
||||
erstelltAm: currentProtokoll.datum || ''
|
||||
});
|
||||
thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
|
||||
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');
|
||||
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){
|
||||
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);
|
||||
if(warAusgewaehlt){
|
||||
const ersatz = Math.min(idx, currentProtokoll.punkte.length - 1);
|
||||
ausgewaehlterPunkt = ersatz >= 0 ? currentProtokoll.punkte[ersatz] : null;
|
||||
}
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
@@ -711,7 +953,16 @@
|
||||
// Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert —
|
||||
// siehe berechneProtokollNummern()).
|
||||
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 = {
|
||||
datum: currentProtokoll.datum,
|
||||
betreff: currentProtokoll.betreff,
|
||||
@@ -719,6 +970,11 @@
|
||||
teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()),
|
||||
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), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
@@ -728,6 +984,7 @@
|
||||
.then(({ ok, d }) => {
|
||||
if(!ok || !d.ok){ alert('Protokoll konnte nicht gespeichert werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
|
||||
currentProtokoll = d.protokoll;
|
||||
ausgewaehlterPunkt = (auswahlPos >= 0 && currentProtokoll.punkte[auswahlPos]) || null;
|
||||
renderProtokollDetail();
|
||||
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()
|
||||
// (plan-sections.js) beim Wechsel in den Bereich aufgerufen — zeigt je
|
||||
// nach Zustand Liste oder Detail (rendert dabei nichts neu, nur aus dem
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// -----------------------------
|
||||
// Der Projektbereich ist der EIGENE BEREICH eines Projekts — seit dem Umbau
|
||||
// kein eigenes Dokument mehr, sondern der Bereich #plan der Hauptseite
|
||||
// Test.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die
|
||||
// Leiste .plan-nav umschaltet (Markup in Test.html):
|
||||
// App.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die
|
||||
// Leiste .plan-nav umschaltet (Markup in App.html):
|
||||
//
|
||||
// dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen-
|
||||
// Fortschritt, nächste Termine, Team, Kompakt-Karten
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
|
||||
// Navigationsleiste. Muss zu den data-section-Attributen in
|
||||
// Test.html passen.
|
||||
// App.html passen.
|
||||
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
||||
|
||||
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
|
||||
|
||||
+286
-119
@@ -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
|
||||
// geladen und definiert window.PlanApp als gemeinsamen Zustand aller
|
||||
// Projektplan-Module. Es liest die URL-Route (Abteilung + Projektname, z. B.
|
||||
// "/HLS/Projektplan/Gymnasium%20Hemer") und die URL-Parameter (weekLabel,
|
||||
// projNr), über die das zu bearbeitende Projekt adressiert wird.
|
||||
// Nach DOMContentLoaded werden Theme, Toolbar-Buttons und die Sitzungsdaten
|
||||
// über die Server-API geladen. Die hier definierten Funktionen (savePlan,
|
||||
// isLocked, showToast u. a.) werden über app.* den anderen plan-Modulen
|
||||
// bereitgestellt, die alle in einem IIFE mit window.PlanApp laufen.
|
||||
// Dieses Modul wird als erstes der plan-*-Module von public/App.html geladen
|
||||
// und definiert window.PlanApp als gemeinsamen Zustand aller Module des
|
||||
// Projektbereichs.
|
||||
//
|
||||
// WICHTIG — geändert gegenüber der früheren Fassung: Der Projektbereich war
|
||||
// einmal eine eigene Seite (public/project-plan.html) mit eigenem
|
||||
// Einstiegspunkt. Sie las Sitzung und Projekt aus der URL und lud sich ihren
|
||||
// Datenbestand über GET /load-all selbst. Beides ist entfallen: Der
|
||||
// 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 = {
|
||||
params: new URLSearchParams(location.search),
|
||||
department: '',
|
||||
weekLabel: '',
|
||||
projNr: '',
|
||||
projName: '',
|
||||
sandboxPlanId: '', // gesetzt von openSandboxPlan() — Sandbox-Pläne werden über id adressiert, nicht über nr/name
|
||||
week: null, // planWeek
|
||||
project: null, // planProject
|
||||
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
|
||||
@@ -34,88 +49,117 @@ window.PlanApp = {
|
||||
|
||||
(function(){
|
||||
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
|
||||
// localStorage/System, prüft die URL-Parameter und bricht mit Fehlermeldung
|
||||
// ab, falls kein Projekt ausgewählt wurde. Sonst bindet es die Toolbar und
|
||||
// startet das Laden der Daten.
|
||||
function init(){
|
||||
loadTheme(); // Theme aus localStorage / System übernehmen
|
||||
if(!app.weekLabel || !app.projName){
|
||||
document.getElementById('planTitle').textContent = 'Fehler';
|
||||
document.getElementById('planSubtitle').textContent = 'Kein Projekt ausgewählt.';
|
||||
return;
|
||||
// planFail: zeigt einen Fehler in der Kopfzeile des Projektbereichs an und
|
||||
// blendet die Bereichsleiste aus, weil ohne Projekt jeder Unterbereich nur
|
||||
// in eine leere Maske führte. Parameter: title, subtitle (Strings).
|
||||
function planFail(title, subtitle){
|
||||
const t = document.getElementById('planTitle');
|
||||
const sub = document.getElementById('planSubtitle');
|
||||
if(t) t.textContent = title;
|
||||
if(sub) sub.textContent = subtitle;
|
||||
const badge = document.getElementById('planIdBadge');
|
||||
if(badge) badge.textContent = '';
|
||||
app.project = null;
|
||||
app.hidePlanNav();
|
||||
}
|
||||
|
||||
// setPlanTarget: merkt sich, welches Projekt der Bereich zeigen soll —
|
||||
// adressiert über Sitzungs-Label und Projekt-Nr./-Bezeichnung, NICHT über
|
||||
// project.id, die beim Duplizieren einer Sitzung (cloneWeek(), js/weeks.js)
|
||||
// neu vergeben wird. Bewusst getrennt vom Rendern: showTab('plan')
|
||||
// (js/tabs-auth.js) schreibt aus diesen Werten zuerst die Adresszeile und
|
||||
// blendet den Bereich ein, und erst der sichtbare Bereich lässt sich
|
||||
// vermessen — das GANTT braucht die Containerbreite. Parameter:
|
||||
// weekLabel, projNr, projName (Strings). Keine Rückgabe.
|
||||
window.setPlanTarget = function(weekLabel, projNr, projName){
|
||||
app.weekLabel = weekLabel || '';
|
||||
app.projNr = projNr || '';
|
||||
app.projName = projName || '';
|
||||
app.sandboxPlanId = '';
|
||||
};
|
||||
|
||||
// openSandboxPlan: das Gegenstück zu setPlanTarget()+showTab('plan') für
|
||||
// einen Terminplan der Planung-Sandbox (js/planung/planung.js). Sandbox-
|
||||
// Pläne haben keine Projektnummer und werden nie geklont (cloneWeek()
|
||||
// betrifft nur echte Wochen) — ihre id bleibt also stabil und ist die
|
||||
// einzige verlässliche Adressierung, anders als bei echten Projekten
|
||||
// (dort nr/name, siehe setPlanTarget()). Parameter: planId. Keine Rückgabe.
|
||||
window.openSandboxPlan = function(planId){
|
||||
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;
|
||||
}
|
||||
app.week = db.sandboxWeek || null;
|
||||
app.project = (app.week && Array.isArray(app.week.projects))
|
||||
? app.week.projects.find(p => p.id === app.sandboxPlanId) || null
|
||||
: null;
|
||||
if(!app.project){
|
||||
planFail('Nicht gefunden', 'Dieser Terminplan wurde nicht gefunden — er wurde möglicherweise gelöscht.');
|
||||
return false;
|
||||
}
|
||||
app.projNr = '';
|
||||
app.projName = app.project.name || '';
|
||||
renderPlanPage();
|
||||
return true;
|
||||
}
|
||||
currentDepartment = app.department; // global aus globals.js
|
||||
bindToolbarButtons();
|
||||
loadPlanData();
|
||||
}
|
||||
|
||||
// bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen,
|
||||
// Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der
|
||||
// anderen plan-Module.
|
||||
function bindToolbarButtons(){
|
||||
const addTodoBtn = document.getElementById('addTodoBtn');
|
||||
if(addTodoBtn) addTodoBtn.addEventListener('click', () => addTodo());
|
||||
const autoScheduleBtn = document.getElementById('autoScheduleBtn');
|
||||
if(autoScheduleBtn) autoScheduleBtn.addEventListener('click', () => autoScheduleTodos());
|
||||
const criticalPathBtn = document.getElementById('criticalPathBtn');
|
||||
if(criticalPathBtn) criticalPathBtn.addEventListener('click', () => toggleCriticalPath());
|
||||
}
|
||||
|
||||
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der
|
||||
// Abteilung über die Server-API, sortiert die Wochen chronologisch und
|
||||
// sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt Fehlermeldungen
|
||||
// der Reihenfolge nach im Seitenkopf an und startet sonst renderPlanPage().
|
||||
function loadPlanData(){
|
||||
if(!window.fetch){
|
||||
document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.';
|
||||
return;
|
||||
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;
|
||||
}
|
||||
const url = baseUrl() + '/load-all?department=' + encodeURIComponent(app.department);
|
||||
fetch(url)
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
||||
.then(data => {
|
||||
db = { weeks: [], currentWeek: null, globalPeople: {} };
|
||||
db.globalPeople[app.department] = parseGlobalPeopleCsv(data.people);
|
||||
const parsed = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) }));
|
||||
parsed.forEach(s => {
|
||||
const w = parseSessionCsv(s.filename, s.rows);
|
||||
if(w) db.weeks.push(w);
|
||||
});
|
||||
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||
app.week = db.weeks.find(w => w.label === app.weekLabel);
|
||||
if(!app.week){
|
||||
document.getElementById('planTitle').textContent = 'Nicht gefunden';
|
||||
document.getElementById('planSubtitle').textContent = 'Sitzung "' + esc(app.weekLabel) + '" konnte nicht geladen werden.';
|
||||
return;
|
||||
}
|
||||
app.project = app.week.projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName);
|
||||
if(!app.project){
|
||||
document.getElementById('planTitle').textContent = 'Nicht gefunden';
|
||||
document.getElementById('planSubtitle').textContent = 'Projekt nicht in dieser Sitzung vorhanden.';
|
||||
return;
|
||||
}
|
||||
renderPlanPage();
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('planTitle').textContent = 'Fehler beim Laden';
|
||||
document.getElementById('planSubtitle').textContent = err.message;
|
||||
});
|
||||
}
|
||||
// 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
|
||||
// erwartete Struktur hat (inkl. Migration alter Einzel-Abhängigkeiten).
|
||||
@@ -139,21 +183,98 @@ window.PlanApp = {
|
||||
return !!app.week.locked;
|
||||
}
|
||||
|
||||
// renderPlanPage: setzt Titel und Untertitel der Seite aus Projekt und Woche,
|
||||
// blendet den Meilenstein-Button bei gesperrter Woche aus und startet die
|
||||
// GANTT-Anzeige über fitPlanGanttRange().
|
||||
// isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur
|
||||
// Planung-Sandbox gehört statt zu einem echten Projekt. Zentraler Merker
|
||||
// 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(){
|
||||
const p = app.project;
|
||||
const plan = ensurePlanLocal();
|
||||
document.getElementById('planTitle').textContent = (p.nr ? p.nr + ' – ' : '') + p.name;
|
||||
document.getElementById('planSubtitle').textContent = app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : '');
|
||||
const sandbox = isSandboxPlan();
|
||||
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 addMilestoneBtn = document.getElementById('addMilestoneBtn');
|
||||
const planPeopleList = document.getElementById('planPeopleList');
|
||||
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();
|
||||
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();
|
||||
|
||||
// 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)
|
||||
@@ -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(){
|
||||
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 => {
|
||||
const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : '';
|
||||
return `<span class="todo-assignee ${cls}">${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}</span>`;
|
||||
const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
|
||||
return `<span class="board-avatar ${planRoleClass(a.role)} state-${a.state}" title="${esc(title)}">${esc(a.person.name)}</span>`;
|
||||
}).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(){
|
||||
if(!app.week || !app.project) return;
|
||||
const filename = filenameForWeek(app.week.label);
|
||||
const csv = weekToCsv(app.week);
|
||||
sendCsvToServer(filename, csv).catch(err => console.error('Speichern fehlgeschlagen:', err));
|
||||
markWeekDirty(app.week.label);
|
||||
scheduleSessionSync();
|
||||
}
|
||||
|
||||
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
|
||||
// _people.csv auf den Server, aber nur wenn sie nicht leer ist.
|
||||
function saveGlobalPeople(){
|
||||
const people = db.globalPeople[app.department] || [];
|
||||
if(people.length > 0){
|
||||
const peopleCsv = globalPeopleToCsv(people);
|
||||
sendCsvToServer('_people.csv', peopleCsv).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
|
||||
// userIdByPersonIdPromise: Cache der Zuordnung Person-ID (String, z. B.
|
||||
// "p_max_mustermann") -> numerische users.id. Seit der Ablösung von
|
||||
// people/person_roles/person_absences gibt es keinen /save-people-Schreibpfad
|
||||
// mehr; Abwesenheiten werden direkt gegen /users/:id/absences geschrieben
|
||||
// (siehe plan-modals.js/plan-drag.js), wofür die numerische User-ID benötigt
|
||||
// wird. Lazy geladen über GET /users (liefert seit Phase 1 auch personId)
|
||||
// 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)
|
||||
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
|
||||
@@ -226,14 +394,13 @@ window.PlanApp = {
|
||||
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
|
||||
app.ensurePlanLocal = ensurePlanLocal;
|
||||
app.isLocked = isLocked;
|
||||
app.isSandboxPlan = isSandboxPlan;
|
||||
app.removeDependencyRefs = removeDependencyRefs;
|
||||
app.renderProjectAssignees = renderProjectAssignees;
|
||||
app.planRoleClass = planRoleClass;
|
||||
app.savePlan = savePlan;
|
||||
app.saveGlobalPeople = saveGlobalPeople;
|
||||
app.getUserIdForPersonId = getUserIdForPersonId;
|
||||
app.findGlobalPerson = findGlobalPerson;
|
||||
app.countPersonAbsences = countPersonAbsences;
|
||||
app.showToast = showToast;
|
||||
|
||||
// Seitenstart: erst initialisieren, wenn das DOM geladen ist
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
+110
-24
@@ -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
|
||||
// sortierbare Listenansicht: Projekte werden nach Gruppe ("Aktive Projekte",
|
||||
// "Kleine Projekte" usw.) gruppiert, inline bearbeitbar und per
|
||||
// Drag-and-Drop umsortiert. Sie wird von `Test.html` nach `js/people/people.js`
|
||||
// (vor `js/load/load.js`) geladen und stellt globale Funktionen wie
|
||||
// `renderProjects()`, `editProjectField()`, `editProjectActiveLP()` sowie die
|
||||
// Drag-and-Drop-Handler und die Sortier-Hilfe `getDragAfterElement()` bereit.
|
||||
// Letztere wird auch von `js/people/people.js` (Personen-Sortierung) genutzt;
|
||||
// `renderBoard()` aus `js/board/board.js` wird nach Änderungen aktualisiert.
|
||||
// Der eigene Tab "Projekte" ist entfallen. Er listete dieselben Projekte ein
|
||||
// zweites Mal auf und ließ dieselben Angaben — Nummer, Bezeichnung, Gruppe,
|
||||
// amtierende Leistungsphase, Schlagwörter, Aufwand — ein zweites Mal
|
||||
// bearbeiten. Diese Angaben haben ihren einen Ort jetzt im Projektbereich
|
||||
// (js/plan/plan-settings.js, js/plan/plan-info.js); die Liste selbst ist im
|
||||
// Projektboard aufgegangen (renderBoard(), js/board/board.js).
|
||||
//
|
||||
// Was hier bleibt, betrifft die LISTE, nicht die einzelnen Angaben:
|
||||
//
|
||||
// * 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
|
||||
// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter
|
||||
@@ -55,15 +67,67 @@ function editProjectActiveLP(id, value){
|
||||
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`
|
||||
// und sortiert je Gruppe nach `order`. Jedes Projekt erhält Felder für Nr.,
|
||||
// Name, Gruppe, aktive LP sowie Drag-and-Drop-Handler. Keine Parameter, keine
|
||||
// Rückgabe.
|
||||
function renderProjects(){
|
||||
// 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();
|
||||
if(!w) return;
|
||||
const locked = isWeekLocked(w);
|
||||
let html="";
|
||||
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); });
|
||||
Object.keys(groups).forEach(g=>{
|
||||
groups[g].sort((a,b)=>(a.order||0)-(b.order||0));
|
||||
@@ -71,30 +135,39 @@ function renderProjects(){
|
||||
|
||||
sortGroups(Object.keys(groups)).forEach(g=>{
|
||||
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=>{
|
||||
html += `<div class="person-list-item" draggable="true" data-project-id="${p.id}" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)">
|
||||
<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px">⋮⋮</span>
|
||||
<input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr">
|
||||
<input type="text" value="${esc(p.name)}" onchange="editProjectField('${p.id}','name',this.value)" style="width:200px" placeholder="Bezeichnung">
|
||||
<select onchange="editProjectField('${p.id}','group',this.value)">
|
||||
const tags = Array.isArray(p.tags) ? p.tags : [];
|
||||
html += `<div class="person-list-item project-item" ${locked ? '' : 'draggable="true" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"'} data-project-id="${p.id}">
|
||||
${locked ? '' : `<span class="drag-handle" style="cursor:grab;color:#999">${icon('grip')}</span>`}
|
||||
<input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr" ${locked ? 'disabled' : ''}>
|
||||
<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==='Kleine Projekte'?'selected':''}>Kleine Projekte</option>
|
||||
<option ${p.group==='In Anbahnung'?'selected':''}>In Anbahnung</option>
|
||||
<option ${p.group==='Allgemein'?'selected':''}>Allgemein</option>
|
||||
</select>
|
||||
<select onchange="editProjectActiveLP('${p.id}',this.value)" title="Aktive Leistungsphase wählen">
|
||||
<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>
|
||||
${AUFWAND_LPS.map(l => `<option value="${l}" ${lpActiveList(p)[0]===l?'selected':''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
<button class="secondary small" onclick="openLpModal('${p.id}')">✎ LP-Aufwand</button>
|
||||
<button class="danger small" onclick="delProjectFromList('${p.id}')">Löschen</button>
|
||||
<div class="project-tags-row">
|
||||
<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>`;
|
||||
});
|
||||
if(groups[g].length===0) html+=`<div class="hint" style="padding:4px 2px 10px">Keine Projekte — Projekt hierher ziehen.</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
|
||||
@@ -134,7 +207,12 @@ function handleDrop(event){
|
||||
sourceSortable.dataset && sortable.dataset &&
|
||||
sourceSortable.dataset.group === sortable.dataset.group;
|
||||
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){
|
||||
sortable.appendChild(draggedProject);
|
||||
} else {
|
||||
@@ -150,7 +228,13 @@ function handleDrop(event){
|
||||
// handleDragEnd: stellt die Deckkraft der gezogenen Zeile wieder her und
|
||||
// setzt `draggedProject` zurück (Ende des Drag-Vorgangs).
|
||||
function handleDragEnd(event){
|
||||
event.currentTarget.style.opacity = '1';
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -196,6 +280,8 @@ function reorderProjects(){
|
||||
});
|
||||
});
|
||||
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();
|
||||
}
|
||||
+60
-32
@@ -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
|
||||
// server.js im Projekt-Root) und wird nach js/csv.js von beiden
|
||||
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl, blobToDataUrl,
|
||||
// sendCsvToServer und deleteCsvFromServer bereit. Genutzt wird sie von
|
||||
// js/store.js (syncAllToServer, Speichern/Löschen aller Wochen),
|
||||
// js/weeks.js (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt
|
||||
// direkt die aktuelle Woche über sendCsvToServer).
|
||||
// server.js im Projekt-Root) und wird nach js/date-picker.js von beiden
|
||||
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl,
|
||||
// sendWeekToServer und deleteWeekFromServer bereit. Genutzt wird sie von
|
||||
// js/store.js (syncAllToServer, Speichern aller Wochen), js/weeks.js
|
||||
// (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt direkt die
|
||||
// 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.
|
||||
// Fallback auf localhost:3003, wenn die Seite als lokale Datei geladen wurde.
|
||||
@@ -20,35 +25,58 @@ function baseUrl(){
|
||||
return 'http://localhost:3003';
|
||||
}
|
||||
|
||||
// blobToDataUrl: liest einen Blob als Data-URL ein (z. B. für Bilddaten)
|
||||
// Parameter: blob (Blob). Rückgabe: Promise, das die Data-URL auflöst.
|
||||
function blobToDataUrl(blob){
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
// sendWeekToServer: POSTet ein komplettes Wochen-Objekt (JSON) an
|
||||
// <baseUrl>/save-week. Parameter: week (Objekt). Rückgabe: Promise mit
|
||||
// Server-Antwort. `baseUpdatedAt` übermittelt die Revision, auf der die
|
||||
// lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error
|
||||
// mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer
|
||||
// den Konflikt anzeigen kann.
|
||||
function sendWeekToServer(week){
|
||||
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
|
||||
// Parameter: filename (String), csvContent (String). Rückgabe: Promise mit Server-Antwort.
|
||||
function sendCsvToServer(filename, csvContent){
|
||||
// deleteWeekFromServer: fordert das Löschen einer Woche über POST /delete-week an
|
||||
// Parameter: label (String, das Wochen-Label). Rückgabe: Promise mit Server-Antwort.
|
||||
// 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'));
|
||||
return fetch(baseUrl() + '/save-csv',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json; charset=utf-8'},
|
||||
body:JSON.stringify({filename,csv:csvContent,department:currentDepartment})
|
||||
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
|
||||
}
|
||||
|
||||
// deleteCsvFromServer: fordert das Löschen einer Speicherwoche über POST /delete-csv an
|
||||
// Parameter: filename (String). Rückgabe: Promise mit Server-Antwort.
|
||||
function deleteCsvFromServer(filename){
|
||||
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
||||
return fetch(baseUrl() + '/delete-csv', {
|
||||
return fetch(baseUrl() + '/delete-week', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json; charset=utf-8'},
|
||||
body: JSON.stringify({ filename, department: currentDepartment })
|
||||
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
|
||||
body: JSON.stringify({ label })
|
||||
}).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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
+301
-100
@@ -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
|
||||
// beim Start, speichert den kompletten Datenbestand (alle Wochen + globale
|
||||
// Personenliste) und verwaltet den Wechsel zwischen Abteilungen (ELT/HLS/ARCH).
|
||||
// Sie wird von `Test.html` nach `js/csv.js` und `js/server.js` geladen und stellt
|
||||
// die globalen Funktionen `save()`, `syncAllToServer()`, `switchDepartment()`,
|
||||
// `load()`, `seed()` und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`,
|
||||
// `js/board/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.
|
||||
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt beim Start alle
|
||||
// (firmenweit globalen, nicht mehr abteilungsgetrennten) Sitzungen und speichert
|
||||
// den kompletten Datenbestand (alle Wochen + globale Personenliste). Die Daten
|
||||
// liegen serverseitig in einer SQLite-Datenbank und werden als strukturierte
|
||||
// JSON-Objekte über die API ausgetauscht (keine CSV-Strings). Sie wird von
|
||||
// `App.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
|
||||
// die globalen Funktionen `save()`, `syncAllToServer()`, `load()`, `seed()`
|
||||
// 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.
|
||||
// Jede Sitzung wird als eigene CSV-Datei gespeichert. Personen und Rollen werden
|
||||
// in der Sitzungscsv selbst abgelegt.
|
||||
function save(){
|
||||
// save: plant einen (debounced) Sync. Standardmäßig wird die aktuell gewählte
|
||||
// Woche als geändert markiert, da sich die meisten Bearbeitungen auf die
|
||||
// aktuelle Sitzung beziehen. Optionen:
|
||||
// { 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();
|
||||
}
|
||||
|
||||
// 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
|
||||
// über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender
|
||||
// Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden.
|
||||
@@ -25,99 +89,228 @@ function scheduleSessionSync(){
|
||||
saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500);
|
||||
}
|
||||
|
||||
// syncAllToServer: schreibt die gesamte Datenbank als CSV-Dateien zum Server.
|
||||
// Zuerst wird die globale Personenliste (`_people.csv`) gespeichert, danach jede
|
||||
// Sitzung einzeln über `filenameForWeek()`/`weekToCsv()` und `sendCsvToServer()`.
|
||||
// Gibt nichts zurück; Fehler werden nur per console.error protokolliert.
|
||||
// syncAllToServer: schreibt nur die seit dem letzten Sync geänderten Wochen
|
||||
// zum Server. Konflikte (409) auf einer Woche werden über
|
||||
// handleWeekConflict() gemeldet. Fehlerhaft gespeicherte Wochen bleiben dirty,
|
||||
// damit der nächste Sync sie erneut versucht; nur erfolgreich gespeicherte
|
||||
// werden aus dem Dirty-Satz entfernt.
|
||||
function syncAllToServer(){
|
||||
// Zuerst globale Personenliste speichern
|
||||
saveGlobalPeopleToServer();
|
||||
// Dann jede Sitzung speichern
|
||||
db.weeks.forEach(w => {
|
||||
const filename = filenameForWeek(w.label);
|
||||
const csv = weekToCsv(w);
|
||||
sendCsvToServer(filename, csv)
|
||||
.then(res => { if(res && res.ok) console.log('Sitzung gespeichert:', filename); })
|
||||
.catch(err => { console.error('Sitzung konnte nicht gespeichert werden:', filename, err.message || err); });
|
||||
if(syncInFlight){ syncQueued = true; return; }
|
||||
syncInFlight = true;
|
||||
|
||||
const jobs = [];
|
||||
const weekLabels = Array.from(dirtyWeeks);
|
||||
let problem = '';
|
||||
weekLabels.forEach(label => {
|
||||
// Die Planung-Sandbox steckt bewusst nicht in db.weeks (siehe
|
||||
// db.sandboxWeek, oben in load()) — ohne diesen Zweig würde jede
|
||||
// Sandbox-Änderung hier lautlos verworfen (dirtyWeeks.delete ohne
|
||||
// jemals zu speichern), weil db.weeks.find() sie nie findet.
|
||||
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : db.weeks.find(x => x.label === label);
|
||||
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
|
||||
// wirklich alle Änderungen dieser Woche beim Server angekommen.
|
||||
const sentGeneration = weekChangeCounter.get(label) || 0;
|
||||
jobs.push(sendWeekToServer(w)
|
||||
.then(res => {
|
||||
if(res && res.ok){
|
||||
console.log('Sitzung gespeichert:', w.label);
|
||||
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
|
||||
// nicht fälschlich als Konflikt gemeldet werden.
|
||||
if(res.updatedAt) w.updatedAt = res.updatedAt;
|
||||
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
|
||||
} else {
|
||||
problem = 'Sitzung "' + w.label + '" konnte nicht gespeichert werden.';
|
||||
console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if(err && err.conflict){
|
||||
dirtyWeeks.delete(label);
|
||||
handleWeekConflict(w, err);
|
||||
} else if(err && err.status === 401){
|
||||
// Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still
|
||||
// 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(); }
|
||||
});
|
||||
}
|
||||
|
||||
// saveGlobalPeopleToServer: serialisiert die globale Personenliste der aktuellen
|
||||
// Abteilung per `globalPeopleToCsv()` und speichert sie als `_people.csv`.
|
||||
// Leere Personenlisten werden übersprungen (kein Schreibvorgang).
|
||||
function saveGlobalPeopleToServer(){
|
||||
const people = db.globalPeople[currentDepartment] || [];
|
||||
if(people.length === 0) return;
|
||||
const csv = globalPeopleToCsv(people);
|
||||
sendCsvToServer('_people.csv', csv)
|
||||
.then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); })
|
||||
.catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); });
|
||||
// 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);
|
||||
}
|
||||
|
||||
// switchDepartment: wechselt die aktive Abteilung (z.B. "ELT") und lädt deren
|
||||
// Daten neu. Parameter `dept` ist der Abteilungsschlüssel aus `DEPARTMENTS`.
|
||||
// Aktualisiert Tab-Markierung und Seitentitel und ruft anschließend `load()`.
|
||||
// Gibt nichts zurück.
|
||||
function switchDepartment(dept){
|
||||
if(!DEPARTMENTS[dept]) return;
|
||||
// Laufenden Speicher-Debounce der alten Abteilung verwerfen, damit dessen
|
||||
// Sync nicht nach dem Reset mit dem neuen currentDepartment feuert und die
|
||||
// CSV der falschen Abteilung schreibt.
|
||||
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
|
||||
currentDepartment = dept;
|
||||
saveUiState();
|
||||
updateRouteUrl(dept, currentTab);
|
||||
|
||||
// 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();
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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
|
||||
// verworfen (Stale-Response), sonst würden Daten der alten Abteilung unter
|
||||
// der neuen Abteilung angezeigt und gespeichert.
|
||||
const requestedDepartment = currentDepartment;
|
||||
const url = baseUrl() + '/load-all' + departmentParam();
|
||||
console.log('Loading from', url);
|
||||
fetch(url)
|
||||
// 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(data => {
|
||||
if(currentDepartment !== requestedDepartment) return;
|
||||
// Globale Personenliste laden
|
||||
db.globalPeople[currentDepartment] = parseGlobalPeopleCsv(data.people);
|
||||
const fresh = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||
.find(w => w && w.label === label);
|
||||
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 = [];
|
||||
parsedSessions.forEach(s => {
|
||||
const w = parseSessionCsv(s.filename, s.rows);
|
||||
if(w) db.weeks.push(w);
|
||||
});
|
||||
const holidays = fetch(baseUrl() + '/holidays')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
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){
|
||||
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();
|
||||
}
|
||||
else {
|
||||
@@ -131,23 +324,29 @@ function load(){
|
||||
}
|
||||
// Geschützte Tabs ('projects'/'people') dürfen nur mit Passwort angezeigt
|
||||
// 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();
|
||||
showTab(currentTab);
|
||||
})
|
||||
.catch(err => {
|
||||
if(currentDepartment !== requestedDepartment) return;
|
||||
console.error('Laden vom Server fehlgeschlagen:', err);
|
||||
if(silent) return;
|
||||
const serverUrl = baseUrl();
|
||||
const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' +
|
||||
'Mögliche Ursachen:\n' +
|
||||
'- Der Server wurde nicht gestartet\n' +
|
||||
'- Der Server läuft auf einem anderen Port (Standard: 3003)\n' +
|
||||
'- Firewall/Antivirus blockiert ' + serverUrl + '\n\n' +
|
||||
'Es werden jetzt die Beispieldaten angezeigt.';
|
||||
'Es werden keine Daten angezeigt. Bitte die Seite neu laden, sobald der Server wieder läuft.';
|
||||
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).
|
||||
@@ -168,7 +367,7 @@ function seed(){
|
||||
{id:personIdForName('MK'), name:"MK", fullName:"Martin Kluge", role:["Bauüberwachung"]},
|
||||
{id:personIdForName('HR'), name:"HR", fullName:"Hartwig Rauch", role:["Bauüberwachung"]},
|
||||
];
|
||||
db.globalPeople[currentDepartment] = JSON.parse(JSON.stringify(people));
|
||||
db.globalPeople = JSON.parse(JSON.stringify(people));
|
||||
|
||||
const wid = uid();
|
||||
db.weeks = [{
|
||||
@@ -184,7 +383,9 @@ function seed(){
|
||||
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){
|
||||
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
@@ -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
|
||||
// Test.html geladen, nach js/showmode.js, vor js/pdf.js). showTab() wechselt
|
||||
// zwischen Board, Auslastung, Projekte und Projektbeteiligte und fordert für
|
||||
// die geschützten Tabs 'projects'/'people' das Passwort an; confirmPassword()
|
||||
// prüft den SHA256-Hash gegen PROTECTED_PASSWORD_HASH (js/globals.js). Die
|
||||
// ausstehende Aktion wird in requestedAction (js/globals.js) gemerkt – davon
|
||||
// profitieren auch die Passwort-Gates in js/weeks.js (Löschen/Entsperren von
|
||||
// Sitzungen). Gerendert werden beim Tab-Wechsel renderBoard()/renderLoad()/
|
||||
// renderProjects()/renderPeople() aus js/board/board.js, js/load/load.js, js/projects/projects.js,
|
||||
// js/people/people.js.
|
||||
// Modul für die Tab-Navigation der Hauptseite (nur von App.html geladen, nach
|
||||
// js/wand/wand.js, vor js/pdf.js). showTab() wechselt zwischen Board,
|
||||
// Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Meine Aufgaben,
|
||||
// Wandtafel, Einstellungen, Admin und dem Projektbereich eines einzelnen
|
||||
// Projekts ('plan', siehe TAB_PANELS).
|
||||
// Einstellungen und Admin sind keine eigenen Tabs mehr, sondern aufklappbare
|
||||
// Bereiche direkt in der Seitenleiste (siehe toggleSidebarAccordion() in
|
||||
// js/ui.js). Gerendert werden beim Tab-Wechsel renderBoard()/
|
||||
// renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()/
|
||||
// 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, skipAuth = false){
|
||||
// Für die Tabs 'projects' und 'people' ist ein Passwortschutz vorgesehen
|
||||
if((t==='projects' || t==='people') && !skipAuth && !isAuthenticated){
|
||||
requestedAction = { type: 'tab', tab: t };
|
||||
document.getElementById('passwordModal').style.display='flex';
|
||||
document.getElementById('passwordInput').value='';
|
||||
document.getElementById('passwordInput').focus();
|
||||
return;
|
||||
}
|
||||
function showTab(t){
|
||||
// Der Bereich „Admin" steht nur Administratoren offen. Ohne diese Weiche
|
||||
// zeigte ein weitergereichter Deep-Link „/Admin" einem Mitglied eine leere
|
||||
// Seite — die Karten darin rendert renderAdminArea() für Nicht-Admins gar
|
||||
// nicht erst. (Ein UI-Gate, mehr nicht: Die Routen dahinter sind
|
||||
// serverseitig mit requireRole('admin') abgesichert.)
|
||||
if(t === 'admin' && !(currentUser && currentUser.role === 'admin')) t = DEFAULT_TAB;
|
||||
if(t !== currentTab) previousTab = currentTab;
|
||||
currentTab = t;
|
||||
saveUiState();
|
||||
updateRouteUrl(currentDepartment, t);
|
||||
updateRouteUrl(t);
|
||||
|
||||
['board','projects','load','people'].forEach(x=>{
|
||||
TAB_PANELS.forEach(x=>{
|
||||
const el = document.getElementById(x);
|
||||
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));
|
||||
if(t==='board' && currentWeek()) renderBoard();
|
||||
if(t==='projects') renderProjects();
|
||||
if(t==='wochenbericht') renderWochenbericht();
|
||||
if(t==='load') renderLoad();
|
||||
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 ----------
|
||||
|
||||
// confirmPassword: prüft das eingegebene Passwort gegen den gespeicherten Hash
|
||||
function confirmPassword(){
|
||||
const pwd = document.getElementById('passwordInput').value;
|
||||
if(!pwd) { alert("Bitte Passwort eingeben!"); return; }
|
||||
|
||||
const pwdHash = CryptoJS.SHA256(pwd).toString();
|
||||
|
||||
if(pwdHash === PROTECTED_PASSWORD_HASH){
|
||||
// Sitzungs-Authentifizierung merken, damit Tab-Wechsel und Abteilungs-
|
||||
// wechsel innerhalb dieser Sitzung kein erneutes Passwort verlangen.
|
||||
isAuthenticated = true;
|
||||
// Nur die zuletzt angeforderte Aktion ausführen (nicht eine evtl. bereits
|
||||
// durch closePasswordModal verworfenen Aktion erneut starten) – daher die
|
||||
// Aktion hier erneut über requestedAction prüfen statt sie festzuhalten.
|
||||
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();
|
||||
// syncAdminNavState: „Admin" ist in der Seitenleiste kein .tab-Knopf,
|
||||
// sondern der Kopf einer aufklappbaren Gruppe — die allgemeine
|
||||
// .tab-Hervorhebung oben greift dort also nicht. Diese Funktion markiert ihn
|
||||
// stattdessen, solange der Bereich offen ist, und klappt die Gruppe dabei
|
||||
// auf, damit der gewählte Abschnitt sichtbar wird.
|
||||
// Parameter: t (aktiver Bereich). Keine Rückgabe.
|
||||
function syncAdminNavState(t){
|
||||
const toggle = document.getElementById('adminAccordionToggle');
|
||||
const subnav = document.getElementById('adminSubnav');
|
||||
if(!toggle || !subnav) return;
|
||||
const aktiv = (t === 'admin');
|
||||
toggle.classList.toggle('active', aktiv);
|
||||
if(aktiv && !subnav.classList.contains('open')){
|
||||
subnav.classList.add('open');
|
||||
toggle.classList.add('expanded');
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
}
|
||||
// closePasswordModal: schließt das Passwort-Modal ohne Ausführung und
|
||||
// verwirft die gemerkte Aktion (requestedAction = null).
|
||||
function closePasswordModal(){
|
||||
document.getElementById('passwordModal').style.display='none';
|
||||
requestedAction = null;
|
||||
|
||||
// syncPlanNavItem: hält den Eintrag des Projektbereichs in der Seitenleiste
|
||||
// aktuell. Er ist der einzige Navigationspunkt, der nicht immer da ist — er
|
||||
// erscheint erst, wenn ein Projekt geöffnet wurde, und trägt dann dessen
|
||||
// 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
@@ -3,12 +3,12 @@
|
||||
// -----------------------------
|
||||
// Diese Datei verwaltet den reinen UI-Zustand: den kollabierbaren Header sowie
|
||||
// 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
|
||||
// js/tabs-auth.js (Tab-Wechsel, showTab) aufgerufen; im Theme-Umschalter der
|
||||
// Seite werden toggleTheme/applyTheme direkt eingebunden.
|
||||
// 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>".
|
||||
|
||||
// saveUiState: speichert den aktiven Tab in den LocalStorage
|
||||
@@ -258,7 +258,7 @@ function toggleSidebarCollapse(){
|
||||
// toggleSidebarAccordion: klappt einen der beiden Aktions-Bereiche in der
|
||||
// Seitenleiste (Einstellungen/Admin) auf oder zu. Erwartet die zugehörigen
|
||||
// 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.
|
||||
// Parameter: key ('settings'|'admin'). Rückgabe: keine.
|
||||
function toggleSidebarAccordion(key){
|
||||
|
||||
+237
-35
@@ -5,7 +5,7 @@
|
||||
// ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden
|
||||
// HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen
|
||||
// ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen,
|
||||
// u. a. js/csv.js, js/board/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.
|
||||
|
||||
// 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.
|
||||
// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle
|
||||
// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung.
|
||||
// Liegt im gemeinsamen Modul, weil auch csv.js (weekToCsv für savePlan auf der
|
||||
// Projektplan-Seite) und pdf.js darauf zugreifen.
|
||||
// Liegt im gemeinsamen Modul, weil board/load/pdf auf sie zugreifen.
|
||||
function peopleByRole(target){
|
||||
const m = {}; ROLES.forEach(r=>m[r]=[]);
|
||||
const people = [];
|
||||
@@ -73,6 +72,18 @@ function peopleByRole(target){
|
||||
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
|
||||
// Zuordnungen auch nach einem Neuladen stabil bleiben.
|
||||
// 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,
|
||||
// Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js)
|
||||
// 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
|
||||
// sie unterschiedliche Speicherpfade ansteuern.
|
||||
|
||||
@@ -278,10 +289,27 @@ function formatStatus(command, value){
|
||||
// eingeschleust werden können; im style-Attribut sind nur color/font-weight
|
||||
// erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei
|
||||
// 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){
|
||||
if(!html) return '';
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
// Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die
|
||||
// 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 allowedAttrs = new Set(['style']);
|
||||
// Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden:
|
||||
@@ -334,26 +362,27 @@ function sanitizeStatusHtml(html){
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// statusHtmlToEditText: wandelt einen gespeicherten Kommentar (HTML) in den
|
||||
// Klartext, der im Textfeld des Kommentar-Editors steht. Fettschrift wird zu
|
||||
// **Sternchen**, Absätze und <br> werden zu Zeilenumbrüchen, alles andere
|
||||
// fällt weg. Gegenstück zu statusEditTextToHtml(). Nötig, weil der Editor im
|
||||
// Board seit der Kommentar-Überarbeitung ein normales <textarea> ist und kein
|
||||
// contenteditable mehr (document.execCommand ist abgekündigt) — gespeichert
|
||||
// wird aber weiterhin HTML, damit alle vorhandenen Anzeigepfade (Board-
|
||||
// Vorschau, Wochenbericht, PDF-Export, Show-Mode, Verlauf) unverändert
|
||||
// funktionieren und Altbestände lesbar bleiben.
|
||||
// statusTextMitZeilen: wandelt Kommentar-HTML in reinen Text und erhält dabei
|
||||
// die Zeilenumbrüche (<br> und Blockgrenzen werden zu \n). Gegenstück zu
|
||||
// statusPlainText() weiter unten, das Umbrüche bewusst zu Leerzeichen
|
||||
// einebnet — hier werden sie gebraucht, weil der einzige Aufrufer die ERSTE
|
||||
// ZEILE will: openTodoAusKommentar() (js/wochenbericht/wochenbericht.js)
|
||||
// schlägt daraus den Titel einer neuen Aufgabe vor.
|
||||
//
|
||||
// Hieß bis zur Umstellung auf contenteditable statusHtmlToEditText() und gab
|
||||
// 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.
|
||||
function statusHtmlToEditText(html){
|
||||
function statusTextMitZeilen(html){
|
||||
if(!html) return '';
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = sanitizeStatusHtml(html);
|
||||
// Fett-Auszeichnungen vor dem Auflösen in Sternchen überführen
|
||||
div.querySelectorAll('b, strong').forEach(el => {
|
||||
const inner = (el.textContent || '').trim();
|
||||
el.replaceWith(document.createTextNode(inner ? '**' + inner + '**' : ''));
|
||||
});
|
||||
// Block-Grenzen zu Zeilenumbrüchen
|
||||
// Block-Grenzen zu Zeilenumbrüchen; alle Auszeichnungen fallen durch
|
||||
// textContent ohnehin weg.
|
||||
div.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode('\n')));
|
||||
div.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode('\n')));
|
||||
return (div.textContent || '')
|
||||
@@ -362,19 +391,192 @@ function statusHtmlToEditText(html){
|
||||
.trim();
|
||||
}
|
||||
|
||||
// statusEditTextToHtml: wandelt den Klartext aus dem Kommentar-Editor in das
|
||||
// gespeicherte HTML. Escapt zuerst vollständig (esc()), sodass eingetippte
|
||||
// spitze Klammern niemals als Markup ankommen, und setzt danach die einzige
|
||||
// erlaubte Auszeichnung **fett** sowie Zeilenumbrüche. Gegenstück zu
|
||||
// statusHtmlToEditText(). Leerer oder reiner Leerraum-Text ergibt '' — die
|
||||
// bisherige Bedeutung von "kein Kommentar" bleibt damit erhalten.
|
||||
// Parameter: text (String). Rückgabe: String (HTML).
|
||||
function statusEditTextToHtml(text){
|
||||
const clean = String(text || '').trim();
|
||||
if(clean === '') return '';
|
||||
return esc(clean)
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
||||
.replace(/\n/g, '<br>');
|
||||
// ---------- Formatierbare Textfelder: contenteditable statt Markup-Zeichen ----------
|
||||
//
|
||||
// Erster Anlauf (Markup-Zeichen **fett**/*kursiv*/[[c:…]] in einem normalen
|
||||
// <textarea>, inzwischen vollständig entfernt) zeigte während des
|
||||
// Tippens sichtbare Stern-/Klammer-Zeichen (**fett**, [[c:…]]) statt echter
|
||||
// Formatierung — Eriks Rückmeldung: "statt den zwei Sternchen... den Text
|
||||
// richtig fett machen". Diese Felder sind jetzt contenteditable statt
|
||||
// <textarea>, und richtextWickeln()/richtextFett()/richtextKursiv()/
|
||||
// richtextFarbe() wenden die Auszeichnung direkt auf die Markierung an — über
|
||||
// die Selection/Range-API (range.extractContents()/insertNode()), bewusst
|
||||
// KEIN document.execCommand (abgekündigt, derselbe Grund, der die vorige
|
||||
// contenteditable-Fassung dieser Felder schon einmal verworfen hatte — nur
|
||||
// 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 --------
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
+121
-108
@@ -2,87 +2,59 @@
|
||||
// Projektübersicht — Sitzungs-/Wochen-Verwaltung
|
||||
// -----------------------------
|
||||
// Diese Datei verwaltet die Sitzungen (Wochen): Auswahl, Anzeige des Datums,
|
||||
// Erzeugung neuer Sitzungen, Duplizieren, Sperren (Auto-Lock) und Löschen.
|
||||
// Sie wird von `Test.html` nach `js/store.js` geladen und stellt globale
|
||||
// Funktionen wie `currentWeek()`, `switchWeek()`, `newWeek()`, `cloneWeek()`,
|
||||
// `applyAutoLock()`, `guardWeekLocked()` und `deleteWeek()` bereit. Genutzt wird
|
||||
// Sperren (Auto-Lock) und Löschen. Sie wird von `App.html` nach `js/store.js`
|
||||
// geladen und stellt globale Funktionen wie `currentWeek()`, `switchWeek()`,
|
||||
// `applyAutoLock()`, `guardWeekLocked()`, `unlockWeek()` und `deleteWeekById()`
|
||||
// bereit. Genutzt wird
|
||||
// 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).
|
||||
|
||||
// refreshWeekSelect: aktualisiert das Dropdown `weekSelect` mit allen Sitzungen
|
||||
// der aktuellen Abteilung und markiert die aktuell ausgewählte. Kein Parameter,
|
||||
// keine Rückgabe.
|
||||
function refreshWeekSelect(){
|
||||
const sel = document.getElementById('weekSelect');
|
||||
sel.innerHTML = db.weeks.map(w=>`<option value="${w.id}" ${w.id===db.currentWeek?'selected':''}>${w.label}</option>`).join('');
|
||||
}
|
||||
|
||||
// updateWeekDate: zeigt das Datum der aktuellen Sitzung im Display
|
||||
// `weekDateDisplay` an (formatiert über `formatWeekDate()`). Kein Parameter,
|
||||
// keine Rückgabe; leer, wenn keine Woche gewählt ist.
|
||||
function updateWeekDate(){
|
||||
const disp = document.getElementById('weekDateDisplay');
|
||||
const w = currentWeek();
|
||||
if(!disp) return;
|
||||
disp.textContent = w?.date ? `Datum: ${formatWeekDate(w.date)}` : '';
|
||||
}
|
||||
//
|
||||
// Neue Sitzungen entstehen seit dem automatischen Sitzungswechsel NICHT MEHR
|
||||
// hier — `ensureCurrentWeekRollover()` (server.js) legt sie serverseitig an,
|
||||
// sobald der Kalender in eine neue ISO-Woche wechselt, unabhängig davon, ob
|
||||
// irgendjemand die Anwendung gerade geöffnet hat (siehe CLAUDE.md). Die
|
||||
// früheren Funktionen `newWeek()`/`cloneWeek()`/`validateWeekLabel()` sind
|
||||
// deshalb entfallen.
|
||||
|
||||
// currentWeek: liefert das Woche-Objekt zur aktuellen Auswahl aus `db.weeks`.
|
||||
// Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined.
|
||||
function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); }
|
||||
|
||||
// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`), speichert und
|
||||
// rendert die Ansicht neu. Keine Rückgabe.
|
||||
function switchWeek(id){ db.currentWeek = id; save(); renderAll(); }
|
||||
|
||||
// newWeek: erzeugt über zwei prompt-Dialoge eine neue leere Sitzung (Bezeichnung
|
||||
// und Datum). Die neue Woche übernimmt die globale Personenliste der Abteilung,
|
||||
// wird angehängt, per `applyAutoLock()` gesperrt und als aktuelle Woche gesetzt.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function newWeek(){
|
||||
const label = prompt("Bezeichnung der neuen Sitzung (z.B. KW 31 / 2026):","KW / 2026");
|
||||
if(!label) return;
|
||||
const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || "";
|
||||
// Neue Woche übernimmt die aktuell globale Personenliste
|
||||
const people = JSON.parse(JSON.stringify(db.globalPeople[currentDepartment] || []));
|
||||
const w = {id:uid(), label, date, people, projects:[], attendance:{}, locked:false};
|
||||
db.weeks.push(w);
|
||||
applyAutoLock();
|
||||
db.currentWeek=w.id; save(); renderAll();
|
||||
}
|
||||
|
||||
// cloneWeek: dupliziert die aktuelle Sitzung inkl. Projekten (Dialog für
|
||||
// Bezeichnung und Datum). Projekte erhalten neue IDs; Status-Markierungen
|
||||
// werden auf `statusState='duplicated'` gesetzt. Keine Parameter, keine Rückgabe.
|
||||
function cloneWeek(){
|
||||
const cur = currentWeek(); if(!cur) return;
|
||||
const label = prompt("Bezeichnung der duplizierten Sitzung:", cur.label+" (Kopie)");
|
||||
if(!label) return;
|
||||
const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || "";
|
||||
const w = JSON.parse(JSON.stringify(cur));
|
||||
w.id=uid(); w.label=label; w.date=date; w.attendance={}; w.locked=false;
|
||||
// Neue IDs für duplizierte Projekte und Kennzeichnung
|
||||
w.projects.forEach(p=>{
|
||||
p.id = uid();
|
||||
if(p.status && p.status.trim() !== "") p.statusState = 'duplicated';
|
||||
else delete p.statusState;
|
||||
});
|
||||
db.weeks.push(w);
|
||||
applyAutoLock();
|
||||
db.currentWeek=w.id; save(); renderAll();
|
||||
}
|
||||
// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`) und rendert die
|
||||
// Ansicht neu. Es werden keine Daten geändert, daher wird nicht gespeichert
|
||||
// (eine save() würde die Woche nur als dirty markieren und unnötig senden).
|
||||
// Keine Rückgabe.
|
||||
function switchWeek(id){ db.currentWeek = id; renderAll(); }
|
||||
|
||||
// -------- Sperren (Auto-Lock) und Entsperren --------
|
||||
|
||||
// applyAutoLock: sperrt alle Sitzungen bis auf die chronologisch
|
||||
// letzte (aktuellste). Wird beim Laden, Erstellen und Duplizieren verwendet,
|
||||
// damit historische Sitzungen nicht mehr unbeabsichtigt bearbeitet werden.
|
||||
//
|
||||
// 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(){
|
||||
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;
|
||||
db.weeks.forEach(w => {
|
||||
echteWochen.forEach(w => {
|
||||
const wasLocked = !!w.locked;
|
||||
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;
|
||||
}
|
||||
|
||||
// unlockCurrentWeek: Entsperrt die aktuelle Sitzung nach Passwortprüfung.
|
||||
// Parameter `skipAuth=true` überspringt die Passwortabfrage (Aufruf nach
|
||||
// erfolgreicher Authentifizierung). Keine Rückgabe.
|
||||
function unlockCurrentWeek(skipAuth = false){
|
||||
const w = currentWeek();
|
||||
// unlockWeek: entsperrt EINE Sitzung serverseitig (POST /unlock-week,
|
||||
// admin-only) und übernimmt bei Erfolg den Sperr-Zustand sowie die neue
|
||||
// Revision (`updatedAt`) lokal.
|
||||
//
|
||||
// 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(!skipAuth && !isAuthenticated){
|
||||
requestedAction = { type: 'unlockWeek' };
|
||||
document.getElementById('passwordModal').style.display='flex';
|
||||
document.getElementById('passwordInput').value='';
|
||||
document.getElementById('passwordInput').focus();
|
||||
return;
|
||||
}
|
||||
w.locked = false;
|
||||
save(); renderAll();
|
||||
requestedAction = null;
|
||||
fetch(baseUrl() + '/unlock-week', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ label: w.label })
|
||||
}).then(r => {
|
||||
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;
|
||||
if(json.updatedAt) w.updatedAt = json.updatedAt;
|
||||
(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
|
||||
// vorhanden) inkl. ihrer CSV-Datei auf dem Server. Parameter `skipAuth=true`
|
||||
// überspringt die Passwortabfrage. Danach wird die letzte verbleibende Woche
|
||||
// aktiviert und neu gerendert. Keine Rückgabe.
|
||||
function deleteWeek(skipAuth = false){
|
||||
// unlockCurrentWeek: entsperrt die gerade offene Sitzung. Aufrufer ist
|
||||
// ausschließlich der Sperr-Hinweis auf dem Board/Wochenbericht
|
||||
// (renderBoard()/renderWochenbericht()), der nur erscheint, wenn
|
||||
// `currentWeek()` selbst gesperrt ist — im Normalbetrieb mit aktiver
|
||||
// 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(!skipAuth && !isAuthenticated){
|
||||
requestedAction = { type: 'deleteWeek' };
|
||||
document.getElementById('passwordModal').style.display='flex';
|
||||
document.getElementById('passwordInput').value='';
|
||||
document.getElementById('passwordInput').focus();
|
||||
return;
|
||||
}
|
||||
if(!confirm("Diese Sitzung wirklich löschen?")) { requestedAction = null; return; }
|
||||
const current = currentWeek();
|
||||
const filename = current ? filenameForWeek(current.label) : null;
|
||||
if(filename){
|
||||
console.log('Lösche CSV-Datei auf Server:', filename);
|
||||
deleteCsvFromServer(filename).then(res=>{
|
||||
if(res && res.ok){ console.log('CSV-Datei gelöscht:', filename); }
|
||||
else { alert('CSV-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler')); }
|
||||
}).catch(err=>{ alert('CSV-Löschung fehlgeschlagen: ' + (err.message || err)); });
|
||||
}
|
||||
db.weeks = db.weeks.filter(w=>w.id!==db.currentWeek);
|
||||
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||
db.currentWeek = db.weeks[db.weeks.length - 1].id;
|
||||
applyAutoLock();
|
||||
save();
|
||||
renderAll();
|
||||
requestedAction = null;
|
||||
const w = db.weeks.find(x => x.id === weekId);
|
||||
if(!w) return;
|
||||
if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return;
|
||||
console.log('Lösche Woche auf Server:', w.label);
|
||||
deleteWeekFromServer(w.label).then(res=>{
|
||||
if(!(res && res.ok)){
|
||||
alert('Wochen-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler'));
|
||||
return;
|
||||
}
|
||||
console.log('Woche gelöscht:', w.label);
|
||||
const warAktuell = (weekId === db.currentWeek);
|
||||
db.weeks = db.weeks.filter(x=>x.id!==weekId);
|
||||
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;
|
||||
}
|
||||
applyAutoLock();
|
||||
save();
|
||||
renderAll();
|
||||
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));
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
// Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu
|
||||
// vergeben) nicht bieten konnte. Die Bearbeitung der Projekte selbst
|
||||
// (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
|
||||
// ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
|
||||
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten).
|
||||
@@ -26,7 +26,7 @@
|
||||
// diesem Tab die Sitzung abgearbeitet wird.
|
||||
//
|
||||
// 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`
|
||||
// (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
|
||||
// Render-Funktionen aus js/board/board.js übergibt. Im Wochenbericht werden
|
||||
// nur die beauftragten Leistungsphasen gezeigt (p.beauftragteLP) — im
|
||||
// Projektboard bleibt es bei allen. Den Verlauf der Vorwochen hängt
|
||||
// renderBoardDrawer() inzwischen überall an (renderProjektVerlauf() in
|
||||
// js/utils.js), er braucht deshalb kein eigenes Kennzeichen mehr.
|
||||
// Render-Funktionen aus js/board/board.js übergibt. dicht ist das einzige,
|
||||
// was den Wochenbericht noch vom Projektboard unterscheidet (zwei statt drei
|
||||
// Spalten) — nurBeauftragteLph gilt inzwischen für beide (BOARD_OPTS,
|
||||
// js/board/board.js). Den Verlauf der Vorwochen hängt renderBoardDrawer()
|
||||
// überall an (renderProjektVerlauf() in js/utils.js), er braucht deshalb kein
|
||||
// eigenes Kennzeichen mehr.
|
||||
const BERICHT_OPTS = { nurBeauftragteLph: true, dicht: true };
|
||||
|
||||
// renderReportCard: baut die immer aufgeklappte Karte eines Projekts im
|
||||
@@ -120,27 +121,6 @@ function renderReportCard(p, reason, allPeople, locked, weekLabel, variant){
|
||||
</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 ----------
|
||||
// Die Anwesenheit hing ursprünglich als Zweitfunktion an der Kürzel-Legende
|
||||
// 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
|
||||
// gespeicherten: Im Meeting tippt man den Beschluss und legt sofort die
|
||||
// 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 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) || '';
|
||||
|
||||
document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120);
|
||||
@@ -844,7 +829,11 @@ document.addEventListener('keydown', function(e){
|
||||
if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return;
|
||||
const w = currentWeek();
|
||||
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)){
|
||||
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
File diff suppressed because it is too large.
Load diff
+238
-53
@@ -678,11 +678,20 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
|
||||
/* === Bereich "Protokolle" (js/plan/plan-protokoll.js) ===
|
||||
Listenansicht (.protokoll-row je Protokoll) und Detailansicht
|
||||
(.protokoll-field-grid Kopf, Teilnehmer- und Punkte-Block). Der
|
||||
Punkte-Block gruppiert in .protokoll-gruppe-Abschnitte (freier Text,
|
||||
keine feste Liste, siehe CLAUDE.md "### Protokolle"); neue Punkte tragen
|
||||
.is-neu (eigenes Gelb-Token --protokoll-neu-bg/-border, siehe styles.css —
|
||||
bewusst nicht --band-neu, das im Wochenbericht schon blau "neu
|
||||
kommentiert" bedeutet). */
|
||||
Punkte-Block ist ZWEIGETEILT (.protokoll-split): links eine schmale
|
||||
Liste aller Punkte nach Gruppen (.protokoll-liste), rechts der
|
||||
Schreibbereich für den gerade ausgewählten (.protokoll-schreiben) —
|
||||
Begründung bei .protokoll-split weiter unten. Gruppierung bleibt freier
|
||||
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; }
|
||||
|
||||
@@ -727,7 +736,7 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
|
||||
|
||||
/* Ladehinweis im Import-Dialog (#protokollImportLoading, Test.html): ohne
|
||||
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
|
||||
gesperrt (setzeProtokollImportLadezustand(), plan-protokoll.js), damit
|
||||
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-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-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:hover { opacity: 1; color: var(--red); }
|
||||
|
||||
.protokoll-gruppe { margin-bottom: 18px; }
|
||||
.protokoll-gruppe:last-of-type { margin-bottom: 8px; }
|
||||
.protokoll-gruppe-head { display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 2px 2px 8px; border-bottom: 1px solid var(--line-soft); margin-bottom: 10px; }
|
||||
.protokoll-gruppe-name { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: var(--accent); background: var(--accent-soft); padding: 3px 9px; border-radius: var(--r-pill); }
|
||||
.protokoll-gruppe-name.is-leer { color: var(--text-subtle); background: var(--surface-sunk); }
|
||||
.protokoll-gruppe-add { font-size: 12px; color: var(--accent); font-weight: 600; cursor: pointer; }
|
||||
.protokoll-gruppe-add:hover { color: var(--accent-hover); }
|
||||
.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; }
|
||||
/* ---------- Punkte: links Liste, rechts Schreibbereich ----------
|
||||
Eriks Auswahl „Liste + Schreibbereich" aus dem Protokoll-schreiben-Mockup,
|
||||
Ablösung der früheren durchgehenden Tabelle (eine Zeile je Punkt mit allen
|
||||
Feldern nebeneinander). Begründung dort und im Kopf von
|
||||
renderPunkteBlockHtml() (js/plan/plan-protokoll.js): ein Protokollpunkt ist
|
||||
ein kleiner Fließtext, keine Tabellenzelle — in der Spalte blieb für den
|
||||
Text weder Breite noch Höhe, Thema und Text klebten übereinander und die
|
||||
Formatierungsleiste saß eingeklemmt dazwischen. */
|
||||
.protokoll-split { display: grid; grid-template-columns: 290px minmax(0, 1fr); gap: 14px; align-items: start; }
|
||||
|
||||
.protokoll-punkt { position: relative; border: 1px solid var(--line-soft); border-radius: var(--r-md);
|
||||
padding: 10px 12px; margin-bottom: 8px; background: var(--surface); }
|
||||
.protokoll-punkt:last-child { margin-bottom: 0; }
|
||||
.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-punkt.is-erledigt { opacity: .6; }
|
||||
.protokoll-punkt-top-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
.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);
|
||||
background: var(--surface-sunk); border-radius: var(--r-sm); }
|
||||
.protokoll-punkt.is-neu .protokoll-punkt-nr { background: rgba(0,0,0,.06); color: var(--text); }
|
||||
.protokoll-punkt-gruppe { width: 110px; flex-shrink: 0; font-size: 12px; }
|
||||
.protokoll-chip-neu { background: var(--protokoll-neu-border); color: #3d2e00; font-size: 10px; font-weight: 700;
|
||||
letter-spacing: .03em; text-transform: uppercase; padding: 2px 7px; border-radius: var(--r-pill); flex-shrink: 0; }
|
||||
.protokoll-punkt-thema { flex: 1; min-width: 160px; font-weight: 600; font-size: 13px; }
|
||||
.protokoll-punkt-erledigt-toggle { display: flex; align-items: center; gap: 5px; font-size: 12px;
|
||||
color: var(--text-muted); flex-shrink: 0; }
|
||||
.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-punkt-foot { display: flex; gap: 18px; flex-wrap: wrap; font-size: 12px; color: var(--text-subtle); }
|
||||
.protokoll-punkt-foot label { display: flex; align-items: center; gap: 6px; }
|
||||
.protokoll-punkt-foot input[type=text] { font-size: 12px; padding: 5px 8px; }
|
||||
.protokoll-punkt-foot .range-picker-display { width: 100px; font-size: 12px; padding: 5px 8px; }
|
||||
/* Reines Anzeigefeld (kein Input) — erstellt_am ist serverseitig maßgeblich
|
||||
und beim Fortschreiben unveränderlich, siehe legeProtokollAn()/PUT
|
||||
.../:id in server.js; rechtsbündig, damit es nicht wie ein drittes
|
||||
Eingabefeld neben Zuständig/Termin wirkt. */
|
||||
.protokoll-punkt-erstellt { margin-left: auto; align-self: center; white-space: nowrap; }
|
||||
.protokoll-liste {
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md);
|
||||
overflow: hidden; box-shadow: var(--sh-1);
|
||||
}
|
||||
.protokoll-liste-kopf {
|
||||
padding: 9px 12px; background: var(--surface-alt); border-bottom: 1px solid var(--line);
|
||||
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--text-subtle);
|
||||
}
|
||||
/* Gruppen-Kopfzeile: Farbbalken links aus dem Gruppennamen gehasht
|
||||
(protokollGruppeFarbe(), plan-protokoll.js — dasselbe Prinzip wie
|
||||
projektFarbe() in der Auslastung), damit eine Gruppe ihre Farbe behält,
|
||||
egal an welcher Position sie steht. Der Name selbst ist das Eingabefeld:
|
||||
umbenennen wirkt auf alle Punkte der Gruppe. */
|
||||
.protokoll-liste-gruppe {
|
||||
display: flex; align-items: center; gap: 7px; padding: 8px 10px 8px 11px;
|
||||
background: var(--surface-sunk); border-bottom: 1px solid var(--line-soft);
|
||||
border-left: 3px solid var(--protokoll-gruppe-farbe, var(--accent));
|
||||
}
|
||||
.protokoll-liste-gruppe + .protokoll-liste-gruppe,
|
||||
.protokoll-liste-zeile + .protokoll-liste-gruppe,
|
||||
.protokoll-liste-neu + .protokoll-liste-gruppe { border-top: 1px solid var(--line); }
|
||||
/* Dieselbe !important-Begründung wie bei den übrigen randlosen Feldern
|
||||
weiter unten: die Dark-Mode-Fassung von input[type=text] (styles.css)
|
||||
hängt ein html-Element vor den Selektor und gewinnt sonst. */
|
||||
.protokoll-liste-gruppe input.protokoll-gruppe-heading {
|
||||
flex: 1; min-width: 0; font-size: 12px; font-weight: 700; letter-spacing: .01em;
|
||||
border: none !important; background: none !important; border-radius: 4px !important;
|
||||
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){
|
||||
.protokoll-row { grid-template-columns: 1fr; gap: 4px; }
|
||||
.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;
|
||||
@@ -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.neu { background: var(--protokoll-neu-bg); border: 1.5px solid var(--protokoll-neu-border); }
|
||||
.legend-swatch.fortgeschrieben,
|
||||
.legend-swatch.erledigt { background: var(--surface); border: 1.5px solid var(--line); }
|
||||
.legend-swatch.erledigt { opacity: .6; }
|
||||
.legend-swatch.info { background: var(--status-info-bg); border: 1.5px solid var(--status-info); }
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Vendored
BIN
Binary file not shown.
@@ -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,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,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,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&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 & 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,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&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 & 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,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,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,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,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;[];[]
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user