Wandtafel-Dashboard, Dateibereinigung und Auslastungs-Vereinfachung
Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte. Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt. Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc30f2900b
commit
db63a29365
47 files changed
+16463
-3745
No files matched your search
@@ -5,3 +5,4 @@ sessions/*.db-shm
|
||||
|
||||
# macOS-Artefakte
|
||||
.DS_Store
|
||||
sessions/*.txt
|
||||
Binary file not shown.
@@ -1,145 +1,129 @@
|
||||
# Projektübersicht — README
|
||||
# Projektübersicht
|
||||
|
||||
## Übersicht
|
||||
Dieses Projekt ist eine lokale Weboberfläche zur Verwaltung von TGA-Projekten (Elektro, HLS, Architektur) über wöchentliche Sitzungen. 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 aus zwei HTML-Einstiegen (`Test.html` für die Hauptansicht mit Tabs, `project-plan.html` für die Projektplanung/GANTT-Ansicht) plus `styles.css` und den JS-Modulen unter `public/js/`
|
||||
- einem einfachen Node.js-/Express-Server in `server.js`
|
||||
- einem Datenspeicher in einer SQLite-Datenbank (`node:sqlite`, benötigt Node ≥ 22.13) unter `sessions/projektuebersicht.db`
|
||||
- einem Docker-Setup (`Docker/Dockerfile` + `docker-compose.yml` im Projektstamm)
|
||||
## 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`, `project-plan.html`, die Module unter `js/`, `styles.css`, etc.) bereit; statische Assets werden mit `Cache-Control: no-store` ausgeliefert
|
||||
- implementiert Endpunkte:
|
||||
- `GET /` → liefert `Test.html`
|
||||
- `GET /load-all?department=…` → lädt alle Sitzungen plus globale Personenliste einer Abteilung
|
||||
- `POST /save-week {department, week, baseUpdatedAt}` → speichert/aktualisiert eine Sitzung; mit `baseUpdatedAt` (die vom Client gehaltene Revision = `updatedAt` aus `load-all`) erkennt der Server veraltete Speicherungen und antwortet dann mit HTTP 409 `{ok:false, conflict:true, …}`; auch gelöschte Sitzungen werden mit nicht-leerer Basis nicht wiederbelebt (409). Erfolgreiche Saves antworten `{ok:true, updatedAt}` (neue Revision)
|
||||
- `POST /save-people {department, people}` → ersetzt die globale Personenliste einer Abteilung
|
||||
- `POST /delete-week {department, label}` → löscht eine Sitzung
|
||||
- `GET /health` → Gesundheitscheck (`{ok:true}`)
|
||||
- Deep Links je Abteilung + Tab, z. B. `GET /HLS/Auslastung` oder `GET /ELT/Projektübersicht` → liefert `Test.html`; gültige Tab-Slugs sind `Projektübersicht | Auslastung | Projekte | Projektbeteiligte`, alles andere ergibt 404
|
||||
- `GET /<Dept>/Projektplan(/<ProjName>)?` → liefert `project-plan.html` (GANTT-Ansicht); die Identifikation erfolgt über die Query-Parameter `weekLabel` und `projNr`, der Projektname ist nur ein lesbares Pfad-Segment
|
||||
- validiert Abteilungen gegen `ELT | HLS | ARCH` (unbekannte fallen stillschweigend auf `ELT` zurück)
|
||||
- legt das `sessions`-Verzeichnis beim Start an, falls es nicht existiert
|
||||
- JSON-Body-Limit von 2 MB
|
||||
- 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`, kein Bundler):
|
||||
- gemeinsame Helfer: `globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `server.js`, `store.js`, `weeks.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`, `init.js`
|
||||
- tab-spezifisch in eigenen Ordnern: `board/board.js` (Projektboard), `people/people.js` (Projektbeteiligte), `projects/projects.js` (Projekte), `load/load.js` (Auslastung)
|
||||
- Planungs-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 als JSON-Objekte in die SQLite-Datenbank zurück
|
||||
- verwendet `currentDepartment` zur Trennung von Abteilungen (`ELT`, `HLS`, `ARCH`)
|
||||
- speichert:
|
||||
- alle Sitzungen und die globale Personenliste pro Abteilung in `sessions/projektuebersicht.db`
|
||||
- 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
|
||||
- speichert keine Daten in `localStorage` (dort liegt nur UI-Zustand wie Abteilung, Theme und Header-Collapse)
|
||||
|
||||
- `Test.html`
|
||||
- die Haupt-Benutzeroberfläche der Anwendung (Tabs: Projektboard, Auslastung, Projekte, Projektbeteiligte)
|
||||
- lädt `styles.css` und die JS-Module aus `js/` in fester Reihenfolge (siehe `AGENTS.md`)
|
||||
- enthält die Abteilungsauswahl, Sitzungsnavigation, Tab-Navigation, das Passwort-Modal sowie PDF-Export
|
||||
- lädt CryptoJS (Passwortprüfung) und pdfmake (PDF-Export) von `cdnjs.cloudflare.com` — diese Funktionen benötigen daher Internetzugang
|
||||
|
||||
- `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/*` und window-übergreifend abgestimmte Pfade
|
||||
|
||||
- `styles.css`
|
||||
- Layout und Styling der Oberfläche
|
||||
|
||||
### Docker-bezogene Dateien
|
||||
- `Docker/Dockerfile`
|
||||
- Basis-Image: `node:22-alpine` (nötig, da `node:sqlite` erst ab Node 22.13 ohne Flag stabil verfügbar ist)
|
||||
- kopiert `package.json` und `package-lock.json`, installiert Produktionsabhängigkeiten mit `npm ci`
|
||||
- kopiert `server.js` und `public/` nach `/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` (im Projektstamm)
|
||||
- definiert den Service `projektuebersicht`
|
||||
- baut das Image mit Build-Kontext `.` und `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: `node_modules/`, `npm-debug.log`, `.git`, `.gitignore`, `Docker/*` (außer `Docker/Dockerfile`)
|
||||
|
||||
### Sonstige Dateien
|
||||
- `dein_bild.ico`
|
||||
- optionales Icon, wird vom Server statisch ausgeliefert, wenn angefragt
|
||||
|
||||
- `AGENTS.md`
|
||||
- Projektnotizen / Dokumentation, aber nicht Teil des Anwendungslaufs
|
||||
|
||||
## Persistenz und Datenfluss
|
||||
|
||||
### Speicherung
|
||||
- Alle persistierten Daten werden in einer SQLite-Datenbank (`node:sqlite`, `DatabaseSync`, WAL-Modus, `PRAGMA foreign_keys = ON`) unter `sessions/projektuebersicht.db` gespeichert.
|
||||
- Das Schema ist vollständig relational (19 Tabellen): `weeks`, `week_people` (+ `week_people_roles`), `attendance`, `projects`, `project_lph`, `project_assign`, `project_aufwand`, `project_aktuelle_lp`, `project_plan`, `plan_people`, `plan_todos` (+ `plan_todo_assignees`, `plan_todo_deps`), `plan_milestones` (+ `plan_milestone_deps`), `people` (+ `person_roles`, `person_absences`). Projekt-/Personen-IDs bleiben dadurch über Neustarts stabil.
|
||||
- Der Datenbankpfad ist über die Umgebungsvariable `DB_PATH` übersteuerbar (für Tests/Backups), Standard ist `sessions/projektuebersicht.db`.
|
||||
|
||||
### Server-API
|
||||
- Der Server wird in `server.js` erstellt und startet auf Port `3003` oder dem Wert von `process.env.PORT`.
|
||||
- Das Frontend ruft den Server über `window.location.origin` an (Fallback `http://localhost:3003` bei `file://`-Öffnung).
|
||||
|
||||
### Wo werden die Daten gespeichert?
|
||||
- Das Frontend schreibt über den Server in die SQLite-Datenbank.
|
||||
- In `js/store.js` speichert die Anwendung nur **geänderte** Sitzungen (Dirty-Tracking über `markWeekDirty()`/`markPeopleDirty()`) via `/save-week` (Sitzungen) bzw. `/save-people` (globale Personen); ein In-Flight-Guard verhindert Doppel-Speicherungen. Jede Sitzung führt einen Revisions-Zeitstempel (`updated_at`, übertragen als `updatedAt`); speichert ein Client mit veralteter Revision (`baseUpdatedAt`), antwortet der Server mit HTTP 409 und der Client lädt nur diese eine Sitzung neu (andere ungespeicherte Änderungen bleiben erhalten).
|
||||
- Der Server zerlegt die JSON-Objekte in die relationalen Tabellen (`serializeWeek()`/`loadWeeks()`) in einer Transaktion pro Sitzung. Beim Start migriert `migrateLegacySchema()` idempotent ein altes Single-JSON-Dokument-Schema in das relationale Schema; anschließend ergänzt `migrateWeeksUpdatedAt()` die `updated_at`-Spalte der `weeks`-Tabelle (idempotent). `PRAGMA busy_timeout = 5000` verhindert Fehler bei parallelen Schreibzugriffen.
|
||||
|
||||
## Betrieb
|
||||
|
||||
### Lokal mit Node.js (Node ≥ 22.13)
|
||||
### 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 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/` (für die SQLite-Datenbank und das Docker-Bind-Mount).
|
||||
- Die Docker-Compose-Konfiguration liegt im Projektstamm und 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 getrennt.
|
||||
- Die Passwortfunktion (Tabs „Projekte“/„Projektbeteiligte“, Sitzungslöschung, Entsperren) ist nur ein einfacher Client-Mechanismus im Frontend (SHA256-Vergleich) und kein echter Sicherheitsmechanismus.
|
||||
- Globale Personen werden pro Abteilung in der Datenbank verwaltet. Wird eine Person global gelöscht, bleibt sie in bereits existierenden Sitzungen sichtbar (Wochen-Personenkopien).
|
||||
- CryptoJS und pdfmake werden von `cdnjs.cloudflare.com` geladen — PDF-Export und Passwortprüfung funktionieren ohne Internet nicht.
|
||||
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.
|
||||
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"
|
||||
}
|
||||
}
|
||||
+1673
File diff suppressed because it is too large.
Load diff
@@ -1,255 +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/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>
|
||||
|
||||
<hr class="menu-divider">
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="menu-label">Backup</div>
|
||||
<button class="secondary" onclick="exportCSV()">CSV Export</button>
|
||||
<button class="secondary" onclick="downloadBackup()">DB Backup</button>
|
||||
<button class="secondary" onclick="importDb()">DB Import</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"></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/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/csv.js?v=1"></script>
|
||||
<script src="/js/backup.js?v=1"></script>
|
||||
<script src="/js/import.js?v=1"></script>
|
||||
<script src="/js/init.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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');
|
||||
|
||||
+5
-3
@@ -3,7 +3,7 @@
|
||||
// -----------------------------
|
||||
// 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 Test.html am Button "DB Backup" und
|
||||
// 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.
|
||||
|
||||
@@ -21,8 +21,10 @@ async function downloadBackup(){
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const filename = 'projektuebersicht_backup_'
|
||||
+ new Date().toISOString().slice(0, 10) + '.db';
|
||||
// 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');
|
||||
|
||||
+606
-255
@@ -1,56 +1,70 @@
|
||||
// -----------------------------
|
||||
// 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
|
||||
// die Projektplan-Seite, die board.js nicht lädt).
|
||||
@@ -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)}">${esc(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>`;
|
||||
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>`;
|
||||
});
|
||||
|
||||
// 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="${esc(v)}" ${inputAttr} placeholder="-">`;
|
||||
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
|
||||
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
|
||||
body += `</td>`;
|
||||
});
|
||||
body += `</tr>`;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
let lockNotice = '';
|
||||
// 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,42 +0,0 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — CSV/ZIP-Export
|
||||
// -----------------------------
|
||||
// Stellt exportCSV() bereit: lädt den ZIP mit den übersichtlichen CSV-Dateien
|
||||
// der aktuellen Abteilung über GET /export-csv und stößt den Download an.
|
||||
// Hängt im Seitenmenü von Test.html am Button "CSV Export" und nutzt baseUrl()
|
||||
// aus js/server.js sowie currentDepartment aus js/globals.js. Der ZIP enthält
|
||||
// pro logischer Entität eine CSV (Wochen, Personen, Projekte, …), erzeugt vom
|
||||
// Server (siehe server.js im Projekt-Root).
|
||||
|
||||
// exportCSV: fordert den CSV-Export der aktuellen Abteilung an und lädt ihn
|
||||
// als ZIP-Datei herunter. Kein Parameter, keine Rückgabe; Fehler werden über
|
||||
// ein Alert gemeldet.
|
||||
async function exportCSV(){
|
||||
try {
|
||||
const dept = currentDepartment || 'ELT';
|
||||
const res = await fetch(baseUrl() + '/export-csv?department=' + encodeURIComponent(dept));
|
||||
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();
|
||||
const filename = 'Projektuebersicht_' + dept + '_'
|
||||
+ new Date().toISOString().slice(0, 10) + '.zip';
|
||||
|
||||
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('CSV-Export fehlgeschlagen:', err);
|
||||
alert('CSV-Export fehlgeschlagen: ' + (err.message || err));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
// Projekt-IDs sind in der DB stabil, aber die Plan-Seite ist eine separate
|
||||
// Route. 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 —
|
||||
|
||||
+21
-24
@@ -4,25 +4,23 @@
|
||||
// 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 er passwortgeschützt (gleiche Logik wie
|
||||
// Wochen-Löschung über requestedAction/isAuthenticated, siehe js/tabs-auth.js
|
||||
// und js/weeks.js) und wird zusätzlich im Browser per Bestätigungsdialog
|
||||
// abgesichert.
|
||||
// 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: öffnet bei Bedarf das Passwort-Modal (requestedAction 'importDb')
|
||||
// und startet danach den Datei-Dialog für das Backup. Parameter `skipAuth=true`
|
||||
// überspringt die Passwortabfrage (wird von confirmPassword() genutzt).
|
||||
// Keine Rückgabe.
|
||||
function importDb(skipAuth = false){
|
||||
if(!skipAuth && !isAuthenticated){
|
||||
requestedAction = { type: 'importDb' };
|
||||
document.getElementById('passwordModal').style.display = 'flex';
|
||||
document.getElementById('passwordInput').value = '';
|
||||
document.getElementById('passwordInput').focus();
|
||||
return;
|
||||
}
|
||||
if(!confirm('Datenbank-Backup wirklich wiederherstellen?\n\nAlle aktuellen Daten werden ersetzt.')){
|
||||
requestedAction = null;
|
||||
// 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');
|
||||
@@ -30,14 +28,10 @@ function importDb(skipAuth = false){
|
||||
input.accept = '.db,application/octet-stream,application/x-sqlite3';
|
||||
input.onchange = () => {
|
||||
const file = input.files && input.files[0];
|
||||
if(!file){
|
||||
requestedAction = null;
|
||||
return;
|
||||
}
|
||||
if(!file) return;
|
||||
uploadBackup(file);
|
||||
};
|
||||
input.click();
|
||||
requestedAction = null;
|
||||
}
|
||||
|
||||
// uploadBackup: lädt die gewählte .db-Datei als rohen Binär-Body an
|
||||
@@ -51,6 +45,7 @@ async function uploadBackup(file){
|
||||
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();
|
||||
@@ -58,7 +53,9 @@ async function uploadBackup(file){
|
||||
} catch(e){ /* Antwort war kein JSON */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
alert('Datenbank-Backup erfolgreich wiederhergestellt.\nDie Seite wird neu geladen.');
|
||||
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);
|
||||
|
||||
+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] });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+109
-322
@@ -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({people:true}); 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({people:true}); 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({people:true}); 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({people:true}); 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({people:true}); 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)">`;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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({people:true});
|
||||
}
|
||||
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({week:false, people:true});
|
||||
renderPeople();
|
||||
if(currentTab === 'gantt') renderGantt();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// deleteAbsence: entfernt eine Abwesenheit (`absenceId`) aus der Liste der
|
||||
// Person (`personId`), speichert und rendert neu. Keine Rückgabe.
|
||||
function deleteAbsence(personId, absenceId){
|
||||
if(guardWeekLocked()) return;
|
||||
const people = db.globalPeople[currentDepartment] || [];
|
||||
const person = people.find(p => p.id === personId);
|
||||
if(!person || !person.absences) return;
|
||||
person.absences = person.absences.filter(a => a.id !== absenceId);
|
||||
save({week:false, people:true});
|
||||
renderPeople();
|
||||
if(currentTab === 'gantt') renderGantt();
|
||||
w.people = w.people.filter(p => p.id !== personId);
|
||||
if(w.attendance) delete w.attendance[personId];
|
||||
save();
|
||||
renderAll();
|
||||
}
|
||||
+92
-26
@@ -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,7 +113,11 @@
|
||||
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);
|
||||
// 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)];
|
||||
@@ -125,17 +129,19 @@
|
||||
// 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 !== todoIds.length) return;
|
||||
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:
|
||||
@@ -382,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.
|
||||
@@ -415,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; }
|
||||
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;
|
||||
}
|
||||
app.savePlan();
|
||||
app.saveGlobalPeople();
|
||||
// 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
|
||||
|
||||
+282
-136
@@ -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,89 +49,118 @@ 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;
|
||||
}
|
||||
currentDepartment = app.department; // global aus globals.js
|
||||
bindToolbarButtons();
|
||||
loadPlanData();
|
||||
// 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();
|
||||
}
|
||||
|
||||
// bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen,
|
||||
// Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der
|
||||
// anderen plan-Module. Die Buttons binden ihre Handler bereits über das
|
||||
// onclick-Attribut in project-plan.html; hier werden nur Lücken gefüllt,
|
||||
// falls ein Button-Element fehlt.
|
||||
function bindToolbarButtons(){
|
||||
}
|
||||
// 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 = '';
|
||||
};
|
||||
|
||||
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der
|
||||
// Abteilung über die Server-API (JSON-Dokumente), 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;
|
||||
// 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;
|
||||
}
|
||||
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] = Array.isArray(data.people) ? data.people : [];
|
||||
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||
.filter(w => w && typeof w.label === 'string')
|
||||
.map(w => {
|
||||
if(!Array.isArray(w.people)) w.people = [];
|
||||
if(!Array.isArray(w.projects)) w.projects = [];
|
||||
if(!w.attendance) w.attendance = {};
|
||||
return 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);
|
||||
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){
|
||||
document.getElementById('planTitle').textContent = 'Nicht gefunden';
|
||||
document.getElementById('planSubtitle').textContent = 'Projekt nicht in dieser Sitzung vorhanden.';
|
||||
return;
|
||||
planFail('Nicht gefunden', 'Dieser Terminplan wurde nicht gefunden — er wurde möglicherweise gelöscht.');
|
||||
return false;
|
||||
}
|
||||
app.projNr = '';
|
||||
app.projName = app.project.name || '';
|
||||
renderPlanPage();
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('planTitle').textContent = 'Fehler beim Laden';
|
||||
document.getElementById('planSubtitle').textContent = err.message;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if(!app.weekLabel || (!app.projNr && !app.projName)){
|
||||
planFail('Kein Projekt gewählt', 'Diesen Bereich öffnet ein Klick auf ein Projekt im Projektboard oder im Tab „Projekte".');
|
||||
return false;
|
||||
}
|
||||
// Vor dem ersten erfolgreichen load() ist db.weeks noch leer (Deep-Link
|
||||
// direkt auf "/Projektplan/…"). Dann ist das kein Fehler, sondern nur
|
||||
// noch nicht so weit: load() ruft showTab(currentTab) erneut auf.
|
||||
if(!Array.isArray(db.weeks) || db.weeks.length === 0){
|
||||
const t = document.getElementById('planTitle');
|
||||
const sub = document.getElementById('planSubtitle');
|
||||
if(t) t.textContent = app.projName || 'Projektbereich';
|
||||
if(sub) sub.textContent = 'Sitzungsdaten werden geladen…';
|
||||
return false;
|
||||
}
|
||||
app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
|
||||
if(!app.week){
|
||||
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
|
||||
return false;
|
||||
}
|
||||
// Projekt-Nr. ist der stabile Schlüssel; die Bezeichnung dient nur als
|
||||
// Rückfallebene für Projekte, die (noch) keine Nummer haben.
|
||||
const projects = app.week.projects || [];
|
||||
app.project = projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName)
|
||||
|| (app.projNr ? projects.find(p => (p.nr || '') === app.projNr) : null)
|
||||
|| projects.find(p => (p.name || '') === app.projName)
|
||||
|| null;
|
||||
if(!app.project){
|
||||
planFail('Nicht gefunden', 'Projekt nicht in dieser Sitzung vorhanden.');
|
||||
return false;
|
||||
}
|
||||
app.projNr = app.project.nr || '';
|
||||
app.projName = app.project.name || '';
|
||||
renderPlanPage();
|
||||
return true;
|
||||
};
|
||||
|
||||
// ensurePlanLocal: stellt sicher, dass das Plan-Objekt des Projekts die
|
||||
// erwartete Struktur hat (inkl. Migration alter Einzel-Abhängigkeiten).
|
||||
function ensurePlanLocal(){
|
||||
@@ -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,58 +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.
|
||||
// In-Flight-Schutz: läuft bereits ein Save, wird kein zweiter POST mit
|
||||
// identischer Revisions-Basis abgeschickt (der würde fälschlich als 409-
|
||||
// Konflikt gemeldet). Stattdessen wird ein Folgesave eingereiht, der nach
|
||||
// Abschluss des laufenden Saves mit der dann frischen Revision nachzieht.
|
||||
let savePlanRunning = false;
|
||||
let savePlanQueued = false;
|
||||
// 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;
|
||||
if(savePlanRunning){ savePlanQueued = true; return; }
|
||||
savePlanRunning = true;
|
||||
sendWeekToServer(app.week)
|
||||
.then(res => {
|
||||
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
|
||||
// nicht fälschlich als Konflikt gemeldet werden.
|
||||
if(res && res.updatedAt) app.week.updatedAt = res.updatedAt;
|
||||
})
|
||||
.catch(err => {
|
||||
if(err && err.conflict){
|
||||
showToast('Konflikt: Die Sitzung wurde zwischenzeitlich geändert. Bitte Seite neu laden.');
|
||||
console.error('Speichern fehlgeschlagen (Konflikt):', err.message || err);
|
||||
} else {
|
||||
console.error('Speichern fehlgeschlagen:', err);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
savePlanRunning = false;
|
||||
if(savePlanQueued){ savePlanQueued = false; savePlan(); }
|
||||
});
|
||||
markWeekDirty(app.week.label);
|
||||
scheduleSessionSync();
|
||||
}
|
||||
|
||||
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
|
||||
// JSON-Array auf den Server. Auch eine leere Liste wird gespeichert, damit
|
||||
// das Löschen der letzten Person erhalten bleibt.
|
||||
function saveGlobalPeople(){
|
||||
const people = db.globalPeople[app.department] || [];
|
||||
sendPeopleToServer(people).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
|
||||
@@ -247,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);
|
||||
})();
|
||||
+108
-23
@@ -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,16 +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));
|
||||
@@ -74,28 +137,37 @@ function renderProjects(){
|
||||
html += `<div class="section-title">${esc(g)}</div>`;
|
||||
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" ${locked ? '' : 'draggable="true" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"'} data-project-id="${p.id}">
|
||||
${locked ? '' : `<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
|
||||
@@ -135,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 {
|
||||
@@ -151,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;
|
||||
}
|
||||
|
||||
@@ -197,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();
|
||||
}
|
||||
+28
-34
@@ -3,13 +3,16 @@
|
||||
// -----------------------------
|
||||
// Diese Datei kapselt die HTTP-Kommunikation mit dem Express-Server (siehe
|
||||
// server.js im Projekt-Root) und wird nach js/date-picker.js von beiden
|
||||
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl, blobToDataUrl,
|
||||
// sendWeekToServer, sendPeopleToServer und deleteWeekFromServer bereit.
|
||||
// Genutzt wird sie von js/store.js (syncAllToServer, Speichern aller Wochen
|
||||
// und der Personenliste), 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).
|
||||
// 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.
|
||||
@@ -22,17 +25,6 @@ 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
|
||||
@@ -46,7 +38,6 @@ function sendWeekToServer(week){
|
||||
headers:{'Content-Type':'application/json; charset=utf-8'},
|
||||
body:JSON.stringify({
|
||||
week,
|
||||
department:currentDepartment,
|
||||
baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined
|
||||
})
|
||||
}).then(r=>{
|
||||
@@ -59,30 +50,33 @@ function sendWeekToServer(week){
|
||||
err.updatedAt = json.updatedAt;
|
||||
throw err;
|
||||
}
|
||||
throw new Error('Server HTTP '+r.status);
|
||||
const err = new Error((json && json.error) || ('Server HTTP '+r.status));
|
||||
err.status = r.status;
|
||||
err.label = (week && week.label) || '';
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// sendPeopleToServer: POSTet die globale Personenliste einer Abteilung als
|
||||
// JSON-Array an <baseUrl>/save-people. Parameter: people (Array).
|
||||
// Rückgabe: Promise mit Server-Antwort.
|
||||
function sendPeopleToServer(people){
|
||||
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
||||
return fetch(baseUrl() + '/save-people',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json; charset=utf-8'},
|
||||
body:JSON.stringify({people, department:currentDepartment})
|
||||
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
|
||||
}
|
||||
|
||||
// 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() + '/delete-week', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json; charset=utf-8'},
|
||||
body: JSON.stringify({ label, 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();
|
||||
}
|
||||
});
|
||||
+186
-116
@@ -1,36 +1,47 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Daten laden, Speichern, Abteilung wechseln
|
||||
// Projektübersicht — Daten laden, Speichern
|
||||
// -----------------------------
|
||||
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt alle Sitzungen
|
||||
// beim Start, speichert den kompletten Datenbestand (alle Wochen + globale
|
||||
// Personenliste) und verwaltet den Wechsel zwischen Abteilungen (ELT/HLS/ARCH).
|
||||
// Die Daten liegen serverseitig in einer SQLite-Datenbank und werden als
|
||||
// strukturierte JSON-Objekte über die API ausgetauscht (keine CSV-Strings).
|
||||
// Sie wird von `Test.html` nach `js/date-picker.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: 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)
|
||||
// { people:true } – zusätzlich die globale Personenliste als geändert markieren
|
||||
function save(opts = {}){
|
||||
if(opts.week !== false){
|
||||
const w = currentWeek();
|
||||
if(w) dirtyWeeks.add(w.label);
|
||||
if(w) markWeekDirty(w.label);
|
||||
}
|
||||
if(opts.people) dirtyPeople = true;
|
||||
scheduleSessionSync();
|
||||
}
|
||||
|
||||
// Dirty-Tracking: merkt sich, welche Wochen bzw. ob die globale Personenliste
|
||||
// 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).
|
||||
// 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();
|
||||
let dirtyPeople = false;
|
||||
|
||||
// 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
|
||||
@@ -39,6 +50,37 @@ let dirtyPeople = false;
|
||||
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.
|
||||
@@ -48,22 +90,28 @@ function scheduleSessionSync(){
|
||||
}
|
||||
|
||||
// syncAllToServer: schreibt nur die seit dem letzten Sync geänderten Wochen
|
||||
// plus – falls markiert – die globale Personenliste 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.
|
||||
// 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(){
|
||||
if(syncInFlight){ syncQueued = true; return; }
|
||||
syncInFlight = true;
|
||||
|
||||
// Zuerst globale Personenliste speichern, falls geändert
|
||||
const jobs = [];
|
||||
if(dirtyPeople) jobs.push(saveGlobalPeopleToServer());
|
||||
|
||||
const weekLabels = Array.from(dirtyWeeks);
|
||||
let problem = '';
|
||||
weekLabels.forEach(label => {
|
||||
const w = db.weeks.find(x => x.label === 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){
|
||||
@@ -71,8 +119,9 @@ function syncAllToServer(){
|
||||
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
|
||||
// nicht fälschlich als Konflikt gemeldet werden.
|
||||
if(res.updatedAt) w.updatedAt = res.updatedAt;
|
||||
dirtyWeeks.delete(label);
|
||||
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');
|
||||
}
|
||||
})
|
||||
@@ -80,8 +129,15 @@ function syncAllToServer(){
|
||||
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 {
|
||||
console.error('Sitzung konnte nicht gespeichert werden:', w.label, err.message || err);
|
||||
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.
|
||||
}
|
||||
}));
|
||||
@@ -89,6 +145,8 @@ function syncAllToServer(){
|
||||
|
||||
Promise.all(jobs).then(() => {
|
||||
syncInFlight = false;
|
||||
showSyncProblem(problem || null);
|
||||
if(problem) scheduleSyncRetry();
|
||||
if(syncQueued){ syncQueued = false; syncAllToServer(); }
|
||||
});
|
||||
}
|
||||
@@ -96,12 +154,9 @@ function syncAllToServer(){
|
||||
// markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für
|
||||
// Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock).
|
||||
function markWeekDirty(label){
|
||||
if(typeof label === 'string' && label) dirtyWeeks.add(label);
|
||||
}
|
||||
|
||||
// markPeopleDirty: markiert die globale Personenliste als geändert.
|
||||
function markPeopleDirty(){
|
||||
dirtyPeople = true;
|
||||
if(typeof label !== 'string' || !label) return;
|
||||
dirtyWeeks.add(label);
|
||||
weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1);
|
||||
}
|
||||
|
||||
// handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich
|
||||
@@ -117,16 +172,14 @@ function handleWeekConflict(week, err){
|
||||
else load();
|
||||
}
|
||||
|
||||
// reloadWeekFromServer: lädt die aktuelle Abteilung neu und ersetzt NUR die
|
||||
// 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){
|
||||
const requestedDepartment = currentDepartment;
|
||||
fetch(baseUrl() + '/load-all' + departmentParam())
|
||||
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;
|
||||
const fresh = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||
.find(w => w && w.label === label);
|
||||
const idx = db.weeks.findIndex(w => w.label === label);
|
||||
@@ -149,93 +202,102 @@ function reloadWeekFromServer(label){
|
||||
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);
|
||||
load();
|
||||
showSyncProblem('Die Sitzung "' + label + '" konnte nicht neu geladen werden. Bitte die Seite neu laden.');
|
||||
});
|
||||
}
|
||||
|
||||
// saveGlobalPeopleToServer: speichert die globale Personenliste der aktuellen
|
||||
// Abteilung über `sendPeopleToServer()` (JSON-Array). Auch eine leere Liste
|
||||
// wird gespeichert, damit das Löschen der letzten Person erhalten bleibt. Bei
|
||||
// Erfolg wird der Dirty-Status für Personen zurückgesetzt, bei Fehler bleibt
|
||||
// er erhalten (Retry beim nächsten Sync). Rückgabe: Promise.
|
||||
function saveGlobalPeopleToServer(){
|
||||
const people = db.globalPeople[currentDepartment] || [];
|
||||
return sendPeopleToServer(people)
|
||||
.then(res => {
|
||||
if(res && res.ok){
|
||||
dirtyPeople = false;
|
||||
console.log('Globale Personenliste gespeichert');
|
||||
} else {
|
||||
console.error('Globale Personenliste konnte nicht gespeichert werden:', res && res.error ? res.error : 'unbekannter Fehler');
|
||||
}
|
||||
// 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); });
|
||||
|
||||
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.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err);
|
||||
// dirtyPeople bleibt true -> der nächste Sync versucht es erneut.
|
||||
});
|
||||
.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]);
|
||||
}
|
||||
|
||||
// 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
|
||||
// Daten der falschen Abteilung schreibt.
|
||||
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
|
||||
dirtyWeeks.clear();
|
||||
dirtyPeople = false;
|
||||
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();
|
||||
}
|
||||
|
||||
// load: liest alle Sitzungen und die globale Personenliste der aktuellen
|
||||
// Abteilung ü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.
|
||||
function load(){
|
||||
// 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();
|
||||
dirtyPeople = false;
|
||||
if(!window.fetch){
|
||||
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
|
||||
seed(); return;
|
||||
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();
|
||||
const url = baseUrl() + '/load-all';
|
||||
console.log('Loading from', url);
|
||||
fetch(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 => {
|
||||
if(currentDepartment !== requestedDepartment) return;
|
||||
// Globale Personenliste laden
|
||||
db.globalPeople[currentDepartment] = Array.isArray(data.people) ? data.people : [];
|
||||
// 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')
|
||||
@@ -262,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).
|
||||
@@ -299,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 = [{
|
||||
@@ -315,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
-58
@@ -1,76 +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);
|
||||
else if(action?.type==='importDb') importDb(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){
|
||||
|
||||
+223
-32
@@ -246,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.
|
||||
|
||||
@@ -289,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:
|
||||
@@ -345,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 || '')
|
||||
@@ -373,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>';
|
||||
}
|
||||
+104
-97
@@ -2,30 +2,19 @@
|
||||
// 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.
|
||||
@@ -37,53 +26,30 @@ function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); }
|
||||
// Keine Rückgabe.
|
||||
function switchWeek(id){ db.currentWeek = id; renderAll(); }
|
||||
|
||||
// newWeek: erzeugt über zwei prompt-Dialoge eine neue leere Sitzung (Bezeichnung
|
||||
// und Datum). Die neue Woche übernimmt die globale Personenliste der Abteilung,
|
||||
// wird angehängt, per `applyAutoLock()` gesperrt und als aktuelle Woche gesetzt.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function newWeek(){
|
||||
const label = prompt("Bezeichnung der neuen Sitzung (z.B. KW 31 / 2026):","KW / 2026");
|
||||
if(!label) return;
|
||||
const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || "";
|
||||
// Neue Woche übernimmt die aktuell globale Personenliste
|
||||
const people = JSON.parse(JSON.stringify(db.globalPeople[currentDepartment] || []));
|
||||
const w = {id:uid(), label, date, people, projects:[], attendance:{}, locked:false};
|
||||
db.weeks.push(w);
|
||||
applyAutoLock();
|
||||
db.currentWeek=w.id; save(); renderAll();
|
||||
}
|
||||
|
||||
// cloneWeek: dupliziert die aktuelle Sitzung inkl. Projekten (Dialog für
|
||||
// Bezeichnung und Datum). Projekte erhalten neue IDs; Status-Markierungen
|
||||
// werden auf `statusState='duplicated'` gesetzt. Keine Parameter, keine Rückgabe.
|
||||
function cloneWeek(){
|
||||
const cur = currentWeek(); if(!cur) return;
|
||||
const label = prompt("Bezeichnung der duplizierten Sitzung:", cur.label+" (Kopie)");
|
||||
if(!label) return;
|
||||
const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || "";
|
||||
const w = JSON.parse(JSON.stringify(cur));
|
||||
w.id=uid(); w.label=label; w.date=date; w.attendance={}; w.locked=false;
|
||||
// Neue IDs für duplizierte Projekte und Kennzeichnung
|
||||
w.projects.forEach(p=>{
|
||||
p.id = uid();
|
||||
if(p.status && p.status.trim() !== "") p.statusState = 'duplicated';
|
||||
else delete p.statusState;
|
||||
});
|
||||
db.weeks.push(w);
|
||||
applyAutoLock();
|
||||
db.currentWeek=w.id; save(); renderAll();
|
||||
}
|
||||
|
||||
// -------- Sperren (Auto-Lock) und Entsperren --------
|
||||
|
||||
// applyAutoLock: sperrt alle Sitzungen bis auf die chronologisch
|
||||
// letzte (aktuellste). Wird beim Laden, Erstellen und Duplizieren verwendet,
|
||||
// damit historische Sitzungen nicht mehr unbeabsichtigt bearbeitet werden.
|
||||
//
|
||||
// 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
|
||||
@@ -111,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;
|
||||
}
|
||||
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;
|
||||
save(); renderAll();
|
||||
requestedAction = null;
|
||||
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) 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();
|
||||
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;
|
||||
}
|
||||
if(!confirm("Diese Sitzung wirklich löschen?")) { requestedAction = null; return; }
|
||||
const current = currentWeek();
|
||||
if(current){
|
||||
const label = current.label;
|
||||
console.log('Lösche Woche auf Server:', label);
|
||||
deleteWeekFromServer(label).then(res=>{
|
||||
if(res && res.ok){ console.log('Woche gelöscht:', label); }
|
||||
else { alert('Wochen-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler')); }
|
||||
}).catch(err=>{ alert('Wochen-Löschung fehlgeschlagen: ' + (err.message || err)); });
|
||||
}
|
||||
db.weeks = db.weeks.filter(w=>w.id!==db.currentWeek);
|
||||
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();
|
||||
requestedAction = null;
|
||||
if(typeof nachDelete === 'function') nachDelete();
|
||||
}).catch(err=>{
|
||||
if(err && err.status === 403){ alert('Nur Administratoren dürfen Sitzungen löschen.'); return; }
|
||||
alert('Wochen-Löschung fehlgeschlagen: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
@@ -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 |
+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,330 +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/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/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>
|
||||
+3396
-630
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user