diff --git a/CLAUDE.md b/CLAUDE.md index adf3a61..6313543 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ user-facing text. ``` . ├── server.js # Express server + SQLite API +├── pdf-arbeit.js # PDF lesen/rendern im Kindprozess (siehe "Betrieb auf dem Pi") ├── package.json # deps: express, cors, plus pdfjs-dist/mammoth │ # (added for Protokoll-Import, see "### Protokolle") ├── .npmrc # omit=optional — hält pdfjs-dist's rein optionales @@ -105,6 +106,7 @@ user-facing text. │ │ ├── auftrag.js # Auftragsstatus (Anfrage→Angebot→beauftragt→ │ │ │ # abgeschlossen), Chip/Auswahlfeld und der │ │ │ # Beauftragungs-Dialog +│ │ ├── benachrichtigungen.js # Glocke im Menüband + "Neu zugewiesen" auf dem Dashboard │ │ ├── backup.js # downloadBackup (lädt SQLite-Snapshot von GET /backup) │ │ ├── import.js # importDb (Backup-Wiederherstellung über POST /import, admin-only) │ │ └── init.js # renderAll/initUiState + Seiteneinstieg hinter checkAuthAndInit() (nur App.html) @@ -178,6 +180,8 @@ user-facing text. │ │ ├── plan-settings.js # Bereich "Einstellungen": Projektangaben, Honorar, │ │ │ # beauftragte Leistungsphasen, Beteiligten-Matrix │ │ │ # (renderPlanSettings, planCycleAssign) +│ │ ├── plan-phasen.js # Bereich "Leistungsphasen": Ablaufbalken + Kachel +│ │ │ # je Phase (renderPlanPhasen), aufgabenPhase() │ │ ├── plan-tasks.js # Bereich "Aufgaben": Aufgaben-/Meilensteinliste │ │ │ # (renderPlanTasks; öffnet dieselben Dialoge wie das GANTT) │ │ ├── plan-history.js # Kommentar-Verlauf über alle Sitzungen (+ Vorwochen-Helfer) @@ -186,6 +190,7 @@ user-facing text. │ │ │ # per KI einlesen + Aufgaben daraus ableiten — │ │ │ # unabhängig von plan-protokoll.js, nichts │ │ │ # davon wird gespeichert +│ │ ├── plan-mail-eintrag.js # "Termin aus Mail": Drag & Drop/Strg+V → KI → Aufgaben-Panel │ │ ├── plan-honorar.js # Terminplan aus Honorar + beauftragten LPH errechnen │ │ ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen) │ │ ├── plan-gantt.js # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load @@ -204,6 +209,8 @@ user-facing text. │ │ │ # und gelber Neu-Markierung (project-protokolle-Endpunkte) │ │ └── plan-schedule.js # Kritischer Pfad, Auto-Schedule, Mail-Link │ ├── styles.css +│ ├── glas.css # Glas-Darstellung (html.glas), siehe "Glas-Darstellung" +│ ├── logo.svg # vektorisiertes Logo (Kopfzeile, Login, Wasserzeichen) │ └── logo.ico └── sessions/ # SQLite-DB; bind-mounted into Docker container └── projektuebersicht.db # node:sqlite database (WAL mode) @@ -637,13 +644,17 @@ on the JSON endpoints. a **project's** discipline(s) (`project_gewerke` — see "Wochenbericht — Gewerke-Trennung" below); `VALID_DEPARTMENTS` is the shared value set for both. -- Static assets get `Cache-Control: no-store`. +- Static assets: files requested with `?v=N` (every JS/CSS in `App.html`) + get `Cache-Control: public, max-age=31536000, immutable`; everything else + (App.html, unversioned files) `no-cache` + ETag. **This makes bumping + `?v=N` for every changed CSS/JS file mandatory** — an unbumped file stays + stale in browsers for a year. See "Ladezeit (Pi)" below. - JSON body limit is 2 MB. ## Persistence (SQLite) - Data lives in `sessions/projektuebersicht.db` (`node:sqlite` `DatabaseSync`, - WAL mode, `PRAGMA foreign_keys = ON`, `PRAGMA busy_timeout = 5000` to avoid + WAL mode, `PRAGMA foreign_keys = ON`, `PRAGMA busy_timeout = 5000`, `PRAGMA synchronous = NORMAL` to avoid `SQLITE_BUSY` under concurrent writers), created automatically. The schema is **fully relational** (26 tables, in the unconditional `CREATE TABLE` block at the top of `server.js`) instead of one big JSON document per week: @@ -1014,6 +1025,114 @@ on the JSON endpoints. - User-provided status HTML is sanitized on render via a small whitelist (`sanitizeStatusHtml` in `js/board/board.js`) to mitigate XSS. +### Einführung — geführter Rundgang (`js/einfuehrung.js`) + +Eriks Wunsch: Jeder Nutzer, auch bestehende, macht einmal einen geführten +Rundgang; unter Einstellungen → Mein Konto „Rundgang starten“ jederzeit +wieder. Abgedunkelte Ebene mit Aussparung um das Ziel (`.tour-loch`, +box-shadow) und Sprechblase (`.tour-blase`, rechts vom Ziel, sonst +darunter/darüber, auf Handybreite unten), Fortschrittspunkte, Zurück/Weiter, +Pfeiltasten, Esc. Die Schritte (`EINFUEHRUNG_SCHRITTE`) wechseln selbst in den +Bereich (`showTab()`) und zeigen nur, was die Rolle darf (`recht`/`wenn`, +Rollen & Rechte); ohne „Anwendung nutzen“ (Wandtafel) kein Rundgang. Der +Projektbereich wird erklärt, nicht geöffnet. Abschluss nennt Fehlendes +(z. B. keine Person-ID). +- `users.einfuehrung_am` (Migration in `migrateUsersDepartment()`, leer = steht + aus — auch für alle bestehenden Konten), `req.user.einfuehrungFaellig`, + `POST /einfuehrung {erledigt}` (nur eigenes Konto). „Fertig“ und + „Überspringen“ vermerken; „Später“/Esc schließt nur für den Seitenaufruf + (`sessionStorage`). +- Start über `einfuehrungBeiBedarf()` aus `afterAuthenticated()` bzw. nach + dem Pflicht-Passwortwechsel; wartet, bis Daten geladen und kein Dialog + offen ist. Stile: Block „RUNDGANG“ am Ende von `styles.css` (z-index 450). + +### Abwesenheiten (Seitenleiste, `js/abwesenheit/abwesenheit.js`) + +Menüpunkt „Abwesenheiten“ (Tab `abwesenheit`, `/Abwesenheiten`) nach +Auslastung. **Gestaltung „Variante 2 im Stil der Projektübersicht“** (Eriks +Wahl): ohne Kopfbereich (Titel, Legende, „+ Abwesenheit“ und Hinweise auf +Eriks Wunsch entfernt), oben direkt zwei +Spalten — links Karte „Neue Abwesenheit“ (Für-Zeile mit Kürzel-Kreis, große +Von/Bis-Kacheln öffnen `openRangePicker(... onApply)`, Art als vier Kacheln, +Notiz, Warnungen: Überschneidung, offene Aufgaben der Person im Zeitraum, +Kollegen desselben Gewerks), rechts die Liste der gewählten Person als +Projektzeilen (`.board-row`: Startdatum in `.board-nr`, KW als `.phase-pill`, +Ändern/Löschen als `.board-row-actions`, Löschen zweistufig); unten „Team im +“ mit Gewerk-Filter und **je Gewerk eine Karte** (`.board-list`) mit +Kürzel-Kreis in Fachrollen-Farbe (`.board-avatar`) und Balken je Abwesenheit +(Klick auf einen Tag = für diese Person eintragen, auf einen Balken = ändern). +Keine halben Tage. +- **Rechte** (Gruppe „Team“, Schalter wie alle anderen): `team.abw` (für + mich), `team.abwProjekte` (Beteiligte gemeinsamer Projekte der jüngsten + Sitzung, `project_assign.state > 0`), `team.abwGewerk` (gleiches + `users.department`), `team.abwAlle`. Keiner = keine Abwesenheiten, kein + Menüpunkt (`data-recht="a|b"` = eines genügt, `renderUserBar()`). + `team.abwFremd` ist entfallen; einmalige Umstellung + `abwesenheitsRechteUmstellen()`: Mitglied nur `team.abw`, Projektleiter + zusätzlich Gewerk + Projekte, selbst angelegte Rollen mit dem alten Recht + bekommen „für alle“. +- **Server:** `abwesenheitZiele(user)`/`darfAbwesenheit(user, zielId)` prüfen + `POST`/`PATCH`/`DELETE /users/:id/absences…` (403 sonst; der frühere + Sonderweg „Ersteller darf löschen“ ist weg). Arten fest (`ABWESENHEIT_ARTEN`), + Datum ISO, Ende ≥ Beginn. **Sichtbarkeit:** `loadGlobalPeople(req.user)` + (in `/load-all`, `/my-dashboard`, `GET /personen`) liefert Abwesenheiten + außerhalb des eigenen Umfangs als `{type:'abwesend', note:'', verdeckt:true}` + und je Person `userId`/`darfAbw`. Jede Änderung sendet das Live-Ereignis + `{typ:'personen'}`; `personenNachladen()` (`store.js`) holt `GET /personen`. +- Der Abwesenheits-Dialog im Terminplan und die Benutzerverwaltung nutzen + dieselben Routen und damit dieselben Rechte. Rundgang: Schritt + „Abwesenheiten“. Stile: Block „ABWESENHEITEN“ am Ende von `styles.css`. + +### Rollen & Rechte (aktueller Stand — löst die festen Rollen ab) + +Eriks Wunsch (Entwurf „B“): Admin → **Rollen & Rechte** (`js/admin/rollen.js`, +Karte `#adminRollenCard`). Links die Rollen (Konten, Anteil der Rechte), +darunter „+ Neue Rolle“ (leer oder mit den Rechten einer anderen); rechts die +gewählte Rolle mit einem Schalter je Recht nach Gruppen, „alle/keine“ je +Gruppe, „Vergleich mit“ einer zweiten Rolle, Umbenennen, Kopieren, Löschen +(zweistufig im Knopf). Jede Änderung speichert sofort. + +- **Katalog** `RECHTE` (server.js, 30 Rechte in 7 Gruppen: Grundzugang, + Projekte, Wochenbericht, Team, Werkzeuge, Sitzungen, Admin-Bereich). Die + Schlüssel stehen im Code, nur die Zuordnung ist frei. +- **Tabellen** `roles` (key = `users.role`, name, sort_order, fest) und + `role_rechte`; NICHT in `IMPORT_TABLES`. Startbelegung (nur bei leerer + Tabelle) = Stand vorher: Administrator fest mit allem, Projektleiter und + Mitglied alles aus Gruppe 1–5 (Mitglied ohne „Projekte anlegen und + löschen“), Wandtafel nur „Wandtafel anzeigen“. `VALID_ROLES` ist weg + (`rolleExistiert()`). +- **Server:** `hatRecht(user, recht)`, `requireRecht()` ersetzt alle + `requireRole('admin')`; `/app-settings` prüft je Einstellung + (`APP_SETTING_RECHT`). Eine Rolle ohne „Anwendung nutzen“ + (`istNurWandtafel()`) ist das frühere Kiosk-Konto: `requireAuth` weist sie + ab, SQL schließt sie über `sqlOhneWandtafel()` aus Teams aus, keine + Fachrolle. **Speichern:** `fehlendesBearbeitungsrecht()` vergleicht in + `/save-week` und `/save-projects` je Projekt mit dem gespeicherten Stand + (Kommentar/`lph` → wb.kommentar, besprochen → wb.besprochen, `plan` → + proj.plan, sonst → proj.stamm, Anwesenheit → wb.anwesenheit, Sandbox → + wz.planung; `normWert()` behandelt leere Werte und Zahl/Text gleich). Wer + alle diese Rechte hat, überspringt den Vergleich. 403 trägt `recht`. +- **Schutz:** Administrator nicht änderbar/löschbar; nur ein Administrator + vergibt/entzieht Administrator; der letzte aktive Administrator bleibt; + „Rollen & Rechte“ kann man der eigenen Rolle nicht entziehen; Rolle mit + Konten nicht löschbar (409 nennt sie). +- **Routen:** `GET /rollen` (Katalog + Rollen mit Kontenzahl, requireAuth), + `POST /rollen {name, vonRolle}`, `PATCH /rollen/:key {name}`, + `PUT /rollen/:key/rechte {rechte, an}`, `DELETE /rollen/:key` — alle mit + Recht a.rollen. `/whoami` liefert `rechte` und `rolleName`. +- **Browser:** `hatRecht()`/`hatAdminRecht()` (`js/auth.js`); Admin-Abschnitte + je Recht (`ADMIN_SECTIONS[].recht`, `adminNavAbgleichen()`); Elemente mit + `.nur-recht[data-recht]` werden ausgeblendet; `showTab()` leitet ohne + „Anwendung nutzen“ auf die Wandtafel. Projektbereich: `app.isLocked()` + (Terminplan, proj.plan), `app.stammGesperrt()` (Einstellungen, Gruppe, + amtierende LPH, Honorar), `app.protokolleGesperrt()`. Wochenbericht: + Kommentar/Fortschritt, „Besprochen“, Anwesenheit je Recht. Ein 403 beim + Speichern zeigt einen Hinweis und lädt die Sitzung neu (`speicherFehler()`, + kein Wiederholen). Benutzerverwaltung: Rollenauswahl aus `GET /rollen`. +- Abschnitte weiter unten, die „admin-only“, `requireRole('admin')`, + `kiosk` oder „Projektleiter und Administratoren“ nennen, beschreiben den + Stand davor — gemeint ist jetzt das jeweilige Recht. + ### Ein Ort je Angabe Every piece of project data now has exactly ONE place where it is entered. @@ -1256,10 +1375,24 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`: written to the URL fragment with `history.replaceState` (`…#aufgaben`) and restored from it on load (`app.initPlanSections()`, called at the end of `renderPlanPage()`); a `hashchange` listener follows external changes. - - **Dashboard** — KPI tiles (`renderPlanKpis()`), the session comment and the - LPH progress list (`renderPlanInfo()`), "Nächste Termine" and "Team" - (`renderPlanDashboard()`), plus three compact cards that jump to the - Ressourcen/Dateien section or open the Verlauf modal. **The LPH progress + - **Dashboard** — Anordnung **„Lagebild“** (Eriks Wahl aus drei Entwürfen): + oben ein schmales **Lageband** (`#planKpiRow`, `renderPlanKpis()`: laufende + Leistungsphase mit Ring + offene Aufgaben, nächster Meilenstein, + Team-Auslastung; der frühere „Gesamtfortschritt“ der Aufgaben ist + entfallen), darunter zwei Spalten (`.plan-lage-raster`), die sich + unabhängig füllen: links Kommentar der Sitzung (mit Verfasser/Zeit, + `#planStatusAutor`) und **Termine und Aufgaben** (`renderPlanDashboard()`: + gegliedert Überfällig / Diese Woche / Später mit festen Anteilen + `PLAN_DASH_ANTEIL`, Rest als „… weitere in Aufgaben“; ein Klick öffnet + denselben Aufgaben-/Meilenstein-Dialog wie der Terminplan), rechts + Leistungsphasen, **Team** (eine Zeile je PERSON, Rollen als Chips — + vorher stand jemand mit drei Rollen dreimal da) und **Unterlagen** (die + früheren Kurzkarten Dateien/Auslastung als eine + Liste; ihre Inhalte füllen weiterhin dieselben Funktionen. Der Eintrag + „Verlauf“ samt `#planHistoryModal`, `renderPlanHistory()` und + `openHistoryModal()` ist entfallen — der Verlauf steht jetzt in der + Kachel „Kommentar der Sitzung“). Stile: + Block „Projekt-Dashboard Lagebild“ am Ende von `plan-styles.css`. **The LPH progress list shows only the BEAUFTRAGTEN Leistungsphasen** (`p.beauftragteLP`, set in "Einstellungen") — Erik reported it still listing phases that weren't marked as commissioned there. `renderPlanInfo()` previously @@ -1270,7 +1403,82 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`: Wochenbericht/Board card (`js/board/board.js`), just unconditional here rather than opt-in, since the Dashboard has no second, unfiltered rendering of this list to preserve. - - **Terminplan** — the Gantt with its toolbar and, below it, the resource + **Die Kachel „Kommentar der Sitzung“ zeigt den ZULETZT GESCHRIEBENEN + Kommentar** (Eriks Wahl „A“): Hat die angezeigte Sitzung keinen, den + jüngsten aus einer früheren, mit Hinweis „angezeigt wird der letzte aus + KW …“ (`#planStatusHinweis`). Darunter eine Zeitleiste der früheren + Kommentare (`#planStatusVerlauf`, `planKommentarDaten()`/ + `renderPlanKommentarVerlauf()` in `plan-info.js`): je Gewerk nur, wenn + sich der Text gegenüber der Sitzung davor geändert hat; gleicher Text in + mehreren Gewerken einer Sitzung (Altbestand aus der Umstellung auf + Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Drei sichtbar, Rest + über „Alle N anzeigen“. Keine neue Datenhaltung — jede Sitzung hält ihre + Kopie des Projekts. Stile: Block „Kommentar der Sitzung mit Verlauf“ am + Ende von `plan-styles.css`. + **Eigene Kachel „Protokolle“** (Eriks Wunsch; links unter „Termine und + Aufgaben“, `#planDashProtokollePanel`, vorher nur eine Zeile in + „Unterlagen“): das letzte Protokoll (Nr., Betreff, Datum, Ort, + Teilnehmer, offen/erledigt/Info), darunter seine OFFENEN Punkte + (höchstens 5, nach Termin, überfällige rot — zweiter Abruf + `GET /project-protokolle/:id`, Anzeige-Nummer über + `berechneProtokollNummern()`), dann bis zu drei frühere Protokolle. + Jeder Klick öffnet DIESES Protokoll (`dashOeffneProtokoll()`). Gerendert + von `renderProtokolleSummary()` in `plan-protokoll.js`; ohne + Projektnummer (Planung) ausgeblendet. Stile: Block „Dashboard-Kachel + Protokolle“ am Ende von `plan-styles.css`. + **Kachel „Team“ nach Gewerk gruppiert** (Eriks Wahl „A“, + `renderPlanDashboard()` in `plan-sections.js`): erst die Gewerke des + Projekts (`p.gewerke`, Reihenfolge `DEPARTMENTS`) — ein unbesetztes mit + Hinweis „Noch niemand aus diesem Gewerk zugeordnet“ —, dann Gewerke von + Beteiligten, die nicht zum Projekt gehören („kein Gewerk des + Projekts“), zuletzt „Ohne Gewerk“. Das Gewerk einer Person kommt aus + ihrem Konto (`personGewerk()`, `users.department`). + - **Terminplan aufgeräumt** (Eriks Wahl „A“ aus drei Entwürfen): + **Eine Leiste** statt zwei Zeilen mit neun Knöpfen — Maßstab + „Übersicht | Wochen | Tage“ (`tpMassstab()`, feste px/Tag statt −/+), + „Heute“ (`tpHeute()`), Zeitraumfeld (zeigt jetzt den tatsächlichen + Zeitraum), Filter „Alle | Meine | Überfällig“ (`tpFilter()`), Menü + **„Ansicht“** (Urlaub, Vorwoche, „Abhängigkeiten als Linien“ + `#planShowPfeile`, Sandbox-PDF; „Zeitraum anpassen“, „Nach + Abhängigkeiten anordnen“ und „Kritischer Pfad“ hat Erik daraus + gestrichen — der kritische Pfad ist samt Code entfernt + (`computeCriticalPath()`, `toggleCriticalPath()`, `.critical`); + `fitPlanGanttRange()` und `autoScheduleTodos()` bleiben, weil Vorlagen, + Honorar-Plan, Protokoll-Aufgaben bzw. das Ziehen von Balken sie nutzen) + und **„+ Neu“** (Aufgabe, Meilenstein, Vorlage, Terminplan aus Honorar, + Aufgaben aus Protokoll) als `
`; alle Knopf-ids + unverändert. **Gliederung nach Leistungsphasen** + (`plan-build-gantt.js`): Phasenbalken (`balkenPhase()`) werden + zuklappbare Gruppenköpfe (`.gantt-phase`, `tpPhaseUmschalten()`, + `app.phasenZu`), jede andere Aufgabe steht unter der Phase, in deren + Balken sie beginnt; außerhalb jedes Balkens unter „Weitere Aufgaben“. + Nur die ANZEIGE wird umsortiert — `plan.todos` übernimmt die + Reihenfolge erst beim Ziehen. Zugeklappte und weggefilterte Zeilen sind + `display:none`, bleiben aber im DOM (der Zählcheck in + `reorderPlanRows()`); Abhängigkeitslinien zu ihnen werden nicht + gezeichnet. Namensspalte: Vorgänger nicht mehr als Schildchen, sondern + im Tooltip (Warnzeichen „!“ bei Terminkonflikt), rechts die Kürzel der + Zuständigen. Die Projektzeile heißt „Projekt gesamt“. **Meilensteine ohne Aufgabenbezug** (`position` leer) stehen nicht mehr in der Projektzeile, sondern je in einer eigenen Zeile direkt darunter, nach Datum (Eriks Meldung: ohne Aufgaben lagen alle übereinander); der Umschalter im Panel heißt „Oben, eigene Zeile | Bei einer Aufgabe“. `reorderPlanRows()` zählt dafür alle Meilensteine und lässt einen Meilenstein vor der ersten Aufgabe ohne Position. Unter dem + Diagramm ein Hinweis auf offene Aufgaben ohne Termin (`#planOhneTermin` + → Bereich „Aufgaben“). **Phasenbalken farblich getrennt** (Eriks Wunsch): Token + `--gantt-phase` (Blauviolett, hell `#6a5fc7`, dunkel `#a59ef2` — bläulicher als das Lila der Meilensteine) am + Ende von `plan-styles.css`. Aufgaben bleiben im Akzentblau, erledigte + grün, Meilensteine lila. Stile: Block „Terminplan aufgeräumt“ am Ende von + `plan-styles.css`. + - **Terminplan** — **Spalte „Projekt“ in der Breite veränderbar wie in + Excel** (Eriks Wunsch): Griff am rechten Rand JEDER Namenszelle ziehen + (`ganttGriffeEinsetzen()` setzt ihn nach jedem `renderPlanGantt()` in + alle Zeilen, auch die aus `plan-build-gantt.js`; click/dblclick werden + dort abgefangen), Doppelklick passt an den längsten Eintrag an + (`ganttSpalteZiehen()`/`ganttSpalteAnpassen()` in `js/gantt.js`). Die + Abhängigkeitspfeile werden beim Ziehen je Bild neu gezeichnet + (`ganttPfeileNachziehen()` → `renderGanttDependencies()`), sonst blieben + sie an der alten Stelle stehen. Die Breite steht in der + CSS-Variable `--gantt-label-w` (alle Zeilen gehen ohne Neuaufbau mit) und + je Browser in `localStorage['projuebersicht-gantt-spalte']` (120–640px, + Standard 220px); Zoom und Mindestbreite rechnen mit `ganttSpaltenBreite()` + statt der früheren festen 220/250px. + The Gantt with its toolbar and, below it, the resource load (`#resourceLoadContainer`, formerly a modal). **Uses the exact same Wochenstunden calculation as the Auslastung tab/Wandtafel** (`computeResourceLoads()`, `js/plan/plan-gantt.js`, calling @@ -1301,6 +1509,36 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`: system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this view now also follows the app-wide dark-mode palette like everywhere else. + - **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“): + `#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am + Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der + Terminplan sichtbar bleibt. Beide gleich gebaut (`.pd`): Kopf mit Art, + Esc-Hinweis, × und Titelfeld; Abschnitte „Wann“, „Wer“/„Wo im + Terminplan“, „Stand“, „Hängt ab von“; feste Fußleiste mit „Löschen“ + links (nur beim Bearbeiten — beim Meilenstein stand es vorher auch beim + Anlegen). Die Meilenstein-Position ist ein Umschalter „In der + Projektzeile | Bei einer Aufgabe“ (`msLageSetzen()`/`msLageSync()`, + `plan-modals.js`) über dem bisherigen Feld `#milestoneEditPosition`, + das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert. + Stile: Block „Seitenpanel“ am Ende von `plan-styles.css`. + - **Leistungsphasen** (eigener Reiter zwischen Terminplan und Aufgaben, + Eriks Wahl „B“, `js/plan/plan-phasen.js`): oben ein Ablaufbalken aller + Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter + eine Kachel je Phase — beauftragte plus alle mit Phasenbalken + (`balkenPhase()`), sortiert läuft → geplant → fertig. Fortschritt aus dem + Wochenbericht (`fortschrittProzent()`), „läuft“ = `arbeitsPhase()`, + Zeitraum aus dem Balken, Aufwand = Summe der Aufgabenstunden der Phase, + Aufgaben = die, die im Balken beginnen (`aufgabenPhase()`, + dieselbe Regel wie die Gliederung im Terminplan). „Bearbeiten“ öffnet den + Balken im Phasen-Panel, ohne Balken „Zeitraum festlegen“ + (`addPlanPhase(lph)` wählt die Phase vor); „Aufgaben ansehen“ springt + gefiltert in die Aufgaben. „Terminplan aus Honorar“ sitzt jetzt hier + statt in der Aufgaben-Leiste. Stile: Block „Bereich Leistungsphasen“ am + Ende von `plan-styles.css`. + - **Aufgaben ohne Phasenbalken**: `renderPlanTasks()` lässt Aufgaben mit + `t.lph` weg (Indizes bleiben die aus `plan.todos`); jede Aufgabe trägt ihr + Phasen-Kennzeichen (`.plan-task-phase`), darüber ein Filter Alle / je + Phase / ohne Phase (`setzeAufgabenFilter()`, gilt je Projekt). - **Aufgaben** — the same todos/milestones as a list (`renderPlanTasks()`, `js/plan/plan-tasks.js`). No own data and no own dialogs: a row opens the very same `openTodoModal()`/`openMilestoneModal()` as the Gantt, and the @@ -1449,6 +1687,38 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`: - **Protokolle** — Besprechungsprotokolle (`js/plan/plan-protokoll.js`), see "### Protokolle" below for the data model and the "Fortschreiben" mechanism. + - **Einstellungen — Aufbau mit Unternavigation** (Eriks Wahl „C“): links + vier Abschnitte (`.plan-einst-nav`) mit Kurzfassung darunter + (`renderEinstUebersicht()`: „21-4001 · ELT · LPH 5“, „Beauftragt · 230.000 €“, + „4 von 10 beauftragt · läuft: LPH 5“, „4 Personen · HLS unbesetzt“), + rechts immer genau einer (`zeigeEinstTeil()`, `plan-settings.js`): + **Allgemein** (Nummer, Name, Gewerke als Umschalt-Knöpfe, Gruppe, + amtierende Leistungsphase — Eriks Wunsch, hierhin statt unter + „Leistungsphasen“ —, Schlagwörter), **Auftrag & Honorar** (Status, + Honorar, Projektgröße, Aufwand), **Leistungsphasen** (kräftig hervorgehoben: beauftragte Phase = getönte + Zeile + gefüllte Akzentkapsel „Beauftragt“, nicht beauftragte = blasse + Zeile + neutrale Kapsel, „läuft“ gefüllt; je Phase eine Zeile + „Beauftragt | Nicht beauftragt“ mit HOAI-Bezeichnung und „läuft“), + **Beteiligte** — als **Personenliste** (Eriks Wahl „B“ aus drei + Entwürfen, dann ausgearbeitet; `renderEinstTeam()`): oben eine + Besetzungsleiste je Rolle (wer die Hauptverantwortung trägt; fehlt sie, + orange gestrichelt), ein Suchfeld „Person hinzufügen“ (Vorschläge erst + aus den Gewerken des Projekts, dann andere; Enter nimmt den ersten; + hinzugefügt wird mit der ersten Fachrolle als Haupt, die Zeile kurz + hervorgehoben), darunter eine Zeile je beteiligter Person, nach Gewerk + gruppiert, mit drei Rollen-Kapseln (umrandet = aus, voll = Haupt, + gestrichelt = Unterstützung; Antippen schaltet über `planCycleAssign()` + weiter; Rollen, die das Konto nicht hat, durchgestrichen und gesperrt) + und „ד (`einstEntfernen()`, 8 s „Rückgängig“ über + `einstRueckgaengig()`). **Keine Auslastung in diesem Bereich** (Eriks + ausdrücklicher Wunsch). Angeboten wird jede Person der Sitzung mit + Fachrolle; wer zugeordnet ist, ohne die Rolle noch zu haben, bleibt + sichtbar, damit er sich entfernen lässt. Gesperrt: Hinweis, kein + Suchfeld, kein „ד, Kapseln zeigen den Stand. Alle + Feld-ids und Schreibwege sind unverändert; die Sandbox blendet weiter + über `.form-row` aus. `setPlanActiveLp()` rendert die Einstellungen mit + nach, damit „läuft“ und die Kurzfassung stimmen. Stile: Block + „Einstellungen mit Unternavigation“ am Ende von `plan-styles.css`. - **Einstellungen** — project number/name (display only — the page is addressed by them), Gruppe, amtierende Leistungsphase (both still rendered by `plan-info.js`), plus Honorar, the **beauftragte @@ -1467,8 +1737,9 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`: `renderPlanTasks()` and `renderPlanDashboard()`, so every existing mutation path (drag, modals, auto-schedule, templates, honorar plan) updates all three views without touching those modules. -- The four former modals (`#planInfoModal`, `#planResourceModal`, - `#planFilesModal`) are gone; only `#planHistoryModal` (Verlauf) remains. +- The former modals (`#planInfoModal`, `#planResourceModal`, + `#planFilesModal`, and since the comment history moved into the + Dashboard tile also `#planHistoryModal`) are gone. `openResourceModal()`/`openFilesPanel()` kept their names but now just switch sections. On a load error (missing params, unknown week/project, network) `app.hidePlanNav()` hides the section bar, since every section would be empty. @@ -1504,6 +1775,37 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`: `plan-gantt.js` both bail out unless `currentTab === 'plan'` — they now run while completely different tabs are on screen. +### Termin aus Mail (`js/plan/plan-mail-eintrag.js`) + +Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze +`.eml`-Datei) oder Strg+V (außerhalb von Eingabefeldern) auf die Bereiche +**Terminplan** oder **Aufgaben** eines Projekts; eine KI macht daraus +Einträge. **Nur Aufgaben, keine Meilensteine** — Eriks Vorgabe: Der +Terminplan ist nur die grafische Darstellung der Aufgabenliste. +- Beim Hineinziehen erscheint die Fläche `#mailAblage` (fest, `pointer-events: + none`, Zähler für dragenter/-leave). Nicht in gesperrten Sitzungen. Kein + Konflikt mit dem Balken-Ziehen (das läuft über Maus-Ereignisse). +- `POST /plan-eintrag-aus-mail {anbieter, text|eml, kontext}` (`proj.plan`): + `.eml` wird am Server zerlegt (`leseEml()`: Kopfzeilen, RFC 2047, + multipart, base64/quoted-printable, Zeichensatz), zitierte Vorgänger-Mails + abgeschnitten (`mailZitateAbschneiden()`), dann `mailEintraegeMitKI()` mit + `KI_MAIL_SCHEMA` (Titel, Start/Ende ISO, Zuständige aus der Liste der + Beteiligten, Aufwand, Zitat). Datum der Mail und heute gehen mit, damit + „nächsten Freitag“ stimmt. Ohne KI/bei Fehler: ein Eintrag mit dem Betreff + als Titel. **Speichert nichts.** `.msg` (Outlook) wird abgewiesen mit dem + Hinweis, Text zu markieren. +- Anbieter: zuletzt erfolgreich genutzter (`localStorage + projuebersicht-mail-ki`), sonst der erste mit Schlüssel. +- Jeder Vorschlag öffnet das normale Aufgaben-Panel vorausgefüllt + (`openTodoModal(null, prefill)`, neu: `title`, `aufwand`, `notiz`, + `mailHinweis`, `nachSchliessen`); bei mehreren erst eine Auswahl + (`#mailAuswahlModal`), dann nacheinander. Ohne Speichern entsteht nichts. +- **Notiz je Aufgabe** (`t.notiz`, `plan_todos.notiz`, Migration + `migrateAddPlanTodoNotiz()`, in `IMPORT_OPTIONAL_COLS`): Feld „Notiz“ im + Aufgaben-Panel für jede Aufgabe; aus einer Mail mit Absender, Datum, + Betreff, Bezugssatz und Mailtext. In der Aufgabenliste ein Symbol mit + Tooltip. Stile: Block „Termin aus Mail“ am Ende von `plan-styles.css`. + ### File uploads (`project_files` / review / comments) - Files are linked to a project via **`(department, project_nr)`**, NOT @@ -2175,6 +2477,75 @@ parallel kind of content. is never sent to the client — only used server-side to resolve the download route. +### Auslastung — nur aus Aufgaben (aktueller Stand, 10/2026) + +**Löst die drei folgenden Auslastungs-Abschnitte in der RECHNUNG ab** (die +Darstellung als Personenkarten gilt weiter). Eriks Vorgabe: „Wenn einem User +eine Aufgabe zugeteilt wird, dann hat diese Aufgabe 8 Stunden und dann ist +diese Person auch mit 8 Std. ausgelastet. Zwei Personen → jeder die Hälfte.“ +- Gezählt werden NUR offene Aufgaben mit Aufwand (`t.aufwand`) und + Zuständigen: offener Teil (Aufwand × (1 − Fortschritt)) zu gleichen Teilen + auf die Zuständigen und auf die Arbeitstage der Aufgabe + (`aufgabenVerlaufJePerson()`, ohne Termine die nächsten 4 Wochen). + Aufgaben ohne Aufwand zählen nicht; Phasenbalken nicht. +- **Entfallen:** das Phasenbudget aus Honorar × Phasenanteil ÷ Stundensatz + und die **Gewichtungstabelle** Leistungsphase × Rolle — firmenweit (Admin → + Auslastung) wie je Projekt („Gewichtung bearbeiten“, `#lpModal`). Gelöscht + in `load.js`: `zuordnungsVerlauf()`, `berechneAuslastung()`, + `rollenGewicht()`, `aufwandVorlage()`, `aufwandMatrixWert()`, + `aufgabenAbzug()`, `projektBasisHonorar()`, `phasenAnteilProzent()`, + `projektVerteilWochen()`, `phasenBalken()`, `verteileGleichmaessig()`, + `zuordnungsGewicht()` und die LP-Aufwand-Maske. Die Admin-Felder + Referenzhonorar und Verteilzeitraum sind ebenfalls weg. + `app_settings.aufwandVorlage`/`referenzHonorar`/`verteilzeitraumWochen` + und `projects.aufwandProLP` bleiben am Server bestehen, werden aber nicht + mehr gelesen. +- Dieselbe Rechnung nutzen Auslastungsseite (`renderLoad()`: Kopf „N von M + Projekten mit Aufwand in Aufgaben“, Lücken „keine Aufgabe mit Aufwand“ + usw.), Ressourcenansicht im Terminplan (`computeResourceLoads()`), + Wandtafel (`wandAuslastungJeGewerk()`) und der Aufwand im Bereich + Leistungsphasen (Summe der Aufgabenstunden der Phase). +- **Ressourcenansicht im Terminplan** (`computeResourceLoads()`): Zahl = + Aufgabenstunden DIESER Woche („h diese Woche“); darunter je Person die + offenen Aufgabenstunden dieses Projekts über alle Wochen + (`offenProjekt`) — sonst stand bei einer erst später terminierten + Aufgabe nur „0,0 h“. Zuständige einer Aufgabe, die nicht unter + „Beteiligte“ stehen, bekommen eine eigene Karte („nur Aufgabe“); + Phasenbalken stehen nicht mehr in der Aufgabenliste der Karte. +- **Terminplan als PDF auch für echte Projekte** (Eriks Wunsch): „Ansicht → + Als PDF exportieren“ (`#sandboxPdfBtn`) ist immer sichtbar. + `exportSandboxPlanPDF(id)` findet einen Planung-Plan oder das geöffnete + Projekt und ruft `exportTerminplanPDF(p, sandbox, week)` (`js/pdf.js`): + Kopf mit Nummer, Name und Sitzung, Datei `Terminplan__.pdf`. + Es kommen nur Aufgaben mit Start und Ende und `showInGantt !== false` ins + PDF, wie am Bildschirm. + **Gestaltung „A2 — volles Raster“** (Eriks Wahl aus einem Entwurfsvergleich, + `PDF_TP`, `pdfTpZeilen()`/`pdfTpSeite()`/`pdfTpLegende()`): links Vorgang, + Start und Ende, rechts Balken über Monaten (im Wechsel hinterlegt) und + Kalenderwochen, Gitter über das ganze Blatt. Jeder Phasenbalken wird eine + dunkelblaue Kopfzeile mit Sammelbalken und Fortschritt aus dem + Wochenbericht, darunter seine Aufgaben (`aufgabenPhase()`) und die + Meilensteine in seinem Zeitraum, der Rest unter „Weitere Aufgaben“. + Bewusst ohne Zuständige, Arbeitstage und Heute-Linie. Die Seite ist frei + positioniert (`absolutePosition`, Canvas für Flächen und Balken). Dabei gilt: + Oberer Seitenrand 0, Legende in der Fußzeile, kein Text über den rechten + Rand und der Kopf je Seite neu gebaut — sonst erzeugt pdfmake leere + Zusatzseiten oder lässt den Kopf ab Seite 2 weg. Zeilenhöhe 17–30 pt, bei + vielen Aufgaben weitere Seiten mit demselben Kopf. +- **Projektgröße entfernt** (Eriks Wunsch): kein Feld mehr in den + Projekteinstellungen (`setPlanGroesse()` weg) und im Assistenten. + Die Spalte `projects.groesse` ist auch aus der Datenbank entfernt + (`migrateDropProjectGroesse()`, `ALTER TABLE … DROP COLUMN`, idempotent, + ersetzt `migrateAddProjectGroesse()`; `normalizeGroesse()` entfällt). + Ältere Backups mit der Spalte bleiben importierbar — der Import überträgt + nur Spalten, die beide Schemas haben. +- **Honorar beim Anlegen freiwillig**: Der Assistent (`projekt-wizard.js`) + hat im Schritt „Auftrag“ nur noch ein optionales Honorarfeld (keine Wahl + „Grundlage“ mehr); ohne Honorar zeigt der Schritt „Terminplan“ „Kein + Terminplan ohne Honorar“, und „Prüfen“ nennt „Honorar: nicht angegeben“. + Die Beschreibung „Honorar ODER Projektgröße, nie beides“ weiter unten ist + damit überholt. + ### Auslastung — Wochenstunden statt Faktoren Die Auslastung wird in **Wochenstunden** gerechnet und erst zum Schluss auf die @@ -2255,6 +2626,74 @@ Bestandszeilen starten mit 0 (= keine Angabe), absichtlich ohne Vorgabewert wie 0,5: Eine erfundene Größe erzeugte Zahlen, die nach Messung aussehen. `normalizeGroesse()` (`server.js`) kappt auf 0…1. +### Auslastung — Wochenvorschau und Personenkarten (aktueller Stand) + +Eriks Wahl „Konzept 1“ (Rechnung) und „B“ (Darstellung). **Löst die beiden +folgenden Abschnitte teilweise ab:** Die Herleitung der Stunden je Phase +(Honorar × Anteil × Rest ÷ Stundensatz × Rolle × Kopf) gilt weiter, die +Verteilung über die Zeit und die Darstellung sind neu. + +- **Verteilung über die Phasenbalken** (`zuordnungsVerlauf()` in + `js/load/load.js`): Für jede beauftragte Phase AB der laufenden + (`arbeitsPhase()`; frühere gelten als erledigt) werden die Reststunden auf + die Arbeitstage IHRES Balkens im Terminplan verteilt + (`verteileAufBalken()`, ohne Wochenenden/Feiertage) und je Kalenderwoche + summiert — `VORSCHAU_WOCHEN` (12) ab dieser Woche. Vorher wurde die + laufende Phase bis zum Projektende gestreckt; eine Phase, die in drei + Wochen endet, erschien dadurch viel zu leicht. Liegt ein Balken in der + Vergangenheit, die Phase ist aber offen, fällt der Rest in die nächsten + `UEBERFAELLIG_WOCHEN` (4). Ohne jeden Phasenbalken gilt das frühere + Verfahren (`verteileGleichmaessig()`, `projektVerteilWochen()`); die + laufende Phase ohne Balken ebenso, spätere Phasen ohne Balken zählen + nicht (kein Zeitpunkt bekannt). +- **Welcher Balken zu welcher Phase gehört:** AUSSCHLIESSLICH + `plan_todos.leistungsphase` (Client `t.lph`, `balkenPhase()`; Migration + `migrateAddPlanTodoLeistungsphase()`, in `IMPORT_OPTIONAL_COLS`) — Eriks + Wunsch: nicht mehr über den Titel. Gesetzt von „+ Neu“ → + **„Leistungsphase“** (`addPlanPhase()`: Aufgaben-Panel im Phasen-Modus mit + Auswahl `#todoEditLph`, Titelvorschlag mit HOAI-Bezeichnung, Phasen mit + schon vorhandenem Balken gesperrt, „Wer“/Aufwand/„Speichern & neu“ + ausgeblendet; ein Phasenbalken öffnet beim Bearbeiten ebenfalls so), von + „Terminplan aus Honorar“ und vom Projekt-Assistenten. Ältere Balken, die + bis dahin nur am Titel erkannt wurden („LPH 5 (307 h)“, „LPH 5 …“, „W+M“), + hat `phasenbalkenAusTitel()` (server.js) EINMAL gekennzeichnet — Merker in + der neuen Tabelle `umstellungen` (`einmaligeUmstellung()`); nach einem + Backup-Import läuft sie erneut, weil ältere Backups das Feld nicht kennen. +- **Aufgaben zählen mit** (Eriks Meldung: „TODO zugewiesen, aber keine + Auslastung“). (A) Zuständige einer offenen Aufgabe zählen als Beteiligte + des Projekts — es erscheint auf ihrer Karte, auch ohne Eintrag unter + „Beteiligte“, und wer keine Fachrolle hat, bekommt trotzdem eine Karte. + (B) Optionales Feld **„Aufwand“ in Stunden** im Aufgaben-Panel + (`#todoEditAufwand`, `t.aufwand`, `plan_todos.aufwand_stunden`, in + `IMPORT_OPTIONAL_COLS`): der offene Teil (Aufwand × (1 − Fortschritt)) + verteilt sich auf die Zuständigen und die Arbeitstage der Aufgabe + (`aufgabenVerlaufJePerson()`; ohne Termine die nächsten 4 Wochen) — auch + in Projekten, die nicht bewertbar oder fertig gerechnet sind. Damit + nichts doppelt zählt, mindert er das Budget der Phase, in deren Balken die + Aufgabe beginnt, sonst der laufenden (`aufgabenAbzug()`). Phasenbalken + selbst zählen nicht als Aufgabe. Die Ressourcenansicht im Terminplan + (`computeResourceLoads()`) rechnet die Aufgabenstunden dieser Woche mit; + die Wandtafel (Summen je Gewerk) noch nicht. +- **Kapazität je Woche** (`wochenKapazitaet()`): Vertragsstunden je + Arbeitstag × verfügbare Tage — ohne Feiertage und ohne eingetragene + Abwesenheiten (`db.globalPeople[].absences`). Eine ganz abwesende Woche + erscheint schraffiert. +- **`berechneAuslastung()` liefert Woche 0** dieser Vorschau (plus + `verlauf`) — Wandtafel, Projekt-Dashboard und Ressourcenansicht zeigen + damit dieselbe Zahl wie „diese Woche“ auf der Auslastungsseite. + Abwesenheiten berücksichtigt nur die Auslastungsseite. +- **Darstellung** (`renderLoad()`): eine Karte je Person mit Projektanzahl + („3 Projekte, davon 2 in Hauptverantwortung“, Projekte „In Anbahnung“ + getrennt genannt), Wert dieser Woche, zwölf Wochensäulen mit + gestrichelter 100-%-Linie, Hinweis auf die Spitze und die zwei größten + Projekte. Sortiert nach Spitze; Personen ohne Projekt zuletzt. Ab dem + dritten Projekt aufklappbar (Knopf „+ N weitere“ / „weniger“, + `loadProjekteUmschalten()`, Zustand je Person in `loadProjekteOffen`, + übersteht das Neuzeichnen; Tooltip je Projekt mit Stunden). Die + Rollenzeilen, das Lastband und die Projektlegende der Seite sind + entfallen. Stile: Block „Auslastung als Personenkarten“ am Ende von + `styles.css`. + ### Auslastung — das Lastband `renderLoad()` (`js/load/load.js`) zeigt eine **Zeile je Person und Rolle**, @@ -2354,6 +2793,11 @@ were retired"). ### Planung — projektunabhängige Terminplan-Sandbox (tab `planung`, `js/planung/planung.js`) +**Der Menüpunkt heißt „Terminplan“** (Eriks Wunsch), der Abschnitt der +Seitenleiste darüber weiter „Planung“. Ebenso „Terminplan“: die Überschrift +der Seite und das Recht `wz.planung`. Intern bleibt alles `planung` (Tab, +Dateien, URL `/Planung`). + Ein zweiter, eigener Abschnitt der Seitenleiste (nicht Teil von "Navigation", zwischen Auslastung und Einstellungen) — eine **Sandbox**, in der sich Terminpläne **unabhängig von einem echten Kundenprojekt** anlegen @@ -2456,9 +2900,8 @@ plan-sections.js fängt zusätzlich einen direkten **Einstellungen** (`plan-settings.js`) die Felder Projektnummer, Auftragsstatus, Gewerke, Projektgröße — **Honorar, beauftragte Leistungsphasen und die Beteiligten-Matrix bleiben sichtbar**. Die -Kompakt-Karte "Verlauf" (`renderPlanHistory()`, plan-history.js) zeigt einen -eigenen Hinweistext statt der (für eine nie geklonte Sitzung bedeutungslosen) -Versions-Historie. Die Kopfzeile zeigt bewusst NICHT das rohe +Kachel „Kommentar der Sitzung“ zeigt dort keinen Verlauf (eine nie +geklonte Sitzung hat keine früheren Fassungen). Die Kopfzeile zeigt bewusst NICHT das rohe `week.label` ("__sandbox__", ein interner Marker) als Untertitel, sondern "Planung — unabhängig von einem laufenden Projekt". @@ -2530,9 +2973,11 @@ gleichzeitig bearbeiten. `pdfStatusText()`, `pdfNormalizeColor()`, `loadLogoPng()`) stayed. This was the one export that showed every project across every Gewerk in a single file — that capability is gone with it; the per-Gewerk Wochenbericht PDFs - are now the only filed record. Werkzeuge is down to two items: - `+ Neues Projekt`, `Wandtafel` (the former `Show Mode` entry — see - "Wandtafel" below). + are now the only filed record. **The whole „Werkzeuge“ group is gone + from the sidebar** (Eriks Wunsch): new projects are created via + „+ Projekt“ in the project list, and only the Wandtafel account needs the + Wandtafel (it lands there automatically via `showTab()`; anyone else can + still open `/Wandtafel` directly). ### Login-Seite (`#loginOverlay`) @@ -2584,6 +3029,13 @@ the system font offline). `z-index: 400`, above `.modal-bg` (300). `ohneRueckfrage` flag for that. The menu is appended to `` with `position: fixed` (the table's horizontally scrolling frame would clip it) and flips upward when there is no room below. + **Neue, geänderte und reaktivierte Konten landen sofort im + Teilnehmerkreis der aktuellen Sitzung** (`kontenInAktuelleSitzung()`, + `server.js`; Kürzel, Name und Fachrollen werden dort abgeglichen, + ältere Sitzungen bleiben unberührt) — beim Serverstart zusätzlich für + alle aktiven Konten. Vorher entstand `week_people` nur beim + Sitzungswechsel; auf einer frischen Datenbank, deren Sitzung vor den + Konten angelegt wurde, bot der Assistent „Neues Projekt" niemanden an. **Vor- und Nachname** are separate fields and columns (`users.first_name`/`last_name`, migration `migrateUsersVorNachname()`); `display_name` stays and is always written as "Vorname Nachname" by @@ -2657,8 +3109,58 @@ the system font offline). `z-index: 400`, above `.modal-bg` (300). and lets the admin take them over (see `/holidays/suggest` above). It flags when the current or next year has no day stored at all — otherwise missing holidays only surface via a wrongly computed schedule. - - **Aufgaben-Vorlagen** — create/delete steps per LPH - (`POST`/`DELETE /lph-templates`). + - **Aufgaben-Vorlagen** — **je Gewerk und Leistungsphase** (Eriks + Wunsch): `lph_templates.gewerk` (`VALID_DEPARTMENTS`, Migration + `migrateAddLphTemplateGewerk()`; `''` = Altbestand, gilt für alle + Gewerke). Oben Gewerk-Umschalter (mit Anzahl) und Leistungsphase (mit + Anzahl), darunter die Schritte mit direkt bearbeitbarer Bezeichnung, + Start und Dauer (`PATCH /lph-templates/:id`, gespeichert beim Verlassen + des Feldes), ↑/↓ (`POST /lph-templates/:id/verschieben {richtung}`, + innerhalb von Phase und Gewerk) und × (`DELETE`). `POST /lph-templates` + verlangt ein Gewerk. Alles `requireRole('admin')`. Im Dialog „+ Vorlage“ + (`plan-templates.js`) steht zusätzlich die Auswahl Gewerk — vorgewählt + das eigene, wenn das Projekt es hat, sonst das erste Gewerk des + Projekts; Gewerke außerhalb des Projekts tragen „(nicht im Projekt)“. + Der Admin-Verweis im Dialog führt nach Admin → Aufgaben-Vorlagen. + **Vorbelegt mit den HOAI-Grundleistungen** (`HOAI_GRUNDLEISTUNGEN`, + `hoaiVorlagenEinfuegen()` in server.js, einmalig über + `einmaligeUmstellung('hoai-aufgabenvorlagen')`, nur für Gewerke ohne + eigene Vorlagen): ARCH nach Anlage 10.1, ELT und HLS nach Anlage 15.1, + gekürzte Titel mit HOAI-Buchstabe, 177 Schritte. **Ohne Dauer** + (Eriks Vorgabe): `duration_days = 0` heißt „ohne Termin“ — „+ Vorlage“ + fügt solche Schritte ohne Start/Ende ein und verlangt dafür kein + Startdatum. Die Prozentanteile der Siemon-Tabelle sind NICHT hinterlegt + (lagen nicht vor). + **Anteil % je Schritt** (Eriks Wunsch, z. B. nach Siemon-Tabelle): + `lph_templates.anteil` (REAL, Migration in + `migrateAddLphTemplateGewerk()`, `templateAnteil()` prüft 0–100, Komma + erlaubt). Spalte „Anteil %“ in der Admin-Maske mit Summe je Phase. Beim + Einfügen (`templatePlan()` in `plan-templates.js`) bekommt jeder Schritt + mit Anteil Anteil ÷ Summe ALLER Schritte der Phase an der „Dauer der + Phase“ (Arbeitstage), nacheinander ab dem Startdatum; abgewählte Schritte + lassen ihre Lücke. Startdatum und Dauer der Phase kommen aus dem + Phasenbalken der Leistungsphase (`templatePhaseVorbelegen()`), sonst von + Hand. Ohne Anteil gelten Start/Dauer der Vorlage, ohne beides ohne + Termin. Rundung über die laufende Summe — der letzte Schritt endet genau + am Phasenende. **Siemon-Werte hinterlegt** (`SIEMON_ANTEILE` in server.js, + Quelle: Siemon-Tabellen zur HOAI 2013, ibr-online Okt. 2013, Gebäude und + Technische Ausrüstung): je Grundleistung der MITTELWERT der Spanne + „von–bis“; eingetragen einmalig von `siemonAnteileEintragen()` + (`einmaligeUmstellung('siemon-anteile')`) über den Buchstaben am Titelende, + nur in Schritte ohne Anteil. „in a) enth.“ steht in der neuen Spalte + `lph_templates.enthalten_in` ('a' bzw. 'a,b'): solche Schritte laufen beim + Einfügen zeitgleich mit a) (frühester Beginn bis spätestes Ende der + genannten Schritte, zweiter Durchgang in `templatePlan()`). In der + Admin-Spalte „Anteil %“ als „in a)“ angezeigt und so auch eingebbar + (`templateAnteilEingabe()`). + **Umgestellt auf das Siemon-Gutachten zur HOAI 2021** (Eriks Meldung + „die Werte sind nicht richtig“; Anlagenband zum Gutachten 2021-1, + Anlage 1.1 Gebäude, 1.7 Technische Ausrüstung): ein fester Wert je + Grundleistung, Summe je Phase = HOAI-Prozentsatz, kein „enthalten in“ + mehr. `SIEMON_ANTEILE` enthält jetzt diese Werte; `SIEMON_2013` nur noch + zum Erkennen unveränderter Altwerte. `siemon2021Umstellen()` + (`einmaligeUmstellung('siemon-anteile-2021')`) ersetzt nur Schritte, die + noch genau den 2013 eingetragenen Wert tragen. - **Betrieb & Sicherheit** — `maxBackups`, `sessionTage`, `loginMaxVersuche`, `loginSperreMinuten`. The session lifetime applies to *new* logins; existing sessions keep their stored `expires_at`. @@ -2733,10 +3235,27 @@ the system font offline). `z-index: 400`, above `.modal-bg` (300). saved through the ordinary `save()` path). - **Besprochen & weiter** — ticks off and jumps to the next open item, searching behind the current position first, then from the top. + Daneben **„Weiter →“** (`berichtWeiter()`/`berichtNaechstes()`, Eriks + Wunsch): springt nur zum nächsten Projekt der Liste (wie ↓), ohne Haken; + auch in gesperrten Sitzungen, am Listenende gesperrt. - **Keyboard**: ↓/↑ switch project, Ctrl+↵ saves the comment and moves on. Bound at document level but gated on `currentTab === 'wochenbericht'`. - - **Vorwochen-Zeile** — last session's comment above the input field, the - answer to "what did we agree last week?" without opening the Verlauf. + - **Kommentar-Formatierung** (`richtextToolbarHtml()`, `js/utils.js`, + shared with the Protokoll-Punkte): Fett/Kursiv/Farbe **toggle** — a + second click on a selection that fully carries it removes it again + (`richtextUmschalten()`/`richtextArtEntfernen()`, splitting a + surrounding element so text before/after keeps its format); another + colour recolours instead of nesting. "Standard" + (`richtextFormatLoeschen()`) strips all formatting from the selection, + or from the whole field without one. Enter in the comment starts the new + line with "- " (`richtextNeueZeileMitStrich()`, nbsp because Chrome + drops a trailing normal space); deletable like any text, Shift+Enter + stays the browser's plain line break. + - **Vorwochen-Zeile** — "Zuletzt KW …: " above the input field + (`renderBerichtZuletzt()`), the latest earlier comment of the chosen + Gewerk — WITH its formatting (bold/italic/colour/lines, sanitized HTML, + at most five lines), on Erik's request; the Verlauf entries keep it too. Replaced the scroll wheel `renderVorwocheRad()` (removed), see + "Verlauf der Vorwochen" below. - **Aufgabe aus Kommentar** — turns a decision into a real `plan_todo` (title suggested from the *unsaved* field content, since in a meeting you type the decision and create the task before saving). @@ -2766,7 +3285,12 @@ the system font offline). `z-index: 400`, above `.modal-bg` (300). Beteiligte-table on the right (`pdfWbLphMiniTable()`/ `pdfWbBeteiligteTable()`, falling back to all `LPHS` when none are beauftragt — same rule as `renderBoardDrawer()`), plus who's assigned but - marked absent (`abwesendeBeteiligte()`). The amtierende-LPH value is + marked absent (`abwesendeBeteiligte()`). Under the comment sits the + latest EARLIER comment of the same Gewerk as a grey box + (`pdfWbVorigerKommentar()`, Erik's request) — same source as the + screen's "Zuletzt KW …" (`berichtVerlaufDaten()`), titled "Vorwoche KW …" + when it is from the directly preceding session, else "Zuletzt KW …", + with author and formatting. The amtierende-LPH value is computed once (`pdfWbAktuelleLphInfo()`, same `lpActiveList()` the Auslastung uses) and read by both the overview row and, indirectly, the Akte — so the two pages can never show conflicting figures for the same @@ -2780,9 +3304,13 @@ the system font offline). `z-index: 400`, above `.modal-bg` (300). reads as tables throughout rather than mixing ruled and unruled blocks. - **Sprung-Feld** — filters the list only while something is typed; the agenda must stay complete. - - **Abwesenheits-Hinweis** — marks projects whose assignees are absent, read - from `week.attendance`. Stays silent while nobody is marked present at - all, since "everyone is missing" would be a false alarm. + - **Abwesenheits-Hinweis** („Heute abwesend: Name (Urlaub bis 28.10.) — + eventuell vertagen“, auch im PDF) — liest seit Eriks Wunsch die + **eingetragenen Abwesenheiten** der zugeordneten Personen + (`db.globalPeople[].absences`, Benutzerverwaltung) für den heutigen Tag, + NICHT mehr die Anwesenheitsliste der Sitzung (`abwesendeBeteiligte()`). + Die Anwesenheitsabfrage am Fuß bleibt unverändert bestehen (Protokoll, + PDF), sie ist nur nicht mehr Quelle dieses Hinweises. - Two bugs worth remembering, both found by measuring, not by looking: - The dense-card rules were scoped to `.report-card`, a container this rewrite removed — the card silently fell back to the Board layout @@ -2842,9 +3370,32 @@ unfiltered "Projekte" board (`renderBoard()`) — untouched by this feature. with a message if called with no Gewerk chosen (only reachable for an account with no maintained `department`, since everyone else gets one from `wochenberichtGewerkVoreinstellen()` — the guard costs nothing either way). -- **Attendance is unaffected** — `w.attendance`/`w.people` are session-wide, - not per-Gewerk, so `pdfWbAnwesenheitBlock()`/`renderAttendance()` keep - showing everyone regardless of the chosen Gewerk. +- **Kommentar und Besprochen-Haken sind je Gewerk getrennt** (Erik: "Die + Kommentare und Anwesenheiten von ELT brauchen nicht in HLS oder Arch zu + sein" / "auch wenn das Projekt in ELT besprochen ist, heißt das nicht, dass + HLS das Projekt auch besprochen hat"). Table **`project_kommentare`** + (week_label, project_id, gewerk, status, status_author, status_at, + besprochen), client shape `p.kommentare = {ELT: {status, statusAuthor, + statusAt, besprochen}, …}`. `projects.status/status_author/status_at/ + besprochen` are legacy: always written empty, only read by + `migrateKommentareJeGewerk()` (startup + after `/import`; author's + `users.department` if the project has that Gewerk, else its only Gewerk, + else all its Gewerke). **All reads/writes go through `kommentarVon(p, g)`/ + `kommentarSetzen(p, g, patch)`** (`js/utils.js`); `kommentarGewerk()` is + the context Gewerk (`wochenberichtGewerk` while on the Wochenbericht tab, + else null). With `g` null, `kommentarVon()` returns the combined display + (each Gewerk's comment under a bold "ELT:" label, besprochen only if all + Gewerke ticked it) — used by the Projekte list, Wandtafel, Projektbereich. + `classifyProjectChange(p, prevWeek, gewerk)` and `projectStatusHistory(uid, + g)` compare/list per Gewerk. Rollover and Papierkorb-restore reset + `p.kommentare = {}`. `project_kommentare` is in `IMPORT_TABLES` but also in + `IMPORT_OPTIONAL_TABLES`, so older backups without it still import. +- **Attendance is filtered per Gewerk**: `w.attendance` is still one map per + session, but `renderAttendance()`, `setAllAttendance()`, + `abwesendeBeteiligte()`, the Protokoll text and `pdfWbAnwesenheitBlock()` + only show/affect `personenDesGewerks(w, g)` — people whose account + department (`personGewerk()`, via `db.globalPeople` by id or full name) is + that Gewerk. People with no known Gewerk stay visible everywhere. - **Backfill for pre-existing projects** (`migrateBackfillProjectGewerkeAusTags()`, `server.js`, runs at every startup): every project that predates the Gewerke step — i.e. has zero @@ -2864,8 +3415,76 @@ unfiltered "Projekte" board (`renderBoard()`) — untouched by this feature. `toggleBeauftragteLp()` (`js/plan/plan-honorar.js`). Before this, a project's Gewerke could only ever be set once, during creation. +### Kommentar der Vorwoche wird übernommen (aktueller Stand) + +Eriks Wunsch: Jede neue Sitzung beginnt mit dem Kommentar der Vorwoche (je +Gewerk), der in der Besprechung angepasst wird. `ensureCurrentWeekRollover()` +kopiert deshalb `p.kommentare` (Text, Verfasser, Zeit) und setzt nur +`besprochen` zurück — die Beschreibungen „cleared status“/„kommentare = {}“ +weiter oben sind überholt. Unveränderte Übernahmen gelten in +`classifyProjectChange()` als „unverändert“ (gleicher Text, gleiches +`statusAt`). Einmalig hat `kommentareInAktuelleSitzungUebernehmen()` +(`einmaligeUmstellung('kommentare-uebernehmen')`) in der damals jüngsten +Sitzung jeden leeren Kommentar je Gewerk mit dem zuletzt geschriebenen +gefüllt (Haken unverändert). Der Papierkorb leert beim Wiederherstellen +weiterhin. Übernommen wird das gespeicherte HTML, also mit Farben und +Formatierung. Die Zeile „Zuletzt KW …“ über dem Kommentarfeld +(`renderBerichtZuletzt()`) wird deshalb nicht mehr angezeigt; der „Verlauf +der Vorwochen“ darunter bleibt. +„Verwerfen“ (und Esc) leert bei einem unverändert übernommenen Kommentar +das ganze Feld (`boardCommentUebernommen`, `kommentarAusVorwocheUebernommen()` +in `board.js`: gleicher Text und `statusAt` wie der jüngste frühere +Kommentar des Gewerks); sonst stellt es wie bisher den gespeicherten Stand her. + ### Wochenbericht — Verlauf der Vorwochen +**Aktueller Stand (Eriks Wahl „B“):** Im Wochenbericht steht der Verlauf +nicht mehr als Kachel-Streifen, sondern wie im Projekt-Dashboard als +senkrechte Zeitleiste — ein eigener Abschnitt „Verlauf der Vorwochen“ über +die volle Breite unter der Bearbeitungsmaske (`renderBerichtVerlauf()`, +`berichtVerlaufDaten()` in `js/wochenbericht/wochenbericht.js`). Ein Eintrag +nur, wenn sich der Kommentar DES GEWÄHLTEN GEWERKS gegenüber der Sitzung davor +geändert hat; Sitzungen dazwischen als grauer Vermerk („KW 40 bis KW 43 ohne +neuen Kommentar“); geänderte Leistungsphasen seit dem vorigen Eintrag als +Chips („LPH 5: 60 → 75 %“); Verfasser und Datum; „übernehmen“ +(`berichtVerlaufUebernehmen()` → `takeCommentFromHistory()`); drei +sichtbar, Rest über „Alle N anzeigen“ (`berichtVerlaufUmschalten()` zeichnet +nur den Abschnitt neu, ein angefangener Kommentar bleibt). Die dichte Karte +bekommt dafür `opts.ohneVerlauf` (`renderBoardDrawer()`), damit der +Kachel-Streifen dort nicht zusätzlich erscheint. Projektliste und +Projektbereich nutzen weiter `renderProjektVerlauf()` wie unten beschrieben. +Stile: Block „Verlauf der Vorwochen im Wochenbericht“ (`.bv-*`, +`.bericht-zuletzt`) in `styles.css`. + +**Absätze, Formatierung, Vorsatz (Eriks Nachtrag):** +- **Bestandskommentare einmalig umgestellt** + (`einmaligeUmstellung('kommentar-spiegelstriche')`, + `kommentarSpiegelstricheUmbrechen()` in `server.js`): Jeder Spiegelstrich + „ - “ in `project_kommentare.status` beginnt eine eigene Zeile (`
`), + sofern er nicht schon am Zeilenanfang oder direkt hinter einem Vorsatz wie + „[ELT]“ steht; Bindestriche in Wörtern bleiben. Repariert dabei auch als + Text gespeicherte „&nbsp;“ (eine ältere Fassung des Enter-Strichs). + Rein textuell, ohne DOM. **Nur einmal** — neue Kommentare werden NICHT an + jedem „-“ umbrochen; dort zählen allein die beim Schreiben gesetzten + Absätze. +- **Farben aus ``** bleiben jetzt erhalten + (`sanitizeStatusHtml()` erlaubt das Attribut an `FONT`, geprüft durch + `fontFarbeErlaubt()`: nur Hex, keine Grau-/Schwarz-/Weißtöne wie das + häufige eingefügte `#1a1a1a`). Vorher fiel es still weg. Die Werkzeugleiste + schreibt ohnehin ``. +- **Gewerke-Vorsatz „[ELT] “/„[ELT+HLS] “ aus den Daten entfernt** + (`kommentareVorsatzUmstellen()`/`kommentarVorsatzEntfernen()` in + `server.js`, bei jedem Start und nach `/import`, idempotent). Er stammt von + `migrateRemoveDepartmentPartition()` und gehört nicht zum Kommentar. Das + Gewerk erscheint stattdessen als Systemtext: in den Verlauf-Kacheln über + `kommentarAnzeigeHtml()` (`js/utils.js`, Beschriftung `.kommentar-gewerk`, + nur bei mehreren Gewerken), im Wochenbericht-Verlauf im Kopf, im + Projekt-Dashboard in der Meta-Zeile. +- **„übernehmen“ lässt den Gewerke-Vorsatz weg** + (`kommentarVorsatzEntfernen()` in `board.js`, in + `takeCommentFromHistory()`): „[ELT] “, „[ELT+HLS] “ am Anfang entfällt, + die Formatierung bleibt. + - The Wochenbericht is the **agenda of a weekly team meeting in which every project is looked at**, changed or not. Completeness beats filtering here: proposals that hide unchanged projects or reorder the list by kind of change @@ -2898,6 +3517,9 @@ unfiltered "Projekte" board (`renderBoard()`) — untouched by this feature. (latest date = project end, plus the next milestone) and the people assigned **in that week** — read from that session's `week.people`, not today's, so an old tile never shows people who weren't there yet. +- The tile shows the comment WITH its formatting (colours, bold, Absätze; + `sanitizeStatusHtml()`, cut off after six lines) — it used to be flattened + to plain text via `statusPlainText()`, which Erik reported as broken. - Values that differ from the tile *before* it are highlighted: the tiles sit side by side, but with ten Leistungsphasen the one changed number is otherwise impossible to spot. @@ -2907,6 +3529,33 @@ unfiltered "Projekte" board (`renderBoard()`) — untouched by this feature. ### Dashboard ("Meine Aufgaben", now the default landing tab) +**Aktueller Aufbau (Entwurf „B — Monatsblatt“, Eriks Wahl aus drei Varianten):** +Kopf und Band wie unten beschrieben; darunter **links „Meine Projekte“**, je +Projekt ein Block (`renderDashProjekte()`): eigene Rollen als Chips (gestrichelt += Unterstützung, „nur Aufgabe“ = nicht zugeordnet, aber mit eigener Aufgabe), +amtierende LPH mit Fortschritt, darunter bis zu `DASH_TERMINE_JE_PROJEKT` (6) +offene Termine, Überfälliges zuerst, der Rest hinter „N weitere Termine im +Projekt“. Eigene Aufgaben tragen ein „A“ in Akzentfarbe, fremde die +Zuständigen, Meilensteine eine Raute (`--meilenstein`, eigenes Token). **Jeder +Block ist einklappbar** (`dashProjUmschalten()`, „Alle einklappen/aufklappen“ +in der Abschnittsüberschrift); eingeklappt zeigt der Kopf „N überfällig / N +offen“. Der Zustand liegt je `projectUid` in `localStorage` +(`projuebersicht-dash-zu`) — reiner UI-Zustand. **Rechts ein Monatskalender** +(`renderDashKalender()`, KW-Spalte, Feiertage aus `HOLIDAYS`, Punkte für +eigene Aufgabe / Projektaufgabe / Meilenstein / überfällig / Abwesenheit, Klick +auf einen Tag listet ihn), darunter Freigaben und Abwesenheiten. +**Projekte und Kalender rechnen client-seitig aus `currentWeek()`** +(`dashDaten()`), nicht aus `GET /my-dashboard`: Der Kalender soll beliebig weit +blättern, der Server liefert Projekttermine nur 21 Tage voraus. Kopf und Band +behalten die Server-Zahlen. Abwesenheiten im Kalender: das eigene Konto plus +alle, die einem der eigenen Projekte zugeordnet sind (`db.globalPeople`). +Solange `/load-all` noch fehlt, steht „Projekte werden geladen…“; `renderAll()` +zeichnet das Dashboard danach neu. Die früheren Listen „Meine Aufgaben“ und +„Termine meiner Projekte“ (`renderDashboardAufgabe()`, +`renderDashboardFristen()`, `renderFristenZeile()`) sind entfallen — ihr Inhalt +steckt in den Projektblöcken. Der Text darunter beschreibt den Stand davor, +soweit er Kopf und Band betrifft, gilt er weiter. + **Aufbau: Kopf — Band — zwei Zonen.** Die Anordnung folgt der Frage, die beim Blick aufs Dashboard zuerst ansteht: „Was muss ich heute anpacken?" @@ -3096,6 +3745,41 @@ across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr." back — meant for a staff member previewing the tab via their own, non-kiosk account, not for the kiosk account itself, which has no reason to ever leave it (and would just hit 403s on whatever tab it landed on). +- **AKTUELLER STAND: „Wandtafel in Glas“ (Eriks Wahl, löst die unten + beschriebene Panel-Reihe ab).** Gleicher Hintergrund wie die Glas-App + (Lichtflächen in den Logo-Blautönen + `logo.svg` als Wasserzeichen, über + `body.wandtafel-aktiv .container` — unabhängig vom Glas-Schalter), Felder + halbdeckend OHNE `backdrop-filter` (Kiosk-Rechner), Schrift **Outfit** + (Fredoka/Nunito Sans sind entfallen, auch in den Übersichten). Alle Maße über + `--u` = `min(1cqw, 1.7778cqh)` (`#wandContent` ist Container mit + `container-type: size`; `--u` steht eine Ebene tiefer, weil + Container-Einheiten nie das Element selbst meinen): 1 % der Breite, aber + nie mehr, als die Höhe bei 16:9 hergäbe — auf flacheren Fenstern schrumpft + die Tafel gleichmäßig, statt unten abgeschnitten zu werden (so war der + Bereich Gewerke abgeschnitten). `.wand-ambient.wt` ist genau einen + Bildschirm hoch. Aufbau (`wt*`-Funktionen in `js/wand/wand.js`, Stile im + Block „WANDTAFEL IN GLAS“ am Ende von `styles.css`, Tokens `--wt-*`): + Kopf (`wtKopfHtml()`: Logo, Sitzung, heute Abwesende → Übersicht, große + Uhr `#wtUhr`, sekündlich über `wandCountdownTick()`), vier Kennzahlen + (`wtKpiHtml()`, Ringe für Auslastung/Besprochen), links **Gewerke** + (`wtGewerkeHtml()`: je Gewerk Auslastung + Besprochen-Kästchen, jede + Teilzeile → bisherige Übersicht), rechts **Die nächsten Tage** + (`wtTageDaten()`/`wtTageHtml()`: fünf Arbeitstage ab heute über + `isWorkday()`, **nur Meilensteine** aller Projekte der Sitzung — Eriks + Wunsch; Aufgaben/Überfälliges stehen in den Kennzahlen, Abwesende im Kopf. + Ein Termin an einem Wochenende/Feiertag zwischen den Spalten landet beim + Arbeitstag davor mit Vermerk „am Sa 24.10.“ — vorher fiel er ganz heraus; + `wtSpaltenEinpassen()` blendet nach dem Zeichnen aus, was nicht in die + Spaltenhöhe passt, und zählt es zu „+ n weitere“), Fuß „Wischen für alle + Projekte“. Eine Null-Auslastung ist grau, nie grün (`wtAmpel()`). + **Der Rücksprung-Zähler ruht im Grundzustand** (`wandImGrundzustand()`: + ganz oben gescrollt und keine Übersicht offen — Eriks Wunsch): dann ist + `#wandIdleCountdown` leer und `wandCountdownTick()` hält die volle Minute + vor; erst Scrollen oder eine geöffnete Übersicht lässt ihn ab 60 s laufen. + `wandDashKopfHtml()`/`wandKpiReiheHtml()`/`wandPanel()`/ + `wandAuslastungInhalt()`/`wandBesprochenInhalt()`/`wandAktuellInhalt()`/ + `wandAktuelleEreignisse()` sind entfernt; Rechenfunktionen und Übersichten + bleiben. Die folgenden Absätze beschreiben frühere Stände. - **Redesigned again, on-screen label now "Dashboard".** After the two-column layout below had been live for a while, Erik asked for a round of completely fresh full-board concepts (not incremental tweaks to the @@ -3465,6 +4149,181 @@ across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr." no special-casing needed. A redirect `GET /Test.html`-style old link isn't needed here since nothing ever pointed at this tab before it existed. +## Gleichzeitiges Arbeiten (Zusammenführen statt Verwerfen, `js/store.js`) + +Eine Sitzung wird weiterhin als Ganzes über `POST /save-week` mit +Revisionsstand gespeichert, und der Server antwortet weiterhin mit 409, wenn +jemand anderes sie inzwischen gespeichert hat. **Geändert hat sich die +Reaktion im Browser:** Früher verwarf `handleWeekConflict()` die eigenen +Änderungen mit einem `alert()` — bei zwei Personen in derselben (laufenden) +Sitzung ständig, auch an verschiedenen Projekten (Eriks Meldung: „immer +Fehlermeldungen, sobald mehr als eine Person in der App arbeitet“). + +- `wochenBasis` (Map label → Kopie) hält je Sitzung den zuletzt bekannten + Server-Stand: gesetzt in `load()`/`reloadWeekFromServer()` und nach jedem + erfolgreichen Speichern auf genau den gesendeten Stand (`gesendet`). +- Bei 409 holt `zusammenfuehrenUndSpeichern()` den frischen Stand und + `zusammenfuehren()` führt drei Fassungen zusammen + (`dreiwegeZusammenfuehren(basis, eigen, fremd)`): Objekte je Schlüssel, + Listen von Objekten mit `id` (Projekte, Aufgaben, Meilensteine, Personen) + je id — damit auch Kommentare je Gewerk und Anwesenheit je Person + getrennt. Nur echte Doppeländerungen desselben Felds sind Konflikte; dort + gilt die eigene Fassung, `zeigeHinweis()` nennt das Projekt (8 s, nicht + blockierend). Danach wird erneut gespeichert; nach drei Überholungen in + Folge oder bei einer gelöschten Sitzung greift der alte Weg. +- `inPlaceUebernehmen()` schreibt das Ergebnis IN die bestehenden Objekte + (gleiche id → gleiches Objekt), weil z. B. `window.PlanApp` eine Referenz + auf Woche und Projekt hält; ein Ersetzen hätte dessen weitere Änderungen + ins Leere laufen lassen. +- **Live-Abgleich:** `wochenLiveAbgleich()` fragt alle 20 s + `GET /week-revisionen` (nur `label → updated_at`) und übernimmt fremde + Änderungen über denselben Weg — nur wenn hier nichts ungespeichert ist, + kein Speichern läuft und niemand tippt oder einen Dialog offen hat + (`wirdGeradeBearbeitet()`), sonst beim nächsten Durchlauf. +- Getestet (zwei Konten): verschiedene Projekte → beide gespeichert; + dasselbe Kommentarfeld → eigene Fassung + Hinweis; fremde Änderung ohne + eigene → nach ≤ 20 s sichtbar; in keinem Fall ein Dialog. + +### Live-Ereignisse und Speichern einzelner Projekte (aktueller Stand) + +Eriks Wunsch: Änderungen sofort bei allen, und nur den geänderten Teil +übertragen. Ergänzt (und ersetzt teilweise) die Beschreibung oben: +- **Speichern nur des Geänderten:** `syncAllToServer()` vergleicht jede + geänderte Sitzung mit ihrem zuletzt bekannten Server-Stand + (`geaenderteProjekte()`). Haben sich NUR Projektinhalte geändert, gehen nur + diese Projekte an **`POST /save-projects {label, projects:[{project, + baseUpdatedAt}]}`** (`sendProjectsToServer()`, `js/server.js`; gemessen + ~1,9 KB statt ~22 KB). Server: Konfliktprüfung JE PROJEKT über + `projects.updated_at` (409 `{conflict, projekte:[ids]}` → dieselbe + Dreiwege-Zusammenführung wie oben), dann `deleteProjectData()` + + `serializeProject()` (aus `serializeWeek()` herausgelöst) in einer + Transaktion, `weeks.updated_at` wird mitgezogen. Sitzungsebene + (Teilnehmer, Anwesenheit, Sperre), Anlegen/Löschen/Umsortieren von + Projekten → weiter `/save-week`. Nach einem `/save-week` steht am Server + jedes Projekt auf der Wochen-Revision; der Client zieht `p.updatedAt` + deshalb für alle Projekte nach. +- **`GET /ereignisse`** (Server-Sent Events, `requireAuthOderKiosk`, ohne + Abhängigkeit; `X-Accel-Buffering: no` für einen Proxy, Herzschlag alle + 25 s): `ereignisSenden()` meldet nach jedem Speichern allen ANDEREN + Browsern (`X-Client-Id`/`?client=` = `CLIENT_ID` je Tab) + `{typ:'projekte', label, updatedAt, projekte}` (der neue Stand der + Projekte), `{typ:'woche', label, geloescht?}` (`/save-week`, Entsperren, + Löschen, Papierkorb) oder `{typ:'neu'}` (Sitzungswechsel, Backup-Import). + Abgemeldete/abgelaufene Sitzungen werden dabei getrennt. +- **Browser** (`liveVerbinden()` nach `load()`, `liveProjekte()`, + `liveWocheNachladen()` über **`GET /week?label=`** = `loadWeeks(label)`, + `liveAllesNeu()`): Projekte ohne eigene ungespeicherte Änderung werden + übernommen, sonst dreiwege-zusammengeführt (Hinweis bei echtem Konflikt). + Neu gezeichnet wird sofort oder, solange jemand tippt/ein Dialog offen ist, + danach (`ansichtNachBearbeitung()`). `wochenLiveAbgleich()` ist nur noch + Rückfall (60 s und nach jedem Wiederverbinden). +- **`gleich()` vergleicht reihenfolgeunabhängig** (`stabilerText()`): Mit + `JSON.stringify` hielte es nach `inPlaceUebernehmen()` Gleiches für + verschieden, und zwei Browser schickten sich endlos dieselben Projekte zu. +- Getestet mit zwei Browsern: Änderung nach ~0,6 s beim anderen (davon 0,5 s + Entprellung), sichtbar im offenen Wochenbericht; verschiedene Projekte + gleichzeitig → beide gespeichert; gleiches Feld → Hinweis; Anwesenheit über + `/save-week` → ~0,6 s; Sandbox-Plan einzeln; in Ruhe keine Speicherungen. + +## Benachrichtigungen (Glocke, `js/benachrichtigungen.js`) + +Eriks Wunsch: Wer eine neue Aufgabe zugewiesen bekommt, sieht das in der +Übersicht. **Entstehen** server-seitig in `POST /save-week`: Vor dem Speichern +merkt sich die Route alle `plan_todo_assignees` der Woche, danach legt +`neueZuweisungenMelden()` für jede NEU hinzugekommene Kombination (Aufgabe × +Person) einer offenen Aufgabe einen Eintrag in **`benachrichtigungen`** an — +nur für aktive Nicht-Kiosk-Konten mit passender `person_id`, nie für den, der +speichert (Selbstzuweisung), nicht doppelt, solange eine ungelesene zur selben +Aufgabe existiert, und nicht für eine Woche, die mit diesem Speichern erst +entsteht. Fehler dort werden nur geloggt, das Speichern scheitert nie daran. +Tabelle mit FK → `users ON DELETE CASCADE`, nicht in `IMPORT_TABLES`; +`purgeBenachrichtigungen()` (Start + stündlich) entfernt gelesene nach 60, +alle nach 180 Tagen. **Routen:** `GET /benachrichtigungen` (30 jüngste + +`ungelesen`), `POST /benachrichtigungen/gelesen {ids}|{alle:true}` — beide +`requireAuth`, immer nur das eigene Konto. +**Löschen** (Eriks Wunsch): `POST /benachrichtigungen/loeschen {ids}|{alle:true}` +(ebenfalls nur eigenes Konto, endgültig). In der Glocke trägt jede Zeile +(`.glocke-zeile` = Öffnen-Knopf + `.glocke-loeschen` „ד) einen eigenen +Löschen-Knopf, der Kopf „Alle gelesen“/„Alle löschen“ +(`loescheBenachrichtigungen()`). +**Anzeige:** Glocke im Menüband (`#glockeBtn`, rote Zahl, Liste +`#glockeMenue`) und auf „Meine Aufgaben“ der Block „Neu zugewiesen“ +(`#dashNeu`, nur bei Ungelesenem). Abgefragt bei jedem `renderAll()` und +minütlich. Ein Klick markiert als gelesen und springt in die Aufgaben des +Projekts — gesucht in der AKTUELLEN Sitzung über die Projektnummer (die +Projekt-ID wechselt je Sitzung), Planung-Terminpläne über ihre ID. + +## Glas-Darstellung (`public/glas.css`) + +Eriks Wahl aus dem Gestaltungsvorschlag „Projektübersicht in Glas“ (Annäherung +an Apple Liquid Glass). **Eine eigene Schicht, als letztes Stylesheet geladen, +die nur mit der Klasse `html.glas` greift** — ohne sie sieht die App aus wie +zuvor. Ein Inline-Skript im `` von `App.html` setzt die Klasse vor dem +ersten Zeichnen, außer `localStorage['projuebersicht-glas'] === 'aus'`; +abschaltbar je Browser unter Einstellungen → Mein Konto („Darstellung“, +`setGlasDarstellung()` in `js/settings/settings.js`), gedacht für langsame +Rechner. + +- **Tokens statt Einzelregeln:** `--surface`/`--surface-alt`/`--surface-sunk`/ + `--line*` werden durchscheinend, `--accent` wird das Logo-Dunkelblau + (`#0f5a85`, dunkel `#8cc9e6`), Rundungen größer (`--r-lg` 18px, `--r-xl` + 22px). Damit wirken die ~70 Stellen mit `var(--surface)` ohne Einzelanpassung. + **Folge:** `var(--surface)` ist unter Glas KEINE deckende Farbe mehr — Text + auf Akzentfläche braucht eine feste Farbe (siehe `.dash-kal-tag.ist-gewaehlt`, + `.pt-badge-person` in glas.css); wer neu `color: var(--surface)` schreibt, + baut denselben Fehler. +- **Echtes Glas (`backdrop-filter`) nur auf den großen Flächen** — eine Liste + von rund 30 Selektoren in glas.css (Menüband, Seitenleiste, Karten, Dialoge, + Projektbereichs-Panels). Neue Karten-Container dort ergänzen. Dialoge, + Menüs und Hinweise sind fast deckend (`--glas-dicht`). +- **Hintergrund:** Lichtflächen in den Logo-Blautönen und `public/logo.svg` + (aus `logo.png` vektorisiert, scharf in jeder Größe) als fixiertes + Wasserzeichen über `body::before`. Login-Seite und Kopfzeile nutzen + ebenfalls `logo.svg`; die Login-Seite hat denselben, aber deckenden + Hintergrund. +- **Seitenleiste** schwebt auf Desktop-Breite mit 12px Abstand und runden + Ecken; aktiver Eintrag als helle Kapsel. +- **GANTT-Namensspalte deckend:** Die feste (sticky) Namensspalte hatte + unter Glas das halbdurchsichtige `--glas-deckend`; beim waagerechten + Scrollen schienen die Balken durch die Namen. Jetzt `--gantt-label-grund` + (derselbe Ton, deckend, hell/dunkel in `glas.css`); getönte Zeilen + (Projekt, Phasenköpfe) legen ihre Tönung als `background-image` darüber. +- **Projektbereich zeigt den Glas-Hintergrund:** `.plan-main` hat in + `plan-styles.css` einen deckenden Grund (`var(--bg)`); unter Glas ist er + durchsichtig (`glas.css`), sonst sah man im Projekt nur flaches + Dunkelblau/Grau statt Lichtflächen und Wasserzeichen. +- **„Alles schwebt“** (Eriks Wahl C, Desktop): Auch das Menüband + (`.app-topbar`, sticky bei `top: 12px`) und der Projektkopf schweben mit + 12px Abstand und runden Ecken. Der Projektkopf ist dafür in + `App.html` in **`.plan-kopf`** gefasst (`.plan-header` + `.plan-nav` + darin ohne eigene Fläche) — vorher stieß ein eckiger, durchgehender + Streifen an die runde Seitenleiste. Der aktive Reiter ist eine Kapsel wie + der aktive Eintrag der Seitenleiste; `.plan-main-content` hat unter Glas + 12px statt 20px Rand, damit die Kacheln bündig unter der Kopfkarte + stehen. Ohne Glas und unter 769px bleibt alles wie zuvor. +- **Ausgenommen:** die Wandtafel (`body.wandtafel-aktiv`: kein Wasserzeichen, + kein `backdrop-filter` — eigenes Design, Kiosk-Rechner) und + `prefers-reduced-transparency` (deckende Flächen). + +## Dunkelmodus nachgeschärft (Kalender, Sitzungskommentar) + +Eriks Wahl „A“ aus einem Kontrast-Vergleich; Block „DUNKELMODUS +NACHGESCHÄRFT“ am Ende von `styles.css`. Alles über Tokens (`--kal-*`, +`--rt-*`, `--kommentar-ansicht-bg`), hell unverändert: +- `--meilenstein` fehlte im Block `html[data-theme="dark"]` — wer den + Dunkelmodus ausdrücklich gewählt hatte, sah das dunkle Lila (2:1). +- Kalender: „meine Aufgabe“ (`--kal-meine` = Akzent) und „Projektaufgabe“ + (`--kal-projekt`, Grün-Türkis) sind verschiedene Farben statt desselben + Blaus mit Transparenz; Punkte dunkel 6px, Nebentage/Legende heller. +- Kommentarfarben: Die Werkzeugleiste speichert feste Farbwerte (`#d70015` + usw., vom Browser teils als `rgb(...)` geschrieben). Attributselektoren + setzen sie beim ANZEIGEN auf `--rt-rot/-gruen/-blau/-orange` um (dunkel + hellere Fassung); `:not([style*="background"])` lässt Farbflächen + (Werkzeugleiste, Gantt) aus. Gespeichert und im PDF bleibt der Originalwert. +- Projekt-Dashboard: `#planStatusWrap` bekam `.locked-cell` (55 % Deckkraft, + dunkel grauer Grund) — entfernt (`renderPlanInfo()`), der Kommentar ist + über `.is-ansicht` als Anzeige erkennbar. + ## Bewegung (Hover/Klick-Rückmeldung) The app's interaction feedback is **token-driven and lives in one place**: @@ -3524,10 +4383,71 @@ in a loop. kiosk touch monitor has no hover, and that screen carries its own `--dash-*` design anyway. +## Ladezeit (Pi) + +Eriks Meldung „das Laden der Seite auf dem Pi dauert extrem lange“. Gemessen +und behoben (`server.js`, `App.html`): +- **Sitzungs-Schreibzugriff je Anfrage:** `attachUser()` schrieb bei JEDER + Anfrage — auch jeder der rund 60 JS/CSS-Dateien — `last_seen_at`; jetzt + höchstens einmal je Minute und Sitzung (`sitzungZuletztGeschrieben`). +- **`PRAGMA synchronous = NORMAL`** (WAL-üblich): Schreibvorgänge warten + nicht mehr jedes Mal auf die SD-Karte; bei Stromausfall können nur die + allerletzten Schreibvorgänge fehlen, die Datenbank bleibt konsistent. +- **gzip:** `gzipJson` (res.json ab 1 KB; `/load-all` 155 KB → 14 KB) und + `gzipStatisch()` für JS/CSS/HTML/SVG (je Datei+mtime einmal komprimiert, + im Speicher), `appSenden()` für die Seitenrouten (App.html 122 → 30 KB). + Ohne `Accept-Encoding: gzip` unverändert über express.static/sendFile. +- **Browser-Cache** über `?v=N` (siehe Server API). Erster Aufruf 1,4 MB → + 0,46 MB, jeder weitere ~2 KB. +- **Externe Dateien blockieren nicht mehr:** Google-Schrift per + `rel=preload` nachgeladen, pdfmake + vfs_fonts erst nach `load` + (nacheinander); die PDF-Exporte prüfen `window.pdfMake` ohnehin. + +## Betrieb auf dem Pi (Pi Zero 2, 512 MB, Ubuntu Server) + +Eriks Wunsch: möglichst nicht abstürzen, sonst von selbst neu starten, kein +Speicherüberlauf. Vier Ebenen, jede fängt ab, was die darüber verpasst: +- **App (`server.js`):** + - **PDF-Arbeit im Kindprozess** (`pdf-arbeit.js`, `pdfImKindprozess()`): + `extrahierePdfText()`/`renderPdfAlsBildsatz()` laufen je Auftrag in einem + frisch geforkten Prozess (Heap `PDF_KIND_HEAP_MB`, Selbstabbruch über + `PDF_KIND_MAX_MB` RSS, 3 min Zeitlimit, höchstens einer gleichzeitig). + Stirbt er, scheitert nur der Import mit „PDF ist zu groß …“. Der + Plan-Checker-Render braucht viel Speicher (am Mac gemessen bis ~600 MB) + und kann auf dem Pi daran scheitern — gewollt, statt den Pi umzuwerfen. + - **Datei-Upload streamt auf die Platte** (`empfangeInDatei()`, 200 MB); + die übrigen Roh-Uploads sind kleiner: Protokoll/PDF/Plan Checker 25 MB, + DB-Import 50 MB. Fehler-Middleware vor `app.listen` macht aus 413 JSON. + - **Wächter** (`betriebsWaechterStarten()`): Speicher über `SPEICHER_MAX_MB` + → sauberes Beenden mit Code 1; Event-Loop steht länger als + `HAENGER_MAX_S` → ein Worker-Thread (Herzschlag über SharedArrayBuffer) + beendet den Prozess hart. `uncaughtException` → Beenden mit Code 1, + `unhandledRejection` nur protokolliert. Ohne die Umgebungsvariablen ist + der Speicher-Wächter aus (lokal), der Hänger-Wächter läuft mit 90 s. + - `betriebAufraeumen()` stündlich: Login-Sperren, `wal_checkpoint(TRUNCATE)`. + Sitzungswechsel lädt nur noch die jüngste Sitzung. gzip Stufe 6. + `/health` meldet `speicherMb`/`laufzeitMin`. +- **Docker** (`docker-compose.yml`, `Docker/Dockerfile`): `mem_limit 320m`, + `memswap_limit 512m`, `init: true`, Log-Rotation 3×5 MB, Healthcheck per + `wget` statt eigenem Node-Prozess, `node --max-old-space-size=160`. + Docker startet „unhealthy“ NICHT neu — dafür ist der Hänger-Wächter da. +- **Pi** (`Docker/pi-einrichten.sh`, einmal mit sudo): Hardware-Watchdog + (systemd `RuntimeWatchdogSec=14s`), zram (halber RAM, zstd), cgroup memory + in `cmdline.txt` (sonst wirkt `mem_limit` nicht), `gpu_mem=16`, Dienste aus + (multipathd, ModemManager, …; snapd nur mit `--snapd-aus`), earlyoom, + journald 50 MB, Container-Neustart täglich 3:30 (cron), Unterspannungs-Check. + **WLAN** (Befund 29.10.: kein Absturz, sondern WLAN verloren und nie + wieder verbunden, ASSOC-REJECT in Schleife): Energiesparen aus + (`wlan-energiesparen-aus.service`), `wlan-waechter.timer` jede Minute — + ohne Gateway ab 2 min neu verbinden, nach 10 min `systemctl reboot`. +- **Prüfen:** `Docker/pi-pruefen.sh` (Speicher, Neustarts, OOM, Strom). +- Offen (Schritt 4 des Plans): `/load-all` liefert weiter ALLE Sitzungen — + wächst wöchentlich auf Server und Browser. + ## Docker - `docker compose up --build` from the repo root. Compose maps `3003:3003`, - bind-mounts `./sessions:/app/sessions`, `restart: unless-stopped` and sets + bind-mounts `./sessions:/app/sessions`, `restart: always` (startet beim Booten auch nach einem manuellen `docker compose stop` wieder; nur `down` entfernt ihn) and sets **`TZ: Europe/Berlin`** — ohne das liefe der Container auf UTC, und die Datumsvergleiche des Servers (`heuteIsoLokal()`) lägen zwischen Mitternacht und 2 Uhr Ortszeit einen Tag zurück. @@ -3640,6 +4560,42 @@ Abweichungen: Rendern des Wochenberichts mit. `esc()` wandelt jetzt über `String()` um (nur `null`/`undefined` werden zu `''`). +## Behobene Fehler (Testlauf 10/2026, fünf Test-Agents) + +- **Gesperrte Sitzungen sind für Nicht-Admins auch inhaltlich unveränderlich**: + `/save-week` antwortet bei einer am Server gesperrten Woche mit **409** + (`conflict:true`), nicht nur beim Entsperren mit 403 — der Browser lädt die + Woche dann neu. Die Auto-Sperre ist nicht betroffen (sie schreibt nur + Wochen, die am Server noch offen sind). Admins dürfen weiter. +- `/save-week` lehnt doppelte Projekt-`id` oder doppelte `projectUid` in einer + Woche mit 400 ab (vorher 500 bzw. ein Weg für Mitglieder, über eine Kopie + mit gleicher uid doch Projekte anzulegen). +- **Login-Sperre**: Schlüssel aus der normalisierten Kennung (`loginKennung()`: + ohne "@…", getrimmt, klein) und `req.socket.remoteAddress` — + `X-Forwarded-For` wird nicht mehr gelesen. Nicht-String-Passwörter → 400. +- `/import` prüft die Datei (`pruefeBackupDatei()`) **vor** dem Auto-Backup; + eine ungültige Datei gibt 400 und verdrängt keine echten Backups mehr. +- **Kiosk-Konten** stehen weder in `loadGlobalPeople()` noch im + Teilnehmerkreis (`kontenInAktuelleSitzung()` trägt sie aus, solange sie + keinem Projekt zugeordnet sind) und sind keine gültigen Dateiprüfer. +- Ein **deaktiviertes Konto** wird aus offenen Dateiprüfungen genommen + (`offenePruefungenAufraeumen()`), sofern es nicht der einzige Prüfer ist. + `/my-dashboard` zeigt nur Freigaben noch offener Dateien und wählt die + Woche ohne die Sandbox. +- **Person-ID-Wechsel** (`PATCH /admin/users/:id`) zieht die ID in der + aktuellen Sitzung und der Sandbox in allen Personen-Tabellen mit. +- **Protokoll-Termine** werden auf ISO normalisiert (`normalisiereTermin()`, + "15.10.2026" → "2026-10-15"); die Textmuster-Punkte tragen `status`. + Kaputte PDF/DOCX → 400. Gelöschte Protokolle entfernen ihre Quelldatei. + Die Protokoll-Liste zählt Info-Punkte separat (`infoCount`). +- Frontend: `[hidden]{display:none!important}` global (styles.css); der + TODO-Dialog zeigt Zuständige außerhalb des Projekts als gestrichelte Chips + statt sie beim Speichern zu verwerfen; `toggleTodoDone()` führt den + Fortschritt mit; `editLph()` nimmt nur 0–100; "Aufgabe aus Kommentar" + lehnt ein Fälligkeitsdatum vor heute ab; neue Protokoll-Punkte werden hinter + ihrer Gruppe eingefügt; "Terminplan aus Honorar" beginnt am nächsten + Arbeitstag; "Entsperren" im Wochenbericht nur für Admins. + ## Conventions & verification - Git repo; commits are German (e.g. "PDF Export von A4 auf A3 …"). Remote: diff --git a/Docker/Dockerfile b/Docker/Dockerfile index f18a0b3..fd8c72d 100644 --- a/Docker/Dockerfile +++ b/Docker/Dockerfile @@ -7,10 +7,10 @@ WORKDIR /app # package.json und package-lock.json zuerst kopieren, damit npm-Installation gecacht wird COPY package.json package-lock.json ./ -RUN npm ci --production +RUN npm config set fetch-retries 6 && npm config set fetch-retry-maxtimeout 180000 && npm config set fetch-timeout 600000 && npm ci --omit=dev # Nur die Laufzeit-Dateien kopieren -COPY server.js ./ +COPY server.js pdf-arbeit.js ./ COPY public ./public # Sessions-Ordner fuer die SQLite-Datenbank sicherstellen @@ -20,7 +20,11 @@ USER node EXPOSE 3003 -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD node -e "require('http').get('http://localhost:3003/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))" +# wget (busybox) statt eines eigenen Node-Prozesses je Prüfung: auf dem +# Pi Zero kostete jeder Healthcheck sonst ~40 MB und eine Sekunde CPU. +HEALTHCHECK --interval=60s --timeout=10s --start-period=60s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3003/health >/dev/null || exit 1 -CMD ["node", "server.js"] +# --max-old-space-size: Node räumt früher auf, statt bis an die +# Container-Grenze zu wachsen (Pi Zero 2, 512 MB). Siehe docker-compose.yml. +CMD ["node", "--max-old-space-size=160", "server.js"] diff --git a/Docker/pi-einrichten.sh b/Docker/pi-einrichten.sh new file mode 100755 index 0000000..9209594 --- /dev/null +++ b/Docker/pi-einrichten.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# pi-einrichten.sh — macht Ubuntu Server auf dem Raspberry Pi Zero 2 (512 MB) +# stabil für die Projektübersicht. Mehrfach ausführbar (prüft jeden Schritt). +# +# sudo bash Docker/pi-einrichten.sh # Standard +# sudo bash Docker/pi-einrichten.sh --snapd-aus # zusätzlich snapd abschalten +# +# Was es tut (Einzelheiten in CLAUDE.md, "Betrieb auf dem Pi"): +# 1. Hardware-Watchdog: hängt das System, startet der Pi selbst neu +# 2. zram: komprimierter Auslagerungsspeicher im RAM (statt SD-Karte) +# 3. Speichergrenzen für Container erlauben (cgroup memory) +# 4. gpu_mem=16 — kein Bildschirm, mehr RAM für Linux +# 5. Unnötige Dienste aus (multipathd, ModemManager, udisks2 …) +# 6. earlyoom: greift ein, BEVOR das System im Speichermangel festfriert +# 7. Systemprotokoll auf 50 MB begrenzen +# 8. Nächtlicher Neustart des Containers (3:30 Uhr) +# 9. Prüfung der Stromversorgung (Unterspannung) +# 10. WLAN: Energiesparen aus, Wächter verbindet neu und startet den Pi +# neu, wenn das Netz 10 Minuten lang weg bleibt +set -u + +if [ "$(id -u)" -ne 0 ]; then echo "Bitte mit sudo ausführen."; exit 1; fi + +SNAPD_AUS=0 +[ "${1:-}" = "--snapd-aus" ] && SNAPD_AUS=1 + +NEUSTART_NOETIG=0 +FIRMWARE=/boot/firmware +[ -d "$FIRMWARE" ] || FIRMWARE=/boot +ok() { echo " ✓ $*"; } +info() { echo " • $*"; } +warn() { echo " ! $*"; } + +echo "== Projektübersicht: Pi einrichten ==" +echo "System: $(. /etc/os-release; echo "$PRETTY_NAME"), $(uname -m), $(free -m | awk '/Mem:/{print $2}') MB RAM" + +# 1. Hardware-Watchdog ------------------------------------------------------ +echo "[1] Hardware-Watchdog" +mkdir -p /etc/systemd/system.conf.d +cat > /etc/systemd/system.conf.d/10-watchdog.conf <<'EOF' +# Hängt das System, löst der Watchdog des Pi nach 14 s einen Neustart aus +# (der bcm2835-Watchdog kann höchstens ~15 s). +[Manager] +RuntimeWatchdogSec=14s +RebootWatchdogSec=2min +EOF +if [ -e /dev/watchdog ]; then ok "Watchdog aktiv nach Neustart (/dev/watchdog vorhanden)" +else warn "/dev/watchdog fehlt — Kernelmodul bcm2835_wdt wird beim Start geladen"; echo bcm2835_wdt > /etc/modules-load.d/watchdog.conf; NEUSTART_NOETIG=1; fi +systemctl daemon-reexec + +# 2. zram ------------------------------------------------------------------- +echo "[2] zram (komprimierter Auslagerungsspeicher)" +apt-get update -qq +if apt-cache show systemd-zram-generator >/dev/null 2>&1; then + apt-get install -y -qq systemd-zram-generator >/dev/null + cat > /etc/systemd/zram-generator.conf <<'EOF' +[zram0] +zram-size = ram / 2 +compression-algorithm = zstd +swap-priority = 100 +EOF + systemctl daemon-reload + systemctl restart systemd-zram-setup@zram0.service 2>/dev/null || true + ok "systemd-zram-generator eingerichtet (halber RAM, zstd)" +else + apt-get install -y -qq zram-tools >/dev/null + printf 'ALGO=zstd\nPERCENT=50\nPRIORITY=100\n' > /etc/default/zramswap + systemctl restart zramswap 2>/dev/null || true + ok "zram-tools eingerichtet (halber RAM, zstd)" +fi +cat > /etc/sysctl.d/90-zram.conf <<'EOF' +# zram ist schnell: früh auslagern, Seiten einzeln holen +vm.swappiness=100 +vm.page-cluster=0 +vm.vfs_cache_pressure=200 +EOF +sysctl -q --system +swapon --show | sed 's/^/ /' + +# 3. Speichergrenzen für Container ------------------------------------------- +echo "[3] Speichergrenzen für Container (cgroup memory)" +if grep -qw memory /sys/fs/cgroup/cgroup.controllers 2>/dev/null; then + ok "bereits verfügbar" +else + CMDLINE="$FIRMWARE/cmdline.txt" + if [ -f "$CMDLINE" ] && ! grep -q "cgroup_enable=memory" "$CMDLINE"; then + cp "$CMDLINE" "$CMDLINE.vor-projektuebersicht" + sed -i '1 s/$/ cgroup_enable=memory cgroup_memory=1/' "$CMDLINE" + ok "in $CMDLINE ergänzt (Sicherung: cmdline.txt.vor-projektuebersicht)" + NEUSTART_NOETIG=1 + else + warn "nicht verfügbar — $CMDLINE prüfen" + fi +fi + +# 4. GPU-Speicher ----------------------------------------------------------- +echo "[4] GPU-Speicher" +CONFIG="$FIRMWARE/config.txt" +if [ -f "$CONFIG" ]; then + if grep -q "^gpu_mem=" "$CONFIG"; then sed -i 's/^gpu_mem=.*/gpu_mem=16/' "$CONFIG" + else printf '\n# Projektübersicht: kein Bildschirm, mehr RAM für Linux\ngpu_mem=16\n' >> "$CONFIG"; fi + ok "gpu_mem=16" + NEUSTART_NOETIG=1 +else warn "$CONFIG nicht gefunden"; fi + +# 5. Unnötige Dienste ------------------------------------------------------- +echo "[5] Unnötige Dienste abschalten" +for d in multipathd multipathd.socket ModemManager udisks2 packagekit bluetooth avahi-daemon avahi-daemon.socket; do + if systemctl list-unit-files "$d.service" "$d" 2>/dev/null | grep -q "$d"; then + systemctl disable --now "$d" >/dev/null 2>&1 && ok "$d aus" + fi +done +if [ "$SNAPD_AUS" = 1 ]; then + if command -v snap >/dev/null && snap list docker >/dev/null 2>&1; then + warn "Docker ist als Snap installiert — snapd bleibt an" + else + systemctl disable --now snapd.service snapd.socket snapd.seeded.service >/dev/null 2>&1 && ok "snapd aus" + fi +else + info "snapd bleibt an (spart ~40 MB mit --snapd-aus)" +fi + +# 6. earlyoom --------------------------------------------------------------- +echo "[6] earlyoom" +apt-get install -y -qq earlyoom >/dev/null +cat > /etc/default/earlyoom <<'EOF' +# Bei unter 5 % freiem RAM UND unter 10 % freiem Swap den größten Prozess +# beenden — nie SSH, Docker oder systemd. Der App-Container startet dann neu. +# Ohne innere Anführungszeichen: systemd reicht sie sonst wörtlich weiter. +EARLYOOM_ARGS="-m 5 -s 10 -r 3600 --avoid ^(sshd|dockerd|containerd|systemd|systemd-journal)$ --prefer ^node$" +EOF +systemctl enable --now earlyoom >/dev/null 2>&1 +systemctl restart earlyoom +ok "aktiv" + +# 7. Systemprotokoll -------------------------------------------------------- +echo "[7] Systemprotokoll begrenzen" +mkdir -p /etc/systemd/journald.conf.d +printf '[Journal]\nSystemMaxUse=50M\n' > /etc/systemd/journald.conf.d/10-groesse.conf +systemctl restart systemd-journald +ok "höchstens 50 MB" + +# 8. Nächtlicher Neustart --------------------------------------------------- +echo "[8] Nächtlicher Neustart des Containers" +cat > /etc/cron.d/projektuebersicht <<'EOF' +# Projektübersicht: frischer Speicher jede Nacht (gegen schleichende Lecks) +30 3 * * * root docker restart projektuebersicht >/dev/null 2>&1 +EOF +systemctl enable docker >/dev/null 2>&1 +ok "täglich 3:30 Uhr; Docker startet beim Booten" + +# 9. Stromversorgung -------------------------------------------------------- +echo "[9] Stromversorgung" +T="" +[ -r /sys/devices/platform/soc/soc:firmware/get_throttled ] && T=$(cat /sys/devices/platform/soc/soc:firmware/get_throttled) +command -v vcgencmd >/dev/null && T=$(vcgencmd get_throttled | cut -d= -f2) +if [ -z "$T" ]; then info "nicht auslesbar (Paket libraspberrypi-bin installieren)" +elif [ "$((T))" -eq 0 ]; then ok "keine Unterspannung seit dem Start ($T)" +else warn "Unterspannung/Drosselung erkannt ($T) — besseres Netzteil (5 V / 2,5 A) verwenden!"; fi + +# 10. WLAN ------------------------------------------------------------------ +# Befund 29.10.: Der Pi "stürzte" nicht ab, sondern verlor das WLAN +# (wlan0: Lost carrier) und kam danach nie wieder in das Netz +# (ASSOC-REJECT in Schleife) — erreichbar war er erst nach Stecker ziehen. +echo "[10] WLAN stabil halten" +apt-get install -y -qq iw >/dev/null 2>&1 +cat > /etc/systemd/system/wlan-energiesparen-aus.service <<'EOF' +[Unit] +Description=WLAN-Energiesparen aus (Pi Zero 2 verliert sonst die Verbindung) +After=sys-subsystem-net-devices-wlan0.device +Wants=sys-subsystem-net-devices-wlan0.device + +[Service] +Type=oneshot +ExecStart=/usr/sbin/iw dev wlan0 set power_save off +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +EOF +cat > /usr/local/sbin/wlan-waechter <<'EOF' +#!/bin/sh +# Prüft jede Minute, ob das Gateway erreichbar ist. Ohne Netz: alle 2 +# Minuten WLAN neu verbinden, nach 10 Minuten den Pi neu starten. +ZAEHLER=/run/wlan-waechter.zaehler +GW=$(ip route | awk '/^default/{print $3; exit}') +if [ -n "$GW" ] && ping -c 2 -W 3 "$GW" >/dev/null 2>&1; then + rm -f "$ZAEHLER"; exit 0 +fi +N=$(( $(cat "$ZAEHLER" 2>/dev/null || echo 0) + 1 )) +echo "$N" > "$ZAEHLER" +logger -t wlan-waechter "kein Netz seit $N Minute(n)" +if [ "$N" -ge 10 ]; then + logger -t wlan-waechter "Neustart des Pi"; systemctl reboot +elif [ $((N % 2)) -eq 0 ]; then + logger -t wlan-waechter "WLAN neu verbinden" + wpa_cli -i wlan0 reassociate >/dev/null 2>&1 + if [ "$N" -ge 4 ]; then + ip link set wlan0 down; sleep 2; ip link set wlan0 up + networkctl reconfigure wlan0 >/dev/null 2>&1 + fi +fi +EOF +chmod 755 /usr/local/sbin/wlan-waechter +cat > /etc/systemd/system/wlan-waechter.service <<'EOF' +[Unit] +Description=WLAN-Wächter der Projektübersicht + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/wlan-waechter +EOF +cat > /etc/systemd/system/wlan-waechter.timer <<'EOF' +[Unit] +Description=WLAN-Wächter jede Minute + +[Timer] +OnBootSec=3min +OnUnitActiveSec=1min + +[Install] +WantedBy=timers.target +EOF +systemctl daemon-reload +systemctl enable --now wlan-energiesparen-aus.service >/dev/null 2>&1 +systemctl enable --now wlan-waechter.timer >/dev/null 2>&1 +if iw dev wlan0 get power_save 2>/dev/null | grep -q off; then ok "Energiesparen aus"; else warn "Energiesparen ließ sich nicht abschalten"; fi +ok "Wächter aktiv (neu verbinden ab 2 min, Neustart nach 10 min ohne Netz)" + +echo +if [ "$NEUSTART_NOETIG" = 1 ]; then + echo "Fertig. Bitte den Pi jetzt neu starten: sudo reboot" +else + echo "Fertig." +fi +echo "Danach prüfen: bash Docker/pi-pruefen.sh" diff --git a/Docker/pi-pruefen.sh b/Docker/pi-pruefen.sh new file mode 100755 index 0000000..09d08bd --- /dev/null +++ b/Docker/pi-pruefen.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# pi-pruefen.sh — zeigt, ob der Pi und die Projektübersicht stabil laufen. +# sudo bash Docker/pi-pruefen.sh (ohne sudo fehlen Docker und Strom) +echo "== Speicher ==" +free -m +echo +echo "== Auslagerung (zram) ==" +swapon --show 2>/dev/null || echo "keine" +echo +echo "== Watchdog ==" +systemctl show -p RuntimeWatchdogUSec 2>/dev/null +[ -e /dev/watchdog ] && echo "/dev/watchdog vorhanden" || echo "/dev/watchdog FEHLT" +echo +echo "== Speichergrenzen für Container ==" +grep -qw memory /sys/fs/cgroup/cgroup.controllers 2>/dev/null && echo "verfügbar" || echo "NICHT verfügbar (cmdline.txt, siehe pi-einrichten.sh)" +echo +echo "== Container ==" +docker ps -a --filter name=projektuebersicht --format 'Status: {{.Status}}' +docker inspect projektuebersicht --format 'Neustarts: {{.RestartCount}} Grenze: {{.HostConfig.Memory}} Byte zuletzt gestartet: {{.State.StartedAt}} OOM: {{.State.OOMKilled}}' 2>/dev/null +docker stats --no-stream --format 'Verbrauch: {{.MemUsage}} ({{.MemPerc}}) CPU: {{.CPUPerc}}' projektuebersicht 2>/dev/null +echo +echo "== App ==" +curl -s --max-time 5 http://localhost:3003/health || wget -qO- http://localhost:3003/health || echo "antwortet NICHT" +echo +echo +echo "== Neustarts der App (letzte 10 Meldungen) ==" +docker logs projektuebersicht 2>&1 | grep -E "Wächter|Unerwarteter Fehler|server running" | tail -10 +echo +echo "== Speichermangel im System (earlyoom / Kernel) ==" +journalctl -u earlyoom --since "-7 days" --no-pager 2>/dev/null | grep -i "sending\|killing" | tail -5 +journalctl -k --since "-7 days" --no-pager 2>/dev/null | grep -i "out of memory\|oom-kill" | tail -5 +echo +echo "== Stromversorgung ==" +if command -v vcgencmd >/dev/null; then vcgencmd get_throttled +elif [ -r /sys/devices/platform/soc/soc:firmware/get_throttled ]; then echo "throttled=$(cat /sys/devices/platform/soc/soc:firmware/get_throttled) (0 = gut)" +else echo "nicht auslesbar"; fi +echo +echo "== WLAN ==" +iw dev wlan0 get power_save 2>/dev/null || echo "nicht auslesbar" +systemctl is-active wlan-waechter.timer 2>/dev/null | sed 's/^/Wächter: /' +journalctl -t wlan-waechter --since "-7 days" --no-pager 2>/dev/null | tail -5 +echo +echo "== Letzte Systemstarts ==" +last -x reboot 2>/dev/null | head -5 diff --git a/docker-compose.yml b/docker-compose.yml index b1b0f02..2a8bcf8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,10 +14,31 @@ services: # Sommerzeit gälte damit zwischen 00:00 und 02:00 Ortszeit noch der # Vortag, und Termine von heute erschienen als überfällig. TZ: Europe/Berlin - restart: unless-stopped + # Speicher-Wächter: darüber startet der Server sich selbst sauber neu, + # bevor die Grenze von 320 MB (mem_limit) erreicht ist. + SPEICHER_MAX_MB: "220" + # PDF-Kindprozess (Protokoll-Import, Plan Checker): bricht darüber ab. + PDF_KIND_MAX_MB: "220" + PDF_KIND_HEAP_MB: "160" + # Hänger-Wächter: reagiert der Server so lange nicht, Neustart. + HAENGER_MAX_S: "90" + restart: always + # Betrieb auf dem Pi Zero 2 (512 MB), siehe CLAUDE.md "Betrieb auf dem Pi": + # tini als PID 1 — Signale kommen sauber bei Node an (DB wird geschlossen). + init: true + # Feste Speichergrenze: Läuft die App über, trifft es nur diesen + # Container (restart: always holt ihn zurück), nicht SSH oder Docker. + mem_limit: 320m + memswap_limit: 512m + # Die Protokolle wuchsen sonst unbegrenzt auf der SD-Karte. + logging: + driver: json-file + options: + max-size: "5m" + max-file: "3" healthcheck: - test: ["CMD", "node", "-e", "require('http').get('http://localhost:3003/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))"] - interval: 30s - timeout: 5s - start_period: 10s + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/health >/dev/null || exit 1"] + interval: 60s + timeout: 10s + start_period: 60s retries: 3 diff --git a/pdf-arbeit.js b/pdf-arbeit.js new file mode 100644 index 0000000..cd976da --- /dev/null +++ b/pdf-arbeit.js @@ -0,0 +1,239 @@ +// pdf-arbeit.js: das Lesen und Rendern von PDFs (pdfjs-dist, @napi-rs/canvas) +// für Protokoll-Import, "TODOs aus Protokoll" und Plan Checker. Aus server.js +// herausgelöst, damit es in einem EIGENEN Kindprozess läuft (pdfImKindprozess() +// in server.js): Ein großes PDF braucht schnell 100–200 MB; läuft der Speicher +// über, stirbt nur der Kindprozess, nicht der Server (Pi Zero 2, 512 MB). +// +// Doppelte Rolle: require() aus server.js liefert nur die Konstanten (die +// Bibliotheken werden erst beim Aufruf geladen); per child_process.fork() +// gestartet, nimmt es genau EINEN Auftrag über IPC entgegen, antwortet und +// beendet sich — jeder Auftrag beginnt mit leerem Speicher. +'use strict'; +const path = require('path'); + +let pdfjsLibPromise = null; +function ladePdfjs(){ + if(!pdfjsLibPromise) pdfjsLibPromise = import('pdfjs-dist/legacy/build/pdf.mjs'); + return pdfjsLibPromise; +} +async function extrahierePdfText(buf){ + const pdfjsLib = await ladePdfjs(); + const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf) }); + try { + const doc = await loadingTask.promise; + let text = ''; + for(let i = 1; i <= doc.numPages; i++){ + const page = await doc.getPage(i); + const content = await page.getTextContent(); + // Textfragmente stehen ohne Zeilenumbrüche in content.items — erkennePunkteAusText() + // braucht aber echte Zeilen (Punkt-Marker müssen am Zeilenanfang stehen). Ein + // Umbruch wird eingefügt, sobald sich die Y-Koordinate (transform[5]) ändert, + // exakt wie es das vormals genutzte pdf-parse intern selbst tat. + let lastY = null; + content.items.forEach(item => { + if(lastY !== null && item.transform[5] !== lastY) text += '\n'; + else if(lastY !== null) text += ' '; + text += item.str; + lastY = item.transform[5]; + }); + text += '\n\n'; + } + return text; + } finally { + loadingTask.destroy(); + } +} + +// ladeCanvas/renderPdfAlsBildsatz: fürs "Plan Checker"-Feature weiter unten +// (POST /plan-checker-pruefen, aufgerufen aus pruefePlanMitKI()) — rendert +// ausschließlich die ERSTE Seite eines PDFs zu einem Bildsatz (Übersicht + +// hochaufgelöste Kachel-Ausschnitte, s. renderPdfAlsBildsatz() dort), für +// BEIDE Anbieter gleich (siehe Projekthistorie bei pruefePlanMitClaude() +// unten — ein einzelnes, von uns oder vom Anbieter selbst erzeugtes +// Gesamtbild der Seite erwies sich für feine Maßzahlen/Plankopf-Text +// wiederholt als zu grob). Ein Planblatt pro Prüfung ist der Normalfall; +// mehrseitige PDFs mit mehreren Planblättern sind vorerst nicht vorgesehen. +let canvasLibPromise = null; +function ladeCanvas(){ + if(!canvasLibPromise) canvasLibPromise = import('@napi-rs/canvas'); + return canvasLibPromise; +} + +// PDFJS_STANDARD_FONTS: Pfad zu den von pdfjs-dist mitgelieferten Standard- +// Schriftdaten (Foxit-Ersatzschriften für Helvetica/Times/Symbol/...), mit +// abschließendem Trenner wie von pdf.js intern per String-Konkatenation +// erwartet (`${standardFontDataUrl}${dateiname}`). OHNE diesen Parameter +// wirft pdf.js beim Rendern pro fehlender Schriftart die Warnung +// "UnknownErrorException: Ensure that the standardFontDataUrl API parameter +// is provided" und kann Zeichen nicht auf Glyphen abbilden — ein einfacher +// Testtext (reine Helvetica-Buchstaben) rendert trotzdem noch halbwegs über +// einen groben Ersatz, aber bei echten CAD-/Architektur-Export-PDFs +// (Revit/AutoCAD, oft mit eigens eingebetteten oder nicht eingebetteten +// Spezialschriften für Maßzahlen und Plankopf-Text) blieben genau diese +// Textstellen dadurch leer oder unleserlich — die Maßketten-LINIEN kamen an +// (reine Vektorgrafik, schriftunabhängig), die Zahlen und der Plankopf-Text +// nicht. Nur für renderPdfAlsBildsatz() relevant: extrahierePdfText() +// liest ausschließlich getTextContent() (die im PDF gespeicherten +// Zeichencodes), ohne je eine Glyphe zu rendern, und ist von fehlenden +// Schriftdaten nicht betroffen. +const PDFJS_STANDARD_FONTS = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts') + path.sep; + +// PDFJS_WASM: Pfad zu den von pdfjs-dist mitgelieferten WASM-Modulen +// (u. a. jbig2.wasm, openjpeg.wasm), im selben Muster wie PDFJS_STANDARD_FONTS +// oben. OHNE diesen Parameter wirft pdf.js beim Laden eines WASM-Decoders +// "UnknownErrorException: Ensure that the wasmUrl API parameter is provided" +// und versucht dann, JBIG2-kodierte Bildinhalte (verbreitetes Kompressions- +// format für eingebettete Scan-/Rasterbilder, z. B. ein eingescanntes Logo im +// Plankopf) über einen Fallback-Pfad zu laden, der mangels `wasmUrl` zu einem +// kaputten Modulnamen zusammengesetzt wird ("nulljbig2_nowasm_fallback.js") +// und mit ERR_MODULE_NOT_FOUND scheitert — der betroffene Bildinhalt fehlt +// dadurch im gerenderten PNG, genau wie zuvor die Ebenen/Schriften. +const PDFJS_WASM = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'wasm') + path.sep; + +// PLAN_CHECK_KACHEL_*: Rastergröße und Überlappung für renderPdfAlsBildsatz() +// unten. 2x2 (vier Kacheln) mit 12% Überlappung je Rand - genug +// Sicherheitsrand, falls ein Feld (z. B. der Plankopf) genau auf einer +// Kachelgrenze liegt, ohne die Zahl der pro Prüfung gesendeten Bilder +// (= Kosten/Dauer je Anbieter-Anfrage) unnötig in die Höhe zu treiben. +const PLAN_CHECK_KACHEL_SPALTEN = 2; +const PLAN_CHECK_KACHEL_ZEILEN = 2; +const PLAN_CHECK_KACHEL_UEBERLAPPUNG = 0.12; +const PLAN_CHECK_UEBERSICHT_BREITE = 2000; +const PLAN_CHECK_KACHEL_BREITE = 2200; + +// renderPdfAlsBildsatz: löst die vorherige renderPdfErstePageAlsPng() ab, die +// die ganze Planseite als EIN einzelnes PNG rendert hat. Grund für die +// Umstellung: Nach den vorherigen Fixes (Ebenen/Schrift/WASM, s.o.) meldete +// Erik einen neuen, grundsätzlicheren Befund - der Plankopf wurde zwar jetzt +// gefunden und der Plantitel korrekt gelesen, aber Projektname, +// Planersteller, Maßstab, Datum und Plannummer blieben "in der vorliegenden +// Auflösung" nicht sämtlich lesbar. Das bestätigt in letzter Konsequenz den +// Verdacht, der schon zur vorherigen PDF-Direktübergabe an Claude geführt +// hatte (s. historischer Kommentar bei pruefePlanMitClaude() weiter unten): +// JEDER Anbieter skaliert ein übermitteltes Bild bzw. eine PDF-Seite intern +// auf eine eigene Obergrenze herunter, BEVOR das Modell draufschaut (bei +// Claude laut Dokumentation ca. 1568px lange Kante, bei OpenAI vergleichbar). +// Ein Plankopf ist nur ein kleiner Ausschnitt eines großen Planblatts - seine +// Feinschrift liegt nach dieser anbieterseitigen Herunterskalierung weit +// unter Lesbarkeit, UNABHÄNGIG davon, wie hoch WIR die Ausgangsauflösung +// wählen (mehr Pixel in der Quelle ändern nichts, wenn der Anbieter ohnehin +// wieder auf dieselbe Obergrenze herunterrechnet). Claudes PDF-Direktübergabe +// half laut Erik auch nicht durchgreifend genug - vermutlich, weil Anthropics +// eigene Dokumenten-Verarbeitung intern denselben Weg geht (PDF-Seite zu +// einem einzigen Bild, dann dieselbe Auflösungsgrenze). +// +// Der einzige verlässliche Ausweg: der KI ZUSÄTZLICH zur Übersicht mehrere, +// überlappende Ausschnitte der Seite in höherer Pixel-Dichte mitgeben, sodass +// Feinschrift gleich welcher Lage auf dem Blatt in mindestens einem Bild +// unterhalb der anbieterseitigen Herunterskalierungs-Schwelle bleibt - OHNE +// dafür die Lage des Plankopfs erkennen zu müssen (die variiert je +// Büro-Vorlage: beim getesteten Plan oben rechts, DIN-Konvention wäre unten +// rechts). Jede Kachel wird NICHT aus einem fertigen Raster-Bild +// zugeschnitten (das brächte keine zusätzliche Schärfe, nur eine vergrößerte +// Unschärfe), sondern pdf.js erneut mit einer höheren scale-Vorgabe gerendert +// und per Context-Transform (ctx.translate) auf den gewünschten Ausschnitt +// verschoben - der Ausschnitt entsteht also selbst aus dem Vektor-Original in +// höherer Auflösung, nicht durch Hochskalieren eines bereits gerenderten +// Bilds. Gilt jetzt für BEIDE Anbieter (Eriks ausdrückliche Entscheidung): +// Claude bekommt dieselben gerenderten Bilder wie ChatGPT statt wieder der +// rohen PDF, damit für beide dasselbe, selbst kontrollierte Verfahren gilt +// statt für Claude erneut auf unbekanntes anbieterinternes Verhalten zu +// vertrauen. +// +// Rückgabe: { uebersicht: Buffer, kacheln: Buffer[] } - je ein PNG-Buffer. +async function renderPdfAlsBildsatz(buf){ + const pdfjsLib = await ladePdfjs(); + const { createCanvas } = await ladeCanvas(); + const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf), standardFontDataUrl: PDFJS_STANDARD_FONTS, wasmUrl: PDFJS_WASM }); + try { + const doc = await loadingTask.promise; + const page = await doc.getPage(1); + const basisViewport = page.getViewport({ scale: 1 }); + + // Alle optionalen Ebenen (PDF Optional Content Groups) erzwungen + // sichtbar schalten, BEVOR gerendert wird: CAD-/BIM-Exporte (AutoCAD, + // Revit, ...) legen technische Pläne sehr häufig in Ebenen an (z. B. + // "Architektur-Grundriss", "ELT-Planung", "Legende", "Plankopf") und + // markieren einen Teil davon im PDF selbst als standardmäßig AUS - oft + // bewusst so exportiert, damit jedes Gewerk seine eigene Ebene in + // Acrobat selbst einschaltet. Ohne diese Ergänzung rendert pdf.js exakt + // diesen Vorgabe-Zustand nach: bei einem echten Plan blieben dadurch nur + // der Architektur-Grundriss übrig, ELT-Planung, Legende UND Plankopf + // fehlten im gerenderten Bild komplett (von Erik direkt bestätigt, nach + // Ansehen des gespeicherten Debug-PNGs) - die KI konnte diese Merkmale + // also gar nicht als "nicht vorhanden" FALSCH einstufen, sie bekam sie + // schlicht nie zu sehen. + const ebenen = await doc.getOptionalContentConfig(); + for(const [ebenenId] of ebenen){ + ebenen.setVisibility(ebenenId, true); + } + + // ausschnitt (optional): {x, y, breite, hoehe} in Geräte-Pixeln DIESES + // scale-Werts. Ohne ausschnitt wird die ganze Seite gerendert. + async function renderAusschnitt(scale, ausschnitt){ + const viewport = page.getViewport({ scale }); + const breite = ausschnitt ? Math.round(ausschnitt.breite) : Math.round(viewport.width); + const hoehe = ausschnitt ? Math.round(ausschnitt.hoehe) : Math.round(viewport.height); + const canvas = createCanvas(breite, hoehe); + const ctx = canvas.getContext('2d'); + if(ausschnitt) ctx.translate(-ausschnitt.x, -ausschnitt.y); + await page.render({ canvasContext: ctx, viewport, optionalContentConfigPromise: Promise.resolve(ebenen) }).promise; + return canvas.toBuffer('image/png'); + } + + // Übersicht: die ganze Seite, moderate Auflösung - liefert der KI den + // Gesamtkontext (Art/Lage der Zeichnung), aber keine verlässliche + // Feinschrift. + const uebersicht = await renderAusschnitt(PLAN_CHECK_UEBERSICHT_BREITE / basisViewport.width, null); + + // Kacheln: Raster mit Überlappung, JEDE Kachel mit eigener, hoher + // Skalierung gerendert (nicht aus der Übersicht zugeschnitten, s. o.). + const anteilBreite = (1 / PLAN_CHECK_KACHEL_SPALTEN) + PLAN_CHECK_KACHEL_UEBERLAPPUNG; + const kachelScale = PLAN_CHECK_KACHEL_BREITE / (basisViewport.width * anteilBreite); + const kachelViewport = page.getViewport({ scale: kachelScale }); + const kacheln = []; + for(let zeile = 0; zeile < PLAN_CHECK_KACHEL_ZEILEN; zeile++){ + for(let spalte = 0; spalte < PLAN_CHECK_KACHEL_SPALTEN; spalte++){ + const startAnteilX = Math.max(0, spalte / PLAN_CHECK_KACHEL_SPALTEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); + const startAnteilY = Math.max(0, zeile / PLAN_CHECK_KACHEL_ZEILEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); + const endAnteilX = Math.min(1, (spalte + 1) / PLAN_CHECK_KACHEL_SPALTEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); + const endAnteilY = Math.min(1, (zeile + 1) / PLAN_CHECK_KACHEL_ZEILEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); + kacheln.push(await renderAusschnitt(kachelScale, { + x: startAnteilX * kachelViewport.width, + y: startAnteilY * kachelViewport.height, + breite: (endAnteilX - startAnteilX) * kachelViewport.width, + hoehe: (endAnteilY - startAnteilY) * kachelViewport.height + })); + } + } + + return { uebersicht, kacheln }; + } finally { + loadingTask.destroy(); + } +} + + +module.exports = { PLAN_CHECK_KACHEL_SPALTEN, PLAN_CHECK_KACHEL_ZEILEN, extrahierePdfText, renderPdfAlsBildsatz }; + +// ---------- Kindprozess ---------- +// Speicher-Wächter im Kind: Native Speicher (Canvas) begrenzt +// --max-old-space-size nicht; über PDF_KIND_MAX_MB beendet sich das Kind +// selbst, der Server meldet dann "PDF zu groß". +if(require.main === module && process.send){ + const MAX_MB = Number(process.env.PDF_KIND_MAX_MB) || 260; + setInterval(() => { + if(process.memoryUsage().rss > MAX_MB * 1024 * 1024) process.exit(99); + }, 250).unref(); + process.once('message', async msg => { + try { + const buf = Buffer.from(msg.daten); + const ergebnis = msg.art === 'text' + ? await extrahierePdfText(buf) + : await renderPdfAlsBildsatz(buf); + process.send({ ok: true, ergebnis }, () => process.exit(0)); + } catch(err){ + process.send({ ok: false, fehler: String(err && err.message || err) }, () => process.exit(0)); + } + }); +} diff --git a/public/App.html b/public/App.html index 6e39580..cd464ac 100644 --- a/public/App.html +++ b/public/App.html @@ -34,15 +34,44 @@ Projektübersicht - - - - - - + + + + + + + + + + + + +
@@ -52,7 +81,7 @@
- +

Projektübersicht

@@ -64,6 +93,15 @@ (renderAdminSitzungen(), js/admin/admin.js). -->
+ +
+ + +
@@ -113,6 +151,11 @@ + + - - - - @@ -163,33 +206,10 @@ Einstellungen - - + + + + - -
@@ -1569,12 +1672,9 @@
- +
@@ -1637,12 +1737,9 @@
- +
@@ -1782,7 +1879,7 @@ blasses Wasserzeichen über die ganze Seite (Variante E aus dem Logo-Vergleich), ein kleines Logo gibt es bewusst nicht. --> - - + + - - - + + + - - - - + + + + - - + + + - + - - + + + - - - - - + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - + + + + - + \ No newline at end of file diff --git a/public/glas.css b/public/glas.css new file mode 100644 index 0000000..e6d3e95 --- /dev/null +++ b/public/glas.css @@ -0,0 +1,400 @@ +/* =========================================================================== + GLAS-DARSTELLUNG (Eriks Wahl: Gestaltungsvorschlag "Projektübersicht in + Glas", Annäherung an Apple Liquid Glass) + --------------------------------------------------------------------------- + Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES + Stylesheet. Alles hängt an der Klasse html.glas, die ein kleines Skript im + von App.html setzt — ohne sie sieht die App aus wie zuvor. Abschalten + lässt sie sich je Browser unter Einstellungen → Mein Konto ("Glas- + Darstellung", localStorage-Schlüssel projuebersicht-glas). + + Prinzip: + - Tokens statt Einzelregeln: --surface & Co. werden durchscheinend, damit + wirken die ~70 Stellen, die var(--surface) als Fläche nutzen, ohne dass + jede einzeln angefasst wird. + - Echtes Glas (Unschärfe des Hintergrunds) nur auf den großen Flächen — + Menüband, Seitenleiste, Karten, Dialoge. Kleine Elemente bleiben ohne + backdrop-filter: verschachtelte Unschärfe kostet Rechenzeit und bringt + auf einer bereits gläsernen Karte nichts. + - Drei Dichten: Rahmen (Menüband/Seitenleiste) am dichtesten, Inhalt + mittel, Dialoge und Menüs fast deckend (sie liegen über Inhalt, der + sonst durchscheinen und den Text stören würde). + - Hintergrund: Lichtflächen in den Logo-Blautönen und das Logo als großes + Wasserzeichen (logo.svg, vektorisiert aus logo.png), beides fixiert. + - Die Wandtafel (body.wandtafel-aktiv) bleibt außen vor: eigenes Design, + und auf dem Kiosk-Rechner wäre Unschärfe zu teuer. + =========================================================================== */ + +/* ---------- Tokens: hell ---------- */ +html.glas { + --bg: #e8eef3; + --surface: rgba(255,255,255,.58); + --surface-alt: rgba(255,255,255,.40); + --surface-sunk: rgba(15,90,133,.07); + --line: rgba(20,45,65,.16); + --line-soft: rgba(20,45,65,.09); + + --accent: #0f5a85; + --accent-hover: #0d5079; + --accent-active: #0a4565; + --accent-soft: rgba(15,90,133,.11); + --band-neu: #0f5a85; + + --text: #14212b; + --text-muted: #4b5d6b; + --text-subtle: #6f8291; + + --r-md: 10px; + --r-lg: 18px; + --r-xl: 22px; + + --sh-1: 0 8px 28px rgba(21,58,86,.10), 0 1px 4px rgba(21,58,86,.06); + --sh-2: 0 12px 34px rgba(21,58,86,.13), 0 2px 6px rgba(21,58,86,.07); + --sh-3: 0 20px 60px rgba(21,58,86,.20), 0 4px 14px rgba(21,58,86,.10); + + --glas-rahmen: rgba(255,255,255,.62); + --glas-dicht: rgba(255,255,255,.86); + --glas-deckend: #f7fafc; + --glas-kante: rgba(255,255,255,.80); + --glas-kante-2: rgba(255,255,255,.35); + --glas-blur: 24px; + --glas-hell: #94b0cc; + --glas-tief: #116896; + --glas-logo: .16; +} + +/* ---------- Tokens: dunkel (System und manuell) ---------- */ +@media (prefers-color-scheme: dark) { + html.glas:not([data-theme="light"]) { + --bg: #0b141b; + --surface: rgba(30,46,60,.50); + --surface-alt: rgba(30,46,60,.34); + --surface-sunk: rgba(255,255,255,.06); + --line: rgba(255,255,255,.14); + --line-soft: rgba(255,255,255,.08); + --accent: #8cc9e6; + --accent-hover: #a3d4ec; + --accent-active: #6fb3d3; + --accent-soft: rgba(140,201,230,.15); + --band-neu: #116896; + --text: #e8f0f5; + --text-muted: #a9bccb; + --text-subtle: #7790a2; + --sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30); + --sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30); + --sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35); + --glas-rahmen: rgba(28,42,54,.58); + --glas-dicht: rgba(22,34,44,.90); + --glas-deckend: #15212b; + --glas-kante: rgba(255,255,255,.18); + --glas-kante-2: rgba(255,255,255,.07); + --glas-hell: #2c5675; + --glas-tief: #1d6f99; + --glas-logo: .10; + } +} +html.glas[data-theme="dark"] { + --bg: #0b141b; + --surface: rgba(30,46,60,.50); + --surface-alt: rgba(30,46,60,.34); + --surface-sunk: rgba(255,255,255,.06); + --line: rgba(255,255,255,.14); + --line-soft: rgba(255,255,255,.08); + --accent: #8cc9e6; + --accent-hover: #a3d4ec; + --accent-active: #6fb3d3; + --accent-soft: rgba(140,201,230,.15); + --band-neu: #116896; + --text: #e8f0f5; + --text-muted: #a9bccb; + --text-subtle: #7790a2; + --sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30); + --sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30); + --sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35); + --glas-rahmen: rgba(28,42,54,.58); + --glas-dicht: rgba(22,34,44,.90); + --glas-deckend: #15212b; + --glas-kante: rgba(255,255,255,.18); + --glas-kante-2: rgba(255,255,255,.07); + --glas-hell: #2c5675; + --glas-tief: #1d6f99; + --glas-logo: .10; +} + +/* ---------- Hintergrund: Lichtflächen und Logo ---------- */ +html.glas body { + background: + radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%), + radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%), + radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%), + radial-gradient(40% 40% at 28% 82%, color-mix(in srgb, var(--glas-hell) 42%, transparent), transparent 70%), + var(--bg); + background-attachment: fixed; +} +/* Wasserzeichen: fixiert, links unten, teils unter Seitenleiste und Karten — + dort schimmert es durch das Glas, daneben ist es klar zu sehen. */ +html.glas body::before { + content: ""; + position: fixed; + left: -3vw; bottom: -14vh; + width: min(1100px, 68vw); aspect-ratio: 256 / 216; + background: url("/logo.svg") no-repeat center / contain; + opacity: var(--glas-logo); + pointer-events: none; + z-index: 0; +} +html.glas .app-shell { position: relative; z-index: 1; } +html.glas body.wandtafel-aktiv::before { display: none; } + +/* ---------- Das Material ---------- */ +html.glas .app-topbar, +html.glas #headerMenu.side-menu, +html.glas .modal, +html.glas .user-menu-dropdown, +html.glas .user-menu, +html.glas .board-list, +html.glas .abw-team-kopf, +html.glas .report-card, +html.glas .load-card, +html.glas .settings-card, +html.glas .dash-karte, +html.glas .dash-band, +html.glas .bericht-panel, +html.glas .pc-panel, +html.glas .table-wrap, +html.glas .gantt-container, +html.glas .people-readonly, +html.glas .bubble-card, +html.glas .range-picker, +html.glas .sync-problem-bar, +html.glas .plan-header, +html.glas .plan-nav, +html.glas .plan-panel, +html.glas .plan-einst-nav, +html.glas .load-karte, +html.glas .plan-kpi-tile, +html.glas .plan-lage-band, +html.glas .plan-mini-card, +html.glas .protokoll-liste, +html.glas .protokoll-schreiben, +html.glas .plan-toast, +html.glas .glocke-menue, +html.glas .wiz { + background: var(--surface); + -webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%); + backdrop-filter: blur(var(--glas-blur)) saturate(170%); + border: 1px solid var(--glas-kante-2); + box-shadow: var(--sh-1), inset 0 1px 0 var(--glas-kante), inset 0 -1px 0 var(--glas-kante-2); +} + +/* Rahmen: Menüband und Seitenleiste dichter als der Inhalt. */ +html.glas .app-topbar { + background: var(--glas-rahmen); + border-width: 0 0 1px 0; + box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(21,58,86,.06); +} +html.glas .topbar-logo { height: 26px; } + +/* Dialoge, Menüs, Hinweise: fast deckend — sie liegen über Inhalt, der sonst + durchscheint und den Text unruhig macht. */ +html.glas .modal, +html.glas .user-menu-dropdown, +html.glas .user-menu, +html.glas .range-picker, +html.glas .plan-toast, +html.glas .glocke-menue, +html.glas .sync-problem-bar { + background: var(--glas-dicht); + box-shadow: var(--sh-3), inset 0 1px 0 var(--glas-kante); +} +html.glas .modal-bg { + background: rgba(15,35,50,.28); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); +} + +/* ---------- Seitenleiste: schwebende Glasfläche (Desktop) ---------- */ +@media (min-width: 769px) { + html.glas #headerMenu.side-menu { + background: var(--glas-rahmen); + top: calc(52px + 12px); + height: calc(100vh - 52px - 24px); + margin: 12px 0 12px 12px; + border-radius: var(--r-xl); + border: 1px solid var(--glas-kante-2); + box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante); + overflow: hidden; + } +} +/* ---------- "Alles schwebt" (Eriks Wahl C, Desktop) ---------- + Vorher stieß der eckige Projektkopf (.plan-header + .plan-nav, ein + durchgehender Streifen ohne Abstand) an die runde, schwebende + Seitenleiste. Jetzt schweben Menüband, Seitenleiste und Projektkopf mit + demselben Abstand (12px) und denselben Rundungen; der aktive Reiter ist + eine Kapsel wie der aktive Eintrag der Seitenleiste. */ +@media (min-width: 769px) { + html.glas .app-topbar { + top: 12px; + margin: 12px 12px 0; + border-radius: var(--r-lg); + border: 1px solid var(--glas-kante-2); + box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante); + } + html.glas #headerMenu.side-menu { + top: calc(52px + 24px); + height: calc(100vh - 52px - 36px); + } + html.glas .plan-kopf { + margin: 12px 12px 0; + border-radius: var(--r-xl); + background: var(--glas-rahmen); + -webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%); + backdrop-filter: blur(var(--glas-blur)) saturate(170%); + border: 1px solid var(--glas-kante-2); + box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante); + padding: 4px 12px 10px; + } + /* Kopf und Reiter sind innerhalb der Karte keine eigenen Flächen mehr. */ + html.glas .plan-kopf .plan-header, + html.glas .plan-kopf .plan-nav { + background: transparent; + -webkit-backdrop-filter: none; + backdrop-filter: none; + border: 0; + box-shadow: none; + } + /* Inhalt bündig unter der Kopfkarte und im selben 12px-Abstand. */ + html.glas .plan-page .plan-main-content { padding-left: 12px; padding-right: 12px; padding-top: 12px; } + html.glas .plan-kopf .plan-header { padding: 10px 12px 6px; } + html.glas .plan-kopf .plan-nav { padding: 0; gap: 2px; } + html.glas .plan-kopf .plan-nav-item { + border-bottom: 0; + border-radius: 999px; + padding: 8px 16px; + font-size: 13.5px; + } + html.glas .plan-kopf .plan-nav-item:hover { background: var(--surface-sunk); } + html.glas .plan-kopf .plan-nav-item.is-active { + background: var(--glas-dicht); + color: var(--accent); + box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10); + } +} +@media (min-width: 769px) and (prefers-color-scheme: dark) { + html.glas:not([data-theme="light"]) .plan-kopf .plan-nav-item.is-active { background: rgba(255,255,255,.10); } +} +@media (min-width: 769px) { + html.glas[data-theme="dark"] .plan-kopf .plan-nav-item.is-active { background: rgba(255,255,255,.10); } +} + +/* Projektbereich: .plan-main hat in plan-styles.css einen deckenden + Grund (var(--bg)). Unter Glas verdeckte er die Lichtflächen und das + Logo-Wasserzeichen — im Projekt sah man nur flaches Dunkelblau bzw. + Grau, auf allen anderen Seiten den Glas-Hintergrund. */ +html.glas .plan-main { background: transparent; } + +html.glas .nav-item { border-radius: 999px; } +html.glas .nav-item:hover, +html.glas .nav-item:visited:hover { background: var(--surface-sunk); } +html.glas .nav-item.active { + background: var(--glas-dicht); + color: var(--text); + box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10); +} +html.glas .nav-item.active .nav-icon { color: var(--accent); } +/* Die bestehenden Dunkel-Regeln für die Navigation setzen feste Werte — + hier auf die Glas-Tokens zurückführen. */ +@media (prefers-color-scheme: dark) { + html.glas:not([data-theme="light"]) .nav-item:hover { background: var(--surface-sunk); } + html.glas:not([data-theme="light"]) .nav-item.active { background: rgba(255,255,255,.10); } + html.glas:not([data-theme="light"]) .app-topbar { background: var(--glas-rahmen); } +} +html.glas[data-theme="dark"] .nav-item:hover { background: var(--surface-sunk); } +html.glas[data-theme="dark"] .nav-item.active { background: rgba(255,255,255,.10); } +html.glas[data-theme="dark"] .app-topbar { background: var(--glas-rahmen); } + +/* ---------- Bedienelemente ---------- */ +/* Zweitknöpfe als klare Glaskapsel statt grauer Fläche. */ +html.glas button.secondary { + background: var(--surface); + border: 1px solid var(--glas-kante-2); + box-shadow: inset 0 1px 0 var(--glas-kante); + color: var(--text); +} +html.glas button.secondary:hover { background: var(--glas-dicht); } +html.glas button:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent); } +/* Hauptknöpfe (ohne eigene Klasse bzw. .small) mit leichtem Glanz oben — + bewusst nicht jeder `; + }; + const tage = e.start ? abwArbeitstage(e.start, e.end) : 0; + const warnungen = e.start ? abwWarnungen(p, e.start, e.end) : []; + return `
+
${abwBearbeiten ? 'Abwesenheit ändern' : 'Neue Abwesenheit'}
+
+ ${abwKreis(p)} + ${istIch ? 'Für mich' : 'Für ' + esc(abwName(p))}${istIch ? ' · ' + esc(abwName(p)) : (p && p.gewerk ? ' · ' + esc(p.gewerk) : '')} + ${fuerAuswahl} +
+
+ ${kachel('Von', e.start, '')} + + ${kachel('Bis', e.end || e.start, e.start ? ' · ' + tage + (tage === 1 ? ' Arbeitstag' : ' Arbeitstage') : '')} +
+ + +
+ ${ABW_ARTEN.map(a => ``).join('')} +
+
+ + ${abwBearbeiten ? '' : ''} + +
+ ${abwFehler ? `` : ''} + ${warnungen.map(w => `
${w}
`).join('')} +
`; +} + +// abwWarnungen: Hinweise zum gewählten Zeitraum — Überschneidung mit einer +// eigenen Abwesenheit der Person, ihre offenen Aufgaben im Zeitraum und +// Kollegen desselben Gewerks, die ebenfalls fehlen. Rückgabe: HTML-Strings. +function abwWarnungen(p, start, end){ + if(!p) return []; + const w = []; + const doppelt = (p.absences || []).filter(a => a.id !== abwBearbeiten && abwUeberlappt(start, end, a.start, a.end)); + if(doppelt.length) w.push('Überschneidet sich mit ' + doppelt.map(a => esc((a.verdeckt ? 'abwesend' : a.type) + ' ' + abwZeitraum(a))).join(', ') + '.'); + + const wk = typeof currentWeek === 'function' ? currentWeek() : null; + const aufgaben = []; + ((wk && wk.projects) || []).forEach(pr => { + ((pr.plan && pr.plan.todos) || []).forEach(t => { + if(t.done || !Array.isArray(t.assignees) || !t.assignees.includes(p.id)) return; + const tEnde = t.end || t.start; + if(!tEnde) return; + if(abwUeberlappt(start, end, t.start || tEnde, tEnde)) aufgaben.push(`„${esc(t.title || 'Aufgabe')}“ (${esc(pr.name || '')}, ${abwDatum(tEnde)})`); + }); + }); + if(aufgaben.length){ + const zuviel = aufgaben.length - 3; + w.push(`In diesen Zeitraum ${aufgaben.length === 1 ? 'fällt 1 offene Aufgabe' : 'fallen ' + aufgaben.length + ' offene Aufgaben'}: ${aufgaben.slice(0, 3).join(', ')}${zuviel > 0 ? ' und ' + zuviel + ' weitere' : ''}.`); + } + + if(p.gewerk){ + const andere = abwPersonen().filter(x => x !== p && x.gewerk === p.gewerk + && (x.absences || []).some(a => abwUeberlappt(start, end, a.start, a.end))); + if(andere.length) w.push(`${andere.map(x => esc(abwName(x))).join(', ')} (${esc(p.gewerk)}) ${andere.length === 1 ? 'ist' : 'sind'} in dieser Zeit ebenfalls weg.`); + } + return w; +} + +// abwListeHtml: Abwesenheiten der gewählten Person als Zeilen wie in der +// Projektliste — Startdatum in der Nummernspalte, Kalenderwoche als Kapsel. +function abwListeHtml(){ + const p = abwPerson(abwFuer); + if(!p) return ''; + const heute = heuteIso(); + const alle = (p.absences || []).slice().sort((a, b) => a.start.localeCompare(b.start)); + const kommend = alle.filter(a => a.end >= heute); + const vergangen = alle.filter(a => a.end < heute).reverse(); + const liste = abwVergangen ? vergangen : kommend; + const titel = p === abwIch() ? 'Meine Abwesenheiten' : 'Abwesenheiten von ' + abwName(p); + const zeilen = liste.map(a => { + const tage = abwArbeitstage(a.start, a.end); + const ds = parseDateISO(a.start), de = parseDateISO(a.end); + const kwA = getWeekNumber(ds), kwE = getWeekNumber(de); + const zr = ABW_WOCHENTAG[ds.getDay()] + ' ' + abwDatum(a.start, a.start.slice(0, 4) !== a.end.slice(0, 4)) + + (a.start === a.end ? a.start.slice(0, 4) : ' – ' + ABW_WOCHENTAG[de.getDay()] + ' ' + abwDatum(a.end, true)); + const fragt = abwLoeschFrage === a.id; + const verdeckt = !!a.verdeckt; + const aendern = verdeckt ? '' : ` onclick="abwAendern('${esc(a.id)}')"`; + return `
+
+ ${abwDatum(a.start)} +
+ ${verdeckt ? 'abwesend' : esc(a.type)}${tage} ${tage === 1 ? 'Arbeitstag' : 'Arbeitstage'} +
${esc(zr)}${!verdeckt && a.note ? ' · ' + esc(a.note) : ''}
+
+
+ ${verdeckt ? '' : ``} + +
+ KW ${kwA}${kwE !== kwA ? '–' + kwE : ''} +
+
`; + }).join(''); + return `
+
+
${esc(titel)}
+ + + + +
+
${zeilen || `
${abwVergangen ? 'Keine vergangenen Abwesenheiten.' : 'Keine kommenden Abwesenheiten.'}
`}
+
`; +} + +// abwTeamHtml: Kopf des Team-Monats und je Gewerk eine Karte (wie die +// Projektgruppen), Personen ohne Gewerk zuletzt. +function abwTeamHtml(){ + const monatName = abwMonat.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); + const gewerke = Object.keys(DEPARTMENTS); + const sichtbar = abwFilter === 'Alle' ? gewerke.concat(['']) : [abwFilter]; + const karten = sichtbar.map(g => { + const ps = abwPersonen().filter(p => (p.gewerk || '') === g); + return ps.length ? abwGewerkKarteHtml(g, ps) : ''; + }).join(''); + return `
+
Team im ${esc(abwMonat.toLocaleDateString('de-DE', { month: 'long' }))}
+ + + + ${esc(monatName)} + + + + ${['Alle'].concat(gewerke).map(g => ``).join('')} + +
+ ${karten || '

Keine Personen in diesem Gewerk.

'}`; +} + +// abwGewerkKarteHtml: eine Karte je Gewerk — Tageskopf, dann eine Zeile je +// Person mit Kürzel-Kreis und je Abwesenheit ein Balken. +function abwGewerkKarteHtml(gewerk, personen){ + const jahr = abwMonat.getFullYear(), monat = abwMonat.getMonth(); + const tage = new Date(jahr, monat + 1, 0).getDate(); + const heute = heuteIso(); + const ich = abwIch(); + const iso = d => jahr + '-' + String(monat + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); + const ersterIso = iso(1), letzterIso = iso(tage); + const sortiert = personen.slice().sort((a, b) => (a === ich ? -1 : b === ich ? 1 : abwName(a).localeCompare(abwName(b), 'de'))); + const darfIrgendwen = sortiert.some(p => p.darfAbw); + + let h = `
${sortiert.length} ${sortiert.length === 1 ? 'Person' : 'Personen'}
`; + for(let d = 1; d <= tage; d++){ + const dt = new Date(jahr, monat, d); + h += `
${ABW_WOCHENTAG[dt.getDay()]}${d}
`; + } + sortiert.forEach(p => { + const sichtbar = (p.absences || []).filter(a => abwUeberlappt(a.start, a.end, ersterIso, letzterIso)); + let tageImMonat = 0; + if(p.darfAbw) sichtbar.forEach(a => { + tageImMonat += abwArbeitstage(a.start < ersterIso ? ersterIso : a.start, a.end > letzterIso ? letzterIso : a.end); + }); + const rolle = abwRollen(p).join(', '); + const meta = p === ich ? 'Ich' + (rolle ? ' · ' + rolle : '') : (p.darfAbw ? (rolle || 'ohne Fachrolle') : 'nur „abwesend“'); + const waehlen = p.darfAbw ? ` ist-klickbar" role="button" tabindex="0" title="Abwesenheiten von ${esc(abwName(p))} anzeigen und eintragen" onclick="abwPersonAusTabelle(${p.userId})" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();abwPersonAusTabelle(${p.userId})}` : ''; + h += `
+ ${abwKreis(p)} + ${esc(abwName(p))} + ${esc(meta)}${tageImMonat ? ' · ' + tageImMonat + (tageImMonat === 1 ? ' Tag' : ' Tage') + ' im ' + abwMonat.toLocaleDateString('de-DE', { month: 'short' }).replace('.', '') + '.' : ''} +
`; + for(let d = 1; d <= tage; d++){ + const dt = new Date(jahr, monat, d); + const tagIso = iso(d); + let balken = ''; + sichtbar.forEach(a => { + const beginn = a.start < ersterIso ? ersterIso : a.start; + if(beginn !== tagIso) return; + const ende = a.end > letzterIso ? letzterIso : a.end; + const len = Number(ende.slice(8)) - d + 1; + const verdeckt = a.verdeckt || !p.darfAbw; + const text = verdeckt ? 'abwesend' : a.type; + const titel = abwName(p) + ': ' + text + ' ' + abwZeitraum(a) + (!verdeckt && a.note ? ' — ' + a.note : ''); + balken += `${len > 1 && !verdeckt ? esc(text) : ''}`; + }); + const klick = p.darfAbw ? ` onclick="abwTagGewaehlt(${p.userId}, '${tagIso}')"` : ''; + h += `
${balken}
`; + } + }); + return `
${esc(gewerk || 'Ohne Gewerk')}
${darfIrgendwen ? '' : 'nur Ansicht'}
+
+
${h}
+
`; +} + +// ---------- Bedienung ---------- + +function abwFuerWaehlen(userId){ + abwFuer = Number(userId); + abwLoeschFrage = null; abwFehler = ''; + renderAbwesenheiten(); +} +// abwPersonAusTabelle: Klick auf eine Person in der Team-Tabelle — sie wird +// oben in Erfassung und Liste gewählt (eine laufende Änderung wird verworfen). +// Die Seite bleibt, wo sie ist (Eriks Wunsch, kein Springen nach oben). +function abwPersonAusTabelle(userId){ + abwBearbeiten = null; + abwEntwurf = { start: '', end: '', type: abwEntwurf.type || 'Urlaub', note: '' }; + abwFuerWaehlen(userId); +} +// abwZeitraumOeffnen: Von- oder Bis-Kachel öffnet den Bereichs-Kalender. +function abwZeitraumOeffnen(anker){ + openRangePicker({ anchor: anker, startInputId: 'abwStart', endInputId: 'abwEnde', onApply: abwZeitraumGewaehlt }); +} +function abwArtWaehlen(art){ abwEntwurf.type = art; renderAbwesenheiten(); } +function abwZeitraumGewaehlt(){ + abwEntwurf.start = document.getElementById('abwStart').value; + abwEntwurf.end = document.getElementById('abwEnde').value || abwEntwurf.start; + abwFehler = ''; + renderAbwesenheiten(); +} +function abwVergangenZeigen(v){ abwVergangen = !!v; abwLoeschFrage = null; renderAbwesenheiten(); } +function abwFilterSetzen(g){ abwFilter = g; renderAbwesenheiten(); } +function abwMonatWechseln(n){ + if(n === 0){ const h = new Date(); abwMonat = new Date(h.getFullYear(), h.getMonth(), 1); } + else abwMonat = new Date(abwMonat.getFullYear(), abwMonat.getMonth() + n, 1); + renderAbwesenheiten(); +} +// abwTagGewaehlt: Klick auf einen leeren Tag der Zeitleiste — Schnellerfassung +// für diese Person und diesen Tag vorbelegen. +function abwTagGewaehlt(userId, tagIso){ + abwFuer = userId; abwBearbeiten = null; abwLoeschFrage = null; abwFehler = ''; + abwEntwurf = { start: tagIso, end: tagIso, type: abwEntwurf.type || 'Urlaub', note: '' }; + renderAbwesenheiten(); + const f = document.querySelector('#abwesenheit .abw-datum'); + if(f){ f.scrollIntoView({ block: 'center', behavior: 'smooth' }); } +} +function abwAendernFuer(userId, id){ abwFuer = userId; abwAendern(id); } +function abwAendern(id){ + const p = abwPerson(abwFuer); + const a = p && (p.absences || []).find(x => x.id === id); + if(!a || a.verdeckt) return; + abwBearbeiten = id; abwLoeschFrage = null; abwFehler = ''; + abwEntwurf = { start: a.start, end: a.end, type: ABW_ARTEN.includes(a.type) ? a.type : 'Sonstiges', note: a.note || '' }; + renderAbwesenheiten(); + const f = document.querySelector('#abwesenheit .abw-datum'); + if(f) f.scrollIntoView({ block: 'center', behavior: 'smooth' }); +} +function abwAbbrechen(){ + abwBearbeiten = null; abwFehler = ''; abwFuer = abwStandardFuer(); + abwEntwurf = { start: '', end: '', type: 'Urlaub', note: '' }; + renderAbwesenheiten(); +} + +// abwAnfrage: Aufruf der Abwesenheits-Routen. Rückgabe: Promise<{ok, json}>. +function abwAnfrage(methode, pfad, daten){ + return fetch(baseUrl() + pfad, { + method: methode, + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: daten ? JSON.stringify(daten) : undefined + }).then(r => r.json().catch(() => ({})).then(json => ({ ok: r.ok && json && json.ok, json }))); +} + +function abwSpeichern(){ + const e = abwEntwurf; + e.note = ((document.getElementById('abwNotiz') || {}).value || '').trim(); + if(!e.start){ abwFehler = 'Bitte einen Zeitraum wählen.'; renderAbwesenheiten(); return; } + const pfad = '/users/' + abwFuer + '/absences' + (abwBearbeiten ? '/' + encodeURIComponent(abwBearbeiten) : ''); + abwAnfrage(abwBearbeiten ? 'PATCH' : 'POST', pfad, { start: e.start, end: e.end || e.start, type: e.type, note: e.note }) + .then(({ ok, json }) => { + if(!ok){ abwFehler = (json && json.error) || 'Die Abwesenheit konnte nicht gespeichert werden.'; renderAbwesenheiten(); return; } + abwBearbeiten = null; abwFehler = ''; abwFuer = abwStandardFuer(); + abwEntwurf = { start: '', end: '', type: 'Urlaub', note: '' }; + return personenNachladen().then(renderAbwesenheiten); + }) + .catch(() => { abwFehler = 'Keine Verbindung zum Server.'; renderAbwesenheiten(); }); +} + +// abwLoeschen: zweistufig im Knopf („Löschen“ → „Wirklich löschen?“). +function abwLoeschen(id){ + if(abwLoeschFrage !== id){ abwLoeschFrage = id; renderAbwesenheiten(); return; } + abwLoeschFrage = null; + abwAnfrage('DELETE', '/users/' + abwFuer + '/absences/' + encodeURIComponent(id)) + .then(({ ok, json }) => { + if(!ok){ abwFehler = (json && json.error) || 'Die Abwesenheit konnte nicht gelöscht werden.'; renderAbwesenheiten(); return; } + if(abwBearbeiten === id) abwAbbrechen(); + return personenNachladen().then(renderAbwesenheiten); + }) + .catch(() => { abwFehler = 'Keine Verbindung zum Server.'; renderAbwesenheiten(); }); +} diff --git a/public/js/admin-users.js b/public/js/admin-users.js index 90dd59e..938b0d9 100644 --- a/public/js/admin-users.js +++ b/public/js/admin-users.js @@ -73,13 +73,44 @@ return Array.from(document.querySelectorAll('#' + containerId + ' input[type="checkbox"]:checked')).map(cb => cb.value); } + // systemRollen: die Rollen aus „Rollen & Rechte“ (GET /rollen) für die + // Auswahl „Rolle“ und die Anzeige in der Tabelle. + let systemRollen = []; + function ladeSystemRollen(){ + fetch(baseUrl() + '/rollen') + .then(r => r.json()) + .then(j => { + if(!j || !j.ok) return; + systemRollen = j.rollen || []; + ['newUserRole', 'editUserRole'].forEach(id => { + const sel = document.getElementById(id); + if(!sel) return; + const wert = sel.value; + sel.innerHTML = systemRollen.map(r => ``).join(''); + if(wert && systemRollen.some(r => r.key === wert)) sel.value = wert; + }); + loadAndRenderUsers(); + }) + .catch(err => console.error('Rollen laden fehlgeschlagen:', err)); + } + // nurWandtafelRolle: Rolle ohne „Anwendung nutzen“ — sieht nur die + // Wandtafel und trägt keine Fachrolle (früher das Kiosk-Konto). + function nurWandtafelRolle(key){ + const r = systemRollen.find(x => x.key === key); + return !!r && !r.fest && !(r.rechte || []).includes('app'); + } + function rollenName(key){ + const r = systemRollen.find(x => x.key === key); + return r ? r.name : systemRolleText(key); + } + // 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 istKiosk = nurWandtafelRolle(document.getElementById(which + 'UserRole').value); const hint = document.getElementById(which + 'UserRoleHint'); if(hint) hint.style.display = istKiosk ? '' : 'none'; document.querySelectorAll('#' + which + 'UserRoleCheckboxes input[type="checkbox"]').forEach(cb => { @@ -100,6 +131,7 @@ // erreichbar (showTab() leitet sie um) und die Endpunkte sind serverseitig // abgesichert. Keine Parameter, keine Rueckgabe. window.renderAdminUsers = function(){ + ladeSystemRollen(); if(!formulareVorbereitet){ renderDepartmentOptions('newUserDepartment'); renderDepartmentOptions('editUserDepartment'); @@ -151,7 +183,7 @@ ${esc(u.username)} ${esc(u.displayName)} ${esc(u.email || '')} - ${esc(systemRolleText(u.role))} + ${esc(rollenName(u.role))} ${deptLabel} ${esc(u.initials || '')} ${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : 'voll'} @@ -202,10 +234,14 @@ document.getElementById('newUserFirstName').value = ''; document.getElementById('newUserLastName').value = ''; document.getElementById('newUserEmail').value = ''; - document.getElementById('newUserRole').value = 'member'; + document.getElementById('newUserRole').value = systemRollen.some(r => r.key === 'member') ? 'member' : ((systemRollen.find(r => !r.fest) || {}).key || ''); + onUserRoleChange('new'); document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT; document.getElementById('newUserInitials').value = ''; document.getElementById('newUserPersonId').value = ''; + // Sonst übernahm das nächste Konto unbemerkt die Wochenstunden des + // zuvor abgebrochenen Dialogs (Teilzeit ohne Absicht). + document.getElementById('newUserCapacity').value = ''; document.querySelectorAll('#newUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { cb.checked = false; }); document.getElementById('newUserPassword').value = ''; document.getElementById('newUserPassword').style.display = 'none'; @@ -312,7 +348,7 @@ // 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)){ + if(!nurWandtafelRolle(role) && (!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; } @@ -341,6 +377,10 @@ } closeNewUserModal(); loadAndRenderUsers(); + // Der Server hat das Konto in den Teilnehmerkreis der aktuellen + // Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis + // zum nächsten Seitenaufruf in Projekten und im Assistenten. + if(res.data.sitzungGeaendert && typeof load === 'function') load(true); if(res.data.generatedPassword){ showGeneratedPassword(username, res.data.generatedPassword); } @@ -415,6 +455,10 @@ } closeEditUserModal(); loadAndRenderUsers(); + // Der Server hat das Konto in den Teilnehmerkreis der aktuellen + // Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis + // zum nächsten Seitenaufruf in Projekten und im Assistenten. + if(res.data.sitzungGeaendert && typeof load === 'function') load(true); }).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; }); }; @@ -521,6 +565,10 @@ return; } loadAndRenderUsers(); + // Der Server hat das Konto in den Teilnehmerkreis der aktuellen + // Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis + // zum nächsten Seitenaufruf in Projekten und im Assistenten. + if(res.data.sitzungGeaendert && typeof load === 'function') load(true); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }; diff --git a/public/js/admin/admin.js b/public/js/admin/admin.js index 01d221c..6ce7375 100644 --- a/public/js/admin/admin.js +++ b/public/js/admin/admin.js @@ -42,7 +42,7 @@ // Dass der Bereich für Nicht-Administratoren gar nicht erst auftaucht, ist // wie überall in dieser App ein reines UI-Gate; die eigentliche Sperre sitzt // serverseitig in requireRole('admin') auf PUT /app-settings, -// PUT /honorar-config, POST/DELETE /lph-templates, POST/DELETE /holidays, +// PUT /honorar-config, POST/PATCH/DELETE /lph-templates, POST/DELETE /holidays, // GET /backup, POST /import und POST /delete-week. // ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die @@ -52,19 +52,37 @@ // 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. +// recht: welches Recht (Rollen & Rechte) den Abschnitt öffnet; bei einer +// Liste genügt eines davon. const ADMIN_SECTIONS = [ - { key: 'benutzer', title: 'Benutzerverwaltung', wide: true }, - { key: 'honorar', title: 'Honorar' }, - { key: 'auslastung', title: 'Auslastung' }, - { key: 'termine', title: 'Termine & Sitzungen' }, - { key: 'feiertage', title: 'Feiertage' }, - { key: 'vorlagen', title: 'Aufgaben-Vorlagen' }, - { key: 'planchecker', title: 'Plan-Checker-Merkmale' }, - { key: 'betrieb', title: 'Betrieb & Sicherheit' }, - { key: 'papierkorb', title: 'Papierkorb' }, - { key: 'datenbank', title: 'Datenbank & Sitzungen' }, + { key: 'benutzer', title: 'Benutzerverwaltung', wide: true, recht: 'a.benutzer' }, + { key: 'rollen', title: 'Rollen & Rechte', wide: true, recht: 'a.rollen' }, + { key: 'honorar', title: 'Honorar', recht: 'a.honorar' }, + { key: 'auslastung', title: 'Auslastung', recht: 'a.auslastung' }, + { key: 'termine', title: 'Termine & Sitzungen', recht: 'a.termine' }, + { key: 'feiertage', title: 'Feiertage', recht: 'a.feiertage' }, + { key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' }, + { key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' }, + { key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' }, + { key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' }, + { key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] }, ]; +// adminNavAbgleichen: blendet Unterpunkte der Seitenleiste ohne Recht aus +// (auch von renderUserBar() nach der Anmeldung gerufen). +function adminNavAbgleichen(){ + ADMIN_SECTIONS.forEach(sec => { + const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]'); + if(item) item.hidden = !adminAbschnittErlaubt(sec); + }); +} + +// adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen? +function adminAbschnittErlaubt(sec){ + const r = Array.isArray(sec.recht) ? sec.recht : [sec.recht]; + return r.some(x => hatRecht(x)); +} + // adminSection: der gerade sichtbare Abschnitt. let adminSection = 'benutzer'; @@ -95,13 +113,13 @@ function renderAdminArea(){ // isAdminUser: Kurzform für die Sichtbarkeit des Bereichs. // Rückgabe: Boolean. function isAdminUser(){ - return !!(currentUser && currentUser.role === 'admin'); + return hatAdminRecht(); } // adminSectionExists: prüft, ob ein Schlüssel ein bekannter Abschnitt ist. // Parameter: key. Rückgabe: Boolean. function adminSectionExists(key){ - return ADMIN_SECTIONS.some(x => x.key === key); + return ADMIN_SECTIONS.some(x => x.key === key && adminAbschnittErlaubt(x)); } // showAdminSection: schaltet auf einen Abschnitt um — blendet die zugehörige @@ -111,8 +129,9 @@ function adminSectionExists(key){ // (Klick aus einem anderen Bereich heraus), wird zuerst dorthin gewechselt. // Parameter: key. Keine Rückgabe. function showAdminSection(key){ - if(!adminSectionExists(key)) key = ADMIN_SECTIONS[0].key; + if(!adminSectionExists(key)) key = (ADMIN_SECTIONS.find(adminAbschnittErlaubt) || ADMIN_SECTIONS[0]).key; adminSection = key; + adminNavAbgleichen(); if(currentTab !== 'admin'){ showTab('admin'); return; } @@ -140,6 +159,7 @@ function showAdminSection(key){ // Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso // frisch holen. if(key === 'papierkorb') loadAdminPapierkorb(); + if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte(); try { history.replaceState(null, '', location.pathname + location.search + '#' + key); @@ -186,8 +206,8 @@ const APP_SETTING_FIELDS = { // Kapazität der Person — ein volles Band ist eine volle Woche. Die feste // Vorgabe 8 hatte zur Folge, dass ein roter Balken (ab 1,0) nur zu einem // Achtel gefüllt war; Balken und Ampel widersprachen einander. - setReferenzHonorar: 'referenzHonorar', - setVerteilWochen: 'verteilzeitraumWochen', + // Referenzhonorar und Verteilzeitraum sind entfallen: Die Auslastung + // rechnet nur noch mit Aufgaben-Stunden (js/load/load.js). }, termine: { setStundenProTag: 'arbeitsstundenProTag', @@ -213,49 +233,6 @@ function renderAppSettingFields(){ }); const autoLock = document.getElementById('setAutoLock'); if(autoLock) autoLock.checked = !!Number(APP_SETTINGS.autoLockAktiv); - renderAufwandMatrix(); -} - -// renderAufwandMatrix: die firmenweite Gewichtung als Tabelle Leistungsphase × -// Rolle. „W+M" fehlt bewusst — das Aufwandsmodell kennt nur die -// Leistungsphasen (AUFWAND_LPS in js/load/load.js). Ein leeres Feld bedeutet -// „kein Eintrag"; dafür zählt die Auslastung dann den vollen Faktor 1,0. -// Keine Parameter, keine Rückgabe. -function renderAufwandMatrix(){ - const wrap = document.getElementById('setAufwandMatrix'); - if(!wrap) return; - const vorlage = APP_SETTINGS.aufwandVorlage || {}; - const phasen = LPHS.filter(l => l !== 'W+M'); - - wrap.innerHTML = ` - ${ROLES.map(r => ``).join('')} - ${phasen.map(l => ` - - ${ROLES.map(r => { - const v = vorlage[l] ? vorlage[l][r] : undefined; - return ``; - }).join('')} - `).join('')} -
Phase${esc(r)}
${esc(l)}
`; -} - -// collectAufwandMatrix: liest die Matrix aus den Feldern zurück. Leere Felder -// werden weggelassen (siehe renderAufwandMatrix), 0 ist ein gültiger Wert und -// bleibt erhalten. Rückgabe: Objekt LPH -> { Rolle: Faktor }. -function collectAufwandMatrix(){ - const wrap = document.getElementById('setAufwandMatrix'); - const out = {}; - if(!wrap) return out; - wrap.querySelectorAll('input[data-lph]').forEach(inp => { - if(inp.value.trim() === '') return; - const v = parseFloat(inp.value); - if(!Number.isFinite(v)) return; - const lph = inp.dataset.lph; - (out[lph] = out[lph] || {})[inp.dataset.role] = v; - }); - return out; } // putAppSettings: schickt einen Teil-Satz an PUT /app-settings und übernimmt @@ -294,11 +271,9 @@ function fieldValues(gruppe){ return patch; } -// saveAuslastungSettings: Schwellen, Skala, Vollzeit-Stunden und die -// Aufwandsvorlage in einem Zug. Keine Parameter, keine Rückgabe. +// saveAuslastungSettings: Schwellen und Vollzeit-Stunden in einem Zug. Keine Parameter, keine Rückgabe. function saveAuslastungSettings(){ const patch = fieldValues('auslastung'); - patch.aufwandVorlage = collectAufwandMatrix(); putAppSettings(patch, 'Auslastungs-Einstellungen gespeichert.'); } @@ -365,7 +340,7 @@ function renderAdminSitzungen(){ aktionen = ``; } else if(w.locked){ chip = 'Gesperrt'; - aktionen = ``; + aktionen = hatRecht('s.entsperren') ? `` : ''; } else { chip = 'Entsperrt'; aktionen = ``; @@ -375,7 +350,7 @@ function renderAdminSitzungen(){ ${esc(w.label)}${datum ? ' · ' + esc(datum) : ''} ${chip} ${aktionen} - + ${hatRecht('s.loeschen') ? `` : ''}
`; }).join(''); } @@ -609,17 +584,31 @@ function deleteHoliday(date){ } // ---------- Abschnitt Aufgaben-Vorlagen ---------- +// +// Vorlagen gibt es je Gewerk und Leistungsphase (Eriks Wunsch). Oben wählt +// man das Gewerk (Umschalter) und die Leistungsphase, darunter steht die +// Liste der Schritte: Bezeichnung, Start und Dauer direkt bearbeitbar +// (gespeichert beim Verlassen des Feldes, PATCH /lph-templates/:id), +// Reihenfolge über ↑/↓ (POST …/verschieben), Löschen über ×. +// Vorlagen ohne Gewerk (Altbestand) stehen unter jedem Gewerk mit dem +// Vermerk „alle Gewerke“. + +let adminTemplates = []; +let adminTemplateGewerk = null; // loadAdminTemplates: holt alle Vorlagen-Schritte (GET /lph-templates) und -// rendert die Liste der gerade gewählten Leistungsphase. -// Keine Parameter, keine Rückgabe. +// rendert die Liste. Keine Parameter, keine Rückgabe. function loadAdminTemplates(){ const select = document.getElementById('adminTemplateLph'); if(select && !select.options.length){ select.innerHTML = LPHS.map(l => ``).join(''); } + if(!adminTemplateGewerk){ + const eigen = currentUser && Object.keys(DEPARTMENTS).includes(currentUser.department) ? currentUser.department : ''; + adminTemplateGewerk = eigen || Object.keys(DEPARTMENTS)[0]; + } const listEl = document.getElementById('templateAdminList'); - if(listEl) listEl.innerHTML = 'Lade Vorlagen…'; + if(listEl && !adminTemplates.length) listEl.innerHTML = 'Lade Vorlagen…'; fetch(baseUrl() + '/lph-templates') .then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); }) @@ -633,33 +622,74 @@ function loadAdminTemplates(){ }); } -// renderAdminTemplates: zeigt die Schritte der gewählten Leistungsphase in -// ihrer gepflegten Reihenfolge, je Zeile mit Versatz/Dauer und Lösch-Knopf. -// Wird auch vom onchange der Leistungsphasen-Auswahl gerufen. -// Keine Parameter, keine Rückgabe. -function renderAdminTemplates(){ - const listEl = document.getElementById('templateAdminList'); - if(!listEl) return; - const select = document.getElementById('adminTemplateLph'); - const lph = select ? select.value : ''; - const entries = adminTemplates.filter(t => t.lph === lph); - - listEl.innerHTML = entries.length === 0 - ? 'Für diese Leistungsphase ist noch kein Schritt hinterlegt.' - : entries.map(t => `
- ${esc(t.title)} - Start +${esc(t.offsetDays)} Tage · Dauer ${esc(t.durationDays)} Tage - -
`).join(''); +// vorlagenAnteilText: Anzeige der Spalte „Anteil %“ — Zahl mit Komma oder +// „in a)“ / „in a) + b)“ (Siemon: in anderen Schritten enthalten). +function vorlagenAnteilText(t){ + if(t.enthaltenIn) return 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + '); + return t.anteil ? String(t.anteil).replace('.', ',') : ''; } -// addTemplateEntry: legt einen Schritt für die gewählte Leistungsphase an -// (POST /lph-templates, Admin-only). Keine Parameter, keine Rückgabe. +// waehleAdminTemplateGewerk: Umschalter Gewerk. Parameter: g. Keine Rückgabe. +function waehleAdminTemplateGewerk(g){ + adminTemplateGewerk = g; + renderAdminTemplates(); +} + +// renderAdminTemplates: Gewerk-Umschalter (mit Anzahl je Gewerk), die +// Anzahl je Leistungsphase in der Auswahl und die Schritte der gewählten +// Kombination. Keine Parameter, keine Rückgabe. +function renderAdminTemplates(){ + const g = adminTemplateGewerk; + const gewEl = document.getElementById('adminTemplateGewerk'); + if(gewEl){ + gewEl.innerHTML = Object.keys(DEPARTMENTS).map(d => { + const n = adminTemplates.filter(t => t.gewerk === d).length; + return ``; + }).join(''); + } + const select = document.getElementById('adminTemplateLph'); + const lph = select ? select.value : ''; + if(select){ + Array.from(select.options).forEach(o => { + const n = adminTemplates.filter(t => t.lph === o.value && t.gewerk === g).length; + o.textContent = o.value + (n ? ' (' + n + ')' : ''); + }); + } + const listEl = document.getElementById('templateAdminList'); + if(!listEl) return; + const eigene = adminTemplates.filter(t => t.lph === lph && t.gewerk === g); + const alle = adminTemplates.filter(t => t.lph === lph && !t.gewerk); + + const zeile = (t, i, n) => `
+ + + + + + ${n !== null ? ` + ` : 'alle Gewerke'} + + +
`; + + // Summe der Anteile: Beim Einfügen zählt das Verhältnis (der Anteil eines + // Schritts an der Summe der Phase) — die Summe ist nur zur Kontrolle da. + const summe = Math.round([...eigene, ...alle].reduce((a, t) => a + (Number(t.anteil) || 0), 0) * 100) / 100; + listEl.innerHTML = (eigene.length || alle.length) + ? eigene.map((t, i) => zeile(t, i, eigene.length)).join('') + alle.map(t => zeile(t, 0, null)).join('') + + `
Summe der Anteile in ${esc(lph)}: ${summe ? String(summe).replace('.', ',') + ' %' : 'keine hinterlegt'}${summe ? '' : ' — Schritte ohne Anteil werden mit „Start/Dauer“ bzw. ohne Termin eingefügt.'}
` + : `Für ${esc(g)} in ${esc(lph)} ist noch kein Schritt hinterlegt.`; +} + +// addTemplateEntry: legt einen Schritt für das gewählte Gewerk und die +// gewählte Leistungsphase an (POST /lph-templates). Keine Parameter. function addTemplateEntry(){ const select = document.getElementById('adminTemplateLph'); const titleEl = document.getElementById('templateNewTitle'); const offsetEl = document.getElementById('templateNewOffset'); const durationEl = document.getElementById('templateNewDuration'); + const anteilEl = document.getElementById('templateNewAnteil'); const title = titleEl ? titleEl.value.trim() : ''; if(!title){ alert('Bitte eine Bezeichnung für den Schritt eingeben.'); return; } @@ -668,15 +698,19 @@ function addTemplateEntry(){ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ lph: select ? select.value : '', + gewerk: adminTemplateGewerk, title, offsetDays: offsetEl ? parseInt(offsetEl.value, 10) || 0 : 0, - durationDays: durationEl ? parseInt(durationEl.value, 10) || 1 : 1, + // leer oder 0 = ohne Termin + durationDays: durationEl ? parseInt(durationEl.value, 10) || 0 : 0, + anteil: anteilEl ? anteilEl.value : '', }) }) .then(r => r.json().then(json => ({ ok: r.ok, json }))) .then(({ ok, json }) => { if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.'); - if(titleEl) titleEl.value = ''; + if(titleEl){ titleEl.value = ''; titleEl.focus(); } + if(anteilEl) anteilEl.value = ''; loadAdminTemplates(); showAdminNote('Schritt angelegt.'); }) @@ -686,6 +720,50 @@ function addTemplateEntry(){ }); } +// aendereTemplateEntry: speichert ein geändertes Feld eines Schritts +// (PATCH /lph-templates/:id). Parameter: id, feld, wert. Keine Rückgabe. +function aendereTemplateEntry(id, feld, wert){ + const body = {}; + body[feld] = (feld === 'title' || feld === 'anteil') ? wert : parseInt(wert, 10); + fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(body) + }) + .then(r => r.json().then(json => ({ ok: r.ok, json }))) + .then(({ ok, json }) => { + if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.'); + const t = adminTemplates.find(x => x.id === id); + if(t) Object.assign(t, json.template); + renderAdminTemplates(); + showAdminNote('Gespeichert.'); + }) + .catch(err => { + console.error('Vorlagen-Schritt ändern fehlgeschlagen:', err); + alert('Änderung konnte nicht gespeichert werden: ' + (err.message || err)); + loadAdminTemplates(); + }); +} + +// verschiebeTemplateEntry: Schritt eine Stelle nach oben/unten. +// Parameter: id, richtung (-1/1). Keine Rückgabe. +function verschiebeTemplateEntry(id, richtung){ + fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id) + '/verschieben', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ richtung }) + }) + .then(r => r.json().then(json => ({ ok: r.ok, json }))) + .then(({ ok, json }) => { + if(!ok || !json.ok) throw new Error((json && json.error) || 'Verschieben fehlgeschlagen.'); + loadAdminTemplates(); + }) + .catch(err => { + console.error('Vorlagen-Schritt verschieben fehlgeschlagen:', err); + alert('Schritt konnte nicht verschoben werden: ' + (err.message || err)); + }); +} + // deleteTemplateEntry: löscht einen Vorlagen-Schritt (DELETE, Admin-only). // Bereits in Projekte eingefügte Aufgaben bleiben unberührt — sie sind nach // dem Einfügen ganz normale plan_todos ohne Verbindung zur Vorlage. @@ -706,7 +784,7 @@ function deleteTemplateEntry(id){ // ---------- Abschnitt Plan-Checker-Merkmale ---------- // -// Dieselbe add/delete-only Liste wie die Aufgaben-Vorlagen oben, nur ohne +// Eine add/delete-only Liste wie früher die Aufgaben-Vorlagen oben, ohne // Gruppierung (die Merkmale gelten unterschiedslos für jede Prüfung) — siehe // plan_checker_merkmale/GET+POST+DELETE /plan-checker-merkmale, server.js. // Die Liste selbst nutzt auch der Plan Checker (js/planchecker/planchecker.js), diff --git a/public/js/admin/rollen.js b/public/js/admin/rollen.js new file mode 100644 index 0000000..3cf832f --- /dev/null +++ b/public/js/admin/rollen.js @@ -0,0 +1,209 @@ +// ----------------------------- +// Admin — Rollen & Rechte +// ----------------------------- +// Eriks Wahl „B“ aus dem Entwurfsvergleich: links die Rollen (Anzahl Konten, +// Anteil der Rechte), darunter „+ Neue Rolle“; rechts die gewählte Rolle mit +// einem Schalter je Recht, nach Gruppen, und „alle/keine“ je Gruppe. Über +// „Vergleich mit“ steht neben jedem Schalter, ob eine zweite Rolle das Recht +// hat (Abweichungen hervorgehoben). +// +// Daten und Katalog kommen vom Server (GET /rollen, server.js „Rollen & +// Rechte: Routen“); jede Änderung geht sofort hin (PUT /rollen/:key/rechte, +// POST/PATCH/DELETE /rollen) und die Antwort ersetzt den Stand hier. Kein +// eigenes Speichern. Ändert jemand seine eigene Rolle, lädt sich /whoami neu, +// damit Seitenleiste und Knöpfe sofort stimmen. + +let rrDaten = null; // { katalog:[{gruppe, rechte:[{key,name,hinweis}]}], rollen:[…] } +let rrAuswahl = 'member'; // gewählte Rolle +let rrVergleich = ''; // Vergleichsrolle ('' = keiner) +let rrNeuOffen = false; // Formular „+ Neue Rolle“ aufgeklappt +let rrMeldung = ''; +let rrMeldungTimer = null; + +// renderRollenRechte: Einstieg beim Öffnen des Abschnitts (showAdminSection). +function renderRollenRechte(){ + const el = document.getElementById('rollenRechte'); + if(!el) return; + if(!rrDaten) el.innerHTML = 'Lade Rollen…'; + fetch(baseUrl() + '/rollen') + .then(r => r.json()) + .then(j => { if(!j.ok) throw new Error(j.error || 'Laden fehlgeschlagen'); rrUebernehmen(j); }) + .catch(err => { el.innerHTML = `Rollen konnten nicht geladen werden: ${esc(err.message || err)}`; }); +} + +// rrUebernehmen: neue Serverantwort übernehmen und neu zeichnen. +function rrUebernehmen(j){ + rrDaten = j; + if(!j.rollen.some(r => r.key === rrAuswahl)) rrAuswahl = (j.rollen.find(r => !r.fest) || j.rollen[0] || {}).key; + if(rrVergleich && !j.rollen.some(r => r.key === rrVergleich)) rrVergleich = ''; + rrZeichnen(); +} + +function rrMelden(text){ + rrMeldung = text; + const el = document.getElementById('rrMeldung'); + if(el) el.textContent = text; + clearTimeout(rrMeldungTimer); + rrMeldungTimer = setTimeout(() => { rrMeldung = ''; const e = document.getElementById('rrMeldung'); if(e) e.textContent = ''; }, 2500); +} + +// rrSenden: gemeinsamer Weg aller Änderungen. Parameter: methode, pfad, +// body, okText. Rückgabe: Promise der Serverantwort (oder null bei Fehler). +function rrSenden(methode, pfad, body, okText){ + return fetch(baseUrl() + pfad, { + method: methode, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: body ? JSON.stringify(body) : undefined + }) + .then(r => r.json().then(j => ({ ok: r.ok, j }))) + .then(({ ok, j }) => { + if(!ok || !j.ok) throw new Error((j && j.error) || 'Fehlgeschlagen'); + rrUebernehmen(j); + if(okText) rrMelden(okText); + rrEigeneRechteAuffrischen(); + return j; + }) + .catch(err => { rrMelden(err.message || String(err)); rrZeichnen(); return null; }); +} + +// rrEigeneRechteAuffrischen: betrifft eine Änderung die eigene Rolle, holt +// /whoami die neuen Rechte, damit Seitenleiste und Knöpfe gleich stimmen. +function rrEigeneRechteAuffrischen(){ + fetch(baseUrl() + '/whoami').then(r => r.json()).then(j => { + if(j && j.user && currentUser && j.user.id === currentUser.id){ + currentUser = j.user; + if(typeof renderUserBar === 'function') renderUserBar(); + } + }).catch(() => {}); +} + +const rrRolle = key => rrDaten && rrDaten.rollen.find(r => r.key === key); +const rrAlleRechte = () => rrDaten.katalog.flatMap(g => g.rechte.map(r => r.key)); + +// Aktionen (aus den Inline-Handlern) +function rrWaehlen(key){ rrAuswahl = key; rrLoeschenBestaetigen = ''; rrZeichnen(); } +function rrVergleichSetzen(key){ rrVergleich = key; rrZeichnen(); } +function rrNeuUmschalten(offen){ + rrNeuOffen = offen; rrZeichnen(); + if(offen){ const i = document.getElementById('rrNeuName'); if(i) i.focus(); } +} +function rrRechtSchalten(key, recht){ + const r = rrRolle(key); if(!r || r.fest) return; + const an = !r.rechte.includes(recht); + rrSenden('PUT', '/rollen/' + encodeURIComponent(key) + '/rechte', { rechte: [recht], an }, 'Gespeichert'); +} +function rrGruppeSchalten(key, gi){ + const r = rrRolle(key); if(!r || r.fest) return; + const keys = rrDaten.katalog[gi].rechte.map(x => x.key); + const an = !keys.every(k => r.rechte.includes(k)); + rrSenden('PUT', '/rollen/' + encodeURIComponent(key) + '/rechte', { rechte: keys, an }, 'Gespeichert'); +} +function rrAnlegen(){ + const name = (document.getElementById('rrNeuName') || {}).value || ''; + const von = (document.getElementById('rrNeuVon') || {}).value || ''; + if(!name.trim()){ rrMelden('Bitte einen Namen eingeben.'); return; } + rrSenden('POST', '/rollen', { name: name.trim(), vonRolle: von }, 'Rolle „' + name.trim() + '“ angelegt.') + .then(j => { if(j && j.neu){ rrAuswahl = j.neu; rrNeuOffen = false; rrZeichnen(); } }); +} +function rrUmbenennen(key, name){ + const r = rrRolle(key); if(!r || r.fest || !name.trim() || name.trim() === r.name) return; + rrSenden('PATCH', '/rollen/' + encodeURIComponent(key), { name: name.trim() }, 'Umbenannt.'); +} +function rrKopieren(key){ + const r = rrRolle(key); if(!r) return; + let name = r.name + ' (Kopie)', i = 2; + while(rrDaten.rollen.some(x => x.name.toLowerCase() === name.toLowerCase())) name = r.name + ' (Kopie ' + (i++) + ')'; + rrSenden('POST', '/rollen', { name, vonRolle: key }, 'Kopie angelegt.') + .then(j => { if(j && j.neu){ rrAuswahl = j.neu; rrZeichnen(); } }); +} +// rrLoeschen: zweistufig im Knopf selbst (kein confirm()) — erster Klick +// fragt nach, zweiter löscht. +let rrLoeschenBestaetigen = ''; +function rrLoeschen(key){ + if(rrLoeschenBestaetigen !== key){ rrLoeschenBestaetigen = key; rrZeichnen(); return; } + rrLoeschenBestaetigen = ''; + rrSenden('DELETE', '/rollen/' + encodeURIComponent(key), null, 'Rolle gelöscht.'); +} + +// rrZeichnen: baut die ganze Maske. Fokus und Eingaben im Formular „Neue +// Rolle“ bleiben erhalten. +function rrZeichnen(){ + const el = document.getElementById('rollenRechte'); + if(!el || !rrDaten) return; + const fokusId = document.activeElement && document.activeElement.id; + const neuName = (document.getElementById('rrNeuName') || {}).value || ''; + const alle = rrAlleRechte(); + const r = rrRolle(rrAuswahl) || rrDaten.rollen[0]; + const v = rrVergleich && rrVergleich !== r.key ? rrRolle(rrVergleich) : null; + const rechteVon = x => x.fest ? alle : x.rechte; + + const liste = rrDaten.rollen.map(x => { + const n = rechteVon(x).length; + return ``; + }).join(''); + + const neu = rrNeuOffen + ? `
+ + +
+ + +
+
` + : ``; + + const gruppen = rrDaten.katalog.map((g, gi) => { + const rechteR = rechteVon(r); + const n = g.rechte.filter(x => rechteR.includes(x.key)).length; + const zeilen = g.rechte.map(x => { + const an = rechteR.includes(x.key); + const vAn = v ? rechteVon(v).includes(x.key) : null; + return `
+ + ${esc(x.name)}${x.hinweis ? `${esc(x.hinweis)}` : ''} + ${v ? `${vAn ? 'ja' : 'nein'}` : ''} +
`; + }).join(''); + return `
+
${esc(g.gruppe)}${n} von ${g.rechte.length} + ${r.fest ? '' : ``} +
+ ${zeilen} +
`; + }).join(''); + + const loeschText = rrLoeschenBestaetigen === r.key ? 'Wirklich löschen?' : 'Löschen'; + el.innerHTML = `
+
${liste}${neu}
+
+
+ + + ${r.fest ? 'Administrator hat immer alle Rechte.' + : ` + `} + ${esc(rrMeldung)} +
+
${gruppen}
+
+
`; + + const ni = document.getElementById('rrNeuName'); + if(ni && neuName) ni.value = neuName; + if(fokusId){ const f = document.getElementById(fokusId); if(f) f.focus(); } +} diff --git a/public/js/auftrag.js b/public/js/auftrag.js index c8cc673..012fcb5 100644 --- a/public/js/auftrag.js +++ b/public/js/auftrag.js @@ -202,9 +202,7 @@ function openBeauftragungModal(pid){ } // Gruppenwechsel nur anbieten, wenn das Projekt noch in "In Anbahnung" - // steht: Diese Gruppe wird von der Auslastungsrechnung ausdrücklich NICHT - // eingerechnet (berechneAuslastung(), js/load/load.js). Bliebe ein - // beauftragtes Projekt dort liegen, zählte die Arbeit daran nirgends mit. + // steht: Ein beauftragtes Projekt gehört nicht mehr in die Anbahnung. const gruppeWrap = document.getElementById('beauftragungGruppeWrap'); const gruppeCheck = document.getElementById('beauftragungGruppeCheck'); if(gruppeWrap) gruppeWrap.style.display = (p.group === 'In Anbahnung') ? '' : 'none'; diff --git a/public/js/auth.js b/public/js/auth.js index 8d378fd..51ce62b 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -19,25 +19,30 @@ // zeigte „Passwortwechsel erforderlich" an, erfüllen konnte ihn niemand. // currentUser: der aktuell angemeldete Benutzer ({id, username, displayName, -// email, role, personId, mustChangePassword}) oder null vor dem Login. +// email, role, rolleName, rechte, personId, mustChangePassword}) oder null +// vor dem Login. let currentUser = null; // renderUserBar: zeigt Namen + Rolle des angemeldeten Benutzers im Menueband // an (Element #userBarInfo) und blendet den gesamten Admin-Bereich der -// Seitenleiste (#navAdminSection) nur fuer Administratoren ein. Dieser -// Bereich fuehrt zu allen Admin-Aufgaben — Benutzerverwaltung, firmenweite -// Vorgaben, Datenbank und Sitzungen. Es ist ein reines UI-Gate; die -// eigentliche Sperre sitzt serverseitig in requireRole('admin') auf den -// zugehoerigen Routen, und showTab() leitet Nicht-Administratoren zusaetzlich -// vom Bereich „Admin" weg (js/tabs-auth.js). +// Seitenleiste (#navAdminSection) nur bei mindestens einem Admin-Recht ein +// (Rollen & Rechte). Es ist ein reines UI-Gate; die eigentliche Sperre sitzt +// serverseitig in requireRecht() auf den zugehoerigen Routen, und showTab() +// leitet ohne Recht zusaetzlich vom Bereich „Admin" weg (js/tabs-auth.js). function renderUserBar(){ const info = document.getElementById('userBarInfo'); if(info && currentUser){ - const roleLabel = systemRolleText(currentUser.role); + const roleLabel = currentUser.rolleName || systemRolleText(currentUser.role); info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')'; } const navAdminSection = document.getElementById('navAdminSection'); - if(navAdminSection) navAdminSection.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none'; + if(navAdminSection) navAdminSection.style.display = hatAdminRecht() ? '' : 'none'; + // Wandtafel-Eintrag nur mit Recht „Wandtafel anzeigen“ + document.querySelectorAll('[data-tab="wand"], .nur-wandtafel-recht').forEach(el => { el.hidden = !hatRecht('wand'); }); + if(typeof adminNavAbgleichen === 'function') adminNavAbgleichen(); + // Allgemeines UI-Gate: Elemente mit class="nur-recht" data-recht="…" + // data-recht="a|b": eines der Rechte genügt. + document.querySelectorAll('.nur-recht[data-recht]').forEach(el => { el.hidden = !el.dataset.recht.split('|').some(r => hatRecht(r)); }); document.querySelectorAll('.nur-projektleitung').forEach(el => { el.hidden = !darfProjektlisteAendern(); }); } @@ -50,13 +55,26 @@ function systemRolleText(role){ } // darfProjektlisteAendern: ob das angemeldete Konto Projekte anlegen und aus -// der Projektliste löschen darf (Projektleiter und Administratoren). Reines +// der Projektliste löschen darf (Recht „Projekte anlegen und löschen“). Reines // UI-Gate — dieselbe Regel setzt /save-week serverseitig durch // (darfProjektlisteAendern() in server.js), dort mit 403. Knöpfe zum // Anlegen tragen die Klasse .nur-projektleitung und werden von // renderUserBar() ausgeblendet. function darfProjektlisteAendern(){ - return !!currentUser && (currentUser.role === 'admin' || currentUser.role === 'projektleiter'); + return hatRecht('proj.anlegen'); +} + +// hatRecht: ob die Rolle des angemeldeten Kontos dieses Recht hat (Admin → +// Rollen & Rechte). /whoami liefert die Liste mit (currentUser.rechte). +// Reines UI-Gate — die Grenze ziehen requireRecht()/hatRecht() im Server. +function hatRecht(recht){ + return !!currentUser && Array.isArray(currentUser.rechte) && currentUser.rechte.includes(recht); +} + +// hatAdminRecht: mindestens ein Recht aus dem Admin-Bereich (a.*) — dann +// erscheint „Admin“ in der Seitenleiste, mit den erlaubten Abschnitten. +function hatAdminRecht(){ + return !!currentUser && Array.isArray(currentUser.rechte) && currentUser.rechte.some(r => r.startsWith('a.')); } // logout: beendet die Server-Session und lädt die Seite neu (zeigt danach @@ -138,6 +156,10 @@ function afterAuthenticated(onAuthenticated){ renderUserBar(); onAuthenticated(); if(currentUser && currentUser.mustChangePassword) openPasswordModal(true); + // Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind + // und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst + // danach. + else if(typeof einfuehrungBeiBedarf === 'function') einfuehrungBeiBedarf(); } // ---------- Eigenes Passwort ändern (#passwordModal) ---------- @@ -218,6 +240,7 @@ async function submitPasswordChange(ev){ // Der Bereich „Einstellungen" zeigt die Kennzeichnung mit an. if(typeof renderSettings === 'function' && typeof currentTab !== 'undefined' && currentTab === 'settings') renderSettings(); alert('Das Passwort wurde geändert.'); + if(typeof einfuehrungBeiBedarf === 'function') einfuehrungBeiBedarf(); } // checkAuthAndInit: prüft per GET /whoami, ob eine gültige Session besteht. diff --git a/public/js/benachrichtigungen.js b/public/js/benachrichtigungen.js new file mode 100644 index 0000000..3f6f4e0 --- /dev/null +++ b/public/js/benachrichtigungen.js @@ -0,0 +1,209 @@ +// ----------------------------- +// Projektübersicht — Benachrichtigungen (Glocke im Menüband + Dashboard) +// ----------------------------- +// Eriks Wunsch: Wer eine neue Aufgabe zugewiesen bekommt, soll das in der +// Übersicht sehen. Der Server legt die Einträge beim Speichern einer Sitzung +// an (neueZuweisungenMelden(), server.js); dieses Modul zeigt sie: +// +// - die GLOCKE im Menüband (#glockeBtn) mit der Zahl der ungelesenen, ein +// Klick öffnet die Liste (#glockeMenue). Ein Eintrag führt in den +// Bereich "Aufgaben" seines Projekts und gilt damit als gelesen. +// - auf "Meine Aufgaben" (#dashNeu, js/dashboard/dashboard.js) die +// ungelesenen als eigener Block "Neu zugewiesen". +// +// Abgefragt wird beim Laden der Daten (renderAll()) und danach jede Minute — +// eine Zuweisung durch einen Kollegen erscheint also spätestens nach einer +// Minute, ohne neu zu laden. Kiosk-Konten fragen nicht (der Server würde sie +// ohnehin mit 403 abweisen). +// +// Nutzt baseUrl() (js/server.js), currentUser (js/auth.js), esc()/ +// formatWeekDate() (js/utils.js), currentWeek(), openProjectPlan() und +// openSandboxPlan(). + +let benachrichtigungen = { ungelesen: 0, eintraege: [] }; +let benachrichtigungenZuletzt = 0; +const BENACHRICHTIGUNG_INTERVALL_MS = 60 * 1000; + +// ladeBenachrichtigungen: holt GET /benachrichtigungen und zeichnet Glocke, +// offene Liste und Dashboard-Block neu. Mehrfachaufrufe innerhalb von fünf +// Sekunden werden zusammengefasst (renderAll() läuft oft mehrmals hintereinander). +// Parameter: erzwingen (Boolean, optional). Keine Rückgabe. +async function ladeBenachrichtigungen(erzwingen){ + if(!currentUser || !hatRecht('app')) return; + if(!erzwingen && Date.now() - benachrichtigungenZuletzt < 5000) return; + benachrichtigungenZuletzt = Date.now(); + // Die Glocke erscheint für jedes angemeldete Konto, auch wenn der Abruf + // scheitert (etwa ein noch nicht neu gestarteter Server ohne die Route) — + // sie dann still auszublenden, sah aus, als fehle die Funktion ganz. + try { + const res = await fetch(baseUrl() + '/benachrichtigungen'); + const data = await res.json().catch(() => null); + if(res.ok && data && data.ok){ + benachrichtigungen = { ungelesen: data.ungelesen || 0, eintraege: Array.isArray(data.eintraege) ? data.eintraege : [] }; + } + } catch(e){ /* still — die Glocke ist kein Grund für eine Fehlermeldung */ } + renderGlocke(); + if(glockeOffen()) renderGlockeMenue(); + renderDashNeu(); +} +setInterval(() => ladeBenachrichtigungen(true), BENACHRICHTIGUNG_INTERVALL_MS); + +// renderGlocke: Zahl am Glockensymbol (ausgeblendet bei 0). +function renderGlocke(){ + const btn = document.getElementById('glockeBtn'); + const zahl = document.getElementById('glockeZahl'); + if(!btn || !zahl) return; + btn.hidden = !currentUser || !hatRecht('app'); + const n = benachrichtigungen.ungelesen; + zahl.textContent = n > 9 ? '9+' : String(n); + zahl.hidden = n === 0; + btn.title = n ? n + (n === 1 ? ' neue Benachrichtigung' : ' neue Benachrichtigungen') : 'Benachrichtigungen'; + btn.classList.toggle('hat-neue', n > 0); +} + +function glockeOffen(){ + const m = document.getElementById('glockeMenue'); + return !!(m && !m.hidden); +} + +// toggleGlocke: Liste öffnen/schließen. Beim Öffnen frisch laden. +function toggleGlocke(){ + const m = document.getElementById('glockeMenue'); + const btn = document.getElementById('glockeBtn'); + if(!m) return; + if(glockeOffen()){ schliesseGlocke(); return; } + m.hidden = false; + if(btn) btn.setAttribute('aria-expanded', 'true'); + renderGlockeMenue(); + ladeBenachrichtigungen(true); + setTimeout(() => { + document.addEventListener('click', glockeAussenKlick); + document.addEventListener('keydown', glockeEscape); + }, 0); +} +function schliesseGlocke(){ + const m = document.getElementById('glockeMenue'); + const btn = document.getElementById('glockeBtn'); + if(m) m.hidden = true; + if(btn) btn.setAttribute('aria-expanded', 'false'); + document.removeEventListener('click', glockeAussenKlick); + document.removeEventListener('keydown', glockeEscape); +} +function glockeAussenKlick(e){ + // composedPath() statt e.target.closest(): Ein Klick auf „ד oder „Alle + // löschen“ zeichnet die Liste neu, bevor dieser Lauscher läuft — das + // angeklickte Element hängt dann nicht mehr im Menü, closest() fände es + // nicht, und das Fenster ginge zu. Der Pfad wird beim Auslösen festgehalten. + const pfad = e.composedPath ? e.composedPath() : []; + const drin = el => el && (pfad.includes(el) || e.target.closest && e.target.closest('#' + el.id)); + if(drin(document.getElementById('glockeMenue')) || drin(document.getElementById('glockeBtn'))) return; + schliesseGlocke(); +} +function glockeEscape(e){ if(e.key === 'Escape') schliesseGlocke(); } + +// zeitVor: "gerade eben", "vor 5 Min.", "vor 3 Std.", sonst das Datum. +function zeitVor(iso){ + const t = Date.parse(iso); + if(isNaN(t)) return ''; + const min = Math.round((Date.now() - t) / 60000); + if(min < 1) return 'gerade eben'; + if(min < 60) return 'vor ' + min + ' Min.'; + const std = Math.round(min / 60); + if(std < 24) return 'vor ' + std + ' Std.'; + return new Date(t).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); +} + +// benachrichtigungZeile: eine Zeile für Glocke und Dashboard. +function benachrichtigungZeile(b){ + const projekt = (b.projectNr ? b.projectNr + ' ' : '') + (b.projectName || ''); + const faellig = b.faellig ? ' · fällig ' + esc(formatWeekDate(b.faellig)) : ''; + // Zeile = Öffnen-Knopf + Löschen-Knopf daneben (Knöpfe dürfen nicht + // ineinander stecken). + return '
' + + '' + + '' + + '
'; +} + +// renderGlockeMenue: die Liste in der Glocke. +function renderGlockeMenue(){ + const m = document.getElementById('glockeMenue'); + if(!m) return; + const e = benachrichtigungen.eintraege; + m.innerHTML = '
Benachrichtigungen' + + (benachrichtigungen.ungelesen ? '' : '') + + (e.length ? '' : '') + + '
' + + (e.length ? '
' + e.map(benachrichtigungZeile).join('') + '
' + : '
Keine Benachrichtigungen. Neue Aufgaben, die Ihnen jemand zuweist, erscheinen hier.
'); +} + +// renderDashNeu: Block "Neu zugewiesen" auf dem Dashboard — nur, solange es +// Ungelesenes gibt; sonst bleibt der Platz leer. +function renderDashNeu(){ + const el = document.getElementById('dashNeu'); + if(!el) return; + const neu = benachrichtigungen.eintraege.filter(b => !b.gelesen); + if(!neu.length){ el.innerHTML = ''; return; } + el.innerHTML = '
' + + '

Neu zugewiesen

' + neu.length + '' + + '
' + + '
' + neu.slice(0, 6).map(benachrichtigungZeile).join('') + '
'; +} + +// markiereGelesen: lokal und am Server. Parameter: ids (Array) oder null = alle. +async function markiereGelesen(ids){ + benachrichtigungen.eintraege.forEach(b => { if(!ids || ids.includes(b.id)) b.gelesen = true; }); + benachrichtigungen.ungelesen = benachrichtigungen.eintraege.filter(b => !b.gelesen).length; + renderGlocke(); if(glockeOffen()) renderGlockeMenue(); renderDashNeu(); + try { + await fetch(baseUrl() + '/benachrichtigungen/gelesen', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ids ? { ids } : { alle: true }), + }); + } catch(e){ /* beim nächsten Abruf stimmt der Stand wieder */ } + ladeBenachrichtigungen(true); +} +function alleBenachrichtigungenGelesen(){ markiereGelesen(null); } + +// loescheBenachrichtigungen: entfernt Einträge lokal sofort und am Server +// endgültig. Parameter: ids (Array) oder null = alle. Keine Rückgabe. +async function loescheBenachrichtigungen(ids){ + benachrichtigungen.eintraege = ids ? benachrichtigungen.eintraege.filter(b => !ids.includes(b.id)) : []; + benachrichtigungen.ungelesen = benachrichtigungen.eintraege.filter(b => !b.gelesen).length; + renderGlocke(); if(glockeOffen()) renderGlockeMenue(); renderDashNeu(); + try { + await fetch(baseUrl() + '/benachrichtigungen/loeschen', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ids ? { ids } : { alle: true }), + }); + } catch(e){ /* beim nächsten Abruf stimmt der Stand wieder */ } + ladeBenachrichtigungen(true); +} + +// oeffneBenachrichtigung: als gelesen markieren und in die Aufgaben des +// Projekts springen. Gesucht wird das Projekt in der AKTUELLEN Sitzung über +// die Projektnummer (die Projekt-ID wechselt mit jeder Sitzung); Terminpläne +// der Planung haben keine Nummer und werden über ihre ID geöffnet. +function oeffneBenachrichtigung(id){ + const b = benachrichtigungen.eintraege.find(x => x.id === id); + if(!b) return; + markiereGelesen([id]); + schliesseGlocke(); + if(b.weekLabel === SANDBOX_WEEK_LABEL){ + if(typeof openSandboxPlan === 'function'){ location.hash = 'aufgaben'; openSandboxPlan(b.projectId); } + return; + } + const w = typeof currentWeek === 'function' ? currentWeek() : null; + const p = w && (w.projects || []).find(x => (b.projectNr && x.nr === b.projectNr) || x.id === b.projectId) + || (w && (w.projects || []).find(x => x.name === b.projectName)); + if(!p) return; + location.hash = 'aufgaben'; + openProjectPlan(p.id); +} diff --git a/public/js/board/board.js b/public/js/board/board.js index 8747994..5ac7396 100644 --- a/public/js/board/board.js +++ b/public/js/board/board.js @@ -117,7 +117,7 @@ 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 => `${esc(t)}`).join(''); - const previewText = statusPreviewText(p.status); + const previewText = statusPreviewText(kommentarVon(p, kommentarGewerk()).status); const isNew = p.statusState === 'new'; // Avatar-Stapel der eingeklappten Zeile: nur tatsächlich zugeordnete @@ -186,21 +186,47 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){ // Wird bei jedem Aufbau der Schublade neu gesetzt. let boardCommentBaseline = {}; +// boardCommentUebernommen: je Projekt-ID, ob der gespeicherte Kommentar die +// unveränderte Übernahme aus der Vorwoche ist (Sitzungswechsel kopiert ihn, +// ensureCurrentWeekRollover() in server.js). Dann leert "Verwerfen" das Feld. +let boardCommentUebernommen = {}; + +// kommentarAusVorwocheUebernommen: true, wenn der Kommentar des gewählten +// Gewerks wortgleich und mit demselben Zeitstempel schon in einer früheren +// Sitzung steht — dort der jüngste nicht leere (die einmalige Übernahme der +// Einführung kam nicht immer aus der direkten Vorwoche). +// Parameter: p (Projekt), weekLabel (String). Rückgabe: Boolean. +function kommentarAusVorwocheUebernommen(p, weekLabel){ + const g = kommentarGewerk(); + if(!g || !p.projectUid) return false; + const jetzt = kommentarVon(p, g); + if(!(jetzt.status || '').trim()) return false; + const versionen = projectVersionsForUid(p.projectUid); + const idx = versionen.findIndex(v => v.week.label === weekLabel); + for(let i = (idx < 0 ? versionen.length : idx) - 1; i >= 0; i--){ + const k = kommentarVon(versionen[i].project, g); + if(!(k.status || '').trim()) continue; + return k.status === jetzt.status && (k.statusAt || '') === (jetzt.statusAt || ''); + } + return false; +} + // 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) !== ''; + const k = kommentarVon(p, kommentarGewerk()); + const hasText = statusPreviewText(k.status) !== ''; if(!hasText){ const me = (currentUser && currentUser.displayName) || ''; return me ? `${esc(initialsForName(me))}${esc(me)}·neuer Eintrag` : `neuer Eintrag`; } - const author = p.statusAuthor || ''; - const at = p.statusAt ? formatCommentStamp(p.statusAt) : ''; + const author = k.statusAuthor || ''; + const at = k.statusAt ? formatCommentStamp(k.statusAt) : ''; const who = author ? `${esc(initialsForName(author))}${esc(author)}` : `Verfasser unbekannt`; @@ -232,8 +258,10 @@ function formatCommentStamp(iso){ // Parameter: p (Projekt), weekLabel (String), locked (Boolean), // dicht (Boolean). Rückgabe: HTML-String. function renderCommentEditor(p, weekLabel, locked, dicht){ - const html = sanitizeStatusHtml(p.status || ''); + // Kommentar des gewählten Gewerks (Wochenbericht), siehe kommentarVon(). + const html = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || ''); boardCommentBaseline[p.id] = html; + boardCommentUebernommen[p.id] = kommentarAusVorwocheUebernommen(p, weekLabel); if(locked){ return `
@@ -367,18 +395,17 @@ function saveBoardComment(el){ const p = currentWeek().projects.find(x => x.id === c.pid); if(!p) return; - const before = (p.status || '').trim(); + // Geschrieben wird immer der Kommentar EINES Gewerks — der Editor steht + // nur im Wochenbericht, und der hat stets ein gewähltes Gewerk. + const gewerk = kommentarGewerk(); + if(!gewerk) return; + const before = (kommentarVon(p, gewerk).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(); - } + kommentarSetzen(p, gewerk, html === '' + ? { status: '', statusAuthor: '', statusAt: '' } + : { status: html, statusAuthor: (currentUser && currentUser.displayName) || '', statusAt: new Date().toISOString() }); boardCommentBaseline[c.pid] = html; save(); renderBoard(); @@ -393,7 +420,9 @@ function saveBoardComment(el){ function discardBoardComment(el){ const c = commentParts(el); if(!c || !c.field) return; - c.field.innerHTML = boardCommentBaseline[c.pid] || ''; + // Übernommener Vorwochen-Kommentar: Verwerfen leert das ganze Feld, damit + // der Kommentar dieser Woche neu geschrieben werden kann (Eriks Wunsch). + c.field.innerHTML = boardCommentUebernommen[c.pid] ? '' : (boardCommentBaseline[c.pid] || ''); markCommentDirty(c.field); richtextCursorAnsEnde(c.field); } @@ -408,6 +437,9 @@ function boardCommentKey(e, el){ } else if(e.key === 'Escape'){ discardBoardComment(el); e.preventDefault(); + } else { + // Enter: neue Zeile beginnt mit "- " (js/utils.js) + richtextNeueZeileMitStrich(e, el); } } @@ -441,15 +473,31 @@ function takeCommentFromHistory(el, weekLabel){ const entry = projectStatusHistory(p.projectUid).find(e => e.week.label === weekLabel); const html = entry ? entry.html - : (v => v ? sanitizeStatusHtml(v.project.status || '') : null)( + : (v => v ? sanitizeStatusHtml(kommentarVon(v.project, kommentarGewerk()).status || '') : null)( projectVersionsForUid(p.projectUid).find(v => v.week.label === weekLabel)); if(html === null || html === undefined) return; c.field.innerHTML = sanitizeStatusHtml(html); + kommentarVorsatzEntfernen(c.field); markCommentDirty(c.field); richtextCursorAnsEnde(c.field); } +// kommentarVorsatzEntfernen: nimmt beim Übernehmen den Gewerke-Vorsatz am +// Anfang weg ("[ELT] ", "[ELT+HLS] " — Altbestand aus der Zeit vor den +// Kommentaren je Gewerk); im Kommentar eines Gewerks ist er überflüssig. +// Arbeitet auf dem ersten Textknoten, damit Formatierung erhalten bleibt. +// Parameter: feld (Element). Keine Rückgabe. +function kommentarVorsatzEntfernen(feld){ + const walker = document.createTreeWalker(feld, NodeFilter.SHOW_TEXT); + let knoten; + while((knoten = walker.nextNode())){ + if(!knoten.textContent.trim()) continue; + knoten.textContent = knoten.textContent.replace(/^\s*\[[^\]]{1,20}\]\s*/, ''); + break; + } +} + // 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- @@ -574,7 +622,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){ ${!locked ? `` : ''}
- ${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''} + ${(opts.ohneVerlauf || typeof renderProjektVerlauf !== 'function') ? '' : renderProjektVerlauf(p, weekLabel, locked)}
`; } @@ -608,7 +656,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){ // 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 || ''); + const text = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || ''); return `
${text || 'Für diese Sitzung ist noch kein Kommentar erfasst.'}
`; } @@ -644,7 +692,8 @@ function renderBoard(){ if(container) container.innerHTML = '
Keine Sitzung geladen.
'; return; } - const locked = isWeekLocked(w); + // Ziehen zwischen Gruppen ändert Stammdaten (Rollen & Rechte). + const locked = isWeekLocked(w) || !hatRecht('proj.stamm'); const grouped = peopleByRole(w); const allPeople = []; ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry))); @@ -695,9 +744,22 @@ function renderBoard(){ function editLph(pid,lph,val){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); - val = val.trim(); + if(!p) return; + if(!p.lph) p.lph = {}; + val = String(val || '').trim(); if(val==="") delete p.lph[lph]; - else p.lph[lph]=val.replace('%',''); + else { + // Nur Zahlen von 0 bis 100 — "abc" oder "250" hätten Auslastung und + // Leistungsstand mit NaN bzw. über 100 % verrechnet. Ungültiges wird + // verworfen (Neuzeichnen stellt den alten Wert wieder her). + const n = Number(val.replace('%','').replace(',','.').trim()); + if(!Number.isFinite(n)){ + renderBoard(); + if(typeof renderWochenbericht === 'function') renderWochenbericht(); + return; + } + p.lph[lph] = String(Math.max(0, Math.min(100, Math.round(n)))); + } save(); renderBoard(); if(typeof renderWochenbericht === 'function') renderWochenbericht(); } diff --git a/public/js/dashboard/dashboard.js b/public/js/dashboard/dashboard.js index 7e9dc40..fce0cda 100644 --- a/public/js/dashboard/dashboard.js +++ b/public/js/dashboard/dashboard.js @@ -13,14 +13,22 @@ // 2. BAND Der Befund des Tages als SATZ, daneben vier Kennzahlen // (renderDashboardBand()). Ist nichts überfällig und nichts // heute fällig, bleibt das Band farblos. -// 3. LINKS Der eigene Arbeitsstrom: "Meine Aufgaben" (persönlich -// zugewiesene offene TODOs, als Zeilen — -// renderDashboardAufgabe()) und "Termine meiner Projekte" -// (Aufgaben und Meilensteine der Projekte, denen man zugeordnet -// ist, nach Dringlichkeit gegliedert — renderDashboardFristen()). -// 4. RECHTS Was man wissen muss, ohne selbst tätig zu werden: ausstehende -// Freigaben und Abwesenheiten der Kollegen, beide in derselben -// schmalen Zeilenform. +// 3. LINKS "Meine Projekte": je Projekt, dem man zugeordnet ist, ein +// Block mit der eigenen Rolle, der laufenden Leistungsphase und +// den offenen Terminen des Projekts — eigene Aufgaben +// hervorgehoben (renderDashProjekte()). Projekte, in denen man +// nur eine einzelne Aufgabe hat, stehen ebenfalls dort. +// 4. RECHTS Ein Monatskalender mit allen Terminen der eigenen Projekte, +// den eigenen Aufgaben, Feiertagen und den Abwesenheiten der +// Projektbeteiligten; ein Klick auf einen Tag listet ihn +// (renderDashKalender()). Darunter ausstehende Freigaben und +// Abwesenheiten. +// +// Projekte und Kalender rechnen mit der bereits geladenen Sitzung +// (currentWeek(), /load-all) statt mit GET /my-dashboard: Der Kalender soll +// beliebig weit blättern können, der Server liefert Projekttermine aber nur +// 21 Tage voraus. Kopf und Band bleiben bei den Zahlen des Servers. +// Entwurf "B — Monatsblatt" aus Eriks Vergleich dreier Varianten. // // Das war zuvor anders und schlechter: Die Uhrzeit war die größte Zahl auf // dem Bildschirm, vier gleich große Kennzahlkacheln zeigten meist drei Nullen @@ -109,20 +117,27 @@ async function renderDashboard(){ html += '
Ihrem Benutzerkonto ist keine Person zugeordnet — es können daher keine Aufgaben angezeigt werden. Ein Administrator kann die Zuordnung in der Benutzerverwaltung ergänzen.
'; } + // Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen, + // weil es das ist, was sich seit dem letzten Blick geändert hat. + html += '
'; html += '
'; - // Linke Zone: der eigene Arbeitsstrom + // Linke Zone: die eigenen Projekte mit ihren Terminen + const daten = dashDaten(); html += '
'; - html += dashAbschnitt('Meine Aufgaben', tasks.length, 'mir persönlich zugewiesen', - !weekLabel ? dashLeer('Keine Sitzung vorhanden.') - : (tasks.length === 0 ? dashLeer('Keine offenen Aufgaben — alles abgearbeitet.') - : '
' + tasks.map(t => renderDashboardAufgabe(weekLabel, t)).join('') + '
')); - html += dashAbschnitt('Termine meiner Projekte', projectDates.length, 'nächste drei Wochen', - renderDashboardFristen(weekLabel, projectDates)); + const zuAlle = dashZuLesen(); + const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p))); + html += dashAbschnitt('Meine Projekte', daten.projekte.length, null, + renderDashProjekte(daten), + daten.projekte.length > 1 + ? '' + : ''); html += '
'; - // Rechte Zone: was man wissen muss, ohne es selbst zu tun + // Rechte Zone: Kalender, dann was man wissen muss, ohne es selbst zu tun html += '
'; + html += dashAbschnitt('Kalender', null, null, '
'); html += dashAbschnitt('Ausstehende Freigaben', pending.length, null, pending.length === 0 ? dashLeer('Keine ausstehenden Freigaben.') : '
' + pending.map(renderDashboardFreigabe).join('') + '
'); @@ -133,9 +148,331 @@ async function renderDashboard(){ html += '
'; container.innerHTML = html; + if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); } + dashKalDaten = daten; + renderDashKalender(); startDashboardClock(); } +// ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ---------- + +// dashKalDaten / dashKalMonat / dashKalTag: Zustand des Kalenders. Der Monat +// und der gewählte Tag überleben ein Neuladen des Dashboards nicht absichtlich +// nicht — wer wiederkommt, will "heute" sehen. +let dashKalDaten = null; +let dashKalMonat = null; // { jahr, monat } (monat 0–11) +let dashKalTag = null; // ISO-Datum + +// dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte +// des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu, +// wenn die Person ihm zugeordnet ist (assign, Haupt 1 / Unterstützung 2) oder +// dort eine eigene offene Aufgabe hat. +// Rückgabe: { projekte:[{p, rollen:[{rolle,state}], termine:[…]}], eintraege:[…] }. +function dashDaten(){ + // geladen: false, solange /load-all noch nicht da ist — das Dashboard wird + // beim Anmelden oft schon vorher aufgebaut; load() zeichnet es danach neu. + const geladen = Array.isArray(db.weeks) && db.weeks.length > 0; + const leer = { projekte: [], eintraege: [], abwesenheiten: [], geladen }; + const w = (geladen && typeof currentWeek === 'function') ? currentWeek() : null; + const ich = currentUser && currentUser.personId; + if(!w || !ich) return leer; + const heute = heuteIso(); + const namen = {}; + (w.people || []).forEach(x => { namen[x.id] = x.fullName || x.name || x.id; }); + + const projekte = []; + (w.projects || []).forEach(p => { + const rollen = []; + Object.keys(p.assign || {}).forEach(key => { + const [pid, rolle] = key.split('|'); + const state = Number(p.assign[key]) || 0; + if(pid === ich && state > 0) rollen.push({ rolle: rolle || '', state }); + }); + const plan = p.plan || {}; + const termine = []; + (plan.todos || []).forEach(t => { + if(t.done) return; + const meins = Array.isArray(t.assignees) && t.assignees.includes(ich); + // Fremde Aufgaben ohne Datum gehören in keinen Kalender und keine Terminliste. + if(!t.end && !meins) return; + termine.push({ + art: 'aufgabe', titel: t.title || '(ohne Titel)', datum: t.end || '', + meins, progress: Number(t.progress) || 0, + wer: (t.assignees || []).map(id => namen[id] || id), + }); + }); + (plan.milestones || []).forEach(m => { + if(!m.date) return; + termine.push({ art: 'meilenstein', titel: m.name || 'Meilenstein', datum: m.date, meins: false, wer: [] }); + }); + const hatEigene = termine.some(t => t.meins); + if(!rollen.length && !hatEigene) return; + // Erledigte Vergangenheit interessiert hier nicht: Meilensteine vor heute + // fallen weg (sie haben kein Erledigt-Kennzeichen), offene Aufgaben vor + // heute bleiben als überfällig stehen. + const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.datum < heute)); + relevant.sort((a, b) => (a.datum || '9999').localeCompare(b.datum || '9999')); + projekte.push({ p, rollen, termine: relevant, alleTermine: termine }); + }); + // Projekte mit dem dringendsten Termin zuerst, Projekte ohne Termin am Ende. + projekte.sort((a, b) => { + const da = (a.termine.find(t => t.datum) || {}).datum || '9999'; + const db_ = (b.termine.find(t => t.datum) || {}).datum || '9999'; + return da.localeCompare(db_) || String(a.p.nr || '').localeCompare(String(b.p.nr || '')); + }); + + // Kalendereinträge: alle datierten Termine dieser Projekte (auch vergangene + // Meilensteine — beim Zurückblättern sollen sie da sein). + const eintraege = []; + projekte.forEach(x => x.alleTermine.forEach(t => { + if(t.datum) eintraege.push(Object.assign({ projekt: x.p }, t)); + })); + // Abwesenheiten: das eigene Konto und alle, die einem der eigenen Projekte + // zugeordnet sind — wer sonst fehlt, betrifft die eigene Arbeit nicht. + const beteiligt = new Set([ich]); + projekte.forEach(x => Object.keys(x.p.assign || {}).forEach(key => { + if(Number(x.p.assign[key]) > 0) beteiligt.add(key.split('|')[0]); + })); + const abwesenheiten = []; + (Array.isArray(db.globalPeople) ? db.globalPeople : []).forEach(person => { + if(!beteiligt.has(person.id)) return; + (person.absences || []).forEach(a => { + if(a.start && a.end) abwesenheiten.push({ name: person.id === ich ? 'Sie' : (person.fullName || person.name), start: a.start, end: a.end, type: a.type || 'Abwesend' }); + }); + }); + return { projekte, eintraege, abwesenheiten, geladen }; +} + +// dashProjektUrl: Adresse des Projektbereichs (gleiches Schema wie +// openProjectPlan() in js/gantt.js). Parameter: p, abschnitt. Rückgabe: String. +function dashProjektUrl(p, abschnitt){ + const w = currentWeek(); + const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(p.name || ''), window.location.origin); + url.searchParams.set('weekLabel', w ? w.label : ''); + url.searchParams.set('projNr', p.nr || ''); + url.hash = abschnitt || 'aufgaben'; + return url.toString(); +} + +// DASH_TERMINE_JE_PROJEKT: so viele Termine stehen je Projekt offen da, +// Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst +// verdrängt ein Projekt mit 25 gerissenen Fristen alle anderen aus dem Blick +// (so im Bestand gesehen). Die Zahl der überfälligen steht ohnehin im Kopf. +const DASH_TERMINE_JE_PROJEKT = 6; + +// Eingeklappte Projektblöcke (Eriks Wunsch): je Projekt merkt sich der +// Browser den Zustand über die dauerhafte projectUid — die Projekt-id ändert +// sich mit jeder Sitzung. Reiner Darstellungszustand, deshalb localStorage +// (Schlüssel DASH_ZU_KEY); ohne Speicher (privates Fenster) ist alles offen. +const DASH_ZU_KEY = 'projuebersicht-dash-zu'; +function dashZuLesen(){ + try { return new Set(JSON.parse(localStorage.getItem(DASH_ZU_KEY) || '[]')); } catch(e){ return new Set(); } +} +function dashZuSchreiben(set){ + try { localStorage.setItem(DASH_ZU_KEY, JSON.stringify([...set])); } catch(e){ /* nur Komfort */ } +} +function dashProjKey(p){ return p.projectUid || p.nr || p.id; } + +// dashProjUmschalten: klappt einen Projektblock auf oder zu — direkt am DOM, +// ohne das Dashboard neu zu laden. Parameter: knopf (der Kopf-Button). +function dashProjUmschalten(knopf){ + const block = knopf.closest('.dash-proj'); + if(!block) return; + const zu = !block.classList.contains('ist-zu'); + block.classList.toggle('ist-zu', zu); + knopf.setAttribute('aria-expanded', String(!zu)); + const set = dashZuLesen(); + if(zu) set.add(block.dataset.key); else set.delete(block.dataset.key); + dashZuSchreiben(set); + dashAlleKnopfAktualisieren(); +} + +// dashAlleUmschalten: alle Projektblöcke auf einmal ein- bzw. ausklappen. +function dashAlleUmschalten(){ + const bloecke = [...document.querySelectorAll('#dashboardContent .dash-proj')]; + const zuklappen = bloecke.some(b => !b.classList.contains('ist-zu')); + const set = dashZuLesen(); + bloecke.forEach(b => { + b.classList.toggle('ist-zu', zuklappen); + const k = b.querySelector('.dash-proj-toggle'); + if(k) k.setAttribute('aria-expanded', String(!zuklappen)); + if(zuklappen) set.add(b.dataset.key); else set.delete(b.dataset.key); + }); + dashZuSchreiben(set); + dashAlleKnopfAktualisieren(); +} +function dashAlleKnopfAktualisieren(){ + const knopf = document.getElementById('dashAlleKnopf'); + if(!knopf) return; + const offen = [...document.querySelectorAll('#dashboardContent .dash-proj')].some(b => !b.classList.contains('ist-zu')); + knopf.textContent = offen ? 'Alle einklappen' : 'Alle aufklappen'; +} + +// renderDashProjekte: die linke Zone. Parameter: daten (dashDaten()). +// Rückgabe: HTML-String. +function renderDashProjekte(daten){ + if(!currentUser || !currentUser.personId) return dashLeer('Ohne zugeordnete Person gibt es keine eigenen Projekte.'); + if(!daten.geladen) return dashLeer('Projekte werden geladen…'); + if(!daten.projekte.length) return dashLeer('Sie sind in der aktuellen Sitzung keinem Projekt zugeordnet und haben keine offenen Aufgaben.'); + const heute = heuteIso(); + const zu = dashZuLesen(); + return daten.projekte.map(x => { + const p = x.p; + const key = dashProjKey(p); + const istZu = zu.has(key); + const rollen = x.rollen.length + ? x.rollen.map(r => '' + esc(r.rolle) + '').join('') + : 'nur Aufgabe'; + const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0]; + const pct = aktLp && p.lph && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? Math.max(0, Math.min(100, Number(p.lph[aktLp]) || 0)) : null; + const phase = aktLp + ? '' + esc(aktLp) + '' + (pct === null ? '—' : pct + '%') + '' + : 'keine amtierende Leistungsphase'; + + const ueber = x.termine.filter(t => t.datum && t.datum < heute); + const kommend = x.termine.filter(t => !t.datum || t.datum >= heute); + const alle = ueber.concat(kommend); + const sichtbar = alle.slice(0, DASH_TERMINE_JE_PROJEKT); + const rest = alle.length - sichtbar.length; + const zeilen = sichtbar.length + ? sichtbar.map(t => renderDashTermin(p, t)).join('') + : '
Keine offenen Termine.
'; + const mehr = rest > 0 + ? '' + rest + ' weitere Termine im Projekt' + : ''; + // Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele + // Termine überfällig bzw. offen sind. + const kurz = (ueber.length ? '' + ueber.length + ' überfällig' : '') + + '' + kommend.length + ' offen'; + return '
' + + '
' + + '' + + '' + kurz + '' + + '' + rollen + '' + + 'Öffnen' + + '
' + + '
' + + '
' + phase + '
' + + '
' + zeilen + mehr + '
' + + '
' + + '
'; + }).join(''); +} + +// renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste +// des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe, +// fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute. +// Parameter: p (Projekt), t (Termin), mitProjekt (Projekt mit anzeigen). +// Rückgabe: HTML-String. +function renderDashTermin(p, t, mitProjekt){ + const heute = heuteIso(); + const ueber = t.art === 'aufgabe' && t.datum && t.datum < heute; + const bald = !ueber && t.datum && t.datum <= isoPlusTage(2); + const zeichen = t.art === 'meilenstein' ? '◆' : (t.meins ? 'A' : 'a'); + const klasse = t.art === 'meilenstein' ? 'ist-ms' : (t.meins ? 'ist-meins' : 'ist-fremd'); + const unter = mitProjekt + ? (p.nr ? p.nr + ' ' : '') + (p.name || '') + : (t.art === 'meilenstein' ? 'Meilenstein' : (t.meins ? 'meine Aufgabe' : (t.wer.length ? t.wer.join(', ') : 'niemand zugewiesen'))); + return '' + + '' + zeichen + '' + + '' + esc(t.titel) + '' + + '' + esc(unter) + '' + + '' + + (t.datum ? esc(formatWeekDate(t.datum)) + '' + esc(relativerTag(t.datum)) + '' : 'ohne Frist') + + ''; +} + +// renderDashKalender: der Monatskalender der rechten Zone samt Tagesliste. +// Zeichnet nur #dashKalender neu (Blättern und Tageswahl lösen keinen neuen +// Abruf von /my-dashboard aus). Keine Parameter. +function renderDashKalender(){ + const el = document.getElementById('dashKalender'); + if(!el || !dashKalDaten) return; + const heute = heuteIso(); + if(!dashKalTag) dashKalTag = heute; + if(!dashKalMonat){ const d = parseDateISO(heute); dashKalMonat = { jahr: d.getFullYear(), monat: d.getMonth() }; } + const { jahr, monat } = dashKalMonat; + const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; + + // Einträge je Tag vorsortieren. + const jeTag = {}; + dashKalDaten.eintraege.forEach(e => { (jeTag[e.datum] = jeTag[e.datum] || []).push(e); }); + const abwesendAm = iso => dashKalDaten.abwesenheiten.filter(a => a.start <= iso && iso <= a.end); + + const erster = new Date(jahr, monat, 1); + const start = new Date(erster); start.setDate(1 - ((erster.getDay() + 6) % 7)); + let zellen = ''; + for(let r = 0; r < 6; r++){ + zellen += '' + getWeekNumber(start) + ''; + for(let c = 0; c < 7; c++){ + const iso = formatDateISO(start); + const e = jeTag[iso] || []; + const punkte = e.slice(0, 3).map(x => '').join('') + + (abwesendAm(iso).length ? '' : ''); + const feiertag = typeof HOLIDAYS !== 'undefined' && HOLIDAYS.has(iso); + const klassen = ['dash-kal-tag', + start.getMonth() !== monat ? 'ist-fremd' : '', + c > 4 || feiertag ? 'ist-frei' : '', + iso === heute ? 'ist-heute' : '', + iso === dashKalTag ? 'ist-gewaehlt' : ''].filter(Boolean).join(' '); + zellen += ''; + start.setDate(start.getDate() + 1); + } + } + + const tagE = (jeTag[dashKalTag] || []); + const tagA = abwesendAm(dashKalTag); + const feiertagHeute = typeof HOLIDAYS !== 'undefined' && HOLIDAYS.has(dashKalTag); + const tagD = parseDateISO(dashKalTag); + const tagTitel = tagD.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' }) + + (dashKalTag === heute ? ' (heute)' : ''); + const liste = (feiertagHeute ? '
Feiertag
' : '') + + tagE.map(e => renderDashTermin(e.projekt, e, true)).join('') + + tagA.map(a => '
–' + + '' + esc(a.name) + '' + + '' + esc(a.type) + ' bis ' + esc(formatWeekDate(a.end)) + '
').join(''); + + el.innerHTML = '
' + + '
' + + '' + + '' + MONATE[monat] + ' ' + jahr + '' + + '' + + '
' + + '
KW' + + ['Mo','Di','Mi','Do','Fr','Sa','So'].map(t => '' + t + '').join('') + + zellen + '
' + + '
meine AufgabeProjektaufgabe' + + 'MeilensteinüberfälligAbwesenheit
' + + '
' + + '
' + esc(tagTitel) + '
' + + (liste || '
Keine Termine an diesem Tag.
') + '
'; +} + +// dashKalWaehle / dashKalBlaettern: Tag wählen bzw. Monat wechseln. Ein Tag +// aus dem Vor- oder Folgemonat blättert dorthin. +function dashKalWaehle(iso){ + dashKalTag = iso; + const d = parseDateISO(iso); + dashKalMonat = { jahr: d.getFullYear(), monat: d.getMonth() }; + renderDashKalender(); +} +function dashKalBlaettern(n){ + let { jahr, monat } = dashKalMonat; + monat += n; + if(monat < 0){ monat = 11; jahr--; } + if(monat > 11){ monat = 0; jahr++; } + dashKalMonat = { jahr, monat }; + renderDashKalender(); +} + // renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein // rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist // die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand. @@ -194,12 +531,15 @@ function renderDashboardBand(z){ // dashAbschnitt / dashLeer: die immer gleiche Umrahmung eines Blocks — // Überschrift, Anzahl, ein erklärender Zusatz am rechten Rand. // Parameter: titel, zahl (oder null), zusatz (oder null), inhalt. -function dashAbschnitt(titel, zahl, zusatz, inhalt){ +// kopfHtml (optional): fertiges HTML am rechten Rand der Überschrift, etwa +// ein Bedienknopf. +function dashAbschnitt(titel, zahl, zusatz, inhalt, kopfHtml){ return '
' + '
' + '

' + esc(titel) + '

' + (zahl != null ? '' + zahl + '' : '') + (zusatz ? '' + esc(zusatz) + '' : '') + + (kopfHtml || '') + '
' + inhalt + '
'; } function dashLeer(text){ @@ -243,46 +583,6 @@ function startDashboardClock(){ dashboardClockTimer = setInterval(tick, 30000); } -// renderDashboardAufgabe: eine eigene Aufgabe als ZEILE — Titel und Projekt -// links, Fortschritt in der Mitte, Fälligkeit rechts. -// -// Zuvor war das eine Karte, die eine halbe Bildschirmbreite für vier Zeilen -// Inhalt belegte; zwei davon füllten den sichtbaren Bereich. Dieselben -// Angaben stehen jetzt in einer Zeile, acht davon auf derselben Fläche. -// Verlinkt wie bisher auf den Projektbereich (gleiches URL-Schema wie -// openProjectPlan() in js/gantt.js). -// Parameter: weekLabel, task. Rückgabe: HTML-String. -function renderDashboardAufgabe(weekLabel, task){ - const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(task.projectName || ''), window.location.origin); - url.searchParams.set('weekLabel', weekLabel); - url.searchParams.set('projNr', task.projectNr || ''); - url.hash = 'aufgaben'; - - const heute = heuteIso(); - const bald = isoPlusTage(2); - // Dieselbe Kennzeichnung wie in der Terminliste darunter, damit die Kachel - // "überfällig" und die beiden Listen zusammenpassen. - const klasse = !task.end ? '' : (task.end < heute ? ' ist-ueberfaellig' : (task.end <= bald ? ' ist-bald' : '')); - const datum = task.end ? formatWeekDate(task.end) : '—'; - const relativ = task.end ? relativerTag(task.end) : 'ohne Frist'; - const progress = Number(task.progress || 0); - const tags = (Array.isArray(task.tags) ? task.tags : []) - .map(t => '' + esc(t) + '').join(''); - const projekt = (task.projectNr ? task.projectNr + ' ' : '') + (task.projectName || ''); - - return '' - + '' - + '' + esc(task.title || '(ohne Titel)') + '' - + '' + esc(projekt) + tags + '' - + '' - + '' - + '' - + '' + progress + '%' - + '' - + '' + esc(datum) + '' + esc(relativ) + '' - + ''; -} - // relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem // ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht // sagt, ob das morgen oder in drei Wochen ist. @@ -296,73 +596,6 @@ function relativerTag(iso){ return 'in ' + d + ' Tagen'; } -// renderDashboardFristen:// renderDashboardFristen: die Termine der Projekte, denen man zugeordnet ist -// (GET /my-dashboard, Feld projectDates — bereits nach Datum sortiert, auf -// 21 Tage begrenzt und um die eigenen Aufgaben bereinigt). -// -// Bewusst eine LISTE und keine Kartenwand wie "Meine Aufgaben": Hier geht es -// um Reihenfolge in der Zeit, und Termine, die untereinander stehen, lassen -// sich der Reihe nach abarbeiten. Gegliedert wird nach Dringlichkeit statt -// nach Projekt — in der Woche, in der etwas fällig wird, ist das Datum die -// Frage, nicht das Projekt. -// Parameter: weekLabel, eintraege (Array). Rückgabe: HTML-String. -function renderDashboardFristen(weekLabel, eintraege){ - if(!weekLabel) return dashLeer('Keine Sitzung vorhanden.'); - if(!eintraege.length){ - return dashLeer('In Ihren Projekten steht in den nächsten drei Wochen kein Termin an.'); - } - - const wocheEnde = isoPlusTage(7); - const gruppen = [ - { titel: 'Überfällig', klasse: 'ist-ueberfaellig', treffer: d => d.ueberfaellig }, - { titel: 'Diese Woche', klasse: 'ist-bald', treffer: d => !d.ueberfaellig && d.datum <= wocheEnde }, - { titel: 'Danach', klasse: '', treffer: d => !d.ueberfaellig && d.datum > wocheEnde }, - ]; - - // In derselben Karte wie "Meine Aufgaben" darüber: Beides ist der eigene - // Arbeitsstrom und soll gleich aussehen. - let html = '
'; - gruppen.forEach(g => { - const teil = eintraege.filter(g.treffer); - if(!teil.length) return; - html += '
' - + '
' + esc(g.titel) + '' + teil.length + '
'; - teil.forEach(d => { html += renderFristenZeile(weekLabel, d); }); - html += '
'; - }); - return html + '
'; -} - -// renderFristenZeile: eine Zeile der Terminliste. Der ganze Eintrag führt in -// den Terminplan des Projekts — dorthin, wo sich der Termin ändern lässt. -// Parameter: weekLabel, d (Eintrag). Rückgabe: HTML-String. -function renderFristenZeile(weekLabel, d){ - const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(d.projectName || ''), window.location.origin); - url.searchParams.set('weekLabel', weekLabel); - url.searchParams.set('projNr', d.projectNr || ''); - url.hash = 'aufgaben'; - - const datum = d.datum ? formatWeekDate(d.datum) : ''; - const tage = tageBis(d.datum); - // Die Angabe in Tagen steht neben dem Datum, weil "12.08." allein nicht - // sagt, ob das morgen oder in drei Wochen ist. - const relativ = d.heute ? 'heute' - : (tage < 0 ? (Math.abs(tage) === 1 ? 'seit 1 Tag' : 'seit ' + Math.abs(tage) + ' Tagen') - : (tage === 1 ? 'morgen' : 'in ' + tage + ' Tagen')); - const wer = Array.isArray(d.zustaendig) && d.zustaendig.length - ? esc(d.zustaendig.join(', ')) - : 'niemand zugewiesen'; - - return '' - + '' + esc(datum) + '' + esc(relativ) + '' - + '' - + (d.art === 'meilenstein' ? 'Meilenstein' : 'Aufgabe') + '' - + '' + esc(d.titel || '(ohne Titel)') + '' - + '' + esc(d.projectNr ? d.projectNr + ' ' + d.projectName : d.projectName) + '' - + '' + wer + '' - + ''; -} - // tageBis: Kalendertage von heute bis zu einem ISO-Datum (negativ = liegt // zurück). Parameter: iso. Rückgabe: ganze Zahl. function tageBis(iso){ diff --git a/public/js/einfuehrung.js b/public/js/einfuehrung.js new file mode 100644 index 0000000..5af060b --- /dev/null +++ b/public/js/einfuehrung.js @@ -0,0 +1,213 @@ +// ----------------------------- +// Einführung — geführter Rundgang +// ----------------------------- +// Eriks Wunsch: Jeder Nutzer, auch bestehende, macht einmal einen geführten +// Rundgang durch die Anwendung; unter Einstellungen → Mein Konto lässt er +// sich jederzeit neu starten. Der Rundgang hebt den jeweiligen Eintrag der +// Seitenleiste hervor (Aussparung in einer abgedunkelten Ebene), wechselt in +// den Bereich und erklärt ihn in einer Sprechblase. +// +// Gezeigt wird nur, was die Rolle darf (hatRecht(), Rollen & Rechte) — ein +// Konto ohne „Anwendung nutzen“ (Wandtafel) bekommt keinen Rundgang. +// Abgeschlossen oder übersprungen → POST /einfuehrung (users.einfuehrung_am), +// danach startet er nicht mehr von selbst. Esc bzw. „Später“ schließt nur +// für diesen Seitenaufruf (sessionStorage) — beim nächsten Laden kommt er +// wieder. +// +// Ein Schritt: { id, recht?, wenn?(), tab?, ziel? (CSS-Selektor), titel, +// text (HTML, aus festen Texten) }. Ohne ziel steht die Blase in der Mitte. + +const EINFUEHRUNG_SCHRITTE = [ + { id: 'start', titel: 'Willkommen in der Projektübersicht', + text: () => `Hallo ${esc((currentUser && (currentUser.displayName || '').split(' ')[0]) || '')}! In gut zwei Minuten zeigen wir dir, wo du was findest. Mit den Pfeiltasten oder „Weiter“ geht es durch, „Später“ schließt den Rundgang für jetzt.` }, + { id: 'dashboard', recht: 'app', tab: 'dashboard', ziel: '.sidebar-nav [data-tab="dashboard"]', titel: 'Dashboard', + text: () => 'Deine Startseite: die Aufgaben, die dir zugewiesen sind, die nächsten Termine deiner Projekte und ein Kalender mit Meilensteinen und Abwesenheiten. Überfälliges steht oben.' }, + { id: 'glocke', recht: 'app', ziel: '#glockeBtn', titel: 'Benachrichtigungen', + text: () => 'Die Glocke zeigt, wenn dir jemand eine neue Aufgabe zugewiesen hat. Ein Klick springt direkt in die Aufgaben des Projekts.' }, + { id: 'projekte', recht: 'app', tab: 'board', ziel: '.sidebar-nav [data-tab="board"]', titel: 'Projekte', + text: () => 'Alle Projekte der laufenden Sitzung, nach Gruppen.' + (hatRecht('proj.anlegen') ? ' Über „+ Projekt“ legst du ein neues an — ein Assistent fragt Gewerke, Auftrag, Team und Terminplan ab.' : '') }, + { id: 'projektbereich', recht: 'app', tab: 'board', ziel: '#board .board-name', titel: 'Der Projektbereich', + text: () => 'Ein Klick auf den Projektnamen öffnet den Bereich des Projekts. Oben stehen die Reiter: Dashboard, Terminplan, Leistungsphasen, Aufgaben, Leistung, Dateien, Protokolle und Einstellungen.' }, + { id: 'terminplan', recht: 'proj.plan', tab: 'board', titel: 'Terminplan und Vorlagen', + text: () => 'Im Reiter Terminplan legst du über „+ Neu“ Leistungsphasen, Aufgaben und Meilensteine an. „+ Vorlage“ fügt die Grundleistungen der HOAI für dein Gewerk ein — die Dauer verteilt sich nach den Siemon-Anteilen über den Phasenbalken. Gib Aufgaben einen Aufwand in Stunden, dann zählen sie in der Auslastung.' }, + { id: 'wochenbericht', recht: 'app', tab: 'wochenbericht', ziel: '.sidebar-nav [data-tab="wochenbericht"]', titel: 'Wochenbericht', + text: () => 'Die Tagesordnung der wöchentlichen Besprechung, je Gewerk. Links die Projekte, rechts das gewählte.' + (hatRecht('wb.kommentar') ? ' Hier schreibst du den Kommentar der Woche und den Fortschritt der Leistungsphasen; „Besprochen & weiter“ führt zum nächsten Projekt.' : '') + ' „Als PDF ablegen“ speichert den Bericht.' }, + { id: 'auslastung', recht: 'app', tab: 'load', ziel: '.sidebar-nav [data-tab="load"]', titel: 'Auslastung', + text: () => 'Wie voll die nächsten zwölf Wochen für jede Person sind. Die Stunden kommen aus den Aufgaben mit Aufwand — eine Aufgabe für zwei Personen zählt für jede zur Hälfte.' }, + { id: 'abwesenheiten', wenn: () => typeof darfIrgendeineAbwesenheit === 'function' && darfIrgendeineAbwesenheit(), tab: 'abwesenheit', ziel: '.sidebar-nav [data-tab="abwesenheit"]', titel: 'Abwesenheiten', + text: () => 'Hier trägst du Urlaub, Krankheit und Fortbildung ein. Unten siehst du das Team im Monat — wer außerhalb deines Bereichs liegt, erscheint nur als „abwesend“.' + (hatRecht('team.abwAlle') || hatRecht('team.abwGewerk') || hatRecht('team.abwProjekte') ? ' Ein Klick auf einen Tag in der Zeile einer Person trägt für sie ein.' : '') }, + { id: 'planung', recht: 'wz.planung', tab: 'planung', ziel: '.sidebar-nav [data-tab="planung"]', titel: 'Planung → Terminplan', + text: () => 'Terminpläne ohne echtes Projekt — zum Ausprobieren, für Angebote oder um Kapazität abzuschätzen. Aus einem Honorar errechnet oder frei angelegt.' }, + { id: 'planchecker', recht: 'wz.checker', tab: 'planchecker', ziel: '.sidebar-nav [data-tab="planchecker"]', titel: 'Plan Checker', + text: () => 'Lädt einen Plan als PDF hoch und lässt ihn von einer KI gegen eine Liste von Merkmalen prüfen. Dafür brauchst du einen eigenen KI-Schlüssel unter Einstellungen.' }, + { id: 'einstellungen', tab: 'settings', ziel: '.sidebar-nav [data-tab="settings"]', titel: 'Einstellungen', + text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen Rundgang jederzeit neu starten.' }, + { id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin', + text: () => 'Du darfst Teile der Verwaltung pflegen: ' + esc(typeof ADMIN_SECTIONS !== 'undefined' ? ADMIN_SECTIONS.filter(adminAbschnittErlaubt).map(s => s.title).join(', ') : '') + '.' }, + { id: 'ende', tab: 'dashboard', titel: 'Fertig!', + text: () => { + const hinweise = []; + if(currentUser && !currentUser.personId){ + hinweise.push('Dein Konto ist noch keiner Person zugeordnet — „Meine Aufgaben“ bleibt deshalb leer. Bitte einen Administrator, das in der Benutzerverwaltung nachzutragen.'); + } + return 'Das war der Überblick. Bei Fragen hilft dir ein Kollege — oder du startest den Rundgang unter Einstellungen noch einmal.' + + (hinweise.length ? '
    ' + hinweise.map(h => '
  • ' + h + '
  • ').join('') + '
' : ''); + } }, +]; + +let tourSchritte = []; +let tourIndex = 0; +let tourAktiv = false; +let tourVorTab = null; + +// einfuehrungBeiBedarf: startet den Rundgang, wenn er für dieses Konto noch +// aussteht — sobald die Daten geladen sind und kein Dialog (Passwort) offen +// ist. Von afterAuthenticated() und nach dem Pflicht-Passwortwechsel gerufen. +function einfuehrungBeiBedarf(){ + if(!currentUser || !currentUser.einfuehrungFaellig || !hatRecht('app')) return; + try { if(sessionStorage.getItem('projuebersicht-tour-spaeter')) return; } catch(e){} + let versuche = 0; + const pruefen = () => { + if(tourAktiv) return; + const geladen = typeof currentWeek === 'function' && currentWeek(); + const dialogOffen = [...document.querySelectorAll('.modal-bg')].some(m => m.style.display && m.style.display !== 'none'); + const login = document.getElementById('loginOverlay'); + const loginOffen = login && login.style.display !== 'none' && !login.hidden && login.offsetParent !== null; + if(geladen && !dialogOffen && !loginOffen) return einfuehrungStarten(); + if(++versuche < 120) setTimeout(pruefen, 500); + }; + setTimeout(pruefen, 600); +} + +// einfuehrungStarten: Rundgang von vorn (auch aus Einstellungen). +function einfuehrungStarten(){ + tourSchritte = EINFUEHRUNG_SCHRITTE.filter(s => (!s.recht || hatRecht(s.recht)) && (!s.wenn || s.wenn())); + tourIndex = 0; + tourAktiv = true; + tourVorTab = typeof currentTab !== 'undefined' ? currentTab : null; + let el = document.getElementById('tourEbene'); + if(!el){ + el = document.createElement('div'); + el.id = 'tourEbene'; + el.className = 'tour'; + el.innerHTML = '
'; + document.body.appendChild(el); + window.addEventListener('resize', () => { if(tourAktiv) tourPositionieren(); }); + document.addEventListener('keydown', tourTaste, true); + } + el.hidden = false; + tourZeigen(); +} + +function tourTaste(e){ + if(!tourAktiv) return; + if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); tourSpaeter(); } + else if(e.key === 'ArrowRight' || (e.key === 'Enter' && !e.target.closest('button'))){ e.preventDefault(); tourWeiter(); } + else if(e.key === 'ArrowLeft'){ e.preventDefault(); tourZurueck(); } +} + +// tourZeigen: aktuellen Schritt aufbauen (Bereich wechseln, Blase füllen, +// danach Aussparung und Blase an das Ziel legen). +function tourZeigen(){ + const s = tourSchritte[tourIndex]; + if(!s) return tourBeenden(true); + if(s.tab && typeof showTab === 'function' && currentTab !== s.tab) showTab(s.tab); + const blase = document.querySelector('#tourEbene .tour-blase'); + const letzter = tourIndex === tourSchritte.length - 1; + blase.innerHTML = ` +
${tourIndex + 1} von ${tourSchritte.length} +
+

${esc(s.titel)}

+
${s.text()}
+ +
+ ${letzter ? '' : ''} + + ${tourIndex > 0 ? '' : ''} + +
`; + // Nach dem Bereichswechsel kurz warten, bis die Seite steht. + requestAnimationFrame(() => { tourPositionieren(); setTimeout(tourPositionieren, 260); }); + const knopf = document.getElementById('tourWeiter'); + if(knopf) knopf.focus({ preventScroll: true }); +} + +// tourZiel: sichtbares Ziel-Element des Schritts oder null (dann Mitte). +function tourZiel(s){ + if(!s || !s.ziel) return null; + const el = document.querySelector(s.ziel); + if(!el || el.hidden || el.offsetParent === null) return null; + const r = el.getBoundingClientRect(); + if(r.width < 2 || r.height < 2 || r.right < 0 || r.left > innerWidth) return null; + return el; +} + +function tourPositionieren(){ + const ebene = document.getElementById('tourEbene'); + if(!ebene || !tourAktiv) return; + const s = tourSchritte[tourIndex]; + const loch = ebene.querySelector('.tour-loch'); + const blase = ebene.querySelector('.tour-blase'); + const ziel = tourZiel(s); + const schmal = innerWidth < 640; + if(!ziel){ + ebene.classList.add('ohne-ziel'); + blase.style.left = Math.max(12, (innerWidth - blase.offsetWidth) / 2) + 'px'; + blase.style.top = schmal ? '' : Math.max(12, (innerHeight - blase.offsetHeight) / 2) + 'px'; + blase.classList.toggle('ist-unten', schmal); + return; + } + ebene.classList.remove('ohne-ziel'); + ziel.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + const r = ziel.getBoundingClientRect(); + const pad = 6; + Object.assign(loch.style, { left: (r.left - pad) + 'px', top: (r.top - pad) + 'px', width: (r.width + 2 * pad) + 'px', height: (r.height + 2 * pad) + 'px' }); + blase.classList.toggle('ist-unten', schmal); + if(schmal){ blase.style.left = '12px'; blase.style.top = ''; return; } + const bw = blase.offsetWidth, bh = blase.offsetHeight, abstand = 16; + // Bevorzugt rechts vom Ziel (Seitenleiste links), sonst darunter/darüber. + let left, top; + if(r.right + abstand + bw < innerWidth - 12){ + left = r.right + abstand; top = r.top + r.height / 2 - bh / 2; + } else if(r.bottom + abstand + bh < innerHeight - 12){ + left = r.left + r.width / 2 - bw / 2; top = r.bottom + abstand; + } else { + left = r.left + r.width / 2 - bw / 2; top = r.top - abstand - bh; + } + blase.style.left = Math.min(Math.max(12, left), innerWidth - bw - 12) + 'px'; + blase.style.top = Math.min(Math.max(12, top), innerHeight - bh - 12) + 'px'; +} + +function tourWeiter(){ + if(tourIndex >= tourSchritte.length - 1) return tourBeenden(true); + tourIndex++; tourZeigen(); +} +function tourZurueck(){ + if(tourIndex === 0) return; + tourIndex--; tourZeigen(); +} + +// tourSpaeter: schließt nur für diesen Seitenaufruf (kommt beim nächsten +// Laden wieder, solange er nicht abgeschlossen ist). +function tourSpaeter(){ + try { sessionStorage.setItem('projuebersicht-tour-spaeter', '1'); } catch(e){} + tourSchliessen(); + if(tourVorTab && typeof showTab === 'function') showTab(tourVorTab); +} + +// tourBeenden: abgeschlossen oder übersprungen — am Server vermerken. +function tourBeenden(){ + tourSchliessen(); + if(currentUser) currentUser.einfuehrungFaellig = false; + fetch(baseUrl() + '/einfuehrung', { + method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ erledigt: true }) + }).catch(err => console.error('Einführung konnte nicht vermerkt werden:', err)); + if(typeof showTab === 'function') showTab('dashboard'); +} + +function tourSchliessen(){ + tourAktiv = false; + const el = document.getElementById('tourEbene'); + if(el) el.hidden = true; +} diff --git a/public/js/gantt.js b/public/js/gantt.js index e9d68bd..33dcded 100644 --- a/public/js/gantt.js +++ b/public/js/gantt.js @@ -150,6 +150,9 @@ function buildGanttHtml(projects, start, end, options = {}){ // Kopfzeile: Monate und Tage let headerMonths = '
'; + // Den Griff zum Ändern der Spaltenbreite setzt ganttGriffeEinsetzen() nach + // dem Rendern in JEDE Zeile (auch die nachträglich eingefügten Aufgaben- + // und Meilensteinzeilen aus js/plan/plan-build-gantt.js). headerMonths += '
Projekt
'; let monthsHtml = ''; let current = new Date(start.getTime()); @@ -280,10 +283,11 @@ function buildGanttHtml(projects, start, end, options = {}){ } } - // Meilensteine: nur die nicht positionierten (keine position) in der Projektzeile anzeigen. - // Positionierte Meilensteine werden vom Aufrufer (z.B. Projektplan) als separate Zeilen eingefügt. + // Meilensteine in der Projektzeile nur ohne showTodos. Im Projektplan + // bekommt jeder Meilenstein eine eigene Zeile (plan-build-gantt.js) — + // sonst lagen mehrere ohne Aufgabenbezug übereinander in einer Zeile. milestones.forEach((m, mIdx) => { - if(options.showTodos && m.position) return; + if(options.showTodos) return; const d = parseDateISO(m.date); if(!d || d < start || d > end) return; const offset = daysBetween(start, d); @@ -298,7 +302,7 @@ function buildGanttHtml(projects, start, end, options = {}){ // Zoom breiter als der Container wird und horizontal gescrollt werden kann. let chartStyle = ''; if(pxPerDay){ - chartStyle = ` style="min-width:${(220 + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`; + chartStyle = ` style="min-width:${(ganttSpaltenBreite() + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`; } return `
${body}
`; } @@ -345,4 +349,104 @@ function springZuEinstellungen(pid){ function closePlanPage(){ const back = (typeof previousTab === 'string' && previousTab && previousTab !== 'plan') ? previousTab : 'board'; showTab(back); -} \ No newline at end of file +} + +// ---------- Breite der Spalte "Projekt" (Eriks Wunsch: "wie in Excel") ---------- +// +// Die Breite steht in der CSS-Variable --gantt-label-w (styles.css, +// .gantt-label), damit alle Zeilen beim Ziehen gleichzeitig mitgehen, ohne +// das Diagramm neu aufzubauen. Gemerkt wird sie je Browser in localStorage — +// reiner Darstellungszustand wie die eingeklappten Dashboard-Projekte. +const GANTT_SPALTE_KEY = 'projuebersicht-gantt-spalte'; +const GANTT_SPALTE_MIN = 120, GANTT_SPALTE_MAX = 640, GANTT_SPALTE_STANDARD = 220; + +// ganttSpaltenBreite: aktuelle Breite in Pixeln (gespeichert oder Standard). +function ganttSpaltenBreite(){ + let w = GANTT_SPALTE_STANDARD; + try { w = Number(localStorage.getItem(GANTT_SPALTE_KEY)) || GANTT_SPALTE_STANDARD; } catch(e){ /* Standard */ } + return Math.max(GANTT_SPALTE_MIN, Math.min(GANTT_SPALTE_MAX, w)); +} +// ganttSpalteSetzen: Breite anwenden; speichern=true merkt sie dauerhaft. +function ganttSpalteSetzen(w, speichern){ + w = Math.round(Math.max(GANTT_SPALTE_MIN, Math.min(GANTT_SPALTE_MAX, w))); + document.documentElement.style.setProperty('--gantt-label-w', w + 'px'); + if(speichern){ try { localStorage.setItem(GANTT_SPALTE_KEY, String(w)); } catch(e){ /* nur Komfort */ } } +} +ganttSpalteSetzen(ganttSpaltenBreite(), false); + +// ganttGriffeEinsetzen: hängt an den rechten Rand jeder Namenszelle einen +// Griff — die Spaltengrenze lässt sich so in jeder Zeile fassen, nicht nur in +// der Kopfzeile (Eriks Wunsch). click/dblclick werden abgefangen, damit ein +// Klick auf die Grenze nicht zusätzlich die Aufgabe der Zeile öffnet. +// Parameter: root (Element mit dem Diagramm). Keine Rückgabe. +function ganttGriffeEinsetzen(root){ + if(!root) return; + root.querySelectorAll('.gantt-chart .gantt-label').forEach(label => { + if(label.querySelector(':scope > .gantt-spalten-griff')) return; + const g = document.createElement('span'); + g.className = 'gantt-spalten-griff'; + g.setAttribute('role', 'separator'); + g.setAttribute('aria-orientation', 'vertical'); + g.title = 'Spaltenbreite ziehen, Doppelklick passt sie an'; + g.addEventListener('pointerdown', ganttSpalteZiehen); + g.addEventListener('mousedown', e => e.stopPropagation()); + g.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); }); + g.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); ganttSpalteAnpassen(); }); + label.appendChild(g); + }); +} + +// ganttPfeileNachziehen: zeichnet die Abhängigkeitspfeile neu, höchstens +// einmal je Bild. Die Pfeile sind ein SVG mit festen Pixelkoordinaten; ohne +// das blieben sie beim Ziehen der Spalte an der alten Stelle stehen, während +// die Balken mitwandern. +let ganttPfeileGeplant = false; +function ganttPfeileNachziehen(){ + if(ganttPfeileGeplant || typeof renderGanttDependencies !== 'function') return; + ganttPfeileGeplant = true; + requestAnimationFrame(() => { ganttPfeileGeplant = false; renderGanttDependencies(); }); +} + +// ganttNeuZeichnen: nach dem Ändern einmal neu aufbauen — die Mindestbreite +// des Diagramms bei hohem Zoom hängt von der Spaltenbreite ab. +function ganttNeuZeichnen(){ + if(typeof renderPlanGantt === 'function' && typeof currentTab !== 'undefined' && currentTab === 'plan') renderPlanGantt(); +} + +// ganttSpalteZiehen: Ziehen am Griff (Maus, Stift, Touch). Parameter: e +// (pointerdown-Ereignis). Keine Rückgabe. +function ganttSpalteZiehen(e){ + if(e.button !== undefined && e.button !== 0) return; + e.preventDefault(); e.stopPropagation(); + const griff = e.currentTarget; + const startX = e.clientX, startW = ganttSpaltenBreite(); + griff.setPointerCapture && griff.setPointerCapture(e.pointerId); + document.body.classList.add('gantt-spalte-zieht'); + let w = startW; + const bewegen = ev => { w = startW + (ev.clientX - startX); ganttSpalteSetzen(w, false); ganttPfeileNachziehen(); }; + const ende = () => { + griff.removeEventListener('pointermove', bewegen); + griff.removeEventListener('pointerup', ende); + griff.removeEventListener('pointercancel', ende); + document.body.classList.remove('gantt-spalte-zieht'); + ganttSpalteSetzen(w, true); + ganttNeuZeichnen(); + }; + griff.addEventListener('pointermove', bewegen); + griff.addEventListener('pointerup', ende); + griff.addEventListener('pointercancel', ende); +} + +// ganttSpalteAnpassen: Doppelklick auf den Griff — Breite an den längsten +// Eintrag anpassen (wie der Doppelklick auf die Spaltengrenze in Excel). +function ganttSpalteAnpassen(){ + let max = 0; + document.querySelectorAll('.gantt-chart .gantt-label').forEach(el => { + // scrollWidth = volle Inhaltsbreite samt Innenabstand, auch wenn der + // Text gerade mit "…" abgeschnitten ist. + max = Math.max(max, el.scrollWidth); + }); + if(!max) return; + ganttSpalteSetzen(max + 6, true); + ganttNeuZeichnen(); +} diff --git a/public/js/globals.js b/public/js/globals.js index aaca787..95464d4 100644 --- a/public/js/globals.js +++ b/public/js/globals.js @@ -143,6 +143,7 @@ const TAB_SLUGS = { board: 'Projektübersicht', wochenbericht: 'Wochenbericht', load: 'Auslastung', + abwesenheit: 'Abwesenheiten', people: 'Projektbeteiligte', planung: 'Planung', planchecker: 'PlanChecker', diff --git a/public/js/init.js b/public/js/init.js index ef517b5..5b5b686 100644 --- a/public/js/init.js +++ b/public/js/init.js @@ -18,10 +18,13 @@ function renderAll(){ const activeTab = document.querySelector('.tab.active')?.dataset.tab || currentTab; if(activeTab === 'wochenbericht') renderWochenbericht(); else if(activeTab === 'load') renderLoad(); + else if(activeTab === 'abwesenheit') renderAbwesenheiten(); else if(activeTab === 'people') renderPeople(); else if(activeTab === 'dashboard') renderDashboard(); else if(activeTab === 'settings') renderSettings(); else if(activeTab === 'admin') renderAdminArea(); + // Glocke im Menüband auf den aktuellen Stand bringen (js/benachrichtigungen.js). + if(typeof ladeBenachrichtigungen === 'function') ladeBenachrichtigungen(); // Der Projektbereich rendert nicht hier, sondern in showTab('plan') — // load() ruft direkt im Anschluss showTab(currentTab) auf, und erst dort // ist der Bereich sichtbar (das GANTT braucht die Containerbreite). diff --git a/public/js/load/load.js b/public/js/load/load.js index 0df3bfc..7086fb1 100644 --- a/public/js/load/load.js +++ b/public/js/load/load.js @@ -1,39 +1,10 @@ // ----------------------------- // Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase // ----------------------------- -// 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, -// stundenFormat) wird zusätzlich von js/pdf.js für den -// PDF-Export wiederverwendet. -// -// Datenmodell je Projekt (rollen- und leistungsphasenspezifisch): -// pr.aufwandProLP = { -// 'LPH 1': { Planung: 0.4, Projektleitung: 0.6, Bauüberwachung: 0.0 }, -// ... -// } // Gewichtungsfaktor je Rolle (0.0–1.0) -// pr.aktuelleLP = ['LPH 6'] // aktuell amtierende Phase (max. 1) -// -// Es zählt genau eine amtierende Phase je Projekt ("Nur amtierende Einzelphase", -// Absprache mit dem Auftraggeber). Altdaten mit mehreren aktiven Phasen -// verwenden deterministisch die erste aktive Phase der Liste. -// -// Berechnungslogik: -// Auslastung(Mitarbeiter) = Σ Faktor der amtierenden LP je Projekt, -// in dem der Mitarbeiter (in seiner Rolle) zugewiesen ist. -// Der Faktor zählt 1:1 – die Zuordnungsart (Haupt/Optional) skaliert nicht. -// Leere Matrix-Felder zählen bei gesetzter aktiver LP mit dem Standard-Faktor -// 1,0; ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet). -// "In Anbahnung" wird nicht eingerechnet. -// -// HINWEIS (Fachplaner-Fragen, bewusst NICHT eigenständig entschieden): -// - Ampel-Schwellen (50 % / 75 % des Maximalwerts) sind PLATZHALTER und müssen -// fachlich abgestimmt werden. -// - Mitarbeiterkapazität (Teilzeit/Vollzeit) existiert nicht – offener Punkt. +// Modul für den Auslastungs-Tab (renderLoad()). Die Auslastung kommt allein +// aus Aufgaben mit Aufwand, gleich geteilt auf ihre Zuständigen (siehe +// "Auslastung aus Aufgaben" unten). aufgabenVerlaufJePerson() nutzen auch +// die Ressourcenansicht im Terminplan (plan-gantt.js) und die Wandtafel. // AUFWAND_LPS: Phasen des Aufwandsmodells — alle echten Leistungsphasen // (inkl. LPH 9), ohne die Sammelkategorie "W+M". Bewusst per Filter statt per @@ -41,19 +12,6 @@ // Raster fällt. const AUFWAND_LPS = LPHS.filter(l => l !== 'W+M'); -// aufwandVorlage: die firmenweite Gewichtung je Leistungsphase und Rolle -// (0,0–1,0). Sie greift überall dort, wo ein Projekt in seiner Aufwandsmaske -// keinen eigenen Wert trägt — also bei den meisten — und bestimmt damit still -// einen Großteil der Auslastungszahlen. Genau deshalb steht sie seit dem -// Ausbau der Einstellungen nicht mehr als Konstante hier, sondern wird -// firmenweit gepflegt (Bereich „Einstellungen", nur Administratoren) und über -// APP_SETTINGS geliefert (js/globals.js, gefüllt in js/store.js). LPH 9 steht -// in der Vorgabe bewusst auf 0 — die Faktoren sind fachlich nicht abgestimmt. -// Rückgabe: Objekt LPH -> { Rolle: Faktor }. -function aufwandVorlage(){ - return (APP_SETTINGS && APP_SETTINGS.aufwandVorlage) || {}; -} - // Ampel-Schwellen und Balkenskala. Die Schwellen beziehen sich auf die // KAPAZITÄT der Person (1,0 = ihre volle Stelle), nicht auf das Maximum der // Balkenskala: Mit einer Skala bis 8 lagen sie vorher faktisch bei 6,0 (rot) @@ -98,128 +56,17 @@ function lpActiveList(pr){ return []; } -// aufwandMatrixWert: Rollenwert eines Projekts für eine Leistungsphase -// (Gewichtungsfaktor, 0,0–1,0). Liefert null, wenn keine (rollenspezifische) Matrix -// bzw. kein Wert für diese Phase/Rolle hinterlegt ist. 0 ist ein gültiger Wert. -function aufwandMatrixWert(projekt, lp, rolle){ - const m = projekt && projekt.aufwandProLP; - const entry = (m && typeof m === 'object') ? m[lp] : undefined; - if(!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; - const v = parseFloat(entry[rolle]); - return isNaN(v) ? null : v; -} - -// ---------- Auslastung in Stunden ---------- +// ---------- Auslastung aus Aufgaben ---------- // -// Die Auslastung wird als WOCHENSTUNDEN gerechnet und erst zum Schluss auf -// die Kapazität der Person bezogen. Vorher war sie eine Summe dimensionsloser -// Gewichte: Ein Projekt in LPH 5 zählte für die Planung 1,0 — ob es 20.000 € -// oder 2 Mio. € umfasste. Zwei kleine Projekte wogen damit schwerer als ein -// großes, und die Zahl sagte nichts darüber, wie viel Arbeit tatsächlich -// anliegt. -// -// Der Weg von einem Projekt zu den Wochenstunden einer Person: -// -// 1. BASIS Honorar des Projekts. Fehlt es, tritt die am Projekt -// gepflegte Größe (0–1) mal dem Referenzhonorar an seine -// Stelle. Fehlt beides, ist das Projekt NICHT BEWERTBAR — -// es wird als Lücke ausgewiesen, nicht als Null gezählt. -// 2. PHASE Anteil der amtierenden Leistungsphase am Honorar -// (HONORAR_ANTEILE, auf die beauftragten Phasen normiert — -// dieselbe Rechnung wie im Leistungsstand des -// Projektbereichs), abzüglich des bereits erbrachten -// Fortschritts dieser Phase. Übrig bleibt der OFFENE Wert. -// 3. STUNDEN Offener Wert geteilt durch den Stundensatz. -// 4. ROLLE Aufteilung auf die Rollen über dieselbe Gewichtung wie -// bisher (Projektmatrix, sonst firmenweite Vorlage), hier -// aber als ANTEIL: Gewicht der Rolle durch die Summe aller -// Rollengewichte dieser Phase. -// 5. KÖPFE Aufteilung auf die Personen, die dieser Rolle im Projekt -// zugeordnet sind. Ohne diesen Schritt zählte jeder von drei -// Planern die vollen Phasenstunden — der Aufwand verdreifacht -// sich nicht dadurch, dass mehr Leute daran arbeiten. -// Hauptverantwortung wiegt 1, Unterstützung 0,5. -// 6. ZEIT Verteilung der Reststunden über die Wochen bis zum Ende -// des Terminplans; ohne Terminplan über den firmenweiten -// Verteilzeitraum. Ohne Horizont wäre die Wochenlast nicht -// bestimmt: Dieselben Reststunden bedeuten über vier Wochen -// etwas anderes als über vierzig. -// -// "In Anbahnung" bleibt wie bisher ausgeklammert. - -// projektBasisHonorar: die Geldgrundlage eines Projekts für die -// Auslastungsrechnung. Parameter: projekt. Rückgabe: -// { betrag, quelle: 'honorar'|'groesse'|null }. -function projektBasisHonorar(projekt){ - const honorar = Number(projekt && projekt.honorar) || 0; - if(honorar > 0) return { betrag: honorar, quelle: 'honorar' }; - const groesse = Number(projekt && projekt.groesse) || 0; - if(groesse > 0){ - const referenz = Number(APP_SETTINGS.referenzHonorar) || 250000; - return { betrag: Math.min(1, groesse) * referenz, quelle: 'groesse' }; - } - return { betrag: 0, quelle: null }; -} - -// phasenAnteilProzent: Anteil einer Leistungsphase am Honorar des Projekts, -// normiert auf die BEAUFTRAGTEN Phasen — ein Projekt, das nur für drei Phasen -// beauftragt ist, verteilt sein Honorar auch nur auf diese drei. Sind keine -// beauftragt, gelten die firmenweiten Anteile unnormiert (sie ergeben -// zusammen 100 %). Parameter: projekt, lph. Rückgabe: Prozent (0 = unbekannt). -function phasenAnteilProzent(projekt, lph){ - const roh = Number(HONORAR_ANTEILE[lph]) || 0; - if(roh <= 0) return 0; - const beauftragt = Array.isArray(projekt.beauftragteLP) ? projekt.beauftragteLP : []; - if(beauftragt.length === 0) return roh; - if(!beauftragt.includes(lph)) return 0; - const summe = beauftragt.reduce((acc, l) => acc + (Number(HONORAR_ANTEILE[l]) || 0), 0); - return summe > 0 ? roh / summe * 100 : 0; -} - -// rollenGewicht: Aufwandsgewicht einer Rolle in einer Phase — Wert der -// Projektmatrix, sonst firmenweite Vorlage, sonst 1,0. Unverändert -// gegenüber der bisherigen Rechnung; neu ist nur, wofür er dient (Anteil -// statt absoluter Last). Parameter: projekt, lph, rolle. Rückgabe: Zahl. -function rollenGewicht(projekt, lph, rolle){ - const wert = aufwandMatrixWert(projekt, lph, rolle); - if(wert !== null) return wert; - const vorlage = aufwandVorlage()[lph]; - const v = vorlage ? parseFloat(vorlage[rolle]) : NaN; - return isNaN(v) ? 1.0 : v; -} - -// zuordnungsGewicht: Wie schwer eine Zuordnung wiegt. 1 = Hauptverantwortung, -// 2 = Unterstützung. Die Unterstützung zählt halb, weil sie sonst dieselbe -// Stundenzahl trüge wie die Hauptverantwortung und die Arbeit rechnerisch -// gleichmäßig verteilt würde, obwohl sie es nicht ist. -function zuordnungsGewicht(status){ - return status === 2 ? 0.5 : status === 1 ? 1 : 0; -} - -// projektVerteilWochen: über wie viele Wochen die offenen Stunden eines -// Projekts verteilt werden. Aus dem Terminplan das späteste Datum (dieselbe -// Quelle, aus der auch der Verlauf das Projektende liest), gerechnet ab -// heute; ohne Terminplan oder bei bereits verstrichenem Ende gilt der -// firmenweite Verteilzeitraum. Parameter: projekt. Rückgabe: Wochen (>= 1). -function projektVerteilWochen(projekt){ - const vorgabe = Math.max(1, Number(APP_SETTINGS.verteilzeitraumWochen) || 12); - const plan = projekt && projekt.plan; - if(!plan) return vorgabe; - const daten = []; - (Array.isArray(plan.todos) ? plan.todos : []).forEach(t => { if(t && t.end) daten.push(t.end); }); - (Array.isArray(plan.milestones) ? plan.milestones : []).forEach(m => { if(m && m.date) daten.push(m.date); }); - if(daten.length === 0) return vorgabe; - const ende = new Date(daten.sort().pop()); - if(isNaN(ende.getTime())) return vorgabe; - const heute = new Date(); - heute.setHours(0, 0, 0, 0); - const tage = daysBetween(heute, ende); - // Ein Ende in der Vergangenheit sagt nichts über die kommenden Wochen — - // dann gilt wieder die Vorgabe, statt die Restarbeit auf null Wochen zu - // verteilen und eine unendliche Wochenlast zu erzeugen. - if(!Number.isFinite(tage) || tage <= 0) return vorgabe; - return Math.max(1, tage / 7); -} +// Eriks Vorgabe (10/2026): Die Auslastung entsteht ALLEIN aus Aufgaben mit +// eingetragenem Aufwand (t.aufwand, Stunden). Hat eine Aufgabe 8 h und eine +// zuständige Person, ist diese Person mit 8 h ausgelastet; bei zwei +// Zuständigen je 4 h, bei drei je ein Drittel. Aufgaben ohne Aufwand zählen +// nicht. Das frühere Phasenbudget (Honorar × Phasenanteil ÷ Stundensatz, +// verteilt über eine Gewichtungstabelle Leistungsphase × Rolle) ist +// entfallen — samt Tabelle (firmenweit im Admin-Bereich und je Projekt). +// Die Stunden verteilen sich auf die Arbeitstage des Aufgabenzeitraums +// (verteileAufBalken()); ohne Termine auf die nächsten UEBERFAELLIG_WOCHEN. // fortschrittProzent: Fortschritt einer Leistungsphase am Projekt (0–100). // Die Werte kommen aus einem Textfeld und können "80", "80 %" oder "12,5" @@ -254,63 +101,138 @@ function arbeitsPhase(projekt, amtierend){ return folgende.find(l => fortschrittProzent(projekt, l) < 100) || null; } -// berechneAuslastung: Wochenstunden EINER Zuordnung (Person/Rolle) zu einem -// Projekt. Parameter: mitarbeiter (ungenutzt, Signatur beibehalten), projekt, -// aktuelleLP, rolle, status (1 = Haupt, 2 = Unterstützung), alleZuordnungen -// (optional: das assign-Objekt des Projekts, für die Aufteilung auf mehrere -// Köpfe derselben Rolle). -// Rückgabe: { stunden, load, anbahnung?, unbewertbar?, grund? }. -// stunden = Wochenstunden dieser Person aus diesem Projekt -// load = dieselbe Größe als Anteil einer Vollzeitstelle (Rückwärts- -// kompatibilität für js/pdf.js, das den Wert weiterhin so nennt) -function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status, alleZuordnungen){ - const leer = { stunden: 0, load: 0 }; - if(!projekt || projekt.group === 'In Anbahnung') return { stunden: 0, load: 0, anbahnung: true }; - if(!aktuelleLP) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne amtierende Leistungsphase' }); +// --------------------------------------------------------------------------- +// Wochenvorschau: VORSCHAU_WOCHEN Kalenderwochen ab dieser Woche; "diese +// Woche" ist Woche 0 — die Zahl, die Wandtafel und Projektbereich zeigen. +// --------------------------------------------------------------------------- +const VORSCHAU_WOCHEN = 12; +// Eine offene Aufgabe, deren Ende schon vorbei ist (oder die keine Termine +// hat): ihre Reststunden fallen in die nächsten Wochen an. +const UEBERFAELLIG_WOCHEN = 4; - const basis = projektBasisHonorar(projekt); - if(basis.betrag <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar und ohne Projektgröße' }); +// vorschauMontag: Montag der aktuellen Woche, 0 Uhr Ortszeit. +function vorschauMontag(){ + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); + return d; +} - // Gerechnet wird mit der Phase, in der tatsächlich gearbeitet wird — der - // amtierenden oder, wenn die fertig ist, der nächsten offenen. - const phase = arbeitsPhase(projekt, aktuelleLP); - if(!phase) return Object.assign({}, leer, { fertig: true }); +// vorschauWochen: die Kalenderwochen der Vorschau. Rückgabe: [{montag, kw}]. +function vorschauWochen(){ + const m = vorschauMontag(); + return Array.from({ length: VORSCHAU_WOCHEN }, (_, i) => { + const d = new Date(m); d.setDate(d.getDate() + 7 * i); + return { montag: d, kw: getWeekNumber(d) }; + }); +} - const anteil = phasenAnteilProzent(projekt, phase); - if(anteil <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar-Anteil für diese Phase' }); +// balkenPhase: zu welcher Leistungsphase ein Terminplan-Balken gehört — +// allein über das Feld lph (Eriks Wunsch: nicht mehr über den Titel). Gesetzt +// von "+ Neu" → "Leistungsphase", "Terminplan aus Honorar" und dem +// Projekt-Assistenten; ältere Balken hat der Server einmalig anhand ihres +// Titels gekennzeichnet (migrateAddPlanTodoLeistungsphase()). +function balkenPhase(t){ + return (t && t.lph) || ''; +} - const offenerWert = basis.betrag * anteil / 100 * (1 - fortschrittProzent(projekt, phase) / 100); - if(offenerWert <= 0) return Object.assign({}, leer, { fertig: true }); - - const satz = Number(APP_SETTINGS.stundensatz) || 100; - const gesamtStunden = offenerWert / satz; - - // Rollenanteil — nach der Phase, in der gearbeitet wird - const gewichte = ROLES.map(r => rollenGewicht(projekt, phase, r)); - const summeGewichte = gewichte.reduce((a, b) => a + b, 0); - const eigenesGewicht = rollenGewicht(projekt, phase, rolle); - if(summeGewichte <= 0 || eigenesGewicht <= 0) return leer; - const rollenStunden = gesamtStunden * (eigenesGewicht / summeGewichte); - - // Aufteilung auf die Köpfe dieser Rolle - const eigenAnteil = zuordnungsGewicht(status); - if(eigenAnteil <= 0) return leer; - let summeKoepfe = eigenAnteil; - if(alleZuordnungen && typeof alleZuordnungen === 'object'){ - summeKoepfe = 0; - Object.keys(alleZuordnungen).forEach(key => { - if(key.slice(key.indexOf('|') + 1) !== rolle) return; - summeKoepfe += zuordnungsGewicht(alleZuordnungen[key]); - }); - if(summeKoepfe <= 0) summeKoepfe = eigenAnteil; +// verteileAufBalken: legt Stunden gleichmäßig auf die Arbeitstage eines +// Zeitraums (ohne Wochenenden und Feiertage) und summiert sie je Woche der +// Vorschau. Was vor heute läge, rückt nach vorn; ein schon abgelaufener +// Balken verteilt seinen Rest auf die nächsten UEBERFAELLIG_WOCHEN. +function verteileAufBalken(verlauf, stunden, start, ende){ + const heute = new Date(); heute.setHours(0, 0, 0, 0); + let von = start, bis = ende; + if(bis < heute){ + von = heute; + bis = new Date(heute); bis.setDate(bis.getDate() + UEBERFAELLIG_WOCHEN * 7 - 1); + } else if(von < heute) von = heute; + const montag = vorschauMontag(); + const tage = []; + const d = new Date(von); + for(let n = 0; d <= bis && n < 1200; n++, d.setDate(d.getDate() + 1)){ + if(isWorkday(d)) tage.push(new Date(d)); } - const personStunden = rollenStunden * (eigenAnteil / summeKoepfe); + if(!tage.length) tage.push(new Date(von)); + const proTag = stunden / tage.length; + tage.forEach(t => { + const i = Math.floor((t - montag) / (7 * 864e5)); + if(i >= 0 && i < VORSCHAU_WOCHEN) verlauf[i] += proTag; + }); +} - const wochen = projektVerteilWochen(projekt); - const stunden = personStunden / wochen; - const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40; +// --------------------------------------------------------------------------- +// Aufgaben mit Aufwand: Der noch offene Teil (Aufwand × (1 − Fortschritt)) +// verteilt sich zu gleichen Teilen auf die Zuständigen und auf die +// Arbeitstage des Aufgabenzeitraums. Phasenbalken selbst sind keine Aufgaben +// in diesem Sinn. +// --------------------------------------------------------------------------- - return { stunden, load: stunden / vollzeit, quelle: basis.quelle, wochen, phase }; +// offeneAufwandsAufgaben: offene Aufgaben eines Projekts mit Aufwand und +// mindestens einem Zuständigen. Rückgabe: [{t, rest}]. +function offeneAufwandsAufgaben(projekt){ + const todos = (projekt && projekt.plan && Array.isArray(projekt.plan.todos)) ? projekt.plan.todos : []; + return todos + .filter(t => t && !t.done && !balkenPhase(t) && Number(t.aufwand) > 0 + && Array.isArray(t.assignees) && t.assignees.length) + .map(t => ({ t, rest: Number(t.aufwand) * (1 - Math.min(100, Math.max(0, Number(t.progress) || 0)) / 100) })) + .filter(a => a.rest > 0); +} + +// aufgabenZeitraum: Zeitraum einer Aufgabe; ohne Termine die nächsten +// UEBERFAELLIG_WOCHEN ab heute. +function aufgabenZeitraum(t){ + const a = t.start ? parseDateISO(t.start) : null; + const e = t.end ? parseDateISO(t.end) : a; + if(a && e && e >= a) return { start: a, ende: e }; + const heute = new Date(); heute.setHours(0, 0, 0, 0); + const bis = new Date(heute); bis.setDate(bis.getDate() + UEBERFAELLIG_WOCHEN * 7 - 1); + return { start: heute, ende: bis }; +} + +// aufgabenVerlaufJePerson: Aufgabenstunden je Person und Woche der +// Vorschau. Rückgabe: { personId: [Stunden je Woche] }. +function aufgabenVerlaufJePerson(projekt){ + const out = {}; + offeneAufwandsAufgaben(projekt).forEach(({ t, rest }) => { + const z = aufgabenZeitraum(t); + const jeKopf = rest / t.assignees.length; + t.assignees.forEach(id => { + const v = out[id] || (out[id] = new Array(VORSCHAU_WOCHEN).fill(0)); + verteileAufBalken(v, jeKopf, z.start, z.ende); + }); + }); + return out; +} + +// globalePerson: der Eintrag aus db.globalPeople (Konto) zu einer Person der +// Sitzung — per id, sonst über den vollen Namen (siehe kapazitaetsFaktor()). +function globalePerson(person){ + const list = db.globalPeople || []; + return list.find(p => p.id === person.id) || (person.fullName ? list.find(p => p.fullName === person.fullName) : null); +} + +// wochenKapazitaet: Kapazität einer Person je Woche der Vorschau in +// Stunden — Vertragsstunden je Arbeitstag mal verfügbare Tage (ohne +// Wochenende, Feiertage und eingetragene Abwesenheiten). +// Rückgabe: [{ stunden, tage, abwesend }]. +function wochenKapazitaet(person){ + const voll = (Number(APP_SETTINGS.vollzeitStundenProWoche) || 40) * kapazitaetsFaktor(person); + const proTag = voll / 5; + const g = globalePerson(person); + const abw = (g && Array.isArray(g.absences) ? g.absences : []) + .map(a => ({ von: a.start || '', bis: a.end || a.start || '' })).filter(a => a.von); + return vorschauWochen().map(w => { + let tage = 0, abwesend = 0; + for(let i = 0; i < 5; i++){ + const d = new Date(w.montag); d.setDate(d.getDate() + i); + if(!isWorkday(d)) continue; + const iso = formatDateISO(d); + if(abw.some(a => iso >= a.von && iso <= a.bis)){ abwesend++; continue; } + tage++; + } + return { stunden: tage * proTag, tage, abwesend }; + }); } // stundenFormat: Wochenstunden in deutscher Schreibweise, eine @@ -352,121 +274,6 @@ function loadAmpelColor(x){ return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)'; } -// ---------- LP-Aufwand Eingabemaske ---------- - -// lpModalProjectId: Projekt-ID des aktuell offenen LP-Aufwand-Modals (null = geschlossen). -let lpModalProjectId = null; - -// lpRowsHtml: erzeugt die Aufwand-Maske als Matrix-Tabelle. -// Zeilen = Leistungsphasen (alle ohne "W+M"), Spalten = Rollen (Planung/Projektleitung/ -// Bauüberwachung). Die amtierende Leistungsphase wird pro Projekt im Tab -// "Projekte" gewählt (renderProjects / editProjectActiveLP). -// prefix: 'lp' = Bearbeiten-Modal, 'np' = Neues-Projekt-Modal (eindeutige IDs). -// Leere Felder werden mit den Werten der firmenweiten Vorlage vorbefüllt. -// Die Feld-IDs bleiben identisch, damit collectLpRows() unverändert funkioniert. -function lpRowsHtml(p, prefix){ - const head = ROLES.map(r => `${esc(r)}`).join(''); - const rows = AUFWAND_LPS.map(l => { - const idx = l.replace(/[^\d]/g,''); - const row = (p.aufwandProLP && typeof p.aufwandProLP[l] === 'object') ? p.aufwandProLP[l] : {}; - const cells = ROLES.map((r, ri) => { - const raw = row[r]; - const vorlage = aufwandVorlage()[l] ? aufwandVorlage()[l][r] : undefined; - const base = (raw !== undefined && raw !== null && raw !== '') ? raw - : (vorlage !== undefined && vorlage !== null && vorlage !== '') ? vorlage : ''; - const val = base !== '' ? String(parseFloat(base)).replace('.', ',') : ''; - // Platzhalter zeigt den Vorgabewert dieser Phase/Rolle, denn genau der - // gilt, solange das Feld leer bleibt (siehe berechneAuslastung()). - const ph = (vorlage !== undefined && vorlage !== null && vorlage !== '') ? String(parseFloat(vorlage)).replace('.', ',') : '1,0'; - return ``; - }).join(''); - return `${l}${cells}`; - }).join(''); - return ` - ${head} - ${rows} -
Leistungsphase
`; -} - -// collectLpRows: liest die Aufwand-Maske aus und validiert die Werte (0–1). -// Rückgabe: { aufwand, hasValue } oder null bei Eingabefehler. -function collectLpRows(prefix){ - const aufwand = {}; - let hasValue = false; - for(const l of AUFWAND_LPS){ - const idx = l.replace(/[^\d]/g,''); - const entry = {}; - let rowHasValue = false; - let invalid = false; - for(let ri = 0; ri < ROLES.length; ri++){ - const r = ROLES[ri]; - const input = document.getElementById(prefix + 'Val' + idx + 'R' + ri); - if(!input) continue; - const raw = input.value.trim().replace(',', '.'); - if(raw === '') continue; - // Strenge Prüfung statt parseFloat: Das nahm auch "0.5.5" (→ 0.5) oder - // "1x" (→ 1) kommentarlos an. - const v = /^\d+(\.\d+)?$/.test(raw) ? parseFloat(raw) : NaN; - if(isNaN(v) || v < 0 || v > 1){ - alert('Bitte Werte zwischen 0 und 1 eintragen (z. B. 0,4). (' + l + ', ' + r + ')\n\n' - + 'Ein leeres Feld bedeutet: es gilt der Vorgabewert dieser Leistungsphase. ' - + 'Für "kein Aufwand" bitte ausdrücklich 0 eintragen.'); - invalid = true; - break; - } - entry[r] = v; - rowHasValue = true; - } - if(invalid) return null; - if(rowHasValue){ aufwand[l] = entry; hasValue = true; } - } - return { aufwand, hasValue }; -} - -// applyLpRows: übernimmt das Ergebnis von collectLpRows auf ein Projekt. -// Die Maske ist mit den Werten der firmenweiten Vorlage vorbefüllt; -// Felder ohne Vorbefüllung, die leer bleiben, zählen bei gesetzter aktiver LP -// später als Standard-Faktor 1,0; ohne aktive LP zählt das Projekt 0. -// Die amtierende Leistungsphase wird separat über den Tab "Projekte" gesetzt. -function applyLpRows(p, lp){ - if(!lp) return false; - p.aufwandProLP = lp.hasValue ? lp.aufwand : undefined; - return true; -} - -// openLpModal: öffnet das Modal "Aufwand je Leistungsphase" für ein Projekt. -// Parameter: pid = Projekt-ID; gesperrte Wochen werden abgewiesen. Kein Rückgabewert. -function openLpModal(pid){ - if(guardWeekLocked()) return; - const p = currentWeek().projects.find(x=>x.id===pid); - if(!p) return; - lpModalProjectId = pid; - const titleEl = document.getElementById('lpModalProject'); - if(titleEl) titleEl.textContent = (p.nr ? p.nr + ' – ' : '') + (p.name || ''); - const rowsEl = document.getElementById('lpModalRows'); - if(rowsEl) rowsEl.innerHTML = lpRowsHtml(p, 'lp'); - document.getElementById('lpModal').style.display = 'flex'; -} - -// closeLpModal: blendet das LP-Aufwand-Modal aus und setzt lpModalProjectId zurück. -function closeLpModal(){ - document.getElementById('lpModal').style.display = 'none'; - lpModalProjectId = null; -} - -// saveLpModal: liest die LP-Maske aus, übernimmt sie auf das Projekt, speichert -// und rendert Board, Projekte und Auslastung neu; danach schließt sich das Modal. -function saveLpModal(){ - if(guardWeekLocked()) return; - const p = currentWeek().projects.find(x=>x.id===lpModalProjectId); - if(!p) return; - const lp = collectLpRows('lp'); - if(lp === null) return; - if(applyLpRows(p, lp)) save(); - renderBoard(); renderProjects(); renderLoad(); - closeLpModal(); -} - // ---------- Neues Projekt (mit LP-Aufwand beim Anlegen) ---------- // Der Dialog "Neues Projekt" ist entfallen — openNewProjModal(), @@ -476,9 +283,6 @@ function saveLpModal(){ // und jeden davon erst nach vollständiger Eingabe freigibt. Der Grund für den // Wechsel: Honorar, Team und Schlagwörter mussten zuvor NACH dem Anlegen an // drei weiteren Stellen nachgetragen werden — und wurden es meist nicht. -// -// collectLpRows()/applyLpRows()/lpRowsHtml() bleiben: Sie tragen weiterhin -// die Aufwandsmaske openLpModal(), die aus dem Projektbereich aufgerufen wird. // PROJEKT_FARBEN: Palette der Lastband-Segmente. Bewusst gedämpfte Mitteltöne // und vom Ampelspektrum (grün/gelb/rot) getrennt — ein Segment sagt aus, @@ -512,236 +316,204 @@ function projektFarbe(name){ // und Balkenskala sind firmenweit einstellbar (Bereich „Einstellungen"). // Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person // überhaupt im Teilnehmerkreis stand — nicht durch alle vorhandenen. +// loadProjekteOffen: Personen (Name), deren Projektliste aufgeklappt ist. +const loadProjekteOffen = new Set(); + +// loadProjekteUmschalten: klappt die Projekte einer Personenkarte auf/zu, +// ohne die Seite neu zu zeichnen. Parameter: knopf (trägt data-person). +function loadProjekteUmschalten(knopf){ + const name = knopf.dataset.person || ''; + const box = knopf.closest('.load-chips'); + if(!box) return; + const offen = !box.classList.contains('ist-offen'); + box.classList.toggle('ist-offen', offen); + if(offen) loadProjekteOffen.add(name); else loadProjekteOffen.delete(name); + knopf.setAttribute('aria-expanded', String(offen)); + const anzahl = box.querySelectorAll('.ist-weitere').length; + knopf.textContent = offen ? 'weniger' : '+ ' + anzahl + ' weitere'; +} + function renderLoad(){ - const selectedWeek = currentWeek(); - const weeks = selectedWeek ? [selectedWeek] : []; - if(weeks.length === 0){ - document.getElementById('loadGrid').innerHTML = '
Keine Sitzung ausgewählt oder die Daten werden noch geladen.
'; + const w = currentWeek(); + const grid = document.getElementById('loadGrid'); + if(!grid) return; + if(!w){ + grid.innerHTML = '
Keine Sitzung ausgewählt oder die Daten werden noch geladen.
'; return; } - const grouped = peopleByRole(selectedWeek); - const stats = {}; - const personen = {}; // fullName -> { person, stunden, rollen:Set } - ROLES.forEach(r=>{ - grouped[r].forEach(entry=>{ - stats[assignKey(entry.person.id, r)] = {stunden:0,projects:{},anbahnung:0,weeks:0}; - }); - }); + const wochen = vorschauWochen(); + const grouped = peopleByRole(w); - // Je Person zählen, in wie vielen der ausgewerteten Sitzungen sie im - // Teilnehmerkreis stand. Ohne das wurde durch ALLE Sitzungen geteilt: Ein - // neuer Kollege, der seit zwei von zwanzig Sitzungen voll eingeplant ist, - // erschien mit 0,30 statt 3,00 — also als praktisch unbeschäftigt. - weeks.forEach(w => { - (Array.isArray(w.people) ? w.people : []).forEach(person => { - ROLES.forEach(r => { - const key = assignKey(person.id, r); - if(stats[key]) stats[key].weeks++; - }); + // Je MENSCH (über alle Rollen): Stunden je Woche, Projekte, Rollen. + const koepfe = {}; + const kopfVon = person => { + const schluessel = person.fullName || person.name; + return koepfe[schluessel] || (koepfe[schluessel] = { + person, ids: new Set(), rollen: new Set(), verlauf: new Array(VORSCHAU_WOCHEN).fill(0), + projekte: {}, anbahnung: new Set() }); - }); + }; + ROLES.forEach(r => grouped[r].forEach(entry => { + const k = kopfVon(entry.person); + k.ids.add(entry.person.id); + k.rollen.add(r); + })); + const kopfZuId = {}; + Object.values(koepfe).forEach(k => k.ids.forEach(id => { kopfZuId[id] = k; })); + // Wer keine Fachrolle hat, aber eine Aufgabe, bekommt ebenfalls eine + // Karte — sonst wäre seine Aufgabe nirgends sichtbar. + const kopfFuerId = id => { + if(kopfZuId[id]) return kopfZuId[id]; + const person = (w.people || []).find(x => x.id === id); + if(!person) return null; + const k = kopfVon(person); + k.ids.add(id); + kopfZuId[id] = k; + return k; + }; - // Nicht bewertbare Projekte werden MITGEZÄHLT statt übergangen: Ein Projekt - // ohne amtierende Phase, ohne Honorar und ohne Größe trug bisher still 0 - // bei und war von echter Unterbeschäftigung nicht zu unterscheiden. Die - // Seite weist die Lücke jetzt oben aus. - const luecken = {}; // Projektname -> Grund + // Nicht bewertbare Projekte werden ausgewiesen statt still als 0 gezählt. + const luecken = {}; let bewertbareProjekte = 0, alleProjekte = 0; - weeks.forEach(w=>{ - w.projects.forEach(pr=>{ - if(pr.group === 'In Anbahnung') return; - alleProjekte++; - const aktivePhase = lpActiveList(pr)[0]; - const probe = berechneAuslastung(null, pr, aktivePhase, ROLES[0], 1, null); - const projektName = pr.nr ? pr.nr + ' ' + pr.name : pr.name; - if(probe.unbewertbar){ luecken[projektName] = probe.grund; } - else bewertbareProjekte++; + (w.projects || []).forEach(pr => { + const anbahnung = pr.group === 'In Anbahnung'; + const assign = pr.assign || {}; + const projektName = pr.nr ? pr.nr + ' ' + pr.name : pr.name; - // Vergebene Stunden je Projekt mitzählen: Ein Projekt kann bewertbar - // sein und trotzdem bei niemandem ankommen — etwa wenn es in LPH 8 - // steht (die die Bauüberwachung trägt), aber nur ein Planer zugeordnet - // ist. Die Arbeit fällt an, wird aber niemandem zugerechnet; das ist - // eine Lücke und keine Null. - let vergeben = 0; + // Projektanzahl je Person: jede Zuordnung zählt, unabhängig davon, ob + // das Projekt bewertbar ist — "wer hat wie viele Projekte". + Object.keys(assign).forEach(key => { + const st = assign[key]; + if(st !== 1 && st !== 2) return; + const k = kopfZuId[key.slice(0, key.indexOf('|'))]; + if(!k) return; + if(anbahnung){ k.anbahnung.add(projektName); return; } + const e = k.projekte[projektName] || (k.projekte[projektName] = { haupt: false, stunden: 0 }); + if(st === 1) e.haupt = true; + }); - Object.keys(pr.assign).forEach(aKey=>{ - const s = stats[aKey]; - if(!s) return; - const st = pr.assign[aKey]; - const rolle = aKey.slice(aKey.indexOf('|') + 1); - const al = berechneAuslastung(null, pr, aktivePhase, rolle, st, pr.assign); - if(al.anbahnung){ - if(st===1 || st===2) s.anbahnung++; - return; - } - if(al.stunden > 0){ - vergeben += al.stunden; - s.stunden += al.stunden; - // Der Stundenbeitrag des einzelnen Projekts wird behalten, nicht - // nur gezählt: Das Lastband zeigt die Summe aufgeteilt nach - // Projekten. Gerechnet wird nichts Zusätzliches — derselbe Wert, - // der ohnehin aufaddiert wird, wurde bisher nur verworfen. - const eintrag = s.projects[pr.name] || (s.projects[pr.name] = { anzahl:0, stunden:0, nr: pr.nr, quelle: al.quelle }); - eintrag.anzahl++; - eintrag.stunden += al.stunden; - if(!eintrag.nr && pr.nr) eintrag.nr = pr.nr; - } + // A: Zuständige offener Aufgaben zählen als Beteiligte des Projekts, + // auch wenn sie unter "Beteiligte" nicht eingetragen sind. + const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : []; + todos.forEach(t => { + if(!t || t.done || balkenPhase(t)) return; + (Array.isArray(t.assignees) ? t.assignees : []).forEach(id => { + const k = kopfFuerId(id); + if(!k) return; + if(anbahnung){ k.anbahnung.add(projektName); return; } + if(!k.projekte[projektName]) k.projekte[projektName] = { haupt: false, stunden: 0 }; }); - - if(!probe.unbewertbar && !probe.fertig && vergeben === 0 && !luecken[projektName]){ - // Welche Rolle trüge die Arbeit? Die mit dem höchsten Gewicht in der - // Phase, in der gerade gearbeitet wird. - const phase = arbeitsPhase(pr, aktivePhase); - const tragend = phase - ? ROLES.slice().sort((a,b)=> rollenGewicht(pr, phase, b) - rollenGewicht(pr, phase, a))[0] - : null; - luecken[projektName] = tragend - ? 'niemand für ' + tragend + ' zugeordnet (' + phase + ')' - : 'niemand zugeordnet'; - bewertbareProjekte--; - } }); + // B: Aufgaben mit Aufwand bringen ihre Stunden mit — auch in Projekten, + // die (noch) nicht bewertbar oder schon fertig gerechnet sind. + const aufgabenStd = aufgabenVerlaufJePerson(pr); + Object.keys(aufgabenStd).forEach(id => { + const k = kopfFuerId(id); + if(!k) return; + aufgabenStd[id].forEach((h, i) => { k.verlauf[i] += h; }); + const e = k.projekte[projektName]; + if(e) e.stunden += aufgabenStd[id].reduce((x, y) => x + y, 0); + }); + + if(anbahnung) return; + + // Bewertet ist ein Projekt, wenn mindestens eine offene Aufgabe mit + // Aufwand und Zuständigen darin steht — sonst trägt es nichts bei, und + // das wird ausgewiesen statt still als Null gezählt. + alleProjekte++; + if(offeneAufwandsAufgaben(pr).length) bewertbareProjekte++; + else { + const offen = todos.filter(t => t && !t.done && !balkenPhase(t)); + luecken[projektName] = offen.length === 0 ? 'keine offenen Aufgaben' + : offen.some(t => Number(t.aufwand) > 0) ? 'Aufgaben mit Aufwand ohne Zuständige' + : 'keine Aufgabe mit Aufwand'; + } }); - // Wochenstunden: über die Sitzungen gemittelt, in denen die Person im - // Teilnehmerkreis stand. - const wochenStunden = k => stats[k].stunden / Math.max(1, stats[k].weeks || weeks.length); + // Karten vorbereiten: Kapazität je Woche (ohne Urlaub/Feiertage), Quote, + // Spitze. + const MAX_QUOTE = 1.4; + const karten = Object.values(koepfe).map(k => { + const kap = wochenKapazitaet(k.person); + const quoten = k.verlauf.map((h, i) => kap[i].stunden > 0 ? h / kap[i].stunden : null); + let spitze = -1, spitzeIdx = -1; + quoten.forEach((q, i) => { if(q !== null && q > spitze){ spitze = q; spitzeIdx = i; } }); + const projektNamen = Object.keys(k.projekte); + return { k, kap, quoten, spitze: Math.max(0, spitze), spitzeIdx, projektNamen, + haupt: projektNamen.filter(n => k.projekte[n].haupt).length }; + }).sort((a, b) => (b.projektNamen.length > 0) - (a.projektNamen.length > 0) || b.spitze - a.spitze); - // Kapazität einer Person in Stunden je Woche (aus der Benutzerverwaltung, - // sonst die firmenweite Vollzeit). - const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40; - const kapazitaetStunden = person => kapazitaetsFaktor(person) * vollzeit; - - // Summe je MENSCH über alle Rollen. Bisher fehlte sie: Wer in drei Rollen - // arbeitet, hat trotzdem nur eine Arbeitswoche — jede Rollenzeile blieb - // unter der Schwelle, die Überlastung wurde nirgends rot. Ein Mensch mit - // 0,80 Planung und 0,40 Projektleitung ist bei 1,20, und das gehört - // sichtbar an den Anfang. - const koepfe = {}; - ROLES.forEach(r=>{ - grouped[r].forEach(entry=>{ - const schluessel = entry.person.fullName || entry.person.name; - const k = koepfe[schluessel] || (koepfe[schluessel] = { person: entry.person, stunden: 0, rollen: [] }); - const std = wochenStunden(assignKey(entry.person.id, r)); - k.stunden += std; - if(std > 0) k.rollen.push(r); - }); - }); - - let html = ""; - - // --- Kopf: was die Zahlen abdecken --- + const prozent = q => Math.round(q * 100) + ' %'; + const ueberlastet = karten.filter(c => c.spitze >= schwelleRot()); const luckenNamen = Object.keys(luecken); - const ueberlastet = Object.values(koepfe).filter(k => k.stunden / kapazitaetStunden(k.person) >= schwelleRot()); - html += `
-
- ${bewertbareProjekte} von ${alleProjekte} Projekten bewertet -
- ${ueberlastet.length ? `
${ueberlastet.length} ${ueberlastet.length === 1 ? 'Person ist' : 'Personen sind'} über der Kapazität
` : ''} - ${luckenNamen.length ? `` : ''} + + let html = `
+
${bewertbareProjekte} von ${alleProjekte} Projekten mit Aufwand in Aufgaben
+ ${ueberlastet.length ? `
${ueberlastet.length} ${ueberlastet.length === 1 ? 'Person ist' : 'Personen sind'} in den nächsten ${VORSCHAU_WOCHEN} Wochen über der Kapazität
` : ''} + ${luckenNamen.length ? `` : ''}
`; if(luckenNamen.length){ html += ``; } + html += `

Auslastung der nächsten ${VORSCHAU_WOCHEN} Wochen: Gezählt werden die offenen Stunden aller Aufgaben mit Aufwand, zu gleichen Teilen auf ihre Zuständigen und auf die Arbeitstage der Aufgabe verteilt. Gestrichelt ist die volle Kapazität, schraffiert sind Wochen mit Urlaub oder anderer Abwesenheit.

`; - // --- Summe je Mensch --- - const kopfListe = Object.values(koepfe) - .filter(k => k.stunden > 0) - .sort((a,b)=> b.stunden / kapazitaetStunden(b.person) - a.stunden / kapazitaetStunden(a.person)); - if(kopfListe.length){ - html += `
Gesamt je Person
`; - kopfListe.forEach(k=>{ - const kap = kapazitaetStunden(k.person); - const quote = kap > 0 ? k.stunden / kap : 0; - const ampel = loadAmpelColor(quote); - const pct = Math.min(100, quote * 100); - html += `
-
-
${esc(k.person.fullName || k.person.name)}
-
${esc(k.rollen.join(' · ') || '—')}
-
-
-
-
-
-
${stundenFormat(k.stunden)}h
-
`; - }); - html += `
`; - } - - // --- Je Rolle --- - const benutzteProjekte = {}; - ROLES.forEach(r=>{ - html += `
${r}
`; - if(grouped[r].length===0){ - html += `
Keine Personen.
`; - return; - } - const sortiert = grouped[r].slice().sort((a,b)=> - wochenStunden(assignKey(b.person.id, r)) - wochenStunden(assignKey(a.person.id, r))); - - sortiert.forEach(entry=>{ - const s = stats[assignKey(entry.person.id, r)]; - const kap = kapazitaetStunden(entry.person); - const nWeeks = Math.max(1, s.weeks || weeks.length); - const std = wochenStunden(assignKey(entry.person.id, r)); - const quote = kap > 0 ? std / kap : 0; - // Ohne jede Zuordnung KEINE Ampelfarbe: Grün hieße "gut ausgelastet", - // die Null heißt aber "nichts zugeordnet". - const ampel = std > 0 ? loadAmpelColor(quote) : 'var(--text-subtle)'; - - const stelleBadge = Math.abs(kapazitaetsFaktor(entry.person) - 1) < 0.005 ? '' - : `${stundenFormat(kap)} h/Woche`; - const anbahnungBadge = (s.anbahnung && s.anbahnung > 0) - ? `${s.anbahnung} in Anbahnung` : ''; - - // Segmente: je Projekt eines, Breite als Anteil der KAPAZITÄT. Die - // Skala ist damit nicht mehr willkürlich (früher fest 8, während die - // Ampel bei 1,0 rot wurde — ein roter Balken war zu 12,5 % gefüllt), - // sondern: volles Band = volle Woche. - const namen = Object.keys(s.projects).sort((a,b)=> s.projects[b].stunden - s.projects[a].stunden); - const segmente = namen.map(name=>{ - const eintrag = s.projects[name]; - const anteilStd = eintrag.stunden / nWeeks; - const breite = kap > 0 ? Math.min(100, anteilStd / kap * 100) : 0; - const farbe = projektFarbe(name); - if(!benutzteProjekte[name]) benutzteProjekte[name] = { nr: eintrag.nr, farbe, quelle: eintrag.quelle }; - const titel = (eintrag.nr ? eintrag.nr + ' · ' : '') + name - + ' · ' + stundenFormat(anteilStd) + ' h/Woche' - + (eintrag.quelle === 'groesse' ? ' · aus der Projektgröße' : '') - + (eintrag.anzahl > 1 ? ' · in ' + eintrag.anzahl + ' Sitzungen' : ''); - const beschriftung = breite >= 7 ? `${esc(eintrag.nr || name)}` : ''; - return `
${beschriftung}
`; - }).join(''); - - html += `
-
-
${esc(entry.person.fullName || entry.person.name)}
-
${esc(entry.person.name)}${stelleBadge}${anbahnungBadge}
+ html += '
' + karten.map(c => { + const k = c.k; + const name = k.person.fullName || k.person.name; + const anzahl = c.projektNamen.length; + const jetztKap = c.kap[0].stunden; + const jetzt = c.quoten[0]; + const jetztText = jetzt === null ? 'abwesend' : prozent(jetzt); + const jetztFarbe = jetzt === null ? 'var(--text-muted)' : (k.verlauf[0] > 0 ? loadAmpelColor(jetzt) : 'var(--text-subtle)'); + const teilzeit = Math.abs(kapazitaetsFaktor(k.person) - 1) < 0.005 ? '' + : `${stundenFormat(kapazitaetsFaktor(k.person) * (Number(APP_SETTINGS.vollzeitStundenProWoche) || 40))} h/Woche`; + const saeulen = c.quoten.map((q, i) => { + const kap = c.kap[i]; + const h = k.verlauf[i]; + const titel = 'KW ' + wochen[i].kw + ': ' + (kap.stunden > 0 + ? stundenFormat(h) + ' h von ' + stundenFormat(kap.stunden) + ' h (' + prozent(q) + ')' + (kap.abwesend ? ', ' + kap.abwesend + ' Tag(e) abwesend' : '') + : 'abwesend' + (h > 0 ? ', ' + stundenFormat(h) + ' h geplant' : '')); + if(q === null) return ``; + const hoehe = Math.max(h > 0 ? 3 : 0, Math.min(q, MAX_QUOTE) / MAX_QUOTE * 100); + return ``; + }).join(''); + const kws = wochen.map((wo, i) => `${wo.kw}`).join(''); + const hinweis = anzahl === 0 ? '
Keinem Projekt zugeordnet.
' + : c.spitze >= schwelleRot() + ? `
Spitze ${prozent(c.spitze)} in KW ${wochen[c.spitzeIdx].kw}: Aufgaben verschieben oder abgeben.
` + : c.spitze >= schwelleGelb() + ? `
Höchstens ${prozent(c.spitze)} (KW ${wochen[c.spitzeIdx].kw}): ausgelastet, aber im Rahmen.
` + : `
Keine Überlastung in den nächsten ${VORSCHAU_WOCHEN} Wochen.
`; + const top = c.projektNamen.slice().sort((a, b) => k.projekte[b].stunden - k.projekte[a].stunden); + // Alle Projekte stehen im Markup; ab dem dritten sind sie eingeklappt + // und lassen sich über den Knopf aufklappen (Eriks Wunsch). Der Zustand + // je Person überlebt das Neuzeichnen (loadProjekteOffen). + const offen = loadProjekteOffen.has(name); + const chipTitel = n => n + (k.projekte[n].stunden > 0 ? ' · ' + stundenFormat(k.projekte[n].stunden) + ' h in 12 Wochen' : ''); + const chips = top.map((n, i) => `${esc(n)}`).join('') + + (top.length > 2 ? `` : ''); + const anbahnung = k.anbahnung.size ? `, ${k.anbahnung.size} in Anbahnung` : ''; + return `
+
+ ${esc(k.person.name)} + ${esc(name)} + ${anzahl === 1 ? '1 Projekt' : anzahl + ' Projekte'}${anzahl ? `, davon ${c.haupt} in Hauptverantwortung` : ''}${anbahnung} + ${teilzeit} + ${jetztText}diese Woche${jetztKap > 0 ? ', ' + stundenFormat(k.verlauf[0]) + ' h' : ''}
-
- ${segmente || 'keine Zuordnung'} + -
${stundenFormat(std)}h
-
`; - }); - html += `
`; - }); + + ${hinweis} + ${chips ? `
${chips}
` : ''} + `; + }).join('') + '
'; - const legende = Object.keys(benutzteProjekte).sort().map(name=>{ - const e = benutzteProjekte[name]; - return `${esc(e.nr ? e.nr + ' ' + name : name)}${e.quelle === 'groesse' ? '≈' : ''}`; - }).join(''); - if(legende){ - const hatSchaetzung = Object.values(benutzteProjekte).some(e => e.quelle === 'groesse'); - html += `
${legende}
`; - if(hatSchaetzung) html += `

≈ aus der Projektgröße gerechnet, nicht aus einem hinterlegten Honorar.

`; - } - - document.getElementById('loadGrid').innerHTML=html; + grid.innerHTML = html; } \ No newline at end of file diff --git a/public/js/pdf.js b/public/js/pdf.js index d32d7c3..0041e0a 100644 --- a/public/js/pdf.js +++ b/public/js/pdf.js @@ -4,7 +4,7 @@ // Dieses Modul stellt die drei PDF-Exporte der Anwendung bereit — jeder in // seinem eigenen, klar abgegrenzten Abschnitt dieser Datei: // * exportWochenberichtPDF() — Wochenbericht (Übersicht + Akte je Gewerk) -// * exportSandboxPlanPDF() — Planung (Balkenplan eines Sandbox-Terminplans) +// * exportSandboxPlanPDF() — Terminplan (Balkenplan eines Projekts oder Planung-Plans) // * exportProtokollPDF() — Protokolle (Besprechungsprotokoll je Projekt) // Ein früherer, inzwischen entfernter dritter Export, exportPDF() (eine // gewerkeübergreifende A3-Tabellenübersicht der ganzen Sitzung, samt @@ -19,10 +19,9 @@ // PDF_PAD_X/PDF_LINE_W). // // Was unten bleibt, wird von MEHREREN der drei Exporte gebraucht (nicht -// zwingend allen): Farbkonstanten, die A3-quer-Seitengeometrie -// PDF_PAGE_W/PDF_MARGIN_X/PDF_USABLE_W (nur exportSandboxPlanPDF() — die -// beiden anderen sind A4 hochkant, eigene Konstanten PDF_WB_PAGE_W/ -// PDF_WB_MARGIN_X weiter unten), PDF_STYLES, pdfStatusText() +// zwingend allen): Farbkonstanten, PDF_STYLES (der Terminplan bringt seine +// eigene Geometrie in PDF_TP mit, Wochenbericht/Protokoll sind A4 hochkant +// mit PDF_WB_PAGE_W/PDF_WB_MARGIN_X weiter unten), pdfStatusText() // (Kommentar-HTML -> pdfmake-Rich-Text), pdfNormalizeColor() und // loadLogoPng(). @@ -35,14 +34,6 @@ const PDF_ASSIGN_MAIN = '#248A3D'; const PDF_ASSIGN_OPT = '#D18B00'; -// --- Seitengeometrie (A3 quer, in pt) — nur von exportSandboxPlanPDF() genutzt; -// exportWochenberichtPDF()/exportProtokollPDF() sind A4 hochkant, siehe -// PDF_WB_PAGE_W/PDF_WB_MARGIN_X weiter unten --- -const PDF_PAGE_W = 1190.55; // A3 quer, vorher A4: 841.89 -const PDF_MARGIN_X = 20; -const PDF_USABLE_W = PDF_PAGE_W - PDF_MARGIN_X * 2; // 1150,55 - - // PDF_STYLES: wiederverwendbare Textstile (Überschriften, Tabellen-/Zellformat) // für die pdfmake-Dokumentdefinition const PDF_STYLES = { @@ -393,6 +384,15 @@ function pdfWbGruppentitel(name, topMargin){ // Landkarte der Sitzung — was hat sich geändert, was ist offen — bevor ab // Seite 2 der vollständige Text folgt. Parameter: liste (aus // berichtProjekte()), prevWeek. Rückgabe: Array von pdfmake-Content-Blöcken. +// pdfHaken: Haken als Vektorgrafik. Das Zeichen "✓" fehlt in der +// PDF-Schrift (Roboto aus vfs_fonts.js) und erschien als leeres Kästchen — +// Eriks Meldung. Parameter: farbe, breite (Spaltenbreite für die Mitte). +function pdfHaken(farbe, breite){ + return { canvas: [{ type: 'polyline', lineWidth: 1.6, lineColor: farbe, lineCap: 'round', + points: [{ x: 0, y: 4 }, { x: 3.2, y: 7.2 }, { x: 9, y: 0.5 }] }], + margin: [Math.max(0, (breite - 9) / 2 - 4), 0, 0, 0] }; +} + function pdfWbUebersichtBlock(liste, prevWeek){ const groups = {}; liste.forEach(p => (groups[p.group] || (groups[p.group] = [])).push(p)); @@ -407,11 +407,11 @@ function pdfWbUebersichtBlock(liste, prevWeek){ { text: 'Projekt', style: 'th' }, { text: 'Änderung', style: 'th' }, { text: 'Amt. LPH', style: 'th' }, - { text: '✓', style: 'th', alignment: 'center' } + { text: 'Bespr.', style: 'th', alignment: 'center' } ]; const body = [header]; projekte.forEach(p => { - const change = classifyProjectChange(p, prevWeek); + const change = classifyProjectChange(p, prevWeek, wochenberichtGewerk); const info = pdfWbAktuelleLphInfo(p); body.push([ pdfWbChangeDot(change.bucket), @@ -419,10 +419,10 @@ function pdfWbUebersichtBlock(liste, prevWeek){ { text: p.name, style: 'td', bold: true }, { text: change.bucket !== 'unchanged' ? change.reason : '', style: 'td' }, { text: info ? info.lph + ' · ' + info.pct + ' %' : '–', style: 'td' }, - { text: p.besprochen ? '✓' : '', style: 'tdCenter', color: PDF_ASSIGN_MAIN } + kommentarVon(p, wochenberichtGewerk).besprochen ? pdfHaken(PDF_ASSIGN_MAIN, 38) : { text: '', style: 'tdCenter' } ]); }); - blocks.push({ table: { headerRows: 1, widths: [14, 40, '*', 118, 76, 18], body }, + blocks.push({ table: { headerRows: 1, widths: [14, 40, '*', 118, 76, 38], body }, layout: pdfWbGridLayout(true), margin: [0, 0, 0, 4] }); }); return blocks; @@ -436,11 +436,12 @@ function pdfWbUebersichtBlock(liste, prevWeek){ // ausdrückliches false — es gibt keinen dritten Zustand. Parameter: w // (Sitzung). Rückgabe: Array von pdfmake-Content-Blöcken. function pdfWbAnwesenheitBlock(w){ - const people = Array.isArray(w.people) ? w.people : []; + // Nur das Gewerk des Berichts — wie am Bildschirm (renderAttendance()). + const people = personenDesGewerks(w, wochenberichtGewerk); if(!people.length){ return [ - pdfWbGruppentitel('Anwesenheit der Sitzung', 18), - { text: 'Für diese Sitzung sind keine Projektbeteiligten hinterlegt.', + pdfWbGruppentitel('Anwesenheit ' + (wochenberichtGewerk || ''), 18), + { text: 'Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.', fontSize: 8, italics: true, color: '#86868B' } ]; } @@ -460,7 +461,7 @@ function pdfWbAnwesenheitBlock(w){ body.push(row); } return [ - pdfWbGruppentitel('Anwesenheit der Sitzung', 18), + pdfWbGruppentitel('Anwesenheit ' + (wochenberichtGewerk || ''), 18), { text: anwesend + ' von ' + people.length + ' anwesend', fontSize: 8, color: '#6E6E73', margin: [0, 0, 0, 6] }, { table: { widths: [16, '*', 16, '*', 16, '*'], body }, layout: pdfWbGridLayout(false) } ]; @@ -472,7 +473,7 @@ function pdfWbAnwesenheitBlock(w){ // Beteiligten-Tabelle. Parameter: p (Projekt), w (Sitzung), prevWeek // (Vorwoche-Objekt oder null), allePersonen. Rückgabe: pdfmake-Content-Block. function pdfWbProjektAkte(p, w, prevWeek, allePersonen){ - const change = classifyProjectChange(p, prevWeek); + const change = classifyProjectChange(p, prevWeek, wochenberichtGewerk); const spineColor = PDF_WB_CHANGE_COLORS[change.bucket] || PDF_WB_CHANGE_COLORS.unchanged; const statusChip = auftragsstatusOf(p) === 'beauftragt' ? '' : ' · ' + auftragsstatusLabel(auftragsstatusOf(p)); const tags = Array.isArray(p.tags) && p.tags.length ? ' [' + p.tags.join(', ') + ']' : ''; @@ -483,7 +484,7 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){ { text: (p.nr ? p.nr + ' ' : '') + p.name, bold: true, fontSize: 10 }, { text: tags + statusChip, fontSize: 7.5, color: '#6E6E73' } ], width: '*' }, - { text: p.besprochen ? '✓ besprochen' : '', fontSize: 7, color: PDF_ASSIGN_MAIN, + { text: kommentarVon(p, wochenberichtGewerk).besprochen ? '✓ besprochen' : '', fontSize: 7, color: PDF_ASSIGN_MAIN, alignment: 'right', width: 70 } ], margin: [0, 0, 0, 3] }; @@ -492,11 +493,17 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){ ? { text: change.reason, fontSize: 7.5, bold: true, color: spineColor, margin: [0, 0, 0, 5] } : { text: '', margin: [0, 0, 0, 1] }; - const kommentarInhalt = pdfStatusText(p.status || ''); + // Der Kommentar des Gewerks, für das der Bericht erstellt wird. + const kommentarInhalt = pdfStatusText(kommentarVon(p, wochenberichtGewerk).status || ''); const kommentar = (kommentarInhalt.text && kommentarInhalt.text.length) ? Object.assign({}, kommentarInhalt, { fontSize: 8 }) : { text: 'Kein Kommentar.', fontSize: 7.5, italics: true, color: '#86868B' }; + // Der zuletzt geschriebene frühere Kommentar desselben Gewerks (wie die + // Zeile "Zuletzt KW …" auf dem Bildschirm, berichtVerlaufDaten() in + // wochenbericht.js) — mit Formatierung, abgesetzt unter dem aktuellen. + const vorher = pdfWbVorigerKommentar(p, w); + const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0]; const lphTable = pdfWbLphMiniTable(p, aktLp); const beteiligteTable = pdfWbBeteiligteTable(p, allePersonen); @@ -507,13 +514,13 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){ rightStack.push({ text: 'BETEILIGTE', fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 10, 0, 3] }); rightStack.push(beteiligteTable); if(abwesend.length){ - rightStack.push({ text: 'Fehlt heute: ' + abwesend.join(', '), fontSize: 7, italics: true, + rightStack.push({ text: 'Heute abwesend: ' + abwesend.join(', '), fontSize: 7, italics: true, color: '#D70015', margin: [0, 6, 0, 0] }); } const innerContent = { columns: [ - { width: '*', stack: [kopf, aenderung, kommentar] }, + { width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) }, { width: 190, stack: rightStack } ], columnGap: 12 @@ -541,6 +548,36 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){ }; } +// pdfWbVorigerKommentar: Block mit dem jüngsten früheren Kommentar des +// Gewerks. Steht er in der direkten Vorwoche, heißt er "Vorwoche KW …", +// sonst "Zuletzt KW …". Parameter: p (Projekt), w (Sitzung). Rückgabe: +// pdfmake-Block oder null (kein früherer Kommentar). +function pdfWbVorigerKommentar(p, w){ + if(!p.projectUid || typeof berichtVerlaufDaten !== 'function') return null; + const e = berichtVerlaufDaten(p, w, wochenberichtGewerk).eintraege[0]; + if(!e) return null; + const inhalt = pdfStatusText(e.html || ''); + if(!inhalt.text || !inhalt.text.length) return null; + const vorwoche = findPreviousWeek(w); + const titel = (vorwoche && vorwoche.label === e.week.label ? 'VORWOCHE ' : 'ZULETZT ') + + berichtKurzKw(e.week.label).toUpperCase() + + (e.autor ? ' · ' + e.autor : ''); + return { + table: { widths: ['*'], body: [[{ + stack: [ + { text: titel, fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 0, 0, 2] }, + Object.assign({}, inhalt, { fontSize: 7.5, color: '#3A3A3C' }) + ], + fillColor: '#F5F5F7' + }]] }, + layout: { + hLineWidth: () => 0, vLineWidth: () => 0, + paddingLeft: () => 6, paddingRight: () => 6, paddingTop: () => 4, paddingBottom: () => 5 + }, + margin: [0, 8, 0, 0] + }; +} + // exportWochenberichtPDF: Hauptfunktion. Erzeugt aus der Tagesordnung der // aktuellen Sitzung ein ablagefähiges, zweiteiliges PDF — Seite 1 die // Übersicht samt Anwesenheit, ab Seite 2 die vollständige Akte je Projekt, @@ -574,7 +611,7 @@ async function exportWochenberichtPDF(){ ROLES.forEach(r => grouped[r].forEach(e => allePersonen.push(e))); const logo = await loadLogoPng(); - const ab = liste.filter(p => p.besprochen).length; + const ab = liste.filter(p => kommentarVon(p, wochenberichtGewerk).besprochen).length; const gewerkLabel = (typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[wochenberichtGewerk]) || wochenberichtGewerk; const kopf = []; @@ -640,218 +677,306 @@ async function exportWochenberichtPDF(){ } } // ----------------------------- -// Export PDF — Planung (Sandbox-Terminplan) +// Export PDF — Terminplan (Projekt und Planung) // ----------------------------- -// exportSandboxPlanPDF() ist der ANDERE der beiden verbleibenden PDF-Exporte -// (der erste ist exportWochenberichtPDF() oben): ein klassischer Balkenplan -// (Entwurf A der Design-Vergleiche, siehe CLAUDE.md "Planung — -// projektunabhängige Terminplan-Sandbox") für EINEN Terminplan der -// Planung-Sandbox — durchgehende Zeitachse, Balken je Aufgabe, Rauten für -// Meilensteine, eine rote Linie für "heute", genau wie am Bildschirm im -// Terminplan des Projektbereichs. A3 quer — eine Zeitachse braucht die -// Breite —, aber kein Übersichts-Raster wie eine Sitzungstabelle, sondern -// EIN Terminplan, deshalb kein eigenes Deckblatt. +// Balkenplan EINES Terminplans, A3 quer, Gestaltung „A2 — volles Raster“ +// (Eriks Wahl aus dem Entwurfsvergleich): links eine Vorgangsliste (Vorgang, +// Start, Ende), rechts die Balken über Monaten (im Wechsel hinterlegt) und +// Kalenderwochen, Gitterlinien über das ganze Blatt. Gegliedert nach +// Leistungsphasen wie der Terminplan am Bildschirm: jeder Phasenbalken +// (balkenPhase()) wird eine dunkelblaue Kopfzeile mit Sammelbalken und +// Fortschritt aus dem Wochenbericht, darunter die Aufgaben, die in ihm +// beginnen (aufgabenPhase()), und die Meilensteine in seinem Zeitraum. Der +// Rest steht unter „Weitere Aufgaben“. Keine Zuständigen, keine Heute-Linie +// (Eriks Vorgabe). // -// Es gab vor diesem Export keinerlei Gantt-Balken-PDF in dieser Anwendung — -// nur flache Prozent-Balken (LPH-/Auslastungs-Balken oben). Die Technik ist -// dieselbe (canvas-Rechtecke mit berechnetem x/w), nur nach Datum statt nach -// Prozent positioniert. Lange Zeiträume werden NICHT auf mehrere Blätter -// aufgeteilt, sondern auf die feste Blattbreite zusammengestaucht (wie die -// Bildschirmansicht bei pxPerDay=null) — für sehr lange Pläne ist das gröber, -// aber deutlich einfacher als eine Kachelung über mehrere Seiten, und für -// eine Sandbox (kein Abgabedokument) ausreichend. Abhängigkeitslinien sind -// bewusst nicht Teil dieser ersten Fassung — sie sind im Bildschirm-Terminplan -// ohnehin sichtbar, das PDF ist in erster Linie eine mitnehmbare -// Datums-Momentaufnahme. +// Technik: Jede Seite wird frei positioniert (absolutePosition) — ein +// Canvas für Flächen, Linien und Balken, Texte als eigene Knoten darüber. +// pdfmake kann Text nicht innerhalb eines Canvas setzen. Passen nicht alle +// Zeilen auf ein Blatt, geht es auf weiteren Seiten mit demselben Kopf weiter. -const PDF_PLAN_LABEL_W = 190; -const PDF_PLAN_ROW_H = 18; +const PDF_TP = { + W: 1190.55, H: 841.89, M: 32, + top: 84, kopfH: 34, unten: 60, + spalten: [ ['Vorgang', 280], ['Start', 60], ['Ende', 60] ], + blau: '#0f5a85', ink: '#1a1f24', grau: '#5f6a73', gitter: '#c9d1d8', + woche: '#dde3e8', monatHell: '#f4f6f8', phase: '#8fb2c9', + aufgabeRand: '#4a6f88', inArbeit: '#4a86b3', erledigt: '#2e7a50' +}; -// pdfPlanDayX: x-Position (pt, relativ zum Beginn der Zeitachse) eines -// Datums innerhalb der Chart-Spalte. Parameter: date (Date), rangeStart -// (Date), totalDays (Zahl), chartW (pt). Rückgabe: Zahl (pt), auf den -// sichtbaren Bereich begrenzt. -function pdfPlanDayX(date, rangeStart, totalDays, chartW){ - const tage = Math.max(0, Math.min(totalDays, daysBetween(rangeStart, date))); - return chartW * tage / totalDays; +const PDF_TP_LPH_NAMEN = { + 'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung', + 'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe', + 'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung', + 'W+M': 'Wartung und Monitoring' +}; + +// pdfTpDatum: 'TT.MM.JJ'. Parameter: Date. Rückgabe: String. +function pdfTpDatum(d){ + return String(d.getDate()).padStart(2, '0') + '.' + String(d.getMonth() + 1).padStart(2, '0') + '.' + + String(d.getFullYear()).slice(2); } -// pdfPlanMonatsAchse: teilt [rangeStart, rangeEnd] in Monatsabschnitte für -// die Kopfzeile der Zeitachse. Rückgabe: Array {label, x1, x2}. -function pdfPlanMonatsAchse(rangeStart, rangeEnd, totalDays, chartW){ - const monate = []; - let cur = new Date(rangeStart.getFullYear(), rangeStart.getMonth(), 1); - let sicherheit = 0; - while(cur <= rangeEnd && sicherheit++ < 60){ - const naechster = new Date(cur.getFullYear(), cur.getMonth() + 1, 1); - const segStart = cur < rangeStart ? rangeStart : cur; - const segEnd = naechster > rangeEnd ? addDays(rangeEnd, 1) : naechster; - monate.push({ - label: segStart.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' }), - x1: pdfPlanDayX(segStart, rangeStart, totalDays, chartW), - x2: pdfPlanDayX(segEnd, rangeStart, totalDays, chartW) - }); - cur = naechster; - } - return monate; +// pdfTpKuerzen: kürzt Text auf eine Breite (pt) bei gegebener Schriftgröße — +// grob geschätzt, Roboto ist im Mittel gut eine halbe Schriftgröße breit. +function pdfTpKuerzen(text, breite, groesse){ + const max = Math.floor(breite / (groesse * 0.52)); + const t = String(text || ''); + return t.length > max ? t.slice(0, Math.max(1, max - 1)) + '…' : t; } -// pdfPlanAchsenZeile: die Kopfzeile der Zeitachse (Monatsbeschriftungen), -// über pdfmake columns statt canvas — Textlabel lassen sich in pdfmake nicht -// frei innerhalb eines Canvas positionieren, wohl aber über Spaltenbreiten. -function pdfPlanAchsenZeile(monate){ +// pdfTpText: frei positionierter Text in einem Kasten der Breite w. +// opts: size, bold, italics, color, align. +function pdfTpText(x, y, w, text, opts){ + const o = opts || {}; + w = Math.max(1, Math.min(w, PDF_TP.W - PDF_TP.M - x)); // nie über den rechten Rand return { - columns: [ - { width: PDF_PLAN_LABEL_W, text: 'Aufgabe / Meilenstein', fontSize: 7.5, bold: true, color: '#6E6E73' }, - ...monate.map(m => ({ - width: Math.max(2, m.x2 - m.x1), text: m.label, fontSize: 7, color: '#6E6E73' - })) - ], - margin: [0, 0, 0, 3] + absolutePosition: { x, y }, + columns: [{ width: w, text: String(text), fontSize: o.size || 9, bold: !!o.bold, italics: !!o.italics, + color: o.color || PDF_TP.ink, alignment: o.align || 'left', noWrap: true }] }; } -// pdfPlanBarFarbe: Balkenfarbe nach Aufgabenstand — dieselben drei Zustände, -// die die Aufgabe tatsächlich kennt (t.done, t.progress); anders als der -// Entwurf im Design-Vergleich gibt es hier keine vierte, erfundene Kategorie -// "geplant", weil dafür kein echtes Datenfeld existiert. -function pdfPlanBarFarbe(t){ - if(t.done) return PDF_ASSIGN_MAIN; - if((Number(t.progress) || 0) > 0) return '#0071E3'; - return '#D2D2D7'; -} - -// pdfPlanZeile: eine Zeile des Balkenplans (Aufgabe ODER Meilenstein) als -// pdfmake columns-Objekt. Parameter: label (String), aktiv (Boolean, fett), -// canvasInhalt (Array von canvas-Primitiven für die Chart-Spalte). -function pdfPlanZeile(label, kursiv, canvasInhalt){ - return { - columns: [ - { width: PDF_PLAN_LABEL_W, text: label, fontSize: 7.5, italics: !!kursiv, - color: kursiv ? '#6E6E73' : '#1D1D1F', margin: [0, (PDF_PLAN_ROW_H - 9) / 2, 4, 0] }, - { width: '*', canvas: canvasInhalt } - ] +// pdfTpZeilen: die Zeilen des Plans in Anzeigereihenfolge — je Phase eine +// Kopfzeile, ihre Aufgaben und Meilensteine, am Ende „Weitere Aufgaben“. +// Parameter: p (Projekt). Rückgabe: [{art:'phase'|'aufgabe'|'ms', …}]. +function pdfTpZeilen(p){ + const plan = p.plan || {}; + const todos = (Array.isArray(plan.todos) ? plan.todos : []) + .filter(t => t.start && t.end && t.showInGantt !== false && parseDateISO(t.start) && parseDateISO(t.end)); + const ms = (Array.isArray(plan.milestones) ? plan.milestones : []).filter(m => parseDateISO(m.date)); + const nachStart = (a, b) => String(a.start).localeCompare(String(b.start)); + const phasen = todos.filter(t => balkenPhase(t)).sort(nachStart); + const aufgaben = todos.filter(t => !balkenPhase(t)); + const msPhase = m => { + const d = parseDateISO(m.date); + const k = phasen.find(b => d >= parseDateISO(b.start) && d <= parseDateISO(b.end)); + return k ? balkenPhase(k) : ''; }; + const zeilen = []; + const gruppe = (kopf, l) => { + const eigene = aufgaben.filter(t => aufgabenPhase(t, todos) === l).sort(nachStart); + const eigeneMs = ms.filter(m => msPhase(m) === l).sort((a, b) => String(a.date).localeCompare(String(b.date))); + if(!kopf && !eigene.length && !eigeneMs.length) return; + zeilen.push(kopf || { art: 'phase', titel: 'Weitere Aufgaben' }); + eigene.forEach(t => zeilen.push({ art: 'aufgabe', titel: t.title || 'Aufgabe', + von: parseDateISO(t.start), bis: parseDateISO(t.end), + fort: t.done ? 100 : Math.max(0, Math.min(100, Number(t.progress) || 0)) })); + eigeneMs.forEach(m => zeilen.push({ art: 'ms', titel: m.name || 'Meilenstein', datum: parseDateISO(m.date) })); + }; + phasen.forEach(b => { + const l = balkenPhase(b); + gruppe({ art: 'phase', titel: l + (PDF_TP_LPH_NAMEN[l] ? ' ' + PDF_TP_LPH_NAMEN[l] : ''), + von: parseDateISO(b.start), bis: parseDateISO(b.end), + fort: typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0 }, l); + }); + gruppe(null, ''); + return zeilen; } -function pdfPlanTodoZeile(t, rangeStart, totalDays, chartW, todayX){ - const s = parseDateISO(t.start) || rangeStart; - const e = parseDateISO(t.end) || s; - const x1 = pdfPlanDayX(s, rangeStart, totalDays, chartW); - const x2 = Math.max(x1 + 2, pdfPlanDayX(addDays(e, 1), rangeStart, totalDays, chartW)); - const canvasInhalt = [ - { type: 'rect', x: x1, y: 4, w: x2 - x1, h: PDF_PLAN_ROW_H - 8, color: pdfPlanBarFarbe(t) }, - { type: 'line', x1: 0, y1: PDF_PLAN_ROW_H, x2: chartW, y2: PDF_PLAN_ROW_H, lineWidth: 0.4, lineColor: '#ECECEC' } - ]; - if(todayX !== null){ - canvasInhalt.push({ type: 'line', x1: todayX, y1: 0, x2: todayX, y2: PDF_PLAN_ROW_H, lineWidth: 0.75, lineColor: '#D70015' }); +// pdfTpLegende: die Legende als Spalten für die Fußzeile (Zeichen + Text). +function pdfTpLegende(){ + const T = PDF_TP, sp = []; + [ + [{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.phase }, 'Leistungsphase'], + [{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.inArbeit }, 'in Arbeit'], + [{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.erledigt }, 'erledigt'], + [{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: '#ffffff', lineColor: T.aufgabeRand, lineWidth: 0.8 }, 'offen'], + [{ type: 'polyline', closePath: true, color: T.ink, points: [{ x: 7, y: 0 }, { x: 12, y: 5 }, { x: 7, y: 10 }, { x: 2, y: 5 }] }, 'Meilenstein'] + ].forEach(([form, text]) => { + sp.push({ width: 20, canvas: [form] }); + sp.push({ width: text.length * 4.4 + 16, text, fontSize: 8, color: T.grau }); + }); + return sp; +} + +// pdfTpSeite: alle Knoten EINER Seite. Parameter: zeilen (die dieser Seite), +// rowH, x (Datum → x), range {start, ende}, kopf (Knoten des Seitenkopfs), +// ersteSeite. Rückgabe: Array von pdfmake-Knoten. +function pdfTpSeite(zeilen, rowH, x, range, kopf, ersteSeite){ + const T = PDF_TP, M = T.M, cx0 = M + T.spalten.reduce((s, c) => s + c[1], 0), cx1 = T.W - M; + const bodyTop = T.top + T.kopfH, bodyBot = bodyTop + zeilen.length * rowH; + const plus1 = d => addDays(d, 1); + const flaechen = [], linien = [], balken = [], texte = []; + + // Monate im Wechsel hinterlegt, mit Namen + let m = new Date(range.start.getFullYear(), range.start.getMonth(), 1), i = 0; + while(m <= range.ende && i < 120){ + const naechster = new Date(m.getFullYear(), m.getMonth() + 1, 1); + const a = Math.max(cx0, x(m)), e = Math.min(cx1, x(naechster)); + if(e > a){ + if(i % 2 === 0) flaechen.push({ type: 'rect', x: a, y: T.top, w: e - a, h: bodyBot - T.top, color: T.monatHell }); + const name = m.toLocaleDateString('de-DE', { month: 'short' }).replace('.', '') + ' ' + String(m.getFullYear()).slice(2); + if(e - a > 30) texte.push(pdfTpText(a, T.top + 4.5, e - a, name, { bold: true, align: 'center' })); + linien.push({ type: 'line', x1: a, y1: T.top, x2: a, y2: bodyBot, lineWidth: 0.8, lineColor: T.gitter }); + } + m = naechster; i++; } - return pdfPlanZeile(t.title || 'Aufgabe', false, canvasInhalt); -} - -function pdfPlanMeilensteinZeile(m, rangeStart, totalDays, chartW, todayX){ - const d = parseDateISO(m.date) || rangeStart; - const x = pdfPlanDayX(d, rangeStart, totalDays, chartW); - const cy = PDF_PLAN_ROW_H / 2, r = 4.5; - const canvasInhalt = [ - { type: 'polyline', points: [{ x: x, y: cy - r }, { x: x + r, y: cy }, { x: x, y: cy + r }, { x: x - r, y: cy }], - closePath: true, color: '#0071E3' }, - { type: 'line', x1: 0, y1: PDF_PLAN_ROW_H, x2: chartW, y2: PDF_PLAN_ROW_H, lineWidth: 0.4, lineColor: '#ECECEC' } - ]; - if(todayX !== null){ - canvasInhalt.push({ type: 'line', x1: todayX, y1: 0, x2: todayX, y2: PDF_PLAN_ROW_H, lineWidth: 0.75, lineColor: '#D70015' }); + // Kalenderwochen: Linie je Woche, Nummer nur so oft, wie Platz ist + const wocheB = x(addDays(range.start, 7)) - x(range.start); + const schritt = Math.max(1, Math.ceil(11 / Math.max(0.1, wocheB))); + for(let w = new Date(range.start), n = 0; w <= range.ende; w = addDays(w, 7), n++){ + const a = x(w); + linien.push({ type: 'line', x1: a, y1: T.top + 17, x2: a, y2: bodyBot, lineWidth: 0.5, lineColor: T.woche }); + if(n % schritt === 0) texte.push(pdfTpText(a, T.top + 23, wocheB * schritt, getWeekNumber(w), { size: 6.5, color: T.grau, align: schritt === 1 ? 'center' : 'left' })); } - return pdfPlanZeile('◆ ' + (m.name || 'Meilenstein'), true, canvasInhalt); + linien.push({ type: 'line', x1: cx0, y1: T.top + 17, x2: cx1, y2: T.top + 17, lineWidth: 0.6, lineColor: T.gitter }); + + // Tabellenkopf links + flaechen.push({ type: 'rect', x: M, y: T.top, w: cx0 - M, h: T.kopfH, color: '#e9edf1' }); + let sx = M; + T.spalten.forEach(([name, w]) => { + texte.push(pdfTpText(sx + 6, T.top + 12, w - 8, name, { bold: true })); + sx += w; + if(sx < cx0) linien.push({ type: 'line', x1: sx, y1: T.top, x2: sx, y2: bodyBot, lineWidth: 0.6, lineColor: T.gitter }); + }); + const xStart = M + T.spalten[0][1], xEnde = xStart + T.spalten[1][1]; + + // Zeilen + zeilen.forEach((z, k) => { + const y = bodyTop + k * rowH, my = y + rowH / 2, ty = my - 4.6; + if(z.art === 'phase'){ + flaechen.push({ type: 'rect', x: M, y, w: cx0 - M, h: rowH, color: T.blau }); + texte.push(pdfTpText(M + 6, ty - 0.4, T.spalten[0][1] - 10, pdfTpKuerzen(z.titel, T.spalten[0][1] - 10, 9.5), { size: 9.5, bold: true, color: '#ffffff' })); + if(z.von){ + texte.push(pdfTpText(xStart + 6, ty, 54, pdfTpDatum(z.von), { bold: true, color: '#ffffff' })); + texte.push(pdfTpText(xEnde + 6, ty, 54, pdfTpDatum(z.bis), { bold: true, color: '#ffffff' })); + const a = x(z.von), e = Math.max(a + 3, x(plus1(z.bis))); + balken.push({ type: 'rect', x: a, y: my - 5, w: e - a, h: 10, color: T.phase }); + if(z.fort > 0) balken.push({ type: 'rect', x: a, y: my - 5, w: (e - a) * z.fort / 100, h: 10, color: T.blau }); + // Prozent rechts neben dem Balken; am rechten Rand links davor — + // Text über den Seitenrand hinaus erzeugte eine leere Zusatzseite. + if(e + 45 <= T.W - M) texte.push(pdfTpText(e + 5, my - 4, 40, z.fort + ' %', { size: 8, bold: true, color: T.blau })); + else texte.push(pdfTpText(a - 45, my - 4, 40, z.fort + ' %', { size: 8, bold: true, color: T.blau, align: 'right' })); + } + } else if(z.art === 'aufgabe'){ + texte.push(pdfTpText(M + 16, ty, T.spalten[0][1] - 20, pdfTpKuerzen(z.titel, T.spalten[0][1] - 20, 9))); + texte.push(pdfTpText(xStart + 6, ty, 54, pdfTpDatum(z.von))); + texte.push(pdfTpText(xEnde + 6, ty, 54, pdfTpDatum(z.bis))); + const a = x(z.von), e = Math.max(a + 3, x(plus1(z.bis))); + balken.push({ type: 'rect', x: a, y: my - 5, w: e - a, h: 10, color: '#ffffff', lineColor: T.aufgabeRand, lineWidth: 0.8 }); + if(z.fort > 0) balken.push({ type: 'rect', x: a, y: my - 5, w: (e - a) * z.fort / 100, h: 10, color: z.fort >= 100 ? T.erledigt : T.inArbeit }); + } else { + texte.push(pdfTpText(M + 16, ty, T.spalten[0][1] - 20, pdfTpKuerzen(z.titel, T.spalten[0][1] - 20, 9), { italics: true })); + texte.push(pdfTpText(xStart + 6, ty, 54, pdfTpDatum(z.datum))); + const mx = x(z.datum), r = 6; + balken.push({ type: 'polyline', closePath: true, color: T.ink, + points: [{ x: mx, y: my - r }, { x: mx + r, y: my }, { x: mx, y: my + r }, { x: mx - r, y: my }] }); + texte.push(pdfTpText(mx - 9 - 40, my - 4, 40, pdfTpDatum(z.datum).slice(0, 6), { size: 8, align: 'right' })); + } + linien.push({ type: 'line', x1: M, y1: y + rowH, x2: cx1, y2: y + rowH, lineWidth: 0.5, lineColor: T.gitter }); + }); + + // Rahmen + const rahmen = [ + { type: 'rect', x: M, y: T.top, w: cx1 - M, h: bodyBot - T.top, lineColor: T.ink, lineWidth: 1 }, + { type: 'line', x1: cx0, y1: T.top, x2: cx0, y2: bodyBot, lineWidth: 1, lineColor: T.ink }, + { type: 'line', x1: M, y1: bodyTop, x2: cx1, y2: bodyTop, lineWidth: 1, lineColor: T.ink } + ]; + + const knoten = [{ canvas: [...flaechen, ...linien, ...balken, ...rahmen], absolutePosition: { x: 0, y: 0 } }]; + if(!ersteSeite) knoten[0].pageBreak = 'before'; + return [...knoten, ...kopf, ...texte]; } -// exportSandboxPlanPDF: Hauptfunktion. Erzeugt den Balkenplan-PDF-Export -// EINES Terminplans der Planung-Sandbox. Parameter: planId. Keine Rückgabe. +// exportSandboxPlanPDF: Einstieg vom Knopf „Ansicht → Als PDF exportieren“. +// Findet einen Planung-Plan oder das im Projektbereich geöffnete Projekt. +// Parameter: planId. Keine Rückgabe. async function exportSandboxPlanPDF(planId){ + const sandbox = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects)) + ? db.sandboxWeek.projects.find(x => x.id === planId) : null; + const app = window.PlanApp; + const p = sandbox || (app && app.project && app.project.id === planId ? app.project : null); + return exportTerminplanPDF(p, !!sandbox, sandbox ? null : (app && app.week)); +} + +// exportTerminplanPDF: erzeugt das PDF. Parameter: p (Projekt), sandbox +// (Boolean, Planung-Plan), week (Sitzung des Projekts oder null). +async function exportTerminplanPDF(p, sandbox, week){ if(!window.pdfMake){ alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. ' + 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.'); return; } - const p = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects)) - ? db.sandboxWeek.projects.find(x => x.id === planId) : null; if(!p){ alert('Dieser Terminplan wurde nicht gefunden.'); return; } try { - const plan = p.plan || {}; - const todos = (Array.isArray(plan.todos) ? plan.todos.slice() : []) - .sort((a, b) => (parseDateISO(a.start) || 0) - (parseDateISO(b.start) || 0)); - const milestones = (Array.isArray(plan.milestones) ? plan.milestones.slice() : []) - .sort((a, b) => (parseDateISO(a.date) || 0) - (parseDateISO(b.date) || 0)); - if(!todos.length && !milestones.length){ - alert('Dieser Terminplan enthält noch keine Aufgaben oder Meilensteine — es gibt nichts abzubilden.'); + const T = PDF_TP, M = T.M; + const zeilen = pdfTpZeilen(p); + const daten = []; + zeilen.forEach(z => { if(z.von) daten.push(z.von, z.bis); if(z.datum) daten.push(z.datum); }); + if(!daten.length){ + alert('Dieser Terminplan enthält noch keine Aufgaben mit Termin oder Meilensteine — es gibt nichts abzubilden.'); return; } - const range = ganttRangeFromProjects([p], 7); - const totalDays = Math.max(1, daysBetween(range.start, range.end) + 1); - const chartW = PDF_USABLE_W - PDF_PLAN_LABEL_W - 10; - const heute = new Date(); - heute.setHours(0, 0, 0, 0); - const todayX = (heute >= range.start && heute <= range.end) - ? pdfPlanDayX(heute, range.start, totalDays, chartW) : null; + // Zeitraum: Montag der ersten bis Sonntag der letzten Woche + const min = new Date(Math.min(...daten)), max = new Date(Math.max(...daten)); + const start = addDays(min, -((min.getDay() + 6) % 7)); + const ende = addDays(max, 6 - ((max.getDay() + 6) % 7)); + const range = { start, ende }; + const cx0 = M + T.spalten.reduce((s, c) => s + c[1], 0), cx1 = T.W - M; + const spanne = daysBetween(start, ende) + 1; + const x = d => cx0 + (cx1 - cx0) * daysBetween(start, d) / spanne; + // Zeilenhöhe: so groß wie möglich (höchstens 30 pt), mindestens 17 pt; + // reicht das nicht, geht es auf weiteren Seiten weiter. + const platz = T.H - T.unten - T.top - T.kopfH; + const rowH = Math.max(17, Math.min(30, platz / zeilen.length)); + const jeSeite = Math.max(1, Math.floor(platz / rowH)); + + // Seitenkopf const logo = await loadLogoPng(); - const kopf = []; - if(logo) kopf.push({ image: logo, width: 46, margin: [0, 0, 0, 6] }); - kopf.push( - { text: p.name, fontSize: 17, bold: true, color: '#1D1D1F' }, - { text: 'Terminplan — Planung, unabhängig von einem laufenden Projekt', - fontSize: 9, color: '#6E6E73', margin: [0, 2, 0, 0] } - ); - const honorar = Number(p.honorar) || 0; - const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []; - if(honorar > 0){ - kopf.push({ text: 'Honorar ' + honorar.toLocaleString('de-DE') + ' €' - + (beauftragt.length ? ' · ' + beauftragt.join(', ') : ''), - fontSize: 8, color: '#6E6E73', margin: [0, 2, 0, 0] }); + const titel = (!sandbox && p.nr ? p.nr + ' ' : '') + (p.name || 'Terminplan'); + const teile = [sandbox ? 'Terminplan — Planung, unabhängig von einem laufenden Projekt' : 'Terminplan']; + if(!sandbox && week && week.label) teile.push('Stand ' + week.label); + if(Number(p.honorar) > 0) teile.push('Honorar ' + Number(p.honorar).toLocaleString('de-DE') + ' €'); + if(Array.isArray(p.beauftragteLP) && p.beauftragteLP.length) teile.push('beauftragt ' + p.beauftragteLP.join(', ')); + // Je Seite neu gebaut: pdfmake verändert die Knoten beim Setzen, ein + // wiederverwendeter Kopf fehlte ab Seite 2. + const kopf = () => { + const k = [ + pdfTpText(M, 30, 900, pdfTpKuerzen(titel, 900, 18), { size: 18, bold: true }), + pdfTpText(M, 54, 900, teile.join(' · '), { size: 10, color: T.grau }) + ]; + if(logo) k.push({ image: logo, fit: [40, 40], absolutePosition: { x: T.W - M - 40, y: 26 } }); + return k; + }; + + const content = []; + for(let i = 0; i < zeilen.length; i += jeSeite){ + content.push(...pdfTpSeite(zeilen.slice(i, i + jeSeite), rowH, x, range, kopf(), i === 0)); } - kopf.push({ text: 'erstellt am ' + new Date().toLocaleDateString('de-DE'), - fontSize: 7.5, color: '#6E6E73', margin: [0, 2, 0, 16] }); - - const content = [...kopf]; - content.push(pdfPlanAchsenZeile(pdfPlanMonatsAchse(range.start, range.end, totalDays, chartW))); - todos.forEach(t => content.push(pdfPlanTodoZeile(t, range.start, totalDays, chartW, todayX))); - milestones.forEach(m => content.push(pdfPlanMeilensteinZeile(m, range.start, totalDays, chartW, todayX))); - - content.push({ - columns: [ - { width: 'auto', text: [{ text: '■ ', color: PDF_ASSIGN_MAIN }, { text: 'Erledigt' }], fontSize: 8 }, - { width: 'auto', text: [{ text: '■ ', color: '#0071E3' }, { text: 'In Arbeit' }], fontSize: 8 }, - { width: 'auto', text: [{ text: '■ ', color: '#D2D2D7' }, { text: 'Offen' }], fontSize: 8 }, - { width: 'auto', text: [{ text: '◆ ', color: '#0071E3' }, { text: 'Meilenstein' }], fontSize: 8 }, - { width: 'auto', text: [{ text: '┃ ', color: '#D70015' }, { text: 'heute' }], fontSize: 8 } - ], - columnGap: 16, margin: [0, 12, 0, 0] - }); + const fussText = 'Terminplan' + (!sandbox && p.nr ? ' ' + p.nr : '') + ' · erstellt am ' + new Date().toLocaleDateString('de-DE'); const doc = { pageSize: 'A3', pageOrientation: 'landscape', - pageMargins: [PDF_MARGIN_X, 30, PDF_MARGIN_X, 30], - defaultStyle: { font: 'Roboto', fontSize: 8, color: '#1D1D1F' }, - styles: PDF_STYLES, + pageMargins: [M, 0, M, 36], // oben 0: Die Seite ist frei positioniert; mit Rand passte der Canvas über die ganze Höhe nicht. + defaultStyle: { font: 'Roboto', fontSize: 9, color: T.ink }, + // Legende links und Seitenangabe rechts in der Fußzeile — als frei + // positionierter Text am Seitenende rutschte die Legende auf eine + // eigene, sonst leere Seite. footer: (page, count) => ({ - text: 'Planung · ' + p.name + ' · Seite ' + page + ' von ' + count, - alignment: 'right', fontSize: 6.5, color: '#6E6E73', - margin: [0, 8, PDF_MARGIN_X, 0] + margin: [M, 12, M, 0], + columns: [ + ...pdfTpLegende(), + { width: '*', text: fussText + ' · Seite ' + page + ' von ' + count, alignment: 'right', fontSize: 8, color: T.grau } + ] }), - info: { title: 'Terminplan ' + p.name, creator: 'Projektübersicht' }, - content: content + info: { title: 'Terminplan ' + (p.name || ''), creator: 'Projektübersicht' }, + content }; - const filename = 'Terminplan_Planung_' + String(p.name || 'Plan').replace(/[^\w]/g, '_') + '.pdf'; + const filename = (sandbox ? 'Terminplan_Planung_' : 'Terminplan_' + (p.nr ? p.nr + '_' : '')) + + String(p.name || 'Plan').replace(/[^\w]/g, '_') + '.pdf'; pdfMake.createPdf(doc).download(filename); } catch(err){ - console.error('Planung-PDF-Export fehlgeschlagen:', err); + console.error('Terminplan-PDF-Export fehlgeschlagen:', err); alert('PDF-Export fehlgeschlagen: ' + (err.message || err)); } } diff --git a/public/js/people/people.js b/public/js/people/people.js index fe7077d..bc251e3 100644 --- a/public/js/people/people.js +++ b/public/js/people/people.js @@ -28,7 +28,7 @@ // Parameter, keine Rückgabe. function renderPeople(){ const people = db.globalPeople || []; - const isAdmin = typeof currentUser !== 'undefined' && !!currentUser && currentUser.role === 'admin'; + const isAdmin = hatRecht('a.benutzer'); // Teilnehmerkreis pflegen: wie Benutzerverwaltung const w = currentWeek(); const inWeek = new Set((w && Array.isArray(w.people) ? w.people : []).map(p => p.id)); const locked = !w || isWeekLocked(w); diff --git a/public/js/plan/plan-build-gantt.js b/public/js/plan/plan-build-gantt.js index 40295d3..1d22bb5 100644 --- a/public/js/plan/plan-build-gantt.js +++ b/public/js/plan/plan-build-gantt.js @@ -86,7 +86,7 @@ // aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die // TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als // HTML-String zurückgegeben. Parameter: projects (Array), start/end (ISO- - // Datum), options (Optionen inkl. editable und criticalPath). Rückgabe: + // Datum), options (Optionen inkl. editable). Rückgabe: // String mit dem kompletten GANTT-HTML. window.buildGanttHtml = function(projects, start, end, options = {}){ if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options); @@ -169,12 +169,11 @@ const span = daysBetween(barStart, barEnd) + 1; const dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : ''; const handles = editable ? `` : ''; - const isCritical = options.criticalPath && options.criticalPath.includes(todo.id); const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0)); const conflictTitle = conflicts.length > 0 ? '\nAchtung: ' + conflicts.map(c => `${c.name} ${c.type.toLowerCase()} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('; ') : ''; - barHtml = `
${handles}
`; + barHtml = `
${handles}
`; } } // Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt. @@ -204,10 +203,22 @@ ? `${icon('warning')}` : ''; - row.innerHTML = `
+ // Ruhigere Namensspalte (Eriks Wahl "A"): Vorgänger stehen nicht mehr + // als Schildchen im Namen, sondern im Tooltip (und als Linie im + // Diagramm); rechts die Kürzel der Zuständigen. + const vorgaengerText = depBadge ? '\nVorgänger: ' + (depBadge.match(/title="Vorgänger: ([^"]*)"/) || [,''])[1] : ''; + const vorgaengerKonflikt = depBadge.indexOf('conflict') !== -1; + const leute = (Array.isArray(todo.assignees) ? todo.assignees : []).map(id => { + const pers = ((app.week && app.week.people) || []).find(x => x.id === id); + return pers ? pers.name : ''; + }).filter(Boolean); + const leuteHtml = leute.length + ? `${leute.slice(0, 3).map(k => `${esc(k)}`).join('')}${leute.length > 3 ? `+${leute.length - 3}` : ''}` : ''; + row.innerHTML = `
${editable ? '' + icon('grip') + '' : ''} - ${esc(todo.title || 'TODO')}${depBadge} - ${conflictBadge} + ${esc(todo.title || 'TODO')} + ${vorgaengerKonflikt ? '!' : ''} + ${conflictBadge}${leuteHtml}
${barHtml}
`; return row; @@ -260,16 +271,73 @@ } if(!projectRow) return; + // Die Projektzeile trägt die Gesamtdauer und Meilensteine ohne eigene + // Zeile; sie heißt jetzt schlicht so, statt den Projektnamen ein + // zweites Mal zu wiederholen. + projectRow.classList.add('tp-projektzeile'); + const projektLabel = projectRow.querySelector('.gantt-label'); + if(projektLabel) projektLabel.textContent = 'Projekt gesamt'; let insertAfter = projectRow; const insertedMilestoneIds = new Set(); - todos.forEach((todo, tIdx) => { + // Gliederung nach Leistungsphasen (Eriks Wahl "A"): Phasenbalken + // (balkenPhase(), load.js) werden Gruppenköpfe; jede andere Aufgabe + // steht unter der Phase, in deren Balken sie beginnt. Nur die + // ANZEIGE wird umsortiert — plan.todos übernimmt die Reihenfolge erst, + // wenn jemand eine Zeile zieht (reorderPlanRows(), plan-drag.js). + const istPhase = t => typeof balkenPhase === 'function' && !!balkenPhase(t); + const hatBalken = t => t.start && t.end && t.showInGantt !== false; + const phasenKoepfe = todos.filter(t => hatBalken(t) && istPhase(t)) + .sort((a, b) => String(a.start).localeCompare(String(b.start))); + const gruppeVon = t => { + if(istPhase(t)) return t.id; + const s0 = parseDateISO(t.start); + const k = s0 && phasenKoepfe.find(p => s0 >= parseDateISO(p.start) && s0 <= parseDateISO(p.end)); + return k ? k.id : ''; + }; + let reihe = todos; + if(phasenKoepfe.length){ + reihe = []; + phasenKoepfe.forEach(p => { reihe.push(p); todos.forEach(t => { if(!istPhase(t) && gruppeVon(t) === p.id) reihe.push(t); }); }); + todos.forEach(t => { if(reihe.indexOf(t) === -1) reihe.push(t); }); + } + const heuteIsoWert = formatDateISO(new Date()); + const filter = options.filter || 'alle'; + const passtFilter = t => filter === 'alle' + || (filter === 'meine' && options.eigenePersonId && Array.isArray(t.assignees) && t.assignees.indexOf(options.eigenePersonId) !== -1) + || (filter === 'ueberfaellig' && !t.done && t.end && t.end < heuteIsoWert); + const phasenZu = options.phasenZu || {}; + let aktuelleGruppeZu = false; + let restKopfGesetzt = false; + const einfuegen = row => { insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; }; + + // Meilensteine ohne Aufgabenbezug: je eine eigene Zeile direkt unter + // "Projekt gesamt", nach Datum sortiert (Eriks Meldung: ohne Aufgaben + // standen alle Meilensteine übereinander in derselben Zeile). + milestones.filter(m => !m.position) + .sort((a, b) => String(a.date || '').localeCompare(String(b.date || ''))) + .forEach(m => einfuegen(buildMilestoneRow(m, milestones.indexOf(m)))); + + reihe.forEach(todo => { + const tIdx = todos.indexOf(todo); + if(phasenKoepfe.length && hatBalken(todo)){ + if(istPhase(todo)) aktuelleGruppeZu = !!phasenZu[balkenPhase(todo)]; + else if(!gruppeVon(todo) && !restKopfGesetzt){ + // Aufgaben außerhalb jedes Phasenbalkens bekommen einen eigenen Kopf. + const kopf = document.createElement('div'); + kopf.className = 'gantt-row tp-gruppe'; + kopf.innerHTML = '
Weitere Aufgaben
'; + einfuegen(kopf); + restKopfGesetzt = true; + aktuelleGruppeZu = false; + } + } // Meilensteine, die VOR dieser Aufgabe positioniert sind milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => { const mIdx = milestones.indexOf(m); const row = buildMilestoneRow(m, mIdx); - insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); - insertAfter = row; + if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu'); + einfuegen(row); insertedMilestoneIds.add(m.id); }); @@ -292,16 +360,40 @@ // kippt. if(todo.start && todo.end && todo.showInGantt !== false){ const todoRow = buildTodoRow(todo, tIdx); - insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling); - insertAfter = todoRow; + if(phasenKoepfe.length && istPhase(todo)){ + const ph = balkenPhase(todo); + const zu = !!phasenZu[ph]; + const kinder = todos.filter(t => !istPhase(t) && hatBalken(t) && gruppeVon(t) === todo.id); + todoRow.classList.add('gantt-phase'); + const label = todoRow.querySelector('.gantt-label'); + if(label){ + const knopf = document.createElement('button'); + knopf.type = 'button'; + knopf.className = 'tp-klappen'; + knopf.setAttribute('aria-expanded', String(!zu)); + knopf.setAttribute('aria-label', (zu ? 'Aufklappen: ' : 'Zuklappen: ') + ph); + knopf.setAttribute('onclick', `event.stopPropagation(); tpPhaseUmschalten('${ph.replace(/'/g, '')}')`); + knopf.textContent = zu ? '▶' : '▼'; + label.insertBefore(knopf, label.firstChild); + const info = document.createElement('span'); + info.className = 'tp-phase-info'; + info.textContent = kinder.length + ' · ' + (parseInt(todo.progress, 10) || 0) + ' %'; + info.title = (kinder.length === 1 ? '1 Aufgabe' : kinder.length + ' Aufgaben') + ', Phase zu ' + (parseInt(todo.progress, 10) || 0) + ' % erledigt'; + label.appendChild(info); + } + } else { + if(aktuelleGruppeZu) todoRow.classList.add('gantt-zu'); + if(!passtFilter(todo)) todoRow.classList.add('gantt-gefiltert'); + } + einfuegen(todoRow); } // Meilensteine, die NACH dieser Aufgabe positioniert sind milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => { const mIdx = milestones.indexOf(m); const row = buildMilestoneRow(m, mIdx); - insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); - insertAfter = row; + if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu'); + einfuegen(row); insertedMilestoneIds.add(m.id); }); }); diff --git a/public/js/plan/plan-drag.js b/public/js/plan/plan-drag.js index 30b03d2..50bd761 100644 --- a/public/js/plan/plan-drag.js +++ b/public/js/plan/plan-drag.js @@ -130,14 +130,11 @@ const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean); const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean); - // Sicherheitscheck: Anzahl muss stimmen. Nicht jedes Meilenstein wird als - // eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten - // Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position - // liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur - // die positionierten zählen. + // Sicherheitscheck: Anzahl muss stimmen. Jeder Meilenstein hat eine + // eigene .gantt-milestone-row (auch ohne Position, dann oben unter + // "Projekt gesamt"). 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; + if(milestoneRowIds.length !== milestones.length) return; // TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden // gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne @@ -153,7 +150,7 @@ // Regeln: // - Steht der Meilenstein direkt vor einer TODO -> before:todoId // - Steht er direkt nach einer TODO -> after:todoId - // - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (oben in Projektzeile) + // - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (eigene Zeile oben) // - Steht er ganz am Ende (nach letzter TODO) -> after:lastTodoId rows.forEach((row, idx) => { if(!row.classList.contains('gantt-milestone-row')) return; @@ -165,7 +162,11 @@ const prevRow = rows[idx - 1]; const nextRow = rows[idx + 1]; - if(prevRow && prevRow.classList.contains('gantt-todo')){ + const todoDavor = rows.slice(0, idx).some(r => r.classList.contains('gantt-todo')); + if(!todoDavor){ + // Vor der ersten Aufgabe: ohne Position (eigene Zeile oben). + position = ''; + } else if(prevRow && prevRow.classList.contains('gantt-todo')){ position = 'after:' + prevRow.dataset.todoId; } else if(nextRow && nextRow.classList.contains('gantt-todo')){ position = 'before:' + nextRow.dataset.todoId; @@ -449,6 +450,14 @@ const globalAbsence = globalPerson && Array.isArray(globalPerson.absences) ? globalPerson.absences.find(x => x.id === absenceId) : null; if(!globalAbsence) return; + // Abwesenheit einer anderen Person: Art und Notiz kennt nur sie selbst + // (loadGlobalPeople() schickt sie verdeckt). Das Verschieben legt neu an + // und bräuchte die Art — sonst wäre der Eintrag nach dem Löschen weg. + if(globalAbsence.verdeckt){ + app.showToast('Abwesenheiten anderer Personen lassen sich hier nicht verschieben.'); + renderPlanGantt(); + return; + } const type = globalAbsence.type || ''; const note = globalAbsence.note || ''; // Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende diff --git a/public/js/plan/plan-files.js b/public/js/plan/plan-files.js index d3b82dc..96448b9 100644 --- a/public/js/plan/plan-files.js +++ b/public/js/plan/plan-files.js @@ -58,7 +58,7 @@ const hint = document.getElementById('filesNoProjectHint'); const uploadSection = document.getElementById('filesUploadSection'); if(hint) hint.style.display = hasNr ? 'none' : ''; - if(uploadSection) uploadSection.style.display = hasNr ? '' : 'none'; + if(uploadSection) uploadSection.style.display = (hasNr && hatRecht('proj.dateien')) ? '' : 'none'; const fileInput = document.getElementById('fileUploadInput'); if(fileInput) fileInput.value = ''; if(hasNr) loadReviewerCheckboxes(); @@ -212,7 +212,7 @@ // Dieselbe Regel wie im Server (DELETE /project-files/:id): der Uploader, // solange die Datei noch offen ist — oder ein Administrator. Vorher stand // der Knopf bei jeder Datei und endete für andere in einer Fehlermeldung. - const isAdmin = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin'); + const isAdmin = hatRecht('proj.dateienFremd'); // Recht „Fremde Dateien löschen“ const isUploader = !!(typeof currentUser !== 'undefined' && currentUser && f.uploadedByUserId === currentUser.id); const canDelete = isAdmin || (isUploader && f.status === 'pending'); const reviewerChips = (f.reviewers || []).map(r => diff --git a/public/js/plan/plan-gantt.js b/public/js/plan/plan-gantt.js index 99bc29e..5f68f78 100644 --- a/public/js/plan/plan-gantt.js +++ b/public/js/plan/plan-gantt.js @@ -10,7 +10,7 @@ // Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter) // und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt, // offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten; -// Team-Auslastung dagegen über berechneAuslastung() aus js/load/load.js, +// Team-Auslastung dagegen über aufgabenVerlaufJePerson() aus js/load/load.js, // siehe computeResourceLoads() weiter unten — dieselbe Rechnung wie im // Auslastungs-Tab, nicht aus den Plan-Daten selbst). renderPlanGantt ist die // zentrale Neu-Render-Funktion (rendert am Ende auch Auslastung und KPIs @@ -26,7 +26,7 @@ // renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts // neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den - // Projektzeitraum), berücksichtigt Urlaub, kritischen Pfad, Sperrstatus und + // Projektzeitraum), berücksichtigt Urlaub, Sperrstatus und // Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die // Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert. window.renderPlanGantt = function(){ @@ -41,16 +41,23 @@ document.getElementById('planGanttStart').value = formatDateISO(start); document.getElementById('planGanttEnd').value = formatDateISO(end); } + // Zeitraum auch sichtbar ins Feld schreiben (es zeigte sonst nur den + // Platzhalter, solange niemand einen Zeitraum gewählt hatte). + const zeitraumAnzeige = document.getElementById('planGanttRange'); + if(zeitraumAnzeige) zeitraumAnzeige.value = formatDateDE(start) + ' – ' + formatDateDE(end); const showVacation = document.getElementById('planShowVacation').checked; const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id); - const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : []; const ghost = collectGhostDates(); container.innerHTML = buildGanttHtml([app.project], start, end, { showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, - showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath, + showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label, + filter: app.ganttFilter || 'alle', phasenZu: app.phasenZu || (app.phasenZu = {}), + eigenePersonId: (typeof currentUser !== 'undefined' && currentUser && currentUser.personId) || '', }); + renderOhneTermin(); container.scrollLeft = prevScroll; + if(typeof ganttGriffeEinsetzen === 'function') ganttGriffeEinsetzen(container); requestAnimationFrame(() => renderGanttDependencies(container)); renderPlanWarnings(); renderResourceLoad(); @@ -61,6 +68,7 @@ // Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei // Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich. if(typeof renderPlanTasks === 'function') renderPlanTasks(); + if(typeof renderPlanPhasen === 'function') renderPlanPhasen(); if(typeof renderPlanDashboard === 'function') renderPlanDashboard(); }; @@ -121,71 +129,68 @@
`; }; - // kpiTile: baut das HTML einer einzelnen Kachel für renderPlanKpis(). - // Parameter: label (Kachel-Titel), value (Hauptzahl), sub (Unterzeile), - // warn (optional, hebt den Wert farblich hervor, z. B. bei überfälligen - // Aufgaben). Rückgabe: HTML-String. - function kpiTile(label, value, sub, warn){ - return `
+ // bandZelle: eine Kennzahl im Lageband des Dashboards. Parameter: label, + // value, sub (Texte), warn (Unterzeile rot). Rückgabe: HTML-String. + function bandZelle(label, value, sub, warn){ + return `
${esc(label)}
${esc(value)}
-
${esc(sub)}
+
${esc(sub)}
`; } - // renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout, - // #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 - // (Ø-Prozentwert aus computeResourceLoads(), dieselbe Zahl wie im - // Auslastungs-Modal). Wird nach jedem GANTT-Rendering aufgerufen, damit sie - // nach TODO-/Meilenstein-/Zuordnungsänderungen sofort aktuell ist. + // renderPlanKpis: füllt das Lageband oben im Dashboard (#planKpiRow, + // Anordnung "Lagebild"): links die laufende Leistungsphase mit Ring + // (amtierende LPH und ihr Fortschritt aus dem Wochenbericht), daneben offene + // Aufgaben (mit Überfälligem), nächster Meilenstein und Team-Auslastung + // (computeResourceLoads(), dieselbe Zahl wie im Auslastungs-Tab). Der + // frühere "Gesamtfortschritt" (Ø der Aufgaben) ist entfallen — für den + // Stand eines Projekts zählt die Leistungsphase. Wird nach jedem + // GANTT-Rendering aufgerufen, damit es sofort aktuell ist. window.renderPlanKpis = function(){ const row = document.getElementById('planKpiRow'); if(!row || !app.project) return; + const p = app.project; const plan = app.ensurePlanLocal(); const todos = Array.isArray(plan.todos) ? plan.todos : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; const today = new Date(); today.setHours(0, 0, 0, 0); - let progressTile; - if(todos.length === 0){ - progressTile = kpiTile('Gesamtfortschritt', '–', 'Keine Aufgaben angelegt'); - } else { - const avg = Math.round(todos.reduce((s, t) => s + (parseInt(t.progress, 10) || 0), 0) / todos.length); - const doneCount = todos.filter(t => t.done).length; - progressTile = kpiTile('Gesamtfortschritt', avg + '%', doneCount + ' von ' + todos.length + ' Aufgaben erledigt'); - } + const lph = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || ''; + const roh = lph && p.lph ? p.lph[lph] : ''; + const pct = roh === undefined || roh === '' ? null : Math.max(0, Math.min(100, Number(roh) || 0)); + const phase = `
+ +
Laufende Phase
+
${lph ? esc(lph) + (pct === null ? '' : ' · ' + pct + ' %') : '–'}
+
${lph ? (pct === 100 ? 'abgeschlossen' : 'Fortschritt laut Wochenbericht') : 'Keine amtierende Phase gesetzt'}
+
`; const open = todos.filter(t => !t.done); const overdue = open.filter(t => t.end && parseDateISO(t.end) < today); - const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'Im Zeitplan' : 'Alle Aufgaben erledigt'); - const openTile = kpiTile('Offene Aufgaben', String(open.length), openSub, overdue.length > 0); + const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt'); const upcoming = milestones .filter(m => m.date && parseDateISO(m.date) >= today) .sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date)); - let milestoneTile; - if(upcoming.length > 0){ - milestoneTile = kpiTile('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein'); - } else { - milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt'); - } + const msZelle = upcoming.length > 0 + ? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein') + : bandZelle('Nächster Meilenstein', '–', milestones.length > 0 ? 'keine anstehenden' : 'keine angelegt'); const { loads } = computeResourceLoads(); const loadValues = Object.values(loads); - let loadTile; + let loadZelle; if(loadValues.length === 0){ - loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet'); + loadZelle = bandZelle('Team-Auslastung', '–', 'niemand zugeordnet'); } else { const avgPct = Math.round(loadValues.reduce((s, l) => s + l.quote * 100, 0) / loadValues.length); const top = loadValues.slice().sort((a, b) => b.quote - a.quote)[0]; - loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.stunden > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch nicht bewertbar'); + loadZelle = bandZelle('Team-Auslastung', avgPct + ' %', top.stunden > 0 + ? 'höchste: ' + (top.person.fullName || top.person.name) + ' ' + Math.round(top.quote * 100) + ' %' : 'noch nicht bewertbar'); } - row.innerHTML = progressTile + openTile + milestoneTile + loadTile; + row.innerHTML = phase + bandZelle('Offene Aufgaben', String(open.length), openSub, overdue.length > 0) + msZelle + loadZelle; }; // renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die @@ -202,7 +207,13 @@ let svg = chart.querySelector('.gantt-dependencies-svg'); if(svg) svg.remove(); - const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]')); + // Linien abgeschaltet (Menü "Mehr") — dann nichts zeichnen. + const pfeile = document.getElementById('planShowPfeile'); + if(pfeile && !pfeile.checked) return; + // Ausgeblendete Zeilen (zugeklappte Phase, Filter) haben keine Lage — + // Linien dorthin würden ins Leere zeigen. + const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]')) + .filter(b => b.offsetParent !== null); if(nodeBars.length === 0) return; const chartRect = chart.getBoundingClientRect(); @@ -283,6 +294,97 @@ renderPlanGantt(); }; + // --------------------------------------------------------------------------- + // Ruhige Leiste (Eriks Wahl "A"): Maßstab, Heute, Filter, Menüs. + // --------------------------------------------------------------------------- + const TP_STUFEN = { fit: null, woche: 8, tag: 32 }; + + // tpMassstab: Maßstab "Übersicht" (an die Breite angepasst), "Wochen" oder + // "Tage" — feste Pixel je Tag statt der früheren −/+-Knöpfe. + window.tpMassstab = function(stufe){ + app.pxPerDay = TP_STUFEN[stufe] === undefined ? null : TP_STUFEN[stufe]; + document.querySelectorAll('#tpMassstab button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.stufe === stufe))); + renderPlanGantt(); + if(app.pxPerDay) tpZuHeuteScrollen(); + }; + + function tpZuHeuteScrollen(){ + const c = document.getElementById('planGanttContainer'); + const heute = c && c.querySelector('.gantt-grid-today'); + if(!c || !heute) return; + const r = heute.getBoundingClientRect(), cr = c.getBoundingClientRect(); + c.scrollLeft += (r.left - cr.left) - c.clientWidth / 3; + } + + // tpHeute: Zeitraum ab zwei Wochen vor heute, gleiche Länge wie bisher + // (mindestens 12 Wochen), und heute ins Bild holen. + window.tpHeute = function(){ + const s = parseDateISO(document.getElementById('planGanttStart').value); + const e = parseDateISO(document.getElementById('planGanttEnd').value); + const tage = s && e ? Math.max(84, daysBetween(s, e)) : 84; + const von = new Date(); von.setHours(0, 0, 0, 0); von.setDate(von.getDate() - 14); + const bis = new Date(von); bis.setDate(bis.getDate() + tage); + document.getElementById('planGanttStart').value = formatDateISO(von); + document.getElementById('planGanttEnd').value = formatDateISO(bis); + const anzeige = document.getElementById('planGanttRange'); + if(anzeige) anzeige.value = formatDateDE(von) + ' – ' + formatDateDE(bis); + renderPlanGantt(); + if(app.pxPerDay) tpZuHeuteScrollen(); + }; + + // tpFilter: Alle / Meine (eigene Zuständigkeit) / Überfällig. Gefilterte + // Zeilen werden nur ausgeblendet, nicht weggelassen — das Umsortieren per + // Ziehen (plan-drag.js) zählt die Zeilen im DOM. + window.tpFilter = function(filter){ + app.ganttFilter = filter; + document.querySelectorAll('#tpFilter button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter))); + renderPlanGantt(); + }; + + // tpPhaseUmschalten: klappt eine Leistungsphase im Terminplan auf/zu. + window.tpPhaseUmschalten = function(phase){ + if(!app.phasenZu) app.phasenZu = {}; + app.phasenZu[phase] = !app.phasenZu[phase]; + renderPlanGantt(); + }; + + // tpMenueZu: schließt die Menüs "Mehr" und "+ Neu". + window.tpMenueZu = function(){ + document.querySelectorAll('.tp-menue[open]').forEach(d => d.removeAttribute('open')); + }; + // Ein Klick außerhalb schließt ein offenes Menü; ein Menü öffnen schließt + // das andere. + document.addEventListener('click', e => { + document.querySelectorAll('.tp-menue[open]').forEach(d => { if(!d.contains(e.target)) d.removeAttribute('open'); }); + }); + document.addEventListener('keydown', e => { if(e.key === 'Escape') window.tpMenueZu(); }); + // Die Liste hängt rechtsbündig unter dem Knopf. Bricht die Leiste in einem + // schmalen Fenster um, steht "+ Neu" links und die Liste rutschte unter die + // Seitenleiste — dann linksbündig aufklappen (Klasse .nach-rechts). + document.addEventListener('toggle', e => { + const d = e.target; + if(!d.classList || !d.classList.contains('tp-menue') || !d.open) return; + const liste = d.querySelector('.tp-menue-liste'); + if(!liste) return; + liste.classList.remove('nach-rechts'); + const seite = document.querySelector('.side-menu'); + const r = seite ? seite.getBoundingClientRect() : null; + const linkeGrenze = r && r.width && getComputedStyle(seite).position !== 'fixed' ? r.right : 0; + if(liste.getBoundingClientRect().left < linkeGrenze + 8) liste.classList.add('nach-rechts'); + }, true); + + // renderOhneTermin: Hinweis unter dem Diagramm auf Aufgaben, die keinen + // Balken haben (ohne Zeitraum oder bewusst ausgeblendet) — sie stehen nur + // unter "Aufgaben". Vorher fehlte jeder Hinweis, dass es sie gibt. + function renderOhneTermin(){ + const el = document.getElementById('planOhneTermin'); + if(!el) return; + const todos = (app.project && app.project.plan && Array.isArray(app.project.plan.todos)) ? app.project.plan.todos : []; + const ohne = todos.filter(t => !t.done && !(t.start && t.end && t.showInGantt !== false)); + el.hidden = ohne.length === 0; + el.innerHTML = ohne.length ? `${ohne.length} ${ohne.length === 1 ? 'offene Aufgabe' : 'offene Aufgaben'} ohne Termin stehen nur unter „Aufgaben“ →` : ''; + } + // zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms. // Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit). window.zoomPlanGantt = function(dir){ @@ -291,7 +393,7 @@ let end = parseDateISO(document.getElementById('planGanttEnd').value); if(!start || !end) return; const totalDays = Math.max(1, daysBetween(start, end) + 1); - const cw = container ? Math.max(200, container.clientWidth - 250) : 900; + const cw = container ? Math.max(200, container.clientWidth - (ganttSpaltenBreite() + 30)) : 900; const fitPx = Math.max(3, cw / totalDays); const base = app.pxPerDay || fitPx; let next; @@ -308,33 +410,20 @@ // ---------- Ressourcen-Auslastung ---------- // computeResourceLoads: berechnet die Auslastung der Projektbeteiligten - // NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel - // (berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor(), - // js/load/load.js) — Wochenstunden aus Honorar × Phasenanteil × - // Restfortschritt, bezogen auf die Kapazität der Person. - // - // Vorher zählte diese Stelle etwas ganz anderes: Aufgabentage aus den - // TODO-Zeiträumen DIESES Terminplans, ohne jeden Bezug zu Honorar oder - // Kapazität — ein Blick auf dieselbe Person zeigte hier eine andere Zahl - // als im Auslastungs-Tab. Erik: "Die Auslastung der Projektbeteiligten im - // Terminplan ist eine andere als die in der sonstigen Anwendung... gleich- - // setzen mit der in der sonstigen Anwendung." Jetzt wird für JEDES Projekt - // der Sitzung (dieses UND alle übrigen) genau derselbe - // berechneAuslastung()-Aufruf gemacht wie in renderLoad() — dieselbe - // Person zeigt hier also exakt dieselbe Gesamtzahl wie im Auslastungs-Tab, - // nur aufgeteilt in den Anteil DIESES Projekts und den Anteil der übrigen - // Projekte der Sitzung (sonst wäre eine Überbuchung hier unsichtbar: - // jemand kann in diesem Projekt entspannt aussehen und trotzdem in vier - // weiteren ausgebucht sein — dieselbe Überlegung wie zuvor, nur jetzt in - // Stunden statt Tagen). + // NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel: + // Stunden dieser Woche aus Aufgaben mit Aufwand, gleich geteilt auf die + // Zuständigen (aufgabenVerlaufJePerson(), js/load/load.js), bezogen auf die + // Kapazität der Person. Für JEDES Projekt der Sitzung, aufgeteilt in den + // Anteil DIESES Projekts und den der übrigen — sonst sähe jemand hier + // entspannt aus, der in vier anderen Projekten ausgebucht ist. // // Gemeinsam genutzt von renderResourceLoad() (Detailkarten im // Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit // beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt // Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere, // stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos - // (Titel der eigenen TODOs in DIESEM Projekt, rein informativ — zählt - // nicht mehr in die Stundenrechnung mit ein). + // (Titel der eigenen Aufgaben in DIESEM Projekt, ohne Phasenbalken), + // offenProjekt (offene Aufgabenstunden dieses Projekts, alle Wochen). function computeResourceLoads(){ const plan = app.ensurePlanLocal(); const todos = Array.isArray(plan.todos) ? plan.todos : []; @@ -353,25 +442,42 @@ if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role); }); + // Zuständige einer Aufgabe zählen mit, auch wenn sie unter „Beteiligte“ + // fehlen — wie auf der Auslastungsseite. Sonst fielen ihre + // Aufgabenstunden hier stillschweigend weg. + const personen = (app.week && app.week.people) || []; todos.forEach(t => { - if(!Array.isArray(t.assignees)) return; + if(!Array.isArray(t.assignees) || balkenPhase(t)) return; t.assignees.forEach(pid => { - if(loads[pid]) loads[pid].todos.push(t.title); + if(!loads[pid]){ + const person = personen.find(x => x.id === pid); + if(!person) return; + loads[pid] = { + person, roles: ['nur Aufgabe'], todos: [], + kapazitaet: kapazitaetsFaktor(person) * vollzeit, + stundenProjekt: 0, stundenAndere: 0, andereProjekte: [], + }; + } + loads[pid].todos.push(t.title); }); }); + // Offene Aufgabenstunden dieses Projekts insgesamt (nicht nur diese + // Woche) — liegt eine Aufgabe erst später, zeigte die Wochenzahl sonst 0. + Object.values(loads).forEach(l => { l.offenProjekt = 0; }); + offeneAufwandsAufgaben(app.project).forEach(({ t, rest }) => { + t.assignees.forEach(pid => { if(loads[pid]) loads[pid].offenProjekt += rest / t.assignees.length; }); + }); + + // Stunden dieser Woche allein aus Aufgaben mit Aufwand — dieselbe + // Rechnung wie auf der Auslastungsseite (aufgabenVerlaufJePerson(), load.js). ((app.week && app.week.projects) || []).forEach(pr => { - const aktivePhase = lpActiveList(pr)[0]; - Object.keys(pr.assign || {}).forEach(aKey => { - const pid = aKey.slice(0, aKey.indexOf('|')); - if(!loads[pid]) return; // nur Personen, die auch in DIESEM Projekt beteiligt sind - const rolle = aKey.slice(aKey.indexOf('|') + 1); - const al = berechneAuslastung(null, pr, aktivePhase, rolle, pr.assign[aKey], pr.assign); - if(al.stunden <= 0) return; - if(pr.id === app.project.id){ - loads[pid].stundenProjekt += al.stunden; - } else { - loads[pid].stundenAndere += al.stunden; + const av = typeof aufgabenVerlaufJePerson === 'function' ? aufgabenVerlaufJePerson(pr) : {}; + Object.keys(av).forEach(pid => { + if(!loads[pid] || !(av[pid][0] > 0)) return; + if(pr.id === app.project.id) loads[pid].stundenProjekt += av[pid][0]; + else { + loads[pid].stundenAndere += av[pid][0]; const label = pr.nr ? (pr.nr + ' ' + (pr.name || '')) : (pr.name || 'Projekt'); if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label); } @@ -456,10 +562,15 @@
` : `
keine weiteren Projekte
`; + const offenText = l.offenProjekt > 0 + ? `
${stundenFormat(l.offenProjekt)} h offen in Aufgaben dieses Projekts
` + : `
keine Aufgaben mit Aufwand
`; + html += `

${esc(l.person.name)} (${esc(l.roles.join(' / '))})

-
${stundenFormat(l.stunden)}h
+
${stundenFormat(l.stunden)}h diese Woche
+ ${offenText} ${otherBadge}
${todoListHtml}
${absBadge} diff --git a/public/js/plan/plan-history.js b/public/js/plan/plan-history.js index b9ed1d2..02534e8 100644 --- a/public/js/plan/plan-history.js +++ b/public/js/plan/plan-history.js @@ -1,19 +1,16 @@ // ----------------------------- // Projektplan — Verlauf über mehrere Sitzungen // ----------------------------- -// Dieses Modul macht die Sitzungs-Historie eines Projekts sichtbar: die -// Kompakt-Karte "Verlauf" unter dem GANTT zeigt den letzten Kommentarwechsel, -// ein Klick öffnet #planHistoryModal mit dem vollständigen Verlauf. +// Dieses Modul liefert die Fassungen eines Projekts über die Sitzungen +// (Vorwochen-Helfer für den GANTT). Angezeigt wird der Kommentar-Verlauf +// im Projekt-Dashboard von plan-info.js. // -// Der Verlauf selbst wird NICHT hier gezeichnet, sondern von -// renderProjektVerlauf() in js/utils.js — derselben Darstellung, die auch der -// Wochenbericht unter der Projektkarte und die Projektliste zeigen. Zuvor +// Der Verlauf in Wochenbericht und Projektliste wird von +// renderProjektVerlauf() in js/utils.js gezeichnet. Zuvor // hatte jede dieser Stellen ihre eigene Fassung mit eigener Auswahl (hier nur // Sitzungen mit Kommentarwechsel, im Bericht auch Termine und // Leistungsphasen), weshalb derselbe Projektverlauf je nach Aufrufort anders -// aussah und sich widersprechen konnte. Der Dialog übergibt kein weekLabel: -// Er kennt keine "aktuelle" Fassung, die auszulassen wäre, und zeigt deshalb -// alle Sitzungen einschließlich der geöffneten. +// aussah und sich widersprechen konnte. // // Hintergrund: Beim wöchentlichen cloneWeek() (js/weeks.js) wird der // Kommentartext in die neue Sitzung kopiert und dort überschrieben. Der alte @@ -86,75 +83,10 @@ return null; } - // historyEntries: die Verlaufsliste des aktuell geöffneten Projekts - // (neueste zuerst, Wiederholungen desselben Textes gefiltert). Logik in - // js/utils.js, siehe projectStatusHistory(). - function historyEntries(){ - return projectStatusHistory(app.project && app.project.projectUid); - } - - // renderPlanHistory: füllt die Kompakt-Karte "Verlauf" (#planHistorySummary - // 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(){ - const summaryEl = document.getElementById('planHistorySummary'); - if(!summaryEl || !app.project) return; - - // Ein Sandbox-Plan der Planung lebt in genau einer, nie geklonten Sitzung - // (SANDBOX_WEEK_LABEL) — es gibt also nie einen "Verlauf über mehrere - // Sitzungen" zu zeigen, nur immer denselben einen Schnappschuss. - if(app.isSandboxPlan && app.isSandboxPlan()){ - summaryEl.innerHTML = 'Kein Verlauf — dieser Terminplan gehört zu keiner Sitzung.'; - return; - } - - if(!app.project.projectUid){ - summaryEl.innerHTML = 'Für dieses Projekt ist kein Verlauf verfügbar.'; - return; - } - const entries = historyEntries(); - if(entries.length === 0){ - summaryEl.innerHTML = 'Noch keine Kommentare erfasst.'; - return; - } - const latest = entries[0]; - const versionCount = projectVersions().length; - summaryEl.innerHTML = `
${esc(latest.text)}
-
- ${entries.length} Eintrag${entries.length === 1 ? '' : 'e'} - zuletzt ${esc(latest.week.label)} - ${versionCount} Sitzung${versionCount === 1 ? '' : 'en'} -
`; - }; - - // openHistoryModal/closeHistoryModal: öffnen bzw. schließen das - // Verlaufs-Modal (#planHistoryModal). Der Inhalt wird beim Öffnen frisch - // aufgebaut, damit ein gerade im Projektinfo-Modal geänderter Kommentar - // sofort mit auftaucht. - window.openHistoryModal = function(){ - const listEl = document.getElementById('planHistoryList'); - if(listEl){ - if(!app.project || !app.project.projectUid){ - listEl.innerHTML = 'Dieses Projekt hat keine dauerhafte Projekt-Kennung und kann daher nicht über Sitzungen hinweg verfolgt werden.'; - } else { - // Dieselbe Darstellung wie im Wochenbericht und im Projektboard - // (renderProjektVerlauf(), js/utils.js). Der Dialog hatte zuvor eine - // eigene, enger gefasste Liste — sie zeigte nur Kommentartexte und - // konnte dem Verlauf an den anderen Stellen widersprechen. - // - // weekLabel bleibt leer: Im Dialog gibt es keine "aktuelle" Fassung, - // die auszulassen wäre — hier sollen ALLE Sitzungen erscheinen, - // einschließlich der gerade geöffneten. - listEl.innerHTML = renderProjektVerlauf(app.project, '', app.isLocked()); - } - } - document.getElementById('planHistoryModal').style.display = 'flex'; - }; - - window.closeHistoryModal = function(){ - document.getElementById('planHistoryModal').style.display = 'none'; - }; + // Die Kompakt-Karte "Verlauf" (renderPlanHistory()) und der Dialog + // #planHistoryModal (openHistoryModal()/closeHistoryModal()) sind entfallen: + // Der Kommentar-Verlauf steht jetzt in der Kachel "Kommentar der Sitzung" + // des Projekt-Dashboards (renderPlanKommentarVerlauf(), plan-info.js). // Export für die anderen plan-Module (plan-gantt.js/plan-build-gantt.js: // Geister-Balken der letzten geplanten Sitzung). diff --git a/public/js/plan/plan-honorar.js b/public/js/plan/plan-honorar.js index c68040f..4467557 100644 --- a/public/js/plan/plan-honorar.js +++ b/public/js/plan/plan-honorar.js @@ -94,7 +94,11 @@ } const stundensatz = honorarConfig.stundensatz; + // Beginnt der Plan an einem Wochenende/Feiertag, auf den nächsten + // Arbeitstag schieben — wie der Assistent "Neues Projekt". Sonst stand der + // Balken ab Samstag, gezählt wurde aber erst ab Montag. let cursor = startDate; + while(!isWorkday(cursor)) cursor = addDays(cursor, 1); let summeStunden = 0, summeTage = 0, summeHonorar = 0; const zeilen = phasen.map(lph => { @@ -137,7 +141,7 @@ // Eingaben gelten als 0 = "kein Honorar hinterlegt". // Parameter: val (Eingabewert). Keine Rückgabe. window.setHonorarBetrag = function(val){ - if(app.isLocked()) return; + if(app.stammGesperrt()) return; const v = parseFloat(String(val).replace(',', '.')); app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0; app.savePlan(); @@ -152,7 +156,7 @@ // Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan // chronologisch aufsteigt. Parameter: lph, checked (Boolean). window.toggleBeauftragteLp = function(lph, checked){ - if(app.isLocked()) return; + if(app.stammGesperrt()) return; const p = app.project; const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []); if(checked) set.add(lph); else set.delete(lph); @@ -260,7 +264,7 @@ function renderHonorarHint(){ const el = document.getElementById('honorarAdminHint'); if(!el) return; - el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none'; + el.style.display = hatRecht('a.honorar') ? '' : 'none'; } // invalidateHonorarConfig: verwirft den Zwischenspeicher, damit der nächste @@ -326,6 +330,9 @@ plan.todos.push({ id: uid(), title: `${z.lph} (${fmtNum(z.stunden, 0)} h)`, + // Kennzeichen für die Auslastung: Die Reststunden dieser Phase + // werden auf die Wochen dieses Balkens verteilt (load.js). + lph: z.lph, start: formatDateISO(z.start), end: formatDateISO(z.ende), done: false, diff --git a/public/js/plan/plan-info.js b/public/js/plan/plan-info.js index d2e97b0..ab975d6 100644 --- a/public/js/plan/plan-info.js +++ b/public/js/plan/plan-info.js @@ -70,9 +70,109 @@ // amtierende Leistungsphase, LPH-Prozentliste) aus app.project auf. Wird // nach jedem Laden (renderPlanPage(), plan-state.js) sowie nach jeder // Änderung hier selbst erneut aufgerufen. Keine Parameter, keine Rückgabe. + // --------------------------------------------------------------------------- + // Kommentar-Verlauf im Projekt-Dashboard + // --------------------------------------------------------------------------- + // planKommentarDaten: sucht über alle Sitzungen dieses Projekts (bis + // einschließlich der angezeigten) den jüngsten Kommentar und listet die + // früheren je Gewerk. Ein Eintrag im Verlauf entsteht nur, wenn sich der + // Text eines Gewerks gegenüber der Sitzung davor geändert hat — ein + // unverändert mitgeführter Kommentar stünde sonst jede Woche erneut da. + // Rückgabe: { letzter: {week, k, aktuell} | null, verlauf: [{week, gewerk, html, autor, at}] } + function planKommentarDaten(p, week){ + const bisKey = week && week.label ? weekSortKey(week.label) : null; + let versionen = (typeof projectVersionsForUid === 'function' && p.projectUid) + ? projectVersionsForUid(p.projectUid).filter(v => !bisKey || weekSortKey(v.week.label) <= bisKey) + : []; + // Angezeigte Sitzung immer mit dem lebenden Projektobjekt (Sandbox, oder + // ein Projekt, das in db.weeks noch nicht steht). + versionen = versionen.filter(v => !week || v.week.label !== week.label); + versionen.push({ week: week || { label: '' }, project: p }); + + let letzter = null; + for(let i = versionen.length - 1; i >= 0; i--){ + const k = kommentarVon(versionen[i].project, null); + if(statusPlainText(k.status)){ + letzter = { week: versionen[i].week, k, aktuell: i === versionen.length - 1 }; + break; + } + } + + const gewerke = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH']; + const verlauf = []; + gewerke.forEach(g => { + let vorher = ''; + versionen.forEach(v => { + const k = kommentarVon(v.project, g); + const text = statusPlainText(k.status); + if(text && text !== vorher && !(letzter && v.week === letzter.week)){ + verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' }); + } + vorher = text; + }); + }); + // Derselbe Text in mehreren Gewerken derselben Sitzung (Altbestand: die + // Umstellung auf Kommentare je Gewerk hat ihn in jedes kopiert) wird ein + // Eintrag mit allen Gewerken, statt zweimal untereinander zu stehen. + const zusammen = []; + verlauf.forEach(e => { + const gleich = zusammen.find(z => z.week === e.week && statusPlainText(z.html) === statusPlainText(e.html)); + if(gleich){ gleich.gewerk += ', ' + e.gewerk; if(!gleich.autor) gleich.autor = e.autor; } + else zusammen.push(Object.assign({}, e)); + }); + zusammen.sort((a, b) => weekSortKey(b.week.label).localeCompare(weekSortKey(a.week.label)) + || String(b.at).localeCompare(String(a.at))); + return { letzter, verlauf: zusammen }; + } + + // "KW 43 / 2026" -> "KW 43" (das Jahr nur, wenn es nicht das laufende ist) + function kurzKw(label){ + const m = /^(KW\s*\d+)\s*\/\s*(\d{4})$/.exec(String(label || '').trim()); + if(!m) return label || ''; + return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2]; + } + + const PLAN_VERLAUF_KURZ = 3; + let planVerlaufAlle = false; + let planVerlaufProjekt = null; + + function renderPlanKommentarVerlauf(verlauf){ + const el = document.getElementById('planStatusVerlauf'); + if(!el) return; + const uid = app.project && (app.project.projectUid || app.project.id); + if(uid !== planVerlaufProjekt){ planVerlaufProjekt = uid; planVerlaufAlle = false; } + if(!verlauf.length){ el.hidden = true; el.innerHTML = ''; return; } + el.hidden = false; + const zeigen = planVerlaufAlle ? verlauf : verlauf.slice(0, PLAN_VERLAUF_KURZ); + const zeilen = zeigen.map((e, i) => ` +
  • + ${esc(kurzKw(e.week.label))} + +
    +
    ${e.html}
    +
    ${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}
    +
    +
  • `).join(''); + const mehr = verlauf.length > PLAN_VERLAUF_KURZ + ? `` + : ''; + el.innerHTML = ` +
    + Verlauf + ${verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'} +
    +
      ${zeilen}
    ${mehr}`; + } + + window.planVerlaufUmschalten = function(){ + planVerlaufAlle = !planVerlaufAlle; + window.renderPlanInfo(); + }; + + window.renderPlanInfo = function(){ const p = app.project; - const locked = app.isLocked(); + const locked = app.stammGesperrt(); // Kommentar, Fortschritt und Zuordnung sind hier NUR ZU SEHEN. // @@ -83,15 +183,42 @@ // Personen konnten dasselbe Feld an verschiedenen Stellen pflegen. // Was hier bleibt, sind die Stammdaten des Projekts (Bereich // "Einstellungen", siehe plan-settings.js). + // Gezeigt wird der ZULETZT GESCHRIEBENE Kommentar (Eriks Wunsch, Entwurf + // "A"): Hat die Sitzung noch keinen, der jüngste aus einer früheren — mit + // Hinweis, aus welcher. Darunter der Verlauf der früheren Kommentare. + const kom = planKommentarDaten(p, app.week); const editEl = document.getElementById('planStatusEdit'); if(editEl){ editEl.className = 'status-edit is-ansicht'; editEl.contentEditable = 'false'; - const text = sanitizeStatusHtml(p.status || ''); - editEl.innerHTML = text || 'Für diese Sitzung ist noch kein Kommentar erfasst.'; + // Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js). + const text = kom.letzter ? sanitizeStatusHtml(kom.letzter.k.status) : ''; + editEl.innerHTML = text || 'Zu diesem Projekt ist noch kein Kommentar erfasst.'; } + const hinweisEl = document.getElementById('planStatusHinweis'); + if(hinweisEl){ + const aelter = kom.letzter && !kom.letzter.aktuell; + hinweisEl.hidden = !aelter; + hinweisEl.innerHTML = aelter + ? `In dieser Sitzung noch kein Kommentar — angezeigt wird der letzte aus ${esc(kurzKw(kom.letzter.week.label))}.` + : ''; + } + // Wer den Kommentar wann geschrieben hat (jüngster Eintrag aller Gewerke). + const autorEl = document.getElementById('planStatusAutor'); + if(autorEl){ + const k = kom.letzter ? kom.letzter.k : {}; + autorEl.innerHTML = k.status && k.statusAuthor + ? `${esc(initialsForName(k.statusAuthor))}${esc(k.statusAuthor)}` + + (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '') + : ''; + } + renderPlanKommentarVerlauf(kom.verlauf); const statusWrap = document.getElementById('planStatusWrap'); - if(statusWrap) statusWrap.classList.add('locked-cell'); + // Nicht mehr als gesperrt markieren: .locked-cell dimmt auf 55 % und legt im + // Dunkelmodus ein Grau darunter — der Kommentar war dadurch kaum lesbar. + // Dass er hier nur angezeigt wird, zeigen schon .is-ansicht und der + // Verweis "Im Wochenbericht bearbeiten". + if(statusWrap) statusWrap.classList.remove('locked-cell'); setzeBerichtVerweis('planStatusVerweis', locked); // Gruppe — Stammdatum, bleibt hier pflegbar @@ -176,7 +303,7 @@ // setPlanGroup: ändert die Gruppe des Projekts. Parameter: val (String). // Keine Rückgabe; bei gesperrter Woche abgebrochen. window.setPlanGroup = function(val){ - if(app.isLocked()) return; + if(app.stammGesperrt()) return; app.project.group = val; app.savePlan(); }; @@ -185,11 +312,14 @@ // Projekts auf. Parameter: val (LPH-Name oder leer). Rendert das // Infopanel neu, damit die Markierung in der LPH-Liste sofort mitzieht. window.setPlanActiveLp = function(val){ - if(app.isLocked()) return; + if(app.stammGesperrt()) return; if(val) app.project.aktuelleLP = [val]; else delete app.project.aktuelleLP; app.savePlan(); window.renderPlanInfo(); + // Abschnitt "Leistungsphasen" der Einstellungen: Kennzeichen "läuft" + // und die Kurzfassung in der Unternavigation nachziehen. + if(typeof renderPlanSettings === 'function') renderPlanSettings(); }; // Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind diff --git a/public/js/plan/plan-mail-eintrag.js b/public/js/plan/plan-mail-eintrag.js new file mode 100644 index 0000000..3d470db --- /dev/null +++ b/public/js/plan/plan-mail-eintrag.js @@ -0,0 +1,293 @@ +// plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per +// Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf +// den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus +// Aufgaben. Der Terminplan ist nur die grafische Darstellung der +// Aufgabenliste — deshalb entstehen hier ausschließlich Aufgaben +// (plan.todos), keine Meilensteine. +// +// Nichts wird ohne Prüfung angelegt: Jeder Vorschlag öffnet das gewohnte +// Aufgaben-Panel vorausgefüllt (openTodoModal(null, prefill)), die Mail +// steht als Notiz darin (t.notiz). Mehrere Vorschläge erst als Liste zum +// Abhaken, dann nacheinander im Panel. Server: POST /plan-eintrag-aus-mail +// (speichert nichts). +(function(){ + const app = window.PlanApp; + const ANBIETER_KEY = 'projuebersicht-mail-ki'; + const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben']; + let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit) + let laeuft = false; + + function aktiverAbschnitt(){ + if(typeof currentTab === 'undefined' || currentTab !== 'plan') return ''; + const s = document.querySelector('.plan-section.is-active'); + return s ? s.dataset.section : ''; + } + + function ablageErlaubt(){ + return ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked(); + } + + function bringtMail(dt){ + if(!dt) return false; + const typen = Array.from(dt.types || []); + return typen.includes('Files') || typen.includes('text/plain') || typen.includes('text/html'); + } + + // ---------- Ablagefläche ---------- + + function flaeche(){ + let el = document.getElementById('mailAblage'); + if(!el){ + el = document.createElement('div'); + el.id = 'mailAblage'; + el.className = 'mail-ablage'; + el.hidden = true; + el.innerHTML = `
    ${icon('mail')} + Mail hier ablegen + Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.
    `; + document.body.appendChild(el); + } + return el; + } + + function zeigeFlaeche(titel, text){ + const el = flaeche(); + document.getElementById('mailAblageTitel').textContent = titel; + if(text !== undefined) document.getElementById('mailAblageText').textContent = text; + el.classList.toggle('ist-beschaeftigt', laeuft); + el.hidden = false; + } + + function versteckeFlaeche(){ + const el = document.getElementById('mailAblage'); + if(el) el.hidden = true; + } + + document.addEventListener('dragenter', e => { + if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)) return; + tiefe++; + zeigeFlaeche('Mail hier ablegen', 'Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.'); + }); + document.addEventListener('dragover', e => { + if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }); + document.addEventListener('dragleave', () => { + if(tiefe === 0) return; + tiefe--; + if(tiefe === 0 && !laeuft) versteckeFlaeche(); + }); + document.addEventListener('drop', e => { + if(tiefe === 0 && !ablageErlaubt()) return; + tiefe = 0; + if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)){ if(!laeuft) versteckeFlaeche(); return; } + e.preventDefault(); + ausDataTransfer(e.dataTransfer); + }); + + // Strg+V als Ausweg, wenn ein Mailprogramm das Ziehen nicht unterstützt — + // nur außerhalb von Eingabefeldern, damit normales Einfügen unberührt bleibt. + document.addEventListener('paste', e => { + if(laeuft || !ablageErlaubt()) return; + const ziel = e.target; + if(ziel && (ziel.closest('input, textarea, select, [contenteditable="true"], .modal-bg'))) return; + const dt = e.clipboardData; + if(!dt) return; + const datei = Array.from(dt.files || []).find(istEml); + const text = (dt.getData('text/plain') || '').trim(); + if(!datei && !text) return; + e.preventDefault(); + ausDataTransfer(dt); + }); + + function istEml(f){ return f && /\.eml$/i.test(f.name || '') || (f && f.type === 'message/rfc822'); } + + function ausDataTransfer(dt){ + const dateien = Array.from(dt.files || []); + const eml = dateien.find(istEml); + if(eml){ + if(eml.size > 5 * 1024 * 1024){ fehler('Die Mail ist zu groß (mehr als 5 MB).'); return; } + eml.text().then(t => auswerten({ eml: t })).catch(() => fehler('Die Mail-Datei ließ sich nicht lesen.')); + return; + } + if(dateien.some(f => /\.msg$/i.test(f.name || ''))){ + fehler('Outlook-Dateien (.msg) lassen sich nicht direkt lesen. Bitte den Mailtext markieren und hineinziehen oder mit Strg+V einfügen.'); + return; + } + let text = (dt.getData('text/plain') || '').trim(); + if(!text){ + const html = dt.getData('text/html') || ''; + const div = document.createElement('div'); + div.innerHTML = html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ''); + text = (div.innerText || div.textContent || '').trim(); + } + if(!text){ + if(dateien.length) fehler('Diese Datei ist keine Mail. Abgelegt werden können markierter Mailtext oder .eml-Dateien.'); + else versteckeFlaeche(); + return; + } + auswerten({ text }); + } + + function fehler(meldung){ + laeuft = false; + versteckeFlaeche(); + app.showToast(meldung, 'error'); + } + + // ---------- Auswerten ---------- + + function anbieterWaehlen(){ + return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => { + const bereit = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured); + let zuletzt = ''; + try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ } + return bereit.includes(zuletzt) ? zuletzt : (bereit[0] || ''); + }).catch(() => ''); + } + + function projektPersonen(){ + const gesehen = new Set(); + const liste = []; + projectAssignees(app.project, app.week).forEach(a => { + if(gesehen.has(a.person.id)) return; + gesehen.add(a.person.id); + liste.push(a.person); + }); + return liste; + } + + function kontext(){ + const todos = (app.project.plan && app.project.plan.todos) || []; + return { + heute: heuteIso(), + projekt: [app.project.nr, app.project.name].filter(Boolean).join(' '), + personen: projektPersonen().map(p => p.fullName || p.name).filter(Boolean), + phasen: todos.filter(t => t.lph && t.start && t.end).map(t => ({ lph: t.lph, start: t.start, ende: t.end })) + }; + } + + function auswerten(nutzlast){ + laeuft = true; + zeigeFlaeche('Mail wird ausgewertet …', 'Die KI liest die Mail. Das dauert einige Sekunden.'); + anbieterWaehlen().then(anbieter => fetch(baseUrl() + '/plan-eintrag-aus-mail', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(Object.assign({ anbieter, kontext: kontext() }, nutzlast)) + })) + .then(r => r.json().then(d => ({ ok: r.ok, d }))) + .then(({ ok, d }) => { + laeuft = false; + versteckeFlaeche(); + if(!ok || !d.ok){ app.showToast(d.error || 'Die Mail konnte nicht ausgewertet werden.', 'error'); return; } + if(d.erkennungsart === 'chatgpt' || d.erkennungsart === 'claude'){ + try { localStorage.setItem(ANBIETER_KEY, d.erkennungsart); } catch(e){ /* egal */ } + } + verarbeiten(d); + }) + .catch(() => fehler('Der Server ist nicht erreichbar.')); + } + + // ---------- Vorschläge ins Panel ---------- + + // personenZuordnen: Namen aus der KI-Antwort → Personen-IDs des Projekts + // (voller Name, sonst Nachname, sonst Kürzel; Groß-/Kleinschreibung egal). + function personenZuordnen(namen){ + const leute = projektPersonen(); + const ids = []; + (namen || []).forEach(n => { + const s = String(n || '').trim().toLowerCase(); + if(!s) return; + const treffer = leute.find(p => String(p.fullName || '').toLowerCase() === s) + || leute.find(p => { const nn = String(p.fullName || '').toLowerCase().split(/\s+/).pop(); return nn && (s === nn || s.split(/\s+/).pop() === nn); }) + || leute.find(p => String(p.name || '').toLowerCase() === s); + if(treffer && !ids.includes(treffer.id)) ids.push(treffer.id); + }); + return ids; + } + + function notizText(mail, eintrag){ + const kopf = []; + const datum = mail.datum ? formatDateDE(parseDateISO(mail.datum)) : ''; + kopf.push('Aus Mail' + (mail.absender ? ' von ' + mail.absender : '') + (datum ? ' vom ' + datum : '')); + if(mail.betreff) kopf.push('Betreff: ' + mail.betreff); + if(eintrag.zitat) kopf.push('Bezug: „' + eintrag.zitat + '“'); + return kopf.join('\n') + '\n\n' + String(mail.text || '').slice(0, 8000); + } + + function hinweisText(d){ + if(d.erkennungsart === 'chatgpt' || d.erkennungsart === 'claude'){ + return 'Vorschlag der KI aus der Mail — bitte prüfen und speichern.'; + } + return 'Die KI war nicht verfügbar (' + (d.erkennungshinweis || 'unbekannter Fehler') + '). Termine bitte selbst eintragen.'; + } + + function verarbeiten(d){ + const eintraege = Array.isArray(d.eintraege) ? d.eintraege : []; + if(eintraege.length === 0){ + app.showToast('In der Mail steht nichts für den Terminplan.'); + return; + } + if(eintraege.length === 1){ nacheinanderOeffnen(d, eintraege); return; } + auswahlZeigen(d, eintraege); + } + + function nacheinanderOeffnen(d, liste){ + const rest = liste.slice(); + const gesamt = rest.length; + const naechster = () => { + const e = rest.shift(); + if(!e){ renderPlanGantt(); return; } + const nr = gesamt - rest.length; + openTodoModal(null, { + title: e.titel, + start: e.start, + end: e.ende, + assignees: personenZuordnen(e.zustaendig), + aufwand: e.aufwandStunden, + notiz: notizText(d.mail || {}, e), + mailHinweis: (gesamt > 1 ? `Vorschlag ${nr} von ${gesamt}. ` : '') + hinweisText(d), + nachSchliessen: naechster + }); + }; + naechster(); + } + + // ---------- Mehrere Vorschläge: Auswahl ---------- + + function auswahlZeigen(d, eintraege){ + let bg = document.getElementById('mailAuswahlModal'); + if(!bg){ + bg = document.createElement('div'); + bg.id = 'mailAuswahlModal'; + bg.className = 'modal-bg'; + bg.style.display = 'none'; + bg.addEventListener('click', e => { if(e.target === bg) bg.style.display = 'none'; }); + document.body.appendChild(bg); + } + const zeitraum = e => { + if(!e.start) return 'ohne Termin'; + const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende)); + return a === b ? a : a + ' – ' + b; + }; + bg.innerHTML = ``; + bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; }; + bg.querySelector('[data-aktion="weiter"]').onclick = () => { + const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked')).map(cb => eintraege[Number(cb.dataset.i)]); + bg.style.display = 'none'; + if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt); + }; + bg.style.display = 'flex'; + } +})(); diff --git a/public/js/plan/plan-modals.js b/public/js/plan/plan-modals.js index 7ef2a9c..a3c68c5 100644 --- a/public/js/plan/plan-modals.js +++ b/public/js/plan/plan-modals.js @@ -55,7 +55,7 @@ if(positionSelect){ const currentPosition = currentMs && currentMs.position ? currentMs.position : ''; const defaultAfterTodo = isNew && plan.todos && plan.todos.length > 0 ? plan.todos[plan.todos.length - 1].id : null; - let opts = ''; + let opts = ''; if(plan.todos && plan.todos.length > 0){ plan.todos.forEach(t => { const beforeVal = 'before:' + t.id; @@ -69,6 +69,14 @@ } positionSelect.innerHTML = opts; } + window.msLageSync(); + + // Seitenpanel: Überschrift und "Löschen" nur beim Bearbeiten — vorher + // stand "Löschen" auch beim Anlegen eines neuen Meilensteins. + const msTitel = document.getElementById('milestoneModalTitle'); + if(msTitel) msTitel.textContent = isNew ? 'Neuer Meilenstein' : 'Meilenstein bearbeiten'; + const msLoeschen = document.getElementById('milestoneEditDelete'); + if(msLoeschen) msLoeschen.hidden = isNew; const dependsOnList = document.getElementById('milestoneEditDependsOn'); if(dependsOnList){ @@ -110,6 +118,35 @@ document.getElementById('milestoneEditName').focus(); }; + // msLageSync/msLageSetzen: "Wo im Terminplan" als Umschalter "In der + // Projektzeile | Bei einer Aufgabe" über dem bisherigen Auswahlfeld + // (#milestoneEditPosition, Wert '' = Projektzeile, sonst before:/after: + // einer Aufgabe). Gespeichert wird weiter allein der Wert des Felds. + window.msLageSync = function(){ + const sel = document.getElementById('milestoneEditPosition'); + const seg = document.getElementById('milestoneEditLage'); + if(!sel || !seg) return; + const beiAufgabe = !!sel.value; + const hatAufgaben = sel.options.length > 1; + seg.querySelectorAll('button').forEach(b => { + b.setAttribute('aria-pressed', String((b.dataset.lage === 'aufgabe') === beiAufgabe)); + if(b.dataset.lage === 'aufgabe') b.disabled = !hatAufgaben; + }); + sel.hidden = !beiAufgabe; + // Die Option "Oben, eigene Zeile" steht schon im Umschalter. + if(sel.options[0] && sel.options[0].value === '') sel.options[0].hidden = true; + }; + window.msLageSetzen = function(art){ + const sel = document.getElementById('milestoneEditPosition'); + if(!sel) return; + if(art === 'zeile') sel.value = ''; + else if(!sel.value){ + const nachOptionen = Array.from(sel.options).filter(o => o.value.indexOf('after:') === 0); + if(nachOptionen.length) sel.value = nachOptionen[nachOptionen.length - 1].value; + } + window.msLageSync(); + }; + // closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den // Bearbeitungsindex zurück. window.closeMilestoneModal = function(){ @@ -186,6 +223,9 @@ // todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für // die Rückfrage beim Abbrechen (siehe todoFormSnapshot()). let todoModalSnapshot = ''; + // todoNachSchliessen: Rückruf nach dem Schließen des Panels (gespeichert + // oder verworfen) — "Termin aus Mail" öffnet damit den nächsten Vorschlag. + let todoNachSchliessen = null; // todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und // "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit" @@ -358,6 +398,69 @@ list.querySelectorAll('.todo-depends-group').forEach(group => { group.hidden = !!query; }); }; + // --------------------------------------------------------------------------- + // Leistungsphase als Balken (Eriks Wunsch): "+ Neu" → "Leistungsphase" + // öffnet dasselbe Panel im Phasen-Modus. Ein Balken ist NUR über das Feld + // t.lph eine Phase (balkenPhase(), load.js) — nicht mehr über seinen Titel. + // --------------------------------------------------------------------------- + const LPH_NAMEN = { + 'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung', + 'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe', + 'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung', 'W+M': 'Wartung und Monitoring' + }; + let todoPhasenModus = false; + let todoPhaseAutoTitel = ''; + const phasenTitel = l => l ? (l + (LPH_NAMEN[l] ? ' ' + LPH_NAMEN[l] : '')) : ''; + + // addPlanPhase: "+ Neu" → "Leistungsphase". + // Optional lph: diese Phase vorwählen (Bereich "Leistungsphasen", Kachel + // ohne Zeitraum). + window.addPlanPhase = function(lph){ openTodoModal(null, { phase: true, lph: typeof lph === 'string' ? lph : '' }); }; + + // todoPhaseGewechselt: Auswahl der Phase geändert — den vorgeschlagenen + // Titel mitziehen, solange niemand ihn von Hand geändert hat. + window.todoPhaseGewechselt = function(){ + const sel = document.getElementById('todoEditLph'); + const titel = document.getElementById('todoEditTitle'); + if(!sel || !titel) return; + if(!titel.value.trim() || titel.value === todoPhaseAutoTitel){ + todoPhaseAutoTitel = phasenTitel(sel.value); + titel.value = todoPhaseAutoTitel; + } + }; + + // todoPhasenModusSetzen: Panel für eine Leistungsphase herrichten (Auswahl + // füllen, "Wer"/"Speichern & neu" ausblenden) oder zurück zur Aufgabe. + function todoPhasenModusSetzen(an, todo, isNew, vorgabeLph){ + todoPhasenModus = an; + const abs = document.getElementById('todoEditPhaseAbs'); + const wer = document.getElementById('todoEditWerAbs'); + const neu = document.getElementById('todoEditSaveNew'); + if(abs) abs.hidden = !an; + if(wer) wer.hidden = an; + if(neu) neu.hidden = an; + const titelEl = document.getElementById('todoModalTitle'); + if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten'); + if(!an) return; + const plan = app.ensurePlanLocal(); + const vergeben = new Set((plan.todos || []).filter(t => t !== todo && t.lph).map(t => t.lph)); + const beauftragt = Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : []; + const sel = document.getElementById('todoEditLph'); + if(!sel) return; + sel.innerHTML = LPHS.map(l => { + const zusatz = vergeben.has(l) ? ' (schon im Plan)' : (beauftragt.length && !beauftragt.includes(l) ? ' (nicht beauftragt)' : ''); + return ``; + }).join(''); + const frei = l => !vergeben.has(l); + sel.value = (todo && todo.lph) || (vorgabeLph && frei(vorgabeLph) ? vorgabeLph : '') || LPHS.find(l => frei(l) && (!beauftragt.length || beauftragt.includes(l))) || LPHS.find(frei) || ''; + if(isNew){ + todoPhaseAutoTitel = phasenTitel(sel.value); + document.getElementById('todoEditTitle').value = todoPhaseAutoTitel; + } else { + todoPhaseAutoTitel = ''; + } + } + // openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe // oder null für eine neue), prefill (optional, Vorbelegung für "Speichern & // Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer @@ -380,7 +483,18 @@ const titleEl = document.getElementById('todoModalTitle'); if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten'; - document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || ''); + document.getElementById('todoEditTitle').value = isNew ? (pre.title || '') : (currentTodo.title || ''); + const notizEl = document.getElementById('todoEditNotiz'); + if(notizEl) notizEl.value = isNew ? (pre.notiz || '') : (currentTodo.notiz || ''); + // Hinweis über der Notiz, nur bei einem Vorschlag aus "Termin aus Mail". + const mailHinweisEl = document.getElementById('todoEditMailHinweis'); + if(mailHinweisEl){ + mailHinweisEl.hidden = !(isNew && pre.mailHinweis); + mailHinweisEl.textContent = (isNew && pre.mailHinweis) || ''; + } + todoNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null; + const saveNewBtn = document.getElementById('todoEditSaveNew'); + if(saveNewBtn) saveNewBtn.hidden = !!todoNachSchliessen; // Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare // Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der @@ -404,6 +518,12 @@ // Wert (dieselben Rahmendaten wie Zeitraum/Zuständige, s. saveTodoEdit()). const showInGanttEl = document.getElementById('todoEditShowInGantt'); if(showInGanttEl) showInGanttEl.checked = isNew ? (pre.showInGantt !== undefined ? pre.showInGantt : true) : currentTodo.showInGantt !== false; + const aufwandEl = document.getElementById('todoEditAufwand'); + if(aufwandEl){ + const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand); + aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : ''; + } + todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null); const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0); todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50; @@ -465,13 +585,25 @@ uniquePeople.push(a.person); } }); + // Bereits Zuständige, die (nicht mehr) dem Projekt zugeordnet sind — + // etwa über "Aufgabe aus Kommentar" aus dem ganzen Teilnehmerkreis + // gewählt — ebenfalls als Chip zeigen. Ohne sie gingen sie beim + // Speichern stillschweigend verloren, weil saveTodoEdit() nur die + // gedrückten Chips liest. + currentAssignees.forEach(id => { + if(seenPeople.has(id)) return; + seenPeople.add(id); + const p = ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id }; + uniquePeople.push(Object.assign({}, p, { nichtImProjekt: true })); + }); // Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die // Personenspalten im Projektboard. assigneesDiv.innerHTML = uniquePeople.map(person => { const pressed = currentAssignees.includes(person.id) ? 'true' : 'false'; const short = person.name || initialsForName(person.fullName || '') || '?'; const full = person.fullName || person.name || ''; - return ``; }).join('') || 'Keine Projektzuordnungen im Board.'; } @@ -512,8 +644,10 @@ const read = id => { const el = document.getElementById(id); return el ? el.value : ''; }; const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value); const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId); + const gantt = document.getElementById('todoEditShowInGantt'); return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'), - currentTodoEditStatus(), read('todoEditProgress'), deps, people]); + currentTodoEditStatus(), read('todoEditProgress'), deps, people, + gantt ? gantt.checked : true, read('todoEditAufwand'), read('todoEditLph'), read('todoEditNotiz')]); } // closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex @@ -527,6 +661,9 @@ document.getElementById('todoModal').style.display = 'none'; app.currentTodoIndex = null; todoModalSnapshot = ''; + const danach = todoNachSchliessen; + todoNachSchliessen = null; + if(danach) setTimeout(danach, 0); return true; }; @@ -573,12 +710,21 @@ const showInGanttEl = document.getElementById('todoEditShowInGantt'); const showInGantt = showInGanttEl ? showInGanttEl.checked : true; + // Aufwand in Stunden (optional): zählt zur Auslastung der Zuständigen. + const aufwandEl = document.getElementById('todoEditAufwand'); + const aufwand = aufwandEl ? Math.max(0, parseFloat(String(aufwandEl.value).replace(',', '.')) || 0) : 0; + // Phasen-Modus: welche Leistungsphase dieser Balken ist. + const lphSel = document.getElementById('todoEditLph'); + const lph = todoPhasenModus && lphSel ? lphSel.value : ''; + const notizEl = document.getElementById('todoEditNotiz'); + const notiz = notizEl ? notizEl.value.trim() : ''; + if(todoPhasenModus && !lph){ alert('Bitte eine Leistungsphase wählen.'); return; } const plan = app.ensurePlanLocal(); if(!Array.isArray(plan.todos)) plan.todos = []; if(app.currentTodoIndex === null){ - plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt }); + plan.todos.push(Object.assign({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt, aufwand, notiz }, lph ? { lph, assignees: [], aufwand: 0 } : {})); } else if(plan.todos[app.currentTodoIndex]){ const t = plan.todos[app.currentTodoIndex]; t.title = title; @@ -589,6 +735,9 @@ t.dependsOn = dependsOn; t.assignees = assignees; t.showInGantt = showInGantt; + t.aufwand = aufwand; + t.notiz = notiz; + if(todoPhasenModus) t.lph = lph; } app.savePlan(); // Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt @@ -633,7 +782,12 @@ if(app.isLocked()) return; const plan = app.ensurePlanLocal(); if(!plan.todos[idx]) return; - plan.todos[idx].done = !plan.todos[idx].done; + const t = plan.todos[idx]; + t.done = !t.done; + // Fortschritt mitführen: Sonst blieb nach dem Zurücknehmen "100 %" stehen, + // und der Dialog las daraus wieder "fertig" (todoStatusFrom()). + if(t.done) t.progress = 100; + else if(Number(t.progress) >= 100) t.progress = 0; app.savePlan(); renderPlanGantt(); }; diff --git a/public/js/plan/plan-phasen.js b/public/js/plan/plan-phasen.js new file mode 100644 index 0000000..f785030 --- /dev/null +++ b/public/js/plan/plan-phasen.js @@ -0,0 +1,180 @@ +// ----------------------------- +// Projektbereich — Leistungsphasen +// ----------------------------- +// Bereich "Leistungsphasen" (Eriks Wahl "B"): die Phasenbalken des +// Terminplans (Aufgaben mit t.lph, balkenPhase() in load.js) als eigener +// Bereich statt als Zeilen zwischen den Aufgaben. Oben ein Ablaufbalken über +// alle Phasen mit Heute-Linie, darunter eine Kachel je Phase: Fortschritt +// (aus dem Wochenbericht, p.lph), Zeitraum (aus dem Balken), Aufwand +// (Summe der Stunden ihrer Aufgaben) und die Aufgaben, die in ihrem +// Zeitraum beginnen — dieselbe Zuordnung wie die Gliederung im Terminplan +// (plan-build-gantt.js). +// +// Keine eigene Datenhaltung: Bearbeiten öffnet dasselbe Panel wie im +// Terminplan (openTodoModal() im Phasen-Modus), Fortschritt bleibt im +// Wochenbericht ("Ein Ort je Angabe"). +// +// renderPlanPhasen() wird von renderPlanGantt() (plan-gantt.js) und beim +// Wechsel in den Bereich (showPlanSection(), plan-sections.js) aufgerufen. + +// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört — +// der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die +// Gliederung im Terminplan. Parameter: t (Aufgabe), todos (alle Aufgaben des +// Plans). Rückgabe: Phasenname ('LPH 5') oder ''. +function aufgabenPhase(t, todos){ + if(!t || balkenPhase(t)) return ''; + const s0 = parseDateISO(t.start); + if(!s0) return ''; + const k = (todos || []) + .filter(b => balkenPhase(b) && b.start && b.end) + .sort((a, b) => String(a.start).localeCompare(String(b.start))) + .find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end)); + return k ? balkenPhase(k) : ''; +} + +(function(){ + const app = window.PlanApp; + + const LPH_NAMEN = { + 'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung', + 'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe', + 'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung', + 'W+M': 'Wartung und Monitoring' + }; + + const kurzDatum = d => d ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) : ''; + const langDatum = d => d ? formatDateDE(d) : ''; + + // phasenDaten: je Phase alles, was Ablaufbalken und Kachel brauchen. + // Rückgabe: Array, sortiert läuft → geplant → fertig, darin nach Beginn. + function phasenDaten(){ + const p = app.project; + const plan = app.ensurePlanLocal(); + const todos = Array.isArray(plan.todos) ? plan.todos : []; + const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []; + + // Phasen: beauftragte plus alle, die einen Balken haben. + const namen = LPHS.filter(l => beauftragt.includes(l) || todos.some(t => balkenPhase(t) === l)); + const amtierend = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || null; + const laufend = typeof arbeitsPhase === 'function' ? arbeitsPhase(p, amtierend) : amtierend; + + return namen.map(l => { + const idx = todos.findIndex(t => balkenPhase(t) === l); + const balken = idx >= 0 ? todos[idx] : null; + const start = balken ? parseDateISO(balken.start) : null; + const ende = balken ? parseDateISO(balken.end) : null; + const prozent = typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0; + const aufgaben = todos.filter(t => aufgabenPhase(t, todos) === l); + // Aufwand = Summe der Stunden ihrer Aufgaben (dieselbe Quelle wie die + // Auslastung, js/load/load.js), nicht mehr aus dem Honorar. + const stunden = Math.round(aufgaben.reduce((s, t) => s + (Number(t.aufwand) || 0), 0) * 10) / 10; + const status = prozent >= 100 ? 'fertig' : (l === laufend ? 'laeuft' : 'geplant'); + return { l, idx, balken, start, ende, prozent, stunden, aufgaben, status, + beauftragt: !beauftragt.length || beauftragt.includes(l) }; + }).sort((a, b) => { + const rang = { laeuft: 0, geplant: 1, fertig: 2 }; + if(rang[a.status] !== rang[b.status]) return rang[a.status] - rang[b.status]; + return LPHS.indexOf(a.l) - LPHS.indexOf(b.l); + }); + } + + // ablaufHtml: der schmale Balken über alle Phasen mit Zeitraum. Phasen + // ohne Zeitraum fehlen hier (sie stehen als Kachel mit Hinweis darunter). + function ablaufHtml(daten){ + const mit = daten.filter(d => d.start && d.ende && d.ende >= d.start) + .sort((a, b) => a.start - b.start); + if(!mit.length) return ''; + const von = mit[0].start.getTime(); + const bis = Math.max(...mit.map(d => d.ende.getTime())); + const spanne = Math.max(1, bis - von); + const pos = t => Math.min(100, Math.max(0, (t - von) / spanne * 100)); + const heute = new Date(); heute.setHours(0, 0, 0, 0); + const heutePos = heute.getTime() >= von && heute.getTime() <= bis ? pos(heute.getTime()) : null; + const segmente = mit.map(d => { + const links = pos(d.start.getTime()); + const breite = Math.max(1.5, pos(d.ende.getTime() + 86400000) - links); + const titel = `${d.l}: ${langDatum(d.start)} – ${langDatum(d.ende)}, ${d.prozent} %`; + return ``; + }).join(''); + const heuteHtml = heutePos === null ? '' : ``; + const monat = d => d.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' }); + return `
    +
    Ablauf
    +
    ${segmente}${heuteHtml}
    +
    ${esc(monat(mit[0].start))}${heutePos === null ? '' : `heute, ${esc(kurzDatum(heute))}`}${esc(monat(new Date(bis)))}
    +
    `; + } + + // kachelHtml: eine Kachel je Phase. + function kachelHtml(d, locked){ + const statusText = { laeuft: 'läuft', geplant: 'geplant', fertig: 'fertig' }[d.status]; + const erledigt = d.aufgaben.filter(t => t.done).length; + const aufgabenText = d.aufgaben.length ? `${erledigt} von ${d.aufgaben.length} erledigt` : 'keine'; + const zeitraum = d.start && d.ende + ? `${d.start.getFullYear() === d.ende.getFullYear() ? kurzDatum(d.start) : langDatum(d.start)} – ${langDatum(d.ende)}` + : 'kein Zeitraum'; + const bearbeiten = locked ? '' + : (d.balken + ? `` + : ``); + const ansehen = d.aufgaben.length + ? `` : ''; + return `
    +
    +
    ${d.prozent} %
    +
    + ${esc(d.l)} + ${esc(LPH_NAMEN[d.l] || '')} +
    + ${statusText} +
    + ${d.beauftragt ? '' : '
    nicht beauftragt
    '} +
    + Zeitraum${zeitraum} + Aufwand${d.stunden ? d.stunden.toLocaleString('de-DE') + ' h' : 'keine Stunden'} + Aufgaben${aufgabenText} +
    + ${bearbeiten || ansehen ? `
    ${bearbeiten}${ansehen}
    ` : ''} +
    `; + } + + // renderPlanPhasen: baut den Bereich neu auf. Keine Parameter. + window.renderPlanPhasen = function(){ + const el = document.getElementById('planPhasenWrap'); + if(!el || !app.project) return; + const locked = app.isLocked(); + const daten = phasenDaten(); + if(!daten.length){ + el.innerHTML = `
    ${locked + ? 'Für dieses Projekt sind keine Leistungsphasen hinterlegt.' + : 'Noch keine Leistungsphase. Unter „Einstellungen“ beauftragen oder über „+ Leistungsphase“ bzw. „Terminplan aus Honorar“ anlegen.'}
    `; + return; + } + const summeH = daten.reduce((s, d) => s + d.stunden, 0); + const enden = daten.filter(d => d.ende).map(d => d.ende.getTime()); + const laufend = daten.find(d => d.status === 'laeuft'); + const teile = [daten.filter(d => d.beauftragt).length + ' beauftragt']; + if(laufend) teile.push('läuft: ' + laufend.l); + if(summeH) teile.push(summeH.toLocaleString('de-DE') + ' h in Aufgaben'); + if(enden.length) teile.push('Ende voraussichtlich ' + langDatum(new Date(Math.max(...enden)))); + el.innerHTML = `
    ${esc(teile.join(' · '))}Fortschritt aus dem Wochenbericht
    + ${ablaufHtml(daten)} +
    ${daten.map(d => kachelHtml(d, locked)).join('')}
    `; + }; + + // phasenKachelZeigen: Klick auf ein Segment im Ablauf — Kachel ins Bild + // holen und kurz hervorheben. + window.phasenKachelZeigen = function(l){ + const k = document.querySelector(`#planPhasenWrap .phasen-kachel[data-phase="${CSS.escape(l)}"]`); + if(!k) return; + k.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + k.classList.remove('ist-markiert'); void k.offsetWidth; k.classList.add('ist-markiert'); + }; + + // phasenAufgabenZeigen: in den Bereich Aufgaben, gefiltert auf diese Phase. + window.phasenAufgabenZeigen = function(l){ + if(typeof setzeAufgabenFilter === 'function') setzeAufgabenFilter(l); + showPlanSection('aufgaben'); + }; +})(); diff --git a/public/js/plan/plan-protokoll-todos.js b/public/js/plan/plan-protokoll-todos.js index 75c47e7..4f0a46a 100644 --- a/public/js/plan/plan-protokoll-todos.js +++ b/public/js/plan/plan-protokoll-todos.js @@ -177,7 +177,7 @@ body.innerHTML = `
    ${erkanntePunkte.map((p, i) => { - const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : (p.status === 'offen'); + const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : ((p.status || (p.erledigt ? 'erledigt' : 'offen')) === 'offen'); punkteAuswahl[i] = checked; return `
    `; } @@ -443,14 +406,11 @@ function wizardTeamHtml(){ } function wizardTerminplanHtml(){ - // Aus der Projektgröße lässt sich kein Terminplan rechnen: Sie ist eine - // Schätzung des Umfangs, kein vereinbarter Betrag. Termine, die daraus - // entstünden, sähen im Gantt so verbindlich aus wie errechnete. - if(wizard.grundlage !== 'honorar'){ + // Ohne Honorar kein Terminplan — er lässt sich später im Projektbereich + // erzeugen („Terminplan aus Honorar"). + if(wizardHonorar() <= 0){ return `
    - Kein Terminplan aus der Projektgröße. -

    Die Größe ist eine Schätzung des Umfangs, kein vereinbarter Betrag. Termine, die daraus - entstünden, sähen im Terminplan so verbindlich aus wie errechnete — ohne es zu sein.

    + Kein Terminplan ohne Honorar.

    Sobald das Honorar feststeht, lässt sich der Terminplan im Projektbereich jederzeit nachträglich erzeugen („Terminplan aus Honorar").

    `; @@ -537,9 +497,7 @@ function wizardPruefenHtml(){ ${zeile('Gruppe', esc(wizard.gruppe))} ${zeile('Schlagwörter', wizard.tags.length ? esc(wizard.tags.join(', ')) : 'keine', !wizard.tags.length)} ${zeile('Auftragsstatus', esc(auftragsstatusLabel(wizard.status)))} - ${zeile('Grundlage', wizard.grundlage === 'honorar' - ? eurFormat(wizardHonorar()) + ' Honorar' - : 'Projektgröße ' + faktorFormat(wizardGroesse()))} + ${zeile('Honorar', wizardHonorar() > 0 ? eurFormat(wizardHonorar()) : 'nicht angegeben', !(wizardHonorar() > 0))} ${zeile('Beauftragte Phasen', esc(wizard.beauftragt.join(', ')))} ${zeile('Amtierende Phase', esc(wizard.amtierend))} ${zeile('Team', esc(team))} @@ -547,14 +505,12 @@ function wizardPruefenHtml(){ const p = wizardPersonen().find(x => x.id === id); return p ? p.kuerzel + (p.gewerk ? ' (' + p.gewerk + ')' : '') : id; }).join(', '))) : ''} - ${zeile('Terminplan', wizard.grundlage !== 'honorar' - ? 'nicht möglich — Grundlage ist die Projektgröße' + ${zeile('Terminplan', !(wizardHonorar() > 0) + ? 'nicht möglich — kein Honorar angegeben' : (!wizard.planErzeugen ? 'wird nicht erzeugt' : (plan.length ? plan.length + ' Phasen bis ' + formatWeekDate(isoVon(plan[plan.length - 1].ende)) : 'nicht errechenbar')), - wizard.grundlage !== 'honorar' || !wizard.planErzeugen || !plan.length)} -
    -

    Die Gewichtung des Aufwands je Phase und Rolle übernimmt die - firmenweite Vorlage; sie lässt sich später im Projektbereich anpassen.

    `; + !(wizardHonorar() > 0) || !wizard.planErzeugen || !plan.length)} +
    `; } function wizardAbschlussHtml(){ @@ -585,13 +541,8 @@ function wizardSetz(feld, wert){ if(wert === 'anfrage' || wert === 'angebot') wizard.gruppe = 'In Anbahnung'; else if(wizard.gruppe === 'In Anbahnung') wizard.gruppe = 'Aktive Projekte'; } - if(feld === 'grundlage'){ - // Das nun gesperrte Feld leeren: Ein Wert, der dasteht und nicht mehr - // zählt, führt in die Irre. - if(wert === 'honorar') wizard.groesse = ''; else wizard.honorar = ''; - } // Freitextfelder nicht neu bauen — sonst springt der Cursor ans Feldende. - if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'groesse' || feld === 'planPersonen'){ + if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'planPersonen'){ wizardNurAbhaengiges(); return; } @@ -624,8 +575,6 @@ function wizardNurAbhaengiges(){ const hh = document.getElementById('wizHinweisHonorar'); if(hh) hh.textContent = wizardHinweisHonorar(); - const hg = document.getElementById('wizHinweisGroesse'); - if(hg) hg.textContent = wizardHinweisGroesse(); const tab = document.getElementById('wizPlanTabelle'); if(tab) tab.innerHTML = wizardPlanTabelleHtml(); @@ -725,8 +674,7 @@ function wizardAnlegen(){ status: '', statusState: undefined, order: nextOrder, lph: {}, assign: {}, plan: null, auftragsstatus: auftragsstatusOf({ auftragsstatus: wizard.status }), - honorar: wizard.grundlage === 'honorar' ? wizardHonorar() : 0, - groesse: wizard.grundlage === 'groesse' ? wizardGroesse() : 0, + honorar: wizardHonorar(), beauftragteLP: wizard.beauftragt.slice(), aktuelleLP: [wizard.amtierend], }; @@ -744,13 +692,13 @@ function wizardAnlegen(){ todos: zeilen.map((z, i) => ({ id: uid(), title: z.lph, + lph: z.lph, start: isoVon(z.start), end: isoVon(z.ende), done: false, progress: 0, assignees: [], - deps: [], - order: i, + dependsOn: [], })), milestones: [], }; diff --git a/public/js/server.js b/public/js/server.js index 9a718c1..ac129fe 100644 --- a/public/js/server.js +++ b/public/js/server.js @@ -31,11 +31,16 @@ function baseUrl(){ // lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error // mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer // den Konflikt anzeigen kann. +// CLIENT_ID: zufällige Kennung dieses Browser-Tabs. Geht bei jedem Speichern +// als X-Client-Id mit und bei GET /ereignisse als ?client= — so bekommt der +// Tab seine eigenen Änderungen nicht als "fremdes" Live-Ereignis zurück. +const CLIENT_ID = Math.random().toString(36).slice(2) + Date.now().toString(36); + function sendWeekToServer(week){ if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar')); return fetch(baseUrl() + '/save-week',{ method:'POST', - headers:{'Content-Type':'application/json; charset=utf-8'}, + headers:{'Content-Type':'application/json; charset=utf-8', 'X-Client-Id': CLIENT_ID}, body:JSON.stringify({ week, baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined @@ -58,6 +63,28 @@ function sendWeekToServer(week){ }); } +// sendProjectsToServer: speichert nur einzelne Projekte einer Sitzung +// (POST /save-projects, siehe js/store.js syncAllToServer()). Parameter: +// label, projekte (Array von Projekt-Objekten; ihr updatedAt ist die Basis +// für die Konfliktprüfung je Projekt). Rückgabe: Promise mit +// {ok, updatedAt, projekte:{id: updatedAt}}; bei 409 ein Error mit +// conflict=true, wie sendWeekToServer(). +function sendProjectsToServer(label, projekte){ + if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar')); + return fetch(baseUrl() + '/save-projects', { + method: 'POST', + headers: {'Content-Type':'application/json; charset=utf-8', 'X-Client-Id': CLIENT_ID}, + body: JSON.stringify({ label, projects: projekte.map(p => ({ project: p, baseUpdatedAt: p.updatedAt || '' })) }) + }).then(r => r.json().catch(() => null).then(json => { + if(r.ok && json) return json; + const err = new Error((json && json.error) || ('Server HTTP ' + r.status)); + if(json && json.conflict){ err.conflict = true; err.projekte = json.projekte || []; } + err.status = r.status; + err.label = label; + throw err; + })); +} + // 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 diff --git a/public/js/settings/settings.js b/public/js/settings/settings.js index cce9365..368b4a0 100644 --- a/public/js/settings/settings.js +++ b/public/js/settings/settings.js @@ -52,7 +52,12 @@ function renderSettingsAccount(){
    ` - + row('Rolle', currentUser.role === 'admin' ? 'Administrator' : 'Mitglied') + + row('Rolle', currentUser.rolleName || currentUser.role) + // Glas-Darstellung (public/glas.css) je Browser abschaltbar — etwa auf + // einem langsamen Rechner, dem die Unschärfe zu viel Leistung kostet. + + row('Darstellung', ``) // Ohne Personen-Zuordnung bleibt „Meine Aufgaben" leer, weil die Aufgaben // über plan_todo_assignees.person_id zugeordnet werden (siehe // GET /my-dashboard). Deshalb steht der Hinweis hier und nicht nur dort. @@ -64,6 +69,14 @@ function renderSettingsAccount(){ : ''); } +// setGlasDarstellung: schaltet die Glas-Darstellung in diesem Browser ein +// oder aus (Klasse html.glas, gemerkt in localStorage). Wirkt sofort, ohne +// Neuladen. Parameter: an (Boolean). Keine Rückgabe. +function setGlasDarstellung(an){ + document.documentElement.classList.toggle('glas', !!an); + try { localStorage.setItem('projuebersicht-glas', an ? 'an' : 'aus'); } catch(e){ /* nur Komfort */ } +} + // submitEmailChange: schickt die eigene E-Mail-Adresse an POST /change-email. // Anders als der Passwortwechsel braucht es kein aktuelles Passwort zur // Bestätigung — die Route ist ohnehin an die eigene, per Session diff --git a/public/js/store.js b/public/js/store.js index 51fc482..1cb7a0f 100644 --- a/public/js/store.js +++ b/public/js/store.js @@ -112,6 +112,43 @@ function syncAllToServer(){ // 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; + // Nur die geänderten Projekte schicken, wenn sich an der Sitzung selbst + // nichts geändert hat (geaenderteProjekte(), siehe unten). Sonst — oder + // ohne bekannten Server-Stand — wie bisher die ganze Sitzung. + const delta = geaenderteProjekte(wochenBasis.get(label), w); + if(delta && delta.length === 0){ + // Nichts zu senden (z. B. eine Änderung, die wieder zurückgenommen wurde). + if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label); + return; + } + if(delta){ + const gesendeteProjekte = delta.map(p => JSON.parse(JSON.stringify(p))); + jobs.push(sendProjectsToServer(label, gesendeteProjekte) + .then(res => { + console.log('Projekte gespeichert:', label, gesendeteProjekte.length); + const basis = wochenBasis.get(label); + gesendeteProjekte.forEach(gp => { + const rev = (res.projekte || {})[gp.id] || res.updatedAt; + gp.updatedAt = rev; + const lokal = (w.projects || []).find(x => x.id === gp.id); + if(lokal) lokal.updatedAt = rev; + // Basis nur für die gesendeten Projekte nachziehen — fremde + // Änderungen, die während des Speicherns eintrafen, stehen dort schon. + if(basis){ + const i = basis.projects.findIndex(x => x.id === gp.id); + if(i >= 0) basis.projects[i] = gp; + } + }); + revisionUebernehmen(w, res.updatedAt); + konfliktVersuche.delete(label); + if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label); + }) + .catch(err => { problem = speicherFehler(label, w, err) || problem; })); + return; + } + // Genau dieser Stand geht an den Server — nach Erfolg ist er die neue + // Basis für ein späteres Zusammenführen (wochenBasis). + const gesendet = wocheKopie(w); jobs.push(sendWeekToServer(w) .then(res => { if(res && res.ok){ @@ -119,28 +156,21 @@ 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; + gesendet.updatedAt = w.updatedAt; + // Das ganze Speichern setzt am Server JEDES Projekt der Sitzung auf + // diese Revision (projects.updated_at) — hier nachziehen, sonst + // scheiterte das nächste Speichern einzelner Projekte als Konflikt. + (w.projects || []).forEach(x => { x.updatedAt = w.updatedAt; }); + (gesendet.projects || []).forEach(x => { x.updatedAt = w.updatedAt; }); + wochenBasis.set(label, gesendet); + konfliktVersuche.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'); } }) - .catch(err => { - if(err && err.conflict){ - dirtyWeeks.delete(label); - handleWeekConflict(w, err); - } else if(err && err.status === 401){ - // Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still - // weiterzuarbeiten und alles Weitere zu verlieren. - problem = 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.'; - if(typeof showLoginOverlay === 'function') showLoginOverlay(); - console.error('Speichern abgelehnt (nicht angemeldet):', w.label); - } else { - problem = 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.'; - console.error('Sitzung konnte nicht gespeichert werden:', w.label, (err && err.message) || err); - // Label bleibt dirty -> der nächste Sync versucht es erneut. - } - })); + .catch(err => { problem = speicherFehler(label, w, err) || problem; })); }); Promise.all(jobs).then(() => { @@ -151,6 +181,58 @@ function syncAllToServer(){ }); } +// speicherFehler: gemeinsame Fehlerbehandlung beider Speicherwege. Rückgabe: +// Text für das Fehlerband oder '' (Konflikt: wird zusammengeführt). +function speicherFehler(label, w, err){ + if(err && err.conflict){ + // Jemand anderes hat die Sitzung bzw. das Projekt inzwischen gespeichert: + // nicht verwerfen, sondern zusammenführen und erneut speichern. + zusammenfuehrenUndSpeichern(label, w, err); + return ''; + } + if(err && err.status === 403){ + // Fehlendes Recht (Rollen & Rechte): Ein erneuter Versuch hilft nicht. + // Änderung verwerfen, Hinweis zeigen und den Stand vom Server holen. + dirtyWeeks.delete(label); + zeigeHinweis((err.message || 'Keine Berechtigung.') + ' Die Änderung wurde nicht gespeichert.'); + reloadWeekFromServer(label); + return ''; + } + if(err && err.status === 401){ + // Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still + // weiterzuarbeiten und alles Weitere zu verlieren. + if(typeof showLoginOverlay === 'function') showLoginOverlay(); + console.error('Speichern abgelehnt (nicht angemeldet):', label); + return 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.'; + } + console.error('Sitzung konnte nicht gespeichert werden:', label, (err && err.message) || err); + // Label bleibt dirty -> der nächste Sync versucht es erneut. + return 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.'; +} + +// geaenderteProjekte: vergleicht eine Sitzung mit ihrem zuletzt bekannten +// Server-Stand. Rückgabe: die geänderten Projekte (Array, evtl. leer), wenn +// sich NUR Projektinhalte geändert haben; null, wenn die ganze Sitzung +// gespeichert werden muss — Teilnehmer, Anwesenheit, Sperre, Datum, ein +// angelegtes, gelöschtes oder umsortiertes Projekt, oder kein Server-Stand. +function geaenderteProjekte(basis, w){ + if(!basis || !w) return null; + const rahmen = x => { const k = Object.assign({}, x); delete k.projects; delete k.updatedAt; return k; }; + if(!gleich(rahmen(basis), rahmen(w))) return null; + const bp = basis.projects || [], wp = w.projects || []; + if(bp.length !== wp.length || bp.some((p, i) => p.id !== wp[i].id)) return null; + return wp.filter((p, i) => !gleich(p, bp[i])); +} + +// revisionUebernehmen: setzt die Revision einer Sitzung (lokal und Basis), +// aber nie zurück — Antworten und Ereignisse können sich überholen. +function revisionUebernehmen(w, rev){ + if(!w || !rev) return; + if(!w.updatedAt || rev > w.updatedAt) w.updatedAt = rev; + const basis = wochenBasis.get(w.label); + if(basis && (!basis.updatedAt || rev > basis.updatedAt)) basis.updatedAt = rev; +} + // 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){ @@ -159,6 +241,400 @@ function markWeekDirty(label){ weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1); } +// =========================================================================== +// Gleichzeitiges Arbeiten: Zusammenführen statt Verwerfen +// --------------------------------------------------------------------------- +// Eine Sitzung wird immer als Ganzes gespeichert (POST /save-week mit +// Revisionsstand). Hat jemand anderes sie inzwischen gespeichert, antwortet +// der Server mit 409. Früher verwarf der Browser daraufhin die eigenen +// Änderungen mit einem alert() — bei zwei Personen in derselben Sitzung +// passierte das ständig, auch wenn sie an verschiedenen Projekten arbeiteten. +// +// Jetzt merkt sich der Browser je Sitzung den Stand, den er zuletzt vom +// Server kennt (wochenBasis). Bei einem 409 holt er den aktuellen Stand und +// führt drei Fassungen zusammen (dreiwegeZusammenfuehren(): Basis, eigene, +// fremde): Was nur eine Seite geändert hat, wird übernommen — bis hinunter zu +// einzelnen Feldern, Projekten, Aufgaben/Meilensteinen (über ihre id), +// Kommentaren je Gewerk und der Anwesenheit je Person. Nur wenn beide +// dasselbe Feld unterschiedlich geändert haben, gilt die eigene (jüngste) +// Fassung, und ein kurzer Hinweis nennt das Projekt. Danach wird erneut +// gespeichert. Das Ergebnis wird IN die vorhandenen Objekte geschrieben +// (inPlaceUebernehmen()), damit z. B. der Projektbereich, der eine Referenz +// auf sein Projekt hält (window.PlanApp), weiter auf dasselbe Objekt zeigt. +// +// Zusätzlich gleicht wochenLiveAbgleich() alle 20 Sekunden ab: Hat sich eine +// Sitzung am Server geändert und gibt es hier nichts Ungespeichertes, werden +// die fremden Änderungen übernommen — außer während jemand gerade tippt oder +// ein Dialog offen ist (dann beim nächsten Durchlauf). +// =========================================================================== + +const wochenBasis = new Map(); // label -> zuletzt bekannter Server-Stand +const konfliktVersuche = new Map(); // label -> Zahl der Zusammenführungen in Folge + +function wocheKopie(w){ return JSON.parse(JSON.stringify(w)); } +function basisMerken(w){ if(w && w.label) wochenBasis.set(w.label, wocheKopie(w)); } + +function istObjekt(x){ return !!x && typeof x === 'object' && !Array.isArray(x); } +function istIdListe(a){ return Array.isArray(a) && a.every(x => istObjekt(x) && x.id !== undefined && x.id !== null && x.id !== ''); } +// gleich: inhaltlicher Vergleich, unabhängig von der Reihenfolge der Felder +// (inPlaceUebernehmen() lässt vorhandene Felder an ihrer Stelle und hängt +// neue an — JSON.stringify allein hielte dann Gleiches für verschieden, und +// zwei Browser schickten sich über die Live-Ereignisse endlos dieselben +// Projekte zu). undefined-Felder zählen wie fehlende. +function stabilerText(x){ + if(Array.isArray(x)) return '[' + x.map(v => v === undefined ? 'null' : stabilerText(v)).join(',') + ']'; + if(x && typeof x === 'object'){ + return '{' + Object.keys(x).filter(k => x[k] !== undefined).sort() + .map(k => JSON.stringify(k) + ':' + stabilerText(x[k])).join(',') + '}'; + } + return x === undefined ? 'null' : JSON.stringify(x); +} +function gleich(a, b){ return a === b || stabilerText(a) === stabilerText(b); } + +// dreiwegeZusammenfuehren: Basis, eigene und fremde Fassung → Ergebnis. +// konflikte sammelt die Stellen, an denen beide Seiten dasselbe +// unterschiedlich geändert haben (dort gewinnt die eigene Fassung). +// Parameter: basis, eigen, fremd, pfad (Text für Hinweise), konflikte (Array). +function dreiwegeZusammenfuehren(basis, eigen, fremd, pfad, konflikte){ + if(gleich(eigen, fremd)) return eigen; + if(basis !== undefined && gleich(eigen, basis)) return fremd; + if(basis !== undefined && gleich(fremd, basis)) return eigen; + if(istObjekt(eigen) && istObjekt(fremd)){ + const b = istObjekt(basis) ? basis : {}; + const ergebnis = {}; + new Set([...Object.keys(fremd), ...Object.keys(eigen)]).forEach(k => { + const inE = k in eigen, inF = k in fremd, inB = k in b; + if(inE && inF){ ergebnis[k] = dreiwegeZusammenfuehren(inB ? b[k] : undefined, eigen[k], fremd[k], pfad, konflikte); return; } + if(inE){ // fremd fehlt: dort gelöscht (wenn in der Basis) oder hier neu + if(inB && gleich(eigen[k], b[k])) return; // fremd hat gelöscht, hier unverändert + if(inB) konflikte.push(pfad); // hier geändert, dort gelöscht → behalten + ergebnis[k] = eigen[k]; return; + } + // eigen fehlt + if(inB && gleich(fremd[k], b[k])) return; // hier gelöscht, dort unverändert + if(inB){ konflikte.push(pfad); return; } // hier gelöscht, dort geändert → gelöscht lassen + ergebnis[k] = fremd[k]; // dort neu + }); + return ergebnis; + } + if(istIdListe(eigen) && istIdListe(fremd)){ + const bListe = istIdListe(basis) ? basis : []; + const bMap = new Map(bListe.map(x => [String(x.id), x])); + const eMap = new Map(eigen.map(x => [String(x.id), x])); + const fMap = new Map(fremd.map(x => [String(x.id), x])); + // Reihenfolge: hat nur diese Seite umsortiert, gilt ihre, sonst die fremde; + // Neues der anderen Seite wird an seiner Stelle angehängt. + const ids = l => l.map(x => String(x.id)); + const eigeneReihe = !gleich(ids(eigen), ids(bListe)) && gleich(ids(fremd), ids(bListe)); + const reihe = eigeneReihe ? ids(eigen) : ids(fremd); + (eigeneReihe ? ids(fremd) : ids(eigen)).forEach(id => { if(!reihe.includes(id)) reihe.push(id); }); + const ergebnis = []; + reihe.forEach(id => { + const e = eMap.get(id), f = fMap.get(id), b = bMap.get(id); + const name = (e || f || {}).name || (e || f || {}).title || id; + const unterpfad = pfad ? pfad : name; + if(e && f){ ergebnis.push(dreiwegeZusammenfuehren(b, e, f, unterpfad, konflikte)); return; } + if(e){ if(b && gleich(e, b)) return; if(b) konflikte.push(unterpfad); ergebnis.push(e); return; } + if(f){ if(b && gleich(f, b)) return; if(b){ konflikte.push(unterpfad); return; } ergebnis.push(f); } + }); + return ergebnis; + } + konflikte.push(pfad); + return eigen; +} + +// inPlaceUebernehmen: schreibt quelle in das bestehende Objekt ziel — Objekte +// mit gleicher id (Projekte, Aufgaben, Meilensteine, Personen) bleiben +// dieselben Objekte, damit offene Ansichten ihre Referenzen behalten. +function inPlaceUebernehmen(ziel, quelle){ + Object.keys(ziel).forEach(k => { if(!(k in quelle)) delete ziel[k]; }); + Object.keys(quelle).forEach(k => { + const z = ziel[k], q = quelle[k]; + if(istObjekt(z) && istObjekt(q)) inPlaceUebernehmen(z, q); + else if(istIdListe(z) && istIdListe(q)){ + const alt = new Map(z.map(x => [String(x.id), x])); + const neu = q.map(x => { const o = alt.get(String(x.id)); if(o){ inPlaceUebernehmen(o, x); return o; } return x; }); + z.length = 0; neu.forEach(x => z.push(x)); + } else ziel[k] = q; + }); +} + +// wocheVomServer: holt den aktuellen Stand EINER Sitzung (über /load-all, +// die Sandbox steckt dort in sandboxPlans). Rückgabe: Promise. +// Seit GET /week nur diese eine Sitzung statt aller über /load-all. +function wocheVomServer(label){ + return fetch(baseUrl() + '/week?label=' + encodeURIComponent(label)) + .then(r => { + if(r.status === 404) return null; + if(!r.ok) throw new Error('Server HTTP ' + r.status); + return r.json().then(data => { + const w = data && data.week; + if(!w) return null; + if(!Array.isArray(w.people)) w.people = []; + if(!Array.isArray(w.projects)) w.projects = []; + if(!w.attendance) w.attendance = {}; + if(label === SANDBOX_WEEK_LABEL) return { label, projects: w.projects, updatedAt: w.updatedAt || '' }; + return w; + }); + }); +} + +// zusammenfuehren: führt eine lokale Woche mit einem frischen Server-Stand +// zusammen und schreibt das Ergebnis in die lokale Woche. Rückgabe: Array +// der Projekte/Stellen mit echtem Konflikt. +function zusammenfuehren(lokal, frisch){ + const basis = wochenBasis.get(lokal.label); + const ohneRev = w => { const k = wocheKopie(w); delete k.updatedAt; return k; }; + const konflikte = []; + let ergebnis; + if(lokal.label === SANDBOX_WEEK_LABEL){ + // Die Sandbox: nur die Terminpläne zusammenführen, der Rest ist lokal gebaut. + ergebnis = Object.assign({}, ohneRev(lokal), { + projects: dreiwegeZusammenfuehren(basis ? basis.projects : undefined, wocheKopie(lokal.projects), frisch.projects, '', konflikte) + }); + } else { + ergebnis = dreiwegeZusammenfuehren(basis ? ohneRev(basis) : undefined, ohneRev(lokal), ohneRev(frisch), '', konflikte); + } + inPlaceUebernehmen(lokal, ergebnis); + lokal.updatedAt = frisch.updatedAt; + const serverStand = lokal.label === SANDBOX_WEEK_LABEL ? Object.assign(wocheKopie(lokal), { projects: wocheKopie(frisch.projects) }) : wocheKopie(frisch); + wochenBasis.set(lokal.label, serverStand); + return [...new Set(konflikte.filter(Boolean))]; +} + +// zusammenfuehrenUndSpeichern: Antwort auf einen 409 beim Speichern. +function zusammenfuehrenUndSpeichern(label, lokal, err){ + const n = (konfliktVersuche.get(label) || 0) + 1; + konfliktVersuche.set(label, n); + if(n > 3){ + // Ungewöhnlich: dreimal in Folge überholt. Dann der alte, sichere Weg. + konfliktVersuche.delete(label); + dirtyWeeks.delete(label); + handleWeekConflict(lokal, err); + return; + } + wocheVomServer(label).then(frisch => { + if(!frisch){ + // Die Sitzung wurde am Server gelöscht — hier nicht wiederbeleben. + dirtyWeeks.delete(label); + handleWeekConflict(lokal, err); + return; + } + const konflikte = zusammenfuehren(lokal, frisch); + markWeekDirty(label); + ansichtAuffrischen(); + if(konflikte.length){ + zeigeHinweis('Gleichzeitig geändert: ' + konflikte.slice(0, 3).join(', ') + + (konflikte.length > 3 ? ' …' : '') + '. Ihre Fassung wurde übernommen.'); + } + scheduleSessionSync(); + }).catch(e => { + console.error('Zusammenführen fehlgeschlagen:', e); + showSyncProblem('Speichern fehlgeschlagen — neuer Versuch in 20 Sekunden.'); + scheduleSyncRetry(); + }); +} + +// ansichtAuffrischen: zeichnet die offene Ansicht nach übernommenen Daten neu. +function ansichtAuffrischen(){ + if(typeof renderAll === 'function') renderAll(); + if(typeof currentTab !== 'undefined' && currentTab === 'plan'){ + if(typeof renderPlanGantt === 'function') renderPlanGantt(); + } else if(typeof currentTab !== 'undefined' && currentTab === 'wand'){ + if(typeof renderWandboard === 'function') renderWandboard(); + } +} + +// zeigeHinweis: kurze, nicht blockierende Meldung unten rechts (8 s). +let hinweisTimer = null; +function zeigeHinweis(text){ + let el = document.getElementById('syncHinweis'); + if(!el){ + el = document.createElement('div'); + el.id = 'syncHinweis'; + el.className = 'sync-problem-bar sync-hinweis'; + document.body.appendChild(el); + } + el.textContent = text; + el.style.display = ''; + clearTimeout(hinweisTimer); + hinweisTimer = setTimeout(() => { el.style.display = 'none'; }, 8000); +} + +// wirdGeradeBearbeitet: tippt jemand oder ist ein Dialog offen? Dann keine +// fremden Änderungen einspielen — ein Neuzeichnen würde Eingaben verwerfen. +function wirdGeradeBearbeitet(){ + const a = document.activeElement; + if(a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))) return true; + return [...document.querySelectorAll('.modal-bg')].some(m => m.style.display && m.style.display !== 'none'); +} + +// wochenLiveAbgleich: übernimmt Änderungen anderer, ohne dass jemand neu +// laden muss. Läuft alle 20 Sekunden; die Wandtafel lädt ohnehin selbst. +let liveAbgleichLaeuft = false; +function wochenLiveAbgleich(){ + if(liveAbgleichLaeuft || !currentUser || !hatRecht('app')) return; + if(syncInFlight || dirtyWeeks.size > 0 || wirdGeradeBearbeitet()) return; + if(!Array.isArray(db.weeks) || db.weeks.length === 0) return; + liveAbgleichLaeuft = true; + fetch(baseUrl() + '/week-revisionen') + .then(r => r.ok ? r.json() : null) + .then(data => { + if(!data || !data.ok) return; + const rev = data.revisionen || {}; + const lokal = db.weeks.concat(db.sandboxWeek ? [db.sandboxWeek] : []); + const geaendert = lokal.filter(w => w.label in rev && rev[w.label] && rev[w.label] !== (w.updatedAt || '')); + if(!geaendert.length) return; + return Promise.all(geaendert.map(w => wocheVomServer(w.label).then(frisch => ({ w, frisch })))).then(liste => { + // Zwischenzeitlich doch etwas bearbeitet? Dann beim nächsten Mal. + if(dirtyWeeks.size > 0 || syncInFlight || wirdGeradeBearbeitet()) return; + liste.forEach(({ w, frisch }) => { if(frisch) zusammenfuehren(w, frisch); }); + ansichtAuffrischen(); + }); + }) + .catch(() => { /* still — der nächste Durchlauf versucht es wieder */ }) + .finally(() => { liveAbgleichLaeuft = false; }); +} +// Nur noch Rückfall: Änderungen kommen sofort über die Live-Ereignisse +// (liveVerbinden(), unten). Der Abgleich fängt ab, was während einer +// Verbindungsunterbrechung verpasst wurde, und läuft zusätzlich direkt nach +// jedem Wiederverbinden. +setInterval(wochenLiveAbgleich, 60000); + +// =========================================================================== +// Live-Ereignisse (GET /ereignisse, Server-Sent Events) +// --------------------------------------------------------------------------- +// Sobald jemand speichert, meldet der Server es allen anderen Browsern — +// bei einzelnen Projekten gleich mit deren neuem Stand, sodass nur diese +// übertragen werden. Eigene ungespeicherte Änderungen an demselben Projekt +// bleiben erhalten (Dreiwege-Zusammenführung wie beim Speicherkonflikt). +// Während jemand tippt oder ein Dialog offen ist, werden die Daten +// übernommen, aber erst danach neu gezeichnet (ansichtNachBearbeitung()). +// =========================================================================== +let liveQuelle = null; +let liveGetrennt = false; +let liveNeuzeichnenAusstehend = false; + +function liveVerbinden(){ + if(liveQuelle || typeof EventSource === 'undefined' || !currentUser) return; + liveQuelle = new EventSource(baseUrl() + '/ereignisse?client=' + encodeURIComponent(CLIENT_ID)); + liveQuelle.onmessage = ev => { + let d; try { d = JSON.parse(ev.data); } catch(e){ return; } + liveEreignis(d); + }; + liveQuelle.onopen = () => { + // Nach einer Unterbrechung nachholen, was in der Zeit passiert ist. + if(liveGetrennt){ liveGetrennt = false; wochenLiveAbgleich(); } + }; + liveQuelle.onerror = () => { liveGetrennt = true; }; // EventSource verbindet selbst neu +} + +function liveTrennen(){ + if(liveQuelle){ liveQuelle.close(); liveQuelle = null; } +} +// Beim Verlassen der Seite die Verbindung sauber schließen. +window.addEventListener('pagehide', liveTrennen); + +function liveEreignis(d){ + if(!d || !d.typ) return; + if(d.typ === 'projekte') return liveProjekte(d); + if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht); + if(d.typ === 'neu') return liveAllesNeu(); + if(d.typ === 'personen') return personenNachladen(); +} + +// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer +// Abwesenheits-Änderung, hier oder in einem anderen Browser) und neu zeichnen. +// Rückgabe: Promise. +function personenNachladen(){ + return fetch(baseUrl() + '/personen', { credentials: 'same-origin' }) + .then(r => r.ok ? r.json() : null) + .then(d => { + if(!d || !Array.isArray(d.people)) return; + db.globalPeople = d.people; + ansichtNachBearbeitung(); + }) + .catch(err => console.error('Personen nachladen fehlgeschlagen:', err)); +} + +// liveProjekte: übernimmt die mitgeschickten Projekte einer Sitzung. +function liveProjekte(d){ + const w = d.label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === d.label); + if(!w) return; + const basis = wochenBasis.get(d.label); + const konflikte = []; + let eigeneOffen = false; + for(const fp of (d.projekte || [])){ + const lp = (w.projects || []).find(x => x.id === fp.id); + if(!lp) return liveWocheNachladen(d.label); // Projekt hier unbekannt: ganze Sitzung holen + const bp = basis ? (basis.projects || []).find(x => x.id === fp.id) : undefined; + if(bp && gleich(lp, bp)){ + inPlaceUebernehmen(lp, JSON.parse(JSON.stringify(fp))); + } else { + // Hier gibt es noch ungespeicherte Änderungen an diesem Projekt. + const erg = dreiwegeZusammenfuehren(bp, JSON.parse(JSON.stringify(lp)), fp, lp.name || '', konflikte); + inPlaceUebernehmen(lp, erg); + lp.updatedAt = fp.updatedAt; + eigeneOffen = true; + } + if(basis){ + const i = basis.projects.findIndex(x => x.id === fp.id); + if(i >= 0) basis.projects[i] = JSON.parse(JSON.stringify(fp)); + } + } + revisionUebernehmen(w, d.updatedAt); + if(eigeneOffen){ markWeekDirty(d.label); scheduleSessionSync(); } + if(konflikte.length){ + zeigeHinweis('Gleichzeitig geändert: ' + [...new Set(konflikte.filter(Boolean))].slice(0, 3).join(', ') + '. Ihre Fassung wurde übernommen.'); + } + ansichtNachBearbeitung(); +} + +// liveWocheNachladen: eine ganze Sitzung hat sich geändert — nur sie holen. +function liveWocheNachladen(label, geloescht){ + if(geloescht || !label) return liveAllesNeu(); + // Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach. + if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; } + const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label); + if(!w) return liveAllesNeu(); + wocheVomServer(label).then(frisch => { + if(!frisch) return liveAllesNeu(); + if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; } + const hatteEigene = dirtyWeeks.has(label); + const konflikte = zusammenfuehren(w, frisch); + if(hatteEigene){ markWeekDirty(label); scheduleSessionSync(); } + if(konflikte.length){ + zeigeHinweis('Gleichzeitig geändert: ' + konflikte.slice(0, 3).join(', ') + '. Ihre Fassung wurde übernommen.'); + } + ansichtNachBearbeitung(); + }).catch(e => console.error('Live-Nachladen fehlgeschlagen:', e)); +} + +// liveAllesNeu: neue Sitzung, gelöschte Sitzung oder eingespieltes Backup — +// alles neu laden, aber erst, wenn hier nichts mehr ungespeichert ist. +function liveAllesNeu(){ + if(dirtyWeeks.size > 0 || syncInFlight || wirdGeradeBearbeitet()){ + setTimeout(liveAllesNeu, 2000); + return; + } + load(true); +} + +// ansichtNachBearbeitung: neu zeichnen — sofort, oder sobald niemand mehr +// tippt bzw. kein Dialog mehr offen ist (ein Neuzeichnen verwürfe Eingaben). +function ansichtNachBearbeitung(){ + if(!wirdGeradeBearbeitet()){ liveNeuzeichnenAusstehend = false; ansichtAuffrischen(); return; } + if(liveNeuzeichnenAusstehend) return; + liveNeuzeichnenAusstehend = true; + const pruefen = () => { + if(!liveNeuzeichnenAusstehend) return; + if(wirdGeradeBearbeitet()){ setTimeout(pruefen, 1500); return; } + liveNeuzeichnenAusstehend = false; + ansichtAuffrischen(); + }; + setTimeout(pruefen, 1500); +} + // handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich // geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu // geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste @@ -177,11 +653,8 @@ function handleWeekConflict(week, err){ // Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von // syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten. function reloadWeekFromServer(label){ - fetch(baseUrl() + '/load-all') - .then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }) - .then(data => { - const fresh = (Array.isArray(data.weeks) ? data.weeks : []) - .find(w => w && w.label === label); + wocheVomServer(label) + .then(fresh => { const idx = db.weeks.findIndex(w => w.label === label); if(idx >= 0){ if(fresh){ @@ -190,6 +663,7 @@ function reloadWeekFromServer(label){ if(!Array.isArray(fresh.projects)) fresh.projects = []; if(!fresh.attendance) fresh.attendance = {}; db.weeks[idx] = fresh; + basisMerken(fresh); } else { db.weeks.splice(idx, 1); } @@ -309,6 +783,11 @@ function load(silent){ return w; }); + // Server-Stand als Basis für späteres Zusammenführen merken. + wochenBasis.clear(); + db.weeks.forEach(basisMerken); + basisMerken(db.sandboxWeek); + if(db.weeks.length === 0){ console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.'); seed(); @@ -327,6 +806,7 @@ function load(silent){ // wenn der Tab per Deep-Link-URL (z. B. "/Projekte") gewählt wurde. renderAll(); showTab(currentTab); + liveVerbinden(); }) .catch(err => { console.error('Laden vom Server fehlgeschlagen:', err); diff --git a/public/js/tabs-auth.js b/public/js/tabs-auth.js index c079c1a..5552ed9 100644 --- a/public/js/tabs-auth.js +++ b/public/js/tabs-auth.js @@ -23,15 +23,17 @@ // 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','planchecker','dashboard','wand','settings','admin','plan']; +const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','people','planung','planchecker','dashboard','wand','settings','admin','plan']; 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; + // Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der + // Server mit requireRecht()): „Admin“ nur mit einem Admin-Recht, die + // Wandtafel nur mit „Wandtafel anzeigen“, und eine Rolle ohne „Anwendung + // nutzen“ (früher das Kiosk-Konto) landet immer auf der Wandtafel. + if(currentUser && !hatRecht('app') && hatRecht('wand')) t = 'wand'; + if(t === 'admin' && !hatAdminRecht()) t = DEFAULT_TAB; + if(t === 'abwesenheit' && currentUser && !darfIrgendeineAbwesenheit()) t = DEFAULT_TAB; + if(t === 'wand' && currentUser && !hatRecht('wand')) t = DEFAULT_TAB; if(t !== currentTab) previousTab = currentTab; currentTab = t; saveUiState(); @@ -58,6 +60,7 @@ function showTab(t){ if(t==='board' && currentWeek()) renderBoard(); if(t==='wochenbericht') renderWochenbericht(); if(t==='load') renderLoad(); + if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); } if(t==='people') renderPeople(); if(t==='planung') renderPlanung(); if(t==='planchecker') renderPlanChecker(); diff --git a/public/js/utils.js b/public/js/utils.js index 99a6c16..3f79178 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -309,6 +309,104 @@ function statusFocus(el, pid){ currentStatusPid = pid; } +// ---------- Kommentar und "besprochen" je Gewerk ---------- +// +// ELT, HLS und ARCH halten ihre Besprechung getrennt (Eriks Wunsch: "Die +// Kommentare und Anwesenheiten von ELT brauchen nicht in HLS oder Arch zu +// sein" — und "auch wenn das Projekt in ELT besprochen ist, heißt das nicht, +// dass HLS das Projekt auch besprochen hat"). Ein Projekt mit mehreren +// Gewerken trägt deshalb je Gewerk einen eigenen Sitzungskommentar samt +// Verfasser/Zeitstempel und einen eigenen Besprochen-Haken: +// p.kommentare = { ELT: { status, statusAuthor, statusAt, besprochen }, … } +// (Tabelle project_kommentare, server.js). Die früheren Felder +// p.status/statusAuthor/statusAt/besprochen werden nicht mehr gelesen oder +// geschrieben — alles läuft über die drei Helfer unten. + +// kommentarGewerk: das Gewerk, in dessen Zusammenhang gerade gelesen bzw. +// geschrieben wird — im Wochenbericht das dort gewählte, überall sonst null +// (= alle Gewerke zusammen, nur zur Anzeige). Keine Parameter. +function kommentarGewerk(){ + if(typeof currentTab !== 'undefined' && currentTab === 'wochenbericht' + && typeof wochenberichtGewerk !== 'undefined' && wochenberichtGewerk) return wochenberichtGewerk; + return null; +} + +// kommentarVon: der Kommentar eines Projekts für ein Gewerk. Ohne Gewerk die +// gewerkübergreifende Anzeige: die Kommentare aller Gewerke untereinander, +// jeweils mit dem Gewerk davor (bei nur einem Gewerk ohne Beschriftung), +// Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des +// Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder +// null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }. +function kommentarVon(p, g){ + const alle = (p && p.kommentare) || {}; + if(g){ + const e = alle[g] || {}; + return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen }; + } + const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH']; + Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); }); + const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== ''); + const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : []; + let status = ''; + if(mitText.length === 1 && gewerke.length <= 1) status = alle[mitText[0]].status; + // Jeder Kommentar in einem eigenen Block: Die Klartext-Vorschau + // (statusPlainText/statusPreviewText) trennt nur an Blockgrenzen — ohne + // eigenen Block klebten die Gewerke dort aneinander ("…fettHLS: …"). + else status = mitText.map(k => `
    ${esc(k)}:
    ${alle[k].status}
    `).join(''); + const juengster = mitText.map(k => alle[k]).sort((a, b) => String(b.statusAt || '').localeCompare(String(a.statusAt || '')))[0] || {}; + const pruefen = gewerke.length ? gewerke : Object.keys(alle); + const besprochen = pruefen.length > 0 && pruefen.every(k => alle[k] && alle[k].besprochen); + return { status, statusAuthor: juengster.statusAuthor || '', statusAt: juengster.statusAt || '', besprochen }; +} + +// kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g +// nur dessen Text; ohne Gewerk die Texte aller Gewerke, bei mehreren je mit +// dem Gewerk als eigener, dezenter Beschriftung (.kommentar-gewerk) — +// Systemtext, nicht Teil des Kommentars (anders als die fette "ELT:"-Zeile, +// die kommentarVon() für Klartext-Vorschauen einfügt). Rückgabe: HTML ('' ohne Text). +function kommentarAnzeigeHtml(p, g){ + if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || ''); + const alle = (p && p.kommentare) || {}; + const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH']; + Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); }); + const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== ''); + if(mitText.length === 0) return ''; + if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status); + return mitText.map(k => `
    ${esc(k)}
    ${sanitizeStatusHtml(alle[k].status)}
    `).join(''); +} + +// kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in +// patch genannten Felder). Parameter: p (Projekt), g (Gewerk), patch +// (Objekt). Keine Rückgabe. +function kommentarSetzen(p, g, patch){ + if(!p || !g) return; + p.kommentare = p.kommentare || {}; + p.kommentare[g] = Object.assign(kommentarVon(p, g), patch || {}); +} + +// personGewerk: das Gewerk einer Person aus dem Teilnehmerkreis einer +// Sitzung. week.people kennt es selbst nicht; es kommt aus dem Konto +// (db.globalPeople, users.department) — gesucht über die Kennung, ersatzweise +// über den vollen Namen (Altbestand mit abweichender Kennung, siehe +// kapazitaetsFaktor()). Parameter: person. Rückgabe: Gewerk oder ''. +function personGewerk(person){ + const liste = (typeof db !== 'undefined' && Array.isArray(db.globalPeople)) ? db.globalPeople : []; + const treffer = liste.find(x => x.id === person.id) + || (person.fullName ? liste.find(x => x.fullName === person.fullName) : null); + return (treffer && treffer.gewerk) || ''; +} + +// personenDesGewerks: der Teilnehmerkreis einer Sitzung, eingeschränkt auf +// ein Gewerk — für die Anwesenheit, die je Gewerk getrennt geführt wird. +// Personen ohne bekanntes Gewerk bleiben überall sichtbar, damit niemand +// unauffindbar wird. Ohne Gewerk alle. Parameter: w (Woche), g. +// Rückgabe: Array. +function personenDesGewerks(w, g){ + const leute = Array.isArray(w && w.people) ? w.people : []; + if(!g) return leute; + return leute.filter(person => { const pg = personGewerk(person); return !pg || pg === g; }); +} + // 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. @@ -345,6 +443,17 @@ function formatStatus(command, value){ // 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. +// fontFarbeErlaubt: nur echte Hex-Farben, und keine Grau-/Schwarz-/Weißtöne — +// eingefügter Text bringt oft color="#1a1a1a" mit, das im Dunkelmodus +// unsichtbar wäre und ohnehin nichts hervorhebt. Rückgabe: Boolean. +function fontFarbeErlaubt(wert){ + const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(wert || '').trim()); + if(!m) return false; + const h = m[1].length === 3 ? m[1].replace(/./g, c => c + c) : m[1]; + const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16); + return Math.max(r, g, b) - Math.min(r, g, b) > 40; +} + function sanitizeStatusHtml(html){ if(!html) return ''; // Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die @@ -386,6 +495,10 @@ function sanitizeStatusHtml(html){ } for(let j = child.attributes.length - 1; j >= 0; j--){ const attr = child.attributes[j].name; + // (ältere Kommentare, Browser-Farbbefehl) behält + // seine Farbe — vorher fiel sie hier still weg, und farbige + // Bestandskommentare erschienen überall einfarbig. + if(tag === 'FONT' && attr.toLowerCase() === 'color' && fontFarbeErlaubt(child.getAttribute(attr))) continue; if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr); } if(child.hasAttribute('style')){ @@ -474,9 +587,239 @@ function richtextWickeln(feld, tagName, styleAttr){ 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); } + +// ---------- Auszeichnung wieder entfernen (Umschalten) ---------- +// +// Eriks Wunsch: Ein zweiter Klick auf denselben Knopf nimmt die Auszeichnung +// wieder zurück — rot markierter Text wird durch erneutes Klicken auf Rot +// wieder schwarz, ebenso Fett/Kursiv und die übrigen Farben. Vorher wickelte +// jeder Klick nur eine weitere Hülle um den Text, rückgängig machen ließ sich +// nichts. +// +// Eine Auszeichnungsart ist ein kleines Objekt { trifft(el), entferne(el) }: +// trifft() erkennt ein Element dieser Art, entferne() nimmt NUR diese Art +// heraus — ein verliert beim +// Entfernen der Farbe nicht auch das Fett. + +// richtextFarbWert: normalisiert eine Farbangabe auf die Schreibweise des +// Browsers ("rgb(215, 0, 21)"), damit '#d70015' und der gespeicherte Wert +// vergleichbar sind. Parameter: farbe (String). Rückgabe: String. +function richtextFarbWert(farbe){ + const t = document.createElement('span'); + t.style.color = farbe || ''; + return t.style.color; +} + +// richtextAuspacken: ersetzt ein Element durch seine Kinder. +function richtextAuspacken(el){ + if(el.parentNode) el.replaceWith(...Array.from(el.childNodes)); +} + +// richtextStilEntfernen: nimmt eine CSS-Eigenschaft aus dem style-Attribut +// und packt ein dann bedeutungsloses / aus. +function richtextStilEntfernen(el, prop){ + el.style.removeProperty(prop); + if(!el.getAttribute('style')) el.removeAttribute('style'); + const tag = el.tagName; + if((tag === 'SPAN' || tag === 'FONT') && el.attributes.length === 0) richtextAuspacken(el); +} + +const RICHTEXT_ART_FETT = { + trifft: el => el.tagName === 'B' || el.tagName === 'STRONG' + || /^(bold|[6-9]00)$/.test((el.style && el.style.fontWeight) || ''), + entferne: el => (el.tagName === 'B' || el.tagName === 'STRONG') ? richtextAuspacken(el) : richtextStilEntfernen(el, 'font-weight') +}; +const RICHTEXT_ART_KURSIV = { + trifft: el => el.tagName === 'I' || el.tagName === 'EM', + entferne: el => richtextAuspacken(el) +}; +// richtextArtFarbe: eine bestimmte Farbe (farbe gesetzt) oder jede Farbe +// (farbe leer — zum Umfärben: erst die alte Farbe entfernen, dann die neue +// setzen, statt Hüllen ineinander zu schachteln). +function richtextArtFarbe(farbe){ + const ziel = farbe ? richtextFarbWert(farbe) : ''; + const farbeVon = el => (el.style && el.style.color) || (el.tagName === 'FONT' ? richtextFarbWert(el.getAttribute('color')) : ''); + return { + trifft: el => { const f = farbeVon(el); return !!f && (!ziel || f === ziel); }, + entferne: el => { + if(el.tagName === 'FONT') el.removeAttribute('color'); + richtextStilEntfernen(el, 'color'); + } + }; +} + +// richtextHatArt: steht in einem Element (zwischen node und feld) eine +// Auszeichnung dieser Art? +function richtextHatArt(node, feld, art){ + for(let n = node.nodeType === 1 ? node : node.parentNode; n && n !== feld; n = n.parentNode){ + if(n.nodeType === 1 && art.trifft(n)) return true; + } + return false; +} + +// richtextAuswahl: die aktuelle, nicht leere Markierung innerhalb von feld +// oder null. +function richtextAuswahl(feld){ + if(!feld) return null; + const sel = window.getSelection(); + if(!sel || sel.rangeCount === 0) return null; + const range = sel.getRangeAt(0); + if(range.collapsed || !feld.contains(range.commonAncestorContainer)) return null; + return range; +} + +// richtextIstAktiv: trägt JEDER markierte (sichtbare) Text die Auszeichnung? +// Nur dann schaltet ein Klick sie ab; ist sie nur teilweise vorhanden, wird +// sie auf die ganze Markierung ausgedehnt — wie in jeder Textverarbeitung. +function richtextIstAktiv(feld, range, art){ + const wurzel = range.commonAncestorContainer; + const texte = []; + if(wurzel.nodeType === 3) texte.push(wurzel); + else { + const walker = document.createTreeWalker(wurzel, NodeFilter.SHOW_TEXT); + while(walker.nextNode()) if(range.intersectsNode(walker.currentNode)) texte.push(walker.currentNode); + } + const relevant = texte.filter(t => t.textContent.trim() !== ''); + return relevant.length > 0 && relevant.every(t => richtextHatArt(t, feld, art)); +} + +// richtextLeereHuellenEntfernen: Beim Aufteilen bleiben leere / +// zurück — unsichtbar, aber sie würden mitgespeichert. +function richtextLeereHuellenEntfernen(feld){ + feld.querySelectorAll('b, strong, i, em, u, span, font').forEach(el => { + if(el.textContent === '' && !el.querySelector('br')) el.remove(); + }); +} + +// richtextArtEntfernen: entfernt eine Auszeichnungsart von der Markierung. +// Der markierte Teil wird herausgelöst und von dieser Art befreit; liegt er +// INNERHALB eines solchen Elements (rot markiertes Wort mitten in einem roten +// Satz), wird dieses Element um ihn herum aufgeteilt, sodass Text davor und +// dahinter seine Auszeichnung behält. Die Markierung bleibt danach auf +// demselben Text stehen. +function richtextArtEntfernen(feld, range, art){ + const mitte = document.createElement('span'); + mitte.appendChild(range.extractContents()); + // Auszeichnungen dieser Art INNERHALB der Markierung + Array.from(mitte.querySelectorAll('*')).reverse().forEach(el => { if(art.trifft(el)) art.entferne(el); }); + range.insertNode(mitte); + + // Umgebende Elemente dieser Art um die Markierung herum aufteilen + for(;;){ + let huelle = null; + for(let n = mitte.parentNode; n && n !== feld; n = n.parentNode){ + if(n.nodeType === 1 && art.trifft(n)){ huelle = n; break; } + } + if(!huelle) break; + const vorher = document.createRange(); + vorher.setStart(huelle, 0); vorher.setEndBefore(mitte); + const vorderteil = vorher.extractContents(); + const nachher = document.createRange(); + nachher.setStartAfter(mitte); nachher.setEnd(huelle, huelle.childNodes.length); + const hinterteil = nachher.extractContents(); + if(vorderteil.textContent !== '' || vorderteil.querySelector && vorderteil.querySelector('br')){ + const k = huelle.cloneNode(false); k.appendChild(vorderteil); huelle.before(k); + } + if(hinterteil.textContent !== '' || hinterteil.querySelector && hinterteil.querySelector('br')){ + const k = huelle.cloneNode(false); k.appendChild(hinterteil); huelle.after(k); + } + art.entferne(huelle); + } + + const kinder = Array.from(mitte.childNodes); + richtextAuspacken(mitte); + richtextLeereHuellenEntfernen(feld); + const sel = window.getSelection(); + const neu = document.createRange(); + const erstes = kinder.find(k => k.parentNode); + const letztes = kinder.slice().reverse().find(k => k.parentNode); + if(erstes && letztes){ + neu.setStartBefore(erstes); neu.setEndAfter(letztes); + sel.removeAllRanges(); sel.addRange(neu); + } +} + +// richtextUmschalten: setzt die Auszeichnung, oder nimmt sie zurück, wenn die +// ganze Markierung sie schon trägt. ersetzt (optional) ist eine Art, die +// vorher entfernt wird — für Farben: Blau auf rotem Text färbt um, statt eine +// blaue Hülle in die rote zu legen. +function richtextUmschalten(feld, art, wickeln, ersetzt){ + const range = richtextAuswahl(feld); + if(!range) return; + if(richtextIstAktiv(feld, range, art)){ + richtextArtEntfernen(feld, range, art); + } else { + if(ersetzt) richtextArtEntfernen(feld, range, ersetzt); + else richtextArtEntfernen(feld, range, art); // keine doppelten Hüllen + wickeln(); + } + feld.dispatchEvent(new Event('input', { bubbles: true })); +} + +function richtextFett(feld){ + richtextUmschalten(feld, RICHTEXT_ART_FETT, () => richtextWickeln(feld, 'b')); +} +function richtextKursiv(feld){ + richtextUmschalten(feld, RICHTEXT_ART_KURSIV, () => richtextWickeln(feld, 'i')); +} +function richtextFarbe(feld, farbe){ + richtextUmschalten(feld, richtextArtFarbe(farbe), + () => richtextWickeln(feld, 'span', 'color:' + farbe), richtextArtFarbe('')); +} + +// richtextFormatLoeschen: setzt Text auf das Standardformat zurück — kein +// Fett, kein Kursiv, keine Farbe. Mit Markierung nur diese, ohne Markierung +// das ganze Feld. Zeilenumbrüche und Absätze bleiben erhalten, nur die +// Auszeichnung fällt weg. Parameter: feld (Element). Keine Rückgabe. +function richtextFormatLoeschen(feld){ + if(!feld || feld.getAttribute('contenteditable') !== 'true') return; + const saeubern = wurzel => { + Array.from(wurzel.querySelectorAll('b, strong, i, em, u, span, font')).reverse().forEach(richtextAuspacken); + wurzel.querySelectorAll('[style]').forEach(el => el.removeAttribute('style')); + }; + const range = richtextAuswahl(feld); + if(range){ + // Alle Arten nacheinander von der Markierung nehmen — teilt umgebende + // Hüllen sauber auf, statt den Text davor/dahinter mit zu entformatieren. + [RICHTEXT_ART_FETT, RICHTEXT_ART_KURSIV, richtextArtFarbe(''), + { trifft: el => el.tagName === 'U', entferne: richtextAuspacken }] + .forEach(art => { const r = richtextAuswahl(feld); if(r) richtextArtEntfernen(feld, r, art); }); + } else { + saeubern(feld); + } + feld.normalize(); + feld.dispatchEvent(new Event('input', { bubbles: true })); +} + +// richtextNeueZeileMitStrich: Enter beginnt die neue Zeile mit "- " (Eriks +// Wunsch für den Wochenbericht-Kommentar — Stichpunkte sind dort die Regel). +// Der Strich ist gewöhnlicher Text: Wer eine Zeile ohne ihn will, löscht ihn +// mit der Rücktaste. Umschalt+Enter bleibt beim Browser und fügt eine Zeile +// ganz ohne Strich ein. +// Parameter: e (keydown-Event), feld (Element). Rückgabe: true, wenn das +// Ereignis behandelt wurde. +function richtextNeueZeileMitStrich(e, feld){ + if(e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return false; + const sel = window.getSelection(); + if(!sel || sel.rangeCount === 0 || !feld.contains(sel.getRangeAt(0).commonAncestorContainer)) return false; + e.preventDefault(); + const range = sel.getRangeAt(0); + range.deleteContents(); + const br = document.createElement('br'); + // Nach dem
    steht immer Text (der Strich) — ein
    am Feldende + // würde der Browser sonst nicht als neue Zeile anzeigen. + // Geschütztes Leerzeichen: Ein gewöhnliches am Zeilenende verwirft der + // Browser beim Weitertippen, aus "- Text" würde "-Text". + const text = document.createTextNode('-\u00a0'); + range.insertNode(text); + range.insertNode(br); + const neu = document.createRange(); + neu.setStart(text, text.textContent.length); + neu.collapse(true); + sel.removeAllRanges(); sel.addRange(neu); + feld.dispatchEvent(new Event('input', { bubbles: true })); + return true; +} // richtextFeldHtml: liefert ein contenteditable-Feld mit Platzhaltertext // (:empty:before + data-placeholder, siehe styles.css) als Ersatz für ein @@ -579,7 +922,7 @@ document.addEventListener('selectionchange', function(){ 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'; + if(hinweis) hinweis.textContent = markiert ? '✓ Markiert — jetzt formatieren' : 'Erst Text markieren · erneut klicken hebt auf'; }); }); @@ -619,7 +962,10 @@ function richtextToolbarHtml(targetSelector){
    ${farbButtons} - Erst Text markieren + + + Erst Text markieren · erneut klicken hebt auf
    `; } @@ -672,18 +1018,22 @@ function statusPlainText(html){ // wurden, tragen den Text der Vorwoche unverändert mit. Parameter: uid // (String). Rückgabe: Array von { week, text, html, author, at, isFirst }, // neueste zuerst. -function projectStatusHistory(uid){ +function projectStatusHistory(uid, g){ + // Gewerk: ausdrücklich übergeben, sonst das des Zusammenhangs (im + // Wochenbericht das gewählte, siehe kommentarGewerk()). + const gewerk = g === undefined ? kommentarGewerk() : g; const versions = projectVersionsForUid(uid); const entries = []; let lastText = null; versions.forEach((v, idx) => { - const text = statusPlainText(v.project.status); + const k = kommentarVon(v.project, gewerk); + const text = statusPlainText(k.status); const entry = { week: v.week, text, - html: sanitizeStatusHtml(v.project.status || ''), - author: v.project.statusAuthor || '', - at: v.project.statusAt || '', + html: sanitizeStatusHtml(k.status || ''), + author: k.statusAuthor || '', + at: k.statusAt || '', isFirst: idx === 0 }; if(idx === 0){ @@ -829,7 +1179,10 @@ function renderProjektVerlauf(p, weekLabel, locked){
    `; }).join(''); - const text = statusPlainText(v.project.status); + const text = statusPlainText(kommentarVon(v.project, kommentarGewerk()).status); + // Angezeigt wird der Kommentar MIT Formatierung (Farben, fett, Absätze), + // das Gewerk als eigene Beschriftung; text dient nur Tooltip und Prüfung. + const html = text ? kommentarAnzeigeHtml(v.project, kommentarGewerk()) : ''; const termine = verlaufTermine(v.project); const termineDavor = davor ? verlaufTermine(davor.project) : { ende:'', meilenstein:'' }; const beteiligte = verlaufBeteiligte(v); @@ -863,7 +1216,7 @@ function renderProjektVerlauf(p, weekLabel, locked){ ${esc(v.week.label)} ${esc(v.week.date ? formatWeekDate(v.week.date) : '')} -
    ${text ? esc(text) : 'Kein Kommentar'}
    +
    ${text ? html : 'Kein Kommentar'}
    ${uebernehmen}
    ${lphHtml}
    ${zeilen.length ? `
    ${zeilen.join('')}
    ` : ''} diff --git a/public/js/wand/wand.js b/public/js/wand/wand.js index 1c31182..6e00915 100644 --- a/public/js/wand/wand.js +++ b/public/js/wand/wand.js @@ -29,7 +29,7 @@ // 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()/ +// Wiederverwendet werden aufgabenVerlaufJePerson()/loadAmpelColor()/ // kapazitaetsFaktor()/stundenFormat()/lpActiveList()/schwelleGelb()/ // schwelleRot() aus js/load/load.js, // classifyProjectChange()/findPreviousWeek()/springZumWochenbericht() aus @@ -78,7 +78,6 @@ const WAND_GEWERKE = ['ELT', 'HLS', 'ARCH']; const WAND_REFRESH_MS = 60000; // Hintergrund-Aktualisierung -const WAND_FEED_MAX = 6; // Höchstzahl Einträge im "Aktuell"-Panel // 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 @@ -192,9 +191,30 @@ function wandKehreZurueck(){ // ü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. +// wandImGrundzustand: ganz oben gescrollt und keine Übersicht offen — dann +// gibt es nichts zurückzusetzen, und der Rücksprung-Zähler ruht (Eriks +// Wunsch: "Der Rücksprung-Timer muss nur laufen, wenn die Seite nicht nach +// ganz oben gescrollt ist"). Rückgabe: Boolean. +function wandImGrundzustand(){ + const c = document.getElementById('wandContent'); + return !wandUebersicht && (!c || c.scrollTop <= 2); +} + function wandCountdownTick(){ + const uhr = document.getElementById('wtUhr'); + if(uhr) uhr.textContent = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); const el = document.getElementById('wandIdleCountdown'); if(!el) return; + // Im Grundzustand ruht die Uhr: ausblenden und die volle Minute + // vorhalten — sobald jemand scrollt oder eine Übersicht öffnet, beginnt + // sie dort bei 60 s (das Scrollen meldet wandAktivitaetErkannt() ohnehin). + if(document.body.classList.contains('wandtafel-aktiv') && wandImGrundzustand()){ + clearTimeout(wandIdleTimer); + wandIdleTimer = setTimeout(wandBeiUntaetigkeitZuruecksetzen, WAND_IDLE_MS); + wandIdleDeadline = Date.now() + WAND_IDLE_MS; + el.textContent = ''; + return; + } const rest = Math.max(0, Math.ceil((wandIdleDeadline - Date.now()) / 1000)); el.textContent = rest > 0 ? ('Rücksprung in ' + rest + ' s') : ''; } @@ -209,6 +229,7 @@ function wandCountdownTick(){ // laufen, nicht nach dem ersten Rücksprung stehen bleiben, solange niemand // die Tafel berührt. Keine Parameter, keine Rückgabe. function wandBeiUntaetigkeitZuruecksetzen(){ + if(wandImGrundzustand()){ wandAktivitaetErkannt(); return; } wandUebersicht = null; renderWandboard({ resetScroll: true }); wandAktivitaetErkannt(); @@ -305,17 +326,18 @@ function renderWandboard(opts){ } container.classList.add('wand-scrollable'); - container.innerHTML = '
    ' - + wandDashKopfHtml(w) - + wandKpiReiheHtml(w) - + '
    ' - + wandPanel('Auslastung / Gewerk', wandAuslastungInhalt(w)) - + wandPanel('Besprochen / Gewerk', wandBesprochenInhalt(w)) - + wandPanel('Aktuell', wandAktuellInhalt(w)) - + '
    ' + // Entwurf "Wandtafel in Glas" (Eriks Wahl): Kopf → vier Kennzahlen → + // links die Gewerke (Auslastung + Besprochen je Zeile), rechts die + // nächsten fünf Arbeitstage → Hinweis auf die Projektliste darunter. + container.innerHTML = '
    ' + + wtKopfHtml(w) + + wtKpiHtml(w) + + '
    ' + wtGewerkeHtml(w) + wtTageHtml(w) + '
    ' + + wtFussHtml(w) + '
    ' + wandProjektlisteHtml(w); container.scrollTop = vorherigerScroll; + wtSpaltenEinpassen(); } // wandGesamtKennzahlen: zwei über alle Gewerke hinweg zusammengefasste @@ -342,61 +364,6 @@ function wandGesamtKennzahlen(w){ }; } -// wandDashKopfHtml: Kopfzeile der Normalansicht — Marke "Dashboard" links, -// rechts wer heute abwesend ist (antippbar, öffnet dieselbe Abwesenheiten- -// Übersicht wie zuvor die Heute-Kachel) und Sitzung/Uhrzeit als laufendes -// Lebenszeichen (ersetzt das frühere separate "Stand: HH:MM Uhr" am Fuß der -// rechten Spalte — ein Uhrzeit-Hinweis genügt, jetzt prominent oben statt -// versteckt unten). Parameter: w. Rückgabe: HTML-String. -function wandDashKopfHtml(w){ - const d = wandHeuteDaten(w); - const uhrzeit = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); - const abwesendHtml = d.abwesend.length - ? '
    ' - + d.abwesend.map(p => '' + esc(initialsFor(p.name)) + '').join('') - + '
    ' - : ''; - return '
    Dashboard' - + '
    ' + abwesendHtml - + '' + esc(w.label) + ' — ' + esc(uhrzeit) + ' Uhr' - + '
    '; -} - -// wandKpiReiheHtml: die vier Kennzahlen-Kacheln oben — Auslastung gesamt, -// Besprochen gesamt (wandGesamtKennzahlen()), offene Aufgaben und -// überfällig (wandHeuteDaten(), dieselben Zahlen, die zuvor die Heute-Kachel -// zeigte). Die beiden letzten bleiben wie zuvor antippbar und öffnen -// dieselbe Übersicht. Parameter: w. Rückgabe: HTML-String. -function wandKpiReiheHtml(w){ - const g = wandGesamtKennzahlen(w); - const heute = wandHeuteDaten(w); - - const auslastungTxt = g.auslastung === null ? '—' : Math.round(g.auslastung * 100) + '%'; - const auslastungKlasse = g.auslastung === null ? '' - : (g.auslastung >= schwelleRot() ? 'crit' : g.auslastung >= schwelleGelb() ? 'warn' : 'ok'); - const besprochenTxt = g.besprochen === null ? '—' : Math.round(g.besprochen * 100) + '%'; - const ueberfaelligKlasse = heute.ueberfaelligeListe.length > 0 ? 'crit' : 'ok'; - - return '
    ' - + '
    Auslastung ges.
    ' - + '
    ' + auslastungTxt + '
    ' - + '
    Besprochen
    ' - + '
    ' + besprochenTxt + '
    ' - + '
    ' - + '
    Offene Aufgaben
    ' + heute.offen + '
    ' - + '
    ' - + '
    Überfällig
    ' + heute.ueberfaelligeListe.length + '
    ' - + '
    '; -} - -// wandPanel: die gemeinsame, nicht-interaktive Panel-Umrahmung der -// Drei-Panel-Reihe (Auslastung/Besprochen/Aktuell). Parameter: titel, -// inhalt (HTML). Rückgabe: HTML-String. -function wandPanel(titel, inhalt){ - return '
    ' + esc(titel) + '
    ' - + '
    ' + inhalt + '
    '; -} - // 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, @@ -552,16 +519,10 @@ function wandAuslastungJeGewerk(w){ 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; - }); + // Stunden dieser Woche allein aus Aufgaben mit Aufwand (load.js). + const av = aufgabenVerlaufJePerson(pr); + if(offeneAufwandsAufgaben(pr).length) gewerke.forEach(g => { ergebnis[g].bewertbar++; }); + const projektStunden = Object.values(av).reduce((s, v) => s + (v[0] || 0), 0); 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 }); @@ -571,35 +532,6 @@ function wandAuslastungJeGewerk(w){ return ergebnis; } -// wandAuslastungInhalt: ein Meter je Gewerk, Ampelfarbe wie im -// Auslastungs-Tab — der Inhalt des Panels "Auslastung / Gewerk" -// (wandPanel() rahmt ihn). 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 wandAuslastungInhalt(w){ - const daten = wandAuslastungJeGewerk(w); - return 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 '
    ' - + '' + esc(label) + '' - + '
    keine Daten
    ' - + '—
    '; - } - const quote = d.stunden / d.kapazitaet; - const ampel = loadAmpelColor(quote); - const breite = Math.min(100, quote * 100); - return '
    ' - + '' + esc(label) + '' - + '
    ' - + '' + stundenFormat(d.stunden) + 'h
    '; - }).join(''); -} - // 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 @@ -631,7 +563,8 @@ function wandBesprochenDaten(w){ 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); + // Je Gewerk sein eigener Haken (kommentarVon(), js/utils.js). + const offeneProjekte = relevante.filter(p => !kommentarVon(p, g).besprochen); ergebnis[g] = { besprochen: relevante.length - offeneProjekte.length, gesamt: relevante.length, @@ -641,31 +574,6 @@ function wandBesprochenDaten(w){ return ergebnis; } -// wandBesprochenInhalt: ein Meter je Gewerk ("X von Y") — der Inhalt des -// Panels "Besprochen / Gewerk" (wandPanel() rahmt ihn). Jede Zeile ist -// einzeln antippbar und öffnet die Liste der noch offenen Projekte dieses -// Gewerks (wandZeigeUebersicht('besprochen', g)). Die Füllfarbe nutzt das -// dunklere Logo-Blau (--dash-ink-soft) statt des früheren generischen -// App-Akzents, damit sie zur Wandtafel-eigenen Farbwelt passt (styles.css). -// Parameter: w. Rückgabe: HTML-String. -function wandBesprochenInhalt(w){ - const daten = wandBesprochenDaten(w); - return WAND_GEWERKE.map(g => { - const d = daten[g]; - const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g; - if(d.gesamt === 0){ - return '
    ' + esc(label) + '' - + 'keine Projekte
    '; - } - const quote = d.besprochen / d.gesamt; - const klick = ' onclick="wandZeigeUebersicht(\'besprochen\',\'' + g + '\')"'; - return '
    ' - + '' + esc(label) + '' - + '
    ' - + '' + d.besprochen + ' von ' + d.gesamt + '
    '; - }).join(''); -} - // 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 @@ -780,87 +688,204 @@ function wandUebersichtAbwesend(w){ // ---------- Ereignis-Feed (Panel "Aktuell") ---------- -// wandAktuelleEreignisse: sammelt drei Ereignisarten aus allen Projekten der -// Sitzung — neue/aktualisierte Kommentare (classifyProjectChange()), frisch -// überfällige Aufgaben und anstehende Meilensteine — als EINE, nach -// zeitlicher Nähe zu heute sortierte Liste (|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). wandAktuellInhalt() zeigt daraus die ersten WAND_FEED_MAX. -// 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){ +// =========================================================================== +// Normalansicht "Wandtafel in Glas" (Eriks Wahl aus dem Entwurf): gleicher +// Hintergrund wie die App (Lichtflächen + Logo, styles.css, Block +// "WANDTAFEL IN GLAS"), Schrift Outfit wie die Kalenderwoche der Login-Seite. +// Jedes Element ist für sich antippbar und führt in dieselben Übersichten +// bzw. Projekte wie zuvor. +// =========================================================================== + +// wtKopfHtml: Logo + "Dashboard", Sitzung, heute Abwesende (antippbar → +// Übersicht "abwesend"), große Uhr mit Datum (#wtUhr, sekündlich von +// wandCountdownTick() nachgeführt). Parameter: w. Rückgabe: HTML. +function wtKopfHtml(w){ + const d = wandHeuteDaten(w); + const jetzt = new Date(); + const abw = d.abwesend.length + ? '
    Heute abwesend' + + d.abwesend.slice(0, 6).map(p => '' + esc(initialsFor(p.name)) + '').join('') + + (d.abwesend.length > 6 ? '+' + (d.abwesend.length - 6) + '' : '') + '
    ' + : '
    Heute alle da
    '; + return '
    ' + + '
    Dashboard
    ' + + '' + esc(w.label) + '' + + abw + + '
    ' + esc(jetzt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })) + '' + + '' + esc(jetzt.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })) + '
    ' + + '
    '; +} + +// wtAmpel: Farbe zur Auslastungsquote — dieselben Schwellen wie überall +// (schwelleGelb()/schwelleRot(), js/load/load.js). +function wtAmpel(quote){ + // Eine Null bekommt KEINE Ampelfarbe (dieselbe Regel wie im Auslastungs- + // Tab): Grün hieße "gut ausgelastet", die Null heißt "nichts erfasst". + if(quote === null || quote === undefined || quote <= 0) return 'var(--wt-tinte-3)'; + return quote >= schwelleRot() ? 'var(--wt-rot)' : quote >= schwelleGelb() ? 'var(--wt-gelb)' : 'var(--wt-gruen)'; +} + +// wtKpiHtml: Auslastung gesamt und Besprochen mit Ring, offene Aufgaben +// und Überfälliges (beide antippbar wie bisher). Parameter: w. Rückgabe: HTML. +function wtKpiHtml(w){ + const g = wandGesamtKennzahlen(w); + const heute = wandHeuteDaten(w); + const bespr = wandBesprochenDaten(w); + let ja = 0, n = 0; + WAND_GEWERKE.forEach(x => { ja += bespr[x].besprochen; n += bespr[x].gesamt; }); + const ring = (pct, farbe) => ''; + const ausl = g.auslastung === null ? '—' : Math.round(g.auslastung * 100) + '%'; + const ueber = heute.ueberfaelligeListe.length; + return '
    ' + + '
    ' + ring(g.auslastung === null ? 0 : g.auslastung * 100, wtAmpel(g.auslastung)) + + '
    ' + ausl + '
    Auslastungalle Gewerke
    ' + + '
    ' + ring(n ? ja / n * 100 : 0, 'var(--wt-gruen)') + + '
    ' + ja + '/' + n + '
    Besprochendiese Woche
    ' + + '
    ' + + '
    ' + heute.offen + '
    Offene Aufgabenin allen Projekten
    ' + + '
    ' + + '
    ' + ueber + '
    Überfällig' + (ueber ? 'Frist gerissen' : 'nichts gerissen') + '
    ' + + '
    '; +} + +// wtGewerkeHtml: je Gewerk eine Zeile mit Auslastung (Balken + Prozent, +// antippbar → Übersicht "auslastung") und Besprochen-Stand (ein Kästchen je +// Projekt, antippbar → Übersicht "besprochen"). Parameter: w. Rückgabe: HTML. +function wtGewerkeHtml(w){ + const ausl = wandAuslastungJeGewerk(w); + const bespr = wandBesprochenDaten(w); + const personen = {}; + (Array.isArray(w.people) ? w.people : []).forEach(p => { + const g = typeof personGewerk === 'function' ? personGewerk(p) : ''; + if(g) personen[g] = (personen[g] || 0) + 1; + }); + const zeilen = WAND_GEWERKE.map(g => { + const a = ausl[g], b = bespr[g]; + const quote = (a.bewertbar > 0 && a.kapazitaet > 0) ? a.stunden / a.kapazitaet : null; + const farbe = wtAmpel(quote); + const auslWert = quote === null ? '—' : Math.round(quote * 100) + ' %'; + const kaestchen = b.gesamt + ? Array.from({ length: b.gesamt }, (_, i) => '').join('') + : 'keine Projekte'; + return '
    ' + + '' + esc(g) + '' + (personen[g] || 0) + ' Personen' + + '
    ' + + '
    ' + + 'Auslastung' + + '' + + '' + auslWert + '
    ' + + '
    ' + + 'Besprochen' + + '' + kaestchen + '' + + '' + (b.gesamt ? b.besprochen + '/' + b.gesamt : '—') + '
    ' + + '
    '; + }).join(''); + return '

    Gewerke Auslastung und Wochenbericht

    ' + zeilen + '
    '; +} + +// WT_TAGE / WT_JE_TAG: wie viele Arbeitstage die Tagesansicht zeigt und wie +// viele Einträge je Tag offen dastehen (der Rest als "+ n weitere"). +const WT_TAGE = 5; +const WT_JE_TAG = 8; + +// wtTageDaten: die nächsten WT_TAGE Arbeitstage ab heute (Wochenenden und +// gepflegte Feiertage übersprungen, isWorkday()) mit den Meilensteinen aller +// Projekte der Sitzung. +// Parameter: w. Rückgabe: Array von { iso, datum: Date, heute, eintraege }. +function wtTageDaten(w){ const heute = heuteIso(); - const prevWeek = (typeof findPreviousWeek === 'function') ? findPreviousWeek(w) : null; - const ereignisse = []; - + const tage = []; + let d = parseDateISO(heute); + if(!isWorkday(d)) { while(!isWorkday(d)) d = addDays(d, 1); } + while(tage.length < WT_TAGE){ + if(isWorkday(d)) tage.push({ iso: formatDateISO(d), datum: new Date(d), heute: formatDateISO(d) === heute, eintraege: [] }); + d = addDays(d, 1); + } + const jeTag = {}; tage.forEach(t => { jeTag[t.iso] = t; }); + const erster = tage[0]; + // Termine an Wochenenden und Feiertagen zwischen den Spalten gehören zum + // Arbeitstag DAVOR (ein Termin am Samstag ist praktisch einer am Freitag). + // Ohne diese Zuordnung fiel ein solcher Eintrag aus der Tafel heraus, weil + // es für sein Datum keine Spalte gibt. Der Eintrag nennt dann das + // eigentliche Datum. + const spalteFuer = {}; + const WT = ['So','Mo','Di','Mi','Do','Fr','Sa']; + for(let x = parseDateISO(erster.iso), ende = parseDateISO(tage[tage.length - 1].iso); x <= ende; x = addDays(x, 1)){ + const iso = formatDateISO(x); + spalteFuer[iso] = jeTag[iso] || tage.filter(t => t.iso < iso).pop() || erster; + } + const hinweisFuer = iso => jeTag[iso] ? '' : 'am ' + WT[parseDateISO(iso).getDay()] + ' ' + parseDateISO(iso).getDate() + '.' + (parseDateISO(iso).getMonth() + 1) + '. · '; (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') + '"', - }); + const projekt = (pr.nr ? pr.nr + ' ' : '') + (pr.name || ''); + const plan = pr.plan || {}; + // Eriks Wunsch: In den Tagesspalten stehen NUR Meilensteine. Aufgaben + // (offen/überfällig) sind über die Kennzahlen oben erreichbar, die heute + // Abwesenden über die Kopfleiste. + (plan.milestones || []).forEach(m => { + if(m.date && m.date >= heute && spalteFuer[m.date]) spalteFuer[m.date].eintraege.push({ art: 'ms', titel: m.name || 'Meilenstein', projekt: hinweisFuer(m.date) + projekt, pid: pr.id, abschnitt: 'terminplan', sort: m.date }); }); }); - - ereignisse.forEach(e => { e.zeit = relativerTag(e.datum); }); - ereignisse.sort((a, b) => Math.abs(tageBis(a.datum)) - Math.abs(tageBis(b.datum))); - return ereignisse; + tage.forEach(t => t.eintraege.sort((a, b) => String(a.sort).localeCompare(String(b.sort)))); + return tage; } -// wandAktuellInhalt: der Inhalt des Panels "Aktuell" (wandPanel() rahmt -// ihn) — eine flache, chronologisch sortierte Liste (wandAktuelleEreignisse() -// ist bereits nach zeitlicher Nähe zu heute sortiert), gedeckelt auf -// WAND_FEED_MAX Einträge. Ein farbiger Punkt zeigt die Art -// (Neu/Überfällig/Termin). Jeder Eintrag bleibt für sich antippbar und -// springt direkt ins zugehörige Projekt. Parameter: w. Rückgabe: -// HTML-String. -function wandAktuellInhalt(w){ - const ereignisse = wandAktuelleEreignisse(w).slice(0, WAND_FEED_MAX); - if(ereignisse.length === 0){ - return '
    Nichts Neues diese Woche.
    '; - } - return ereignisse.map(e => - '
    ' - + '' - + '
    ' + e.projekt + ' — ' + e.beschreibung - + '' + esc(e.zeit) + '
    ' - + '
    ' - ).join(''); +// wtTageHtml: die Spalten der nächsten Tage, nur mit Meilensteinen (Eriks +// Wunsch). Ein Eintrag springt in den Terminplan seines Projekts. +// Parameter: w. Rückgabe: HTML. +function wtTageHtml(w){ + const tage = wtTageDaten(w); + const WT = ['So','Mo','Di','Mi','Do','Fr','Sa']; + const gruppe = {}; + const spalten = tage.map(t => { + const sichtbar = t.eintraege.slice(0, WT_JE_TAG); + const rest = t.eintraege.length - sichtbar.length; + let letzteGruppe = null; + const eintraege = sichtbar.map(e => { + let kopf = ''; + if(t.heute && gruppe[e.art] && e.art !== letzteGruppe){ kopf = '
    ' + gruppe[e.art] + '
    '; letzteGruppe = e.art; } + const klick = e.pid ? ' onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + e.abschnitt + '\')"' : ''; + return kopf + '
    ' + esc(e.titel) + '' + esc(e.projekt) + '
    '; + }).join(''); + // "+ n weitere" steht fest am unteren Rand; wtSpaltenEinpassen() zählt + // nach dem Zeichnen noch die Einträge hinzu, die nicht in die Höhe passen. + const mehr = '
    0 ? '' : ' hidden') + '>+ ' + rest + ' weitere
    '; + const titel = t.heute ? 'Heute' : WT[t.datum.getDay()]; + const datum = (t.heute ? WT[t.datum.getDay()] + ' ' : '') + t.datum.getDate() + '.' + (t.datum.getMonth() + 1) + '.'; + return '
    ' + titel + '' + datum + '
    ' + + '
    ' + (eintraege || '
    Kein Meilenstein.
    ') + '
    ' + mehr + '
    '; + }).join(''); + return '

    Die nächsten Tage Meilensteine aller Projekte

    ' + + '
    ' + spalten + '
    ' + + '
    '; } + +// wtSpaltenEinpassen: blendet in jeder Tagesspalte die Einträge aus, die +// nicht mehr in die Höhe passen (die Spaltenhöhe hängt vom Bildschirm ab, +// zweizeilige Titel brauchen mehr Platz), und zählt sie zu "+ n weitere". +// Läuft nach jedem Zeichnen der Normalansicht. Keine Parameter. +function wtSpaltenEinpassen(){ + document.querySelectorAll('#wandContent .wt-tag').forEach(tag => { + const liste = tag.querySelector('.wt-tag-liste'); + const mehr = tag.querySelector('.wt-mehr'); + if(!liste || !mehr) return; + let versteckt = 0; + const eintraege = [...liste.querySelectorAll('.wt-ein')]; + while(liste.scrollHeight > liste.clientHeight + 1 && eintraege.length){ + const e = eintraege.pop(); + e.hidden = true; versteckt++; + // Eine Gruppenüberschrift ohne sichtbaren Eintrag danach mit ausblenden. + const vor = e.previousElementSibling; + if(vor && vor.classList.contains('wt-gruppe') && (!vor.nextElementSibling || vor.nextElementSibling.hidden)) vor.hidden = true; + } + const rest = (Number(mehr.dataset.rest) || 0) + versteckt; + mehr.textContent = '+ ' + rest + ' weitere'; + mehr.hidden = rest === 0; + }); +} + +// wtFussHtml: Hinweis auf die Projektliste darunter (zum Wischen). +function wtFussHtml(w){ + const n = (w.projects || []).length; + return '
    ↓Wischen für alle ' + n + ' Projekte
    '; +} + diff --git a/public/js/wochenbericht/wochenbericht.js b/public/js/wochenbericht/wochenbericht.js index 2c149de..319cf26 100644 --- a/public/js/wochenbericht/wochenbericht.js +++ b/public/js/wochenbericht/wochenbericht.js @@ -56,7 +56,11 @@ function lphSignature(p){ // ihr (z. B. gerade erst angelegtes Projekt) gilt es als neu. Parameter: p // (Projekt der aktuellen Sitzung), prevWeek (Vorwoche-Objekt oder null). // Rückgabe: { bucket: 'new'|'status'|'progress'|'unchanged', reason: String }. -function classifyProjectChange(p, prevWeek){ +function classifyProjectChange(p, prevWeek, gewerk){ + // Kommentare werden je Gewerk verglichen (kommentarVon(), js/utils.js) — + // ein neuer ELT-Kommentar ist für die HLS-Besprechung keine Neuigkeit. + // Ohne ausdrückliches Gewerk gilt das des Zusammenhangs. + const g = gewerk === undefined ? kommentarGewerk() : gewerk; const prev = (prevWeek && p.projectUid) ? prevWeek.projects.find(x => x.projectUid === p.projectUid) : null; @@ -64,8 +68,10 @@ function classifyProjectChange(p, prevWeek){ if(!prev){ return { bucket: 'new', reason: 'Neues Projekt' }; } - const statusNow = (p.status || '').trim(); - const statusPrev = (prev.status || '').trim(); + const kNow = kommentarVon(p, g); + const kPrev = kommentarVon(prev, g); + const statusNow = (kNow.status || '').trim(); + const statusPrev = (kPrev.status || '').trim(); // Nur ein tatsächlich vorhandener, geänderter Text zählt als neuer // Kommentar. Seit cloneWeek() (js/weeks.js) den Text der Vorwoche nicht // mehr mitkopiert, startet jede duplizierte Sitzung mit leerem Kommentar — @@ -81,7 +87,7 @@ function classifyProjectChange(p, prevWeek){ // wortgleich den Text der Vorwoche, setzt dabei aber einen neuen // Zeitstempel. Ohne diese Prüfung landete das Projekt unter "Unverändert", // obwohl diese Woche ausdrücklich etwas dazu festgehalten wurde. - if(statusNow !== '' && p.statusAt && p.statusAt !== (prev.statusAt || '')){ + if(statusNow !== '' && kNow.statusAt && kNow.statusAt !== (kPrev.statusAt || '')){ return { bucket: 'status', reason: 'Kommentar bestätigt' }; } if(lphSignature(p) !== lphSignature(prev)){ @@ -166,9 +172,12 @@ function renderAttendance(){ const w = currentWeek(); if(!w){ container.innerHTML = ''; return; } - const locked = isWeekLocked(w); + // Ohne Recht „Anwesenheit erfassen“ (Rollen & Rechte) nur Anzeige. + const locked = isWeekLocked(w) || !hatRecht('wb.anwesenheit'); const att = w.attendance || {}; - const people = Array.isArray(w.people) ? w.people : []; + // Nur die Personen des gewählten Gewerks — jedes Gewerk erfasst die + // Anwesenheit seiner eigenen Besprechung (personenDesGewerks(), js/utils.js). + const people = personenDesGewerks(w, wochenberichtGewerk); // Fokus-Modus (Eriks Wahl "C"): die Anwesenheit ist ein aufklappbarer // Abschnitt wie Leistungsphasen/Beteiligte, kein dauerhaft sichtbarer Block // mehr — Zustand in berichtDisclosureState (js/board/board.js). @@ -177,7 +186,7 @@ function renderAttendance(){ if(people.length === 0){ container.innerHTML = `
    Anwesenheit${icon('chevronRight')} -
    Für diese Sitzung sind keine Projektbeteiligten hinterlegt.
    +
    Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.
    `; return; } @@ -213,7 +222,7 @@ function renderAttendance(){ }).join(''); container.innerHTML = `
    - Anwesenheit der Sitzung${anwesend} von ${people.length}${icon('chevronRight')} + Anwesenheit ${esc(wochenberichtGewerk || "")}${anwesend} von ${people.length}${icon('chevronRight')}
    ${actions}
    ${tiles}
    `; } @@ -243,7 +252,7 @@ function setAllAttendance(present){ const w = currentWeek(); if(!w) return; if(!w.attendance) w.attendance = {}; - (w.people || []).forEach(p => { w.attendance[p.id] = !!present; }); + personenDesGewerks(w, wochenberichtGewerk).forEach(p => { w.attendance[p.id] = !!present; }); save(); renderAttendance(); } @@ -352,8 +361,7 @@ function aktuellesBerichtProjekt(w){ // Parameter: uid (project_uid). Keine Rückgabe. function waehleBerichtProjekt(uid){ berichtAuswahl = uid; - vorwocheRadIndex = 0; // neues Projekt beginnt wieder bei der jüngsten Vorwoche - vorwocheRadScrollAkku = 0; // kein halb aufgelaufenes Scrollen vom vorherigen Projekt übernehmen + berichtVerlaufAlle = false; // neues Projekt zeigt wieder nur die jüngsten Einträge renderWochenbericht(); const detail = document.getElementById('berichtDetail'); if(detail) detail.scrollIntoView({ block: 'nearest' }); @@ -374,23 +382,39 @@ function waehleBerichtProjekt(uid){ // das noch offen ist. Der Motor des Meetings — ohne ihn sucht man nach jedem // Projekt von Hand die nächste Zeile. Parameter: pid. Keine Rückgabe. function besprochenUndWeiter(pid){ - if(guardWeekLocked()) return; + if(guardWeekLocked() || !hatRecht('wb.besprochen')) return; const w = currentWeek(); const p = (w.projects || []).find(x => x.id === pid); if(!p) return; - p.besprochen = true; + kommentarSetzen(p, wochenberichtGewerk, { besprochen: true }); save(); const liste = berichtProjekte(w); const idx = liste.findIndex(x => x.id === pid); // Erst hinter der aktuellen Stelle suchen, dann von vorn — so landet man // auch dann beim nächsten offenen Punkt, wenn man mittendrin eingestiegen ist. - const offen = liste.slice(idx + 1).find(x => !x.besprochen) - || liste.find(x => !x.besprochen); + const istAb = x => kommentarVon(x, wochenberichtGewerk).besprochen; + const offen = liste.slice(idx + 1).find(x => !istAb(x)) + || liste.find(x => !istAb(x)); berichtAuswahl = offen ? offen.projectUid : p.projectUid; renderWochenbericht(); } +// berichtNaechstes: das Projekt direkt nach pid in der sichtbaren Liste +// (dieselbe Reihenfolge und Suche wie die Pfeiltaste ↓), sonst null. +function berichtNaechstes(w, pid){ + const liste = berichtProjekte(w).filter(passtZurSuche); + const idx = liste.findIndex(x => x.id === pid); + return idx >= 0 ? (liste[idx + 1] || null) : null; +} + +// berichtWeiter: springt zum nächsten Projekt, OHNE es abzuhaken — für ein +// Projekt, das heute nicht besprochen wird. Parameter: pid. Keine Rückgabe. +function berichtWeiter(pid){ + const naechstes = berichtNaechstes(currentWeek(), pid); + if(naechstes) waehleBerichtProjekt(naechstes.projectUid || null); +} + // setzeAlleBesprochen: setzt alle Haken der Sitzung zurück bzw. hakt alles ab. // Parameter: wert (Boolean). Keine Rückgabe. function setzeAlleBesprochen(wert){ @@ -402,7 +426,9 @@ function setzeAlleBesprochen(wert){ // ANDEREN Gewerke als besprochen, die in dieser Besprechung nie zu sehen // waren (widerspricht "Wochenbericht — Gewerke-Trennung": Vollständigkeit // gilt innerhalb des gewählten Gewerks, nicht über alle hinweg). - berichtProjekte(w).forEach(p => { p.besprochen = !!wert; }); + // Nur der Haken DIESES Gewerks — die anderen Gewerke besprechen das + // Projekt in ihrer eigenen Sitzung. + berichtProjekte(w).forEach(p => kommentarSetzen(p, wochenberichtGewerk, { besprochen: !!wert })); save(); renderWochenbericht(); } @@ -410,20 +436,34 @@ function setzeAlleBesprochen(wert){ // ---------- Abwesenheit ---------- // abwesendeBeteiligte: die einem Projekt zugeordneten Personen, die heute -// fehlen. "Heute" ist der Anwesenheitsstand dieser Sitzung (w.attendance) — -// er wird am Fuß des Berichts ohnehin erfasst. Punkte, deren Zuständige -// fehlen, vertagt man im Meeting besser. -// Parameter: p (Projekt), w (Woche). Rückgabe: Array von Personennamen. +// laut ihrer EINGETRAGENEN Abwesenheit fehlen (Urlaub, Krank, Fortbildung … +// aus der Benutzerverwaltung, db.globalPeople[].absences) — Eriks Wunsch. +// Früher wurde stattdessen die Anwesenheitsliste am Fuß des Berichts +// ausgewertet: Wer dort nicht abgehakt war, "fehlte" — auch wenn er nur +// nicht in dieser Besprechung saß, und umgekehrt fehlte ein Urlauber nicht, +// solange niemand die Anwesenheit erfasst hatte. Punkte, deren Zuständige +// abwesend sind, vertagt man im Meeting besser. +// Zugeordnet = in irgendeiner Rolle (Haupt oder Unterstützung) im Projekt. +// Die Person der Sitzung wird über ihre Kennung, ersatzweise den vollen +// Namen, ihrem Konto zugeordnet (wie personGewerk(), js/utils.js). +// Parameter: p (Projekt), w (Woche). Rückgabe: Array von Texten +// "Name (Urlaub bis 28.10.)". function abwesendeBeteiligte(p, w){ - const att = w.attendance || {}; - // Solange niemand als anwesend markiert ist, ist die Anwesenheit schlicht - // noch nicht erfasst — dann wäre "alle fehlen" eine Falschmeldung. - if(!Object.values(att).some(v => v)) return []; + const heute = heuteIso(); + const konten = Array.isArray(db.globalPeople) ? db.globalPeople : []; const namen = []; (w.people || []).forEach(person => { const rollen = Array.isArray(person.role) ? person.role : [person.role]; const zugeordnet = rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0); - if(zugeordnet && !att[person.id] && namen.indexOf(person.name) === -1) namen.push(person.name); + if(!zugeordnet) return; + const konto = konten.find(x => x.id === person.id) + || (person.fullName ? konten.find(x => x.fullName === person.fullName) : null); + const a = konto && (konto.absences || []).find(x => x.start && x.end && x.start <= heute && heute <= x.end); + if(!a) return; + const name = person.fullName || person.name; + const bis = a.end > heute ? ' bis ' + formatWeekDate(a.end).replace(/\d{4}$/, '') : ''; + const text = name + ' (' + (a.type || 'abwesend') + bis + ')'; + if(!namen.some(n => n.startsWith(name + ' ('))) namen.push(text); }); return namen; } @@ -487,13 +527,13 @@ function renderWochenbericht(){ if(locked){ html += `
    Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich. - + ${hatRecht('s.entsperren') ? '' : ''}
    `; } if(liste.length === 0){ html += renderBerichtKopf(w, liste, prevWeek, locked); - html += `
    In dieser Sitzung ist noch kein Projekt angelegt.
    `; + html += `
    Für ${esc(wochenberichtGewerk)} steht in dieser Sitzung kein Projekt auf der Tagesordnung.
    `; container.innerHTML = html; renderAttendance(); return; @@ -560,7 +600,7 @@ function renderBerichtKopfNeu(){ // renderBerichtKopf: Gewerke-Wahl, Fortschrittsring und das Sprungfeld. // Parameter: w, liste, prevWeek, locked. Rückgabe: HTML. function renderBerichtKopf(w, liste, prevWeek, locked){ - const ab = liste.filter(p => p.besprochen).length; + const ab = liste.filter(p => kommentarVon(p, wochenberichtGewerk).besprochen).length; const pct = liste.length ? Math.round(ab / liste.length * 100) : 0; // Der Vergleichs-Hinweis ("Vergleich mit KW …") steht nicht mehr als eigene // Zeile da, sondern als Titel-Tooltip am Fortschritt — dieselbe Angabe, @@ -569,7 +609,7 @@ function renderBerichtKopf(w, liste, prevWeek, locked){ ? `Vergleich mit ${esc(prevWeek.label)}${prevWeek.date ? ' (' + esc(formatWeekDate(prevWeek.date)) + ')' : ''}.` : 'Dies ist die früheste vorhandene Sitzung — kein Vergleich zur Vorwoche möglich.'; - const resetBtn = locked ? '' : (ab > 0 + const resetBtn = (locked || !hatRecht('wb.besprochen')) ? '' : (ab > 0 ? `` : ``); @@ -627,7 +667,7 @@ function renderBerichtListe(){ const gruppenkopf = gruppe !== gruppeVorher ? `
    ${esc(gruppe)}
    ` : ''; gruppeVorher = gruppe; const name = (p.nr ? p.nr + ' · ' : '') + p.name; - return `${gruppenkopf}
    ${String(position).padStart(2, '0')} @@ -651,6 +691,9 @@ function renderBerichtListe(){ // "Besprochen & weiter". Parameter: p, w, prevWeek, locked. Rückgabe: HTML. function renderBerichtDetail(p, w, prevWeek, locked){ if(!p) return ''; + // Kommentar und Fortschritt nur mit Recht „Kommentar und Fortschritt + // bearbeiten“, sonst wie gesperrt als Anzeige (Rollen & Rechte). + const kLocked = locked || !hatRecht('wb.kommentar'); const grouped = peopleByRole(w); const allPeople = []; ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry))); @@ -669,89 +712,179 @@ function renderBerichtDetail(p, w, prevWeek, locked){

    ${esc(p.name)}

    ${subText}
    - ${fehlen.length ? `Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.` : ''} - ${renderVorwocheRad(p, w)} - ${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)} - ${locked ? '' : `
    - + ` : ''} - -
    `}`; + + ${!locked && hatRecht('wb.besprochen') ? `` : ''} +
    + ${renderBerichtVerlauf(p, w, kLocked)}`; } -// vorwocheRadIndex: Position im Kommentar-Verlauf des GERADE GEÖFFNETEN -// Projekts (0 = jüngste Vorwoche, höher = weiter zurück). Ein einzelner -// modulweiter Stand genügt, da immer nur ein Projekt im Fokus steht; -// waehleBerichtProjekt() setzt ihn beim Projektwechsel zurück. -let vorwocheRadIndex = 0; +// ---------- Verlauf der Vorwochen (Eriks Wahl "B") ---------- +// +// Löst das Scrollrad über dem Kommentarfeld (renderVorwocheRad()) und den +// aufklappbaren Kachel-Streifen (renderProjektVerlauf(), bleibt für +// Projektliste/Projektbereich) im Wochenbericht ab. Darstellung wie im +// Projekt-Dashboard (renderPlanKommentarVerlauf(), plan-info.js): eine +// senkrechte Zeitleiste, hier als eigener Abschnitt über die volle Breite +// unter der Bearbeitungsmaske. Über dem Kommentarfeld bleibt nur die schmale +// Zeile "Zuletzt KW …" (renderBerichtZuletzt()). +// +// Ein Eintrag entsteht nur, wenn sich der Kommentar DES GEWÄHLTEN GEWERKS +// gegenüber der Sitzung davor geändert hat; Sitzungen dazwischen ohne neuen +// Text stehen als grauer Vermerk am Eintrag. Geänderte Leistungsphasen seit +// dem vorigen Eintrag erscheinen als Chips ("LPH 5: 60 → 75 %"), statt in +// jedem Eintrag alle Werte zu wiederholen. Keine neue Datenhaltung — jede +// Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()). -// renderVorwocheRad: löst renderVorwochenzeile() ab, die nur die -// UNMITTELBARE Vorwoche zeigte. Eriks Rückmeldung zum Entwurfsvergleich: mit -// dem Mausrad (oder den beiden Pfeilen) durch MEHRERE vergangene Kommentare -// blättern können, nicht nur eine Vorwoche sehen. Nutzt -// projectStatusHistory() (js/utils.js) — denselben, bereits für den -// Verlaufs-Dialog geprüften Verlauf (nur Sitzungen mit tatsächlich -// geändertem Text, neueste zuerst), abzüglich der aktuellen Sitzung selbst -// (die steht ja bereits im Eingabefeld darunter). Parameter: p, w (aktuelle -// Sitzung). Rückgabe: HTML (leer ohne Verlauf). -function renderVorwocheRad(p, w){ +const BERICHT_VERLAUF_KURZ = 3; +// berichtVerlaufAlle: "Alle N anzeigen" aufgeklappt; waehleBerichtProjekt() +// setzt es beim Projektwechsel zurück. +let berichtVerlaufAlle = false; + +// berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende) +function berichtKurzKw(label){ + const m = /^(KW\s*\d+)\s*\/\s*(\d{4})$/.exec(String(label || '').trim()); + if(!m) return label || ''; + return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2]; +} + +// berichtVerlaufDaten: die früheren Kommentare eines Projekts für ein Gewerk. +// Parameter: p (Projekt der Sitzung w), w (aktuelle Sitzung), g (Gewerk oder +// null). Rückgabe: { sitzungen, eintraege: [{week, html, autor, at, ruhe, chips}] }, +// neueste zuerst. +function berichtVerlaufDaten(p, w, g){ + const bis = weekSortKey(w.label); + const versionen = projectVersionsForUid(p.projectUid) + .filter(v => weekSortKey(v.week.label).localeCompare(bis) < 0); + const roh = []; + let vorher = ''; + versionen.forEach((v, i) => { + const k = kommentarVon(v.project, g); + const text = statusPlainText(k.status); + if(text && text !== vorher){ + roh.push({ i, week: v.week, project: v.project, html: sanitizeStatusHtml(k.status || ''), + autor: k.statusAuthor || '', at: k.statusAt || '' }); + } + vorher = text; + }); + const eintraege = roh.map((e, n) => { + // Sitzungen nach diesem Eintrag bis zum nächsten (bzw. bis zur aktuellen) + const bisIdx = n + 1 < roh.length ? roh[n + 1].i : versionen.length; + const still = versionen.slice(e.i + 1, bisIdx).map(v => berichtKurzKw(v.week.label)); + const ruhe = still.length === 0 ? '' + : (still.length === 1 ? still[0] : still[0] + ' bis ' + still[still.length - 1]) + ' ohne neuen Kommentar'; + // LPH-Änderungen gegenüber dem vorigen Eintrag (beim ältesten gegenüber + // der Sitzung direkt davor, sofern es eine gibt) + const basis = n > 0 ? roh[n - 1].project : (e.i > 0 ? versionen[e.i - 1].project : null); + const chips = []; + if(basis){ + const alt = basis.lph || {}, neu = e.project.lph || {}; + LPHS.forEach(l => { + const a = alt[l] === undefined || alt[l] === '' ? '' : String(alt[l]); + const b = neu[l] === undefined || neu[l] === '' ? '' : String(neu[l]); + if(a !== b && b !== '') chips.push(l + ': ' + (a || '0') + ' → ' + b + ' %'); + }); + } + return { week: e.week, html: e.html, autor: e.autor, at: e.at, ruhe, chips }; + }).reverse(); + return { sitzungen: versionen.length, eintraege }; +} + +// berichtVerlaufDatum: ISO-Zeitstempel -> "20.10." (leer, wenn unbrauchbar) +function berichtVerlaufDatum(at){ + const d = at ? new Date(at) : null; + if(!d || isNaN(d)) return ''; + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); +} + +// renderBerichtZuletzt: NICHT MEHR AUFGERUFEN (Eriks Wunsch — der Kommentar +// der Vorwoche steht seit der Übernahme beim Sitzungswechsel ohnehin im +// Feld). Früher: die schmale Zeile über dem Kommentarfeld — der +// jüngste frühere Kommentar dieses Gewerks. Rückgabe: HTML (leer ohne Verlauf). +function renderBerichtZuletzt(p, w){ if(!p.projectUid) return ''; - const verlauf = projectStatusHistory(p.projectUid).filter(e => e.week.label !== w.label); - if(verlauf.length === 0) return ''; - const i = Math.max(0, Math.min(verlauf.length - 1, vorwocheRadIndex)); - const eintrag = verlauf[i]; - const punkte = verlauf.map((_, idx) => ``).join(''); - return `
    - -
    - ${esc(eintrag.week.label)} - ${esc(eintrag.text)} -
    - -
    ${punkte}
    + const e = berichtVerlaufDaten(p, w, wochenberichtGewerk).eintraege[0]; + if(!e) return ''; + // Mit Formatierung (fett, kursiv, Farbe, Zeilen) wie im Verlauf darunter — + // e.html ist bereits über sanitizeStatusHtml() bereinigt. + return `
    + Zuletzt ${esc(berichtKurzKw(e.week.label))} +
    ${e.html}
    `; } -// vorwocheRadSchritt: bewegt vorwocheRadIndex um delta (geklemmt auf den -// vorhandenen Verlauf) und zeichnet den Bericht neu — derselbe vollständige -// Neuaufbau, den auch editLph() auslöst; ein offener -// Kommentar-Editor übersteht das über dasselbe Fokus-Wiederherstellungsnetz -// (pendingCommentFocus, js/board/board.js), eine aufgeklappte Anwesenheit -// über berichtDisclosureState. Parameter: delta (1 = weiter zurück, -1 = -// neuer). Keine Rückgabe. -function vorwocheRadSchritt(delta){ - vorwocheRadScrollAkku = 0; // ein gezielter Klick verwirft ein halb aufgelaufenes Scrollen - const w = currentWeek(); - const p = aktuellesBerichtProjekt(w); - if(!w || !p) return; - const max = projectStatusHistory(p.projectUid).filter(e => e.week.label !== w.label).length - 1; - vorwocheRadIndex = Math.max(0, Math.min(max, vorwocheRadIndex + delta)); - renderWochenbericht(); +// renderBerichtVerlauf: der Abschnitt "Verlauf der Vorwochen" unter der +// Bearbeitungsmaske. Parameter: p, w, locked. Rückgabe: HTML. +function renderBerichtVerlauf(p, w, locked){ + if(!p || !p.projectUid) return ''; + const g = wochenberichtGewerk; + const { sitzungen, eintraege } = berichtVerlaufDaten(p, w, g); + const gText = g || 'alle Gewerke'; + let inhalt; + if(eintraege.length === 0){ + inhalt = `
    ${sitzungen === 0 + ? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.' + : 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}
    `; + } else { + const zeigen = berichtVerlaufAlle ? eintraege : eintraege.slice(0, BERICHT_VERLAUF_KURZ); + inhalt = `
      ${zeigen.map((e, i) => { + const datum = berichtVerlaufDatum(e.at); + const wer = [e.autor, datum].filter(Boolean).join(', '); + // onmousedown + preventDefault wie beim bisherigen "übernehmen": sonst + // verliert das Kommentarfeld zuerst den Fokus und speichert dazwischen. + const knopf = locked ? '' : + ``; + return `
    1. + ${esc(berichtKurzKw(e.week.label))}${wer ? `${esc(wer)}` : ''} + + + ${e.html} + ${e.ruhe ? `${esc(e.ruhe)}` : ''} + + ${e.chips.map(c => `${esc(c)}`).join('')} + ${knopf} +
    2. `; + }).join('')}
    `; + } + const mehr = eintraege.length > BERICHT_VERLAUF_KURZ + ? `` + : ''; + const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare'; + return `
    +
    +

    Verlauf der Vorwochen

    + ${esc(gText)}, ${anzahl} in ${sitzungen === 1 ? '1 Sitzung' : sitzungen + ' Sitzungen'} + + ${mehr} +
    + ${inhalt} +
    `; } -// vorwocheRadScrollAkku/-SCHWELLE: jeder einzelne wheel-Tick löste zunächst -// sofort einen Schritt aus — auf einem Trackpad erzeugt schon ein kurzes -// Antippen mehrere Ticks mit kleinem deltaY, wodurch ein einziges Scrollen -// mehrere Wochen auf einmal übersprang (Erik: "Empfindlichkeit beim Scrollen -// etwas verlangsamen"). Jetzt wird deltaY aufsummiert, bis die Schwelle -// überschritten ist — eine einzelne Mausrad-Rastung (meist ~100) überschreitet -// sie sofort und bleibt damit bei einem Schritt je Rastung, ein kurzes -// Trackpad-Antippen dagegen oft nicht. Erster Richtwert, bei Bedarf -// nachjustieren. -let vorwocheRadScrollAkku = 0; -const VORWOCHE_RAD_SCHWELLE = 50; +// berichtVerlaufUmschalten: "Alle N anzeigen" / "Weniger anzeigen". Zeichnet +// nur den Verlauf neu, damit ein angefangener Kommentar unberührt bleibt. +function berichtVerlaufUmschalten(){ + berichtVerlaufAlle = !berichtVerlaufAlle; + const w = currentWeek(); + const p = w && aktuellesBerichtProjekt(w); + const el = document.getElementById('berichtVerlauf'); + if(!p || !el) return; + el.outerHTML = renderBerichtVerlauf(p, w, isWeekLocked(w)); +} -// vorwocheRadScroll: Mausrad-/Trackpad-Bedienung desselben Scrollrads — -// runter (deltaY positiv, aufsummiert) blättert weiter zurück, hoch blättert -// zu neueren Einträgen. e.preventDefault() verhindert, dass dieselbe Bewegung -// zusätzlich die Seite scrollt. Parameter: e (WheelEvent). Keine Rückgabe. -function vorwocheRadScroll(e){ - e.preventDefault(); - vorwocheRadScrollAkku += e.deltaY; - if(Math.abs(vorwocheRadScrollAkku) < VORWOCHE_RAD_SCHWELLE) return; - vorwocheRadSchritt(vorwocheRadScrollAkku > 0 ? 1 : -1); +// berichtVerlaufUebernehmen: schreibt den Kommentar jener Sitzung in das +// Kommentarfeld der aktuellen (takeCommentFromHistory(), js/board/board.js). +function berichtVerlaufUebernehmen(weekLabel){ + const feld = document.querySelector('#berichtDetail .comment-block .comment-field'); + if(feld) takeCommentFromHistory(feld, weekLabel); } // ---------- Aufgabe aus dem Kommentar ---------- @@ -783,7 +916,7 @@ function openTodoAusKommentar(pid){ // 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 ? statusTextMitZeilen(feld.innerHTML) : statusPlainText(p.status); + const text = feld ? statusTextMitZeilen(feld.innerHTML) : statusPlainText(kommentarVon(p, wochenberichtGewerk).status); const ersteZeile = String(text || '').split('\n').map(z => z.replace(/^[-•*\s]+/, '').trim()).find(z => z) || ''; document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120); @@ -832,6 +965,13 @@ function saveTodoAusKommentar(){ return; } const ende = document.getElementById('todoKommentarEnde').value; + // Fälligkeit vor heute ergäbe einen Balken mit negativer Breite (Start ist + // immer heute) — lieber gleich ablehnen. + if(ende && ende < formatDateISO(new Date())){ + fehler.textContent = 'Das Fälligkeitsdatum liegt in der Vergangenheit.'; + fehler.hidden = false; + return; + } const personen = Array.from(document.querySelectorAll('#todoKommentarPersonen input:checked')).map(i => i.value); if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 }; @@ -870,8 +1010,9 @@ function openBerichtProtokoll(){ if(prevWeek) zeilen.push('Vergleich mit ' + prevWeek.label); zeilen.push(''); - const anwesend = (w.people || []).filter(x => (w.attendance || {})[x.id]).map(x => x.fullName || x.name); - const fehlend = (w.people || []).filter(x => !(w.attendance || {})[x.id]).map(x => x.fullName || x.name); + const leute = personenDesGewerks(w, wochenberichtGewerk); + const anwesend = leute.filter(x => (w.attendance || {})[x.id]).map(x => x.fullName || x.name); + const fehlend = leute.filter(x => !(w.attendance || {})[x.id]).map(x => x.fullName || x.name); if(anwesend.length){ zeilen.push('Anwesend: ' + anwesend.join(', ')); if(fehlend.length) zeilen.push('Entschuldigt: ' + fehlend.join(', ')); @@ -884,7 +1025,7 @@ function openBerichtProtokoll(){ if(info.bucket === 'unchanged') return; // nur, was sich geändert hat anzahl++; zeilen.push('— ' + (p.nr ? p.nr + ' ' : '') + p.name + ' [' + info.reason + ']'); - const text = statusPlainText(p.status); + const text = statusPlainText(kommentarVon(p, wochenberichtGewerk).status); if(text) zeilen.push(' ' + text); // Aufgaben, die in dieser Sitzung entstanden sind, gehören ins Protokoll: // Sie sind das Ergebnis der Besprechung. diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..2e4bb10 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/plan-styles.css b/public/plan-styles.css index a857980..1aefaed 100644 --- a/public/plan-styles.css +++ b/public/plan-styles.css @@ -133,27 +133,6 @@ .modal-head h2 { margin: 0; } .modal-kbd { font-size: 11px; color: var(--text-subtle); white-space: nowrap; } -/* Etwas breiter als die 500 px der Standard-Maske, damit die vier - Schaltflächen der Fußzeile in eine Zeile passen (gleiche Ausnahme wie - #lpModal/#newProjModal in styles.css). */ -#todoModal .modal { max-width: 560px; } - -/* Fußzeile bleibt stehen, wenn die Abhängigkeiten aufgeklappt sind */ -.modal-footer { - position: sticky; - bottom: -28px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - flex-wrap: wrap; - margin: 20px -28px -28px; - padding: 16px 28px; - border-top: 1px solid var(--line-soft); - background: var(--surface-alt); - border-radius: 0 0 var(--r-xl) var(--r-xl); -} - /* Validierungshinweis direkt am Feld statt im alert() */ /* .field-error steht in styles.css: der Passwort-Dialog (js/auth.js) nutzt sie auch ausserhalb des Projektbereichs. */ @@ -210,6 +189,9 @@ color: var(--accent); } .person-chip[aria-pressed="true"]:hover { background: var(--accent-soft); } +/* Zuständige, die dem Projekt (nicht mehr) zugeordnet sind (openTodoModal()): + gestrichelt wie die "fremden" Personen im Assistenten. */ +.person-chip.ist-fremd { border-style: dashed; } .person-chip[aria-pressed="true"] .person-chip-ini { background: var(--accent); color: #fff; } /* Hover-Fläche im Status-Segment: eigene Abstufung, weil das Segment auf @@ -1115,3 +1097,695 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); } sich jetzt mit derselben Dauer wie der Pfeil der Projektliste (.board-chev), statt mit einem eigenen Wert. */ .pt-chevron { transition: transform var(--dur) var(--ease-out); } + +/* =========================================================================== + Projekt-Dashboard "Lagebild" (Eriks Wahl aus drei Anordnungen): + Lageband oben (#planKpiRow), darunter zwei Spalten, die sich unabhängig + füllen — links Kommentar + Termine, rechts Phasen, Team, Unterlagen. + =========================================================================== */ +.plan-lage-band { + display: grid; grid-template-columns: minmax(0, 1.35fr) repeat(3, minmax(0, 1fr)); + background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--r-lg); + box-shadow: var(--sh-1); padding: 4px; +} +.plan-lage-zelle { padding: 12px 16px; min-width: 0; } +.plan-lage-zelle + .plan-lage-zelle { border-left: 1px solid var(--line-soft); } +.plan-lage-zelle .kpi-label { font-size: 11px; font-weight: 650; letter-spacing: .05em; text-transform: uppercase; color: var(--text-subtle); } +.plan-lage-zelle .kpi-value { font-size: 22px; font-weight: 600; letter-spacing: -.01em; margin-top: 2px; font-variant-numeric: tabular-nums; } +.plan-lage-zelle .kpi-sub { font-size: 12px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.plan-lage-zelle .kpi-sub.ist-rot { color: var(--red); font-weight: 600; } +.plan-lage-phase { display: flex; align-items: center; gap: 14px; } +.plan-lage-ring { + flex: none; width: 54px; height: 54px; border-radius: 50%; + background: conic-gradient(var(--accent) calc(var(--p) * 1%), color-mix(in srgb, var(--text-subtle) 18%, transparent) 0); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 7px), #000 calc(100% - 6px)); + mask: radial-gradient(farthest-side, transparent calc(100% - 7px), #000 calc(100% - 6px)); +} +.plan-lage-raster { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(0, 1fr); gap: 14px; align-items: start; margin-top: 14px; } +.plan-lage-spalte { display: flex; flex-direction: column; gap: 14px; min-width: 0; } +.plan-lage-spalte .plan-panel { box-shadow: var(--sh-1); } +@media (max-width: 1000px) { + .plan-lage-band { grid-template-columns: 1fr 1fr; } + .plan-lage-zelle + .plan-lage-zelle { border-left: 0; } + .plan-lage-raster { grid-template-columns: 1fr; } +} + +.plan-panel-title .plan-panel-link, .plan-unterlage-kopf .plan-panel-link { margin-left: auto; } +.plan-panel-link { + appearance: none; border: 0; background: none !important; box-shadow: none !important; padding: 0; + color: var(--accent); font: inherit; font-size: 12px; font-weight: 550; cursor: pointer; +} +.plan-panel-link:hover { text-decoration: underline; } +.plan-panel-link:active { transform: none; } + +.plan-kommentar-fuss { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; } +.plan-kommentar-autor { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-subtle); } +.plan-kommentar-autor:empty { display: none; } +.plan-autor-ava { width: 24px; height: 24px; border-radius: 50%; display: inline-grid; place-items: center; font-size: 9.5px; font-weight: 700; background: var(--accent-soft); color: var(--accent); } +.plan-kommentar-fuss .plan-verweis-row { margin-left: auto; } + +.plan-termin-gruppe { display: flex; gap: 6px; align-items: baseline; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-subtle); padding: 10px 0 2px; } +.plan-termin-gruppe:first-child { padding-top: 0; } +.plan-termin-gruppe span { font-weight: 500; letter-spacing: 0; } +.plan-termin-gruppe.ist-rot { color: var(--red); } +.plan-termin { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 8px 6px; margin: 0 -6px; border-top: 1px solid var(--line-soft); border-radius: 8px; } +.plan-termin-gruppe + .plan-termin { border-top: 0; } +.plan-termin.ist-klickbar { cursor: pointer; } +.plan-termin.ist-klickbar:hover { background: var(--surface-sunk); } +.plan-termin-art { width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; font-size: 10px; font-weight: 700; background: var(--surface-sunk); color: var(--text-muted); } +.plan-termin-art.ist-meins { background: var(--accent-soft); color: var(--accent); } +.plan-termin-art.ist-ms { background: color-mix(in srgb, #6d4bb3 14%, transparent); color: #6d4bb3; } +.plan-termin-text { min-width: 0; display: flex; flex-direction: column; } +.plan-termin-text b { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.plan-termin-text span { font-size: 11.5px; color: var(--text-subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.plan-termin-datum { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.plan-termin-datum em { display: block; font-style: normal; font-size: 11px; color: var(--text-subtle); } +.plan-termin-datum.ist-rot, .plan-termin-datum.ist-rot em { color: var(--red); } +.plan-termin-mehr { margin-top: 8px; } + +.plan-team-zeile { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 7px 0; border-top: 1px solid var(--line-soft); } +.plan-team-zeile:first-child { border-top: 0; padding-top: 0; } +.plan-team-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.plan-team-text b { font-weight: 550; } +.plan-team-rollen { display: flex; flex-wrap: wrap; gap: 4px; } +.plan-rolle { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); } +.plan-rolle.ist-neben { background: transparent; border: 1px dashed var(--line); color: var(--text-muted); } +.plan-team-offen { font-size: 12px; color: var(--text-muted); white-space: nowrap; } + +.plan-unterlage { padding: 9px 6px; margin: 0 -6px; border-top: 1px solid var(--line-soft); border-radius: 8px; cursor: pointer; } +.plan-unterlage:first-child { border-top: 0; padding-top: 2px; } +.plan-unterlage:hover { background: var(--surface-sunk); } +.plan-unterlage-kopf { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; } +.plan-unterlage-kopf b { font-weight: 600; } +@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .plan-termin-art.ist-ms { color: #c3a6ff; } } +html[data-theme="dark"] .plan-termin-art.ist-ms { color: #c3a6ff; } + +/* Kommentar der Sitzung im Projekt-Dashboard größer (Eriks Wunsch): Das + allgemeine .status-edit ist auf die enge Zeile des Boards ausgelegt + (11px). Hier ist er das wichtigste Lesefeld des Lagebilds. */ +#planSection-dashboard #planStatusEdit { + font-size: 14.5px; + line-height: 1.6; + padding: 14px 16px; + min-height: 160px; + border-radius: var(--r-md); + border-style: solid; +} + +/* Kommentar der Sitzung mit Verlauf (Eriks Wahl "A"): gezeigt wird der + zuletzt geschriebene Kommentar; stammt er aus einer früheren Sitzung, + sagt das der Hinweis darüber. Darunter eine Zeitleiste der früheren + Kommentare je Gewerk (renderPlanKommentarVerlauf(), plan-info.js). */ +.plan-kommentar-hinweis { + margin-bottom: 10px; padding: 8px 12px; border-radius: var(--r-sm, 8px); + font-size: 12.5px; line-height: 1.45; + color: var(--text); + background: color-mix(in srgb, var(--amber) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--amber) 40%, transparent); +} +.plan-kommentar-verlauf { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); } +.plan-verlauf-kopf { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; } +.plan-verlauf-titel { font-size: 11.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); } +.plan-verlauf-anzahl { font-size: 12px; color: var(--text-subtle); } +.plan-verlauf-liste { list-style: none; margin: 0; padding: 0; } +.plan-verlauf-eintrag { display: grid; grid-template-columns: 52px 12px minmax(0, 1fr); column-gap: 10px; } +.plan-verlauf-kw { font-size: 12px; font-weight: 600; color: var(--text); padding-top: 1px; white-space: nowrap; } +.plan-verlauf-punkt { position: relative; } +.plan-verlauf-punkt::before { + content: ""; position: absolute; left: 1.5px; top: 5px; + width: 9px; height: 9px; border-radius: 50%; + background: color-mix(in srgb, var(--accent) 45%, transparent); +} +.plan-verlauf-eintrag.ist-juengster .plan-verlauf-punkt::before { background: var(--accent); } +.plan-verlauf-punkt::after { + content: ""; position: absolute; left: 5px; top: 17px; bottom: 0; width: 2px; background: var(--line); +} +.plan-verlauf-eintrag:last-child .plan-verlauf-punkt::after { display: none; } +.plan-verlauf-inhalt { padding-bottom: 14px; min-width: 0; } +.plan-verlauf-text { font-size: 13.5px; line-height: 1.5; color: var(--text); overflow-wrap: anywhere; } +.plan-verlauf-text p { margin: 0; } +.plan-verlauf-meta { font-size: 11.5px; color: var(--text-subtle); margin-top: 3px; } +.plan-verlauf-mehr { + border: 1px solid var(--line); background: var(--surface); color: var(--accent); + border-radius: 999px; padding: 6px 14px; font: inherit; font-size: 12.5px; cursor: pointer; +} +.plan-verlauf-mehr:hover { background: var(--surface-alt); } + +/* Dashboard-Kachel "Protokolle" (Eriks Wunsch, renderProtokolleSummary() + in plan-protokoll.js): letztes Protokoll, seine offenen Punkte in der + Zeilenform der Termine, darunter die früheren als Kurzliste. */ +#planDashProtokollePanel .plan-termin { grid-template-columns: auto minmax(0, 1fr) auto; } +#planDashProtokollePanel .plan-termin-art { width: auto; min-width: 34px; padding: 0 6px; box-sizing: border-box; } +.plan-prot-kopf { + display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; + font: inherit; color: var(--text); background: var(--surface-sunk); + border: 1px solid var(--line-soft); border-radius: var(--r-md, 12px); + padding: 10px 12px; cursor: pointer; +} +.plan-prot-kopf:hover { border-color: var(--accent); } +.plan-prot-nr { + flex: none; font-size: 12px; font-weight: 700; color: var(--accent); + background: var(--accent-soft); border-radius: 8px; padding: 4px 8px; +} +.plan-prot-titel { min-width: 0; display: flex; flex-direction: column; } +.plan-prot-titel b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.plan-prot-titel span { font-size: 12px; color: var(--text-subtle); } +.plan-prot-punkte { margin-top: 8px; } +.plan-prot-punkte:empty { display: none; } +.plan-prot-mehr { display: block; margin-top: 6px; } +.plan-prot-frueher { + display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: baseline; + width: 100%; text-align: left; font: inherit; font-size: 12.5px; color: var(--text-muted); + background: none; border: 0; border-top: 1px solid var(--line-soft); padding: 7px 6px; cursor: pointer; border-radius: 8px; +} +.plan-termin-gruppe + .plan-prot-frueher { border-top: 0; } +.plan-prot-frueher:hover { background: var(--surface-sunk); } +.plan-prot-frueher-titel { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.plan-prot-frueher-zahl { color: var(--amber); font-size: 12px; white-space: nowrap; } +.plan-dash-leer { display: flex; align-items: center; gap: 10px; color: var(--text-subtle); font-size: 13px; } + +/* Kachel "Team" nach Gewerk gruppiert (Eriks Wahl "A", plan-sections.js): + je Gewerk eine Überschrift mit Personenzahl; ein Gewerk des Projekts ohne + Beteiligte zeigt einen Hinweis statt zu verschwinden. */ +.plan-team-gruppe + .plan-team-gruppe { margin-top: 6px; } +.plan-team-gewerk { display: flex; align-items: center; gap: 8px; padding: 6px 0 4px; } +.plan-team-gruppe:first-child .plan-team-gewerk { padding-top: 0; } +.plan-gewerk-schild { font-size: 10.5px; font-weight: 700; letter-spacing: .04em; padding: 2px 8px; border-radius: 6px; background: var(--accent-soft); color: var(--accent); } +.plan-gewerk-titel { font-size: 12px; color: var(--text-muted); white-space: nowrap; } +.plan-gewerk-linie { flex: 1; height: 1px; background: var(--line-soft); } +.plan-gewerk-anzahl { font-size: 12px; color: var(--text-subtle); white-space: nowrap; } +.plan-team-gewerk + .plan-team-zeile { border-top: 0; } +.plan-team-luecke { font-size: 12.5px; color: var(--amber); padding: 2px 0 6px; } + +/* Einstellungen mit Unternavigation (Eriks Wahl "C", plan-settings.js): + links vier Abschnitte mit Kurzfassung, rechts immer genau einer. */ +.plan-einst { display: flex; gap: 14px; align-items: flex-start; margin-top: 4px; } +.plan-einst-nav { + flex: none; width: 260px; position: sticky; top: 90px; + display: flex; flex-direction: column; gap: 4px; padding: 8px; + background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--r-lg); +} +.plan-einst-reiter { + display: flex; flex-direction: column; align-items: flex-start; gap: 3px; text-align: left; + min-height: 56px; padding: 9px 14px; border: 0; border-radius: 12px; + background: transparent; color: var(--text); font: inherit; cursor: pointer; +} +.plan-einst-reiter b { font-size: 14px; font-weight: 600; } +.plan-einst-reiter span { font-size: 12px; color: var(--text-muted); line-height: 1.35; } +.plan-einst-reiter:hover { background: var(--surface-sunk); } +.plan-einst-reiter.is-active { background: var(--surface-sunk); box-shadow: inset 3px 0 0 var(--accent); } +.plan-einst-inhalt { flex: 1; min-width: 0; padding: 22px 26px; } +.plan-einst-titel { margin: 0 0 16px; font-size: 18px; font-weight: 650; } +.plan-einst-teil .form-row { max-width: 640px; } +.plan-einst-paar { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 0 12px; max-width: 640px; } +.plan-einst-paar > .form-row { min-width: 0; margin-bottom: 6px; } +.plan-einst-teil[data-teil="auftrag"] .plan-einst-paar { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 460px; } +.plan-einst-hint { margin: 0 0 16px; max-width: 640px; line-height: 1.45; } +.form-row .plan-einst-hint { margin: 2px 0 0; } +.plan-einst-schmal { max-width: 320px; } +.plan-einst-chips { display: flex; gap: 8px; flex-wrap: wrap; } +.plan-einst-chip { + min-height: 36px; padding: 0 16px; border-radius: 999px; font: inherit; font-size: 13px; cursor: pointer; + border: 1px solid var(--line); background: var(--surface); color: var(--text); +} +.plan-einst-chip.an { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } + +/* Umschalter (Leistungsphasen, Beteiligte) */ +.plan-einst-seg { display: inline-flex; gap: 2px; padding: 3px; border-radius: 999px; background: var(--surface-sunk); } +.plan-einst-seg button { + border: 0; background: transparent; color: var(--text-muted); font: inherit; font-size: 12.5px; + min-height: 30px; padding: 0 12px; border-radius: 999px; cursor: pointer; white-space: nowrap; +} +.plan-einst-seg button.an { background: var(--surface); color: var(--accent); font-weight: 600; box-shadow: 0 1px 4px rgba(15,40,60,.12); } +.plan-einst-seg button:disabled { cursor: default; } + +.plan-einst-phasen { display: flex; flex-direction: column; max-width: 760px; } +.plan-einst-phase { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 9px 0; border-top: 1px solid var(--line-soft); } +.plan-einst-phase-name { display: flex; align-items: center; gap: 12px; min-width: 0; } +.plan-einst-phase-name b { width: 52px; flex: none; font-size: 13px; } +.plan-einst-phase-name span { font-size: 14px; color: var(--text-muted); } +.plan-einst-phase.ist-an .plan-einst-phase-name span { color: var(--text); } +.plan-einst-phase-name em { font-style: normal; font-size: 11px; font-weight: 700; color: var(--accent); background: var(--accent-soft); border-radius: 999px; padding: 2px 8px; } + + +@media (max-width: 900px) { + .plan-einst { flex-direction: column; } + .plan-einst-nav { width: auto; position: static; flex-direction: row; overflow-x: auto; } + .plan-einst-reiter { flex: none; } +} +/* Zahlenfelder (Honorar, Projektgröße) wie die Textfelder daneben — die + allgemeine Feldregel in styles.css kennt type=number nicht; sie dort + aufzunehmen träfe auch kompakte Zahlenfelder anderer Bereiche. */ +.plan-einst-teil input[type=number] { + padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--r-md); + font: inherit; font-size: 13px; color: var(--text); background: var(--surface); +} +.plan-einst-teil input[type=number]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,113,227,.18); } +/* Dunkel ist der Akzent hell — weiße Schrift darauf wäre kaum lesbar. */ +@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .plan-einst-chip.an { color: #0b141b; } } +html[data-theme="dark"] .plan-einst-chip.an { color: #0b141b; } + +/* Beteiligte als Personenliste (Eriks Wahl "B", renderEinstTeam() in + plan-settings.js): Besetzungsleiste, Suchfeld mit Vorschlägen, eine Zeile + je Person mit Rollen-Kapseln (umrandet = aus, voll = Haupt, gestrichelt + = Unterstützung), Entfernen mit "Rückgängig". */ +.plan-einst-teil[data-teil="team"] { position: relative; } +.plan-einst-team-kopfzeile { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px 20px; } +.plan-einst-team-kopfzeile .plan-einst-hint { margin-bottom: 14px; } +.plan-einst-legende { display: flex; gap: 14px; font-size: 12px; color: var(--text-muted); margin-bottom: 16px; } +.plan-einst-legende span { display: inline-flex; align-items: center; gap: 6px; } +.plan-einst-legende i { width: 24px; height: 14px; border-radius: 999px; box-sizing: border-box; } +.plan-einst-legende i.ist-haupt { background: var(--accent); } +.plan-einst-legende i.ist-neben { border: 1.5px dashed var(--accent); background: var(--accent-soft); } +.plan-einst-sperre { padding: 10px 14px; border-radius: 12px; background: var(--surface-sunk); font-size: 13px; margin-bottom: 14px; } +.plan-einst-besetzung { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 16px; } +.plan-einst-besetzt { display: flex; flex-direction: column; gap: 3px; min-width: 0; padding: 10px 12px; border-radius: 12px; background: var(--surface-sunk); border: 1px solid transparent; } +.plan-einst-besetzt b { font-size: 12px; font-weight: 650; color: var(--text-muted); } +.plan-einst-besetzt span { font-size: 12.5px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.plan-einst-besetzt.ist-luecke { background: color-mix(in srgb, var(--amber) 10%, transparent); border: 1px dashed var(--amber); } +.plan-einst-besetzt.ist-luecke b, .plan-einst-besetzt.ist-luecke span { color: var(--amber); } + +.plan-einst-suche { position: relative; display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; max-width: 640px; } +.plan-einst-suche[hidden] { display: none; } +.plan-einst-suche label { font-size: 12px; font-weight: 500; color: var(--text-muted); } +.plan-einst-suche input { height: 40px; box-sizing: border-box; font-size: 14px; } +.plan-einst-vorschlaege { + position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 20; max-height: 320px; overflow: auto; + padding: 6px; border-radius: 14px; background: var(--surface); border: 1px solid var(--line); + box-shadow: 0 16px 40px rgba(15,40,60,.18); +} +html.glas .plan-einst-vorschlaege { background: var(--glas-dicht); } +.plan-einst-vorschlag-titel { font-size: 11.5px; font-weight: 650; color: var(--text-muted); padding: 8px 10px 4px; } +.plan-einst-vorschlag-leer { font-size: 13px; color: var(--text-muted); padding: 10px; } +.plan-einst-vorschlag { + display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; min-height: 48px; padding: 4px 10px; + border: 0; border-radius: 10px; background: transparent; color: var(--text); font: inherit; cursor: pointer; +} +.plan-einst-vorschlag:hover, .plan-einst-vorschlag:focus-visible { background: var(--surface-sunk); } +.plan-einst-vorschlag > span:last-child { display: flex; flex-direction: column; min-width: 0; } +.plan-einst-vorschlag b { font-size: 13.5px; font-weight: 600; } +.plan-einst-vorschlag > span:last-child > span { font-size: 12px; color: var(--text-muted); } + +.plan-einst-ava { + width: 34px; height: 34px; flex: none; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; + font-size: 11px; font-weight: 700; background: var(--accent); color: #fff; +} +.plan-einst-gruppe { display: flex; align-items: center; gap: 10px; padding: 16px 0 6px; } +.plan-einst-zeile { + display: grid; grid-template-columns: minmax(200px, 1fr) auto 36px; gap: 10px 14px; align-items: center; + padding: 9px 10px; margin: 0 -10px; border-radius: 12px; border-top: 1px solid var(--line-soft); + transition: background-color var(--dur-lg, .2s) var(--ease-out, ease); +} +.plan-einst-gruppe + .plan-einst-zeile { border-top-color: transparent; } +.plan-einst-zeile.ist-neu { background: var(--accent-soft); } +.plan-einst-person { display: flex; align-items: center; gap: 12px; min-width: 0; } +.plan-einst-person b { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.plan-einst-kapseln { display: flex; flex-wrap: wrap; gap: 6px; } +.plan-einst-kapsel { + min-height: 36px; padding: 0 14px; border-radius: 999px; box-sizing: border-box; white-space: nowrap; + border: 1px solid var(--line); background: transparent; color: var(--text-muted); font: inherit; font-size: 12.5px; cursor: pointer; + transition: transform var(--dur, .15s) var(--ease-out, ease), background-color var(--dur, .15s); +} +.plan-einst-kapsel:hover:not(:disabled) { border-color: var(--accent); color: var(--text); } +.plan-einst-kapsel:active:not(:disabled) { transform: scale(var(--press, .97)); } +.plan-einst-kapsel.ist-haupt { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } +.plan-einst-kapsel.ist-neben { border: 1.5px dashed var(--accent); background: var(--accent-soft); color: var(--text); font-weight: 600; } +.plan-einst-kapsel.ist-fremd { border-color: transparent; opacity: .5; text-decoration: line-through; } +.plan-einst-kapsel:disabled { cursor: default; } +.plan-einst-weg { width: 36px; height: 36px; border-radius: 50%; border: 0; background: transparent; color: var(--text-muted); font-size: 18px; cursor: pointer; } +.plan-einst-weg:hover { background: var(--surface-sunk); color: var(--red); } +.plan-einst-leer { display: flex; flex-direction: column; gap: 6px; padding: 20px; border-radius: 14px; border: 1px dashed var(--line); margin-top: 8px; } +.plan-einst-leer b { font-size: 14px; font-weight: 600; } +.plan-einst-leer span { font-size: 13px; color: var(--text-muted); line-height: 1.5; } +.plan-einst-toast { + position: sticky; bottom: 16px; margin: 16px auto 0; width: max-content; max-width: 100%; + display: flex; align-items: center; gap: 14px; padding: 8px 8px 8px 16px; border-radius: 999px; + background: var(--text); color: var(--bg); font-size: 13px; box-shadow: 0 16px 40px rgba(15,40,60,.25); +} +.plan-einst-toast[hidden] { display: none; } +.plan-einst-toast button { min-height: 32px; padding: 0 14px; border-radius: 999px; border: 0; background: color-mix(in srgb, var(--bg) 18%, transparent); color: inherit; font: inherit; font-weight: 600; cursor: pointer; } +/* Dunkel ist der Akzent hell — Schrift darauf dunkel. */ +@media (prefers-color-scheme: dark) { + html:not([data-theme="light"]) .plan-einst-kapsel.ist-haupt, + html:not([data-theme="light"]) .plan-einst-ava { color: #0b141b; } +} +html[data-theme="dark"] .plan-einst-kapsel.ist-haupt, +html[data-theme="dark"] .plan-einst-ava { color: #0b141b; } +@media (max-width: 900px) { + .plan-einst-besetzung { grid-template-columns: minmax(0, 1fr); } + .plan-einst-zeile { grid-template-columns: minmax(0, 1fr) 36px; } + .plan-einst-kapseln { grid-column: 1 / -1; order: 3; } +} +/* Gesperrte Sitzung: Die allgemeine :disabled-Regel (styles.css, 45 %) + machte "Haupt" und "aus" kaum unterscheidbar — die Kapseln sollen den + Stand weiter klar zeigen, nur nicht mehr klickbar sein. Fremde Rollen + bleiben bewusst blass. */ +.plan-einst-kapsel:disabled:not(.ist-fremd) { opacity: 1; cursor: default; } + +/* Leistungsphasen: kräftigere Hervorhebung (Eriks Wunsch). Vorher war die + gewählte Seite nur eine weiße Kapsel mit Akzentschrift auf hellgrauem + Grund — "Beauftragt" und "Nicht beauftragt" waren kaum auseinander- + zuhalten. Jetzt: beauftragt = gefüllte Akzentkapsel und die ganze Zeile + leicht getönt; nicht beauftragt = neutrale Kapsel, Zeile zurückgenommen; + "läuft" gefüllt. */ +.plan-einst-phase { padding: 9px 10px; margin: 0 -10px; border-radius: 12px; } +.plan-einst-phase.ist-an { background: var(--accent-soft); border-top-color: transparent; } +.plan-einst-phase.ist-an + .plan-einst-phase { border-top-color: transparent; } +.plan-einst-phase .plan-einst-seg { background: var(--surface-sunk); border: 1px solid var(--line-soft); } +.plan-einst-phase .plan-einst-seg button { color: var(--text-muted); } +.plan-einst-phase .plan-einst-seg button.an:first-child { background: var(--accent); color: #fff; box-shadow: 0 1px 4px rgba(15,40,60,.18); } +.plan-einst-phase .plan-einst-seg button.an:last-child { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(15,40,60,.12); } +.plan-einst-phase:not(.ist-an) .plan-einst-phase-name b { color: var(--text-muted); } +.plan-einst-phase.ist-an .plan-einst-phase-name b { color: var(--accent); } +.plan-einst-phase-name em { background: var(--accent); color: #fff; } +.plan-einst-phase .plan-einst-seg button:disabled { opacity: 1; } +@media (prefers-color-scheme: dark) { + html:not([data-theme="light"]) .plan-einst-phase .plan-einst-seg button.an:first-child, + html:not([data-theme="light"]) .plan-einst-phase-name em { color: #0b141b; } +} +html[data-theme="dark"] .plan-einst-phase .plan-einst-seg button.an:first-child, +html[data-theme="dark"] .plan-einst-phase-name em { color: #0b141b; } +.plan-einst-phase + .plan-einst-phase { margin-top: 2px; } +@media (prefers-color-scheme: dark) { + html:not([data-theme="light"]) .plan-einst-phase .plan-einst-seg button.an:last-child { background: rgba(255,255,255,.16); } +} +html[data-theme="dark"] .plan-einst-phase .plan-einst-seg button.an:last-child { background: rgba(255,255,255,.16); } + +/* Seitenpanel für Aufgabe und Meilenstein (Eriks Wahl "B"): rechts am + Rand, volle Höhe, der Terminplan bleibt links sichtbar (Hintergrund nur + leicht abgedunkelt, nicht verschwommen). Kopf und Fußleiste stehen fest, + nur der Inhalt scrollt. */ +.modal-bg.als-panel { + justify-content: flex-end; align-items: stretch; + background: rgba(15,35,50,.14); + -webkit-backdrop-filter: none; backdrop-filter: none; +} +html.glas .modal-bg.als-panel { background: rgba(15,35,50,.14); -webkit-backdrop-filter: none; backdrop-filter: none; } +@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .modal-bg.als-panel { background: rgba(0,0,0,.32); } } +html[data-theme="dark"] .modal-bg.als-panel { background: rgba(0,0,0,.32); } +.modal-bg.als-panel > .modal.pd { + width: 440px; max-width: 100%; height: 100%; max-height: none; padding: 0; + border-radius: var(--r-xl) 0 0 var(--r-xl); + display: flex; flex-direction: column; overflow: hidden; + box-shadow: -14px 0 44px rgba(15,40,60,.22); + animation: pd-rein var(--dur-lg, .2s) var(--ease-out, ease); +} +@keyframes pd-rein { from { transform: translateX(32px); opacity: 0; } to { transform: none; opacity: 1; } } +.pd-kopf { padding: 18px 22px 14px; border-bottom: 1px solid var(--line-soft); display: flex; flex-direction: column; gap: 10px; } +.pd-art { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 650; color: var(--text-muted); } +.pd-art .modal-kbd { margin-left: auto; font-weight: 400; } +.pd-kasten { width: 11px; height: 11px; border-radius: 3px; background: var(--accent); } +.pd-raute { width: 10px; height: 10px; background: var(--meilenstein, #6d4bb3); transform: rotate(45deg); margin: 0 1px; } +.pd-zu { width: 34px; height: 34px; border-radius: 50%; border: 0; background: transparent; color: var(--text-muted); font-size: 20px; line-height: 1; padding: 0; cursor: pointer; } +.pd-zu:hover { background: var(--surface-sunk); color: var(--text); } +.pd-kopf .pd-titel { width: 100%; box-sizing: border-box; font-size: 17px; font-weight: 600; padding: 9px 12px; } +.pd-inhalt { flex: 1; min-height: 0; overflow: auto; padding: 2px 22px 16px; } +.pd-abs { display: flex; flex-direction: column; gap: 8px; padding: 14px 0; border-bottom: 1px solid var(--line-soft); } +.pd-abs:last-child { border-bottom: 0; } +.pd-abs > h3 { margin: 0; font-size: 12px; font-weight: 650; color: var(--text-muted); } +.pd-abs .range-picker-display, .pd-abs select { width: 100%; box-sizing: border-box; } +.pd-abs .field-inline { display: flex; align-items: center; gap: 8px; } +.pd-abs .field-inline .range-picker-display { flex: 1; } +.pd-haken { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 400; } +.pd-hint { margin: 0; } +.pd-seg { width: 100%; } +.pd-seg button { flex: 1; justify-content: center; min-height: 32px; } +.pd-seg button:disabled { opacity: .45; } +.pd-liste { max-height: 300px; overflow: auto; } +.pd-abs .fold { margin: 0; } +.pd-fuss { + display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; + padding: 14px 22px calc(14px + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--line-soft); background: var(--surface-alt); +} +.pd-loeschen { margin-right: auto; background: transparent; border: 0; color: var(--red); padding: 8px 10px; font-weight: 500; } +.pd-loeschen:hover { background: color-mix(in srgb, var(--red) 10%, transparent); } +.pd-loeschen[hidden] { display: none; } +/* Aufwand einer Aufgabe in Stunden (zählt zur Auslastung). */ +.pd-aufwand { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 400; margin-top: 4px; } +.pd-aufwand > span:first-child { font-weight: 600; color: var(--text-muted); font-size: 12px; } +.pd-aufwand > span:last-child { color: var(--text-muted); font-size: 12px; } +.pd-aufwand input { width: 96px; padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--r-md); font: inherit; font-size: 13px; color: var(--text); background: var(--surface); text-align: right; } +.pd-aufwand input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,113,227,.18); } + +/* Terminplan aufgeräumt (Eriks Wahl "A", plan-gantt.js / plan-build-gantt.js): + eine Leiste statt zwei, Menüs "Mehr" und "+ Neu", Gliederung nach + Leistungsphasen mit zuklappbaren Köpfen, Filter, Hinweis "Ohne Termin". */ +.tp-leiste { + gap: 10px; padding: 8px 10px; margin-bottom: 12px; border-radius: var(--r-lg); + background: var(--surface); border: 1px solid var(--line-soft); +} +.tp-luecke { flex: 1; } +.tp-seg button { min-height: 30px; } +.tp-zeitraum { width: 200px; } +.tp-menue { position: relative; } +.tp-menue > summary { + list-style: none; cursor: pointer; display: inline-flex; align-items: center; min-height: 34px; padding: 0 14px; + border-radius: 999px; border: 1px solid var(--line); background: var(--surface); color: var(--text); font-size: 13px; user-select: none; +} +.tp-menue > summary::-webkit-details-marker { display: none; } +.tp-neu > summary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } +.tp-menue-liste { + position: absolute; right: 0; top: calc(100% + 6px); z-index: 60; min-width: 250px; padding: 6px; + border-radius: 14px; background: var(--surface); border: 1px solid var(--line-soft); box-shadow: 0 16px 40px rgba(15,40,60,.22); + display: flex; flex-direction: column; +} +html.glas .tp-menue-liste { background: var(--glas-dicht); } +.tp-menue-liste.nach-rechts { right: auto; left: 0; } +.tp-menue-liste button, .tp-menue-liste label { + display: flex; align-items: center; gap: 10px; width: 100%; min-height: 38px; padding: 0 10px; box-sizing: border-box; + border: 0; border-radius: 9px; background: transparent; color: var(--text); font: inherit; font-size: 13.5px; font-weight: 400; text-align: left; cursor: pointer; +} +.tp-menue-liste button:hover, .tp-menue-liste label:hover { background: var(--surface-sunk); } +.tp-menue-liste button.active { color: var(--accent); font-weight: 600; } +.tp-menue-liste hr { border: 0; border-top: 1px solid var(--line-soft); margin: 6px 4px; width: auto; } +.tp-sym-aufgabe { width: 10px; height: 10px; border-radius: 3px; background: var(--accent); flex: none; } +.tp-sym-ms { width: 9px; height: 9px; background: var(--meilenstein, #7a3fb8); transform: rotate(45deg); flex: none; margin: 0 1px; } +@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .tp-neu > summary { color: #0b141b; } } +html[data-theme="dark"] .tp-neu > summary { color: #0b141b; } + +/* Projektzeile ruhiger */ +#planGanttContainer .tp-projektzeile .gantt-label { font-weight: 500; color: var(--text-muted); font-size: 12px; } +/* Phasenköpfe */ +#planGanttContainer .gantt-row.gantt-phase { background: var(--surface-sunk); } +#planGanttContainer .gantt-row.gantt-phase .gantt-label { background: var(--surface-sunk); padding-left: 6px !important; font-weight: 650 !important; color: var(--text) !important; gap: 6px; } +#planGanttContainer .gantt-row.gantt-phase .gantt-bar.todo { top: 50%; height: 10px; margin-top: -5px; border-radius: 999px; } +.tp-klappen { width: 24px; height: 24px; flex: none; border: 0; border-radius: 6px; background: transparent; color: var(--text-muted); font-size: 10px; cursor: pointer; padding: 0; } +.tp-klappen:hover { background: var(--surface); color: var(--text); } +.tp-phase-info { margin-left: auto; font-size: 11.5px; font-weight: 400; color: var(--text-muted); white-space: nowrap; } +#planGanttContainer .gantt-row.tp-gruppe .gantt-label { font-weight: 650; color: var(--text-muted); font-size: 12px; background: var(--surface-sunk); } +#planGanttContainer .gantt-row.tp-gruppe { background: var(--surface-sunk); min-height: 30px; } +/* Unter einem Phasenkopf eingerückt */ +#planGanttContainer .gantt-row.gantt-todo:not(.gantt-phase) .gantt-label, +#planGanttContainer .gantt-row.gantt-milestone-row .gantt-label { padding-left: 30px !important; } +#planGanttContainer .gantt-row.gantt-zu, +#planGanttContainer .gantt-row.gantt-gefiltert { display: none; } +#planGanttContainer .gantt-row.gantt-todo.done .gantt-todo-title { text-decoration: line-through; opacity: .7; } +/* Kürzel der Zuständigen rechts in der Namensspalte */ +.gantt-label .gantt-leute { margin-left: auto; display: inline-flex; flex: none; padding-left: 6px; } +.gantt-label .gantt-leute span { + min-width: 20px; height: 20px; padding: 0 3px; box-sizing: border-box; border-radius: 999px; margin-left: -4px; + display: inline-flex; align-items: center; justify-content: center; font-size: 8.5px; font-weight: 700; + background: var(--surface-sunk); color: var(--text); border: 1.5px solid var(--surface); +} +.gantt-label .gantt-todo-title { overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.gantt-vorgaenger-warn { + flex: none; width: 16px; height: 16px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; + font-size: 10px; font-weight: 800; background: color-mix(in srgb, var(--amber) 20%, transparent); color: var(--amber); +} +/* Heute deutlicher */ +#planGanttContainer .gantt-grid-today { border-left-width: 2px; opacity: 1; } +/* Hinweis auf Aufgaben ohne Termin */ +.tp-ohne { + display: flex; align-items: center; gap: 8px; width: 100%; margin-top: 8px; padding: 10px 14px; box-sizing: border-box; + border: 1px dashed var(--line); border-radius: var(--r-md); background: transparent; color: var(--text-muted); font: inherit; font-size: 13px; text-align: left; cursor: pointer; +} +.tp-ohne:hover { border-color: var(--accent); color: var(--text); } +.tp-ohne[hidden] { display: none; } +.tp-ohne-zahl { background: var(--surface-sunk); border-radius: 999px; padding: 1px 8px; font-weight: 600; color: var(--text); } +.tp-ohne-hinweis { margin-left: auto; font-size: 12px; } +#planGanttContainer .gantt-row.gantt-phase .gantt-todo-title { flex: 1 1 auto; min-width: 70px; } +#planGanttContainer .gantt-row.gantt-phase .tp-phase-info { flex: none; } +/* Die Glas-Darstellung gibt Knöpfen einen Rahmen — in Umschaltern und + Menüs soll es keinen geben; das Menü deckt den Plan dahinter ab. */ +#planSection-terminplan .tp-seg button, +#planSection-terminplan .tp-menue-liste button { border: 0 !important; box-shadow: none; } +#planSection-terminplan .tp-seg button[aria-pressed="true"] { box-shadow: var(--sh-1); } +#planSection-terminplan .tp-menue-liste { -webkit-backdrop-filter: blur(24px) saturate(160%); backdrop-filter: blur(24px) saturate(160%); } +html.glas #planSection-terminplan .tp-menue-liste { background: var(--glas-dicht); } +@media (prefers-color-scheme: light) { html.glas:not([data-theme="dark"]) #planSection-terminplan .tp-menue-liste { background: rgba(255,255,255,.96); } } +html.glas[data-theme="light"] #planSection-terminplan .tp-menue-liste { background: rgba(255,255,255,.96); } +/* Getönte Namenszellen (Projektzeile, Phasenköpfe, Gruppen) liegen auf + einem deckenden Grund — sonst schienen beim waagerechten Scrollen die + Balken durch. --gantt-label-grund setzt glas.css; ohne Glas ist + --surface ohnehin deckend. */ +#planGanttContainer .gantt-row.gantt-project.highlighted .gantt-label { + background: linear-gradient(var(--accent-soft), var(--accent-soft)), var(--gantt-label-grund, var(--surface)); +} +#planGanttContainer .gantt-row.gantt-phase .gantt-label, +#planGanttContainer .gantt-row.tp-gruppe .gantt-label { + background: linear-gradient(var(--surface-sunk), var(--surface-sunk)), var(--gantt-label-grund, var(--surface)); +} +.tp-sym-phase { width: 14px; height: 6px; border-radius: 999px; background: var(--accent); flex: none; opacity: .75; } +#todoEditPhaseAbs select { width: 100%; } + +/* ===================== Bereich Leistungsphasen ===================== + Eriks Wahl "B": Ablaufbalken über alle Phasen, darunter eine Kachel je + Phase (renderPlanPhasen(), js/plan/plan-phasen.js). Text auf Akzent- + flächen in festem Weiß — unter Glas ist var(--surface) durchscheinend. */ +.phasen-zusammenfassung { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; + font-size: 13px; color: var(--text-muted); margin: 0 2px 12px; +} +.phasen-zusammenfassung span { font-size: 12px; color: var(--text-subtle); } +.phasen-kopf { font-size: 11px; font-weight: 650; letter-spacing: .02em; color: var(--text-muted); text-transform: uppercase; margin-bottom: 8px; } +.phasen-ablauf { margin-bottom: 16px; } +.phasen-ablauf-spur { position: relative; height: 30px; } +.phasen-ablauf-seg { + position: absolute; top: 0; bottom: 0; min-width: 0; + display: flex; align-items: center; padding: 0 8px; overflow: hidden; + border: 0; border-radius: var(--r-md); cursor: pointer; + font: inherit; font-size: 12px; font-weight: 600; white-space: nowrap; + color: var(--text); background: var(--surface-sunk); +} +.phasen-ablauf-seg + .phasen-ablauf-seg { box-shadow: -2px 0 0 var(--surface); } +.phasen-ablauf-seg.is-fertig { background: var(--green); color: #fff; } +.phasen-ablauf-seg.is-laeuft { + color: #fff; + background: linear-gradient(90deg, var(--accent) var(--fort), color-mix(in srgb, var(--accent) 45%, transparent) var(--fort)); +} +.phasen-ablauf-seg span { overflow: hidden; text-overflow: ellipsis; } +.phasen-ablauf-heute { position: absolute; top: -4px; bottom: -4px; border-left: 2px solid var(--red); pointer-events: none; } +.phasen-ablauf-achse { position: relative; display: flex; justify-content: space-between; margin-top: 6px; font-size: 11.5px; color: var(--text-muted); } +.phasen-ablauf-heute-text { position: absolute; top: 0; transform: translateX(-50%); color: var(--red); font-weight: 600; white-space: nowrap; } + +.phasen-raster { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; } +.phasen-kachel { + display: flex; flex-direction: column; gap: 12px; + background: var(--surface); border: 1px solid var(--line-soft); + border-radius: var(--r-lg); padding: 14px 16px; +} +.phasen-kachel.is-laeuft { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.phasen-kachel.is-fertig { opacity: .8; } +.phasen-kachel.ist-markiert { animation: phasen-markiert 1.2s var(--ease-out, ease-out); } +@keyframes phasen-markiert { 0%, 40% { box-shadow: 0 0 0 3px var(--accent); } 100% { box-shadow: none; } } +.phasen-kachel-kopf { display: flex; align-items: flex-start; gap: 12px; } +.phasen-ring { + --ring: var(--text-subtle); + width: 52px; height: 52px; border-radius: 50%; flex: none; + display: flex; align-items: center; justify-content: center; + background: conic-gradient(var(--ring) var(--fort), var(--surface-sunk) 0); +} +.phasen-kachel.is-laeuft .phasen-ring { --ring: var(--accent); } +.phasen-kachel.is-fertig .phasen-ring { --ring: var(--green); } +.phasen-ring span { + width: 42px; height: 42px; border-radius: 50%; background: var(--surface-alt); + display: flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; +} +.phasen-kachel-name { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.phasen-kachel-name b { font-size: 15px; } +.phasen-kachel-name span { font-size: 12.5px; color: var(--text-muted); } +.phasen-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 999px; background: var(--surface-sunk); color: var(--text-muted); white-space: nowrap; } +.phasen-chip.is-laeuft { background: var(--accent); color: #fff; } +.phasen-chip.is-fertig { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); } +.phasen-hinweis { font-size: 12px; color: var(--amber); margin-top: -4px; } +.phasen-werte { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; font-size: 12.5px; } +.phasen-werte > span { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.phasen-werte small { font-size: 11.5px; color: var(--text-muted); } +.phasen-werte b { font-weight: 600; font-variant-numeric: tabular-nums; } +.phasen-leer { color: var(--text-subtle); font-weight: 400; font-style: italic; } +.phasen-knoepfe { display: flex; flex-wrap: wrap; gap: 8px; } + +/* Aufgabenliste: Phasen-Kennzeichen je Aufgabe und Filter */ +.plan-task-phase { + flex-shrink: 0; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px; + background: var(--accent-soft); color: var(--accent); white-space: nowrap; +} +.plan-task-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: -2px 0 10px; font-size: 12px; color: var(--text-muted); } +.plan-task-filter:empty { display: none; } +.plan-task-filter > span { margin-right: 2px; } +.plan-task-filter-btn { + min-height: 28px; padding: 0 12px; border-radius: 999px; font: inherit; font-size: 12px; + border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer; +} +.plan-task-filter-btn.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; } +@media (max-width: 600px) { .phasen-werte { grid-template-columns: 1fr 1fr; } } + +/* Leistungsphasen farblich von Aufgaben getrennt (Eriks Wunsch): Phasen- + balken in Blauviolett (bewusst bläulicher als das Lila der Meilensteine), Aufgaben bleiben im Akzentblau, erledigte grün, + Meilensteine lila — keine Farbe ist doppelt belegt. */ +:root { --gantt-phase: #6a5fc7; } +@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) { --gantt-phase: #a59ef2; } } +html[data-theme="dark"] { --gantt-phase: #a59ef2; } +#planGanttContainer .gantt-row.gantt-phase .gantt-bar.todo, +#planGanttContainer .gantt-row.gantt-phase .gantt-bar.todo.done { background: var(--gantt-phase); } +.tp-sym-phase { background: var(--gantt-phase); opacity: 1; } + +/* Aufgaben-Vorlagen je Gewerk (Admin): Gewerk-Umschalter, Spaltenkopf und + bearbeitbare Zeilen — gleiche Spalten wie die Zeile „+ Schritt". */ +.vorlagen-wahl { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin: 10px 0 12px; } +.vorlagen-gewerke { display: inline-flex; gap: 4px; padding: 3px; border-radius: 999px; background: var(--surface-sunk); } +.vorlagen-gewerk { border: 0; background: transparent; color: var(--text-muted); font: inherit; font-size: 13px; font-weight: 600; + padding: 6px 14px; border-radius: 999px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; } +.vorlagen-gewerk span { font-size: 11px; font-weight: 500; opacity: .75; } +.vorlagen-gewerk.is-active { background: var(--surface); color: var(--text); box-shadow: var(--sh-1); } +.vorlagen-kopf, .vorlagen-zeile, .vorlagen-neu { + display: grid !important; grid-template-columns: minmax(0, 1fr) 80px 90px 90px 120px; gap: 8px; align-items: center; +} +.vorlagen-kopf { font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); padding: 0 6px 4px; } +.vorlagen-zeile input { width: 100%; box-sizing: border-box; min-width: 0; } +.vorlagen-neu input[type=text], .vorlagen-neu input[type=number] { width: 100%; min-width: 0; flex: none; box-sizing: border-box; } +.vorlagen-aktionen { display: inline-flex; gap: 4px; justify-content: flex-end; align-items: center; } +#planSection-terminplan .vorlagen-gewerk, #admin .vorlagen-gewerk { border: 0 !important; } +@media (max-width: 640px){ + .vorlagen-kopf { display: none !important; } + .vorlagen-zeile, .vorlagen-neu { grid-template-columns: 1fr 1fr; } + .vorlagen-zeile input[type=text], .vorlagen-neu input[type=text] { grid-column: 1 / -1; } + .template-head-grid { grid-template-columns: 1fr; } +} + +.vorlagen-summe { font-size: 12.5px; color: var(--text-muted); padding: 6px 6px 0; } +.vorlagen-summe b { color: var(--text); font-variant-numeric: tabular-nums; } + +/* ---------- Termin aus Mail (js/plan/plan-mail-eintrag.js) ---------- */ +.mail-ablage { + position: fixed; inset: 0; z-index: 250; + display: flex; align-items: center; justify-content: center; + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 3px dashed var(--accent); + pointer-events: none; +} +.mail-ablage[hidden] { display: none; } +.mail-ablage-karte { + display: flex; flex-direction: column; align-items: center; gap: 8px; + max-width: 420px; padding: 24px 28px; text-align: center; + background: var(--bg); color: var(--text); + border: 1px solid var(--line); border-radius: var(--r-lg, 14px); + box-shadow: 0 12px 40px rgba(0,0,0,.18); +} +.mail-ablage-karte svg { width: 32px; height: 32px; color: var(--accent); } +.mail-ablage-karte b { font-size: 1.1em; } +.mail-ablage-karte span { color: var(--text-subtle); font-size: .9em; } +.mail-ablage.ist-beschaeftigt .mail-ablage-karte svg { animation: mail-puls 1.2s ease-in-out infinite; } +@keyframes mail-puls { 50% { opacity: .35; } } +@media (prefers-reduced-motion: reduce) { .mail-ablage.ist-beschaeftigt .mail-ablage-karte svg { animation: none; } } + +.pd-notiz { + width: 100%; box-sizing: border-box; resize: vertical; min-height: 80px; + font: inherit; font-size: .9em; line-height: 1.4; +} +.pd-mail-hinweis { + margin: 0 0 6px; padding: 6px 10px; border-radius: 8px; + background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text); +} +.plan-task-notiz { display: inline-flex; color: var(--text-subtle); } +.plan-task-notiz svg { width: 14px; height: 14px; } + +.mail-auswahl { width: min(560px, calc(100vw - 32px)); } +.mail-auswahl-liste { display: flex; flex-direction: column; gap: 4px; max-height: 50vh; overflow-y: auto; margin: 12px 0; } +.mail-auswahl-zeile { + display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; + padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; +} +.mail-auswahl-datum { color: var(--text-subtle); font-size: .88em; white-space: nowrap; } diff --git a/public/styles.css b/public/styles.css index 4b37b63..6681c63 100644 --- a/public/styles.css +++ b/public/styles.css @@ -33,6 +33,7 @@ Kontrast 4,4 : 1 und bliebe damit unter den 4,5 : 1 für normalen Text). */ --band-neu: #0071e3; --band-fortschritt: #22823a; + --meilenstein: #7a3fb8; /* Gelbe Markierung neu hinzugekommener Protokoll-Punkte — bewusst eigenes Token statt --band-neu (dort blau, bedeutet "neu kommentiertes Projekt" @@ -108,6 +109,11 @@ body { } .container { padding: 20px 24px; } +/* Das hidden-Attribut muss immer gewinnen. Regeln wie button.menu-item + { display:inline-flex } überstimmten sonst die Browser-Vorgabe, und z. B. + "+ Neues Projekt" blieb für Mitglieder sichtbar, obwohl renderUserBar() + es per hidden ausblendet. */ +[hidden] { display: none !important; } /* === App-Shell: globales Menüband oben + linke Navigations-Sidebar + Hauptbereich (OpenProject-Anordnung: ein durchgehendes globales Menüband @@ -313,6 +319,11 @@ button.menu-item:hover { background: var(--surface-sunk); } gap: 2px; padding: 12px; } +/* Kein Eintrag darf schrumpfen: Reicht die Höhe nicht (z. B. Admin + aufgeklappt), scrollt .sidebar-nav. Ohne das stauchte die Flex-Spalte das + zuletzt aufgeklappte Untermenü (overflow: hidden → Mindesthöhe 0) auf 0 px + — „Werkzeuge“ ließ sich bei offenem Admin scheinbar nicht aufklappen. */ +.sidebar-nav > * { flex-shrink: 0; } .sidebar-collapse-bar { flex-shrink: 0; @@ -458,7 +469,7 @@ html[data-theme="dark"] body.wandtafel-aktiv { /* ===== Kopf: Marke "Dashboard" + Sitzung/Uhrzeit + Abwesend-Mini ===== */ .dash-kopf { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } -.dash-marke { font-family: 'Fredoka', sans-serif; font-weight: 600; font-size: 19px; color: var(--dash-ink); } +.dash-marke { font-family: 'Outfit', -apple-system, system-ui, sans-serif; font-weight: 600; font-size: 19px; color: var(--dash-ink); } .dash-kopf-rechts { display: flex; align-items: center; gap: 14px; } .dash-datum { font-size: 12.5px; color: var(--dash-text-muted); background: var(--dash-panel-3); padding: 4px 12px; border-radius: var(--r-pill); } .dash-abwesend-mini { display: flex; } @@ -481,7 +492,7 @@ html[data-theme="dark"] body.wandtafel-aktiv { .dash-kpi.ist-klickbar { cursor: pointer; } .dash-kpi.ist-klickbar:hover { filter: brightness(.96); } .dash-kpi-label { font-size: 11px; font-weight: 700; color: var(--dash-text-muted); margin-bottom: 8px; } -.dash-kpi-wert { font-family: 'Fredoka', sans-serif; font-weight: 600; font-size: 32px; color: var(--dash-ink); font-variant-numeric: tabular-nums; } +.dash-kpi-wert { font-family: 'Outfit', -apple-system, system-ui, sans-serif; font-weight: 600; font-size: 32px; color: var(--dash-ink); font-variant-numeric: tabular-nums; } .dash-kpi-wert.ok { color: var(--dash-ink); } .dash-kpi-wert.info { color: var(--dash-ink-soft); } .dash-kpi-wert.warn { color: var(--dash-warn); } @@ -514,7 +525,7 @@ html[data-theme="dark"] body.wandtafel-aktiv { .wand-meter-leer { font-size: 12px; color: var(--dash-text-subtle); padding-left: 4px; } .wand-meter-wert { flex: 0 0 76px; text-align: right; font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; - font-family: 'Fredoka', sans-serif; color: var(--dash-ink); + font-family: 'Outfit', -apple-system, system-ui, sans-serif; color: var(--dash-ink); } .wand-einheit { font-size: 11px; font-weight: 400; color: var(--dash-text-subtle); margin-left: 2px; } .wand-leer-klein { font-size: 11.5px; color: var(--dash-text-subtle); } @@ -549,7 +560,7 @@ html[data-theme="dark"] body.wandtafel-aktiv { box-shadow: var(--sh-2); padding: 20px 24px; color: var(--dash-text); } .wand-uebersicht-kopf { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; flex-shrink: 0; } -.wand-uebersicht-kopf h2 { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -.01em; font-family: 'Fredoka', sans-serif; } +.wand-uebersicht-kopf h2 { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -.01em; font-family: 'Outfit', -apple-system, system-ui, sans-serif; } .wand-uebersicht-liste { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; } .wand-uebersicht-zeile { display: flex; align-items: center; gap: 18px; padding: 13px 6px; border-top: 1px solid var(--dash-line); @@ -778,9 +789,10 @@ button.active { } .sidebar-subnav.open { - /* Reicht für die längste Gruppe („Einstellungen", sieben Punkte); die - Animation läuft über max-height, deshalb steht hier eine feste Zahl. */ - max-height: 360px; + /* Reicht für die längste Gruppe (Admin, zwölf Punkte); die Animation + läuft über max-height, deshalb steht hier eine feste Zahl. 360px + schnitt seit „Rollen & Rechte“ die letzten Admin-Punkte ab. */ + max-height: 640px; opacity: 1; } @@ -1041,6 +1053,11 @@ tr:nth-child(even) td { background: var(--surface-alt); } width: 22px; height: 20px; border-radius: 4px; font-size: 11px; font-weight: 700; cursor: pointer; line-height: 1; } .richtext-btn:hover { background: var(--surface-alt); color: var(--text); } +/* "Standard": setzt die Formatierung zurück — Text statt Symbol, ein + Radiergummi-Zeichen wäre missverständlich. Bleibt auch ohne Markierung + voll sichtbar, weil er dann den ganzen Text zurücksetzt. */ +.richtext-btn.richtext-btn-loeschen { width: auto; padding: 0 6px; font-weight: 600; } +.richtext-toolbar:not(.hat-auswahl) .richtext-btn.richtext-btn-loeschen { opacity: 1; } .richtext-divider { width: 1px; height: 14px; background: var(--line); flex-shrink: 0; } .richtext-farben { display: inline-flex; gap: 6px; } .richtext-farbe-btn { @@ -2577,6 +2594,8 @@ input[type=text].edit-name:focus { Logo-Blau selbst (dasselbe #116896 wie am Dashboard). */ --band-neu: #116896; --band-fortschritt: #1f7a4f; + --meilenstein: #c19cf0; + --meilenstein: #c19cf0; --protokoll-neu-bg: rgba(255,214,10,.14); --protokoll-neu-border: #ffd60a; @@ -2908,10 +2927,11 @@ html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,2 } .gantt-label { - width: 220px; - flex: 0 0 220px; - min-width: 220px; - max-width: 220px; + /* Breite per Griff veränderbar (ganttSpalteZiehen(), js/gantt.js). */ + width: var(--gantt-label-w, 220px); + flex: 0 0 var(--gantt-label-w, 220px); + min-width: var(--gantt-label-w, 220px); + max-width: var(--gantt-label-w, 220px); height: 100%; align-self: stretch; box-sizing: border-box; @@ -3120,10 +3140,6 @@ html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,2 .gantt-bar.todo.done { background: var(--green); } -.gantt-bar.todo.critical { - background: var(--red); - box-shadow: 0 0 0 2px rgba(215,0,21,.25); -} .gantt-todo-add-controls { display: flex; align-items: center; @@ -4165,14 +4181,22 @@ tr.is-menu-open { background: var(--surface-alt); } /* Der Kommentar wird auf sechs Zeilen begrenzt, damit alle Kacheln einer Schiene gleich hoch bleiben; der vollständige Text steht im Tooltip. */ +/* Formatierter Kommentar (Absätze als
    /
    ): statt line-clamp, das + mit Block-Kindern nicht greift, eine Höhe von sechs Zeilen. */ .timeline-text { - display: -webkit-box; - -webkit-line-clamp: 6; - -webkit-box-orient: vertical; + max-height: calc(1.45em * 6); overflow: hidden; + overflow-wrap: anywhere; line-height: 1.45; min-height: 3em; } +.timeline-text p { margin: 0; } +/* Gewerk über dem Kommentar: Systemtext, nicht Teil des Kommentars. */ +.kommentar-gewerk { + font-size: 10.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; + color: var(--text-subtle); margin-top: 4px; +} +.kommentar-gewerk:first-child { margin-top: 0; } .timeline-leer { color: var(--text-muted); font-style: italic; } .timeline-lph-list { display: flex; flex-direction: column; gap: 1px; } @@ -4456,11 +4480,11 @@ tr.is-menu-open { background: var(--surface-alt); } .bericht-rail-dot.is-progress { background: var(--band-fortschritt); } .bericht-rail-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* max-width: die Hauptspalte bleibt eine schmale Lesespalte statt bis zum - rechten Rand zu laufen — Teil der Fokus-Idee, nicht nur die Nebeninfos - wegzuklappen, sondern auch dem Kommentar selbst keine unnötige Breite zu - geben. */ -.bericht-main { padding: 26px 36px 30px; max-width: 640px; min-width: 0; } +/* Keine Breitenbegrenzung: Die Hauptspalte nutzt die ganze Bildschirmbreite + neben der Leiste (Eriks Wunsch). Früher hielt max-width: 640px sie als + schmale Lesespalte — auf breiten Monitoren blieb dabei die halbe Fläche + leer. */ +.bericht-main { padding: 26px 36px 30px; min-width: 0; } .bericht-head-line { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; } .bericht-head-nr { @@ -4480,36 +4504,64 @@ tr.is-menu-open { background: var(--surface-alt); } color: var(--amber); font-size: 12px; margin-bottom: 16px; } -/* Scrollrad über dem Kommentar-Eingabefeld: "Was hatten wir letzte Woche - vereinbart?" ist die häufigste Frage im Meeting - stand bis hierhin als - starre Einzeile mit nur der unmittelbaren Vorwoche da. Erik wollte mit dem - Mausrad durch MEHRERE vergangene Kommentare blättern können - (renderVorwocheRad(), js/wochenbericht/wochenbericht.js), nicht nur die - eine Vorwoche sehen - die Quelle ist derselbe, bereits geprüfte Verlauf wie - im Verlaufs-Dialog (projectStatusHistory(), js/utils.js). */ -.bericht-vorwoche-rad { - display: flex; align-items: center; gap: 8px; background: var(--surface-sunk); - border-radius: var(--r-md); padding: 7px 9px; margin-bottom: 14px; cursor: ns-resize; +/* Verlauf der Vorwochen im Wochenbericht (Eriks Wahl "B"): oben die + schmale Zeile "Zuletzt KW …" (renderBerichtZuletzt()), unter der + Bearbeitungsmaske die Zeitleiste über die volle Breite + (renderBerichtVerlauf(), js/wochenbericht/wochenbericht.js) — dieselbe + Bildsprache wie der Verlauf im Projekt-Dashboard (.plan-verlauf-*). */ +.bericht-zuletzt { + background: var(--surface-sunk); border-radius: var(--r-md); + padding: 8px 12px; margin-bottom: 14px; font-size: 12.5px; line-height: 1.5; color: var(--text-muted); } -.bericht-vorwoche-nav { - width: 22px; height: 22px; border: none; background: none; border-radius: 50%; - color: var(--text-muted); cursor: pointer; font-size: 13px; flex-shrink: 0; - display: flex; align-items: center; justify-content: center; font-family: inherit; +.bericht-zuletzt-kw { display: block; font-size: 11px; font-weight: 650; color: var(--text); margin-bottom: 2px; } +/* Lange Kommentare nicht endlos: höchstens fünf Zeilen, der Rest steht im Verlauf. */ +.bericht-zuletzt-text { max-height: calc(1.5em * 5); overflow: hidden; overflow-wrap: anywhere; } +.bericht-zuletzt-text p { margin: 0; } + +.bericht-verlauf { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--line-soft); } +.bv-kopf { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; } +.bv-titel { font-size: 15px; font-weight: 650; margin: 0; color: var(--text); } +.bv-anzahl { font-size: 12px; color: var(--text-subtle); } +.bv-mehr, .bv-uebernehmen { + border: 0; background: transparent; font: inherit; font-size: 12px; font-weight: 600; + color: var(--accent); cursor: pointer; padding: 4px 8px; border-radius: var(--r-md); } -.bericht-vorwoche-nav:hover:not(:disabled) { background: var(--surface); color: var(--text); } -.bericht-vorwoche-nav:disabled { opacity: .3; cursor: default; } -.bericht-vorwoche-inhalt { flex: 1; min-width: 0; } -.bericht-vorwoche-label { - display: block; font-weight: 600; color: var(--text-subtle); - text-transform: uppercase; font-size: 10.5px; letter-spacing: .05em; margin-bottom: 3px; +.bv-mehr:hover, .bv-uebernehmen:hover { background: var(--surface-alt); } +.bv-liste { list-style: none; margin: 0; padding: 0; } +.bv-zeile { + display: grid; grid-template-columns: 120px 14px minmax(0, 1fr) minmax(0, 220px) auto; + column-gap: 14px; align-items: start; padding: 10px 0; } -.bericht-vorwoche-text { - color: var(--text-muted); font-size: 12px; display: block; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +.bv-kw { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.bv-kw b { font-size: 13px; color: var(--text); } +.bv-kw span { font-size: 11.5px; color: var(--text-subtle); } +/* Punkt und Linie: die Linie läuft von Punkt zu Punkt über den Zeilenabstand */ +.bv-punkt { position: relative; align-self: stretch; } +.bv-punkt::before { + content: ""; position: absolute; left: 1px; top: 4px; width: 12px; height: 12px; + border-radius: 50%; box-sizing: border-box; border: 2px solid var(--accent); background: var(--surface); z-index: 1; +} +.bv-zeile.ist-juengster .bv-punkt::before { background: var(--accent); } +.bv-punkt::after { + content: ""; position: absolute; left: 6px; top: 16px; bottom: -14px; width: 2px; background: var(--line); +} +.bv-zeile:last-child .bv-punkt::after { display: none; } +.bv-inhalt { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.bv-text { font-size: 13.5px; line-height: 1.5; color: var(--text); overflow-wrap: anywhere; } +.bv-text p { margin: 0; } +.bv-ruhe { font-size: 11.5px; color: var(--text-subtle); } +.bv-chips { display: flex; flex-wrap: wrap; gap: 4px; } +.bv-chip { + font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; + background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); +} +.bv-uebernehmen { justify-self: end; } +@media (max-width: 1100px) { + .bv-zeile { grid-template-columns: 76px 14px minmax(0, 1fr); row-gap: 6px; } + .bv-chips, .bv-uebernehmen { grid-column: 3; } + .bv-uebernehmen { justify-self: start; padding-left: 0; } + .bv-chips:empty { display: none; } } -.bericht-vorwoche-dots { display: flex; flex-direction: column; gap: 3px; flex-shrink: 0; } -.bericht-vorwoche-dots .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--line); } -.bericht-vorwoche-dots .dot.aktiv { background: var(--accent); } .bericht-detail-fuss { display: flex; align-items: center; gap: 14px; margin-top: 20px; } .bericht-fuss-spacer { flex: 1; } @@ -4685,16 +4737,12 @@ tr.is-menu-open { background: var(--surface-alt); } color: var(--amber); } -.auftrag-select { - font-size: 12px; - padding: 3px 8px; - border: 1px solid var(--line); - border-radius: var(--r-sm); - background: var(--surface); - color: var(--text); - max-width: 100%; -} -.auftrag-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +/* .auftrag-select steht nur noch in den Projekteinstellungen und sieht dort + aus wie jedes andere Auswahlfeld (Gruppe direkt darunter): Größe, Rahmen + und Pfeil kommen aus der allgemeinen select-Regel. Die frühere kompakte + Fassung setzte `background:` als Kurzform und löschte damit den Pfeil — + das Feld wirkte wie ein loses Etikett statt wie eine Auswahl. */ +.auftrag-select { width: 100%; } /* =========================================================== Ein Ort je Angabe — Anzeige statt Eingabe @@ -5433,3 +5481,694 @@ html[data-theme="dark"] .login-tage li.ist-heute span, html[data-theme="dark"] . .login-wasserzeichen { width: 260vw; top: 0; transform: translate(-30%, -10%); } } @media (prefers-reduced-motion: reduce) { .login-woche { animation: none; } .login-spinner { animation: none; } } + +/* ---------- Dashboard: Meine Projekte + Monatskalender (Entwurf B) ---------- */ +.dash-zonen { grid-template-columns: minmax(0, 1fr) 340px; } +.dash-proj { margin-bottom: 10px; } +.dash-proj-kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap; padding: 12px 16px 4px; } +.dash-proj-kopf { align-items: center; padding-bottom: 10px; } +.dash-proj.ist-zu .dash-proj-kopf { padding-top: 10px; } +.dash-proj-toggle { flex: 1 1 260px; min-width: 0; display: flex; align-items: baseline; gap: 0; padding: 0; border: 0; background: none; color: var(--text); font: inherit; text-align: left; cursor: pointer; } +.dash-proj-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--r-sm); } +.dash-proj-titel { font-weight: 650; font-size: 14px; } +/* Die allgemeine Knopfregel (button:hover → Akzentfläche, :active → + Stauchen) ist spezifischer als eine einzelne Klasse und färbte den + Projektkopf beim Zeigen blau ein — der Titel verschwand darin. Diese + Knöpfe sind Textschalter, keine Aktionsknöpfe: Sie bekommen eine eigene, + ruhige Rückmeldung (Kopfzeile hellt auf, Pfeil und Titel in Akzentfarbe). */ +button.dash-proj-toggle, button.dash-proj-toggle:hover, button.dash-proj-toggle:active, +button.dash-proj-alle, button.dash-proj-alle:hover, button.dash-proj-alle:active { + background: none; transform: none; box-shadow: none; +} +button.dash-kal-nav:hover { background: var(--surface-sunk); } +button.dash-kal-nav:active, button.dash-kal-tag:active { transform: none; } +.dash-proj-kopf, .dash-proj-sub { transition: background var(--dur, 150ms) var(--ease-out, ease); } +.dash-proj:has(.dash-proj-toggle:hover) .dash-proj-kopf, +.dash-proj:has(.dash-proj-toggle:hover) .dash-proj-sub { background: color-mix(in srgb, var(--accent) 4%, var(--surface)); } +.dash-proj-toggle:hover .dash-proj-titel, +.dash-proj-toggle:hover .dash-proj-chev { color: var(--accent); } +button.dash-proj-alle:hover { text-decoration: underline; } +.dash-proj-chev { display: inline-block; width: 16px; margin-right: 6px; font-size: 17px; line-height: 1; text-align: center; color: var(--text-subtle); transform: rotate(90deg); transition: transform var(--dur, 150ms) var(--ease-out, ease); } +.dash-proj.ist-zu .dash-proj-chev { transform: rotate(0deg); } +.dash-proj.ist-zu .dash-proj-inhalt { display: none; } +.dash-proj-kurz { display: none; gap: 6px; } +.dash-proj.ist-zu .dash-proj-kurz { display: flex; } +.dash-proj-zahl { font-size: 10.5px; padding: 1px 7px; border-radius: var(--r-pill); background: var(--surface-sunk); color: var(--text-muted); white-space: nowrap; } +.dash-proj-zahl.ist-rot { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); font-weight: 600; } +.dash-proj-oeffnen { font-size: 11.5px; color: var(--accent); text-decoration: none; } +.dash-proj-oeffnen:hover { text-decoration: underline; } +.dash-proj-alle { margin-left: auto; padding: 2px 4px; border: 0; background: none; color: var(--accent); font: inherit; font-size: 11.5px; cursor: pointer; } +@media (prefers-reduced-motion: reduce) { .dash-proj-chev { transition: none; } } +.dash-proj-nr { font-family: ui-monospace, 'SF Mono', monospace; font-size: 11px; color: var(--text-subtle); font-weight: 500; margin-right: 8px; } +.dash-rollen { display: flex; gap: 4px; flex-wrap: wrap; } +.dash-rolle { font-size: 10.5px; font-weight: 600; padding: 1px 8px; border-radius: var(--r-pill); background: var(--accent-soft); color: var(--accent); } +.dash-rolle.ist-neben { background: transparent; border: 1px dashed var(--line); color: var(--text-muted); } +.dash-proj-sub { padding: 2px 16px 8px; } +.dash-proj-phase { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--text-muted); max-width: 320px; } +.dash-proj-phase .dash-auf-bar i { background: var(--green); } +.dash-proj-phase.is-leer { color: var(--text-subtle); } +.dash-proj-termine { border-top: 1px solid var(--line-soft); } +.dash-proj-mehr { display: block; padding: 8px 16px; font-size: 11.5px; color: var(--accent); text-decoration: none; border-top: 1px solid var(--line-soft); } +.dash-termin { display: flex; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid var(--line-soft); color: inherit; text-decoration: none; } +.dash-termin:last-child { border-bottom: none; } +a.dash-termin:hover { background: var(--surface-sunk); } +.dash-termin-art { flex: 0 0 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; font-size: 11px; font-weight: 700; background: var(--surface-sunk); color: var(--text-muted); } +.dash-termin-art.ist-meins { background: var(--accent-soft); color: var(--accent); } +.dash-termin-art.ist-ms { background: color-mix(in srgb, var(--meilenstein) 14%, transparent); color: var(--meilenstein); } +.dash-termin-text { flex: 1; min-width: 0; } +.dash-termin-titel { display: block; font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dash-termin-meta { display: block; font-size: 11px; color: var(--text-subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dash-termin-datum { flex: 0 0 auto; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; } +.dash-termin-datum em { display: block; font-style: normal; font-size: 10.5px; color: var(--text-subtle); } +.dash-termin-datum.ist-ueberfaellig, .dash-termin-datum.ist-ueberfaellig em { color: var(--red); } +.dash-termin-datum.ist-bald { color: var(--amber); } +.dash-kal { padding: 12px 14px 10px; } +.dash-kal-kopf { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.dash-kal-kopf b { font-size: 13.5px; } +.dash-kal-nav { width: 26px; height: 26px; padding: 0; border: 1px solid var(--line); background: var(--surface); color: var(--text); border-radius: var(--r-sm); cursor: pointer; } +.dash-kal-raster { display: grid; grid-template-columns: 26px repeat(7, 1fr); gap: 2px; font-variant-numeric: tabular-nums; } +.dash-kal-wt { font-size: 10.5px; color: var(--text-subtle); text-align: center; padding-bottom: 4px; } +.dash-kal-kw { font-size: 10px; color: var(--text-subtle); display: grid; place-items: center; } +.dash-kal-tag { position: relative; aspect-ratio: 1; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; } +.dash-kal-tag:hover { background: var(--surface-sunk); } +.dash-kal-tag.ist-fremd, .dash-kal-tag.ist-frei { color: var(--text-subtle); } +.dash-kal-tag.ist-heute { color: var(--accent); font-weight: 700; box-shadow: inset 0 0 0 1.5px var(--accent); } +.dash-kal-tag.ist-gewaehlt { background: var(--accent); color: var(--surface); } +.dash-kal-tag:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.dash-kal-punkte { display: flex; gap: 2px; height: 5px; } +.dash-kal-punkte i, .dash-kal-legende i { width: 5px; height: 5px; border-radius: 50%; display: inline-block; background: var(--accent); } +.dash-kal-legende i { width: 7px; height: 7px; } +i.ist-fremd { background: color-mix(in srgb, var(--accent) 45%, transparent); } +i.ist-ms { background: var(--meilenstein); } +i.ist-rot { background: var(--red); } +i.ist-ab { background: var(--text-subtle); } +.dash-kal-tag.ist-gewaehlt .dash-kal-punkte i { background: currentColor; } +.dash-kal-legende { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 8px; font-size: 10.5px; color: var(--text-muted); } +.dash-kal-legende span { display: flex; align-items: center; gap: 5px; } +.dash-kal-liste { border-top: 1px solid var(--line-soft); } +.dash-kal-liste-titel { padding: 10px 16px 4px; font-size: 11.5px; font-weight: 600; color: var(--text-muted); } +.dash-kal-hinweis { padding: 2px 16px 6px; font-size: 11.5px; color: var(--amber); } +.dash-kal-liste .dash-termin { padding: 7px 16px; } + +/* Griff zum Ändern der Spaltenbreite im GANTT (js/gantt.js). Ein breiter, + unsichtbarer Greifbereich über der Spaltengrenze; sichtbar wird er erst + beim Zeigen und Ziehen, wie die Spaltengrenze in Excel. */ +.gantt-spalten-griff { + position: absolute; top: 0; right: -4px; width: 9px; height: 100%; + cursor: col-resize; z-index: 20; touch-action: none; +} +.gantt-spalten-griff::after { + content: ""; position: absolute; top: 3px; bottom: 3px; left: 3px; width: 3px; + border-radius: 2px; background: var(--accent); opacity: 0; transition: opacity .15s; +} +.gantt-spalten-griff:hover::after, +.gantt-spalte-zieht .gantt-spalten-griff::after { opacity: .7; } +.gantt-spalte-zieht, .gantt-spalte-zieht * { cursor: col-resize !important; user-select: none !important; } + +/* ---------- Glocke: Benachrichtigungen (js/benachrichtigungen.js) ---------- */ +.glocke { position: relative; } +#glockeBtn { position: relative; } +#glockeBtn.hat-neue { color: var(--accent); } +.glocke-zahl { + position: absolute; top: -3px; right: -3px; min-width: 17px; height: 17px; padding: 0 4px; + border-radius: 999px; background: var(--red); color: #fff; font-size: 10px; font-weight: 700; + line-height: 17px; text-align: center; box-shadow: 0 0 0 2px var(--bg); +} +.glocke-menue { + position: absolute; right: 0; top: calc(100% + 8px); z-index: 260; + width: min(360px, calc(100vw - 32px)); max-height: min(520px, 70vh); overflow: auto; + background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--r-lg); + box-shadow: var(--sh-3); +} +.glocke-kopf { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px 8px; position: sticky; top: 0; background: inherit; } +.glocke-kopf b { font-size: 13px; } +.glocke-alle { padding: 2px 4px; border: 0; background: none !important; color: var(--accent); font: inherit; font-size: 11.5px; cursor: pointer; box-shadow: none !important; } +.glocke-alle:hover { text-decoration: underline; } +.glocke-leer { padding: 10px 14px 16px; font-size: 12.5px; color: var(--text-subtle); line-height: 1.5; } +.glocke-eintrag { + display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 10px 14px; + border: 0; border-top: 1px solid var(--line-soft); border-radius: 0; background: none; color: var(--text); + font: inherit; text-align: left; cursor: pointer; box-shadow: none; +} +.glocke-eintrag:hover { background: var(--surface-sunk); } +.glocke-eintrag:active { transform: none; } +.glocke-punkt { flex: 0 0 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: transparent; } +.glocke-eintrag.ist-neu .glocke-punkt { background: var(--accent); } +.glocke-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.glocke-titel { font-size: 13px; font-weight: 500; } +.glocke-eintrag.ist-neu .glocke-titel { font-weight: 650; } +.glocke-meta { font-size: 11px; color: var(--text-subtle); } +.dash-neu .glocke-eintrag:first-child { border-top: 0; } +#dashNeu:empty { display: none; } +/* Löschen je Benachrichtigung: Zeile = Öffnen-Knopf + ×. */ +.glocke-zeile { display: flex; align-items: stretch; border-top: 1px solid var(--line-soft); } +.glocke-zeile .glocke-eintrag { border-top: 0; flex: 1 1 auto; min-width: 0; } +.dash-neu .glocke-zeile:first-child { border-top: 0; } +.glocke-aktionen { display: flex; gap: 6px; } +.glocke-loeschen { + flex: 0 0 34px; padding: 0; border: 0; border-radius: 0; background: none !important; box-shadow: none !important; + color: var(--text-subtle); font-size: 18px; line-height: 1; cursor: pointer; opacity: .55; +} +.glocke-zeile:hover .glocke-loeschen, .glocke-loeschen:focus-visible { opacity: 1; } +.glocke-loeschen:hover { color: var(--red); } +@media (hover: none) { .glocke-loeschen { opacity: 1; } } + +/* =========================================================================== + WANDTAFEL IN GLAS (js/wand/wand.js, wt*-Funktionen) — Eriks Wahl aus dem + Entwurf: derselbe Hintergrund wie die Glas-App (Lichtflächen in den + Logo-Blautönen + Logo als Wasserzeichen), Felder halbdeckend OHNE + backdrop-filter (Kiosk-Rechner), Schrift Outfit für Ziffern und + Überschriften. Größen in Container-Einheiten (cqw von #wandContent), damit + die Tafel auf jedem Bildschirm gleich aufgeteilt ist. + =========================================================================== */ +body.wandtafel-aktiv { + --wt-tief: #0f5a85; --wt-hell: #94b0cc; --wt-grund: #e8eef3; --wt-mint: #7fc3b6; + --wt-tinte: #14212b; --wt-tinte-2: #4b5d6b; --wt-tinte-3: #6f8291; + --wt-gruen: #1f8a4c; --wt-gelb: #b4780a; --wt-rot: #c8242f; --wt-ms: #6d4bb3; + --wt-feld: rgba(255,255,255,.68); --wt-feld-2: rgba(255,255,255,.46); + --wt-kante: rgba(255,255,255,.85); --wt-kante-2: rgba(255,255,255,.4); + --wt-linie: rgba(20,45,65,.09); + --wt-schatten: 0 calc(1.2 * var(--u)) calc(3.4 * var(--u)) rgba(21,58,86,.12), 0 calc(.2 * var(--u)) calc(.6 * var(--u)) rgba(21,58,86,.06); + --wt-logo: .17; + --wt-zahl: 'Outfit', -apple-system, 'SF Pro Display', system-ui, sans-serif; +} +@media (prefers-color-scheme: dark) { + html:not([data-theme="light"]) body.wandtafel-aktiv { + --wt-tief: #8cc9e6; --wt-hell: #2c5675; --wt-grund: #0b141b; --wt-mint: #2f6b62; + --wt-tinte: #e8f0f5; --wt-tinte-2: #a9bccb; --wt-tinte-3: #7790a2; + --wt-gruen: #5fd394; --wt-gelb: #f0c05e; --wt-rot: #ff7b72; --wt-ms: #c3a6ff; + --wt-feld: rgba(26,40,52,.72); --wt-feld-2: rgba(30,46,60,.5); + --wt-kante: rgba(255,255,255,.16); --wt-kante-2: rgba(255,255,255,.06); --wt-linie: rgba(255,255,255,.08); + --wt-schatten: 0 calc(1.2 * var(--u)) calc(3.4 * var(--u)) rgba(0,0,0,.4), 0 calc(.2 * var(--u)) calc(.6 * var(--u)) rgba(0,0,0,.3); + --wt-logo: .10; + } +} +html[data-theme="dark"] body.wandtafel-aktiv { + --wt-tief: #8cc9e6; --wt-hell: #2c5675; --wt-grund: #0b141b; --wt-mint: #2f6b62; + --wt-tinte: #e8f0f5; --wt-tinte-2: #a9bccb; --wt-tinte-3: #7790a2; + --wt-gruen: #5fd394; --wt-gelb: #f0c05e; --wt-rot: #ff7b72; --wt-ms: #c3a6ff; + --wt-feld: rgba(26,40,52,.72); --wt-feld-2: rgba(30,46,60,.5); + --wt-kante: rgba(255,255,255,.16); --wt-kante-2: rgba(255,255,255,.06); --wt-linie: rgba(255,255,255,.08); + --wt-schatten: 0 calc(1.2 * var(--u)) calc(3.4 * var(--u)) rgba(0,0,0,.4), 0 calc(.2 * var(--u)) calc(.6 * var(--u)) rgba(0,0,0,.3); + --wt-logo: .10; +} +/* Hintergrund: wie die Glas-App, auch wenn die Glas-Darstellung abgeschaltet ist. */ +body.wandtafel-aktiv .container { + position: relative; isolation: isolate; + background: + radial-gradient(52% 48% at 8% 4%, color-mix(in srgb, var(--wt-hell) 72%, transparent), transparent 70%), + radial-gradient(42% 46% at 94% 10%, color-mix(in srgb, var(--wt-tief) 26%, transparent), transparent 70%), + radial-gradient(48% 55% at 72% 98%, color-mix(in srgb, var(--wt-mint) 30%, transparent), transparent 70%), + radial-gradient(38% 40% at 28% 84%, color-mix(in srgb, var(--wt-hell) 44%, transparent), transparent 70%), + var(--wt-grund); +} +body.wandtafel-aktiv .container::before { + content: ""; position: absolute; z-index: -1; left: -3vw; bottom: -11vw; width: 62vw; aspect-ratio: 256 / 216; + background: url("/logo.svg") no-repeat center / contain; opacity: var(--wt-logo); pointer-events: none; +} +/* #wandContent ist Abfrage-Container in Breite UND Höhe. Alle Maße der Tafel + laufen über --u: 1 % der Breite, aber nie mehr, als die Höhe bei 16:9 + hergäbe. Auf einem flacheren Fenster (Browserleiste, 16:10-Laptop, + 21:9-Monitor) schrumpft so alles gleichmäßig, statt unten abgeschnitten zu + werden; die Spalten füllen die Breite trotzdem über fr-Einheiten. */ +body.wandtafel-aktiv .wand-grid-wrap { background: transparent; container-type: size; } +body.wandtafel-aktiv .wand-grid-wrap > * { --u: min(1cqw, 1.7778cqh); } +body.wandtafel-aktiv .wand-kopfzeile { padding-bottom: 6px; } + +/* Genau einen Bildschirm hoch (nicht nur mindestens): Die Felder teilen + sich die Höhe, statt den Fuß unter den Rand zu schieben. Die Projektliste + folgt darunter und wird gewischt. */ +.wand-ambient.wt { height: 100%; min-height: 0; color: var(--wt-tinte); font-size: calc(1.05 * var(--u)); gap: calc(1.2 * var(--u)); padding-bottom: calc(.4 * var(--u)); } +.wt-gewerke { overflow: hidden; } +.wt-feld { background: var(--wt-feld); border: 1px solid var(--wt-kante-2); border-radius: calc(1.5 * var(--u)); + box-shadow: var(--wt-schatten), inset 0 1px 0 var(--wt-kante); } +.wt .ist-klickbar { cursor: pointer; } + +.wt-kopf { flex-shrink: 0; display: flex; align-items: center; gap: calc(1.6 * var(--u)); padding: calc(.9 * var(--u)) calc(1.4 * var(--u)); } +.wt-marke { display: flex; align-items: center; gap: calc(.8 * var(--u)); } +.wt-marke img { width: calc(3.4 * var(--u)); height: auto; } +.wt-marke b { font-family: var(--wt-zahl); font-weight: 500; font-size: calc(2 * var(--u)); letter-spacing: -.01em; } +.wt-sitzung { font-size: calc(1 * var(--u)); font-weight: 650; padding: calc(.3 * var(--u)) calc(.9 * var(--u)); border-radius: calc(99 * var(--u)); background: color-mix(in srgb, var(--wt-tief) 12%, transparent); color: var(--wt-tief); } +.wt-abw { display: flex; align-items: center; gap: calc(.5 * var(--u)); margin-left: auto; font-size: calc(1 * var(--u)); color: var(--wt-tinte-2); cursor: pointer; } +.wt-abw.ist-leer { cursor: default; color: var(--wt-tinte-3); } +.wt-ava { width: calc(2.5 * var(--u)); height: calc(2.5 * var(--u)); border-radius: 50%; display: grid; place-items: center; font-size: calc(.85 * var(--u)); font-weight: 700; + background: var(--wt-feld); border: 1px solid var(--wt-kante-2); color: var(--wt-tinte-2); } +.wt-uhr { font-family: var(--wt-zahl); font-weight: 300; font-size: calc(3.4 * var(--u)); letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1; text-align: right; } +.wt-uhr small { display: block; font-family: inherit; font-weight: 400; font-size: calc(.9 * var(--u)); color: var(--wt-tinte-3); letter-spacing: 0; margin-top: calc(.25 * var(--u)); } + +.wt-kpis { flex-shrink: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: calc(1.2 * var(--u)); } +.wt-kpi { padding: calc(1.1 * var(--u)) calc(1.4 * var(--u)); display: flex; align-items: center; gap: calc(1.2 * var(--u)); transition: transform .15s; } +.wt-kpi.ist-klickbar:active { transform: scale(.98); } +.wt-wert { font-family: var(--wt-zahl); font-weight: 500; font-size: calc(4.2 * var(--u)); line-height: 1; font-variant-numeric: tabular-nums; letter-spacing: -.02em; } +.wt-wert small { font-size: calc(2 * var(--u)); margin-left: calc(.15 * var(--u)); } +.wt-lab { font-size: calc(1.05 * var(--u)); color: var(--wt-tinte-2); line-height: 1.35; } +.wt-lab b { display: block; color: var(--wt-tinte); font-size: calc(1.15 * var(--u)); } +.wt-kpi.ist-rot .wt-wert { color: var(--wt-rot); } +.wt-ring { flex: none; width: calc(4.4 * var(--u)); height: calc(4.4 * var(--u)); border-radius: 50%; + background: conic-gradient(var(--c) calc(var(--p) * 1%), color-mix(in srgb, var(--wt-tinte-3) 20%, transparent) 0); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - calc(.55 * var(--u))), #000 calc(100% - calc(.5 * var(--u)))); + mask: radial-gradient(farthest-side, transparent calc(100% - calc(.55 * var(--u))), #000 calc(100% - calc(.5 * var(--u)))); } + +.wt-felder { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 2.08fr); gap: calc(1.2 * var(--u)); } +.wt-panel { padding: calc(1.1 * var(--u)) calc(1.3 * var(--u)); display: flex; flex-direction: column; min-height: 0; } +.wt-panel h2 { margin: 0 0 calc(.9 * var(--u)); font-family: var(--wt-zahl); font-size: calc(1.45 * var(--u)); font-weight: 500; letter-spacing: -.005em; color: var(--wt-tinte); + display: flex; align-items: baseline; gap: calc(.6 * var(--u)); } +.wt-panel h2 span { margin-left: auto; font-family: inherit; font-size: calc(.95 * var(--u)); font-weight: 400; color: var(--wt-tinte-3); } + +.wt-gw { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: calc(5 * var(--u)) minmax(0, 1fr); gap: calc(1 * var(--u)); align-items: center; padding: calc(.9 * var(--u)) 0; border-top: 1px solid var(--wt-linie); } +.wt-gw:first-of-type { border-top: 0; } +.wt-gew { font-family: var(--wt-zahl); font-weight: 500; font-size: calc(1.7 * var(--u)); } +.wt-gew small { display: block; font-family: inherit; font-weight: 400; font-size: calc(.85 * var(--u)); color: var(--wt-tinte-3); } +.wt-gw-werte { display: flex; flex-direction: column; gap: calc(.5 * var(--u)); min-width: 0; } +.wt-gw-zeile { display: grid; grid-template-columns: calc(6.6 * var(--u)) minmax(0, 1fr) calc(4.6 * var(--u)); align-items: center; gap: calc(.8 * var(--u)); padding: calc(.25 * var(--u)) calc(.4 * var(--u)); margin: 0 -calc(.4 * var(--u)); border-radius: calc(.8 * var(--u)); } +.wt-gw-zeile:hover { background: color-mix(in srgb, var(--wt-tief) 7%, transparent); } +.wt-gw-lab { font-size: calc(.92 * var(--u)); color: var(--wt-tinte-3); } +.wt-balken { height: calc(1.1 * var(--u)); border-radius: calc(99 * var(--u)); background: color-mix(in srgb, var(--wt-tinte-3) 16%, transparent); overflow: hidden; display: block; } +.wt-balken i { display: block; height: 100%; border-radius: inherit; } +.wt-zahl { font-family: var(--wt-zahl); font-weight: 500; font-size: calc(1.6 * var(--u)); text-align: right; font-variant-numeric: tabular-nums; } +.wt-zahl.klein { font-size: calc(1.25 * var(--u)); color: var(--wt-tinte-2); } +/* Besprochen-Stand: immer EINE Reihe. Bei wenigen Projekten Kästchen, bei + vielen werden sie schmaler und bilden einen Segmentbalken (ein Segment je + Projekt) — umbrechende Kästchen liefen sonst bei ELT (25 Projekte) über + die Trennlinie zum nächsten Gewerk. */ +.wt-punkte { display: flex; flex-wrap: nowrap; gap: calc(.22 * var(--u)); min-width: 0; } +.wt-punkte i { flex: 1 1 0; min-width: 2px; max-width: calc(.95 * var(--u)); height: calc(.95 * var(--u)); + border-radius: calc(.25 * var(--u)); background: color-mix(in srgb, var(--wt-tinte-3) 22%, transparent); } +.wt-punkte i.ja { background: var(--wt-gruen); } +.wt-leer { font-size: calc(.95 * var(--u)); color: var(--wt-tinte-3); } + +.wt-tage-raster { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: calc(.8 * var(--u)); } +.wt-tag { display: flex; flex-direction: column; gap: calc(.55 * var(--u)); min-height: 0; padding: calc(.8 * var(--u)); border-radius: calc(1.1 * var(--u)); overflow: hidden; + background: color-mix(in srgb, var(--wt-feld) 55%, transparent); border: 1px solid var(--wt-kante-2); } +.wt-tag.ist-heute { background: color-mix(in srgb, var(--wt-tief) 9%, var(--wt-feld)); border-color: color-mix(in srgb, var(--wt-tief) 30%, transparent); } +.wt-tag-kopf { display: flex; align-items: baseline; gap: calc(.55 * var(--u)); padding-bottom: calc(.45 * var(--u)); border-bottom: 1px solid var(--wt-linie); } +.wt-tag-kopf b { font-family: var(--wt-zahl); font-weight: 500; font-size: calc(1.75 * var(--u)); letter-spacing: -.01em; } +.wt-tag-kopf span { font-size: calc(.95 * var(--u)); color: var(--wt-tinte-3); } +.wt-tag.ist-heute .wt-tag-kopf b { color: var(--wt-tief); } +.wt-ein { display: grid; grid-template-columns: calc(.35 * var(--u)) minmax(0, 1fr); gap: calc(.6 * var(--u)); font-size: calc(1 * var(--u)); line-height: 1.3; border-radius: calc(.5 * var(--u)); } +.wt-ein.ist-klickbar:hover { background: color-mix(in srgb, var(--wt-tief) 6%, transparent); } +.wt-ein > i { border-radius: calc(99 * var(--u)); background: var(--c); } +.wt-ein > div { min-width: 0; } +.wt-ein b { font-weight: 600; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.wt-ein span { display: block; color: var(--wt-tinte-3); font-size: calc(.88 * var(--u)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wt-ein.wt-ab b { font-weight: 500; color: var(--wt-tinte-2); } +.wt-ueber { --c: var(--wt-rot); } .wt-aufgabe { --c: var(--wt-tief); } .wt-ms { --c: var(--wt-ms); } +.wt-kommentar { --c: var(--wt-gruen); } .wt-ab { --c: var(--wt-tinte-3); } +.wt-gruppe { font-size: calc(.78 * var(--u)); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--c); margin-top: calc(.15 * var(--u)); } +.wt-tag-liste { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; gap: calc(.55 * var(--u)); } +.wt-mehr { flex-shrink: 0; font-size: calc(.9 * var(--u)); color: var(--wt-tief); font-weight: 600; cursor: pointer; } +.wt-legende { display: flex; flex-wrap: wrap; gap: calc(.4 * var(--u)) calc(1.4 * var(--u)); margin-top: calc(.8 * var(--u)); font-size: calc(.9 * var(--u)); color: var(--wt-tinte-2); } +.wt-legende span { display: flex; align-items: center; gap: calc(.45 * var(--u)); } +.wt-legende i { width: calc(.9 * var(--u)); height: calc(.9 * var(--u)); border-radius: calc(.3 * var(--u)); background: var(--c); } + +.wt-fuss { flex-shrink: 0; display: flex; align-items: center; gap: calc(1 * var(--u)); padding: calc(.6 * var(--u)) calc(1.4 * var(--u)); font-size: calc(1.05 * var(--u)); color: var(--wt-tinte-2); } +.wt-pfeil { font-family: var(--wt-zahl); font-weight: 300; font-size: calc(1.7 * var(--u)); color: var(--wt-tief); } +@media (prefers-reduced-motion: reduce) { .wt-kpi { transition: none; } } + +/* Hinweis nach dem Zusammenführen gleichzeitiger Änderungen (js/store.js, + zeigeHinweis()) — kein Fehler, deshalb nicht rot wie .sync-problem-bar. */ +.sync-problem-bar.sync-hinweis { background: var(--accent); color: #fff; bottom: 70px; } + +/* =========================================================================== + DUNKELMODUS NACHGESCHÄRFT (Eriks Wahl „A“ aus dem Kontrast-Vergleich): + Kalender auf „Meine Aufgaben“ und die Farben im Sitzungskommentar. + Alles über Tokens: :root = hell (unverändert bzw. gleichwertig), die + beiden Dunkel-Blöcke (System-Einstellung / ausdrücklich gewählt) setzen + die helleren Fassungen. + =========================================================================== */ +:root { + /* „Meine Aufgabe“ und „Projektaufgabe“ waren derselbe Blauton (einmal + halbtransparent) — jetzt zwei klar verschiedene Farben. */ + --kal-meine: var(--accent); + --kal-projekt: #138a6e; + --kal-abw: var(--text-subtle); + --kal-wt: var(--text-subtle); + --kal-frei: var(--text-subtle); + --kal-fremd: var(--text-subtle); + --kal-kw: var(--text-subtle); + --kal-legende: var(--text-muted); + --kal-punkt: 5px; + /* Farben der Kommentar-Werkzeugleiste (gespeichert als feste Werte, + siehe richtextToolbarHtml() in js/utils.js). */ + --rt-rot: #d70015; + --rt-gruen: #248a3d; + --rt-blau: #0071e3; + --rt-orange: #b25000; +} +@media (prefers-color-scheme: dark) { + html:not([data-theme="light"]) { + --meilenstein: #c19cf0; + --kal-projekt: #6fd6b4; + --kal-abw: #b4c3cf; + --kal-wt: #93aabb; + --kal-frei: #93aabb; + --kal-fremd: #6d8597; + --kal-kw: #8299aa; + --kal-legende: #b8c8d4; + --kal-punkt: 6px; + --rt-rot: #ff8a7f; + --rt-gruen: #5fd38d; + --rt-blau: #7cc0ff; + --rt-orange: #f2a65a; + --kommentar-ansicht-bg: #1b2730; + } +} +html[data-theme="dark"] { + --meilenstein: #c19cf0; /* fehlte hier bisher: blieb das dunkle Lila */ + --kal-projekt: #6fd6b4; + --kal-abw: #b4c3cf; + --kal-wt: #93aabb; + --kal-frei: #93aabb; + --kal-fremd: #6d8597; + --kal-kw: #8299aa; + --kal-legende: #b8c8d4; + --kal-punkt: 6px; + --rt-rot: #ff8a7f; + --rt-gruen: #5fd38d; + --rt-blau: #7cc0ff; + --rt-orange: #f2a65a; + --kommentar-ansicht-bg: #1b2730; +} + +/* Kalender */ +.dash-kal-wt { color: var(--kal-wt); } +.dash-kal-kw { color: var(--kal-kw); } +.dash-kal-tag.ist-frei { color: var(--kal-frei); } +.dash-kal-tag.ist-fremd { color: var(--kal-fremd); } +.dash-kal-legende { color: var(--kal-legende); } +.dash-kal-punkte { height: var(--kal-punkt); } +.dash-kal-punkte i { width: var(--kal-punkt); height: var(--kal-punkt); background: var(--kal-meine); } +.dash-kal-legende i { background: var(--kal-meine); } +.dash-kal-punkte i.ist-fremd, .dash-kal-legende i.ist-fremd { background: var(--kal-projekt); } +.dash-kal-punkte i.ist-ms, .dash-kal-legende i.ist-ms { background: var(--meilenstein); } +.dash-kal-punkte i.ist-rot, .dash-kal-legende i.ist-rot { background: var(--red); } +.dash-kal-punkte i.ist-ab, .dash-kal-legende i.ist-ab { background: var(--kal-abw); } +.dash-kal-tag.ist-gewaehlt .dash-kal-punkte i { background: currentColor; } + +/* Kommentar als Text (Projekt-Dashboard, Projektliste, Wochenbericht + gesperrt): heller Fließtext auf satter Fläche. */ +.status-edit.is-ansicht { color: var(--text); background: var(--kommentar-ansicht-bg, var(--surface-sunk)); } + +/* Farbige Stellen im Kommentar: Die Farbe steht als Inline-Stil im Text + (mal "#d70015", mal vom Browser als "rgb(215, 0, 21)" geschrieben, alt + auch ). Im Dunkelmodus auf die helle Fassung umsetzen; im + hellen Modus sind die Tokens identisch mit den gespeicherten Werten. + :not([style*="background"]) hält Farbflächen (Werkzeugleiste, Gantt) + heraus. Gespeichert wird weiterhin der Originalwert, das PDF bleibt gleich. */ +[style*="color"][style*="d70015" i]:not([style*="background"]), +[style*="color"][style*="215, 0, 21"]:not([style*="background"]), +font[color="#d70015" i] { color: var(--rt-rot) !important; } +[style*="color"][style*="248a3d" i]:not([style*="background"]), +[style*="color"][style*="36, 138, 61"]:not([style*="background"]), +font[color="#248a3d" i] { color: var(--rt-gruen) !important; } +[style*="color"][style*="0071e3" i]:not([style*="background"]), +[style*="color"][style*="0, 113, 227"]:not([style*="background"]), +font[color="#0071e3" i] { color: var(--rt-blau) !important; } +[style*="color"][style*="b25000" i]:not([style*="background"]), +[style*="color"][style*="178, 80, 0"]:not([style*="background"]), +font[color="#b25000" i] { color: var(--rt-orange) !important; } + +/* Auslastung als Personenkarten mit Wochenvorschau (Eriks Wahl "B", + renderLoad() in js/load/load.js). Je Person: Projektanzahl, Wert dieser + Woche, zwölf Wochensäulen mit gestrichelter 100-%-Linie, Hinweis auf die + Spitze und die größten Projekte. */ +.load-erklaerung { margin: 4px 0 14px; max-width: 900px; line-height: 1.5; } +.load-karten { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; } +.load-karte { + background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--r-lg); + padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; min-width: 0; +} +.load-karte.ist-ohne { opacity: .75; } +.load-karte-kopf { display: flex; align-items: center; gap: 12px; } +.load-ava { + width: 36px; height: 36px; flex: none; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; + font-size: 11.5px; font-weight: 700; background: var(--accent); color: #fff; +} +.load-karte-wer { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } +.load-karte-wer b { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.load-karte-wer > span { font-size: 12px; color: var(--text-muted); } +.load-karte-wer .load-capacity { align-self: flex-start; margin: 2px 0 0; } +.load-jetzt { display: flex; flex-direction: column; align-items: flex-end; text-align: right; flex: none; } +.load-jetzt b { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; } +.load-jetzt span { font-size: 11px; color: var(--text-muted); } +.load-saeulen { + position: relative; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 4px; + align-items: end; height: 86px; padding-top: 4px; +} +.load-saeule { display: block; border-radius: 4px 4px 2px 2px; opacity: .85; min-height: 0; } +.load-saeule.ist-jetzt { opacity: 1; } +.load-saeule.ist-teil { background-image: repeating-linear-gradient(135deg, transparent 0 4px, rgba(255,255,255,.35) 4px 7px); } +.load-saeule.ist-abwesend { height: 100%; opacity: 1; background: repeating-linear-gradient(135deg, var(--surface-sunk) 0 5px, var(--line) 5px 10px); } +.load-linie { position: absolute; left: 0; right: 0; border-top: 1.5px dashed var(--text-muted); opacity: .55; pointer-events: none; } +.load-kws { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 4px; font-size: 10px; color: var(--text-muted); text-align: center; font-variant-numeric: tabular-nums; } +.load-kws .ist-jetzt { color: var(--accent); font-weight: 700; } +.load-hinweis { font-size: 12.5px; padding: 8px 10px; border-radius: 10px; line-height: 1.4; } +.load-hinweis.ist-rot { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); } +.load-hinweis.ist-gelb { background: color-mix(in srgb, var(--amber) 12%, transparent); color: var(--amber); } +.load-hinweis.ist-ok { background: color-mix(in srgb, var(--green) 10%, transparent); color: var(--green); } +.load-hinweis.ist-leer { background: var(--surface-sunk); color: var(--text-muted); } +.load-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.load-chip { + display: inline-flex; align-items: center; min-height: 24px; max-width: 100%; padding: 0 9px; border-radius: 999px; + background: var(--surface-sunk); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.load-chip.ist-mehr { color: var(--text-muted); border: 0; font: inherit; font-size: 11.5px; cursor: pointer; } +.load-chip.ist-mehr:hover { color: var(--accent); } +.load-chips:not(.ist-offen) .load-chip.ist-weitere { display: none; } +@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .load-ava { color: #0b141b; } } +html[data-theme="dark"] .load-ava { color: #0b141b; } + +/* ========================================================================= + ROLLEN & RECHTE (Admin, js/admin/rollen.js) — Eriks Wahl „B“: links die + Rollen mit „+ Neue Rolle“ darunter, rechts die gewählte Rolle mit Schaltern + je Gruppe. + ========================================================================= */ +.rr-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 16px; align-items: start; } +@media (max-width: 820px) { .rr-layout { grid-template-columns: 1fr; } } +.rr-rollen { display: flex; flex-direction: column; gap: 6px; } +.rr-rolle { + display: flex; flex-direction: column; gap: 2px; text-align: left; width: 100%; + border: 1px solid var(--line); background: var(--surface); color: var(--text); + border-radius: 12px; padding: 10px 12px; font: inherit; cursor: pointer; +} +.rr-rolle:hover { border-color: var(--accent); } +.rr-rolle.ist-aktiv { border-color: var(--accent); background: var(--accent-soft); } +.rr-rolle b { font-weight: 600; } +.rr-rolle small { color: var(--text-muted); font-size: 12px; } +.rr-balken { display: block; height: 4px; border-radius: 2px; background: var(--surface-sunk); overflow: hidden; margin-top: 4px; } +.rr-balken i { display: block; height: 100%; background: var(--accent); } +.rr-plus { + border: 1.5px dashed var(--line) !important; background: transparent; color: var(--accent); + border-radius: 12px; padding: 10px 12px; text-align: left; font: inherit; font-weight: 600; cursor: pointer; width: 100%; +} +.rr-plus:hover { border-color: var(--accent) !important; background: var(--accent-soft); } +.rr-neu { border: 1.5px solid var(--accent); border-radius: 12px; padding: 10px; display: flex; flex-direction: column; gap: 8px; background: var(--surface); } +.rr-neu input, .rr-neu select { width: 100%; box-sizing: border-box; } +.rr-neu label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-muted); } +.rr-neu-knoepfe { display: flex; gap: 6px; } +.rr-detail { border: 1px solid var(--line); border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 14px; min-width: 0; } +.rr-detail-kopf { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.rr-titel { + font: inherit; font-size: 18px; font-weight: 600; flex: 1 1 180px; min-width: 0; + border: 1px solid transparent !important; background: transparent !important; color: var(--text); border-radius: 8px; padding: 4px 8px; +} +.rr-titel:hover:not(:disabled), .rr-titel:focus { border-color: var(--line) !important; background: var(--surface-sunk) !important; } +.rr-titel:disabled { color: var(--text); opacity: 1; } +.rr-vergleich-wahl { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-muted); } +.rr-meldung { font-size: 12.5px; color: var(--text-muted); min-width: 80px; } +.rr-gruppen { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; align-items: start; } +.rr-gruppe { background: var(--surface-sunk); border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 4px; } +.rr-gruppe-kopf { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.rr-gruppe-kopf small { color: var(--text-muted); } +.rr-luecke { flex: 1; } +.rr-zeile { display: flex; align-items: center; gap: 10px; padding: 4px 0; } +.rr-name { flex: 1; min-width: 0; } +.rr-name small { display: block; color: var(--text-muted); font-size: 11.5px; } +.rr-schalter { + width: 36px; height: 20px; border-radius: 999px; border: 0 !important; padding: 0; flex: none; position: relative; cursor: pointer; + background: color-mix(in srgb, var(--text) 22%, transparent); transition: background var(--dur, 150ms); +} +.rr-schalter::after { + content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; + background: #fff; transition: transform var(--dur, 150ms) var(--ease-out, ease); +} +.rr-schalter.ist-an { background: var(--accent); } +.rr-schalter.ist-an::after { transform: translateX(16px); } +.rr-schalter:disabled { opacity: .55; cursor: not-allowed; } +.rr-schalter:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.rr-vergleich { font-size: 11px; color: var(--text-muted); min-width: 34px; text-align: right; } +.rr-vergleich.ist-anders { color: var(--accent); font-weight: 700; } + +/* ========================================================================= + RUNDGANG (js/einfuehrung.js) — abgedunkelte Ebene mit Aussparung um das + Ziel und eine Sprechblase. Liegt über allem (auch über Dialogen, 300), + aber unter der Anmeldeseite (400) bewusst nicht: der Rundgang startet erst + nach der Anmeldung. + ========================================================================= */ +.tour { position: fixed; inset: 0; z-index: 450; } +.tour-loch { + position: fixed; border-radius: 12px; pointer-events: none; + box-shadow: 0 0 0 9999px rgba(6, 14, 20, .62), 0 0 0 2px var(--accent); + transition: left .25s var(--ease-out, ease), top .25s var(--ease-out, ease), width .25s var(--ease-out, ease), height .25s var(--ease-out, ease); +} +.tour.ohne-ziel { background: rgba(6, 14, 20, .62); } +.tour.ohne-ziel .tour-loch { display: none; } +.tour-blase { + position: fixed; width: min(360px, calc(100vw - 24px)); box-sizing: border-box; + background: var(--surface); color: var(--text); border-radius: 16px; padding: 16px 18px; + box-shadow: var(--sh-3); display: flex; flex-direction: column; gap: 8px; outline: none; + transition: left .25s var(--ease-out, ease), top .25s var(--ease-out, ease); +} +html.glas .tour-blase { background: var(--glas-dicht, var(--surface)); -webkit-backdrop-filter: blur(24px) saturate(160%); backdrop-filter: blur(24px) saturate(160%); } +.tour-blase.ist-unten { left: 12px !important; right: 12px; width: auto; top: auto !important; bottom: calc(12px + env(safe-area-inset-bottom, 0px)); } +.tour-kopf { display: flex; align-items: center; justify-content: space-between; } +.tour-schritt { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); font-weight: 600; } +.tour-x { border: 0 !important; background: transparent; color: var(--text-muted); font-size: 20px; line-height: 1; padding: 0 4px; cursor: pointer; } +.tour-x:hover { color: var(--text); } +.tour-blase h3 { margin: 0; font-size: 17px; font-weight: 650; text-wrap: balance; } +.tour-text { font-size: 13.5px; line-height: 1.55; color: var(--text); } +.tour-hinweise { margin: 8px 0 0; padding-left: 18px; color: var(--text-muted); } +.tour-punkte { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 2px; } +.tour-punkte i { width: 6px; height: 6px; border-radius: 50%; background: var(--line); } +.tour-punkte i.ist-erledigt { background: color-mix(in srgb, var(--accent) 45%, transparent); } +.tour-punkte i.ist-aktiv { background: var(--accent); width: 16px; border-radius: 3px; } +.tour-knoepfe { display: flex; align-items: center; gap: 6px; margin-top: 4px; } +.tour-luecke { flex: 1; } +.tour-skip { border: 0 !important; background: transparent; color: var(--text-muted); font: inherit; font-size: 12.5px; padding: 4px 0; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } +.tour-skip:hover { color: var(--text); } +@media (prefers-reduced-motion: reduce) { .tour-loch, .tour-blase { transition: none; } } + +/* ============================================================ + ABWESENHEITEN (js/abwesenheit/abwesenheit.js) — Eriks Wahl + „Variante 2 im Stil der Projektübersicht“: nutzt .bericht-titelzeile, + .legend/.swatch, .board-list, .board-row(-main), .board-nr, .board-name, + .board-status-line, .phase-pill, .board-row-actions und .board-avatar + der Projektliste; hier nur, was es dort nicht gibt. + Artfarben sind mittlere Töne, in beiden Themen lesbar. + ============================================================ */ +:root { + --abw-urlaub: #2f7fb8; + --abw-krank: #c4553d; + --abw-fortbildung: #7a5fc0; + --abw-sonstiges: #7d8450; + --abw-verdeckt: #8d9aa6; +} +/* showTab() setzt display:block inline — Abstände deshalb über Geschwister. */ +#abwesenheit > * + * { margin-top: 14px; } +#abwesenheit .legend { margin: 6px 0 0; } +.abw-u { --abw-c: var(--abw-urlaub); } +.abw-k { --abw-c: var(--abw-krank); } +.abw-f { --abw-c: var(--abw-fortbildung); } +.abw-s { --abw-c: var(--abw-sonstiges); } +.swatch.abw-u, .swatch.abw-k, .swatch.abw-f, .swatch.abw-s { background: var(--abw-c); } +.swatch.abw-verdeckt, .abw-balken.abw-verdeckt { + background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--abw-verdeckt) 50%, transparent) 0 3px, transparent 3px 6px); + box-shadow: inset 0 0 0 1px var(--abw-verdeckt); +} + +/* Zwei Spalten: Neue Abwesenheit | Liste */ +/* Beide Kacheln gleich breit und gleich hoch; die Überschrift steht in der + Kachel. Die Liste rechts bestimmt die Höhe nicht mit (height 0 + + min-height 100 %), sie scrollt innen, wenn die Erfassung kürzer ist. */ +.abw-zwei { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: stretch; } +.abw-spalte { display: flex; flex-direction: column; gap: 10px; min-width: 0; } +.abw-spalte > .board-list { flex: 1; } +.abw-karten-titel { margin: 0 !important; } +.abw-listen-karte { height: 0; min-height: 100%; } +.abw-listen-kopf { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 16px 16px 12px; border-bottom: 1px solid var(--line-soft); } +.abw-listen-zeilen { flex: 1; min-height: 0; overflow-y: auto; } +.abw-karte { padding: 16px; gap: 12px; } +.abw-fuer { display: flex; align-items: center; gap: 10px; font-size: 13px; flex-wrap: wrap; } +.abw-kreis { cursor: default; } +.abw-fuer-wahl { margin-left: auto; border-radius: var(--r-pill); font-size: 12.5px; padding: 4px 10px; max-width: 220px; } +.abw-datumzeile { display: grid; grid-template-columns: 1fr auto 1fr; gap: 10px; align-items: center; } +.abw-datum { + display: grid; text-align: left; gap: 0; padding: 10px 13px; cursor: pointer; + border: 1px solid var(--line-soft); border-radius: 14px; background: var(--surface-alt); color: var(--text); + font: inherit; +} +.abw-datum:hover { background: var(--surface-sunk); } +.abw-datum small { font-size: 10.5px; color: var(--text-subtle); text-transform: uppercase; letter-spacing: .08em; font-weight: 600; } +.abw-datum b { font-size: 21px; font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-weight: 600; letter-spacing: -.01em; } +.abw-datum span { font-size: 12px; color: var(--text-muted); } +.abw-pfeil { color: var(--text-subtle); } +.abw-arten { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } +.abw-arten button { + display: grid; justify-items: center; gap: 5px; padding: 8px 6px; cursor: pointer; font: inherit; font-size: 12.5px; + border: 1px solid var(--line-soft); border-radius: 12px; background: var(--surface-alt); color: var(--text); +} +.abw-arten button i { width: 18px; height: 6px; border-radius: 3px; background: var(--abw-c); } +.abw-arten button.ist-an { background: color-mix(in srgb, var(--abw-c) 18%, transparent); border-color: var(--abw-c); font-weight: 600; } +.abw-unten { display: flex; gap: 8px; align-items: center; } +.abw-notiz { flex: 1; min-width: 0; border-radius: var(--r-pill); } +.abw-fehler { font-size: 12.5px; color: var(--red); } +.abw-warnung { display: flex; gap: 9px; align-items: flex-start; font-size: 12.5px; border-radius: 12px; padding: 8px 12px; background: color-mix(in srgb, var(--amber) 15%, transparent); } +.abw-warnung::before { content: ""; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--amber); margin-top: 6px; } + +/* Liste: Zeilen der Projektliste, mit Punkt in Artfarbe und Kapsel */ +.abw-zeile.ist-aktiv .board-row-main { background: var(--accent-soft); } +.abw-zeile-name { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.abw-punkt { width: 9px; height: 9px; border-radius: 50%; background: var(--abw-c); flex: none; } +.abw-tag { font-size: 11px; font-weight: 500; padding: 1px 8px; border-radius: var(--r-pill); border: 1px solid var(--line); color: var(--text-muted); } +.board-row-actions.abw-fragt { opacity: 1; } +.abw-leer { padding: 14px 16px; font-size: 12.5px; color: var(--text-muted); } +.abw-reiter { display: inline-flex; gap: 2px; padding: 3px; border-radius: var(--r-pill); background: var(--surface-sunk); } +.abw-reiter button { border: 0; background: none; padding: 3px 11px; border-radius: var(--r-pill); font: inherit; font-size: 12px; color: var(--text-muted); cursor: pointer; } +.abw-reiter button.ist-an { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,.12); } + +/* Team je Gewerk */ +.abw-team-kopf { + margin-top: 26px !important; flex-wrap: wrap; gap: 10px; + /* Hinterlegt wie die Karten darunter (.board-list) */ + background: var(--surface); border: 1px solid var(--line-soft); + border-radius: var(--r-xl); box-shadow: var(--sh-2); + padding: 12px 16px; margin-bottom: 14px; +} +.abw-team-steuer { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.abw-monat { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; } +.abw-monat strong { min-width: 120px; text-align: center; font-weight: 600; } +.abw-rund { width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer; display: grid; place-items: center; padding: 0; } +.abw-gewerk-kopf { display: flex; align-items: baseline; gap: 10px; } +.abw-gewerk-kopf small { color: var(--text-muted); font-size: 12.5px; } +#abwesenheit .abw-gewerk-kopf + .abw-tl-karte { margin-top: 8px; } +.abw-tl-karte { overflow-x: auto; } +.abw-tl { display: grid; min-width: 880px; font-size: 11px; } +.abw-tl > div { position: relative; height: 52px; border-bottom: 1px solid var(--line-soft); } +.abw-tl { margin-bottom: -1px; } +.abw-tl-tag, .abw-tl-kopf { height: 36px !important; } +.abw-tl-tag { display: grid; align-content: center; justify-items: center; line-height: 1.1; color: var(--text-subtle); font-variant-numeric: tabular-nums; } +.abw-tl-tag small { font-size: 9px; text-transform: uppercase; letter-spacing: .04em; } +.abw-tl-tag b { font-size: 11.5px; color: var(--text-muted); font-weight: 600; } +.abw-tl-tag.ist-heute b { background: var(--red); color: #fff; border-radius: var(--r-pill); padding: 0 5px; } +.abw-tl-wer { position: sticky; left: 0; z-index: 3; display: flex; align-items: center; gap: 10px; padding: 0 14px; background: var(--surface); } +html.glas .abw-tl-wer { background: var(--glas-deckend, var(--surface)); } +.abw-tl-kopf { color: var(--text-subtle); font-size: 11px; } +.abw-tl-wer.ist-gewaehlt { box-shadow: inset 3px 0 0 var(--accent); } +.abw-tl-wer.ist-klickbar { cursor: pointer; transition: background var(--dur) var(--ease-out); } +.abw-tl-wer.ist-klickbar:hover { background-image: linear-gradient(var(--surface-alt), var(--surface-alt)); } /* Tönung über dem deckenden Grund, die Spalte ist sticky */ +.abw-tl-wer.ist-klickbar:hover .abw-tl-wer-name { color: var(--accent); } +.abw-tl-wer.ist-klickbar:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.abw-tl-wer-text { display: flex; flex-direction: column; min-width: 0; } +.abw-tl-wer-name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.abw-tl-wer-meta { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.abw-tl .ist-frei { background: color-mix(in srgb, var(--text-subtle) 7%, transparent); } +.abw-tl-zelle.ist-heute::after { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; background: var(--red); opacity: .5; z-index: 1; } +.abw-tl-zelle.ist-klickbar { cursor: pointer; } +.abw-tl-zelle.ist-klickbar:hover { background: var(--surface-sunk); } +.abw-balken { + position: absolute; top: 14px; bottom: 14px; left: 2px; z-index: 2; cursor: pointer; + display: flex; align-items: center; padding: 0 10px; white-space: nowrap; overflow: hidden; + border-radius: var(--r-pill); font-size: 11px; font-weight: 600; color: var(--text); + background: color-mix(in srgb, var(--abw-c) 20%, transparent); box-shadow: inset 0 0 0 1px var(--abw-c); +} +.abw-balken::before { content: ""; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--abw-c); margin-right: 6px; } +.abw-balken.abw-verdeckt { cursor: default; } +.abw-balken.abw-verdeckt::before { display: none; } + +@media (max-width: 980px) { .abw-zwei { grid-template-columns: 1fr; } .abw-listen-karte { height: auto; min-height: 0; } .abw-listen-zeilen { overflow: visible; } } +@media (max-width: 768px) { + .abw-arten { grid-template-columns: repeat(2, 1fr); } +} +/* Verdeckte Abwesenheit einer anderen Person: nur „abwesend“, neutral, nicht änderbar */ +.abw-verdeckt-zeile { --abw-c: var(--text-subtle); } +.abw-verdeckt-zeile .board-row-main { cursor: default; } diff --git a/server.js b/server.js index 9d10810..52b5335 100644 --- a/server.js +++ b/server.js @@ -58,206 +58,37 @@ const mammoth = require('mammoth'); // fetchMitTimeout() weiter unten) ein höheres Verbindungslimit als Node/ // undicis eigenen Vorgabewert mitzugeben, siehe dort. const { Agent: UndiciAgent } = require('undici'); -let pdfjsLibPromise = null; -function ladePdfjs(){ - if(!pdfjsLibPromise) pdfjsLibPromise = import('pdfjs-dist/legacy/build/pdf.mjs'); - return pdfjsLibPromise; +// PDF-Arbeit im Kindprozess (pdf-arbeit.js): ein Auftrag je Prozess, höchstens +// einer gleichzeitig (Warteschlange), mit Zeitlimit. Stirbt das Kind am +// Speicher, scheitert nur dieser eine Import mit verständlicher Meldung. +const { fork } = require('child_process'); +const { PLAN_CHECK_KACHEL_SPALTEN, PLAN_CHECK_KACHEL_ZEILEN } = require('./pdf-arbeit.js'); +const PDF_KIND_TIMEOUT_MS = 3 * 60 * 1000; +let pdfWarteschlange = Promise.resolve(); +function pdfImKindprozess(art, buf){ + const lauf = pdfWarteschlange.then(() => new Promise((resolve, reject) => { + const kind = fork(path.join(__dirname, 'pdf-arbeit.js'), [], { + execArgv: ['--max-old-space-size=' + (Number(process.env.PDF_KIND_HEAP_MB) || 192)], + serialization: 'advanced', stdio: ['ignore', 'inherit', 'inherit', 'ipc'] + }); + let fertig = false; + const ende = (fn, wert) => { if(fertig) return; fertig = true; clearTimeout(uhr); fn(wert); }; + const uhr = setTimeout(() => { kind.kill('SIGKILL'); ende(reject, new Error('Das PDF konnte nicht in angemessener Zeit gelesen werden.')); }, PDF_KIND_TIMEOUT_MS); + kind.on('message', m => m && m.ok ? ende(resolve, m.ergebnis) : ende(reject, new Error((m && m.fehler) || 'PDF konnte nicht gelesen werden.'))); + kind.on('exit', code => ende(reject, new Error(code === 99 || code === null + ? 'Das PDF ist zu groß für den Server (Arbeitsspeicher).' + : 'PDF konnte nicht gelesen werden (Code ' + code + ').'))); + kind.on('error', err => ende(reject, err)); + kind.send({ art, daten: buf }); + })); + pdfWarteschlange = lauf.catch(() => {}); + return lauf; } -async function extrahierePdfText(buf){ - const pdfjsLib = await ladePdfjs(); - const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf) }); - try { - const doc = await loadingTask.promise; - let text = ''; - for(let i = 1; i <= doc.numPages; i++){ - const page = await doc.getPage(i); - const content = await page.getTextContent(); - // Textfragmente stehen ohne Zeilenumbrüche in content.items — erkennePunkteAusText() - // braucht aber echte Zeilen (Punkt-Marker müssen am Zeilenanfang stehen). Ein - // Umbruch wird eingefügt, sobald sich die Y-Koordinate (transform[5]) ändert, - // exakt wie es das vormals genutzte pdf-parse intern selbst tat. - let lastY = null; - content.items.forEach(item => { - if(lastY !== null && item.transform[5] !== lastY) text += '\n'; - else if(lastY !== null) text += ' '; - text += item.str; - lastY = item.transform[5]; - }); - text += '\n\n'; - } - return text; - } finally { - loadingTask.destroy(); - } -} - -// ladeCanvas/renderPdfAlsBildsatz: fürs "Plan Checker"-Feature weiter unten -// (POST /plan-checker-pruefen, aufgerufen aus pruefePlanMitKI()) — rendert -// ausschließlich die ERSTE Seite eines PDFs zu einem Bildsatz (Übersicht + -// hochaufgelöste Kachel-Ausschnitte, s. renderPdfAlsBildsatz() dort), für -// BEIDE Anbieter gleich (siehe Projekthistorie bei pruefePlanMitClaude() -// unten — ein einzelnes, von uns oder vom Anbieter selbst erzeugtes -// Gesamtbild der Seite erwies sich für feine Maßzahlen/Plankopf-Text -// wiederholt als zu grob). Ein Planblatt pro Prüfung ist der Normalfall; -// mehrseitige PDFs mit mehreren Planblättern sind vorerst nicht vorgesehen. -let canvasLibPromise = null; -function ladeCanvas(){ - if(!canvasLibPromise) canvasLibPromise = import('@napi-rs/canvas'); - return canvasLibPromise; -} - -// PDFJS_STANDARD_FONTS: Pfad zu den von pdfjs-dist mitgelieferten Standard- -// Schriftdaten (Foxit-Ersatzschriften für Helvetica/Times/Symbol/...), mit -// abschließendem Trenner wie von pdf.js intern per String-Konkatenation -// erwartet (`${standardFontDataUrl}${dateiname}`). OHNE diesen Parameter -// wirft pdf.js beim Rendern pro fehlender Schriftart die Warnung -// "UnknownErrorException: Ensure that the standardFontDataUrl API parameter -// is provided" und kann Zeichen nicht auf Glyphen abbilden — ein einfacher -// Testtext (reine Helvetica-Buchstaben) rendert trotzdem noch halbwegs über -// einen groben Ersatz, aber bei echten CAD-/Architektur-Export-PDFs -// (Revit/AutoCAD, oft mit eigens eingebetteten oder nicht eingebetteten -// Spezialschriften für Maßzahlen und Plankopf-Text) blieben genau diese -// Textstellen dadurch leer oder unleserlich — die Maßketten-LINIEN kamen an -// (reine Vektorgrafik, schriftunabhängig), die Zahlen und der Plankopf-Text -// nicht. Nur für renderPdfAlsBildsatz() relevant: extrahierePdfText() -// liest ausschließlich getTextContent() (die im PDF gespeicherten -// Zeichencodes), ohne je eine Glyphe zu rendern, und ist von fehlenden -// Schriftdaten nicht betroffen. -const PDFJS_STANDARD_FONTS = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts') + path.sep; - -// PDFJS_WASM: Pfad zu den von pdfjs-dist mitgelieferten WASM-Modulen -// (u. a. jbig2.wasm, openjpeg.wasm), im selben Muster wie PDFJS_STANDARD_FONTS -// oben. OHNE diesen Parameter wirft pdf.js beim Laden eines WASM-Decoders -// "UnknownErrorException: Ensure that the wasmUrl API parameter is provided" -// und versucht dann, JBIG2-kodierte Bildinhalte (verbreitetes Kompressions- -// format für eingebettete Scan-/Rasterbilder, z. B. ein eingescanntes Logo im -// Plankopf) über einen Fallback-Pfad zu laden, der mangels `wasmUrl` zu einem -// kaputten Modulnamen zusammengesetzt wird ("nulljbig2_nowasm_fallback.js") -// und mit ERR_MODULE_NOT_FOUND scheitert — der betroffene Bildinhalt fehlt -// dadurch im gerenderten PNG, genau wie zuvor die Ebenen/Schriften. -const PDFJS_WASM = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'wasm') + path.sep; - -// PLAN_CHECK_KACHEL_*: Rastergröße und Überlappung für renderPdfAlsBildsatz() -// unten. 2x2 (vier Kacheln) mit 12% Überlappung je Rand - genug -// Sicherheitsrand, falls ein Feld (z. B. der Plankopf) genau auf einer -// Kachelgrenze liegt, ohne die Zahl der pro Prüfung gesendeten Bilder -// (= Kosten/Dauer je Anbieter-Anfrage) unnötig in die Höhe zu treiben. -const PLAN_CHECK_KACHEL_SPALTEN = 2; -const PLAN_CHECK_KACHEL_ZEILEN = 2; -const PLAN_CHECK_KACHEL_UEBERLAPPUNG = 0.12; -const PLAN_CHECK_UEBERSICHT_BREITE = 2000; -const PLAN_CHECK_KACHEL_BREITE = 2200; - -// renderPdfAlsBildsatz: löst die vorherige renderPdfErstePageAlsPng() ab, die -// die ganze Planseite als EIN einzelnes PNG rendert hat. Grund für die -// Umstellung: Nach den vorherigen Fixes (Ebenen/Schrift/WASM, s.o.) meldete -// Erik einen neuen, grundsätzlicheren Befund - der Plankopf wurde zwar jetzt -// gefunden und der Plantitel korrekt gelesen, aber Projektname, -// Planersteller, Maßstab, Datum und Plannummer blieben "in der vorliegenden -// Auflösung" nicht sämtlich lesbar. Das bestätigt in letzter Konsequenz den -// Verdacht, der schon zur vorherigen PDF-Direktübergabe an Claude geführt -// hatte (s. historischer Kommentar bei pruefePlanMitClaude() weiter unten): -// JEDER Anbieter skaliert ein übermitteltes Bild bzw. eine PDF-Seite intern -// auf eine eigene Obergrenze herunter, BEVOR das Modell draufschaut (bei -// Claude laut Dokumentation ca. 1568px lange Kante, bei OpenAI vergleichbar). -// Ein Plankopf ist nur ein kleiner Ausschnitt eines großen Planblatts - seine -// Feinschrift liegt nach dieser anbieterseitigen Herunterskalierung weit -// unter Lesbarkeit, UNABHÄNGIG davon, wie hoch WIR die Ausgangsauflösung -// wählen (mehr Pixel in der Quelle ändern nichts, wenn der Anbieter ohnehin -// wieder auf dieselbe Obergrenze herunterrechnet). Claudes PDF-Direktübergabe -// half laut Erik auch nicht durchgreifend genug - vermutlich, weil Anthropics -// eigene Dokumenten-Verarbeitung intern denselben Weg geht (PDF-Seite zu -// einem einzigen Bild, dann dieselbe Auflösungsgrenze). -// -// Der einzige verlässliche Ausweg: der KI ZUSÄTZLICH zur Übersicht mehrere, -// überlappende Ausschnitte der Seite in höherer Pixel-Dichte mitgeben, sodass -// Feinschrift gleich welcher Lage auf dem Blatt in mindestens einem Bild -// unterhalb der anbieterseitigen Herunterskalierungs-Schwelle bleibt - OHNE -// dafür die Lage des Plankopfs erkennen zu müssen (die variiert je -// Büro-Vorlage: beim getesteten Plan oben rechts, DIN-Konvention wäre unten -// rechts). Jede Kachel wird NICHT aus einem fertigen Raster-Bild -// zugeschnitten (das brächte keine zusätzliche Schärfe, nur eine vergrößerte -// Unschärfe), sondern pdf.js erneut mit einer höheren scale-Vorgabe gerendert -// und per Context-Transform (ctx.translate) auf den gewünschten Ausschnitt -// verschoben - der Ausschnitt entsteht also selbst aus dem Vektor-Original in -// höherer Auflösung, nicht durch Hochskalieren eines bereits gerenderten -// Bilds. Gilt jetzt für BEIDE Anbieter (Eriks ausdrückliche Entscheidung): -// Claude bekommt dieselben gerenderten Bilder wie ChatGPT statt wieder der -// rohen PDF, damit für beide dasselbe, selbst kontrollierte Verfahren gilt -// statt für Claude erneut auf unbekanntes anbieterinternes Verhalten zu -// vertrauen. -// -// Rückgabe: { uebersicht: Buffer, kacheln: Buffer[] } - je ein PNG-Buffer. +function extrahierePdfText(buf){ return pdfImKindprozess('text', buf); } async function renderPdfAlsBildsatz(buf){ - const pdfjsLib = await ladePdfjs(); - const { createCanvas } = await ladeCanvas(); - const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf), standardFontDataUrl: PDFJS_STANDARD_FONTS, wasmUrl: PDFJS_WASM }); - try { - const doc = await loadingTask.promise; - const page = await doc.getPage(1); - const basisViewport = page.getViewport({ scale: 1 }); - - // Alle optionalen Ebenen (PDF Optional Content Groups) erzwungen - // sichtbar schalten, BEVOR gerendert wird: CAD-/BIM-Exporte (AutoCAD, - // Revit, ...) legen technische Pläne sehr häufig in Ebenen an (z. B. - // "Architektur-Grundriss", "ELT-Planung", "Legende", "Plankopf") und - // markieren einen Teil davon im PDF selbst als standardmäßig AUS - oft - // bewusst so exportiert, damit jedes Gewerk seine eigene Ebene in - // Acrobat selbst einschaltet. Ohne diese Ergänzung rendert pdf.js exakt - // diesen Vorgabe-Zustand nach: bei einem echten Plan blieben dadurch nur - // der Architektur-Grundriss übrig, ELT-Planung, Legende UND Plankopf - // fehlten im gerenderten Bild komplett (von Erik direkt bestätigt, nach - // Ansehen des gespeicherten Debug-PNGs) - die KI konnte diese Merkmale - // also gar nicht als "nicht vorhanden" FALSCH einstufen, sie bekam sie - // schlicht nie zu sehen. - const ebenen = await doc.getOptionalContentConfig(); - for(const [ebenenId] of ebenen){ - ebenen.setVisibility(ebenenId, true); - } - - // ausschnitt (optional): {x, y, breite, hoehe} in Geräte-Pixeln DIESES - // scale-Werts. Ohne ausschnitt wird die ganze Seite gerendert. - async function renderAusschnitt(scale, ausschnitt){ - const viewport = page.getViewport({ scale }); - const breite = ausschnitt ? Math.round(ausschnitt.breite) : Math.round(viewport.width); - const hoehe = ausschnitt ? Math.round(ausschnitt.hoehe) : Math.round(viewport.height); - const canvas = createCanvas(breite, hoehe); - const ctx = canvas.getContext('2d'); - if(ausschnitt) ctx.translate(-ausschnitt.x, -ausschnitt.y); - await page.render({ canvasContext: ctx, viewport, optionalContentConfigPromise: Promise.resolve(ebenen) }).promise; - return canvas.toBuffer('image/png'); - } - - // Übersicht: die ganze Seite, moderate Auflösung - liefert der KI den - // Gesamtkontext (Art/Lage der Zeichnung), aber keine verlässliche - // Feinschrift. - const uebersicht = await renderAusschnitt(PLAN_CHECK_UEBERSICHT_BREITE / basisViewport.width, null); - - // Kacheln: Raster mit Überlappung, JEDE Kachel mit eigener, hoher - // Skalierung gerendert (nicht aus der Übersicht zugeschnitten, s. o.). - const anteilBreite = (1 / PLAN_CHECK_KACHEL_SPALTEN) + PLAN_CHECK_KACHEL_UEBERLAPPUNG; - const kachelScale = PLAN_CHECK_KACHEL_BREITE / (basisViewport.width * anteilBreite); - const kachelViewport = page.getViewport({ scale: kachelScale }); - const kacheln = []; - for(let zeile = 0; zeile < PLAN_CHECK_KACHEL_ZEILEN; zeile++){ - for(let spalte = 0; spalte < PLAN_CHECK_KACHEL_SPALTEN; spalte++){ - const startAnteilX = Math.max(0, spalte / PLAN_CHECK_KACHEL_SPALTEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); - const startAnteilY = Math.max(0, zeile / PLAN_CHECK_KACHEL_ZEILEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); - const endAnteilX = Math.min(1, (spalte + 1) / PLAN_CHECK_KACHEL_SPALTEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); - const endAnteilY = Math.min(1, (zeile + 1) / PLAN_CHECK_KACHEL_ZEILEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2); - kacheln.push(await renderAusschnitt(kachelScale, { - x: startAnteilX * kachelViewport.width, - y: startAnteilY * kachelViewport.height, - breite: (endAnteilX - startAnteilX) * kachelViewport.width, - hoehe: (endAnteilY - startAnteilY) * kachelViewport.height - })); - } - } - - return { uebersicht, kacheln }; - } finally { - loadingTask.destroy(); - } + const r = await pdfImKindprozess('bild', buf); + // IPC ("advanced") liefert Uint8Array statt Buffer zurück. + return { uebersicht: Buffer.from(r.uebersicht), kacheln: r.kacheln.map(k => Buffer.from(k)) }; } const app = express(); @@ -277,19 +108,10 @@ const SANDBOX_WEEK_LABEL = '__sandbox__'; // Projekt-Zuordnungs-Rollen aus ROLES in public/js/globals.js (bewusst dupliziert, // kein Bundler vorhanden — NICHT zu verwechseln mit users.role, s. VALID_ROLES). const VALID_DISCIPLINE_ROLES = new Set(['Planung','Projektleitung','Bauüberwachung']); -// VALID_ROLES: die Systemrollen eines Kontos (users.role, keine CHECK- -// Constraint im Schema, daher hier die eine Stelle, die einen Wert -// zurückweist). 'kiosk' ist das Wandtafel-Konto (siehe CLAUDE.md -// "Wandtafel"): darf nur die lesenden Routen erreichen, die die Wandtafel -// braucht (requireAuthOderKiosk weiter unten), überall sonst wie requireAuth -// abgewiesen — und darf keine Fachrolle tragen (dort geprüft, wo roles -// validiert wird). 'projektleiter' ist ein Mitglied, das zusätzlich Projekte -// anlegen und aus der Projektliste löschen darf (siehe -// darfProjektlisteAendern()); sonst gilt -// für das Konto alles wie für 'member'. Nicht zu verwechseln mit der -// Fachrolle 'Projektleitung' (user_roles), die nur die Projektzuordnung -// betrifft und keine Berechtigung verleiht. -const VALID_ROLES = new Set(['admin', 'member', 'projektleiter', 'kiosk']); +// Systemrollen eines Kontos (users.role) stehen seit „Rollen & Rechte“ in der +// Tabelle roles (siehe RECHTE weiter unten) — geprüft über rolleExistiert(). +// Nicht zu verwechseln mit den Fachrollen (user_roles), die nur die +// Projektzuordnung betreffen und keine Berechtigung verleihen. // GELOESCHTE_PROJEKTE_TAGE: so lange bleibt ein aus der Projektliste // gelöschtes Projekt im Papierkorb (deleted_projects) und ist für // Administratoren wiederherstellbar; danach entfernt es @@ -364,16 +186,97 @@ app.use((req, res, next) => { // Endpunkte" — als function-Deklaration ist sie im gesamten Modul gehoistet). // Muss vor express.static laufen, damit sie garantiert für jeden Request greift. app.use(attachUser); +// Kompression: Texte (JSON, JS, CSS, HTML, SVG) werden gzip-komprimiert +// ausgeliefert, wenn der Browser es anbietet — /load-all schrumpft so von +// rund 150 KB auf einen Bruchteil. Statische Dateien werden je Datei und +// Änderungszeit einmal komprimiert und im Speicher gehalten. +app.use(gzipJson); +const gzipPublic = gzipStatisch(path.join(__dirname, 'public')); +app.use(gzipPublic); +// Zwischenspeichern: Jede JS-/CSS-Datei wird in App.html mit ?v=N +// eingebunden und die Nummer bei jeder Änderung erhöht — eine solche +// Adresse ändert ihren Inhalt nie und darf deshalb ein Jahr im Browser +// liegen. Alles andere (App.html selbst, Dateien ohne ?v) wird bei jedem +// Aufruf nachgefragt (no-cache + ETag, im Normalfall nur ein kurzes 304). +// Vorher: no-store, also bei JEDEM Laden rund 1,4 MB neu. app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, path) => { - if(path.endsWith('.js') || path.endsWith('.css') || path.endsWith('.html')){ - res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.set('Pragma', 'no-cache'); - res.set('Expires', '0'); + if(path.endsWith('.js') || path.endsWith('.css') || path.endsWith('.html') || path.endsWith('.svg')){ + const versioniert = res.req && /[?&]v=/.test(res.req.originalUrl || ''); + res.set('Cache-Control', versioniert ? 'public, max-age=31536000, immutable' : 'no-cache'); } } })); +// gzipJson: komprimiert res.json()-Antworten ab 1 KB, sofern der Browser +// gzip annimmt. Middleware; ersetzt res.json nur für diese Anfrage. +function gzipJson(req, res, next){ + if(!/\bgzip\b/.test(req.headers['accept-encoding'] || '')) return next(); + const original = res.json.bind(res); + res.json = function(body){ + const text = JSON.stringify(body); + if(text === undefined || text.length < 1024) return original(body); + const gz = zlib.gzipSync(text, { level: 6 }); + if(!res.get('Content-Type')) res.set('Content-Type', 'application/json; charset=utf-8'); + res.set('Content-Encoding', 'gzip'); + res.set('Vary', 'Accept-Encoding'); + return res.send(gz); + }; + next(); +} + +// gzipStatisch: liefert JS/CSS/HTML/SVG aus wurzel komprimiert aus (GET/HEAD, +// nur wenn der Browser gzip annimmt). Sonst — und für alles andere — weiter +// an express.static. Cache: Pfad -> { mtime, gz, etag }. +function gzipStatisch(wurzel){ + const TYPEN = { '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml' }; + const cache = new Map(); + const mw = (req, res, next) => { + if(req.method !== 'GET' && req.method !== 'HEAD') return next(); + let rel; + try { rel = decodeURIComponent(req.path); } catch(e){ return next(); } + if(rel.endsWith('/')) rel += 'index.html'; + const datei = path.join(wurzel, rel); + if(!datei.startsWith(wurzel + path.sep)) return next(); + sende(req, res, next, datei); + }; + // sende: eine Datei komprimiert ausliefern, sonst next() (auch nutzbar für + // die Seitenrouten, die App.html unter anderen Pfaden liefern). + const sende = (req, res, next, datei) => { + if(!/\bgzip\b/.test(req.headers['accept-encoding'] || '')) return next(); + const typ = TYPEN[path.extname(datei).toLowerCase()]; + if(!typ) return next(); + let st; + try { st = fs.statSync(datei); } catch(e){ return next(); } + if(!st.isFile()) return next(); + let e = cache.get(datei); + if(!e || e.mtime !== st.mtimeMs){ + const gz = zlib.gzipSync(fs.readFileSync(datei), { level: 6 }); + e = { mtime: st.mtimeMs, gz, etag: 'W/"gz-' + st.size.toString(16) + '-' + Math.floor(st.mtimeMs).toString(16) + '"' }; + cache.set(datei, e); + } + const versioniert = /[?&]v=/.test(req.originalUrl || ''); + res.set('Cache-Control', versioniert ? 'public, max-age=31536000, immutable' : 'no-cache'); + res.set('Vary', 'Accept-Encoding'); + res.set('ETag', e.etag); + if(req.headers['if-none-match'] === e.etag) return res.status(304).end(); + res.set('Content-Type', typ); + res.set('Content-Encoding', 'gzip'); + res.set('Content-Length', String(e.gz.length)); + if(req.method === 'HEAD') return res.end(); + res.end(e.gz); + }; + mw.sende = sende; + return mw; +} + +// appSenden: App.html für die Seitenrouten — komprimiert, sonst wie bisher. +function appSenden(req, res){ + const datei = path.join(__dirname, 'public', 'App.html'); + gzipPublic.sende(req, res, () => res.sendFile(datei), datei); +} + // Datenverzeichnis sessions/ bei Bedarf anlegen (bind-mounted in Docker, enthält die DB) const SESSIONS_DIR = path.join(__dirname, 'sessions'); if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true }); @@ -386,6 +289,11 @@ const db = new DatabaseSync(DB_PATH); db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA foreign_keys = ON;'); db.exec('PRAGMA busy_timeout = 5000;'); // 5 s warten statt SQLITE_BUSY-Fehlern bei parallelen Schreibern +// synchronous=NORMAL ist im WAL-Modus die übliche, sichere Einstellung: Die +// Datenbank bleibt bei einem Absturz konsistent, nur ein Stromausfall kann die +// allerletzten Schreibvorgänge kosten. Mit der Vorgabe FULL wartete JEDES +// Schreiben auf die SD-Karte des Pi — spürbar bei jedem Seitenaufruf. +db.exec('PRAGMA synchronous = NORMAL;'); // ---------- Schema (voll relational normalisiert) ---------- @@ -450,13 +358,6 @@ db.exec(`CREATE TABLE IF NOT EXISTS projects ( -- Sitzung. Deshalb steht er hier und nicht an einer wochenunabhängigen -- Stelle; beim Duplizieren einer Sitzung wird er zurückgesetzt. besprochen INTEGER NOT NULL DEFAULT 0, - -- groesse: Umfang des Projekts als Zahl zwischen 0 und 1, gemessen am - -- Referenzhonorar (app_settings.referenzHonorar). Sie tritt an die Stelle - -- des Honorars, wenn keines hinterlegt ist, und ist damit die Grundlage - -- der Auslastungsrechnung in Stunden. 0 = keine Angabe: Das Projekt ist - -- dann nicht bewertbar und wird in der Auslastung als Lücke ausgewiesen, - -- nicht stillschweigend als Null gezählt. - groesse REAL NOT NULL DEFAULT 0, -- auftragsstatus: Wo das Projekt zwischen Anfrage und Abschluss steht -- ('anfrage'|'angebot'|'beauftragt'|'abgeschlossen', siehe -- VALID_AUFTRAGSSTATUS). Im Unterschied zu besprochen gehört der Wert zum @@ -541,6 +442,25 @@ db.exec(`CREATE TABLE IF NOT EXISTS project_gewerke ( gewerk TEXT NOT NULL, PRIMARY KEY (week_label, project_id, gewerk) )`); +// project_kommentare: Sitzungskommentar und Besprochen-Haken JE GEWERK. +// ELT, HLS und ARCH besprechen ihre Projekte in getrennten Sitzungen (Eriks +// Wunsch: "Die Kommentare und Anwesenheiten von ELT brauchen nicht in HLS +// oder Arch zu sein" / "auch wenn das Projekt in ELT besprochen ist, heißt +// das nicht, dass HLS das Projekt auch besprochen hat"). Löst die Spalten +// projects.status/status_author/status_at/besprochen ab, die nur noch für +// den Übergang (migrateKommentareJeGewerk()) gelesen und sonst leer +// geschrieben werden. Client-Form: p.kommentare = { ELT: {status, +// statusAuthor, statusAt, besprochen}, … }. +db.exec(`CREATE TABLE IF NOT EXISTS project_kommentare ( + week_label TEXT NOT NULL, + project_id TEXT NOT NULL, + gewerk TEXT NOT NULL, + status TEXT NOT NULL DEFAULT '', + status_author TEXT NOT NULL DEFAULT '', + status_at TEXT NOT NULL DEFAULT '', + besprochen INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (week_label, project_id, gewerk) +)`); db.exec(`CREATE TABLE IF NOT EXISTS project_plan ( week_label TEXT NOT NULL, project_id TEXT NOT NULL, @@ -567,6 +487,9 @@ db.exec(`CREATE TABLE IF NOT EXISTS plan_todos ( progress INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, show_in_gantt INTEGER NOT NULL DEFAULT 1, + leistungsphase TEXT NOT NULL DEFAULT '', + aufwand_stunden REAL NOT NULL DEFAULT 0, + notiz TEXT NOT NULL DEFAULT '', PRIMARY KEY (week_label, project_id, todo_id) )`); db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees ( @@ -668,6 +591,31 @@ db.exec(`CREATE TABLE IF NOT EXISTS user_ai_keys ( FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE )`); +// benachrichtigungen: Hinweise an ein Konto, bisher nur "neue Aufgabe +// zugewiesen" (art 'aufgabe'). Entstehen in POST /save-week, wenn eine +// Aufgabe einen Zuständigen bekommt, den sie vorher nicht hatte +// (neueZuweisungenMelden()). Gelesen = in der Glocke angeklickt oder "alle +// gelesen". Keine Kopie in IMPORT_TABLES: wie die Konten selbst kontobezogen, +// ein DB-Import lässt sie unberührt. Alte Einträge räumt +// purgeBenachrichtigungen() auf. +db.exec(`CREATE TABLE IF NOT EXISTS benachrichtigungen ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + art TEXT NOT NULL DEFAULT 'aufgabe', + titel TEXT NOT NULL DEFAULT '', + project_id TEXT NOT NULL DEFAULT '', + project_nr TEXT NOT NULL DEFAULT '', + project_name TEXT NOT NULL DEFAULT '', + week_label TEXT NOT NULL DEFAULT '', + todo_id TEXT NOT NULL DEFAULT '', + faellig TEXT NOT NULL DEFAULT '', + von_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + gelesen INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +)`); +db.exec('CREATE INDEX IF NOT EXISTS idx_benachrichtigungen_user ON benachrichtigungen(user_id, gelesen, created_at)'); + db.exec(`CREATE TABLE IF NOT EXISTS project_files ( id TEXT PRIMARY KEY, project_nr TEXT NOT NULL, @@ -761,13 +709,15 @@ db.exec(`CREATE TABLE IF NOT EXISTS protokoll_punkte ( )`); db.exec(`CREATE INDEX IF NOT EXISTS idx_protokoll_punkte_protokoll ON protokoll_punkte(protokoll_id)`); -// lph_templates: firmenweite Aufgaben-Vorlagen je Leistungsphase. Anders als +// lph_templates: firmenweite Aufgaben-Vorlagen je Leistungsphase und Gewerk +// (Spalte gewerk, nachgerüstet von migrateAddLphTemplateGewerk(); '' = gilt +// für alle Gewerke, nur Altbestand). Anders als // alle Tabellen darüber hängt diese an KEINER Sitzung und an keinem Projekt — // sie ist globale Konfiguration ("in LPH 5 machen wir immer diese Schritte"), // aus der auf der Projektplan-Seite per Klick TODOs erzeugt werden (siehe // js/plan/plan-templates.js). offset_days = Start relativ zum beim Einfügen -// gewählten Datum (0 = am Starttag), duration_days = Dauer in Kalendertagen -// (mindestens 1). Die erzeugten TODOs sind danach ganz normale plan_todos und +// gewählten Datum (0 = am Starttag), duration_days = Dauer in Kalendertagen; +// 0 = ohne Termin (die Aufgabe wird ohne Start/Ende eingefügt). Die erzeugten TODOs sind danach ganz normale plan_todos und // haben keinerlei Verbindung mehr zur Vorlage — spätere Änderungen an der // Vorlage wirken sich also NICHT auf bereits eingefügte Aufgaben aus (bewusst: // ein laufender Projektplan soll sich nicht rückwirkend ändern). @@ -781,6 +731,202 @@ db.exec(`CREATE TABLE IF NOT EXISTS lph_templates ( )`); db.exec('CREATE INDEX IF NOT EXISTS idx_lph_templates_lph ON lph_templates(lph)'); +// ---------- Rollen und Rechte ---------- +// Eriks Wunsch: Rollen und ihre Rechte werden in der Oberfläche gepflegt +// (Admin → Rollen & Rechte, js/admin/rollen.js), neue Rollen lassen sich +// anlegen. RECHTE ist der feste Katalog — die Schlüssel stehen im Code, nur +// ihre Zuordnung zu Rollen ist frei. Fachrollen (user_roles: Planung, +// Projektleitung, Bauüberwachung) haben damit NICHTS zu tun. +// +// roles: key (= users.role), name, sort_order, fest (1 = Administrator: hat +// immer alle Rechte, ist weder änderbar noch löschbar — sonst könnte man sich +// aussperren). role_rechte: eine Zeile je Haken. Beide NICHT in +// IMPORT_TABLES: ein Backup-Import lässt Rechte wie Konten unberührt. +// +// Eine Rolle OHNE das Recht „app“ ist eine reine Wandtafel-Rolle: sie +// erreicht nur die lesenden Routen der Wandtafel (requireAuthOderKiosk) und +// zählt nirgends als Teammitglied — das frühere fest verdrahtete 'kiosk'. +const RECHTE = [ + { gruppe: 'Grundzugang', rechte: [ + ['app', 'Anwendung nutzen', 'Projekte, Wochenbericht, Auslastung und Meine Aufgaben sehen'], + ['wand', 'Wandtafel anzeigen', ''] ]}, + { gruppe: 'Projekte', rechte: [ + ['proj.anlegen', 'Projekte anlegen und löschen', ''], + ['proj.stamm', 'Stammdaten und Beteiligte bearbeiten', 'Projekteinstellungen'], + ['proj.plan', 'Terminplan bearbeiten', 'Aufgaben, Meilensteine, Phasen, Vorlagen'], + ['proj.dateien', 'Dateien hochladen, prüfen und kommentieren', ''], + ['proj.dateienFremd', 'Fremde Dateien löschen', ''], + ['proj.protokolle', 'Protokolle bearbeiten', 'anlegen, importieren, löschen'] ]}, + { gruppe: 'Wochenbericht', rechte: [ + ['wb.kommentar', 'Kommentar und Fortschritt bearbeiten', ''], + ['wb.besprochen', '„Besprochen“ abhaken', ''], + ['wb.anwesenheit', 'Anwesenheit erfassen', ''] ]}, + { gruppe: 'Team', rechte: [ + ['team.abw', 'Abwesenheiten für mich', 'eigene eintragen, ändern, löschen'], + ['team.abwProjekte', 'Abwesenheiten für Personen meiner Projekte', 'Beteiligte der eigenen Projekte (aktuelle Sitzung)'], + ['team.abwGewerk', 'Abwesenheiten für mein Gewerk', 'alle Konten mit demselben Gewerk'], + ['team.abwAlle', 'Abwesenheiten für alle', ''] ]}, + { gruppe: 'Werkzeuge', rechte: [ + ['wz.planung', 'Terminplan', 'Terminpläne ohne Projekt (Seitenleiste „Terminplan“)'], + ['wz.checker', 'Plan Checker', ''] ]}, + { gruppe: 'Sitzungen', rechte: [ + ['s.entsperren', 'Sitzung entsperren', ''], + ['s.loeschen', 'Sitzung löschen', ''], + ['s.gesperrt', 'Gesperrte Sitzung bearbeiten', ''] ]}, + { gruppe: 'Admin-Bereich', rechte: [ + ['a.benutzer', 'Benutzerverwaltung', ''], ['a.honorar', 'Honorar', ''], + ['a.auslastung', 'Auslastung', ''], ['a.termine', 'Termine & Sitzungen', ''], + ['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''], + ['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''], + ['a.papierkorb', 'Papierkorb', ''], ['a.backup', 'Datenbank-Backup', ''], + ['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''] ]}, +]; +const RECHT_LABEL = new Map(RECHTE.flatMap(g => g.rechte.map(r => [r[0], r[1]]))); +const RECHT_KEYS = new Set(RECHT_LABEL.keys()); + +db.exec(`CREATE TABLE IF NOT EXISTS roles ( + key TEXT PRIMARY KEY, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + fest INTEGER NOT NULL DEFAULT 0 +)`); +db.exec(`CREATE TABLE IF NOT EXISTS role_rechte ( + role_key TEXT NOT NULL, + recht TEXT NOT NULL, + PRIMARY KEY (role_key, recht) +)`); + +// Startbelegung = Stand vor dem Umbau: Projektleiter und Mitglied alles aus +// Grundzugang bis Werkzeuge (Mitglied ohne „Projekte anlegen und löschen“), +// Wandtafel nur die Wandtafel. Läuft nur, solange roles leer ist. +(function rollenStartbelegung(){ + if(db.prepare('SELECT COUNT(*) AS n FROM roles').get().n > 0) return; + const basis = RECHTE.slice(0, 5).flatMap(g => g.rechte.map(r => r[0])); + const start = [ + ['admin', 'Administrator', 1, []], + ['projektleiter', 'Projektleiter', 0, basis], + ['member', 'Mitglied', 0, basis.filter(k => k !== 'proj.anlegen')], + ['kiosk', 'Wandtafel', 0, ['wand']], + ]; + const insR = db.prepare('INSERT INTO roles (key, name, sort_order, fest) VALUES (?, ?, ?, ?)'); + const insH = db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)'); + db.exec('BEGIN'); + try { + start.forEach(([k, n, fest, rechte], i) => { insR.run(k, n, i, fest); rechte.forEach(r => insH.run(k, r)); }); + db.exec('COMMIT'); + console.log('Rollen & Rechte: Startbelegung angelegt.'); + } catch(e){ db.exec('ROLLBACK'); throw e; } +})(); + +// rollenCache: key → { key, name, sortOrder, fest, rechte:Set } — jede +// Anfrage prüft gegen diesen Speicher, neu geladen nach jeder Änderung. +let rollenCache = new Map(); +function ladeRollen(){ + const m = new Map(); + db.prepare('SELECT * FROM roles ORDER BY sort_order, name').all().forEach(r => { + m.set(r.key, { key: r.key, name: r.name, sortOrder: r.sort_order, fest: !!r.fest, rechte: new Set() }); + }); + db.prepare('SELECT * FROM role_rechte').all().forEach(r => { + const x = m.get(r.role_key); + if(x && RECHT_KEYS.has(r.recht)) x.rechte.add(r.recht); + }); + rollenCache = m; +} +ladeRollen(); +// Umstellung der Abwesenheits-Rechte (Eriks Wunsch): „Fremde Abwesenheiten +// löschen“ (team.abwFremd) entfällt. Startbelegung wie im Entwurf: Mitglied +// nur „für mich“, Projektleiter „für mein Gewerk“ und „für Personen meiner +// Projekte“. Selbst angelegte Rollen mit team.abwFremd bekommen „für alle“ +// (Mitglied und Projektleiter hatten es aus der Startbelegung, nicht bewusst). +(function abwesenheitsRechteUmstellen(){ + db.exec('CREATE TABLE IF NOT EXISTS umstellungen (name TEXT PRIMARY KEY, am TEXT NOT NULL)'); + if(db.prepare("SELECT 1 FROM umstellungen WHERE name = 'abwesenheit-rechte'").get()) return; + const ins = db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)'); + db.exec('BEGIN'); + try { + db.prepare("SELECT role_key FROM role_rechte WHERE recht = 'team.abwFremd' AND role_key NOT IN ('member', 'projektleiter')").all() + .forEach(r => { ins.run(r.role_key, 'team.abwAlle'); ins.run(r.role_key, 'team.abw'); }); + if(db.prepare("SELECT 1 FROM roles WHERE key = 'projektleiter'").get()){ + ['team.abw', 'team.abwGewerk', 'team.abwProjekte'].forEach(r => ins.run('projektleiter', r)); + } + db.exec("DELETE FROM role_rechte WHERE recht = 'team.abwFremd'"); + db.prepare("INSERT INTO umstellungen (name, am) VALUES ('abwesenheit-rechte', ?)").run(new Date().toISOString()); + db.exec('COMMIT'); + } catch(e){ db.exec('ROLLBACK'); throw e; } + ladeRollen(); +})(); + +// abwesenheitZiele: für wen ein Konto Abwesenheiten eintragen, ändern und +// löschen darf (und bei wem es die Art sieht). Rückgabe: { alle:Boolean, +// ids:Set }. Das eigene Konto gehört dazu, sobald irgendein +// Abwesenheits-Recht gesetzt ist. „Meine Projekte“ = Projekte der jüngsten +// echten Sitzung, in denen beide als Beteiligte (state > 0) stehen. +function abwesenheitZiele(user){ + const ids = new Set(); + if(!user) return { alle: false, ids }; + if(hatRecht(user, 'team.abwAlle')) return { alle: true, ids }; + const gewerk = hatRecht(user, 'team.abwGewerk'); + const projekte = hatRecht(user, 'team.abwProjekte'); + if(hatRecht(user, 'team.abw') || gewerk || projekte) ids.add(user.id); + if(gewerk && user.department){ + db.prepare('SELECT id FROM users WHERE department = ? AND active = 1').all(user.department).forEach(r => ids.add(r.id)); + } + if(projekte && user.personId){ + const label = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL) + .map(r => r.label).sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b))).pop(); + if(label){ + db.prepare(`SELECT DISTINCT u.id FROM project_assign a + JOIN project_assign b ON b.week_label = a.week_label AND b.project_id = a.project_id AND b.state > 0 + JOIN users u ON u.person_id = b.person_id + WHERE a.week_label = ? AND a.person_id = ? AND a.state > 0`).all(label, user.personId).forEach(r => ids.add(r.id)); + } + } + return { alle: false, ids }; +} +function darfAbwesenheit(user, zielId){ + const z = abwesenheitZiele(user); + return z.alle || z.ids.has(Number(zielId)); +} + +function rolleExistiert(key){ return rollenCache.has(String(key)); } +// rechteDerRolle: Liste der Rechte einer Rolle (fest = alle). +function rechteDerRolle(key){ + const r = rollenCache.get(String(key)); + if(!r) return []; + return r.fest ? [...RECHT_KEYS] : [...r.rechte]; +} +// hatRecht: darf dieses Konto das? Parameter: user (req.user), recht. +function hatRecht(user, recht){ + if(!user) return false; + const r = rollenCache.get(user.role); + return !!r && (r.fest || r.rechte.has(recht)); +} +// istNurWandtafel: Rolle ohne Grundzugang „Anwendung nutzen“. +function istNurWandtafel(roleKey){ + const r = rollenCache.get(String(roleKey)); + return !r || (!r.fest && !r.rechte.has('app')); +} +// sqlOhneWandtafel: SQL-Bedingung, die Konten reiner Wandtafel-Rollen +// ausschließt (ersetzt das frühere role != 'kiosk'). Rollenschlüssel sind +// beim Anlegen auf [a-z0-9_-] beschränkt, daher sicher einzusetzen. +function sqlOhneWandtafel(spalte){ + const keys = [...rollenCache.keys()].filter(k => istNurWandtafel(k)).filter(k => /^[a-z0-9_-]+$/.test(k)); + const s = spalte || 'role'; + return keys.length ? `${s} NOT IN (${keys.map(k => `'${k}'`).join(',')})` : '1=1'; +} +// rechtFehlt: einheitliche 403-Antwort, nennt das fehlende Recht. +function rechtFehlt(res, recht){ + return res.status(403).json({ ok: false, recht, error: 'Keine Berechtigung: „' + (RECHT_LABEL.get(recht) || recht) + '“ fehlt.' }); +} +// requireRecht: Middleware — angemeldet UND Recht vorhanden. +function requireRecht(recht){ + return function(req, res, next){ + if(!req.user) return res.status(401).json({ ok: false, error: 'Nicht angemeldet.' }); + if(!hatRecht(req.user, recht)) return rechtFehlt(res, recht); + next(); + }; +} + // plan_checker_merkmale: firmenweite Grundliste der Merkmale, gegen die der // Plan Checker (Seitenleisten-Bereich "Studenten") einen hochgeladenen Plan // prüft (z. B. "Maßketten vorhanden"). "prompt" ist die Prüfanweisung, die @@ -1041,12 +1187,23 @@ function serializeWeek(week){ insAttendance.run(weekLabel, pid, att[pid] ? 1 : 0); }); - const insProject = db.prepare(`INSERT INTO projects (week_label, project_id, nr, name, status, status_author, status_at, "group", array_order, order_value, project_uid, honorar, besprochen, auftragsstatus, groesse, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + (Array.isArray(week.projects) ? week.projects : []).forEach((p, idx) => serializeProject(weekLabel, p, idx, nowIso)); + // Rückgabe der Revision für den Aufrufer (saveWeekRelational -> Route) + return nowIso; +} + +// projektStatements: die vorbereiteten INSERTs für ein Projekt, einmal angelegt. +let projektStatementsCache = null; +function projektStatements(){ + if(projektStatementsCache) return projektStatementsCache; + const insProject = db.prepare(`INSERT INTO projects (week_label, project_id, nr, name, status, status_author, status_at, "group", array_order, order_value, project_uid, honorar, besprochen, auftragsstatus, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); const insBeauftragteLp = db.prepare(`INSERT OR IGNORE INTO project_beauftragte_lph (week_label, project_id, lph) VALUES (?, ?, ?)`); const insGewerk = db.prepare(`INSERT OR IGNORE INTO project_gewerke (week_label, project_id, gewerk) VALUES (?, ?, ?)`); + const insKommentar = db.prepare(`INSERT OR REPLACE INTO project_kommentare (week_label, project_id, gewerk, status, status_author, status_at, besprochen) + VALUES (?, ?, ?, ?, ?, ?, ?)`); const insTag = db.prepare(`INSERT OR IGNORE INTO project_tags (week_label, project_id, tag, tag_order) VALUES (?, ?, ?, ?)`); const insLph = db.prepare(`INSERT OR REPLACE INTO project_lph (week_label, project_id, lph, value) @@ -1061,8 +1218,8 @@ function serializeWeek(week){ VALUES (?, ?, ?, ?, ?)`); const insPlanPerson = db.prepare(`INSERT OR IGNORE INTO plan_people (week_label, project_id, person_id, array_order) VALUES (?, ?, ?, ?)`); - const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order, show_in_gantt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order, show_in_gantt, leistungsphase, aufwand_stunden, notiz) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); const insTodoAssignee = db.prepare(`INSERT OR IGNORE INTO plan_todo_assignees (week_label, project_id, todo_id, person_id) VALUES (?, ?, ?, ?)`); const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id) @@ -1072,76 +1229,105 @@ function serializeWeek(week){ const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id) VALUES (?, ?, ?, ?)`); - (Array.isArray(week.projects) ? week.projects : []).forEach((p, idx) => { - const projId = String(p.id || ''); - // status trägt Formatierungs-HTML (Fett/Kursiv/Farbe aus dem - // Kommentar-Editor) und wird hier — an der Schreibstelle — auf die - // erlaubte Positivliste reduziert, nicht erst beim Anzeigen. Siehe - // sanitizeFormatHtml() oben. - insProject.run(weekLabel, projId, String(p.nr || ''), String(p.name || ''), - sanitizeFormatHtml(p.status), String(p.statusAuthor || ''), String(p.statusAt || ''), - String(p.group || ''), idx, typeof p.order === 'number' ? p.order : idx, - String(p.projectUid || ''), Number(p.honorar) || 0, p.besprochen ? 1 : 0, - normalizeAuftragsstatus(p.auftragsstatus), normalizeGroesse(p.groesse), nowIso); - (Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []).forEach(lph => { - insBeauftragteLp.run(weekLabel, projId, String(lph || '')); - }); - // Nur gültige Gewerke schreiben: Ein unbekannter Wert wäre stiller - // Datenmüll, der die Beteiligten-Auswahl später leer laufen ließe. - (Array.isArray(p.gewerke) ? p.gewerke : []).forEach(g => { - const wert = String(g || '').trim().toUpperCase(); - if(VALID_DEPARTMENTS.has(wert)) insGewerk.run(weekLabel, projId, wert); - }); - (Array.isArray(p.tags) ? p.tags : []).forEach((tag, tidx) => { - const clean = String(tag || '').trim(); - if(clean) insTag.run(weekLabel, projId, clean, tidx); - }); - const lph = p.lph || {}; - Object.keys(lph).forEach(l => insLph.run(weekLabel, projId, String(l || ''), String(lph[l] === undefined ? '' : lph[l]))); - const assign = p.assign || {}; - Object.keys(assign).forEach(key => { - // Key-Format: "personId|Rolle" - const sep = key.indexOf('|'); - const pid = sep >= 0 ? key.slice(0, sep) : key; - const role = sep >= 0 ? key.slice(sep + 1) : ''; - insAssign.run(weekLabel, projId, pid, role, Number(assign[key]) || 0); - }); - const aufwand = p.aufwandProLP || {}; - Object.keys(aufwand).forEach(lph => { - const roles = aufwand[lph] || {}; - Object.keys(roles).forEach(role => { - const v = Number(roles[role]); - if(!isNaN(v)) insAufwand.run(weekLabel, projId, String(lph || ''), String(role || ''), v); - }); - }); - (Array.isArray(p.aktuelleLP) ? p.aktuelleLP : []).forEach(lph => { - insAktuelleLp.run(weekLabel, projId, String(lph || '')); - }); - const plan = p.plan || null; - if(plan){ - insPlan.run(weekLabel, projId, String(plan.start || ''), String(plan.end || ''), Number(plan.progress) || 0); - (Array.isArray(plan.people) ? plan.people : []).forEach((pid, pidx) => insPlanPerson.run(weekLabel, projId, String(pid || ''), pidx)); - (Array.isArray(plan.todos) ? plan.todos : []).forEach((t, tidx) => { - insTodo.run(weekLabel, projId, String(t.id || ''), String(t.title || ''), - String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx, - t.showInGantt === false ? 0 : 1); - (Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => insTodoAssignee.run(weekLabel, projId, String(t.id || ''), String(pid || ''))); - (Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || ''))); - }); - (Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => { - insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx); - (Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || ''))); - }); - } + projektStatementsCache = { insProject, insBeauftragteLp, insGewerk, insKommentar, insTag, insLph, insAssign, insAufwand, insAktuelleLp, insPlan, insPlanPerson, insTodo, insTodoAssignee, insTodoDep, insMilestone, insMilestoneDep }; + return projektStatementsCache; +} + +// serializeProject: schreibt EIN Projekt einer Sitzung in alle Projekttabellen +// (die Zeilen müssen vorher fehlen — deleteWeekData() bzw. deleteProjectData()). +// Gemeinsam genutzt von serializeWeek() und POST /save-projects. +// Parameter: weekLabel, p (Projekt), idx (Position, array_order), nowIso +// (Revision des Projekts, projects.updated_at). +function serializeProject(weekLabel, p, idx, nowIso){ + const { insProject, insBeauftragteLp, insGewerk, insKommentar, insTag, insLph, insAssign, insAufwand, insAktuelleLp, insPlan, insPlanPerson, insTodo, insTodoAssignee, insTodoDep, insMilestone, insMilestoneDep } = projektStatements(); + const projId = String(p.id || ''); + // status trägt Formatierungs-HTML (Fett/Kursiv/Farbe aus dem + // Kommentar-Editor) und wird hier — an der Schreibstelle — auf die + // erlaubte Positivliste reduziert, nicht erst beim Anzeigen. Siehe + // sanitizeFormatHtml() oben. + // status/status_author/status_at/besprochen bleiben leer: Kommentar und + // Haken stehen je Gewerk in project_kommentare (siehe dort). + insProject.run(weekLabel, projId, String(p.nr || ''), String(p.name || ''), + '', '', '', + String(p.group || ''), idx, typeof p.order === 'number' ? p.order : idx, + String(p.projectUid || ''), Number(p.honorar) || 0, 0, + normalizeAuftragsstatus(p.auftragsstatus), nowIso); + (Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []).forEach(lph => { + insBeauftragteLp.run(weekLabel, projId, String(lph || '')); }); - // Rückgabe der Revision für den Aufrufer (saveWeekRelational -> Route) - return nowIso; + // Nur gültige Gewerke schreiben: Ein unbekannter Wert wäre stiller + // Datenmüll, der die Beteiligten-Auswahl später leer laufen ließe. + (Array.isArray(p.gewerke) ? p.gewerke : []).forEach(g => { + const wert = String(g || '').trim().toUpperCase(); + if(VALID_DEPARTMENTS.has(wert)) insGewerk.run(weekLabel, projId, wert); + }); + (Array.isArray(p.tags) ? p.tags : []).forEach((tag, tidx) => { + const clean = String(tag || '').trim(); + if(clean) insTag.run(weekLabel, projId, clean, tidx); + }); + const kommentare = (p.kommentare && typeof p.kommentare === 'object') ? p.kommentare : {}; + Object.keys(kommentare).forEach(g => { + const wert = String(g || '').trim().toUpperCase(); + const k = kommentare[g] || {}; + const text = sanitizeFormatHtml(k.status); + // Leere Einträge nicht speichern — kein Kommentar und kein Haken ist + // derselbe Zustand wie keine Zeile. + if(!VALID_DEPARTMENTS.has(wert) || (!text && !k.besprochen)) return; + insKommentar.run(weekLabel, projId, wert, text, + text ? String(k.statusAuthor || '') : '', text ? String(k.statusAt || '') : '', k.besprochen ? 1 : 0); + }); + const lph = p.lph || {}; + Object.keys(lph).forEach(l => insLph.run(weekLabel, projId, String(l || ''), String(lph[l] === undefined ? '' : lph[l]))); + const assign = p.assign || {}; + Object.keys(assign).forEach(key => { + // Key-Format: "personId|Rolle" + const sep = key.indexOf('|'); + const pid = sep >= 0 ? key.slice(0, sep) : key; + const role = sep >= 0 ? key.slice(sep + 1) : ''; + insAssign.run(weekLabel, projId, pid, role, Number(assign[key]) || 0); + }); + const aufwand = p.aufwandProLP || {}; + Object.keys(aufwand).forEach(lph => { + const roles = aufwand[lph] || {}; + Object.keys(roles).forEach(role => { + const v = Number(roles[role]); + if(!isNaN(v)) insAufwand.run(weekLabel, projId, String(lph || ''), String(role || ''), v); + }); + }); + (Array.isArray(p.aktuelleLP) ? p.aktuelleLP : []).forEach(lph => { + insAktuelleLp.run(weekLabel, projId, String(lph || '')); + }); + const plan = p.plan || null; + if(plan){ + insPlan.run(weekLabel, projId, String(plan.start || ''), String(plan.end || ''), Number(plan.progress) || 0); + (Array.isArray(plan.people) ? plan.people : []).forEach((pid, pidx) => insPlanPerson.run(weekLabel, projId, String(pid || ''), pidx)); + (Array.isArray(plan.todos) ? plan.todos : []).forEach((t, tidx) => { + insTodo.run(weekLabel, projId, String(t.id || ''), String(t.title || ''), + String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx, + t.showInGantt === false ? 0 : 1, String(t.lph || ''), + Math.max(0, Number(t.aufwand) || 0), String(t.notiz || '').slice(0, 20000)); + (Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => insTodoAssignee.run(weekLabel, projId, String(t.id || ''), String(pid || ''))); + (Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || ''))); + }); + (Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => { + insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx); + (Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || ''))); + }); + } +} + +// deleteProjectData: entfernt alle Zeilen EINES Projekts einer Sitzung. +function deleteProjectData(weekLabel, projectId){ + ['project_tags','project_lph','project_assign','project_aufwand','project_aktuelle_lp', + 'project_beauftragte_lph','project_gewerke','project_kommentare','project_plan','plan_people', + 'plan_todos','plan_todo_assignees','plan_todo_deps','plan_milestones','plan_milestone_deps','projects'] + .forEach(t => db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND project_id = ?`).run(weekLabel, projectId)); } // deleteWeekData: entfernt alle relationalen Zeilen einer Woche (Label) function deleteWeekData(weekLabel){ const tables = ['week_people_roles','week_people','attendance','project_tags','project_lph','project_assign', - 'project_aufwand','project_aktuelle_lp','project_beauftragte_lph','project_gewerke', + 'project_aufwand','project_aktuelle_lp','project_beauftragte_lph','project_gewerke','project_kommentare', 'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps', 'plan_milestones','plan_milestone_deps','projects']; tables.forEach(t => { @@ -1173,19 +1359,32 @@ function saveWeekRelational(week){ // gescopte) Personenliste aus den Tabellen users/user_roles/user_absences // zusammen. Rückgabeform unverändert: Array von // {id,name,fullName,email,role:[],absences:[]}. -function loadGlobalPeople(){ +// fuerUser (optional): Art und Notiz einer Abwesenheit sieht nur die Person +// selbst und der Administrator (feste Rolle 'admin'); für alle anderen kommen +// fremde Abwesenheiten als „abwesend“ ohne Notiz an — unabhängig von weiteren +// Rechten (Eriks Vorgabe: „jeder User sieht nur bei sich die genauen Gründe“, +// „der Admin sieht alles“). darfAbw sagt +// dem Browser, für wen er eintragen darf. Ohne fuerUser (Sitzungswechsel +// u. ä., nie an einen Browser) ungefiltert. +function loadGlobalPeople(fuerUser){ // person_id != '' : Ein Konto ohne Personen-Kennung (z. B. das Bootstrap- // Konto "admin") ist keine planbare Person. Ohne diesen Filter landen // mehrere solcher Konten mit derselben leeren Kennung im Teilnehmerkreis // einer Sitzung und verletzen dort den Primärschlüssel // (week_label, person_id) — das Speichern schlüge dauerhaft mit 500 fehl. - const users = db.prepare(`SELECT * FROM users WHERE active = 1 AND person_id != '' ORDER BY display_name, username`).all(); + const users = db.prepare(`SELECT * FROM users WHERE active = 1 AND person_id != '' AND ${sqlOhneWandtafel()} ORDER BY display_name, username`).all(); const roles = db.prepare(`SELECT * FROM user_roles ORDER BY role_order`).all(); const absences = db.prepare(`SELECT * FROM user_absences`).all(); const rolesByUser = {}; roles.forEach(r => (rolesByUser[r.user_id] = rolesByUser[r.user_id] || []).push(r.role)); - const absByUser = {}; absences.forEach(a => (absByUser[a.user_id] = absByUser[a.user_id] || []).push({ id:a.id, start:a.start, end:a.end, type:a.type, note:a.note })); + const ziele = fuerUser ? abwesenheitZiele(fuerUser) : null; + const sichtbar = id => !fuerUser || fuerUser.role === 'admin' || id === fuerUser.id; + const absByUser = {}; absences.forEach(a => (absByUser[a.user_id] = absByUser[a.user_id] || []).push(sichtbar(a.user_id) + ? { id:a.id, start:a.start, end:a.end, type:a.type, note:a.note } + : { id:a.id, start:a.start, end:a.end, type:'abwesend', note:'', verdeckt:true })); return users.map(u => ({ id: u.person_id, + userId: u.id, + darfAbw: !!ziele && (ziele.alle || ziele.ids.has(u.id)), name: u.initials || u.username, fullName: u.display_name, email: u.email, @@ -1207,26 +1406,35 @@ function loadGlobalPeople(){ // pro Abteilung) aus den relationalen Tabellen wieder zu kompletten // Wochen-Objekten zusammen. Rückgabe: Array von Wochen-Objekten, sortiert // nach Label. -function loadWeeks(){ - const weeks = db.prepare('SELECT * FROM weeks ORDER BY label').all(); - const weekPeople = db.prepare('SELECT * FROM week_people ORDER BY array_order').all(); - const weekRoles = db.prepare('SELECT * FROM week_people_roles ORDER BY role_order').all(); - const attendance = db.prepare('SELECT * FROM attendance').all(); - const projects = db.prepare('SELECT * FROM projects ORDER BY array_order').all(); - const tags = db.prepare('SELECT * FROM project_tags ORDER BY tag_order').all(); - const lph = db.prepare('SELECT * FROM project_lph').all(); - const assign = db.prepare('SELECT * FROM project_assign').all(); - const aufwand = db.prepare('SELECT * FROM project_aufwand').all(); - const aktuelleLp = db.prepare('SELECT * FROM project_aktuelle_lp').all(); - const beauftragteLp = db.prepare('SELECT * FROM project_beauftragte_lph').all(); - const gewerke = db.prepare('SELECT * FROM project_gewerke').all(); - const plans = db.prepare('SELECT * FROM project_plan').all(); - const planPeople = db.prepare('SELECT * FROM plan_people ORDER BY array_order').all(); - const todos = db.prepare('SELECT * FROM plan_todos ORDER BY sort_order').all(); - const todoAssignees = db.prepare('SELECT * FROM plan_todo_assignees').all(); - const todoDeps = db.prepare('SELECT * FROM plan_todo_deps').all(); - const milestones = db.prepare('SELECT * FROM plan_milestones ORDER BY sort_order').all(); - const milestoneDeps = db.prepare('SELECT * FROM plan_milestone_deps').all(); +function loadWeeks(nurLabel){ + // nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die + // Live-Ereignisse, die sonst jedes Mal alle Sitzungen zusammensetzen müssten. + const alle = (tabelle, reihenfolge) => { + const spalte = tabelle === 'weeks' ? 'label' : 'week_label'; + const wo = nurLabel === undefined ? '' : ` WHERE ${spalte} = ?`; + const st = db.prepare(`SELECT * FROM ${tabelle}${wo}${reihenfolge ? ' ' + reihenfolge : ''}`); + return nurLabel === undefined ? st.all() : st.all(String(nurLabel)); + }; + const weeks = alle('weeks', 'ORDER BY label'); + const weekPeople = alle('week_people', 'ORDER BY array_order'); + const weekRoles = alle('week_people_roles', 'ORDER BY role_order'); + const attendance = alle('attendance'); + const projects = alle('projects', 'ORDER BY array_order'); + const tags = alle('project_tags', 'ORDER BY tag_order'); + const lph = alle('project_lph'); + const assign = alle('project_assign'); + const aufwand = alle('project_aufwand'); + const aktuelleLp = alle('project_aktuelle_lp'); + const beauftragteLp = alle('project_beauftragte_lph'); + const gewerke = alle('project_gewerke'); + const kommentare = alle('project_kommentare'); + const plans = alle('project_plan'); + const planPeople = alle('plan_people', 'ORDER BY array_order'); + const todos = alle('plan_todos', 'ORDER BY sort_order'); + const todoAssignees = alle('plan_todo_assignees'); + const todoDeps = alle('plan_todo_deps'); + const milestones = alle('plan_milestones', 'ORDER BY sort_order'); + const milestoneDeps = alle('plan_milestone_deps'); // Index-Karten für schnellen Zugriff const weekKey = (label) => label; @@ -1257,14 +1465,13 @@ function loadWeeks(){ const projectsMap = {}; projects.forEach(r => { projectsMap[projKey(r.week_label, r.project_id)] = { - id: r.project_id, nr: r.nr, name: r.name, status: r.status, - statusAuthor: r.status_author || '', statusAt: r.status_at || '', + id: r.project_id, nr: r.nr, name: r.name, + // Kommentar und Besprochen-Haken je Gewerk (project_kommentare). + kommentare: {}, group: r.group, tags: [], lph: {}, assign: {}, order: r.order_value, plan: null, projectUid: r.project_uid, honorar: r.honorar, - besprochen: !!r.besprochen, - groesse: normalizeGroesse(r.groesse), // Rückfall auf die Vorgabe, weil Zeilen aus der Zeit vor der Spalte // (und aus einem älteren Backup, siehe IMPORT_OPTIONAL_COLS) einen // leeren Wert tragen können — die Oberfläche soll keinen Leerzustand @@ -1298,6 +1505,10 @@ function loadWeeks(){ const p = projectsMap[projKey(r.week_label, r.project_id)]; if(p && !p.gewerke.includes(r.gewerk)) p.gewerke.push(r.gewerk); }); + kommentare.forEach(r => { + const p = projectsMap[projKey(r.week_label, r.project_id)]; + if(p) p.kommentare[r.gewerk] = { status: r.status || '', statusAuthor: r.status_author || '', statusAt: r.status_at || '', besprochen: !!r.besprochen }; + }); const planMap = {}; plans.forEach(r => { planMap[projKey(r.week_label, r.project_id)] = { start: r.start, end: r.end, progress: r.progress, people: [], todos: [], milestones: [] }; }); @@ -1309,7 +1520,7 @@ function loadWeeks(){ const todoMap = {}; todos.forEach(r => { const k = todoKey(r.week_label, r.project_id, r.todo_id); - todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress, showInGantt: !!r.show_in_gantt }; + todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress, showInGantt: !!r.show_in_gantt, lph: r.leistungsphase || '', aufwand: Number(r.aufwand_stunden) || 0, notiz: r.notiz || '' }; }); todoAssignees.forEach(r => { const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)]; @@ -1452,7 +1663,12 @@ const IMPORT_TABLES = ['weeks','week_people','week_people_roles','attendance','p 'project_gewerke', 'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps', 'plan_milestones','plan_milestone_deps', - 'project_protokolle','protokoll_teilnehmer','protokoll_punkte']; + 'project_protokolle','protokoll_teilnehmer','protokoll_punkte', + 'project_kommentare']; +// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen. +// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk() +// verteilt die alten Kommentare danach aus projects.status. +const IMPORT_OPTIONAL_TABLES = ['project_kommentare']; // dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen // jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info. @@ -1467,12 +1683,37 @@ function dbTableColumns(conn, table){ // Wiederherstellung geschrieben wird, nach einem Versionssprung nicht mehr // einspielbar. const IMPORT_OPTIONAL_COLS = ['updated_at', 'project_uid', 'honorar', 'status_author', 'status_at', - 'auftragsstatus', 'groesse', 'besprochen']; + 'auftragsstatus', 'besprochen', 'leistungsphase', 'aufwand_stunden', 'notiz']; // restoreDbFromBackup: validiert die Backup-Datei (muss das bekannte Schema // mit identischen Spalten wie die Live-DB besitzen) und ersetzt den // Datenbestand der Live-DB durch den Snapshot. Parameter: backupPath (Pfad). // Wirft bei ungültigem Schema oder Transferfehler (Transaktion wird gerollt). +// pruefeBackupDatei: öffnet die Datei und prüft das Schema (Schritt 1 von +// restoreDbFromBackup()) — eigenständig, damit /import eine ungültige Datei +// mit 400 abweisen kann, BEVOR runAutoBackup() läuft. Vorher erzeugte jeder +// Fehlversuch ein Auto-Backup und verdrängte so echte ältere. +// Wirft mit verständlicher Meldung. Parameter: backupPath. +function pruefeBackupDatei(backupPath){ + let conn; + try { conn = new DatabaseSync(backupPath, { readOnly: true }); conn.prepare('SELECT 1 FROM sqlite_master LIMIT 1').get(); } + catch(err){ if(conn) conn.close(); throw new Error('Die Datei ist keine gültige Datenbank-Sicherung.'); } + try { + for(const t of IMPORT_TABLES){ + const liveCols = dbTableColumns(db, t); + const backCols = dbTableColumns(conn, t); + if(backCols.length === 0){ + if(IMPORT_OPTIONAL_TABLES.includes(t)) continue; + throw new Error('Ungültiges Backup (Tabelle fehlt): ' + t); + } + const missing = liveCols.filter(c => !backCols.includes(c) && !IMPORT_OPTIONAL_COLS.includes(c)); + if(missing.length > 0) throw new Error('Backup-Schema passt nicht zur Live-DB (Tabelle ' + t + ', fehlt: ' + missing.join(', ') + ')'); + } + } finally { + conn.close(); + } +} + function restoreDbFromBackup(backupPath){ const backupConn = new DatabaseSync(backupPath, { readOnly: true }); try { @@ -1483,7 +1724,10 @@ function restoreDbFromBackup(backupPath){ const liveCols = dbTableColumns(db, t); const backCols = dbTableColumns(backupConn, t); if(liveCols.length === 0) throw new Error('Live-Schema defekt (Tabelle fehlt): ' + t); - if(backCols.length === 0) throw new Error('Ungültiges Backup (Tabelle fehlt): ' + t); + if(backCols.length === 0){ + if(IMPORT_OPTIONAL_TABLES.includes(t)) continue; + throw new Error('Ungültiges Backup (Tabelle fehlt): ' + t); + } const missing = liveCols.filter(c => !backCols.includes(c) && !IMPORT_OPTIONAL_COLS.includes(c)); if(missing.length > 0) throw new Error('Backup-Schema passt nicht zur Live-DB (Tabelle ' + t + ', fehlt: ' + missing.join(', ') + ')'); } @@ -1503,6 +1747,7 @@ function restoreDbFromBackup(backupPath){ // Nur Spalten übertragen, die BEIDE Schemas enthalten (alte Backups // ohne updated_at bleiben so importierbar). const common = liveCols.filter(c => backCols.includes(c)); + if(common.length === 0) continue; // optionale Tabelle fehlt im Backup // Anführungszeichen um jede Spalte: `projects` enthält die // SQL-Schlüsselwort-Spalte "group", die sonst einen Syntaxfehler wirft. const quoted = common.map(c => '"' + c + '"').join(', '); @@ -2190,22 +2435,6 @@ function migrateAddProjectBesprochen(){ } } -// migrateAddProjectGroesse: rüstet die Spalte `groesse` nach (idempotentes -// ALTER TABLE, gleiches Muster wie migrateAddProjectBesprochen()). -// Bestandszeilen starten mit 0 = keine Angabe; sie erscheinen in der -// Auslastung als nicht bewertbar, bis jemand entweder ein Honorar oder eine -// Größe pflegt. Absichtlich kein Vorgabewert wie 0,5: Eine erfundene Größe -// erzeugte Zahlen, die nach Messung aussehen. -// normalizeGroesse: Projektgröße auf den gültigen Bereich bringen. -// Erlaubt ist 0 bis 1 (0 = keine Angabe, 1 = Projekt in Höhe des -// Referenzhonorars). Unbrauchbare Werte werden zu 0 und damit zu einer -// sichtbaren Lücke, nicht zu einer erfundenen Zahl. -// Parameter: wert (beliebig). Rückgabe: Zahl zwischen 0 und 1. -function normalizeGroesse(wert){ - const n = Number(wert); - if(!Number.isFinite(n) || n <= 0) return 0; - return n > 1 ? 1 : n; -} // migrateBackfillProjectGewerkeAusTags: setzt für Projekte OHNE jede // project_gewerke-Zeile das Gewerk aus dem Schlagwort, das @@ -2242,16 +2471,210 @@ function migrateBackfillProjectGewerkeAusTags(){ } } -function migrateAddProjectGroesse(){ +// migrateKommentareJeGewerk: verteilt die alten, gewerkübergreifenden +// Kommentare und Haken (projects.status/status_author/status_at/besprochen) +// auf project_kommentare. Läuft bei jedem Start und nach jedem DB-Import; +// idempotent, weil die Altspalten danach geleert werden. +// Zuordnung je Kommentar: das Gewerk des Verfassers (users.department über +// den Anzeigenamen), wenn das Projekt dieses Gewerk hat; sonst das einzige +// Gewerk des Projekts; bei mehreren und unbekanntem Verfasser alle Gewerke +// des Projekts — welches gemeint war, lässt sich dann nicht mehr sagen. +// Projekte ohne Gewerk bleiben unverändert (sie erscheinen ohnehin in keinem +// Gewerke-Wochenbericht). +function migrateKommentareJeGewerk(){ + const zeilen = db.prepare(`SELECT week_label, project_id, status, status_author, status_at, besprochen + FROM projects WHERE status != '' OR besprochen = 1`).all(); + if(!zeilen.length) return; + const gewerkeVon = db.prepare('SELECT gewerk FROM project_gewerke WHERE week_label = ? AND project_id = ?'); + const abteilungVon = db.prepare("SELECT department FROM users WHERE display_name = ? AND department != '' LIMIT 1"); + const vorhanden = db.prepare('SELECT 1 FROM project_kommentare WHERE week_label = ? AND project_id = ? AND gewerk = ?'); + const ins = db.prepare(`INSERT INTO project_kommentare (week_label, project_id, gewerk, status, status_author, status_at, besprochen) + VALUES (?, ?, ?, ?, ?, ?, ?)`); + const leeren = db.prepare(`UPDATE projects SET status = '', status_author = '', status_at = '', besprochen = 0 + WHERE week_label = ? AND project_id = ?`); + let anzahl = 0; + db.exec('BEGIN'); + try { + zeilen.forEach(r => { + const gewerke = gewerkeVon.all(r.week_label, r.project_id).map(x => x.gewerk); + if(!gewerke.length) return; + let ziele = gewerke; + if(r.status && gewerke.length > 1){ + const a = r.status_author ? abteilungVon.get(r.status_author) : null; + if(a && gewerke.includes(a.department)) ziele = [a.department]; + } + ziele.forEach(g => { + if(vorhanden.get(r.week_label, r.project_id, g)) return; + ins.run(r.week_label, r.project_id, g, r.status || '', r.status_author || '', r.status_at || '', r.besprochen ? 1 : 0); + }); + leeren.run(r.week_label, r.project_id); + anzahl++; + }); + db.exec('COMMIT'); + if(anzahl) console.log('Kommentare je Gewerk übernommen:', anzahl, 'Projekt-Fassung(en).'); + } catch(err){ + db.exec('ROLLBACK'); + console.error('Übernahme der Kommentare je Gewerk fehlgeschlagen:', err.message); + } +} + +// migrateDropProjectGroesse: entfernt die frühere Spalte `groesse` +// (Projektgröße 0–1, Ersatz für ein fehlendes Honorar in der alten +// Auslastungsrechnung). Seit die Auslastung allein aus Aufgaben-Stunden +// entsteht, hat sie keine Verwendung mehr — Eriks Wunsch, sie auch aus der +// Datenbank zu nehmen. Idempotent; ein Backup mit der Spalte bleibt +// importierbar (überzählige Spalten werden beim Import ignoriert). +function migrateDropProjectGroesse(){ const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name); - if(!cols.includes('groesse')){ - db.exec('ALTER TABLE projects ADD COLUMN groesse REAL NOT NULL DEFAULT 0'); - console.log('Projekt-Tabelle um groesse ergänzt.'); + if(cols.includes('groesse')){ + db.exec('ALTER TABLE projects DROP COLUMN groesse'); + console.log('Spalte projects.groesse entfernt.'); + } +} + +// einmaligeUmstellung: führt eine Datenumstellung genau einmal je +// Datenbank aus (Merker in der Tabelle umstellungen). Für Umstellungen, die +// nicht an ein neu angelegtes Feld gekoppelt werden können. +function einmaligeUmstellung(name, fn){ + db.exec('CREATE TABLE IF NOT EXISTS umstellungen (name TEXT PRIMARY KEY, am TEXT NOT NULL)'); + if(db.prepare('SELECT 1 FROM umstellungen WHERE name = ?').get(name)) return; + fn(); + db.prepare('INSERT INTO umstellungen (name, am) VALUES (?, ?)').run(name, new Date().toISOString()); +} + +// kommentarSpiegelstricheUmbrechen: Eriks Wunsch für die Bestandskommentare +// — jeder Spiegelstrich "- " beginnt eine eigene Zeile. Ältere Kommentare +// (aus der Zeit des Markup-Editors bzw. aus eingefügtem Text) tragen ihre +// Stichpunkte oft als eine Zeile "- A - B - C". Rein textuell, ohne DOM: +// Das HTML wird in Tags und Text zerlegt; Block-Tags (br/div/p) gelten als +// Zeilenanfang, Inline-Tags (b/i/span/font …) nicht. Ein "-" mit +// Leerzeichen (oder Anfang) davor und Leerzeichen/  danach bekommt ein +//
    vorangestellt — außer es steht schon am Zeilenanfang oder nur hinter +// einem Gewerke-Vorsatz wie "[ELT]" ("[ELT] - Text" bleibt eine Zeile). +// Bindestriche in Wörtern ("SV-Abnahme") bleiben unberührt. Repariert +// nebenbei als Text gespeicherte " " (siehe unten). +// Parameter: html (String). Rückgabe: String (unverändert, wenn nichts zu tun). +function kommentarSpiegelstricheUmbrechen(html){ + // Eine ältere Fassung des Kommentarfelds hat das geschützte Leerzeichen nach + // dem Strich als Text " " gespeichert (sichtbar als "- "). + if(html) html = String(html).replace(/&nbsp;/g, ' '); + if(!html || html.indexOf('-') === -1) return html; + const teile = String(html).split(/(<[^>]*>)/); + let zeile = ''; // sichtbarer Text der laufenden Zeile bis hierher + const aus = teile.map(t => { + if(t.startsWith('<')){ + if(/^<\/?(br|div|p)\b/i.test(t)) zeile = ''; + return t; + } + const neu = t.replace(/(^|\s)-(?=\s| |\u00a0)/g, (m, vor, pos, ganz) => { + const sichtbar = (zeile + ganz.slice(0, pos)).replace(/ /g, ' ').trim(); + if(sichtbar === '' || /^\[[^\]]{1,20}\]$/.test(sichtbar)) return m; + return '
    -'; + }).replace(/\s+
    -/g, '
    -'); + zeile += t; + return neu; + }); + return aus.join(''); +} + +// kommentarVorsatzEntfernen: nimmt den Gewerke-Vorsatz "[ELT] ", "[ELT+HLS] " +// am Textanfang weg. Ihn hat migrateRemoveDepartmentPartition() beim +// Zusammenführen der Abteilungen vor jeden Kommentar gesetzt; seit es +// Kommentare je Gewerk gibt, steht das Gewerk ohnehin am Eintrag und wird +// in der Anzeige als eigene Beschriftung gezeigt (Eriks Wunsch: "nur als +// Systemtext, komplett weg vom normalen Text"). Ein direkt folgendes
    +// fällt mit weg, damit keine Leerzeile am Anfang bleibt. +// Parameter: html (String). Rückgabe: String. +function kommentarVorsatzEntfernen(html){ + if(!html) return html; + return String(html).replace(/^\s*\[(?:ELT|HLS|ARCH)(?:\s*\+\s*(?:ELT|HLS|ARCH))*\]\s*(?:\s*)?/i, ''); +} + +function kommentareVorsatzUmstellen(){ + const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status LIKE '%[%'").all(); + const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?'); + let n = 0; + db.exec('BEGIN'); + try { + zeilen.forEach(r => { + const neu = kommentarVorsatzEntfernen(r.status); + if(neu !== r.status){ upd.run(neu, r.week_label, r.project_id, r.gewerk); n++; } + }); + db.exec('COMMIT'); + } catch(e){ db.exec('ROLLBACK'); throw e; } + if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.'); +} + +function kommentareSpiegelstricheUmstellen(){ + const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status != ''").all(); + const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?'); + let n = 0; + db.exec('BEGIN'); + try { + zeilen.forEach(r => { + const neu = kommentarSpiegelstricheUmbrechen(r.status); + if(neu !== r.status){ upd.run(neu, r.week_label, r.project_id, r.gewerk); n++; } + }); + db.exec('COMMIT'); + } catch(e){ db.exec('ROLLBACK'); throw e; } + console.log('Spiegelstriche in ' + n + ' Bestandskommentaren auf eigene Zeilen gesetzt.'); +} + +// migrateAddPlanTodoLeistungsphase: rüstet die Spalte `leistungsphase` nach +// (idempotent). Kennzeichnet eine Aufgabe als Balken einer Leistungsphase +// ("LPH 5") — die Auslastung verteilt die Reststunden einer Phase auf die +// Wochen ihres Balkens. Gesetzt von "Terminplan aus Honorar" und vom +// Projekt-Assistenten +// und "+ Neu" → "Leistungsphase"; ältere Balken kennzeichnet +// phasenbalkenAusTitel() einmalig. +function migrateAddPlanTodoLeistungsphase(){ + const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name); + if(!cols.includes('leistungsphase')){ + db.exec("ALTER TABLE plan_todos ADD COLUMN leistungsphase TEXT NOT NULL DEFAULT ''"); + console.log('plan_todos um leistungsphase ergänzt.'); + } + // Phasenbalken sind seit Eriks Wunsch NUR noch über leistungsphase eine + // Phase, nicht mehr über ihren Titel. Bestehende Balken, die bisher allein + // am Titel erkannt wurden ("LPH 5 (307 h)", "LPH 5 …", "W+M"), werden + // einmalig gekennzeichnet — danach zählt nur das Feld. + einmaligeUmstellung('phasenbalken-aus-titel', phasenbalkenAusTitel); +} + +// phasenbalkenAusTitel: kennzeichnet Balken, die bisher allein am Titel als +// Phase erkannt wurden. Läuft einmal je Datenbank und nach einem +// Backup-Import (ältere Backups kennen das Feld noch nicht). +function phasenbalkenAusTitel(){ + const n = db.prepare(`UPDATE plan_todos SET leistungsphase = CASE + WHEN title GLOB 'W+M' OR title GLOB 'W+M *' OR title GLOB 'W+M(*' THEN 'W+M' + ELSE substr(title, 1, 5) END + WHERE leistungsphase = '' AND (title GLOB 'LPH [1-9]' OR title GLOB 'LPH [1-9] *' OR title GLOB 'LPH [1-9](*' + OR title GLOB 'W+M' OR title GLOB 'W+M *' OR title GLOB 'W+M(*')`).run().changes; + if(n) console.log(n + ' Phasenbalken anhand ihres Titels als Leistungsphase gekennzeichnet.'); +} + +// migrateAddPlanTodoAufwand: aufwand_stunden — geschätzter Aufwand einer +// Aufgabe; zählt zur Auslastung der Zuständigen (verteilt auf den Zeitraum +// der Aufgabe). Idempotent. +function migrateAddPlanTodoAufwand(){ + const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name); + if(!cols.includes('aufwand_stunden')){ + db.exec('ALTER TABLE plan_todos ADD COLUMN aufwand_stunden REAL NOT NULL DEFAULT 0'); + console.log('plan_todos um aufwand_stunden ergänzt.'); + } +} + +// migrateAddPlanTodoNotiz: notiz — freier Text zur Aufgabe, z. B. die Mail, +// aus der „Termin aus Mail“ sie erzeugt hat (Absender, Datum, Text). Idempotent. +function migrateAddPlanTodoNotiz(){ + const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name); + if(!cols.includes('notiz')){ + db.exec("ALTER TABLE plan_todos ADD COLUMN notiz TEXT NOT NULL DEFAULT ''"); + console.log('plan_todos um notiz ergänzt.'); } } // migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach -// (idempotent, gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()). +// (idempotent, gleiches ALTER-TABLE-Muster wie migrateAddProjectBesprochen()). // DEFAULT 1, damit jede bestehende Aufgabe ihr bisheriges Verhalten behält // (sichtbar im GANTT, sobald Start UND Ende gesetzt sind) — die neue // Checkbox im Aufgaben-Dialog ("Im Terminplan anzeigen", plan-modals.js) @@ -2260,6 +2683,30 @@ function migrateAddProjectGroesse(){ // todos.js) gezeigt hatte, dass ein einzelner Import leicht 15-25 Aufgaben // auf einmal anlegt — alle MIT Datum (siehe dortiger Kopfkommentar), also // bisher alle zwingend auch als GANTT-Balken. +// migrateAddLphTemplateGewerk: Aufgaben-Vorlagen je Gewerk (Eriks Wunsch). +// Bestandsvorlagen behalten gewerk = '' und gelten dann für alle Gewerke. +function migrateAddLphTemplateGewerk(){ + const cols = db.prepare("SELECT name FROM pragma_table_info('lph_templates')").all().map(r => r.name); + if(!cols.includes('gewerk')){ + db.exec("ALTER TABLE lph_templates ADD COLUMN gewerk TEXT NOT NULL DEFAULT ''"); + console.log('lph_templates um gewerk ergänzt.'); + } + // anteil: Anteil des Schritts in Prozent (z. B. nach Siemon-Tabelle). + // Beim Einfügen bekommt jeder Schritt diesen Anteil an der Dauer der Phase + // (js/plan/plan-templates.js) — Eriks Wunsch. 0 = kein Anteil hinterlegt. + if(!cols.includes('anteil')){ + db.exec('ALTER TABLE lph_templates ADD COLUMN anteil REAL NOT NULL DEFAULT 0'); + console.log('lph_templates um anteil ergänzt.'); + } + // enthalten_in: Buchstaben der Schritte, in denen dieser enthalten ist + // (Siemon-Tabelle „in a) enth.“, z. B. 'a' oder 'a,b') — er läuft beim + // Einfügen zeitgleich mit ihnen statt mit eigenem Anteil. + if(!cols.includes('enthalten_in')){ + db.exec("ALTER TABLE lph_templates ADD COLUMN enthalten_in TEXT NOT NULL DEFAULT ''"); + console.log('lph_templates um enthalten_in ergänzt.'); + } +} + function migrateAddPlanTodoShowInGantt(){ const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name); if(!cols.includes('show_in_gantt')){ @@ -2269,7 +2716,7 @@ function migrateAddPlanTodoShowInGantt(){ } // migrateAddProjectUpdatedAt: rüstet die Spalte `updated_at` nach (idempotent, -// gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()). Gebraucht von +// gleiches ALTER-TABLE-Muster wie migrateAddProjectBesprochen()). Gebraucht von // der Planung-Sandbox, siehe Spaltenkommentar am CREATE TABLE oben. function migrateAddProjectUpdatedAt(){ const cols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map(r => r.name); @@ -2280,7 +2727,7 @@ function migrateAddProjectUpdatedAt(){ } // migrateAddUserAiKeyModel: rüstet die Spalte `model` nach (idempotent, -// gleiches Muster wie migrateAddProjectGroesse()). Leer = die in +// gleiches Muster wie migrateAddProjectBesprochen()). Leer = die in // erkennePunkteMitChatGPT()/erkennePunkteMitClaude()/pruefePlanMitChatGPT()/ // pruefePlanMitClaude() hartcodierte Vorgabe gilt weiterhin — nötig, seit // Erik mit Mammouth.ai einen eigenständigen Anbieter mit eigenem @@ -2301,7 +2748,7 @@ function migrateAddUserAiKeyModel(){ // migrateAddProtokollQuelle: rüstet die beiden Spalten nach, die ein // importiertes Protokoll von einem manuell angelegten unterscheiden -// (idempotent, gleiches Muster wie migrateAddProjectGroesse()). Beide +// (idempotent, gleiches Muster wie migrateAddProjectBesprochen()). Beide // bleiben für ein manuell angelegtes Protokoll leer — siehe // legeProtokollAn() und CLAUDE.md "### Protokolle". function migrateAddProtokollQuelle(){ @@ -2411,7 +2858,13 @@ function formatWeekLabel(week, year){ // Serverbetrieb nie stören. Keine Parameter, keine Rückgabe. function ensureCurrentWeekRollover(){ try { - const echteWochen = loadWeeks().filter(w => w.label !== SANDBOX_WEEK_LABEL); + // Nur die jüngste Sitzung laden — alle Sitzungen stündlich komplett + // zusammenzusetzen kostete auf dem Pi Zero unnötig Speicher. + const juengstesLabel = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL) + .map(r => r.label) + .sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b))) + .pop(); + const echteWochen = juengstesLabel === undefined ? [] : loadWeeks(juengstesLabel); const { week, year } = getIsoWeekInfo(new Date()); const heuteKey = String(year) + '-' + String(week).padStart(2, '0'); const neuesLabel = formatWeekLabel(week, year); @@ -2439,16 +2892,24 @@ function ensureCurrentWeekRollover(){ delete neueWoche.updatedAt; (neueWoche.projects || []).forEach(p => { p.id = crypto.randomUUID(); - p.status = ''; - p.statusAuthor = ''; - p.statusAt = ''; - p.besprochen = false; + // Eriks Wunsch: Der Kommentar der Vorwoche wird je Gewerk übernommen + // und in der Besprechung angepasst; nur der Besprochen-Haken gehört + // zur Sitzung und beginnt leer. + const vorher = p.kommentare || {}; + p.kommentare = {}; + Object.keys(vorher).forEach(g => { + const k = vorher[g] || {}; + if(!String(k.status || '').trim()) return; + p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '', + statusAt: k.statusAt || '', besprochen: false }; + }); delete p.statusState; }); } saveWeekRelational(neueWoche); console.log('Automatischer Sitzungswechsel: neue Sitzung "' + neuesLabel + '" angelegt.'); + if(typeof ereignisSenden === 'function') ereignisSenden({ typ: 'neu' }, null); if(Number(readAppSettings().autoLockAktiv)){ const nowIso = new Date().toISOString(); @@ -2567,6 +3028,13 @@ function migrateAddProjectAuftragsstatus(){ // bestehenden users-Tabellen (idempotent; neue DBs haben sie bereits). function migrateUsersDepartment(){ const cols = db.prepare("SELECT name FROM pragma_table_info('users')").all().map(r => r.name); + // einfuehrung_am: wann das Konto den Rundgang (public/js/einfuehrung.js) + // abgeschlossen hat; leer = steht noch aus. Bewusst auch für bestehende + // Konten leer (Eriks Wunsch: jeder macht den Rundgang einmal). + if(!cols.includes('einfuehrung_am')){ + db.exec("ALTER TABLE users ADD COLUMN einfuehrung_am TEXT NOT NULL DEFAULT ''"); + console.log('Benutzer-Tabelle um einfuehrung_am ergänzt.'); + } if(!cols.includes('department')){ db.exec("ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''"); console.log('Benutzer-Tabelle um department ergänzt.'); @@ -2716,9 +3184,13 @@ migrateAddProjectBesprochen(); migrateAddProjectHonorar(); migrateAddProjectStatusAuthor(); migrateAddProjectAuftragsstatus(); -migrateAddProjectGroesse(); +migrateDropProjectGroesse(); migrateAddProjectUpdatedAt(); migrateAddPlanTodoShowInGantt(); +migrateAddLphTemplateGewerk(); +migrateAddPlanTodoLeistungsphase(); +migrateAddPlanTodoAufwand(); +migrateAddPlanTodoNotiz(); migrateAddUserAiKeyModel(); migrateAddProtokollQuelle(); migrateAddProtokollPunktErstelltAm(); @@ -2726,6 +3198,342 @@ migrateAddProtokollPunktStatus(); migrateAddProtokollPunktEntfaelltNaechstes(); migrateUsersDepartment(); migrateUsersVorNachname(); +migrateKommentareJeGewerk(); +// ---------- Aufgaben-Vorlagen: Grundleistungen nach HOAI ---------- +// Eriks Wunsch: die Aufgaben-Vorlagen je Gewerk mit den Grundleistungen der +// HOAI füllen — ARCH nach Anlage 10.1 (Gebäude und Innenräume), ELT und HLS +// nach Anlage 15.1 (Technische Ausrüstung; die HOAI unterscheidet die +// Anlagengruppen bei den Grundleistungen nicht). Titel sind gekürzt, der +// Buchstabe der HOAI steht am Ende. Ohne Termin (duration_days = 0, siehe +// lph_templates): Die Dauer legt man im Projekt fest. Die Prozentanteile der +// Siemon-Tabelle sind bewusst NICHT hinterlegt — sie lagen nicht vor. +// Läuft einmal (einmaligeUmstellung) und nur für Gewerke ohne eigene Vorlagen. +const HOAI_GRUNDLEISTUNGEN = { + gebaeude: { + 'LPH 1': [ + 'Aufgabenstellung klären (Vorgaben, Bedarfsplanung) (a)', + 'Ortsbesichtigung (b)', + 'Zum Leistungs- und Untersuchungsbedarf beraten (c)', + 'Entscheidungshilfen zur Auswahl weiterer Fachplaner formulieren (d)', + 'Ergebnisse zusammenfassen, erläutern und dokumentieren (e)'], + 'LPH 2': [ + 'Grundlagen analysieren, Leistungen mit Fachplanern abstimmen (a)', + 'Zielvorstellungen abstimmen, auf Zielkonflikte hinweisen (b)', + 'Vorplanung erarbeiten, Varianten untersuchen und bewerten (c)', + 'Wesentliche Zusammenhänge, Vorgaben und Bedingungen klären (d)', + 'Ergebnisse für Fachplaner bereitstellen, Koordination und Integration (e)', + 'Vorverhandlungen über die Genehmigungsfähigkeit (f)', + 'Kostenschätzung nach DIN 276, Vergleich mit Budget (g)', + 'Terminplan mit den wesentlichen Vorgängen erstellen (h)', + 'Ergebnisse zusammenfassen, erläutern und dokumentieren (i)'], + 'LPH 3': [ + 'Entwurfsplanung bis zum vollständigen Entwurf erarbeiten (a)', + 'Ergebnisse für Fachplaner bereitstellen, Koordination und Integration (b)', + 'Objektbeschreibung (c)', + 'Verhandlungen über die Genehmigungsfähigkeit (d)', + 'Kostenberechnung nach DIN 276, Vergleich mit Kostenschätzung (e)', + 'Terminplan fortschreiben (f)', + 'Ergebnisse zusammenfassen, erläutern und dokumentieren (g)'], + 'LPH 4': [ + 'Vorlagen und Nachweise für Genehmigungen erarbeiten und zusammenstellen (a)', + 'Vorlagen einreichen (b)', + 'Planungsunterlagen, Beschreibungen und Berechnungen ergänzen und anpassen (c)'], + 'LPH 5': [ + 'Ausführungsplanung mit allen Einzelangaben erarbeiten (a)', + 'Ausführungs-, Detail- und Konstruktionszeichnungen (b)', + 'Ergebnisse für Fachplaner bereitstellen, Koordination und Integration (c)', + 'Terminplan fortschreiben (d)', + 'Ausführungsplanung während der Objektausführung fortschreiben (e)', + 'Montagepläne auf Übereinstimmung mit der Ausführungsplanung prüfen (f)'], + 'LPH 6': [ + 'Vergabeterminplan aufstellen (a)', + 'Leistungsbeschreibungen mit Leistungsverzeichnissen aufstellen, Mengen ermitteln (b)', + 'Schnittstellen zu den Leistungsbeschreibungen der Fachplaner abstimmen (c)', + 'Kosten anhand bepreister Leistungsverzeichnisse ermitteln (d)', + 'Kostenkontrolle: bepreiste LV mit Kostenberechnung vergleichen (e)', + 'Vergabeunterlagen für alle Leistungsbereiche zusammenstellen (f)'], + 'LPH 7': [ + 'Vergaben der Fachplaner koordinieren (a)', + 'Angebote einholen (b)', + 'Angebote prüfen und werten, Preisspiegel aufstellen (c)', + 'Bietergespräche führen (d)', + 'Vergabevorschläge erstellen, Vergabeverfahren dokumentieren (e)', + 'Vertragsunterlagen für alle Leistungsbereiche zusammenstellen (f)', + 'Kostenkontrolle: Ausschreibungsergebnisse mit bepreisten LV vergleichen (g)', + 'Bei der Auftragserteilung mitwirken (h)'], + 'LPH 8': [ + 'Ausführung überwachen (Genehmigung, Verträge, Pläne, Regeln der Technik) (a)', + 'Ausführung einfacher Tragwerke auf Standsicherheitsnachweis überwachen (b)', + 'An der Objektüberwachung Beteiligte koordinieren (c)', + 'Terminplan (Balkendiagramm) aufstellen, fortschreiben, überwachen (d)', + 'Bauablauf dokumentieren (Bautagebuch) (e)', + 'Gemeinsames Aufmaß mit den ausführenden Unternehmen (f)', + 'Rechnungsprüfung einschließlich Prüfen der Aufmaße (g)', + 'Rechnungsprüfungen mit Auftragssummen inkl. Nachträgen vergleichen (h)', + 'Kostenkontrolle: Abrechnung mit Vertragspreisen vergleichen (i)', + 'Kostenfeststellung nach DIN 276 (j)', + 'Abnahme organisieren, Mängel feststellen, Abnahmeempfehlung (k)', + 'Öffentlich-rechtliche Abnahmen beantragen und teilnehmen (l)', + 'Dokumentation, Zeichnungen und Berechnungen systematisch zusammenstellen (m)', + 'Objekt übergeben (n)', + 'Verjährungsfristen für Mängelansprüche auflisten (o)', + 'Beseitigung der Abnahmemängel überwachen (p)'], + 'LPH 9': [ + 'Innerhalb der Verjährungsfristen festgestellte Mängel fachlich bewerten (a)', + 'Objektbegehung vor Ablauf der Verjährungsfristen (b)', + 'Bei der Freigabe von Sicherheitsleistungen mitwirken (c)'], + }, + technik: { + 'LPH 1': [ + 'Aufgabenstellung im Benehmen mit dem Objektplaner klären (a)', + 'Planungsrandbedingungen ermitteln, zum Leistungsbedarf und zur Erschließung beraten (b)', + 'Ergebnisse zusammenfassen, erläutern und dokumentieren (c)'], + 'LPH 2': [ + 'Grundlagen analysieren, beim Abstimmen der Leistungen mitwirken (a)', + 'Planungskonzept erarbeiten: Vordimensionierung, Varianten, Raumbedarf (b)', + 'Funktionsschema bzw. Prinzipschaltbild je Anlage aufstellen (c)', + 'Fachübergreifende Prozesse, Randbedingungen und Schnittstellen klären (d)', + 'Vorverhandlungen mit Behörden und Versorgern (e)', + 'Kostenschätzung nach DIN 276 (2. Ebene) und Terminplanung (f)', + 'Ergebnisse zusammenfassen, erläutern und dokumentieren (g)'], + 'LPH 3': [ + 'Planungskonzept bis zum vollständigen Entwurf durcharbeiten (a)', + 'Alle Systeme und Anlagenteile festlegen (b)', + 'Anlagen berechnen und bemessen, Entwurf zeichnen, Schemata fortschreiben (c)', + 'Berechnungsergebnisse übergeben, Durchführungen und Lasten für die Tragwerksplanung (d)', + 'Verhandlungen mit Behörden über die Genehmigungsfähigkeit (e)', + 'Kostenberechnung nach DIN 276 (3. Ebene) und Terminplanung (f)', + 'Kostenkontrolle: Kostenberechnung mit Kostenschätzung vergleichen (g)', + 'Ergebnisse zusammenfassen, erläutern und dokumentieren (h)'], + 'LPH 4': [ + 'Vorlagen und Nachweise für Genehmigungen erarbeiten, bei Behördenverhandlungen mitwirken (a)', + 'Planungsunterlagen, Beschreibungen und Berechnungen vervollständigen und anpassen (b)'], + 'LPH 5': [ + 'Ausführungsplanung bis zur ausführungsreifen Lösung erarbeiten (a)', + 'Berechnungen fortschreiben, Anlagen zeichnen, Schemata detaillieren, abstimmen (b)', + 'Schlitz- und Durchbruchspläne anfertigen (c)', + 'Terminplan fortschreiben (d)', + 'Ausführungsplanung auf Stand der Ausschreibung fortschreiben und übergeben (e)', + 'Montage- und Werkstattpläne prüfen und anerkennen (f)'], + 'LPH 6': [ + 'Mengen als Grundlage für die Leistungsverzeichnisse ermitteln (a)', + 'Vergabeunterlagen mit Leistungsverzeichnissen inkl. Wartung aufstellen (b)', + 'Beim Abstimmen der Schnittstellen zu anderen Leistungsbeschreibungen mitwirken (c)', + 'Kosten anhand bepreister Leistungsverzeichnisse ermitteln (d)', + 'Kostenkontrolle: bepreiste LV mit Kostenberechnung vergleichen (e)', + 'Vergabeunterlagen zusammenstellen (f)'], + 'LPH 7': [ + 'Angebote einholen (a)', + 'Angebote prüfen und werten, Preisspiegel aufstellen (b)', + 'Bietergespräche führen (c)', + 'Ausschreibungsergebnisse mit bepreisten LV und Kostenberechnung vergleichen (d)', + 'Vergabevorschläge erstellen, bei der Dokumentation mitwirken (e)', + 'Vertragsunterlagen zusammenstellen, bei der Auftragserteilung mitwirken (f)'], + 'LPH 8': [ + 'Ausführung überwachen (Genehmigung, Verträge, Montagepläne, Regeln der Technik) (a)', + 'Bei der Koordination der Projektbeteiligten mitwirken (b)', + 'Terminplan (Balkendiagramm) aufstellen, fortschreiben, überwachen (c)', + 'Bauablauf dokumentieren (Bautagebuch) (d)', + 'Nachträge prüfen und bewerten (e)', + 'Gemeinsames Aufmaß mit den ausführenden Unternehmen (f)', + 'Rechnungsprüfung, Leistungsstand bescheinigen (g)', + 'Kostenkontrolle: Abrechnung mit Vertragspreisen und Kostenanschlag vergleichen (h)', + 'Kostenfeststellung (i)', + 'Bei Leistungs- und Funktionsprüfungen mitwirken (j)', + 'Fachtechnische Abnahme, Abnahmeprotokoll, Mängel feststellen (k)', + 'Behördliche Abnahmen beantragen und teilnehmen (l)', + 'Revisionsunterlagen auf Vollständigkeit prüfen (m)', + 'Verjährungsfristen der Mängelansprüche auflisten (n)', + 'Beseitigung der Abnahmemängel überwachen (o)', + 'Dokumentation, Zeichnungen und Berechnungen systematisch zusammenstellen (p)'], + 'LPH 9': [ + 'Innerhalb der Verjährungsfristen festgestellte Mängel fachlich bewerten (a)', + 'Objektbegehung vor Ablauf der Verjährungsfristen (b)', + 'Bei der Freigabe von Sicherheitsleistungen mitwirken (c)'], + }, +}; +function hoaiVorlagenEinfuegen(){ + const vorhanden = new Set(db.prepare('SELECT DISTINCT gewerk FROM lph_templates').all().map(r => r.gewerk)); + const ins = db.prepare(`INSERT INTO lph_templates (id, lph, gewerk, title, offset_days, duration_days, sort_order) + VALUES (?, ?, ?, ?, 0, 0, ?)`); + const ziele = [['ARCH', 'gebaeude'], ['ELT', 'technik'], ['HLS', 'technik']]; + db.exec('BEGIN'); + try { + let n = 0; + ziele.forEach(([gewerk, art]) => { + if(vorhanden.has(gewerk)) return; // eigene Vorlagen nie überschreiben + Object.entries(HOAI_GRUNDLEISTUNGEN[art]).forEach(([lph, titel]) => { + titel.forEach((t, i) => { ins.run(crypto.randomUUID(), lph, gewerk, t, i); n++; }); + }); + }); + db.exec('COMMIT'); + if(n) console.log('Aufgaben-Vorlagen: ' + n + ' HOAI-Grundleistungen angelegt.'); + } catch(e){ db.exec('ROLLBACK'); throw e; } +} +einmaligeUmstellung('kommentar-spiegelstriche', kommentareSpiegelstricheUmstellen); +// kommentareInAktuelleSitzungUebernehmen: einmalig zur Einführung der +// Kommentar-Übernahme im Sitzungswechsel. Füllt in der jüngsten echten +// Sitzung jeden LEEREN Kommentar je Gewerk mit dem zuletzt geschriebenen aus +// einer früheren Sitzung (gleiche project_uid). Der Besprochen-Haken bleibt. +function kommentareInAktuelleSitzungUebernehmen(){ + const labels = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL) + .map(r => r.label) + .sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b))); + if(labels.length < 2) return; + const aktuell = labels[labels.length - 1]; + const rang = new Map(labels.map((l, i) => [l, i])); + const projekte = db.prepare("SELECT project_id, project_uid FROM projects WHERE week_label = ? AND project_uid != ''").all(aktuell); + const frueher = db.prepare(`SELECT k.week_label, k.gewerk, k.status, k.status_author, k.status_at + FROM project_kommentare k JOIN projects p ON p.week_label = k.week_label AND p.project_id = k.project_id + WHERE p.project_uid = ? AND k.week_label != ? AND k.week_label != ? AND trim(k.status) != ''`); + const schreiben = db.prepare(`INSERT INTO project_kommentare (week_label, project_id, gewerk, status, status_author, status_at, besprochen) + VALUES (?, ?, ?, ?, ?, ?, 0) + ON CONFLICT(week_label, project_id, gewerk) DO UPDATE SET status = excluded.status, + status_author = excluded.status_author, status_at = excluded.status_at + WHERE trim(project_kommentare.status) = ''`); + const jetzt = new Date().toISOString(); + let n = 0; + db.exec('BEGIN'); + try { + projekte.forEach(pr => { + const juengste = new Map(); + frueher.all(pr.project_uid, aktuell, SANDBOX_WEEK_LABEL).forEach(r => { + if(!rang.has(r.week_label)) return; + const bisher = juengste.get(r.gewerk); + if(!bisher || rang.get(r.week_label) > rang.get(bisher.week_label)) juengste.set(r.gewerk, r); + }); + juengste.forEach((r, g) => { + const erg = schreiben.run(aktuell, pr.project_id, g, r.status, r.status_author || '', r.status_at || ''); + if(erg.changes){ + n++; + db.prepare('UPDATE projects SET updated_at = ? WHERE week_label = ? AND project_id = ?').run(jetzt, aktuell, pr.project_id); + } + }); + }); + if(n) db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, aktuell); + db.exec('COMMIT'); + if(n) console.log('Kommentar-Übernahme: ' + n + ' Kommentar(e) in "' + aktuell + '" übernommen.'); + } catch(e){ db.exec('ROLLBACK'); throw e; } +} +einmaligeUmstellung('kommentare-uebernehmen', kommentareInAktuelleSitzungUebernehmen); +// SIEMON_ANTEILE: Teilleistungsbewertung nach dem Siemon-Gutachten zur +// HOAI 2021 (Anlagenband zum Gutachten 2021-1, Anlage 1.1 Gebäude und 1.7 +// Technische Ausrüstung, veröffentlicht u. a. auf architektenhonorar.de): +// ein Wert je Grundleistung in Prozent des Gesamthonorars, Summe je Phase = +// HOAI-Prozentsatz. Löst die zuerst hinterlegten Mittelwerte der Spannen aus +// den Siemon-Tabellen 2013 ab (SIEMON_2013 unten, nur noch zum Erkennen +// unveränderter Werte bei der Umstellung) — Eriks Meldung „die Werte sind +// nicht richtig“. +const SIEMON_ANTEILE = { + gebaeude: { + 'LPH 1': { a: 0.75, b: 0.10, c: 0.75, d: 0.25, e: 0.15 }, + 'LPH 2': { a: 0.50, b: 0.10, c: 3.00, d: 0.85, e: 0.50, f: 0.30, g: 1.00, h: 0.50, i: 0.25 }, + 'LPH 3': { a: 11.00, b: 1.15, c: 0.40, d: 0.20, e: 1.25, f: 0.35, g: 0.65 }, + 'LPH 4': { a: 2.50, b: 0.25, c: 0.25 }, + 'LPH 5': { a: 10.00, b: 9.50, c: 3.00, d: 0.85, e: 0.40, f: 1.25 }, + 'LPH 6': { a: 0.20, b: 7.60, c: 0.35, d: 1.50, e: 0.25, f: 0.10 }, + 'LPH 7': { a: 0.25, b: 0.10, c: 2.00, d: 0.25, e: 0.80, f: 0.10, g: 0.25, h: 0.25 }, + 'LPH 8': { a: 18.00, b: 0.10, c: 1.50, d: 1.60, e: 0.75, f: 1.00, g: 3.75, h: 0.25, i: 0.85, j: 1.00, + k: 1.50, l: 0.10, m: 0.25, n: 0.25, o: 0.10, p: 1.00 }, + 'LPH 9': { a: 1.00, b: 0.80, c: 0.20 }, + }, + technik: { + 'LPH 1': { a: 1.50, b: 0.30, c: 0.20 }, + 'LPH 2': { a: 0.80, b: 5.25, c: 1.00, d: 0.80, e: 0.20, f: 0.70, g: 0.25 }, + 'LPH 3': { a: 5.50, b: 0.25, c: 9.95, d: 0.25, e: 0.15, f: 0.65, g: 0.10, h: 0.15 }, + 'LPH 4': { a: 1.75, b: 0.25 }, + 'LPH 5': { a: 4.50, b: 8.50, c: 4.00, d: 0.50, e: 0.50, f: 4.00 }, + 'LPH 6': { a: 2.90, b: 3.05, c: 0.10, d: 0.75, e: 0.10, f: 0.10 }, + 'LPH 7': { a: 0.10, b: 3.50, c: 0.50, d: 0.25, e: 0.50, f: 0.15 }, + 'LPH 8': { a: 17.50, b: 0.30, c: 0.65, d: 0.50, e: 0.10, f: 2.25, g: 6.50, h: 0.85, i: 0.80, j: 0.50, + k: 2.50, l: 0.10, m: 0.75, n: 0.10, o: 1.50, p: 0.10 }, + 'LPH 9': { a: 0.50, b: 0.40, c: 0.10 }, + }, +}; +// SIEMON_2013: die zuerst eingetragenen Werte (Mittelwerte der Spannen der +// Siemon-Tabellen 2013, Buchstabe = „in x) enthalten“). Nur noch dafür da, +// bei der Umstellung auf 2021 zu erkennen, was niemand verändert hat. +const SIEMON_2013 = { + gebaeude: { + 'LPH 1': { a: 0.875, b: 'a', c: 0.875, d: 'c', e: 0.3 }, + 'LPH 2': { a: 0.375, b: 'a', c: 3.25, d: 1.5, e: 'd', f: 0.3, g: 1.125, h: 0.3, i: 0.3 }, + 'LPH 3': { a: 11, b: 1, c: 0.5, d: 0.75, e: 1.5, f: 0.375, g: 0.375 }, + 'LPH 4': { a: 3, b: 'a', c: 'a' }, + 'LPH 5': { a: 11.5, b: 11.5, c: 'a,b', d: 0.5, e: 0.75, f: 'a,b' }, + 'LPH 6': { a: 0.125, b: 8.5, c: 'b', d: 1.5, e: 'd', f: 'b' }, + 'LPH 7': { a: 0.3, b: 0.125, c: 3.125, d: 'c', e: 'c', f: 0.175, g: 0.375, h: 0.125 }, + 'LPH 8': { a: 21.5, b: 'a', c: 'a', d: 0.75, e: 0.375, f: 'g', g: 5.5, h: 1.25, i: 'h', j: 0.75, + k: 2, l: 'k', m: 0.175, n: 'k', o: 'k', p: 0.875 }, + 'LPH 9': { a: 0.625, b: 1.375, c: 'b' }, + }, + technik: { + 'LPH 1': { a: 0.875, b: 0.875, c: 0.175 }, + 'LPH 2': { a: 0.375, b: 6, c: 'b', d: 1.5, e: 'd', f: 0.75, g: 0.175 }, + 'LPH 3': { a: 5.5, b: 'a', c: 10.5, d: 0.175, e: 'a', f: 1.125, g: 'f', h: 0.175 }, + 'LPH 4': { a: 2, b: 'a' }, + 'LPH 5': { a: 5, b: 9.5, c: 3, d: 0.3, e: 0.75, f: 3 }, + 'LPH 6': { a: 2.625, b: 3, c: 'a,b', d: 1.5, e: 'd', f: 'b' }, + 'LPH 7': { a: 0.05, b: 3.875, c: 'b', d: 0.75, e: 'b', f: 0.175 }, + 'LPH 8': { a: 19, b: 0.75, c: 0.375, d: 0.375, e: 0.5, f: 'g', g: 9, h: 1, i: 'h', j: 0.175, + k: 3.25, l: 'k', m: 0.625, n: 'k', o: 0.875, p: 0.175 }, + 'LPH 9': { a: 0.5, b: 0.625, c: 'b' }, + }, +}; +// siemon2021Umstellen: ersetzt die Werte von 2013 durch die von 2021 — nur +// dort, wo noch genau der 2013 eingetragene Wert steht (selbst Gepflegtes +// bleibt). Einmalig (einmaligeUmstellung('siemon-anteile-2021')). +function siemon2021Umstellen(){ + const art = { ARCH: 'gebaeude', ELT: 'technik', HLS: 'technik' }; + const upd = db.prepare("UPDATE lph_templates SET anteil = ?, enthalten_in = '' WHERE id = ?"); + let n = 0; + db.exec('BEGIN'); + try { + db.prepare('SELECT * FROM lph_templates').all().forEach(t => { + const a = art[t.gewerk]; + const m = String(t.title).match(/\(([a-p])\)\s*$/); + if(!a || !m) return; + const alt = (SIEMON_2013[a][t.lph] || {})[m[1]]; + const neuW = (SIEMON_ANTEILE[a][t.lph] || {})[m[1]]; + if(alt === undefined || neuW === undefined) return; + const unveraendert = typeof alt === 'number' + ? (Math.abs((Number(t.anteil) || 0) - alt) < 1e-9 && !t.enthalten_in) + : (t.enthalten_in === alt && !(Number(t.anteil) > 0)); + if(!unveraendert) return; + upd.run(neuW, t.id); n++; + }); + db.exec('COMMIT'); + if(n) console.log('Aufgaben-Vorlagen: ' + n + ' Anteile auf Siemon-Gutachten 2021 umgestellt.'); + } catch(e){ db.exec('ROLLBACK'); throw e; } +} +// siemonAnteileEintragen: trägt die Werte in die HOAI-Vorlagen ein — je +// Gewerk und Phase über den Buchstaben am Ende des Titels („… (c)“). Nur +// Schritte ohne Anteil und ohne „enthalten in“, also nie etwas, das jemand +// schon selbst gepflegt hat. Einmalig (einmaligeUmstellung). +function siemonAnteileEintragen(){ + const art = { ARCH: 'gebaeude', ELT: 'technik', HLS: 'technik' }; + const upd = db.prepare('UPDATE lph_templates SET anteil = ?, enthalten_in = ? WHERE id = ?'); + let n = 0; + db.exec('BEGIN'); + try { + db.prepare("SELECT * FROM lph_templates WHERE anteil = 0 AND enthalten_in = ''").all().forEach(t => { + const tab = SIEMON_ANTEILE[art[t.gewerk]]; + const m = String(t.title).match(/\(([a-p])\)\s*$/); + if(!tab || !tab[t.lph] || !m) return; + const wert = tab[t.lph][m[1]]; + if(wert === undefined) return; + if(typeof wert === 'number') upd.run(wert, '', t.id); else upd.run(0, wert, t.id); + n++; + }); + db.exec('COMMIT'); + if(n) console.log('Aufgaben-Vorlagen: Siemon-Anteile für ' + n + ' Schritte eingetragen.'); + } catch(e){ db.exec('ROLLBACK'); throw e; } +} +einmaligeUmstellung('hoai-aufgabenvorlagen', hoaiVorlagenEinfuegen); +einmaligeUmstellung('siemon-anteile', siemonAnteileEintragen); +einmaligeUmstellung('siemon-anteile-2021', siemon2021Umstellen); +// Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei +// jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen. +kommentareVorsatzUmstellen(); ensureSandboxWeek(); ensureUserPersonIdUniqueIndex(); migrateRetirePeopleTablesIntoUsers(); @@ -2843,7 +3651,8 @@ function attachUser(req, res, next){ SELECT s.expires_at AS expires_at, u.id AS id, u.username AS username, u.display_name AS display_name, u.email AS email, u.role AS role, u.person_id AS person_id, u.active AS active, - u.must_change_password AS must_change_password, u.department AS department + u.must_change_password AS must_change_password, u.department AS department, + u.einfuehrung_am AS einfuehrung_am FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? `).get(token); @@ -2859,15 +3668,26 @@ function attachUser(req, res, next){ // department: das eigene Gewerk des Kontos — der Wochenbericht schlägt es // als Vorauswahl vor (js/wochenbericht/wochenbericht.js), damit man nicht // jedes Mal manuell das eigene Gewerk wählen muss. - department: row.department || '' + department: row.department || '', + // einfuehrungFaellig: Rundgang noch nicht abgeschlossen (js/einfuehrung.js) + einfuehrungFaellig: !row.einfuehrung_am }; - try { - db.prepare('UPDATE user_sessions SET last_seen_at = ? WHERE token = ?').run(new Date().toISOString(), token); - } catch(err){ - console.error('Konnte last_seen_at nicht aktualisieren:', err); + // last_seen_at höchstens einmal je Minute und Sitzung schreiben. Vorher + // schrieb JEDE Anfrage — auch jede der rund 60 JS/CSS-Dateien eines + // Seitenaufrufs — in die Datenbank; auf der SD-Karte des Pi der größte + // Teil der Ladezeit. + const jetzt = Date.now(); + if(jetzt - (sitzungZuletztGeschrieben.get(token) || 0) > 60 * 1000){ + sitzungZuletztGeschrieben.set(token, jetzt); + try { + db.prepare('UPDATE user_sessions SET last_seen_at = ? WHERE token = ?').run(new Date(jetzt).toISOString(), token); + } catch(err){ + console.error('Konnte last_seen_at nicht aktualisieren:', err); + } } next(); } +const sitzungZuletztGeschrieben = new Map(); // (Registrierung via app.use(attachUser) erfolgt bewusst weiter oben, direkt // nach express.json() und vor express.static — siehe dort.) @@ -2876,11 +3696,11 @@ function attachUser(req, res, next){ // Route angewendet. function requireAuth(req, res, next){ if(!req.user) return res.status(401).json({ ok:false, error:'Nicht angemeldet.' }); - // Die Kiosk-Rolle (Wandtafel-Konto) ist überall, wo requireAuth genügte, - // bewusst AUSGESCHLOSSEN — sie darf nichts schreiben und nichts lesen, was - // die Wandtafel nicht selbst braucht. Die wenigen Routen, die sie braucht, - // nutzen requireAuthOderKiosk statt requireAuth. - if(req.user.role === 'kiosk') return res.status(403).json({ ok:false, error:'Dieses Konto hat nur Lesezugriff für die Wandtafel.' }); + // Rollen ohne „Anwendung nutzen“ (früher fest: 'kiosk') sind überall, wo + // requireAuth genügte, bewusst AUSGESCHLOSSEN — sie dürfen nichts schreiben + // und nichts lesen, was die Wandtafel nicht selbst braucht. Die wenigen + // Routen, die sie braucht, nutzen requireAuthOderKiosk statt requireAuth. + if(istNurWandtafel(req.user.role)) return res.status(403).json({ ok:false, error:'Dieses Konto hat nur Lesezugriff für die Wandtafel.' }); next(); } // requireAuthOderKiosk: wie requireAuth, lässt zusätzlich die Kiosk-Rolle zu — @@ -2924,9 +3744,19 @@ function loginBlockMs(){ return readAppSetting('loginSperreMinuten') * 60 * 1000 // sperren würde im Büro alle hinter derselben Leitung aussperren, sobald sich // eine Person zehnmal vertippt. So ist immer nur das betroffene Konto von // dieser Stelle aus gebremst. +// Schlüssel aus der NORMALISIERTEN Kennung (wie sie /login auch sucht: ohne +// "@…", ohne Leerzeichen, klein) — sonst bekam jede Schreibweise +// ("x@a.de", " x") einen eigenen Zähler und die Sperre ließ sich beliebig +// umgehen. Die Adresse kommt nur noch aus der Verbindung selbst: Der +// Header X-Forwarded-For ist frei wählbar, und vor diesem Server steht kein +// Proxy, der ihn setzen würde. +function loginKennung(username){ + const eingabe = String(username || '').trim(); + return (eingabe.includes('@') ? eingabe.slice(0, eingabe.indexOf('@')) : eingabe).trim().toLowerCase(); +} function loginClientKey(req, username){ - const ip = String((req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || 'unbekannt'); - return String(username || '').toLowerCase() + '|' + ip; + const ip = String(req.socket.remoteAddress || 'unbekannt'); + return loginKennung(username) + '|' + ip; } function loginIsBlocked(key){ @@ -2945,6 +3775,10 @@ function loginNoteFailure(key){ app.post('/login', (req, res) => { const { username, password } = req.body || {}; if(!username || !password) return res.status(400).json({ ok:false, error:'Benutzername und Passwort erforderlich.' }); + // Nur Zeichenketten — eine Zahl ließ scrypt mit 500 abbrechen. + if(typeof username !== 'string' || typeof password !== 'string'){ + return res.status(400).json({ ok:false, error:'Benutzername und Passwort erforderlich.' }); + } const clientKey = loginClientKey(req, username); if(loginIsBlocked(clientKey)){ return res.status(429).json({ ok:false, error:'Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.' }); @@ -2952,8 +3786,7 @@ app.post('/login', (req, res) => { // Benutzername = Anfang der E-Mail-Adresse (z. B. e.dunin). Wer die // ganze Adresse eintippt, wird ebenso erkannt (alles ab "@" fällt weg), // und Groß-/Kleinschreibung spielt keine Rolle ("E.Dunin"). - const eingabe = String(username).trim(); - const kennung = eingabe.includes('@') ? eingabe.slice(0, eingabe.indexOf('@')) : eingabe; + const kennung = loginKennung(username); const row = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(kennung); // Auch bei unbekanntem Benutzernamen einmal hashen: Sonst verrät die // Antwortzeit (scrypt läuft bewusst langsam), welche Konten es gibt. @@ -2996,14 +3829,141 @@ app.post('/logout', requireAuthOderKiosk, (req, res) => { // Route: liefert den aktuell angemeldeten Benutzer (oder null) — Basis für das // clientseitige Login-Overlay. app.get('/whoami', (req, res) => { - res.json({ ok:true, user: req.user || null }); + // rechte/rolleName: für die Oberfläche (Knöpfe ein-/ausblenden). Die + // eigentliche Grenze sind requireRecht()/hatRecht() an den Routen. + const user = req.user ? Object.assign({}, req.user, { + rechte: rechteDerRolle(req.user.role), + rolleName: (rollenCache.get(req.user.role) || {}).name || req.user.role + }) : null; + res.json({ ok:true, user }); +}); + +// POST /einfuehrung {erledigt: true}: Rundgang abgeschlossen bzw. +// übersprungen — nur für das eigene Konto. Neu starten lässt er sich jederzeit +// unter Einstellungen, ohne diesen Stand zu ändern. +app.post('/einfuehrung', requireAuth, (req, res) => { + try { + const wert = (req.body || {}).erledigt === false ? '' : new Date().toISOString(); + db.prepare('UPDATE users SET einfuehrung_am = ? WHERE id = ?').run(wert, req.user.id); + res.json({ ok: true }); + } catch(err){ console.error('Einführung speichern fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); } +}); + +// ---------- Rollen & Rechte: Routen ---------- +// GET liefert Katalog und Rollen (mit Anzahl Konten) — jedes angemeldete +// Konto mit Grundzugang, weil die Benutzerverwaltung die Liste für ihre +// Rollenauswahl braucht. Ändern nur mit Recht „Rollen & Rechte“. +function rollenAntwort(){ + const zaehl = new Map(db.prepare('SELECT role, COUNT(*) AS n FROM users WHERE active = 1 GROUP BY role').all().map(r => [r.role, r.n])); + return { + ok: true, + katalog: RECHTE.map(g => ({ gruppe: g.gruppe, rechte: g.rechte.map(([key, name, hinweis]) => ({ key, name, hinweis })) })), + rollen: [...rollenCache.values()].map(r => ({ key: r.key, name: r.name, fest: r.fest, konten: zaehl.get(r.key) || 0, rechte: rechteDerRolle(r.key) })) + }; +} +app.get('/rollen', requireAuth, (req, res) => { + try { res.json(rollenAntwort()); } + catch(err){ console.error('Rollen laden fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); } +}); + +// POST /rollen {name, vonRolle?}: neue Rolle, optional mit den Rechten einer +// bestehenden. Der Schlüssel wird aus dem Namen gebildet (a-z, 0-9, -). +app.post('/rollen', requireRecht('a.rollen'), (req, res) => { + try { + const name = String((req.body || {}).name || '').trim(); + if(!name) return res.status(400).json({ ok: false, error: 'Name fehlt.' }); + if([...rollenCache.values()].some(r => r.name.toLowerCase() === name.toLowerCase())){ + return res.status(409).json({ ok: false, error: 'Eine Rolle mit diesem Namen gibt es schon.' }); + } + const basis = name.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30) || 'rolle'; + let key = basis, i = 2; + while(rollenCache.has(key)) key = basis + '-' + (i++); + const von = (req.body || {}).vonRolle ? String(req.body.vonRolle) : ''; + if(von && !rolleExistiert(von)) return res.status(400).json({ ok: false, error: 'Unbekannte Vorlage-Rolle.' }); + const sort = (db.prepare('SELECT MAX(sort_order) AS m FROM roles').get().m || 0) + 1; + db.exec('BEGIN'); + try { + db.prepare('INSERT INTO roles (key, name, sort_order, fest) VALUES (?, ?, ?, 0)').run(key, name, sort); + const ins = db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)'); + (von ? rechteDerRolle(von) : []).forEach(r => ins.run(key, r)); + db.exec('COMMIT'); + } catch(e){ db.exec('ROLLBACK'); throw e; } + ladeRollen(); + res.json(Object.assign(rollenAntwort(), { neu: key })); + } catch(err){ console.error('Rolle anlegen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); } +}); + +// PATCH /rollen/:key {name}: umbenennen. Administrator bleibt, wie er ist. +app.patch('/rollen/:key', requireRecht('a.rollen'), (req, res) => { + try { + const r = rollenCache.get(String(req.params.key)); + if(!r) return res.status(404).json({ ok: false, error: 'Rolle nicht gefunden.' }); + if(r.fest) return res.status(400).json({ ok: false, error: 'Die Rolle Administrator ist fest.' }); + const name = String((req.body || {}).name || '').trim(); + if(!name) return res.status(400).json({ ok: false, error: 'Name fehlt.' }); + if([...rollenCache.values()].some(x => x.key !== r.key && x.name.toLowerCase() === name.toLowerCase())){ + return res.status(409).json({ ok: false, error: 'Eine Rolle mit diesem Namen gibt es schon.' }); + } + db.prepare('UPDATE roles SET name = ? WHERE key = ?').run(name, r.key); + ladeRollen(); + res.json(rollenAntwort()); + } catch(err){ console.error('Rolle umbenennen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); } +}); + +// PUT /rollen/:key/rechte {rechte:[…], an:true|false}: setzt oder entzieht +// ein oder mehrere Rechte (ein Schalter oder „alle/keine“ einer Gruppe). +app.put('/rollen/:key/rechte', requireRecht('a.rollen'), (req, res) => { + try { + const r = rollenCache.get(String(req.params.key)); + if(!r) return res.status(404).json({ ok: false, error: 'Rolle nicht gefunden.' }); + if(r.fest) return res.status(400).json({ ok: false, error: 'Die Rolle Administrator hat immer alle Rechte.' }); + const liste = Array.isArray((req.body || {}).rechte) ? req.body.rechte.map(String) : []; + if(!liste.length || liste.some(k => !RECHT_KEYS.has(k))) return res.status(400).json({ ok: false, error: 'Unbekanntes Recht.' }); + const an = !!req.body.an; + // Sich selbst „Rollen & Rechte“ zu entziehen, sperrte einen sofort aus. + if(!an && r.key === req.user.role && liste.includes('a.rollen')){ + return res.status(400).json({ ok: false, error: 'Du kannst deiner eigenen Rolle „Rollen & Rechte“ nicht entziehen.' }); + } + const stmt = an + ? db.prepare('INSERT OR IGNORE INTO role_rechte (role_key, recht) VALUES (?, ?)') + : db.prepare('DELETE FROM role_rechte WHERE role_key = ? AND recht = ?'); + db.exec('BEGIN'); + try { liste.forEach(k => stmt.run(r.key, k)); db.exec('COMMIT'); } + catch(e){ db.exec('ROLLBACK'); throw e; } + ladeRollen(); + res.json(rollenAntwort()); + } catch(err){ console.error('Rechte setzen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); } +}); + +// DELETE /rollen/:key: nur ohne zugeordnete Konten (auch deaktivierte). +app.delete('/rollen/:key', requireRecht('a.rollen'), (req, res) => { + try { + const r = rollenCache.get(String(req.params.key)); + if(!r) return res.json(rollenAntwort()); + if(r.fest) return res.status(400).json({ ok: false, error: 'Die Rolle Administrator kann nicht gelöscht werden.' }); + const konten = db.prepare('SELECT display_name FROM users WHERE role = ? ORDER BY display_name').all(r.key).map(x => x.display_name); + if(konten.length){ + return res.status(409).json({ ok: false, error: 'Der Rolle sind noch Konten zugeordnet: ' + konten.join(', ') + '. Erst eine andere Rolle zuweisen.' }); + } + db.exec('BEGIN'); + try { + db.prepare('DELETE FROM role_rechte WHERE role_key = ?').run(r.key); + db.prepare('DELETE FROM roles WHERE key = ?').run(r.key); + db.exec('COMMIT'); + } catch(e){ db.exec('ROLLBACK'); throw e; } + ladeRollen(); + res.json(rollenAntwort()); + } catch(err){ console.error('Rolle löschen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); } }); // Route: Passwort ändern — verlangt immer das aktuelle Passwort (auch bei // erzwungenem must_change_password), neues Passwort muss mind. 8 Zeichen haben. app.post('/change-password', requireAuthOderKiosk, (req, res) => { const { currentPassword, newPassword } = req.body || {}; - if(!currentPassword || !newPassword) return res.status(400).json({ ok:false, error:'Aktuelles und neues Passwort erforderlich.' }); + if(!currentPassword || !newPassword || typeof currentPassword !== 'string' || typeof newPassword !== 'string'){ + return res.status(400).json({ ok:false, error:'Aktuelles und neues Passwort erforderlich.' }); + } if(newPassword.length < 8) return res.status(400).json({ ok:false, error:'Neues Passwort muss mindestens 8 Zeichen haben.' }); const row = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); if(!row || !verifyPassword(currentPassword, row.password_salt, row.password_hash)){ @@ -3032,7 +3992,7 @@ app.post('/change-email', requireAuthOderKiosk, (req, res) => { // ---------- Admin-Benutzerverwaltung ---------- // -// Alle Routen dieses Abschnitts sind `requireRole('admin')`-geschützt und +// Alle Routen dieses Abschnitts verlangen das Recht „Benutzerverwaltung“ und // dienen dem Abschnitt Benutzerverwaltung des Bereichs Admin. Sie legen // Benutzerkonten an, ändern sie, setzen Passwörter zurück und löschen Konten // (Soft-Delete standardmäßig, Hard-Delete nur ohne Referenzen in @@ -3089,7 +4049,7 @@ function loadUserRolesByUserId(){ } // Route: Liste aller Benutzer (ohne Passwortfelder), sortiert nach Benutzername. -app.get('/admin/users', requireRole('admin'), (req, res) => { +app.get('/admin/users', requireRecht('a.benutzer'), (req, res) => { try { const rows = db.prepare('SELECT * FROM users ORDER BY username').all(); const rolesByUser = loadUserRolesByUserId(); @@ -3104,7 +4064,7 @@ app.get('/admin/users', requireRole('admin'), (req, res) => { // GET /admin/users liefert bewusst keine Abwesenheiten mit (würde die Liste // unnötig aufblähen) — das Bearbeiten-Modal in admin-users.js lädt sie beim // Öffnen separat über diese Route nach. -app.get('/admin/users/:id/absences', requireRole('admin'), (req, res) => { +app.get('/admin/users/:id/absences', requireRecht('a.benutzer'), (req, res) => { const id = Number(req.params.id); try { const rows = db.prepare('SELECT id, start, end, type, note FROM user_absences WHERE user_id = ? ORDER BY start').all(id); @@ -3120,7 +4080,7 @@ app.get('/admin/users/:id/absences', requireRole('admin'), (req, res) => { // muss der neue Account beim ersten Login sein Passwort ändern. `personId` // wird 1:1 vom Client übernommen (Berechnung/Vorschau via personIdForName() // passiert clientseitig). -app.post('/admin/users', requireRole('admin'), (req, res) => { +app.post('/admin/users', requireRecht('a.benutzer'), (req, res) => { const { username, email, role, personId, department, initials, capacityHours, roles, password, generatePassword } = req.body || {}; const firstName = String((req.body || {}).firstName || '').trim(); const lastName = String((req.body || {}).lastName || '').trim(); @@ -3132,7 +4092,12 @@ app.post('/admin/users', requireRole('admin'), (req, res) => { return res.status(409).json({ ok: false, error: 'Benutzername bereits vergeben.' }); } const finalRole = role ? String(role) : 'member'; - if(!VALID_ROLES.has(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' }); + if(!rolleExistiert(finalRole)) return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' }); + // Administrator vergibt nur, wer selbst Administrator ist — sonst könnte + // sich eine Rolle mit „Benutzerverwaltung“ selbst alle Rechte verschaffen. + if(rollenCache.get(finalRole).fest && !(rollenCache.get(req.user.role) || {}).fest){ + return res.status(403).json({ ok: false, error: 'Nur ein Administrator kann die Rolle Administrator vergeben.' }); + } if(typeof department !== 'string' || !VALID_DEPARTMENTS.has(department)){ return res.status(400).json({ ok: false, error: 'Ungültige oder fehlende Abteilung.' }); } @@ -3144,8 +4109,8 @@ app.post('/admin/users', requireRole('admin'), (req, res) => { // Projektboard und wird keiner Rolle zugeordnet, die Felder wären also // ohnehin wirkungslos; ihr Setzen wird deshalb zurückgewiesen statt // stillschweigend ignoriert. - if(finalRole === 'kiosk' && roleList.length > 0){ - return res.status(400).json({ ok: false, error: 'Ein Kiosk-Konto kann keine Fachrolle haben.' }); + if(istNurWandtafel(finalRole) && roleList.length > 0){ + return res.status(400).json({ ok: false, error: 'Ein reines Wandtafel-Konto kann keine Fachrolle haben.' }); } // capacityHours: vertragliche Wochenstunden (Teilzeit). 0 bzw. keine // Angabe heißt "nicht gepflegt" — das Frontend rechnet dann mit einer @@ -3174,6 +4139,7 @@ app.post('/admin/users', requireRole('admin'), (req, res) => { roleList.forEach((r, idx) => insRole.run(info.lastInsertRowid, r, idx)); const row = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid); const result = { ok: true, user: Object.assign(serializeUserRow(row), { roles: roleList }) }; + result.sitzungGeaendert = kontenInAktuelleSitzung([row.id]) > 0; if(generatePassword === true) result.generatedPassword = plainPassword; res.json(result); } catch(err){ @@ -3190,15 +4156,22 @@ app.post('/admin/users', requireRole('admin'), (req, res) => { // Route: Aktualisiert einen bestehenden Benutzer (nur die übergebenen Felder). // Schützt einen Admin davor, sich selbst zu deaktivieren oder zu degradieren. -app.patch('/admin/users/:id', requireRole('admin'), (req, res) => { +app.patch('/admin/users/:id', requireRecht('a.benutzer'), (req, res) => { const id = Number(req.params.id); const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id); if(!row) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' }); const { displayName, email, role, active, personId, department, initials, capacityHours, roles } = req.body || {}; - if(role !== undefined && !VALID_ROLES.has(String(role))){ + if(role !== undefined && !rolleExistiert(role)){ return res.status(400).json({ ok: false, error: 'Ungültige Rolle.' }); } + // Administrator vergeben oder entziehen darf nur ein Administrator. + const ichAdmin = !!(rollenCache.get(req.user.role) || {}).fest; + const zielAdminVorher = !!(rollenCache.get(row.role) || {}).fest; + if(role !== undefined && String(role) !== row.role && !ichAdmin + && (zielAdminVorher || (rollenCache.get(String(role)) || {}).fest)){ + return res.status(403).json({ ok: false, error: 'Nur ein Administrator kann die Rolle Administrator vergeben oder entziehen.' }); + } if(department !== undefined && !VALID_DEPARTMENTS.has(department)){ return res.status(400).json({ ok: false, error: 'Ungültige Abteilung.' }); } @@ -3218,22 +4191,29 @@ app.patch('/admin/users/:id', requireRole('admin'), (req, res) => { // Fachrollen-Liste, wie sie nach diesem PATCH jeweils gelten würden (eines // von beiden kann unverändert bleiben, wenn es im Body fehlt). const effectiveRole = role !== undefined ? String(role) : row.role; - if(effectiveRole === 'kiosk'){ + if(istNurWandtafel(effectiveRole)){ const effectiveRoles = roles !== undefined ? roleList : db.prepare('SELECT role FROM user_roles WHERE user_id = ?').all(id).map(r => r.role); if(effectiveRoles.length > 0){ - return res.status(400).json({ ok: false, error: 'Ein Kiosk-Konto kann keine Fachrolle haben.' }); + return res.status(400).json({ ok: false, error: 'Ein reines Wandtafel-Konto kann keine Fachrolle haben.' }); } } const isSelf = req.user.id === id; const wouldDeactivate = active !== undefined && !active; // Degradierung schließt jeden Wechsel WEG von admin ein, nicht nur zu // 'member' — sonst gäbe es für den Selbstschutz eine Kiosk-förmige Lücke. - const wouldDemote = role !== undefined && row.role === 'admin' && role !== 'admin'; + const wouldDemote = role !== undefined && zielAdminVorher && !(rollenCache.get(String(role)) || {}).fest; if(isSelf && (wouldDeactivate || wouldDemote)){ return res.status(400).json({ ok: false, error: 'Sie können sich nicht selbst deaktivieren oder degradieren.' }); } + // Der letzte aktive Administrator bleibt: sonst kann niemand mehr Rollen + // und Rechte pflegen. + if(zielAdminVorher && row.active && (wouldDeactivate || wouldDemote)){ + const festRollen = [...rollenCache.values()].filter(r => r.fest).map(r => r.key); + const n = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE active = 1 AND role IN (${festRollen.map(() => '?').join(',')})`).get(...festRollen).n; + if(n <= 1) return res.status(400).json({ ok: false, error: 'Der letzte aktive Administrator kann nicht deaktiviert oder herabgestuft werden.' }); + } const fields = []; const values = []; @@ -3259,6 +4239,23 @@ app.patch('/admin/users/:id', requireRole('admin'), (req, res) => { values.push(id); try { db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values); + // Neue Person-ID: in der laufenden Sitzung und der Planung-Sandbox + // mitziehen. Sonst legte kontenInAktuelleSitzung() die Person unter der + // neuen ID ein zweites Mal an, und ihre Zuordnungen, Aufgaben und + // Anwesenheit hingen weiter an der alten. Ältere Sitzungen bleiben + // Historie (wie beim endgültigen Löschen). + const alteId = String(row.person_id || ''), neueId = personId !== undefined ? String(personId) : alteId; + if(alteId && neueId && alteId !== neueId){ + const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label) + .sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b))); + [echte[echte.length - 1], SANDBOX_WEEK_LABEL].filter(Boolean).forEach(w => { + ['week_people','week_people_roles','attendance','project_assign','plan_todo_assignees','plan_people'].forEach(t => { + db.prepare(`UPDATE OR IGNORE ${t} SET person_id = ? WHERE week_label = ? AND person_id = ?`).run(neueId, w, alteId); + db.prepare(`DELETE FROM ${t} WHERE week_label = ? AND person_id = ?`).run(w, alteId); + }); + db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(new Date().toISOString(), w); + }); + } if(roleList !== undefined){ db.prepare('DELETE FROM user_roles WHERE user_id = ?').run(id); const insRole = db.prepare('INSERT INTO user_roles (user_id, role, role_order) VALUES (?, ?, ?)'); @@ -3267,10 +4264,12 @@ app.patch('/admin/users/:id', requireRole('admin'), (req, res) => { // Deaktivierung erzwingt sofortigen Logout (alle Sessions löschen). if(wouldDeactivate){ db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(id); + offenePruefungenAufraeumen(id); } const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(id); const updatedRoles = roleList !== undefined ? roleList : (loadUserRolesByUserId()[id] || []); - res.json({ ok: true, user: Object.assign(serializeUserRow(updated), { roles: updatedRoles }) }); + const sitzungGeaendert = kontenInAktuelleSitzung([id]) > 0; + res.json({ ok: true, user: Object.assign(serializeUserRow(updated), { roles: updatedRoles }), sitzungGeaendert }); } catch(err){ if(err && String(err.message || '').includes('person_id')){ return res.status(409).json({ ok: false, error: 'Person-ID bereits vergeben.' }); @@ -3283,7 +4282,7 @@ app.patch('/admin/users/:id', requireRole('admin'), (req, res) => { // Route: Setzt das Passwort eines Benutzers auf ein neues Zufallspasswort // zurück, erzwingt eine Passwortänderung beim nächsten Login und löscht alle // bestehenden Sessions (erzwingt Neuanmeldung). -app.post('/admin/users/:id/reset-password', requireRole('admin'), (req, res) => { +app.post('/admin/users/:id/reset-password', requireRecht('a.benutzer'), (req, res) => { const id = Number(req.params.id); const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id); if(!row) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' }); @@ -3342,6 +4341,94 @@ function personenKennungenInWoche(row, woche){ return [...ids].filter(Boolean); } +// kontenInAktuelleSitzung: trägt aktive Konten in den Teilnehmerkreis der +// AKTUELLEN Sitzung (jüngste echte Woche) ein bzw. gleicht dort Kürzel, +// Name und Fachrollen mit dem Konto ab. Der Teilnehmerkreis einer Sitzung +// ist eine Kopie, die beim Sitzungswechsel entsteht — ein später angelegtes +// Konto fehlte darin, und der Assistent "Neues Projekt" bot niemanden an +// (aufgefallen auf einer frisch aufgesetzten Datenbank, deren Sitzung vor den +// Konten entstand). Ältere Sitzungen bleiben als Historie unberührt. +// userIds: nur diese Konten; ohne Angabe alle aktiven (Abgleich beim Start). +// Bei einer Änderung bekommt die Woche eine neue Revision, damit ein Browser +// mit altem Stand neu lädt, statt die Person beim Speichern wieder zu tilgen. +function kontenInAktuelleSitzung(userIds){ + const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label) + .sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b))); + const woche = echte[echte.length - 1]; + if(!woche) return 0; + const filter = Array.isArray(userIds) ? new Set(userIds.map(Number)) : null; + // Kiosk-Konten (Wandtafel) gehören nicht in den Teilnehmerkreis: Sie + // haben keine Fachrolle und könnten keine Aufgabe je bearbeiten. + const konten = db.prepare(`SELECT * FROM users WHERE active = 1 AND person_id != '' AND ${sqlOhneWandtafel()}`).all() + .filter(u => !filter || filter.has(u.id)); + let geaendert = 0; + db.exec('BEGIN'); + try { + // Bereits eingetragene Kiosk-Konten wieder austragen — solange sie keinem + // Projekt der Sitzung zugeordnet sind (dann bleibt die Zuordnung lesbar). + db.prepare(`SELECT person_id FROM users WHERE NOT (${sqlOhneWandtafel()}) AND person_id != ''`).all().forEach(k => { + const zugeordnet = db.prepare('SELECT 1 FROM project_assign WHERE week_label = ? AND person_id = ? AND state > 0 LIMIT 1').get(woche, k.person_id); + if(zugeordnet) return; + const weg = db.prepare('DELETE FROM week_people WHERE week_label = ? AND person_id = ?').run(woche, k.person_id).changes; + if(weg){ + db.prepare('DELETE FROM week_people_roles WHERE week_label = ? AND person_id = ?').run(woche, k.person_id); + db.prepare('DELETE FROM attendance WHERE week_label = ? AND person_id = ?').run(woche, k.person_id); + geaendert++; + } + }); + konten.forEach(u => { + const rollen = db.prepare('SELECT role FROM user_roles WHERE user_id = ? ORDER BY role_order').all(u.id).map(r => r.role); + const kuerzel = u.initials || u.username; + const da = db.prepare('SELECT * FROM week_people WHERE week_label = ? AND person_id = ?').get(woche, u.person_id); + const alteRollen = db.prepare('SELECT role FROM week_people_roles WHERE week_label = ? AND person_id = ? ORDER BY role_order') + .all(woche, u.person_id).map(r => r.role); + if(da && da.name === kuerzel && da.full_name === u.display_name && alteRollen.join('|') === rollen.join('|')) return; + if(da){ + db.prepare('UPDATE week_people SET name = ?, full_name = ? WHERE week_label = ? AND person_id = ?') + .run(kuerzel, u.display_name, woche, u.person_id); + } else { + const ende = db.prepare('SELECT COALESCE(MAX(array_order), -1) + 1 n FROM week_people WHERE week_label = ?').get(woche).n; + db.prepare('INSERT INTO week_people (week_label, person_id, name, full_name, array_order) VALUES (?, ?, ?, ?, ?)') + .run(woche, u.person_id, kuerzel, u.display_name, ende); + } + db.prepare('DELETE FROM week_people_roles WHERE week_label = ? AND person_id = ?').run(woche, u.person_id); + const insRolle = db.prepare('INSERT INTO week_people_roles (week_label, person_id, role, role_order) VALUES (?, ?, ?, ?)'); + rollen.forEach((r, i) => insRolle.run(woche, u.person_id, r, i)); + geaendert++; + }); + if(geaendert) db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(new Date().toISOString(), woche); + db.exec('COMMIT'); + } catch(err){ + db.exec('ROLLBACK'); + console.error('Teilnehmerkreis der aktuellen Sitzung konnte nicht abgeglichen werden:', err); + return 0; + } + if(geaendert) console.log(`Teilnehmerkreis "${woche}": ${geaendert} Konto/Konten eingetragen bzw. abgeglichen.`); + return geaendert; +} + +// offenePruefungenAufraeumen: nimmt ein deaktiviertes Konto aus den noch +// offenen Dateiprüfungen und berechnet deren Gesamtstatus neu. Ohne das blieb +// eine Datei, deren Prüfer deaktiviert wurde, für immer "ausstehend" — die +// übrigen Prüfer konnten zustimmen, so viel sie wollten. War das Konto der +// EINZIGE Prüfer, bleibt die Prüfung stehen (eine Datei ganz ohne Prüfer +// wäre ebenso festgefahren; nach einer Reaktivierung geht es dort weiter). +// Parameter: userId. Keine Rückgabe. +function offenePruefungenAufraeumen(userId){ + const jetzt = new Date().toISOString(); + const offen = db.prepare(`SELECT r.file_id FROM project_file_reviewers r JOIN project_files f ON f.id = r.file_id + WHERE r.user_id = ? AND r.decision = 'pending' AND f.status = 'pending'`).all(userId); + offen.forEach(({ file_id }) => { + const andere = db.prepare('SELECT decision FROM project_file_reviewers WHERE file_id = ? AND user_id != ?').all(file_id, userId).map(r => r.decision); + if(!andere.length) return; + db.prepare('DELETE FROM project_file_reviewers WHERE file_id = ? AND user_id = ?').run(file_id, userId); + let status = 'pending'; + if(andere.some(d => d === 'rejected')) status = 'rejected'; + else if(andere.every(d => d === 'approved')) status = 'approved'; + if(status !== 'pending') db.prepare('UPDATE project_files SET status = ?, updated_at = ? WHERE id = ?').run(status, jetzt, file_id); + }); +} + function loescheBenutzerEndgueltig(row){ const echte = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL).map(r => r.label) .sort((a, b) => weekSortKeyServer(a).localeCompare(weekSortKeyServer(b))); @@ -3385,7 +4472,7 @@ function loescheBenutzerEndgueltig(row){ return zaehler; } -app.delete('/admin/users/:id', requireRole('admin'), (req, res) => { +app.delete('/admin/users/:id', requireRecht('a.benutzer'), (req, res) => { const id = Number(req.params.id); const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id); if(!row) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' }); @@ -3401,6 +4488,7 @@ app.delete('/admin/users/:id', requireRole('admin'), (req, res) => { const now = new Date().toISOString(); db.prepare('UPDATE users SET active = 0, updated_at = ? WHERE id = ?').run(now, id); db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(id); + offenePruefungenAufraeumen(id); res.json({ ok: true, hard: false }); } catch(err){ console.error('Benutzer löschen fehlgeschlagen:', err); @@ -3435,19 +4523,35 @@ app.post('/save-week', requireAuth, (req, res) => { // daher gibt es im Prozess keine Race-Condition zwischen Prüfung und // Transaktion. // Sperr-Status serverseitig durchsetzen: Eine gesperrte Sitzung darf NUR - // über /unlock-week (requireRole('admin')) wieder geöffnet werden. Das + // über /unlock-week (Recht „Sitzung entsperren“) wieder geöffnet werden. Das // Sperren selbst (false -> true) bleibt jedem erlaubt, weil es die // Auto-Sperre des Clients (applyAutoLock(), public/js/weeks.js) beim // normalen Speichern mitschreibt. Ohne diese Prüfung wäre die Sperre reine // Client-Logik: ein beliebiges Mitglied könnte die Woche mit locked:false // zurückschreiben und die Admin-Grenze umgehen. const lockRow = db.prepare('SELECT locked FROM weeks WHERE label = ?').get(String(week.label)); - if(lockRow && lockRow.locked && !week.locked && !(req.user && req.user.role === 'admin')){ + if(lockRow && lockRow.locked && !week.locked && !hatRecht(req.user, 's.gesperrt')){ return res.status(403).json({ ok: false, error: 'Die Sitzung "' + week.label + '" ist gesperrt. Nur Administratoren können sie wieder öffnen.' }); } + // Auch der INHALT einer gesperrten Sitzung ist für Nicht-Admins tabu — + // bisher wurde nur das Entsperren abgewiesen, Kommentare und Aufgaben + // einer gesperrten Woche ließen sich über die API weiter ändern. Als 409 + // (Konflikt) statt 403: Ein Browser, der die Woche noch offen glaubt (etwa + // weil der Sitzungswechsel sie inzwischen gesperrt hat), lädt sie dann neu + // und zeigt sie gesperrt, statt in einer Fehlerschleife zu hängen. + // Das Mitschreiben der Auto-Sperre ist nicht betroffen: Es trifft nur + // Wochen, die am Server noch offen sind. + if(lockRow && lockRow.locked && !hatRecht(req.user, 's.gesperrt')){ + const stand = db.prepare('SELECT updated_at FROM weeks WHERE label = ?').get(String(week.label)); + return res.status(409).json({ + ok: false, conflict: true, + error: 'Die Sitzung "' + week.label + '" ist gesperrt und kann nicht mehr geändert werden.', + updatedAt: stand ? stand.updated_at : '' + }); + } if(typeof baseUpdatedAt === 'string'){ const row = db.prepare('SELECT updated_at FROM weeks WHERE label = ?').get(String(week.label)); @@ -3481,6 +4585,22 @@ app.post('/save-week', requireAuth, (req, res) => { // (deleted_projects). Die Planung-Sandbox ist ausgenommen — ihre // Terminpläne sind keine Projekte der Projektliste und werden dort von // jedem angelegt und gelöscht. + // Jede Projekt-id und jede projectUid darf in einer Woche nur einmal + // vorkommen. Eine doppelte id ließ das Speichern mit 500 (UNIQUE) scheitern; + // eine doppelte uid ließ eine Kopie eines bestehenden Projekts als + // "bekannt" durchgehen — so konnte auch ein Mitglied Projekte anlegen, + // und das spätere Löschen der Kopie landete nicht im Papierkorb. + { + const ids = new Set(), uids = new Set(); + for(const p of (Array.isArray(week.projects) ? week.projects : []).filter(Boolean)){ + const id = String(p.id || ''), uid = String(p.projectUid || ''); + if((id && ids.has(id)) || (uid && uids.has(uid))){ + return res.status(400).json({ ok: false, error: 'Ein Projekt kommt in der Sitzung doppelt vor (' + String(p.nr || p.name || id) + ').' }); + } + if(id) ids.add(id); + if(uid) uids.add(uid); + } + } const aenderung = week.label === SANDBOX_WEEK_LABEL ? { neu: [], entfernt: [] } : projektlisteAenderungen(week); const entfernt = aenderung.entfernt; @@ -3488,16 +4608,31 @@ app.post('/save-week', requireAuth, (req, res) => { return res.status(403).json({ ok: false, error: aenderung.neu.length - ? 'Projekte anlegen dürfen nur Projektleiter und Administratoren.' - : 'Projekte löschen dürfen nur Projektleiter und Administratoren.' + ? 'Keine Berechtigung: „Projekte anlegen und löschen“ fehlt.' + : 'Keine Berechtigung: „Projekte anlegen und löschen“ fehlt.', + recht: 'proj.anlegen' }); } + { + const fehlt = fehlendesBearbeitungsrecht(req.user, week.label, week.projects, week); + if(fehlt) return rechtFehlt(res, fehlt); + } + + // Zuständige VOR dem Speichern festhalten — danach lassen sich neu + // hinzugekommene erkennen (neueZuweisungenMelden()). Eine Woche, die es + // noch nicht gab, meldet nichts: Sie entsteht als Ganzes, nicht durch + // eine Zuweisung. + const wocheGabEs = !!db.prepare('SELECT 1 FROM weeks WHERE label = ?').get(String(week.label)); + const alteZuweisungen = new Set(db.prepare('SELECT todo_id, person_id FROM plan_todo_assignees WHERE week_label = ?') + .all(String(week.label)).map(r => r.todo_id + '|' + r.person_id)); // `updatedAt` zurückgeben, damit der Client seine lokale Revisions-Basis // aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save). const updatedAt = saveWeekRelational(week); if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user); + if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user); console.log('Saved week:', week.label); + ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req); return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() }); } catch(err){ console.error('Save week error', err); @@ -3505,11 +4640,336 @@ app.post('/save-week', requireAuth, (req, res) => { } }); -// darfProjektlisteAendern: Systemrollen, die Projekte anlegen und aus der -// Projektliste löschen dürfen. Dieselbe Regel steht clientseitig in +// neueZuweisungenMelden: legt für jede Person, die einer offenen Aufgabe neu +// als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der +// Person ein aktives Konto gehört (users.person_id; Kiosk-Konten nicht) und +// sie sich nicht selbst zugewiesen hat. Läuft NACH dem erfolgreichen +// Speichern und darf es nie scheitern lassen (Fehler werden nur geloggt). +// Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId" +// vor dem Speichern), user (req.user). Keine Rückgabe. +function neueZuweisungenMelden(week, alt, user){ + try { + const konto = db.prepare(`SELECT id FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND ${sqlOhneWandtafel()}`); + const schonDa = db.prepare('SELECT 1 FROM benachrichtigungen WHERE user_id = ? AND todo_id = ? AND gelesen = 0'); + const ins = db.prepare(`INSERT INTO benachrichtigungen + (id, user_id, art, titel, project_id, project_nr, project_name, week_label, todo_id, faellig, von_name, created_at) + VALUES (?, ?, 'aufgabe', ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + const jetzt = new Date().toISOString(); + const von = (user && (user.displayName || user.username)) || ''; + let anzahl = 0; + (Array.isArray(week.projects) ? week.projects : []).forEach(p => { + const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : []; + todos.forEach(t => { + if(!t || t.done) return; + (Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => { + if(alt.has(String(t.id) + '|' + String(pid))) return; + const k = konto.get(String(pid)); + if(!k || (user && k.id === user.id)) return; + if(schonDa.get(k.id, String(t.id))) return; + ins.run(crypto.randomUUID(), k.id, String(t.title || 'Aufgabe'), String(p.id || ''), String(p.nr || ''), + String(p.name || ''), String(week.label || ''), String(t.id || ''), String(t.end || ''), von, jetzt); + anzahl++; + }); + }); + }); + if(anzahl) console.log('Benachrichtigungen angelegt:', anzahl, '(neue Aufgaben-Zuweisungen)'); + } catch(err){ + console.error('Benachrichtigungen konnten nicht angelegt werden:', err.message); + } +} + +// purgeBenachrichtigungen: gelesene nach 60, alle nach 180 Tagen entfernen. +function purgeBenachrichtigungen(){ + try { + const tag = 864e5; + const n = db.prepare('DELETE FROM benachrichtigungen WHERE (gelesen = 1 AND created_at < ?) OR created_at < ?') + .run(new Date(Date.now() - 60 * tag).toISOString(), new Date(Date.now() - 180 * tag).toISOString()).changes; + if(n) console.log('Alte Benachrichtigungen entfernt:', n); + } catch(err){ console.error('Benachrichtigungen aufräumen fehlgeschlagen:', err.message); } +} + +// GET /benachrichtigungen: die 30 jüngsten des angemeldeten Kontos plus die +// Zahl der ungelesenen (für die Glocke). Kiosk-Konten bekommen 403 über +// requireAuth wie überall. +app.get('/benachrichtigungen', requireAuth, (req, res) => { + try { + const rows = db.prepare(`SELECT * FROM benachrichtigungen WHERE user_id = ? + ORDER BY created_at DESC LIMIT 30`).all(req.user.id); + const ungelesen = db.prepare('SELECT COUNT(*) AS n FROM benachrichtigungen WHERE user_id = ? AND gelesen = 0').get(req.user.id).n; + res.json({ ok: true, ungelesen, eintraege: rows.map(r => ({ + id: r.id, art: r.art, titel: r.titel, projectId: r.project_id, projectNr: r.project_nr, + projectName: r.project_name, weekLabel: r.week_label, todoId: r.todo_id, faellig: r.faellig, + von: r.von_name, erstellt: r.created_at, gelesen: !!r.gelesen, + })) }); + } catch(err){ + console.error('Benachrichtigungen laden fehlgeschlagen:', err); + res.status(500).json({ ok: false, error: String(err) }); + } +}); + +// POST /benachrichtigungen/gelesen {ids:[…]} oder {alle:true}: markiert als +// gelesen — immer nur Einträge des eigenen Kontos. +app.post('/benachrichtigungen/gelesen', requireAuth, (req, res) => { + try { + const { ids, alle } = req.body || {}; + let n = 0; + if(alle){ + n = db.prepare('UPDATE benachrichtigungen SET gelesen = 1 WHERE user_id = ? AND gelesen = 0').run(req.user.id).changes; + } else if(Array.isArray(ids)){ + const upd = db.prepare('UPDATE benachrichtigungen SET gelesen = 1 WHERE user_id = ? AND id = ?'); + ids.slice(0, 200).forEach(id => { n += upd.run(req.user.id, String(id)).changes; }); + } else { + return res.status(400).json({ ok: false, error: 'ids oder alle angeben.' }); + } + res.json({ ok: true, markiert: n }); + } catch(err){ + console.error('Benachrichtigungen markieren fehlgeschlagen:', err); + res.status(500).json({ ok: false, error: String(err) }); + } +}); + +// POST /benachrichtigungen/loeschen {ids:[…]} oder {alle:true}: entfernt +// Einträge endgültig (Eriks Wunsch: für die User löschbar) — wie beim +// Markieren immer nur Einträge des eigenen Kontos. +app.post('/benachrichtigungen/loeschen', requireAuth, (req, res) => { + try { + const { ids, alle } = req.body || {}; + let n = 0; + if(alle){ + n = db.prepare('DELETE FROM benachrichtigungen WHERE user_id = ?').run(req.user.id).changes; + } else if(Array.isArray(ids)){ + const del = db.prepare('DELETE FROM benachrichtigungen WHERE user_id = ? AND id = ?'); + ids.slice(0, 200).forEach(id => { n += del.run(req.user.id, String(id)).changes; }); + } else { + return res.status(400).json({ ok: false, error: 'ids oder alle angeben.' }); + } + res.json({ ok: true, geloescht: n }); + } catch(err){ + console.error('Benachrichtigungen löschen fehlgeschlagen:', err); + res.status(500).json({ ok: false, error: String(err) }); + } +}); + +// ---------- Live-Ereignisse (Server-Sent Events) ---------- +// +// Eriks Wunsch: Änderungen sollen sofort bei allen ankommen, nicht erst beim +// nächsten Abfragen (vorher alle 20 Sekunden), und nur der geänderte Teil. +// GET /ereignisse hält je offenem Browser eine Verbindung offen; nach jedem +// Speichern meldet ereignisSenden() allen anderen, was sich geändert hat: +// { typ:'projekte', label, updatedAt, projekte:[…] } — nur die geänderten +// Projekte, so wie sie jetzt in der Datenbank stehen (POST /save-projects) +// { typ:'woche', label, updatedAt, geloescht? } — eine ganze Sitzung hat sich +// geändert (POST /save-week, Entsperren, Papierkorb, Löschen); der +// Browser holt sie über GET /week nach +// { typ:'neu' } — neue Sitzung oder Backup eingespielt: alles neu laden +// Der Absender bekommt sein eigenes Ereignis nicht (Kopfzeile X-Client-Id, +// je Browser-Tab zufällig). Keine Abhängigkeit, kein WebSocket: SSE ist +// reines HTTP und läuft unverändert durch Docker. Ein vorgeschalteter Proxy +// (nginx) darf diese Antwort nicht puffern — X-Accel-Buffering: no. +const ereignisKunden = new Set(); // { res, token, clientId } + +app.get('/ereignisse', requireAuthOderKiosk, (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + res.write('retry: 3000\n\n'); + const kunde = { + res, + token: parseCookies(req)[SESSION_COOKIE] || '', + clientId: String(req.query.client || '') + }; + ereignisKunden.add(kunde); + req.on('close', () => ereignisKunden.delete(kunde)); +}); + +// Herzschlag: hält die Verbindung durch Router/Proxys offen, die stille +// Verbindungen sonst nach einer Weile trennen. +setInterval(() => { ereignisKunden.forEach(k => { try { k.res.write(': ping\n\n'); } catch(e){} }); }, 25000); + +// ereignisSenden: verteilt ein Ereignis an alle verbundenen Browser außer dem +// auslösenden. Abgelaufene oder abgemeldete Sitzungen bekommen nichts mehr +// und werden getrennt. Parameter: daten (Objekt), req (auslösende Anfrage +// oder null). +function ereignisSenden(daten, req){ + if(ereignisKunden.size === 0) return; + const von = req ? String(req.get('x-client-id') || '') : ''; + const text = 'data: ' + JSON.stringify(daten) + '\n\n'; + const jetzt = new Date().toISOString(); + const gueltig = db.prepare('SELECT 1 FROM user_sessions WHERE token = ? AND expires_at > ?'); + ereignisKunden.forEach(k => { + if(von && k.clientId === von) return; + if(!gueltig.get(k.token, jetzt)){ ereignisKunden.delete(k); try { k.res.end(); } catch(e){} return; } + try { k.res.write(text); } catch(e){ ereignisKunden.delete(k); } + }); +} + +// GET /week?label=: eine einzelne Sitzung (statt /load-all mit allen) — für +// das Nachladen nach einem Ereignis und beim Zusammenführen. Die Sandbox +// kommt in derselben Form wie bei /load-all (nur ihre Projekte). +app.get('/week', requireAuthOderKiosk, (req, res) => { + try { + const label = String(req.query.label || ''); + const w = label ? loadWeeks(label)[0] : null; + if(!w) return res.status(404).json({ ok: false, error: 'Sitzung nicht gefunden' }); + res.json({ ok: true, week: w }); + } catch(err){ + res.status(500).json({ ok: false, error: String(err) }); + } +}); + +// POST /save-projects {label, projects:[{project, baseUpdatedAt}]}: speichert +// NUR die übergebenen Projekte einer Sitzung — der Browser schickt, was sich +// gegenüber dem zuletzt bekannten Server-Stand geändert hat, statt der ganzen +// Sitzung. Konflikte werden je PROJEKT geprüft (projects.updated_at): Zwei +// Personen an verschiedenen Projekten kommen sich nicht mehr in die Quere. +// Nur für bestehende Projekte; Anlegen, Löschen, Umsortieren und Änderungen +// an der Sitzung selbst (Teilnehmer, Anwesenheit, Sperre) laufen weiter über +// /save-week. Antwort: { ok, updatedAt (Sitzung), projekte: {id: updatedAt} }; +// 409 { conflict, projekte:[ids] }, wenn ein Projekt inzwischen anders steht. +app.post('/save-projects', requireAuth, (req, res) => { + const { label, projects } = req.body || {}; + if(typeof label !== 'string' || !label || !Array.isArray(projects) || projects.length === 0){ + return res.status(400).json({ ok: false, error: 'label und projects erforderlich' }); + } + const liste = projects.filter(e => e && e.project && e.project.id); + const badStatus = findInvalidAuftragsstatus({ projects: liste.map(e => e.project) }); + if(badStatus){ + return res.status(400).json({ ok: false, error: 'Unbekannter Auftragsstatus "' + badStatus.wert + '".' }); + } + try { + const wocheRow = db.prepare('SELECT locked, updated_at FROM weeks WHERE label = ?').get(label); + if(!wocheRow) return res.status(409).json({ ok: false, conflict: true, error: 'Die Sitzung "' + label + '" gibt es nicht mehr.', projekte: [] }); + if(wocheRow.locked && !hatRecht(req.user, 's.gesperrt')){ + return res.status(409).json({ ok: false, conflict: true, error: 'Die Sitzung "' + label + '" ist gesperrt.', projekte: [] }); + } + const stand = db.prepare('SELECT array_order, updated_at, project_uid FROM projects WHERE week_label = ? AND project_id = ?'); + const konflikte = []; + const gefunden = liste.map(e => { + const row = stand.get(label, String(e.project.id)); + if(!row || String(e.baseUpdatedAt || '') !== String(row.updated_at || '') + || String(e.project.projectUid || '') !== String(row.project_uid || '')) konflikte.push(String(e.project.id)); + return { e, row }; + }); + if(konflikte.length){ + return res.status(409).json({ ok: false, conflict: true, projekte: konflikte, + error: 'Ein Projekt wurde zwischenzeitlich von einer anderen Person geändert.' }); + } + { + const fehlt = fehlendesBearbeitungsrecht(req.user, label, liste.map(e => e.project), null); + if(fehlt) return rechtFehlt(res, fehlt); + } + const alteZuweisungen = new Set(db.prepare('SELECT todo_id, person_id FROM plan_todo_assignees WHERE week_label = ?') + .all(label).map(r => r.todo_id + '|' + r.person_id)); + const jetzt = new Date().toISOString(); + db.exec('BEGIN'); + try { + gefunden.forEach(({ e, row }) => { + deleteProjectData(label, String(e.project.id)); + serializeProject(label, e.project, row.array_order, jetzt); + }); + // Die Sitzung als Ganzes ist damit auch geändert: Ein gleichzeitiges + // /save-week auf altem Stand muss das als Konflikt bemerken. + db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, label); + db.exec('COMMIT'); + } catch(err){ db.exec('ROLLBACK'); throw err; } + neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user); + const ids = new Set(liste.map(e => String(e.project.id))); + const frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id))); + ereignisSenden({ typ: 'projekte', label, updatedAt: jetzt, projekte: frisch }, req); + const projekte = {}; + frisch.forEach(p => { projekte[p.id] = p.updatedAt; }); + console.log('Saved projects:', label, Array.from(ids).length); + res.json({ ok: true, updatedAt: jetzt, projekte }); + } catch(err){ + console.error('Save projects error', err); + res.status(500).json({ ok: false, error: String(err) }); + } +}); + +// GET /week-revisionen: der Revisionsstand (weeks.updated_at) jeder Sitzung — +// winzig, deshalb fragt der Browser ihn alle 20 Sekunden ab und holt nur bei +// einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim +// gleichzeitigen Arbeiten, js/store.js). Für die Wandtafel ebenfalls lesbar. +app.get('/week-revisionen', requireAuthOderKiosk, (req, res) => { + try { + const revisionen = {}; + db.prepare('SELECT label, updated_at FROM weeks').all().forEach(r => { revisionen[r.label] = r.updated_at || ''; }); + res.json({ ok: true, revisionen }); + } catch(err){ + res.status(500).json({ ok: false, error: String(err) }); + } +}); + +// ---------- Bearbeitungsrechte beim Speichern ---------- +// /save-week und /save-projects schreiben ganze Projekte. Welches Recht eine +// Änderung braucht, zeigt erst der Vergleich mit dem gespeicherten Stand: +// Kommentar und Leistungsphasen-Fortschritt → wb.kommentar, „besprochen“ → +// wb.besprochen, Terminplan (plan) → proj.plan, alles andere am Projekt → +// proj.stamm, Anwesenheit der Sitzung → wb.anwesenheit, die Planung-Sandbox +// → wz.planung. Wer alle diese Rechte hat (Startbelegung), überspringt den +// Vergleich ganz — nur eingeschränkte Rollen zahlen dafür. +const BEARBEITUNGS_RECHTE = ['wb.kommentar', 'wb.besprochen', 'wb.anwesenheit', 'proj.stamm', 'proj.plan']; +const PROJEKT_FELDER_OHNE_RECHT = new Set(['id', 'projectUid', 'updatedAt', 'order', + 'status', 'statusAuthor', 'statusAt', 'besprochen', 'statusState']); + +// normWert: vergleichbare Form — leere Werte (undefined, null, '', false, 0, +// [], {}) gelten als gleich, Zahlen und Text ebenso („5“ = 5), Schlüssel- +// reihenfolge zählt nicht. Sonst meldete ein Feld, das der Browser nur +// anders schreibt, eine Änderung, die niemand gemacht hat. +function normWert(v){ + if(v === undefined || v === null || v === '' || v === false || v === 0) return null; + if(Array.isArray(v)){ const a = v.map(normWert); return a.length ? a : null; } + if(typeof v === 'object'){ + const o = {}; + Object.keys(v).sort().forEach(k => { const x = normWert(v[k]); if(x !== null) o[k] = x; }); + return Object.keys(o).length ? o : null; + } + return String(v); +} +const gleichNorm = (a, b) => JSON.stringify(normWert(a)) === JSON.stringify(normWert(b)); + +// fehlendesBearbeitungsrecht: erstes fehlende Recht für die Änderungen oder +// null. Parameter: user, label, projekte (eingehend), woche (bei /save-week, +// für die Anwesenheit; sonst null). +function fehlendesBearbeitungsrecht(user, label, projekte, woche){ + if(String(label) === SANDBOX_WEEK_LABEL) return hatRecht(user, 'wz.planung') ? null : 'wz.planung'; + if(BEARBEITUNGS_RECHTE.every(r => hatRecht(user, r))) return null; + const alt = loadWeeks(String(label))[0]; + if(!alt) return null; // neue Sitzung: legt nur der Server an + if(woche && !hatRecht(user, 'wb.anwesenheit') && !gleichNorm(woche.attendance, alt.attendance)) return 'wb.anwesenheit'; + const altJeId = new Map(), altJeUid = new Map(); + (alt.projects || []).forEach(p => { altJeId.set(String(p.id), p); if(p.projectUid) altJeUid.set(String(p.projectUid), p); }); + for(const p of (projekte || []).filter(Boolean)){ + const a = altJeId.get(String(p.id)) || (p.projectUid && altJeUid.get(String(p.projectUid))); + if(!a) continue; // neu angelegt — prüft darfProjektlisteAendern() + const keys = new Set([...Object.keys(p), ...Object.keys(a)]); + for(const k of keys){ + if(PROJEKT_FELDER_OHNE_RECHT.has(k) || gleichNorm(p[k], a[k])) continue; + if(k === 'kommentare'){ + const kn = p.kommentare || {}, ka = a.kommentare || {}; + for(const g of new Set([...Object.keys(kn), ...Object.keys(ka)])){ + const x = kn[g] || {}, y = ka[g] || {}; + if(!gleichNorm({ s: x.status, a: x.statusAuthor, t: x.statusAt }, { s: y.status, a: y.statusAuthor, t: y.statusAt }) + && !hatRecht(user, 'wb.kommentar')) return 'wb.kommentar'; + if(!gleichNorm(x.besprochen, y.besprochen) && !hatRecht(user, 'wb.besprochen')) return 'wb.besprochen'; + } + continue; + } + const recht = k === 'lph' ? 'wb.kommentar' : k === 'plan' ? 'proj.plan' : 'proj.stamm'; + if(!hatRecht(user, recht)) return recht; + } + } + return null; +} + +// darfProjektlisteAendern: Recht „Projekte anlegen und löschen“ (Rollen & +// Rechte). Dieselbe Regel steht clientseitig in // darfProjektlisteAendern() (public/js/auth.js), dort nur als UI-Gate. function darfProjektlisteAendern(user){ - return !!user && (user.role === 'admin' || user.role === 'projektleiter'); + return hatRecht(user, 'proj.anlegen'); } // projektlisteAenderungen: vergleicht die eingehende Woche mit dem @@ -3563,7 +5023,7 @@ function legeInPapierkorb(projekte, weekLabel, user){ // Route: Papierkorb — listet die gelöschten Projekte der letzten // GELOESCHTE_PROJEKTE_TAGE Tage (ohne den Schnappschuss selbst). -app.get('/admin/deleted-projects', requireRole('admin'), (req, res) => { +app.get('/admin/deleted-projects', requireRecht('a.papierkorb'), (req, res) => { try { purgeGeloeschteProjekte(); const rows = db.prepare(`SELECT id, week_label, nr, name, deleted_by_name, deleted_at @@ -3589,7 +5049,7 @@ app.get('/admin/deleted-projects', requireRole('admin'), (req, res) => { // wurde: Ältere Sitzungen sind gesperrte Historie. Eine Nummern-Kollision mit // einem inzwischen neu angelegten Projekt wird mit 409 abgewiesen (Dateien // und Protokolle hängen an der Nummer und gehörten sonst zwei Projekten). -app.post('/admin/deleted-projects/:id/restore', requireRole('admin'), (req, res) => { +app.post('/admin/deleted-projects/:id/restore', requireRecht('a.papierkorb'), (req, res) => { try { const row = db.prepare('SELECT * FROM deleted_projects WHERE id = ?').get(String(req.params.id)); if(!row) return res.status(404).json({ ok: false, error: 'Eintrag nicht gefunden (evtl. schon wiederhergestellt oder abgelaufen).' }); @@ -3616,14 +5076,14 @@ app.post('/admin/deleted-projects/:id/restore', requireRole('admin'), (req, res) projekt.order = maxOrder + 1; // Der Sitzungskommentar gehört zur Sitzung, aus der das Projekt stammt. if(row.week_label !== ziel.label){ - projekt.status = ''; projekt.statusAuthor = ''; projekt.statusAt = ''; - projekt.besprochen = false; delete projekt.statusState; + projekt.kommentare = {}; delete projekt.statusState; } projekte.push(projekt); const updatedAt = saveWeekRelational(ziel); db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(row.id); console.log('Projekt aus dem Papierkorb wiederhergestellt:', ((projekt.nr ? projekt.nr + ' ' : '') + (projekt.name || '')).trim(), '→', ziel.label); + ereignisSenden({ typ: 'woche', label: ziel.label, updatedAt }, req); res.json({ ok: true, weekLabel: ziel.label, updatedAt }); } catch(err){ console.error('Wiederherstellen fehlgeschlagen:', err); @@ -3632,7 +5092,7 @@ app.post('/admin/deleted-projects/:id/restore', requireRole('admin'), (req, res) }); // Route: Papierkorb — entfernt einen Eintrag sofort endgültig. -app.delete('/admin/deleted-projects/:id', requireRole('admin'), (req, res) => { +app.delete('/admin/deleted-projects/:id', requireRecht('a.papierkorb'), (req, res) => { try { db.prepare('DELETE FROM deleted_projects WHERE id = ?').run(String(req.params.id)); res.json({ ok: true }); @@ -3642,7 +5102,7 @@ app.delete('/admin/deleted-projects/:id', requireRole('admin'), (req, res) => { }); // Route: Löscht eine Woche (fehlende Woche wird toleriert) -app.post('/delete-week', requireRole('admin'), (req, res) => { +app.post('/delete-week', requireRecht('s.loeschen'), (req, res) => { const { label } = req.body || {}; if(!label) return res.status(400).json({ ok: false, error: 'Missing label' }); if(label === SANDBOX_WEEK_LABEL) return res.status(400).json({ ok: false, error: 'Die Planung-Sandbox ist keine Sitzung und kann hier nicht gelöscht werden.' }); @@ -3661,6 +5121,7 @@ app.post('/delete-week', requireRole('admin'), (req, res) => { throw err; } console.log('Deleted week:', label, existed ? '' : '(nicht vorhanden)'); + if(existed) ereignisSenden({ typ: 'woche', label: String(label), geloescht: true }, req); return res.json({ ok: true, deleted: existed }); } catch(err){ console.error('Delete week error', err); @@ -3672,7 +5133,7 @@ app.post('/delete-week', requireRole('admin'), (req, res) => { // clientseitige Entsperr-Logik). Setzt `locked = 0` und schreibt eine neue // Revision (`updated_at`), damit der Client seine lokale Sync-Basis // aktualisieren kann (gleiche Konvention wie bei /save-week). -app.post('/unlock-week', requireRole('admin'), (req, res) => { +app.post('/unlock-week', requireRecht('s.entsperren'), (req, res) => { const { label } = req.body || {}; if(!label) return res.status(400).json({ ok: false, error: 'Missing label' }); if(label === SANDBOX_WEEK_LABEL) return res.status(400).json({ ok: false, error: 'Die Planung-Sandbox ist keine Sitzung und wird nie gesperrt.' }); @@ -3682,6 +5143,7 @@ app.post('/unlock-week', requireRole('admin'), (req, res) => { const updatedAt = new Date().toISOString(); db.prepare('UPDATE weeks SET locked = 0, updated_at = ? WHERE label = ?').run(updatedAt, String(label)); console.log('Unlocked week:', label); + ereignisSenden({ typ: 'woche', label: String(label), updatedAt }, req); return res.json({ ok: true, updatedAt }); } catch(err){ console.error('Unlock week error', err); @@ -3704,7 +5166,7 @@ app.get('/load-all', requireAuthOderKiosk, (req, res) => { const sandbox = allWeeks.find(w => w.label === SANDBOX_WEEK_LABEL); const sandboxPlans = sandbox ? sandbox.projects : []; const sandboxUpdatedAt = sandbox ? sandbox.updatedAt : ''; - const people = loadGlobalPeople(); + const people = loadGlobalPeople(req.user); res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt }); } catch(err){ console.error('Load error', err); @@ -3761,14 +5223,17 @@ app.get('/my-dashboard', requireAuth, (req, res) => { try { // Normalfall: applyAutoLock() garantiert genau eine entsperrte Woche // (jetzt firmenweit global, nicht mehr je Abteilung). - const unlocked = db.prepare('SELECT label FROM weeks WHERE locked = 0').all(); + // Die Planung-Sandbox ist immer entsperrt und keine echte Sitzung — ohne + // diesen Ausschluss gab es stets zwei entsperrte Wochen, und die richtige + // wurde nur zufällig über die Sortierung im Rückfallzweig gefunden. + const unlocked = db.prepare('SELECT label FROM weeks WHERE locked = 0 AND label != ?').all(SANDBOX_WEEK_LABEL); let weekLabel = null; if(unlocked.length === 1){ weekLabel = unlocked[0].label; } else { // Fallback (0 oder >1 entsperrte Wochen, sollte normalerweise nicht // vorkommen): jüngste Woche nach weekSortKeyServer() wählen. - const all = db.prepare('SELECT label FROM weeks').all(); + const all = db.prepare('SELECT label FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL); if(all.length > 0){ weekLabel = all.map(r => r.label) .sort((a, b) => weekSortKeyServer(b).localeCompare(weekSortKeyServer(a)))[0]; @@ -3944,7 +5409,7 @@ app.get('/my-dashboard', requireAuth, (req, res) => { FROM project_file_reviewers r JOIN project_files f ON f.id = r.file_id LEFT JOIN users u ON u.id = f.uploaded_by_user_id - WHERE r.user_id = ? AND r.decision = 'pending' + WHERE r.user_id = ? AND r.decision = 'pending' AND f.status = 'pending' ORDER BY f.created_at DESC `).all(req.user.id); @@ -3956,7 +5421,7 @@ app.get('/my-dashboard', requireAuth, (req, res) => { const todayIso = heuteIsoLokal(); const horizonIso = isoPlusTageLokal(14); const colleagueAbsences = []; - loadGlobalPeople().forEach(person => { + loadGlobalPeople(req.user).forEach(person => { if(person.id && person.id === req.user.personId) return; (person.absences || []).forEach(a => { if(!a.start || !a.end) return; @@ -4041,7 +5506,7 @@ function serializeFileRow(row){ // Route: Liste aller aktiven Benutzer (ohne Passwortfelder, ohne inaktive // Konten) — für die Prüfer-Auswahl beim Datei-Upload. Bewusst für JEDEN -// eingeloggten Benutzer zugänglich (requireAuth, nicht requireRole('admin')), +// eingeloggten Benutzer zugänglich (requireAuth, kein Admin-Recht), // da jeder Uploader Prüfer auswählen muss; getrennt von GET /admin/users // (admin-only, zeigt zusätzlich inaktive Konten + mehr Felder). app.get('/users', requireAuth, (req, res) => { @@ -4049,7 +5514,7 @@ app.get('/users', requireAuth, (req, res) => { // role != 'kiosk': das Wandtafel-Konto ist kein Teammitglied — es soll // weder im Datei-Freigabe-Prüfer-Auswahlfeld noch in der // abteilungsübergreifenden Besetzung zur Wahl stehen. - const rows = db.prepare("SELECT id, username, display_name, email, person_id, department, initials FROM users WHERE active = 1 AND role != 'kiosk' ORDER BY display_name, username").all(); + const rows = db.prepare(`SELECT id, username, display_name, email, person_id, department, initials FROM users WHERE active = 1 AND ${sqlOhneWandtafel()} ORDER BY display_name, username`).all(); const rolesByUser = loadUserRolesByUserId(); res.json({ ok: true, users: rows.map(r => ({ id: r.id, username: r.username, displayName: r.display_name, email: r.email, @@ -4062,51 +5527,72 @@ app.get('/users', requireAuth, (req, res) => { } }); -// Route: Abwesenheit für einen Benutzer anlegen. Bewusst NICHT admin-only und -// bewusst auch für FREMDE Konten erlaubt: In der Praxis trägt die -// Projektleitung Urlaube ihres Teams mit ein. Wer den Eintrag angelegt hat, -// wird in created_by festgehalten — das Löschen ist dadurch nachvollziehbar -// eingeschränkt (siehe DELETE-Route unten). +// ---------- Abwesenheiten ---------- +// Rechte (Rollen & Rechte, Gruppe „Team“): team.abw (für mich), +// team.abwProjekte, team.abwGewerk, team.abwAlle — geprüft über +// darfAbwesenheit() für Anlegen, Ändern und Löschen gleichermaßen. +const ABWESENHEIT_ARTEN = new Set(['Urlaub', 'Krank', 'Fortbildung', 'Sonstiges']); +// abwesenheitPruefen: Eingaben prüfen. Rückgabe: Fehlertext oder ''. +function abwesenheitPruefen(start, end, type){ + const iso = /^\d{4}-\d{2}-\d{2}$/; + if(!iso.test(String(start || '')) || !iso.test(String(end || ''))) return 'Start- und Enddatum erforderlich.'; + if(String(end) < String(start)) return 'Das Ende liegt vor dem Beginn.'; + if(!ABWESENHEIT_ARTEN.has(String(type || ''))) return 'Unbekannte Art der Abwesenheit.'; + return ''; +} +const ABW_KEIN_RECHT = 'Für diese Person darfst du keine Abwesenheiten eintragen.'; + +// GET /personen: die Personenliste wie in /load-all (mit den Abwesenheiten +// aus Sicht des Aufrufers) — zum Nachladen nach einem Live-Ereignis. +app.get('/personen', requireAuthOderKiosk, (req, res) => { + res.json({ ok: true, people: loadGlobalPeople(req.user) }); +}); + app.post('/users/:id/absences', requireAuth, (req, res) => { const id = Number(req.params.id); const user = db.prepare('SELECT id FROM users WHERE id = ?').get(id); if(!user) return res.status(404).json({ ok: false, error: 'Benutzer nicht gefunden.' }); + if(!darfAbwesenheit(req.user, id)) return res.status(403).json({ ok: false, error: ABW_KEIN_RECHT }); const { start, end, type, note } = req.body || {}; - if(!start || !String(start).trim() || !end || !String(end).trim()){ - return res.status(400).json({ ok: false, error: 'Start- und Enddatum erforderlich.' }); - } + const fehler = abwesenheitPruefen(start, end, type); + if(fehler) return res.status(400).json({ ok: false, error: fehler }); try { const absenceId = crypto.randomUUID(); db.prepare('INSERT INTO user_absences (user_id, id, start, end, type, note, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)') - .run(id, absenceId, String(start), String(end), String(type || ''), String(note || ''), req.user.id); - res.json({ ok: true, absence: { id: absenceId, start: String(start), end: String(end), type: String(type || ''), note: String(note || '') } }); + .run(id, absenceId, String(start), String(end), String(type), String(note || ''), req.user.id); + ereignisSenden({ typ: 'personen' }, req); + res.json({ ok: true, absence: { id: absenceId, start: String(start), end: String(end), type: String(type), note: String(note || '') } }); } catch(err){ console.error('Abwesenheit anlegen fehlgeschlagen:', err); res.status(500).json({ ok: false, error: String(err) }); } }); -// Route: Abwesenheit löschen (idempotent — auch ok, wenn nichts gelöscht wurde). -// Erlaubt für: die betroffene Person selbst, wer den Eintrag angelegt hat, und -// Administratoren. Ohne diese Prüfung konnte jedes angemeldete Konto fremde -// Urlaube entfernen und damit unbemerkt die Ressourcenplanung verfälschen. +app.patch('/users/:id/absences/:absenceId', requireAuth, (req, res) => { + const id = Number(req.params.id); + if(!darfAbwesenheit(req.user, id)) return res.status(403).json({ ok: false, error: ABW_KEIN_RECHT }); + const row = db.prepare('SELECT * FROM user_absences WHERE user_id = ? AND id = ?').get(id, req.params.absenceId); + if(!row) return res.status(404).json({ ok: false, error: 'Abwesenheit nicht gefunden.' }); + const b = req.body || {}; + const start = b.start !== undefined ? b.start : row.start; + const end = b.end !== undefined ? b.end : row.end; + const type = b.type !== undefined ? b.type : row.type; + const note = b.note !== undefined ? String(b.note) : row.note; + const fehler = abwesenheitPruefen(start, end, type); + if(fehler) return res.status(400).json({ ok: false, error: fehler }); + db.prepare('UPDATE user_absences SET start = ?, end = ?, type = ?, note = ? WHERE user_id = ? AND id = ?') + .run(String(start), String(end), String(type), note, id, req.params.absenceId); + ereignisSenden({ typ: 'personen' }, req); + res.json({ ok: true, absence: { id: row.id, start: String(start), end: String(end), type: String(type), note } }); +}); + +// Löschen: idempotent (auch ok, wenn nichts gelöscht wurde). app.delete('/users/:id/absences/:absenceId', requireAuth, (req, res) => { try { const targetUserId = Number(req.params.id); - const row = db.prepare('SELECT created_by FROM user_absences WHERE user_id = ? AND id = ?') - .get(targetUserId, req.params.absenceId); - if(row){ - const istAdmin = req.user.role === 'admin'; - const istBetroffener = req.user.id === targetUserId; - const istErsteller = row.created_by && row.created_by === req.user.id; - if(!istAdmin && !istBetroffener && !istErsteller){ - return res.status(403).json({ - ok: false, - error: 'Diese Abwesenheit wurde von einer anderen Person eingetragen. Nur sie selbst, die betroffene Person oder ein Administrator kann sie entfernen.' - }); - } - } - db.prepare('DELETE FROM user_absences WHERE user_id = ? AND id = ?').run(targetUserId, req.params.absenceId); + if(!darfAbwesenheit(req.user, targetUserId)) return res.status(403).json({ ok: false, error: ABW_KEIN_RECHT }); + const r = db.prepare('DELETE FROM user_absences WHERE user_id = ? AND id = ?').run(targetUserId, req.params.absenceId); + if(r.changes) ereignisSenden({ typ: 'personen' }, req); res.json({ ok: true }); } catch(err){ console.error('Abwesenheit löschen fehlgeschlagen:', err); @@ -4114,19 +5600,37 @@ app.delete('/users/:id/absences/:absenceId', requireAuth, (req, res) => { } }); +// empfangeInDatei: schreibt den rohen Anfrage-Body als Datenstrom in ziel, +// ohne ihn im Speicher zu sammeln. Über maxBytes: Abbruch, Datei weg, 413. +// Rückgabe: Promise. +function empfangeInDatei(req, ziel, maxBytes){ + return new Promise((resolve, reject) => { + const aus = fs.createWriteStream(ziel); + let n = 0, abgebrochen = false; + const fehler = (err) => { + if(abgebrochen) return; abgebrochen = true; + req.unpipe(aus); aus.destroy(); fs.unlink(ziel, () => {}); + reject(err); + }; + req.on('data', chunk => { + n += chunk.length; + if(n > maxBytes){ const e = new Error('Datei zu groß (höchstens ' + Math.round(maxBytes / 1048576) + ' MB).'); e.status = 413; fehler(e); req.resume(); } + }); + req.on('aborted', () => fehler(new Error('Upload abgebrochen.'))); + aus.on('error', fehler); + aus.on('finish', () => { if(!abgebrochen) resolve(n); }); + req.pipe(aus); + }); +} + // Route: Datei-Upload für ein Projekt. Roher Binär-Body (wie /import), Metadaten // als Query-Parameter, da der Body ausschließlich die Datei selbst enthält. -app.post('/project-files', requireAuth, express.raw({ type: '*/*', limit: '200mb' }), (req, res) => { +app.post('/project-files', requireAuth, requireRecht('proj.dateien'), async (req, res) => { try { const projectNr = String(req.query.projectNr || ''); if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' }); const projectName = String(req.query.projectName || ''); - const buf = req.body; - if(!Buffer.isBuffer(buf) || buf.length === 0){ - return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' }); - } - let filename = ''; try { filename = decodeURIComponent(String(req.query.filename || '')); } catch(e){ filename = String(req.query.filename || ''); } @@ -4138,7 +5642,9 @@ app.post('/project-files', requireAuth, express.raw({ type: '*/*', limit: '200mb .split(',') .map(s => Number(s.trim())) .filter(n => Number.isInteger(n) && n > 0); - const activeUsers = db.prepare('SELECT id FROM users WHERE active = 1').all().map(r => r.id); + // Kiosk-Konten können nie entscheiden (requireAuth weist sie ab) — als + // Prüfer würden sie die Datei für immer offen halten. + const activeUsers = db.prepare(`SELECT id FROM users WHERE active = 1 AND ${sqlOhneWandtafel()}`).all().map(r => r.id); const activeSet = new Set(activeUsers); const reviewerIds = Array.from(new Set(rawReviewerIds)) .filter(id => activeSet.has(id) && id !== req.user.id); @@ -4149,7 +5655,15 @@ app.post('/project-files', requireAuth, express.raw({ type: '*/*', limit: '200mb const id = crypto.randomUUID(); const storedName = id + (ext ? '.' + ext : ''); const dir = uploadDirFor(); - fs.writeFileSync(path.join(dir, storedName), buf); + // Direkt auf die Platte statt erst ganz in den Arbeitsspeicher (Pi Zero: + // eine 200-MB-Datei im RAM brachte den Server zum Absturz). + let groesse; + try { groesse = await empfangeInDatei(req, path.join(dir, storedName), 200 * 1024 * 1024); } + catch(err){ return res.status(err.status || 400).json({ ok: false, error: err.message }); } + if(!groesse){ + fs.unlink(path.join(dir, storedName), () => {}); + return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' }); + } const contentType = req.headers['content-type'] || 'application/octet-stream'; const now = new Date().toISOString(); @@ -4159,7 +5673,7 @@ app.post('/project-files', requireAuth, express.raw({ type: '*/*', limit: '200mb db.prepare(`INSERT INTO project_files (id, project_nr, project_name, original_filename, stored_name, content_type, size_bytes, uploaded_by_user_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`) - .run(id, projectNr, projectName, filename || storedName, storedName, contentType, buf.length, req.user.id, now, now); + .run(id, projectNr, projectName, filename || storedName, storedName, contentType, groesse, req.user.id, now, now); const insReviewer = db.prepare(`INSERT INTO project_file_reviewers (file_id, user_id, decision, decided_at, note) VALUES (?, ?, 'pending', '', '')`); reviewerIds.forEach(uid => insReviewer.run(id, uid)); db.exec('COMMIT'); @@ -4176,7 +5690,7 @@ app.post('/project-files', requireAuth, express.raw({ type: '*/*', limit: '200mb file: { id, projectNr, projectName, originalFilename: filename || storedName, - contentType, sizeBytes: buf.length, status: 'pending', createdAt: now, + contentType, sizeBytes: groesse, status: 'pending', createdAt: now, uploadedBy: req.user.displayName, } }); @@ -4244,7 +5758,7 @@ app.get('/project-files/:id/download', requireAuth, (req, res) => { // für Benutzer, die tatsächlich als Prüfer für diese Datei eingetragen sind. // Neuberechnung des Gesamtstatus: eine Ablehnung blockiert sofort; erst wenn // ALLE Prüfer zugestimmt haben, gilt die Datei als freigegeben. -app.post('/project-files/:id/review', requireAuth, (req, res) => { +app.post('/project-files/:id/review', requireAuth, requireRecht('proj.dateien'), (req, res) => { try { const fileRow = db.prepare('SELECT * FROM project_files WHERE id = ?').get(req.params.id); if(!fileRow) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' }); @@ -4299,7 +5813,7 @@ app.get('/project-files/:id/comments', requireAuth, (req, res) => { // Route: Kommentar zu einer Datei hinzufügen. Jeder eingeloggte Benutzer darf // kommentieren, nicht nur die eingetragenen Prüfer. -app.post('/project-files/:id/comments', requireAuth, (req, res) => { +app.post('/project-files/:id/comments', requireAuth, requireRecht('proj.dateien'), (req, res) => { try { const fileRow = db.prepare('SELECT id FROM project_files WHERE id = ?').get(req.params.id); if(!fileRow) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' }); @@ -4328,7 +5842,7 @@ app.delete('/project-files/:id', requireAuth, (req, res) => { if(!row) return res.status(404).json({ ok: false, error: 'Datei nicht gefunden.' }); const isOwnerAndPending = (req.user.id === row.uploaded_by_user_id) && row.status === 'pending'; - const isAdmin = req.user.role === 'admin'; + const isAdmin = hatRecht(req.user, 'proj.dateienFremd'); if(!isOwnerAndPending && !isAdmin){ return res.status(403).json({ ok: false, error: 'Keine Berechtigung, diese Datei zu löschen.' }); } @@ -4423,7 +5937,10 @@ app.get('/project-protokolle', requireAuth, (req, res) => { return { id: row.id, nummer: row.nummer, datum: row.datum, betreff: row.betreff, ort: row.ort, verfasserName: row.verfasser_name || '', createdAt: row.created_at, - offenCount, erledigtCount: punkte.length - offenCount, teilnehmerCount + // Info-Punkte sind weder offen noch erledigt — eigene Zahl statt + // sie bei "erledigt" mitzuzählen. + offenCount, erledigtCount: punkte.filter(p => p.status === 'erledigt').length, + infoCount: punkte.filter(p => p.status === 'info').length, teilnehmerCount }; }); res.json({ ok: true, protokolle }); @@ -4488,9 +6005,9 @@ const PROTOKOLL_PUNKT_MARKER = /^\s*(?:top|punkt)?\s*(\d+(?:\.\d+)*)[.\):]?\s+(. // Zuständig-Treffer hinein. Ein Punkt gilt nur als Satzende, wenn ihm keine // Ziffer folgt — sonst würde ein Datum wie "15.10.2026" beim ersten Punkt // abgeschnitten. -const PROTOKOLL_LABEL_STOP = '(?=[,;]|\\.(?!\\d)|$|\\s*(?:zust[äa]ndig|verantwortlich|termin|frist)\\s*:)'; +const PROTOKOLL_LABEL_STOP = '(?=[,;]|\\.(?!\\d)|$|\\s*(?:zust(?:ä|ae|a)ndig|verantwortlich|termin|frist)\\s*:)'; const PROTOKOLL_LABEL_MUSTER = [ - { feld: 'zustaendig', regex: new RegExp('(?:zust[äa]ndig|verantwortlich)\\s*:\\s*([^,;]+?)' + PROTOKOLL_LABEL_STOP, 'i') }, + { feld: 'zustaendig', regex: new RegExp('(?:zust(?:ä|ae|a)ndig|verantwortlich)\\s*:\\s*([^,;]+?)' + PROTOKOLL_LABEL_STOP, 'i') }, { feld: 'termin', regex: new RegExp('(?:termin|frist)\\s*:\\s*([^,;]+?)' + PROTOKOLL_LABEL_STOP, 'i') } ]; @@ -4511,6 +6028,23 @@ const PROTOKOLL_LABEL_MUSTER = [ // (berechneProtokollNummern(), public/js/plan/plan-protokoll.js). // Parameter: text (String). Rückgabe: Array von // { nummer, gruppe, thema, text, zustaendig, termin, erledigt }. +// normalisiereTermin: bringt ein erkanntes Datum auf ISO (JJJJ-MM-TT), das +// Format, das Protokoll-Ansicht, PDF (parseDateISO()) und +// erwarten. Protokolle schreiben "15.10.2026" oder "5.1.26" — das wurde +// bisher unverändert gespeichert und erschien dann überall als "kein +// Termin"; bei "TODOs aus Protokoll" ging die Fälligkeit still verloren. +// Was kein erkennbares Datum ist ("KW 45", "laufend"), bleibt wie es ist. +// Parameter: wert. Rückgabe: String. +function normalisiereTermin(wert){ + const s = String(wert || '').trim(); + const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/); + if(!m) return s; + const jahr = m[3].length === 2 ? '20' + m[3] : m[3]; + const monat = Number(m[2]), tag = Number(m[1]); + if(monat < 1 || monat > 12 || tag < 1 || tag > 31) return s; + return jahr + '-' + String(monat).padStart(2, '0') + '-' + String(tag).padStart(2, '0'); +} + function erkennePunkteAusText(text){ const zeilen = String(text || '').split(/\r?\n/).map(z => z.trim()); @@ -4563,8 +6097,11 @@ function erkennePunkteAusText(text){ // "…abzuschließen.") — am Ende auf einen einzigen Punkt einkürzen. thema = thema.replace(/[.\s]+$/, m => (m.indexOf('.') !== -1 ? '.' : '')).trim(); const erledigt = /\berledigt\b|\bgeschlossen\b/i.test(restText) || /\berledigt\b|\bgeschlossen\b/i.test(thema); + // status zusätzlich zu erledigt: Der Client (TODOs aus Protokoll) wählt + // offene Punkte über status vor — ohne das Feld war nichts vorausgewählt. return { nummer: t.nummer, gruppe: '', thema, text: restText, - zustaendig: t.zustaendig, termin: t.termin, erledigt }; + zustaendig: t.zustaendig, termin: normalisiereTermin(t.termin), erledigt, + status: erledigt ? 'erledigt' : 'offen' }; }); } @@ -4794,7 +6331,7 @@ function normalisierePunktAusKi(p){ thema: String((p && p.thema) || ''), text: String((p && p.text) || ''), zustaendig: String((p && p.zustaendig) || ''), - termin: String((p && p.termin) || ''), + termin: normalisiereTermin((p && p.termin) || ''), status: VALID_PROTOKOLL_PUNKT_STATUS.has(p && p.status) ? p.status : 'offen' }; } @@ -5169,13 +6706,15 @@ async function leiteAufgabenMitKI(punkte, userId, provider){ // Archiv, siehe Kopfkommentar oben). Die erkannten Punkte gehen direkt an // den Client zurück und werden von der zweiten Route unten (ohne erneuten // Server-Lookup) weiterverarbeitet. -app.post('/plan-todos-aus-protokoll/einlesen', requireAuth, express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => { +app.post('/plan-todos-aus-protokoll/einlesen', requireAuth, requireRecht('proj.plan'), express.raw({ type: '*/*', limit: '25mb' }), async (req, res) => { try { const buf = req.body; if(!Buffer.isBuffer(buf) || buf.length === 0){ return res.status(400).json({ ok: false, error: 'Keine Datei empfangen.' }); } - const text = await extrahierePdfText(buf); + let text; + try { text = await extrahierePdfText(buf); } + catch(err){ return res.status(400).json({ ok: false, error: 'Die Datei konnte nicht als PDF gelesen werden.' }); } let punkte, erkennungsart = 'textmuster', erkennungshinweis = ''; const anbieter = req.query.anbieter; @@ -5203,10 +6742,12 @@ app.post('/plan-todos-aus-protokoll/einlesen', requireAuth, express.raw({ type: // Route 2/2: leitet aus den (vom Client ausgewählten) Punkten Aufgaben- // Vorschläge ab. Die Punkte kommen direkt im Body — kein Bezug zu // project_protokolle, siehe Kopfkommentar oben. -app.post('/plan-todos-aus-protokoll/aufgaben-vorschlag', requireAuth, async (req, res) => { +app.post('/plan-todos-aus-protokoll/aufgaben-vorschlag', requireAuth, requireRecht('proj.plan'), async (req, res) => { try { - const { anbieter, punkte } = req.body || {}; - if(!Array.isArray(punkte) || punkte.length === 0){ + const { anbieter } = req.body || {}; + const punkte = Array.isArray((req.body || {}).punkte) + ? req.body.punkte.filter(p => p && typeof p === 'object') : []; + if(punkte.length === 0){ return res.status(400).json({ ok: false, error: 'Keine Punkte ausgewählt.' }); } @@ -5233,6 +6774,7 @@ app.post('/plan-todos-aus-protokoll/aufgaben-vorschlag', requireAuth, async (req })); } + vorschlaege = vorschlaege.map(v => Object.assign({}, v, { faelligkeit: normalisiereTermin(v.faelligkeit) })); res.json({ ok: true, vorschlaege, erkennungsart, erkennungshinweis }); } catch(err){ console.error('TODOs aus Protokoll (Aufgaben-Vorschlag) fehlgeschlagen:', err); @@ -5240,6 +6782,286 @@ app.post('/plan-todos-aus-protokoll/aufgaben-vorschlag', requireAuth, async (req } }); +// ---------- Termin aus Mail (Drag & Drop in Terminplan/Aufgaben) ---------- +// +// Eriks Wunsch: Mailtext (markiert oder als ganze .eml-Datei) auf den +// Terminplan ziehen, eine KI macht daraus Aufgaben. Wie "TODOs aus +// Protokoll" wird hier NICHTS gespeichert — die Route liefert nur +// Vorschläge, angelegt wird erst im Browser nach Prüfung im Aufgaben-Panel. +// Mit dem Mailtext als Notiz der Aufgabe (plan_todos.notiz). + +// mailWoerterDekodieren: RFC-2047-Wörter in Kopfzeilen ("=?utf-8?B?…?="). +function mailWoerterDekodieren(s){ + return String(s || '').replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=(\s+(?==\?))?/g, (m, cs, enc, txt) => { + try { + const buf = enc.toUpperCase() === 'B' + ? Buffer.from(txt, 'base64') + : Buffer.from(txt.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); + return mailZeichensatz(buf, cs); + } catch(e){ return m; } + }); +} + +function mailZeichensatz(buf, charset){ + const cs = String(charset || 'utf-8').toLowerCase().replace(/^"|"$/g, ''); + try { return new TextDecoder(cs).decode(buf); } + catch(e){ return buf.toString(cs.includes('8859') || cs.includes('1252') ? 'latin1' : 'utf8'); } +} + +function mailKopfTeilen(roh){ + const trenn = roh.search(/\r?\n\r?\n/); + const kopfText = trenn < 0 ? roh : roh.slice(0, trenn); + const rumpf = trenn < 0 ? '' : roh.slice(trenn).replace(/^\r?\n\r?\n/, ''); + const kopf = {}; + kopfText.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/).forEach(z => { + const i = z.indexOf(':'); + if(i > 0){ const k = z.slice(0, i).trim().toLowerCase(); if(!(k in kopf)) kopf[k] = z.slice(i + 1).trim(); } + }); + return { kopf, rumpf }; +} + +function mailKopfParam(wert, name){ + const m = String(wert || '').match(new RegExp(name + '\\s*=\\s*("([^"]*)"|[^;\\s]+)', 'i')); + return m ? (m[2] !== undefined ? m[2] : m[1]) : ''; +} + +// mailTeilLesen: liefert {plain, html} eines (ggf. mehrteiligen) Mailteils. +function mailTeilLesen(roh, tiefe){ + const { kopf, rumpf } = mailKopfTeilen(roh); + const typ = String(kopf['content-type'] || 'text/plain').toLowerCase(); + if(typ.startsWith('multipart/') && tiefe < 6){ + const grenze = mailKopfParam(kopf['content-type'], 'boundary'); + if(!grenze) return { plain: rumpf, html: '' }; + const teile = rumpf.split('--' + grenze).slice(1).filter(t => !t.startsWith('--')); + const erg = { plain: '', html: '' }; + teile.forEach(t => { + const r = mailTeilLesen(t.replace(/^\r?\n/, ''), tiefe + 1); + if(!erg.plain && r.plain) erg.plain = r.plain; + if(!erg.html && r.html) erg.html = r.html; + }); + return erg; + } + if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' }; + if(!typ.startsWith('text/')) return { plain: '', html: '' }; + const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase(); + let buf; + if(enc === 'base64') buf = Buffer.from(rumpf.replace(/\s+/g, ''), 'base64'); + else if(enc === 'quoted-printable'){ + buf = Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); + } else buf = Buffer.from(rumpf, 'latin1'); + const text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8'); + return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' }; +} + +function htmlZuText(html){ + return String(html || '') + .replace(/<(script|style|head)[\s\S]*?<\/\1>/gi, '') + .replace(//gi, '\n') + .replace(/<\/(p|div|li|tr|h\d|blockquote)>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') + .replace(/&#(\d+);/g, (m, n) => String.fromCharCode(Number(n))).replace(/&/g, '&') + .replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +// leseEml: .eml-Datei (als Text) → {betreff, absender, datum, text}. +function leseEml(roh){ + const s = String(roh || ''); + const { kopf } = mailKopfTeilen(s); + const teil = mailTeilLesen(s, 0); + let datum = ''; + const d = new Date(kopf['date'] || ''); + if(!isNaN(d)) datum = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + return { + betreff: mailWoerterDekodieren(kopf['subject'] || ''), + absender: mailWoerterDekodieren(kopf['from'] || ''), + datum, + text: (teil.plain || htmlZuText(teil.html)).trim() + }; +} + +// mailZitateAbschneiden: entfernt zitierte Vorgänger-Mails — sonst griffe +// die KI Termine aus alten Mails derselben Unterhaltung auf. +function mailZitateAbschneiden(text){ + const zeilen = String(text || '').replace(/\r\n/g, '\n').split('\n'); + let ende = zeilen.length; + for(let i = 1; i < zeilen.length; i++){ + const z = zeilen[i].trim(); + const naechste = zeilen.slice(i + 1, i + 5).join('\n'); + if(/^-{2,}\s*(Ursprüngliche Nachricht|Original Message)/i.test(z) + || /^(Am|On) .+(schrieb|wrote).*:$/i.test(z) + || (/^(Von|From):\s/i.test(z) && /^(Gesendet|Sent|Datum|Date):/im.test(naechste))){ + ende = i; break; + } + } + return zeilen.slice(0, ende).filter(z => !/^\s*>/.test(z)).join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +const KI_MAIL_SCHEMA = { + type: 'object', + properties: { + eintraege: { + type: 'array', + items: { + type: 'object', + properties: { + titel: { type: 'string' }, + start: { type: 'string' }, + ende: { type: 'string' }, + zustaendig: { type: 'array', items: { type: 'string' } }, + aufwandStunden: { type: 'number' }, + zitat: { type: 'string' } + }, + required: ['titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'], + additionalProperties: false + } + } + }, + required: ['eintraege'], + additionalProperties: false +}; + +// Wie bei den übrigen KI-Prompts: Startpunkt, vor Änderungen mit echten +// Mails bei temperature:0 einzeln prüfen (siehe CLAUDE.md "### Protokolle"). +const KI_MAIL_SYSTEM_PROMPT = 'Du bekommst eine E-Mail aus einem Ingenieurbüro und Angaben zum Projekt. Mache daraus ' + + 'Einträge für den Terminplan des Projekts: je Termin, Frist oder zu erledigender Arbeit einen Eintrag. ' + + 'Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" ' + + 'rechnest du vom Datum der Mail aus. Ein einzelner Termin oder eine Frist: start und ende gleich. ' + + 'Ohne Datum in der Mail bleiben start und ende leer. zustaendig: nur Namen aus der Liste der Beteiligten, ' + + 'die in der Mail als verantwortlich genannt sind, sonst leer. aufwandStunden nur wenn genannt, sonst 0. zitat: der Satz aus der Mail, ' + + 'auf dem der Eintrag beruht. Enthält die Mail nichts für den Terminplan, gib eine leere Liste zurück.'; + +function mailTextFuerKi(m, kontext){ + const teile = [ + 'Heute: ' + (kontext.heute || heuteIsoLokal()), + 'Datum der Mail: ' + (m.datum || 'unbekannt'), + 'Projekt: ' + (kontext.projekt || ''), + 'Beteiligte: ' + ((kontext.personen || []).join(', ') || 'keine'), + 'Leistungsphasen im Terminplan: ' + ((kontext.phasen || []).map(p => `${p.lph} ${p.start}–${p.ende}`).join('; ') || 'keine'), + '', + 'Von: ' + (m.absender || ''), + 'Betreff: ' + (m.betreff || ''), + '', + m.text + ]; + return teile.join('\n'); +} + +function normalisiereMailEintrag(e){ + const iso = v => { const s = normalisiereTermin(v); return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : ''; }; + let start = iso(e && e.start), ende = iso(e && e.ende); + if(start && !ende) ende = start; + if(ende && !start) start = ende; + if(start && ende && ende < start){ const t = start; start = ende; ende = t; } + return { + titel: String((e && e.titel) || '').trim().slice(0, 200), + start, ende, + zustaendig: Array.isArray(e && e.zustaendig) ? e.zustaendig.map(String).filter(Boolean) : [], + aufwandStunden: Math.max(0, Number(e && e.aufwandStunden) || 0), + zitat: String((e && e.zitat) || '').trim().slice(0, 500) + }; +} + +async function mailEintraegeMitKI(m, kontext, userId, provider){ + const { apiKey, apiUrl, model } = readUserAiKey(userId, provider); + if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.'); + const inhalt = mailTextFuerKi(m, kontext); + let liste; + if(provider === 'chatgpt'){ + const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt); + let istLokal = false; + try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* bleibt false */ } + const body = { + model: model || 'gpt-4o-mini', + messages: [{ role: 'system', content: KI_MAIL_SYSTEM_PROMPT }, { role: 'user', content: inhalt }], + response_format: { type: 'json_schema', json_schema: { name: 'terminplan_eintraege', strict: true, schema: KI_MAIL_SCHEMA } }, + max_tokens: 4096, + temperature: 0 + }; + if(istLokal) body.reasoning_effort = 'none'; + const res = await fetchMitTimeout(zielUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }, + body: JSON.stringify(body) + }); + if(!res.ok){ + const t = await res.text().catch(() => ''); + throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : '')); + } + const data = await res.json(); + const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; + let parsed; + try { parsed = JSON.parse(content || ''); } catch(e){ throw new Error('ChatGPT-Antwort war kein gültiges JSON.'); } + liste = parsed && parsed.eintraege; + } else if(provider === 'claude'){ + const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, + body: JSON.stringify({ + model: model || 'claude-haiku-4-5-20251001', + max_tokens: 4096, + temperature: 0, + system: KI_MAIL_SYSTEM_PROMPT, + messages: [{ role: 'user', content: inhalt }], + tools: [{ name: 'terminplan_eintraege', description: 'Die Einträge für den Terminplan.', input_schema: KI_MAIL_SCHEMA }], + tool_choice: { type: 'tool', name: 'terminplan_eintraege' } + }) + }); + if(!res.ok){ + const t = await res.text().catch(() => ''); + throw new Error('Claude antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : '')); + } + const data = await res.json(); + const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null; + liste = toolUse && toolUse.input && toolUse.input.eintraege; + } else { + throw new Error('Unbekannter Anbieter: ' + provider); + } + if(!Array.isArray(liste)) throw new Error('Die KI-Antwort hatte nicht die erwartete Form.'); + return liste.map(normalisiereMailEintrag).filter(e => e.titel); +} + +// Body: {anbieter, text?, eml?, kontext:{heute, projekt, personen[], phasen[{lph,start,ende}]}} +// — eml ist der Inhalt einer .eml-Datei als Text (der Browser liest sie ein). +app.post('/plan-eintrag-aus-mail', requireAuth, requireRecht('proj.plan'), async (req, res) => { + try { + const b = req.body || {}; + const kontext = (b.kontext && typeof b.kontext === 'object') ? b.kontext : {}; + let mail; + if(typeof b.eml === 'string' && b.eml.trim()) mail = leseEml(b.eml); + else mail = { betreff: '', absender: '', datum: '', text: String(b.text || '') }; + mail.text = mailZitateAbschneiden(mail.text).slice(0, 15000); + if(!mail.text && !mail.betreff){ + return res.status(400).json({ ok: false, error: 'In der Mail wurde kein Text gefunden.' }); + } + + let eintraege = null, erkennungsart = 'ohne', erkennungshinweis = ''; + const anbieter = b.anbieter; + if(anbieter === 'chatgpt' || anbieter === 'claude'){ + try { + eintraege = await mailEintraegeMitKI(mail, kontext, req.user.id, anbieter); + erkennungsart = anbieter; + } catch(err){ + console.warn('Termin aus Mail: KI fehlgeschlagen:', err.message); + erkennungshinweis = err.message; + } + } else { + erkennungshinweis = 'Kein KI-Anbieter mit Schlüssel hinterlegt.'; + } + if(!eintraege){ + // Rückfall: ein leerer Eintrag mit dem Betreff — das Panel öffnet + // sich trotzdem, Termine trägt man selbst ein. + const ersteZeile = mail.text.split('\n').map(z => z.trim()).find(Boolean) || ''; + eintraege = [normalisiereMailEintrag({ titel: mail.betreff || ersteZeile.slice(0, 80) })]; + } + res.json({ ok: true, mail: { betreff: mail.betreff, absender: mail.absender, datum: mail.datum, text: mail.text }, + eintraege, erkennungsart, erkennungshinweis }); + } catch(err){ + console.error('Termin aus Mail fehlgeschlagen:', err); + res.status(500).json({ ok: false, error: String(err) }); + } +}); + // ---------- Plan Checker (plan_checker_merkmale) ---------- // // Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt @@ -5667,8 +7489,8 @@ function legeProtokollAn(opts){ // erstellt_am wandert UNVERÄNDERT mit (wie stamm_id) — ein fortgeschriebener // Punkt wurde hier nicht neu erstellt, nur übernommen. fortzuschreiben.forEach(p => { - insPunkt.run(crypto.randomUUID(), id, p.stamm_id, p.nummer, p.gruppe, 0, p.thema, p.text, p.zustaendig, p.termin, 0, - p.erstellt_am || '', p.status, p.status === 'erledigt' ? 1 : 0, order++); + insPunkt.run(crypto.randomUUID(), id, p.stamm_id, p.nummer, p.gruppe, 0, p.thema, p.text, p.zustaendig, p.termin, + p.status === 'erledigt' ? 1 : 0, p.erstellt_am || '', p.status, p.status === 'erledigt' ? 1 : 0, order++); }); } @@ -5704,7 +7526,7 @@ function legeProtokollAn(opts){ return id; } -app.post('/project-protokolle', requireAuth, (req, res) => { +app.post('/project-protokolle', requireAuth, requireRecht('proj.protokolle'), (req, res) => { try { const projectNr = String(req.query.projectNr || ''); if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' }); @@ -5731,7 +7553,7 @@ app.post('/project-protokolle', requireAuth, (req, res) => { // Original-Datei bleibt unter UPLOADS_DIR gespeichert und über // GET /project-protokolle/:id/quelldatei abrufbar — Erkennung ist ein // Entwurf, keine Garantie, siehe CLAUDE.md "### Protokolle". -app.post('/project-protokolle/import', requireAuth, express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => { +app.post('/project-protokolle/import', requireAuth, requireRecht('proj.protokolle'), express.raw({ type: '*/*', limit: '25mb' }), async (req, res) => { try { const projectNr = String(req.query.projectNr || ''); if(!projectNr) return res.status(400).json({ ok: false, error: 'Projekt hat keine Projektnummer.' }); @@ -5748,14 +7570,16 @@ app.post('/project-protokolle/import', requireAuth, express.raw({ type: '*/*', l const ext = extractFileExtension(filename); let text = ''; - if(ext === 'pdf'){ - text = await extrahierePdfText(buf); - } else if(ext === 'docx'){ - const result = await mammoth.extractRawText({ buffer: buf }); - text = result.value || ''; - } else { + if(ext !== 'pdf' && ext !== 'docx'){ return res.status(400).json({ ok: false, error: 'Nur PDF- und Word-Dateien (.pdf, .docx) werden unterstützt.' }); } + // Eine beschädigte Datei ist ein Eingabefehler (400), kein Serverfehler. + try { + if(ext === 'pdf') text = await extrahierePdfText(buf); + else text = ((await mammoth.extractRawText({ buffer: buf })).value) || ''; + } catch(err){ + return res.status(400).json({ ok: false, error: 'Die Datei konnte nicht gelesen werden (beschädigt oder kein echtes ' + ext.toUpperCase() + ').' }); + } // Teilnehmer kommen NUR aus der KI-Erkennung (siehe KI_SYSTEM_PROMPT) — // erkennePunkteAusText() ist reines Regex und kann keine Teilnehmerliste @@ -5825,7 +7649,7 @@ app.get('/project-protokolle/:id/quelldatei', requireAuth, (req, res) => { // dagegen NIE vom Client vertraut: für einen Punkt mit bekannter id werden // sie aus der bisherigen Zeile übernommen, ein Punkt ohne id ist neu // (frische stamm_id, neu=1). -app.put('/project-protokolle/:id', requireAuth, (req, res) => { +app.put('/project-protokolle/:id', requireAuth, requireRecht('proj.protokolle'), (req, res) => { try { const row = db.prepare('SELECT * FROM project_protokolle WHERE id = ?').get(req.params.id); if(!row) return res.status(404).json({ ok: false, error: 'Protokoll nicht gefunden.' }); @@ -5903,9 +7727,9 @@ app.put('/project-protokolle/:id', requireAuth, (req, res) => { // Berechtigungsstufe wie das Löschen einer Aufgabe/eines Meilensteins. // Idempotent wie DELETE /users/:id/absences/:absenceId (kein Fehler, wenn // bereits gelöscht). -app.delete('/project-protokolle/:id', requireAuth, (req, res) => { +app.delete('/project-protokolle/:id', requireAuth, requireRecht('proj.protokolle'), (req, res) => { try { - const row = db.prepare('SELECT id FROM project_protokolle WHERE id = ?').get(req.params.id); + const row = db.prepare('SELECT id, quelle_stored_name FROM project_protokolle WHERE id = ?').get(req.params.id); if(!row) return res.json({ ok: true }); db.exec('BEGIN'); try { @@ -5917,6 +7741,11 @@ app.delete('/project-protokolle/:id', requireAuth, (req, res) => { db.exec('ROLLBACK'); throw err; } + // Die Quelldatei eines importierten Protokolls gleich mit entfernen — + // sonst bliebe sie ohne jeden Verweis in sessions/uploads liegen. + if(row.quelle_stored_name){ + fs.unlink(path.join(uploadDirFor(), path.basename(row.quelle_stored_name)), () => {}); + } console.log('Protokoll gelöscht:', row.id); res.json({ ok: true }); } catch(err){ @@ -5934,11 +7763,37 @@ app.delete('/project-protokolle/:id', requireAuth, (req, res) => { // Altdaten möglich). const VALID_LPHS = new Set(['LPH 1','LPH 2','LPH 3','LPH 4','LPH 5','LPH 6','LPH 7','LPH 8','LPH 9','W+M']); +// templateAnteil: Prozentwert eines Vorlagen-Schritts prüfen (Komma oder +// Punkt, 0–100, drei Nachkommastellen). Rückgabe: Zahl oder null (ungültig). +function templateAnteil(v){ + if(v === undefined || v === null || v === '') return 0; + const n = Number(String(v).replace(',', '.')); + if(!Number.isFinite(n) || n < 0 || n > 100) return null; + return Math.round(n * 1000) / 1000; +} +// templateAnteilEingabe: Eingabe der Spalte „Anteil %“ — eine Zahl, oder +// „in a)“ / „in a + b“ / „in a u. b“ für einen Schritt, der in anderen +// enthalten ist (Siemon-Tabelle). Rückgabe: {anteil, enthaltenIn} oder null. +function templateAnteilEingabe(v){ + const t = String(v === undefined || v === null ? '' : v).trim(); + if(/^in\b/i.test(t)){ + const buchst = (t.slice(2).match(/[a-z]/gi) || []).map(x => x.toLowerCase()) + .filter(x => x !== 'u'); // „u.“ aus „a u. b“ + const enthaltenIn = [...new Set(buchst)].join(','); + return enthaltenIn ? { anteil: 0, enthaltenIn } : null; + } + const anteil = templateAnteil(t); + return anteil === null ? null : { anteil, enthaltenIn: '' }; +} + // serializeTemplateRow: DB-Zeile -> öffentliche JSON-Form. function serializeTemplateRow(row){ return { id: row.id, lph: row.lph, + gewerk: row.gewerk || '', + anteil: Number(row.anteil) || 0, + enthaltenIn: row.enthalten_in || '', title: row.title, offsetDays: row.offset_days, durationDays: row.duration_days, @@ -5950,7 +7805,7 @@ function serializeTemplateRow(row){ // einfügen, daher nur requireAuth — nur das Pflegen ist Admin-Sache). app.get('/lph-templates', requireAuth, (req, res) => { try { - const rows = db.prepare('SELECT * FROM lph_templates ORDER BY lph, sort_order, title').all(); + const rows = db.prepare('SELECT * FROM lph_templates ORDER BY lph, gewerk, sort_order, title').all(); res.json({ ok: true, templates: rows.map(serializeTemplateRow) }); } catch(err){ console.error('Vorlagen laden fehlgeschlagen:', err); @@ -5959,24 +7814,31 @@ app.get('/lph-templates', requireAuth, (req, res) => { }); // Route: neue Vorlagen-Aufgabe anlegen. Admin-only (firmenweite Konfiguration). -app.post('/lph-templates', requireRole('admin'), (req, res) => { +app.post('/lph-templates', requireRecht('a.vorlagen'), (req, res) => { try { - const { lph, title, offsetDays, durationDays } = req.body || {}; + const { lph, gewerk, title, offsetDays, durationDays, anteil } = req.body || {}; if(!VALID_LPHS.has(String(lph))){ return res.status(400).json({ ok: false, error: 'Unbekannte Leistungsphase.' }); } + if(!VALID_DEPARTMENTS.has(String(gewerk))){ + return res.status(400).json({ ok: false, error: 'Unbekanntes Gewerk.' }); + } const cleanTitle = String(title || '').trim(); if(!cleanTitle) return res.status(400).json({ ok: false, error: 'Titel fehlt.' }); const offset = Number.isFinite(Number(offsetDays)) ? Math.max(0, Math.trunc(Number(offsetDays))) : 0; - const duration = Number.isFinite(Number(durationDays)) ? Math.max(1, Math.trunc(Number(durationDays))) : 1; - // sort_order: ans Ende der jeweiligen Leistungsphase anhängen - const maxRow = db.prepare('SELECT MAX(sort_order) AS m FROM lph_templates WHERE lph = ?').get(String(lph)); + // Dauer 0 = ohne Termin: die Aufgabe wird ohne Start/Ende eingefügt. + const duration = Number.isFinite(Number(durationDays)) ? Math.max(0, Math.trunc(Number(durationDays))) : 1; + // sort_order: ans Ende der Liste dieser Leistungsphase und dieses Gewerks + const maxRow = db.prepare('SELECT MAX(sort_order) AS m FROM lph_templates WHERE lph = ? AND gewerk = ?').get(String(lph), String(gewerk)); const sortOrder = (maxRow && Number.isFinite(maxRow.m) ? maxRow.m : -1) + 1; + const ein = templateAnteilEingabe(anteil); + if(ein === null) return res.status(400).json({ ok: false, error: 'Anteil: eine Zahl von 0 bis 100 oder „in a)“.' }); const id = crypto.randomUUID(); - db.prepare(`INSERT INTO lph_templates (id, lph, title, offset_days, duration_days, sort_order) - VALUES (?, ?, ?, ?, ?, ?)`).run(id, String(lph), cleanTitle, offset, duration, sortOrder); + db.prepare(`INSERT INTO lph_templates (id, lph, gewerk, title, offset_days, duration_days, sort_order, anteil, enthalten_in) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, String(lph), String(gewerk), cleanTitle, offset, duration, sortOrder, ein.anteil, ein.enthaltenIn); res.json({ ok: true, template: serializeTemplateRow({ - id, lph: String(lph), title: cleanTitle, offset_days: offset, duration_days: duration, sort_order: sortOrder + id, lph: String(lph), gewerk: String(gewerk), title: cleanTitle, offset_days: offset, duration_days: duration, sort_order: sortOrder, + anteil: ein.anteil, enthalten_in: ein.enthaltenIn })}); } catch(err){ console.error('Vorlage anlegen fehlgeschlagen:', err); @@ -5984,10 +7846,71 @@ app.post('/lph-templates', requireRole('admin'), (req, res) => { } }); +// Route: Vorlagen-Schritt ändern (Titel, Start-Versatz, Dauer). Admin-only. +// Nur übergebene Felder werden geändert. Bereits eingefügte Aufgaben bleiben +// unberührt (siehe Tabellen-Kommentar bei lph_templates). +app.patch('/lph-templates/:id', requireRecht('a.vorlagen'), (req, res) => { + try { + const row = db.prepare('SELECT * FROM lph_templates WHERE id = ?').get(String(req.params.id)); + if(!row) return res.status(404).json({ ok: false, error: 'Schritt nicht gefunden.' }); + const b = req.body || {}; + let title = row.title, offset = row.offset_days, duration = row.duration_days, anteil = Number(row.anteil) || 0, + enthaltenIn = row.enthalten_in || ''; + if(b.anteil !== undefined){ + const ein = templateAnteilEingabe(b.anteil); + if(ein === null) return res.status(400).json({ ok: false, error: 'Anteil: eine Zahl von 0 bis 100 oder „in a)“.' }); + anteil = ein.anteil; enthaltenIn = ein.enthaltenIn; + } + if(b.title !== undefined){ + title = String(b.title || '').trim(); + if(!title) return res.status(400).json({ ok: false, error: 'Titel fehlt.' }); + } + if(b.offsetDays !== undefined){ + if(!Number.isFinite(Number(b.offsetDays))) return res.status(400).json({ ok: false, error: 'Start ungültig.' }); + offset = Math.max(0, Math.trunc(Number(b.offsetDays))); + } + if(b.durationDays !== undefined){ + if(!Number.isFinite(Number(b.durationDays))) return res.status(400).json({ ok: false, error: 'Dauer ungültig.' }); + duration = Math.max(0, Math.trunc(Number(b.durationDays))); // 0 = ohne Termin + } + db.prepare('UPDATE lph_templates SET title = ?, offset_days = ?, duration_days = ?, anteil = ?, enthalten_in = ? WHERE id = ?') + .run(title, offset, duration, anteil, enthaltenIn, row.id); + res.json({ ok: true, template: serializeTemplateRow({ ...row, title, offset_days: offset, duration_days: duration, anteil, enthalten_in: enthaltenIn }) }); + } catch(err){ + console.error('Vorlage ändern fehlgeschlagen:', err); + res.status(500).json({ ok: false, error: String(err) }); + } +}); + +// Route: Vorlagen-Schritt um eine Stelle nach oben (richtung -1) oder unten +// (+1) verschieben — innerhalb derselben Leistungsphase und desselben +// Gewerks. Nummeriert die Liste dabei lückenlos neu. Admin-only. +app.post('/lph-templates/:id/verschieben', requireRecht('a.vorlagen'), (req, res) => { + try { + const row = db.prepare('SELECT * FROM lph_templates WHERE id = ?').get(String(req.params.id)); + if(!row) return res.status(404).json({ ok: false, error: 'Schritt nicht gefunden.' }); + const richtung = Number((req.body || {}).richtung) < 0 ? -1 : 1; + const liste = db.prepare('SELECT id FROM lph_templates WHERE lph = ? AND gewerk = ? ORDER BY sort_order, title') + .all(row.lph, row.gewerk).map(r => r.id); + const i = liste.indexOf(row.id), j = i + richtung; + if(j >= 0 && j < liste.length){ + [liste[i], liste[j]] = [liste[j], liste[i]]; + const upd = db.prepare('UPDATE lph_templates SET sort_order = ? WHERE id = ?'); + db.exec('BEGIN'); + try { liste.forEach((id, k) => upd.run(k, id)); db.exec('COMMIT'); } + catch(e){ db.exec('ROLLBACK'); throw e; } + } + res.json({ ok: true }); + } catch(err){ + console.error('Vorlage verschieben fehlgeschlagen:', err); + res.status(500).json({ ok: false, error: String(err) }); + } +}); + // Route: Vorlagen-Aufgabe löschen. Admin-only. Idempotent (bereits gelöscht // ist kein Fehler). Bereits eingefügte TODOs bleiben unberührt (siehe // Tabellen-Kommentar bei lph_templates). -app.delete('/lph-templates/:id', requireRole('admin'), (req, res) => { +app.delete('/lph-templates/:id', requireRecht('a.vorlagen'), (req, res) => { try { db.prepare('DELETE FROM lph_templates WHERE id = ?').run(String(req.params.id)); res.json({ ok: true }); @@ -6016,7 +7939,7 @@ app.get('/plan-checker-merkmale', requireAuth, (req, res) => { }); // Route: neues Merkmal anlegen. Admin-only (firmenweite Konfiguration). -app.post('/plan-checker-merkmale', requireRole('admin'), (req, res) => { +app.post('/plan-checker-merkmale', requireRecht('a.checker'), (req, res) => { try { const { titel, prompt } = req.body || {}; const cleanTitel = String(titel || '').trim(); @@ -6036,7 +7959,7 @@ app.post('/plan-checker-merkmale', requireRole('admin'), (req, res) => { }); // Route: Merkmal löschen. Admin-only. Idempotent. -app.delete('/plan-checker-merkmale/:id', requireRole('admin'), (req, res) => { +app.delete('/plan-checker-merkmale/:id', requireRecht('a.checker'), (req, res) => { try { db.prepare('DELETE FROM plan_checker_merkmale WHERE id = ?').run(String(req.params.id)); res.json({ ok: true }); @@ -6053,7 +7976,7 @@ app.delete('/plan-checker-merkmale/:id', requireRole('admin'), (req, res) => { // Bilddateien werden gespeichert (siehe Erläuterung bei pruefePlanMitKI() // oben). Das Rendering zum Bildsatz (Übersicht + Kacheln) passiert NICHT // hier, sondern innerhalb von pruefePlanMitKI() — für beide Anbieter gleich. -app.post('/plan-checker-pruefen', requireAuth, express.raw({ type: '*/*', limit: '40mb' }), async (req, res) => { +app.post('/plan-checker-pruefen', requireAuth, requireRecht('wz.checker'), express.raw({ type: '*/*', limit: '25mb' }), async (req, res) => { try { const buf = req.body; if(!Buffer.isBuffer(buf) || buf.length === 0){ @@ -6385,13 +8308,26 @@ app.post('/my-ai-settings/test', requireAuth, async (req, res) => { // (nur die im Rumpf genannten Schlüssel werden geschrieben); unbekannte // Schlüssel und unbrauchbare Werte führen zu 400, bevor irgendetwas // geschrieben wird — sonst bliebe die Hälfte einer Maske liegen. -app.put('/app-settings', requireRole('admin'), (req, res) => { +// APP_SETTING_RECHT: welches Recht eine Einstellung ändern darf. +const APP_SETTING_RECHT = { + stundensatz: 'a.honorar', + arbeitsstundenProTag: 'a.termine', autoLockAktiv: 'a.termine', + vollzeitStundenProWoche: 'a.auslastung', auslastungSchwelleGelb: 'a.auslastung', auslastungSchwelleRot: 'a.auslastung', + aufwandVorlage: 'a.auslastung', referenzHonorar: 'a.auslastung', verteilzeitraumWochen: 'a.auslastung', + feiertagBundesland: 'a.feiertage', + maxBackups: 'a.betrieb', sessionTage: 'a.betrieb', loginMaxVersuche: 'a.betrieb', loginSperreMinuten: 'a.betrieb', +}; +app.put('/app-settings', requireAuth, (req, res) => { try { const body = (req.body && typeof req.body === 'object') ? req.body : {}; const writes = []; for(const [key, value] of Object.entries(body)){ const def = APP_SETTING_DEFS[key]; if(!def) return res.status(400).json({ ok:false, error: 'Unbekannte Einstellung: ' + key }); + // Jede Einstellung gehört zu einem Admin-Abschnitt und damit zu dessen + // Recht (Rollen & Rechte) — geprüft je Feld, bevor irgendetwas schreibt. + const recht = APP_SETTING_RECHT[key] || 'a.betrieb'; + if(!hatRecht(req.user, recht)) return rechtFehlt(res, recht); const parsed = def.parse(value); if(parsed === null) return res.status(400).json({ ok:false, error: 'Unbrauchbarer Wert für „' + def.label + '".' }); writes.push([key, def.json ? JSON.stringify(parsed) : String(parsed)]); @@ -6553,7 +8489,7 @@ app.get('/holidays/suggest', requireAuth, (req, res) => { // Tage bleiben unveraendert — ein von Hand angepasster Name (etwa ein // Brueckentag, den jemand auf denselben Tag gelegt hat) soll nicht // ueberschrieben werden. Die Antwort meldet beides getrennt. -app.post('/holidays/import', requireRole('admin'), (req, res) => { +app.post('/holidays/import', requireRecht('a.feiertage'), (req, res) => { try { const year = Number((req.body || {}).year); const state = String((req.body || {}).state || readAppSetting('feiertagBundesland')); @@ -6603,7 +8539,7 @@ app.get('/holidays', requireAuthOderKiosk, (req, res) => { // Route: Feiertag anlegen oder umbenennen. Admin-only. Das Datum ist der // Primärschlüssel, ein zweites Anlegen desselben Tages ersetzt also nur den // Namen — das ist gewollt und erspart ein eigenes PATCH. -app.post('/holidays', requireRole('admin'), (req, res) => { +app.post('/holidays', requireRecht('a.feiertage'), (req, res) => { try { const { date, name } = req.body || {}; if(typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)){ @@ -6624,7 +8560,7 @@ app.post('/holidays', requireRole('admin'), (req, res) => { }); // Route: Feiertag löschen. Admin-only, idempotent. -app.delete('/holidays/:date', requireRole('admin'), (req, res) => { +app.delete('/holidays/:date', requireRecht('a.feiertage'), (req, res) => { try { db.prepare('DELETE FROM holidays WHERE date = ?').run(String(req.params.date)); res.json({ ok: true }); @@ -6664,7 +8600,7 @@ app.get('/honorar-config', requireAuthOderKiosk, (req, res) => { // `anteile` ist ein vollständiger Ersatz (delete + insert in einer // Transaktion, gleiches Muster wie die Rollen in PATCH /admin/users/:id): // eine im Objekt fehlende Leistungsphase gilt damit als "kein Anteil". -app.put('/honorar-config', requireRole('admin'), (req, res) => { +app.put('/honorar-config', requireRecht('a.honorar'), (req, res) => { try { const { anteile, stundensatz } = req.body || {}; if(anteile !== undefined && (typeof anteile !== 'object' || anteile === null)){ @@ -6728,7 +8664,7 @@ app.put('/honorar-config', requireRole('admin'), (req, res) => { // user_sessions.token im Klartext. Mit einem fremden Token könnte sich sonst // jedes Mitglied als Administrator ausgeben (Rechteausweitung) — deshalb ist // diese Route strenger abgesichert als der Rest der fachlichen Daten-Routen. -app.get('/backup', requireRole('admin'), (req, res) => { +app.get('/backup', requireRecht('a.backup'), (req, res) => { const tmp = path.join(os.tmpdir(), 'projektuebersicht_backup_' + Date.now() + '.db'); try { createDbSnapshot(tmp); @@ -6749,7 +8685,7 @@ app.get('/backup', requireRole('admin'), (req, res) => { // validiert das Schema und ersetzt den gesamten Datenbestand. VOR dem Import // wird der Zustand vor dem Import als frisches Backup gesichert, damit ein // versehentlicher Restore rückgängig gemacht werden kann. -app.post('/import', requireRole('admin'), express.raw({ type: '*/*', limit: '100mb' }), (req, res) => { +app.post('/import', requireRecht('a.import'), express.raw({ type: '*/*', limit: '50mb' }), (req, res) => { const buf = req.body; if(!Buffer.isBuffer(buf) || buf.length === 0){ return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' }); @@ -6757,11 +8693,19 @@ app.post('/import', requireRole('admin'), express.raw({ type: '*/*', limit: '100 const tmp = path.join(os.tmpdir(), 'projektuebersicht_import_' + Date.now() + '.db'); try { fs.writeFileSync(tmp, buf); + try { pruefeBackupDatei(tmp); } + catch(err){ return res.status(400).json({ ok: false, error: err.message }); } // Zustand VOR dem Restore sichern (danach wäre der Snapshot bereits der // importierte Stand und die Wiederherstellung unmöglich). runAutoBackup(); restoreDbFromBackup(tmp); + // Ältere Backups tragen Kommentare noch gewerkübergreifend in projects. + migrateKommentareJeGewerk(); + kommentareVorsatzUmstellen(); + // Ältere Backups kennen das Feld leistungsphase noch nicht. + phasenbalkenAusTitel(); console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)'); + ereignisSenden({ typ: 'neu' }, req); return res.json({ ok: true }); } catch(err){ console.error('Datenbank-Import fehlgeschlagen:', err); @@ -6772,11 +8716,15 @@ app.post('/import', requireRole('admin'), express.raw({ type: '*/*', limit: '100 }); // Route: Healthcheck-Endpunkt für Docker/Überwachung -app.get('/health', (req,res)=> res.json({ok:true})); +app.get('/health', (req,res)=> res.json({ + ok: true, + speicherMb: Math.round(process.memoryUsage().rss / 1048576), + laufzeitMin: Math.round(process.uptime() / 60) +})); // Route: Liefert die Hauptseite App.html app.get('/', (req,res)=>{ - res.sendFile(path.join(__dirname, 'public', 'App.html')); + appSenden(req, res); }); // Gültige Tab-Slugs für Deep-Link-Routen (muss zu TAB_SLUGS in js/globals.js passen) @@ -6784,7 +8732,7 @@ app.get('/', (req,res)=>{ // Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404 // enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um // (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr. -const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']); +const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']); // Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen) const PLAN_SLUG = 'Projektplan'; @@ -6829,17 +8777,17 @@ app.get('/:tab', (req, res) => { // seit dem Umbau kein eigenes Dokument mehr, sondern der Bereich #plan // der Hauptseite (applyUrlRoute() in public/js/ui.js wertet die Adresse // clientseitig aus). - return res.sendFile(path.join(__dirname, 'public', 'App.html')); + return appSenden(req, res); } if(!VALID_TAB_SLUGS.has(tab)) return res.status(404).send('Nicht gefunden'); - res.sendFile(path.join(__dirname, 'public', 'App.html')); + appSenden(req, res); }); // Route: Projektbereich mit Projektname im Pfad, z. B. // "/Projektplan/Gymnasium%20Hemer?weekLabel=..." – das Pfad-Segment dient nur // der lesbaren Adresse, die Identifikation erfolgt über weekLabel/projNr. app.get('/Projektplan/:projName', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'App.html')); + appSenden(req, res); }); // Route: Weiterleitung alter, noch abteilungspräfixierter Projektplan-Links @@ -6862,6 +8810,18 @@ app.get('/:legacyDept/:tab', (req, res) => { res.redirect(301, '/' + encodeURIComponent(req.params.tab) + qs); }); +// Fehler aus Body-Parsern (z. B. Datei über dem Limit) als lesbares JSON statt +// der HTML-Fehlerseite von Express. +app.use((err, req, res, next) => { + if(res.headersSent) return next(err); + if(err && (err.type === 'entity.too.large' || err.status === 413)){ + const mb = err.limit ? Math.round(err.limit / 1048576) : null; + return res.status(413).json({ ok: false, error: 'Datei zu groß' + (mb ? ' (höchstens ' + mb + ' MB).' : '.') }); + } + console.error('Unbehandelter Fehler in', req.method, req.path, err); + res.status(err && err.status || 500).json({ ok: false, error: 'Interner Fehler' }); +}); + // Serverstart mit Fehlerbehandlung (z.B. Port bereits belegt) const server = app.listen(PORT, () => { console.log(`Projektübersicht server running on http://localhost:${PORT}`); @@ -6876,9 +8836,81 @@ const server = app.listen(PORT, () => { // app.listen()-Rückruf statt in der früheren Migrations-/Bootstrap- // Reihenfolge weiter oben. ensureCurrentWeekRollover(); + // Konten, die nach dem Entstehen der aktuellen Sitzung angelegt wurden, + // in deren Teilnehmerkreis nachtragen (siehe kontenInAktuelleSitzung()). + kontenInAktuelleSitzung(); setInterval(ensureCurrentWeekRollover, 60 * 60 * 1000); // Papierkorb gelöschter Projekte: Einträge nach 30 Tagen entfernen. setInterval(purgeGeloeschteProjekte, 60 * 60 * 1000); + purgeBenachrichtigungen(); + setInterval(purgeBenachrichtigungen, 60 * 60 * 1000); + // Betrieb auf dem Pi: Login-Sperrliste und WAL-Datei klein halten, + // Speicher und Hänger überwachen (siehe betriebsWaechterStarten()). + setInterval(betriebAufraeumen, 60 * 60 * 1000); + betriebsWaechterStarten(); +}); + +// betriebAufraeumen: abgelaufene Login-Sperren entfernen (die Liste wuchs +// sonst mit jeder Fehleingabe) und die WAL-Datei zurück in die Datenbank +// schreiben, damit sie auf der SD-Karte nicht wächst. +function betriebAufraeumen(){ + try { + const grenze = Date.now() - loginBlockMs(); + loginAttempts.forEach((e, k) => { if(e.first < grenze) loginAttempts.delete(k); }); + db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + } catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); } +} + +// betriebsWaechterStarten: zwei Wächter für den unbeaufsichtigten Betrieb. +// (1) SPEICHER: Über SPEICHER_MAX_MB (Umgebungsvariable, im Docker-Container +// gesetzt; ohne sie aus) beendet sich der Server sauber mit Code 1 — +// Docker (restart: always) startet ihn mit frischem Speicher neu, bevor +// der Kernel den Container hart abschießt. +// (2) HÄNGER: Ein Worker-Thread prüft den Herzschlag des Haupt-Threads. Steht +// der Event-Loop länger als HAENGER_MAX_S (Vorgabe 90 s), beendet der +// Worker den Prozess hart. Docker startet "unhealthy" Container NICHT +// neu — ohne diesen Wächter bliebe ein hängender Server hängen. +function betriebsWaechterStarten(){ + const maxMb = Number(process.env.SPEICHER_MAX_MB) || 0; + if(maxMb > 0){ + setInterval(() => { + const mb = process.memoryUsage().rss / 1048576; + if(mb > maxMb){ + console.error('Speicher-Wächter: ' + Math.round(mb) + ' MB > ' + maxMb + ' MB — Neustart.'); + shutdown('Speicher-Wächter', 1); + } + }, 30 * 1000).unref(); + } + const haengerS = Number(process.env.HAENGER_MAX_S) || 90; + const { Worker } = require('worker_threads'); + const herz = new Float64Array(new SharedArrayBuffer(8)); + herz[0] = Date.now(); + setInterval(() => { herz[0] = Date.now(); }, 5000).unref(); + const w = new Worker(` + const { workerData } = require('worker_threads'); + const herz = new Float64Array(workerData.puffer); + setInterval(() => { + if(Date.now() - herz[0] > workerData.maxMs){ + // console im Worker läuft über den (hängenden) Haupt-Thread — direkt schreiben. + require('fs').writeSync(2, 'Hänger-Wächter: Server reagiert seit ' + Math.round((Date.now() - herz[0]) / 1000) + ' s nicht — Neustart.\\n'); + process.kill(process.pid, 'SIGKILL'); + } + }, 5000); + `, { eval: true, workerData: { puffer: herz.buffer, maxMs: haengerS * 1000 }, resourceLimits: { maxOldGenerationSizeMb: 8 } }); + w.unref(); + w.on('error', err => console.error('Hänger-Wächter ausgefallen:', err.message)); +} + +// Unerwartete Fehler: protokollieren und mit Code 1 beenden — ein Prozess in +// unbekanntem Zustand soll nicht weiterlaufen, Docker startet ihn neu. +// Abgelehnte Promises betreffen dagegen fast immer nur eine Anfrage: nur +// protokollieren (Node beendete sonst den ganzen Server). +process.on('uncaughtException', (err) => { + console.error('Unerwarteter Fehler — Server wird neu gestartet:', err); + shutdown('uncaughtException', 1); +}); +process.on('unhandledRejection', (grund) => { + console.error('Nicht abgefangene Promise-Ablehnung:', grund); }); server.on('error', (err) => { console.error('Server konnte nicht starten:', err.message); @@ -6894,17 +8926,24 @@ server.on('error', (err) => { // eines abrupten Abbruchs mitten im Schreiben). Ohne diesen Handler würde // Node ebenfalls beenden — kein anderer Code hier überschreibt das // Standardverhalten —, aber lautlos und ohne den Checkpoint. -function shutdown(signal){ +let beendetBereits = false; +function shutdown(signal, code = 0){ + if(beendetBereits) return; + beendetBereits = true; console.log(`\n${signal} empfangen, Server wird beendet…`); // Sicherheitsnetz: eine offene Keep-Alive-Verbindung kann server.close() // unbegrenzt blockieren (bekannte Node-Eigenheit) — nach 3s notfalls hart // beenden, damit der Prozess in jedem Fall nur bis zum Tastendruck läuft. - const notbremse = setTimeout(() => process.exit(0), 3000); + const notbremse = setTimeout(() => process.exit(code), 3000); notbremse.unref(); + // Offene Live-Verbindungen (GET /ereignisse) hielten server.close() sonst + // bis zur Notbremse auf. + if(typeof server.closeAllConnections === 'function') server.closeIdleConnections(); + try { ereignisKunden.forEach(k => { try { k.res.end(); } catch(e){} }); } catch(e){} server.close(() => { try { db.close(); } catch(e){ /* bereits geschlossen */ } console.log('Server beendet.'); - process.exit(0); + process.exit(code); }); } process.on('SIGINT', () => shutdown('SIGINT'));