4 Commits
Author SHA1 Message Date
erikandClaude Opus 5.5 e12936444d Aufgaben im Wochenbericht, Meilensteine „Erreicht“, Panels als Planblatt
- Wochenbericht: Abschnitt „Aufgaben“ je Projekt (erledigt seit Vorwoche,
  überfällig, nächste zwei Wochen), rote Zahl in der Tagesordnung,
  Tabelle in der PDF-Akte; Klick öffnet das Aufgaben-Panel
- Meilensteine lassen sich als erreicht markieren (plan_milestones.erreicht);
  nur nicht erreichte vergangene gelten als überfällig
- Aufgabe- und Meilenstein-Panel neu aufgebaut: Stand oben, Lage im
  Terminplan, Angaben als Zeilen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:41:44 +01:00
erikandClaude Opus 5.5 4ac3d8d53d Kommentare je Gewerk klar getrennt, abgewählte Gewerke ausgeblendet
- Kommentare mehrerer Gewerke mit Überschrift und Linie: Gewerk in seiner
  Farbe, rechts Verfasser und Zeit, darunter der Text (Projekt-Dashboard,
  Projektliste, Wandtafel, Verlauf-Kacheln)
- Behoben: Im Projekt-Dashboard erschienen Kommentare von Gewerken, die das
  Projekt nicht mehr hat (z. B. HLS bei 21-4030); Anzeige und Verlauf zeigen
  nur noch Gewerke des Projekts
- Sitzungswechsel übernimmt nur noch Kommentare der Gewerke des Projekts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:08:20 +01:00
erikandClaude Opus 5.5 e291ca8438 Neues Design „Bauplan“, Dashboard, Notizen-Bereich, Seitenleiste
- Gestaltung „Bauplan“: ruhigere Farben, Schriftskala, Abstände im
  4-px-Raster, Kalenderwochen und Nummern in Mono-Ziffern, Glas beruhigt
- Dashboard „Meine Aufgaben“: Kopf mit Kalenderwoche, Band „Diese Woche“
  (Mo–Fr), je Projekt Meine Rolle / Team / Notizen; offene Termine blau
  hervorgehoben; Monatskalender unverändert
- Neuer Bereich „Notizen“ in der Seitenleiste: Notizen aller eigenen
  Projekte und eigene Notizen ohne Projekt, später einem Projekt zuordenbar
- Seitenleiste neu gegliedert (Projekte, Team, Notizen, Studenten,
  Einstellungen/Admin unten), Admin-Punkte in Personen/Vorgaben/System
- Reiter im Projektkopf wie die Seitenleiste hervorgehoben
- Behoben: „&amp;amp;…“ in Kommentaren wuchs bei jedem Speichern;
  bestehende Kommentare werden beim Start repariert
- Behoben: Lösch-Rückfrage bei Notizen unlesbar bzw. blieb stehen,
  Cursor im Notiztitel verdeckt, Dropdown im Dunkelmodus gekachelt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:07:32 +01:00
erikandClaude Opus 5.5 e697057b86 Projekte aus „Meine Aufgaben“ im selben Tab öffnen
- „Öffnen“, Termine und „weitere Termine im Projekt“ im Dashboard öffnen
  den Projektbereich jetzt im selben Tab statt in einem neuen, ohne
  Neuladen der Seite (Strg/Cmd-Klick und Mittelklick öffnen weiter einen
  neuen Tab)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:32:49 +01:00
27 changed files with 2950 additions and 1371 deletions

No files matched your search

+191 -3
View File
@@ -1540,6 +1540,24 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
Teams-Nachricht. Wer einer Aufgabe zugewiesen ist, aber (nicht mehr) im Teams-Nachricht. Wer einer Aufgabe zugewiesen ist, aber (nicht mehr) im
Projekt, erscheint beim Öffnen automatisch dort. Keine Daten­änderung Projekt, erscheint beim Öffnen automatisch dort. Keine Daten­änderung
am Projekt: die Person wird NICHT unter „Beteiligte“ eingetragen. am Projekt: die Person wird NICHT unter „Beteiligte“ eingetragen.
- **Panels als „Planblatt“** (Eriks Wahl, unslop, 11/2026; löst den
Aufbau mit `.pd-abs`-Abschnitten im folgenden Absatz ab): Kopf mit Art,
Projekt (`#todoModalWo`/`#milestoneModalWo`, `panelWoSetzen()`), Titel
ohne Kasten und dem **Stand direkt darunter** (Aufgabe: Status-Segment +
Fortschritt; Meilenstein: „Offen | Erreicht“ über `msStandSetzen()`, das
versteckte Feld `#milestoneEditErreicht` bleibt die Quelle für
`saveMilestoneEdit()`, dazu „seit N Tagen überfällig“). Darunter die
**Lage im Terminplan** (`lageBildHtml()`, `todoLageNeu()`,
`msLageBildNeu()` in `plan-modals.js`): sechs Kalenderwochen ab einer
Woche vor dem Eintrag (früher, wenn ein Vorgänger davor endet), oben
Vorgänger, Mitte der Eintrag mit Fortschritt, unten Nachfolger,
Heute-Linie; nur Anzeige, neu gezeichnet bei Zeitraum, Stand und
Vorgängern. Dann die Angaben als Zeilen (`.pd-zeile`, Beschriftung
links): Zeitraum, Zuständig, Aufwand (`#todoEditAufwandAbs`, im
Phasen-Modus mit ausgeblendet), Hängt ab von (gewählte Vorgänger offen
in `#todoEditDependsSummary`, Auswahl eingeklappt unter „Vorgänger
ändern“), Notiz. Alle Feld-ids und Schreibwege unverändert. Stile:
Block „Planblatt“ am Ende von `plan-styles.css`.
- **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“): - **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“):
`#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am `#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am
Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der
@@ -1837,6 +1855,58 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
`plan-gantt.js` both bail out unless `currentTab === 'plan'` — they now run `plan-gantt.js` both bail out unless `currentTab === 'plan'` — they now run
while completely different tabs are on screen. while completely different tabs are on screen.
### Notizen in der Seitenleiste (Tab `notizen`, `js/notizen/notizen.js`)
Eriks Wunsch: ein eigener Bereich „Notizen“ (Seitenleiste, eigene Gruppe
„Notizen“ unter „Team“, `/Notizen`), in dem jedes Konto die Notizen ALLER seiner
Projekte sieht und **projektunabhängige Notizen** anlegt, die sich später
einem Projekt zuordnen lassen. Gleicher Aufbau wie der Projekt-Reiter
(Liste + Editor, Werkzeugleiste, automatisches Speichern, Anheften, Löschen
zweistufig), dazu oben ein **Projektfilter** (Alle / Ohne Projekt / je
Projekt mit Anzahl) und im Editor die Auswahl **„Projekt“** (Ohne Projekt,
„Meine Projekte“, „Weitere Projekte“ der aktuellen Sitzung) plus „Im Projekt
öffnen“. „+ Neu“ legt im gefilterten Projekt an, sonst ohne Projekt. Eigene
Element-ids (`mn…`) und Funktionen (`mnNeu()`, `mnZuordnen()` …), weil der
Projekt-Reiter gleichzeitig im DOM liegt; Stile aus `.notizen-*`
(plan-styles.css) plus Block „NOTIZEN (Seitenleiste)“ am Ende von
`styles.css`.
- **Server:** Eine projektunabhängige Notiz ist eine Zeile in
`projekt_notizen` mit `project_uid = ''`; sie sieht und ändert NUR ihr
Verfasser (`notizDarf()`). `GET /meine-notizen` liefert die Notizen der
eigenen Projekte (`meineProjektUids()`: jüngste echte Sitzung, zugeordnet
oder mit eigener Aufgabe — Regel wie `dashDaten()`) plus die eigenen
projektunabhängigen, dazu `meineProjekte`. `PUT /projekt-notizen/:id` nimmt
`uid` zum Zuordnen (Ziel-Projekt braucht `proj.plan`, Lösen nur der
Verfasser). `POST`/`PUT`/`DELETE`/`aus-mail` prüfen jetzt selbst
(`notizDarf()`) statt pauschal `requireRecht('proj.plan')`: ohne Projekt
genügt die Anmeldung. `GET /projekt-notizen/anzahl` zählt nur
Projekt-Notizen.
- **Live:** `{typ:'notizen'}` geht in `store.js` an `meineNotizenLive()` und
`notizenLiveAenderung()`; das Echo der eigenen Speicherung (kommt auch im
selben Tab an) wird 3 s lang ausgelassen, sonst verschwände die Anzeige
„Gespeichert ✓“.
### Seitenleiste — Gliederung nach Arbeitsbereich (11/2026)
Eriks Wahl „A“ (unslop + Taste-Skill): Die Einträge stehen in Gruppen
(`.sidebar-gruppe` in `App.html`): **Dashboard** · **Projekte**
(Projekte, darunter eingezogen das geöffnete Projekt `#planNavItem` mit
Projektnummer in Mono, Wochenbericht, Terminplan) · **Team** (Auslastung,
Abwesenheiten) · **Notizen** (eigene Gruppe, Eriks Wunsch) · **Studenten** (Plan Checker, ganze Gruppe `nur-recht
wz.checker`) · unten `.sidebar-unten` mit **Einstellungen** und **Admin**.
Die Überschriften „Navigation“ und „Planung“ sind entfallen; der Menüpunkt
heißt weiter „Dashboard“. Admin-Unterpunkte in drei Zwischengruppen
(`.sidebar-subgruppe`: Personen, Vorgaben, System); `adminNavAbgleichen()`
blendet eine Zwischengruppe aus, wenn keiner ihrer Punkte erlaubt ist.
Maße: Einträge 32 px, Icon 18 px + 8 px, Rubrik 16 px über der Gruppe und
4 px über ihren Einträgen. Stile: Block „SEITENLEISTE“ am Ende von
`styles.css`.
**Falle in CSS-Kommentaren:** `*/` beendet einen Kommentar auch mitten im
Text (z. B. „--dash-*/--wt-*“). Der Rest wird dann als Regel gelesen und
verschluckt die nächste echte Regel. So fehlten zeitweise `--font-mono`
(alle Mono-Ziffern) und `--accent` im automatischen Dunkelmodus.
### Termin aus Mail (`js/plan/plan-mail-eintrag.js`) ### Termin aus Mail (`js/plan/plan-mail-eintrag.js`)
Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze
@@ -3399,6 +3469,26 @@ the system font offline). `z-index: 400`, above `.modal-bg` (300).
one project is ever open, so height is free while width is scarce — beside one project is ever open, so height is free while width is scarce — beside
the list, three columns left the people 137 px. the list, three columns left the people 137 px.
### Wochenbericht — Aufgaben des Projekts (Eriks Wahl „B“, 11/2026)
Unter der Projektkarte, vor „Besprochen & weiter“, der Abschnitt „Aufgaben“
(`renderBerichtAufgaben()`/`berichtAufgabenDaten()` in
`js/wochenbericht/wochenbericht.js`) mit drei Spalten: **Erledigt seit KW …**
(jetzt `done`, in der vorigen GELADENEN Sitzung offen oder nicht vorhanden —
Abgleich über die Aufgaben-id, ohne Vorwoche leer), **Überfällig** (offen,
Ende vor heute), **Nächste zwei Wochen** (offene Aufgaben und Meilensteine
bis heute + 14). Phasenbalken (`t.lph`) zählen nicht. Aufgaben mit
Zuständigen aus dem gewählten Gewerk stehen zuerst, gezeigt werden aber alle.
Nur Anzeige: ein Klick öffnet das normale Aufgaben-/Meilenstein-Panel ÜBER
dem Wochenbericht (`berichtEintragOeffnen()` setzt dafür
`PlanApp.week/project`; `showTab('plan')` setzt beides ohnehin neu);
`closeTodoModal()`/`closeMilestoneModal()` zeichnen danach nur den Abschnitt
und die Tagesordnung neu (`berichtAufgabenNeu()`), ein angefangener Kommentar
bleibt. Die Tagesordnung zeigt je Projekt die Zahl überfälliger Aufgaben rot
(`.bericht-rail-ueber`). Das PDF hat in jeder Akte die Tabelle „Aufgaben“
(`pdfWbAufgaben()`, höchstens fünf je Gruppe). Stile: Block „AUFGABEN IM
WOCHENBERICHT“ am Ende von `styles.css`.
### Wochenbericht — Gewerke-Trennung ### Wochenbericht — Gewerke-Trennung
Since departments stopped partitioning the data (see "Departments were Since departments stopped partitioning the data (see "Departments were
@@ -3604,6 +3694,26 @@ Stile: Block „Verlauf der Vorwochen im Wochenbericht“ (`.bv-*`,
### Dashboard ("Meine Aufgaben", now the default landing tab) ### Dashboard ("Meine Aufgaben", now the default landing tab)
**Stand 11/2026 — Entwurf „Bauplan“ (Eriks Wahl, Vorlage im Artifact
„Projektübersicht neu gestaltet“):** Kopf ohne Begrüßung und Uhr: Zeile
„KW 45, Mo 2. bis Fr 6. November 2026“ (Mono) über dem Titel „Meine
Aufgaben“, rechts die Sitzung (`renderDashboardKopf()`, `dashWochenMontag()`;
`greetingForHour()`/`startDashboardClock()` sind entfallen). Band mit Satz und
vier Zahlen: offene Aufgaben, überfällig (rot), diese Woche fällig (aus
`dashDaten()`, heute bis Sonntag), Freigaben. Neu darunter **„Diese Woche“**
(`renderDashWoche()`): Mo–Fr als Spalten mit den Terminen der eigenen
Projekte, getönt nach eigene Aufgabe / Aufgabe im Projekt / Meilenstein /
überfällig, je Tag höchstens `DASH_WOCHE_JE_TAG` (4). Jeder Projektblock hat
die Zeile **„Meine Rolle | Team | Notizen“** (`renderDashProjektInfo()`, Eriks
Wunsch): Team = übrige Beteiligte als Kürzel-Kreise in Rollenfarben
(`beteiligteJePerson()`/`beteiligtenAvatarHtml()`, höchstens 5, Namen
gekürzt), Notizen nur wenn vorhanden, mit Sprung in den Reiter „Notizen“.
Die Anzahl liefert **`GET /projekt-notizen/anzahl`** (`{uid: {anzahl,
zuletzt}}`, requireAuth), parallel zu `/my-dashboard` geladen
(`dashNotizen`). Die Rollen-Chips im Projektkopf sind dafür entfallen. Der
**Monatskalender bleibt unverändert** (Eriks Vorgabe). Stile: Block „MEINE
AUFGABEN — Entwurf Bauplan“ am Ende von `styles.css`.
**Aktueller Aufbau (Entwurf „B — Monatsblatt“, Eriks Wahl aus drei Varianten):** **Aktueller Aufbau (Entwurf „B — Monatsblatt“, Eriks Wahl aus drei Varianten):**
Kopf und Band wie unten beschrieben; darunter **links „Meine Projekte“**, je Kopf und Band wie unten beschrieben; darunter **links „Meine Projekte“**, je
Projekt ein Block (`renderDashProjekte()`): eigene Rollen als Chips (gestrichelt Projekt ein Block (`renderDashProjekte()`): eigene Rollen als Chips (gestrichelt
@@ -3691,9 +3801,14 @@ Terminplänen zu beantworten war. Details:
Altersgrenze stehen — eine Frist verfällt nicht dadurch, dass sie lange Altersgrenze stehen — eine Frist verfällt nicht dadurch, dass sie lange
gerissen ist. Ohne die Grenze nach vorn stünden Termine aus einem Jahr in gerissen ist. Ohne die Grenze nach vorn stünden Termine aus einem Jahr in
der Liste und die dringenden gingen darin unter. der Liste und die dringenden gingen darin unter.
- **Meilensteine haben kein Erledigt-Kennzeichen** (`plan_milestones` hat - **Meilensteine haben ein Kennzeichen „Erreicht“** (`m.erreicht`,
keine `done`-Spalte), deshalb zählt bei ihnen allein das Datum; bei Aufgaben `plan_milestones.erreicht`, Migration in `migrateAddPlanTodoNotiz()`, in
gilt zusätzlich `done = 0` und ein gesetztes Enddatum. `IMPORT_OPTIONAL_COLS`; Häkchen im Meilenstein-Panel unter „Wann“). Ein
vergangener, nicht erreichter Meilenstein gilt überall als überfällig
(Band und Projektblöcke auf „Meine Aufgaben“, Projekt-Dashboard,
Wochenbericht); erreichte fallen aus Terminlisten, „Nächster Meilenstein“
und Wandtafel heraus, im Kalender bleiben sie stehen. Bei Aufgaben gilt
`done = 0` und ein gesetztes Enddatum.
- **Die Namen der Zuständigen kommen aus `week_people`**, nicht aus - **Die Namen der Zuständigen kommen aus `week_people`**, nicht aus
`loadGlobalPeople()`: Die globale Liste enthält nur Personen mit `loadGlobalPeople()`: Die globale Liste enthält nur Personen mit
Benutzerkonto, die Teilnehmerliste einer Sitzung dagegen jede Person, die Benutzerkonto, die Teilnehmerliste einer Sitzung dagegen jede Person, die
@@ -4433,6 +4548,39 @@ minütlich. Ein Klick markiert als gelesen und springt in die Aufgaben des
Projekts — gesucht in der AKTUELLEN Sitzung über die Projektnummer (die Projekts — gesucht in der AKTUELLEN Sitzung über die Projektnummer (die
Projekt-ID wechselt je Sitzung), Planung-Terminpläne über ihre ID. Projekt-ID wechselt je Sitzung), Planung-Terminpläne über ihre ID.
## Gestaltung „Bauplan“ (unslop-Überarbeitung, 11/2026)
Eriks Wunsch: das gesamte Design mit dem Skill „unslop“ überarbeiten, Wahl
„volle Überarbeitung“, Glas „beruhigen“. Inhalte, Aufbau und Funktionen sind
unverändert; geändert sind nur Stylesheets, ein paar Texte und Inline-Größen.
- **Richtung:** Planköpfe und Raster eines Bauplans — Flächen fast neutral
(Graphit, leicht kühl), ein Akzent (Logo-Blau `#0f5a85`, dunkel
`#86bfdf`), Farbe nur in Inhalten (Terminplan, Auslastung, Status).
Löst die frühere Dunkel-Palette in Wandtafel-Blau ab.
- **Regeln, die neue Stile einhalten sollten:** Schriftgrößen 11/12/13/15/19/24
(keine halben Pixel), Gewichte 400/500, 600 nur selten; keine gesperrten
Großbuchstaben-Labels; keine negative Laufweite unter 40px; Abstände im
4px-Raster (2 und 6 nur in kleinen Bedienelementen); Rundungen 4–6
(Schildchen/Chips), 8 (Knöpfe, Eingaben, Umschalter), 12–16 (Karten);
Kapselform nur für dünne Balken, Schalter, Zählpunkte und Avatare; keine
farbigen Schatten/Leuchten, keine Emoji, keine Ausrufezeichen in Texten.
- **Kennzeichen:** Kalenderwochen, Projektnummern und Kennzahlen in
Monospace-Ziffern (`--font-mono`, tabellarisch) — Block „BAUPLAN“ am Ende
von `styles.css` (auch: ruhige aktive Navigation, Knopf-Übergänge).
- **Dunkel:** Hauptknöpfe in `--knopf` (`#276f9c`, weiße Schrift 5,6:1), weil
weiße Schrift auf dem hellen Akzent nur ~2:1 erreicht.
- **Glas beruhigt** (`glas.css`): Flächen fast deckend, nur noch zwei
schwache Lichtflächen in den Logo-Blautönen (Mint entfallen), Rundungen
8/14/16, neutrale Schatten, kein `saturate()`, aktive Einträge mit Haarlinie
statt farbigem Schatten.
- **Ausgenommen:** die Wandtafel (eigenes Design, `--dash-*`/`--wt-*`) und
die Login-Seite bei den Schriftgrößen.
- Das Umbauskript arbeitete je CSS-Regel; geprüft: gleiche Regelzahl wie
vorher (kein Parse-Verlust), keine Überläufe/Großbuchstaben auf Dashboard,
Projekte, Wochenbericht, Auslastung, Abwesenheiten, Einstellungen und in
den Projektbereichen. Methode nach „unslop“ von Damir Sokolovsky
(@heydamir), CC BY 4.0.
## Glas-Darstellung (`public/glas.css`) ## Glas-Darstellung (`public/glas.css`)
Eriks Wahl aus dem Gestaltungsvorschlag „Projektübersicht in Glas“ (Annäherung Eriks Wahl aus dem Gestaltungsvorschlag „Projektübersicht in Glas“ (Annäherung
@@ -4751,6 +4899,46 @@ Abweichungen:
Rendern des Wochenberichts mit. `esc()` wandelt jetzt über `String()` um Rendern des Wochenberichts mit. `esc()` wandelt jetzt über `String()` um
(nur `null`/`undefined` werden zu `''`). (nur `null`/`undefined` werden zu `''`).
## Behobener Fehler: wachsende „&amp;amp;…“ in Kommentaren (11/2026)
Eriks Meldung nach dem Sitzungswechsel KW 45: In Kommentaren stand
„&amp;amp;amp;amp;nbsp;“. Ursache: `sanitizeFormatHtml()` (server.js)
maskierte den Text zwischen den Tags mit `htmlEscape()` — auch ein `&`, das
schon eine fertige Entity begann. Weil `serializeProject()` die Kommentare
bei JEDEM Speichern eines Projekts erneut bereinigt (auch wenn nur eine
Aufgabe geändert wurde), kam pro Speichern eine Stufe dazu (&nbsp; →
&amp;nbsp; → &amp;amp;nbsp; …). Jetzt maskiert `textMaskieren()` ein `&` nur,
wenn es keine Entity beginnt — beliebig oft wiederholbar (geprüft: alle
Kommentare nach dreimaligem Bereinigen unverändert). `entitiesReparieren()`
(beim Start und nach `/import`, idempotent) führt gestapelte Stufen in
`project_kommentare.status` und `protokoll_punkte.text` aller Sitzungen über
`entitiesEntstapeln()` zurück.
## Behobener Fehler: Kommentare abgewählter Gewerke (11/2026)
Eriks Meldung zu 21-4030: Im Projekt-Dashboard stand ein HLS-Kommentar,
obwohl das Projekt seit KW 31 nur noch ELT hat. Ursache: Die einmalige
Umstellung `kommentareInAktuelleSitzungUebernehmen()` hatte je Gewerk den
jüngsten früheren Kommentar übernommen, ohne die aktuellen Gewerke des
Projekts zu prüfen; der Sitzungswechsel trug ihn weiter. Jetzt:
`kommentareDesProjekts()` (js/utils.js) — die Gesamtanzeige
(`kommentarVon(p, null)`, `kommentarAnzeigeHtml()`) und der Verlauf im
Projekt-Dashboard (`planKommentarDaten()`, je Sitzung mit DEREN Gewerken)
zeigen nur Gewerke des Projekts; `ensureCurrentWeekRollover()` übernimmt nur
Kommentare dieser Gewerke. Gelöscht wird nichts — die verirrte Kopie fällt
beim nächsten Sitzungswechsel aus der aktuellen Sitzung heraus.
**Darstellung mehrerer Gewerke „B – Überschrift mit Linie“** (Eriks Wahl
aus dem Artifact „Kommentare je Gewerk“): `kommentarAnzeigeHtml(p, null,
mitMeta)` zeichnet je Gewerk eine Kopfzeile (Gewerk in seiner Farbe
`--gewerk-elt/-hls/-arch`, feine Linie, mit `mitMeta` Verfasser und Zeit)
und darunter den Text; bei nur einem Gewerk wie bisher nur den Text.
Genutzt von der Kachel „Kommentar der Sitzung“ im Projekt-Dashboard (dort
entfällt dann die gemeinsame Verfasserzeile, `kommentarGewerkeMitText()`),
der Kommentaransicht der Projektliste/Wandtafel (`renderKommentarAnsicht()`)
und den Verlauf-Kacheln (ohne Verfasser). Stile `.kommentar-gewerke`,
`.kommentar-kopf` in `styles.css`.
## Behobene Fehler (Testlauf 10/2026, fünf Test-Agents) ## Behobene Fehler (Testlauf 10/2026, fünf Test-Agents)
- **Gesperrte Sitzungen sind für Nicht-Admins auch inhaltlich unveränderlich**: - **Gesperrte Sitzungen sind für Nicht-Admins auch inhaltlich unveränderlich**:
+164 -152
View File
@@ -38,13 +38,13 @@
<link rel="icon" type="image/png" href="/logo.png"> <link rel="icon" type="image/png" href="/logo.png">
<link rel="alternate icon" href="/logo.ico"> <link rel="alternate icon" href="/logo.ico">
<link rel="apple-touch-icon" href="/logo.png"> <link rel="apple-touch-icon" href="/logo.png">
<link rel="stylesheet" href="/styles.css?v=111"> <link rel="stylesheet" href="/styles.css?v=124">
<link rel="stylesheet" href="/plan-styles.css?v=51"> <link rel="stylesheet" href="/plan-styles.css?v=57">
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit <!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen, der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
unter Einstellungen → Mein Konto (localStorage projuebersicht-glas). --> unter Einstellungen → Mein Konto (localStorage projuebersicht-glas). -->
<link rel="stylesheet" href="/glas.css?v=14"> <link rel="stylesheet" href="/glas.css?v=18">
<script> <script>
try { if(localStorage.getItem('projuebersicht-glas') !== 'aus') document.documentElement.classList.add('glas'); } try { if(localStorage.getItem('projuebersicht-glas') !== 'aus') document.documentElement.classList.add('glas'); }
catch(e){ document.documentElement.classList.add('glas'); } catch(e){ document.documentElement.classList.add('glas'); }
@@ -129,116 +129,87 @@
</button> </button>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav" aria-label="Hauptnavigation">
<!-- Gliederung nach Arbeitsbereich (Eriks Wahl „A“, unslop-Überarbeitung
11/2026): oben das Dashboard, dann Projekte, Team, Notizen und Studenten;
Einstellungen und Admin sitzen unten (.sidebar-unten, margin-top:
auto) und rutschen nur bei aufgeklapptem Admin in den Bildlauf. -->
<div class="sidebar-gruppe">
<button class="nav-item tab active" data-tab="dashboard" onclick="showTab('dashboard')" title="Dashboard"> <button class="nav-item tab active" data-tab="dashboard" onclick="showTab('dashboard')" title="Dashboard">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="m4.5 7.5 1.3 1.3L8.5 6"/><line x1="13" y1="6" x2="21" y2="6"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="m4.5 17.5 1.3 1.3 2.7-2.8"/><line x1="13" y1="16" x2="21" y2="16"/></svg></span></span><span class="nav-label">Dashboard</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="m4.5 7.5 1.3 1.3L8.5 6"/><line x1="13" y1="6" x2="21" y2="6"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="m4.5 17.5 1.3 1.3 2.7-2.8"/><line x1="13" y1="16" x2="21" y2="16"/></svg></span></span><span class="nav-label">Dashboard</span>
</button> </button>
</div>
<hr class="sidebar-nav-divider"> <div class="sidebar-gruppe">
<div class="sidebar-section-label">Projekte</div>
<div class="sidebar-section-label">Navigation</div>
<!-- Die eine Projektliste. Der frühere zweite Eintrag "Projekte" —
dieselben Projekte, dieselben Angaben, eine zweite Bearbeitungs-
maske — ist hier aufgegangen: Reihenfolge, Gruppenwechsel und
Löschen stehen in dieser Liste, die Projektangaben im
Projektbereich, die wöchentliche Erfassung im Wochenbericht. -->
<button class="nav-item tab" data-tab="board" onclick="showTab('board')" title="Projekte"> <button class="nav-item tab" data-tab="board" onclick="showTab('board')" title="Projekte">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2.5"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg></span></span><span class="nav-label">Projekte</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2.5"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg></span></span><span class="nav-label">Projekte</span>
</button> </button>
<!-- Geöffnetes Projekt: eingezogen unter „Projekte“, nur sichtbar,
solange ein Projekt offen ist (syncPlanNavItem(), js/tabs-auth.js). -->
<button class="nav-item tab nav-unterpunkt" data-tab="plan" id="planNavItem" style="display:none" onclick="showTab('plan')" title="Projektbereich">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="14" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="16" x2="16" y2="16"/></svg></span></span><span class="nav-label" id="planNavItemLabel">Projektbereich</span>
</button>
<button class="nav-item tab" data-tab="wochenbericht" onclick="showTab('wochenbericht')" title="Wochenbericht"> <button class="nav-item tab" data-tab="wochenbericht" onclick="showTab('wochenbericht')" title="Wochenbericht">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5h8a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1-1.5 1.5H8A1.5 1.5 0 0 1 6.5 19V5A1.5 1.5 0 0 1 8 3.5Z"/><path d="M9.5 3.5h5v2.2a.8.8 0 0 1-.8.8h-3.4a.8.8 0 0 1-.8-.8V3.5Z"/><path d="m9.3 12.2 1.5 1.5 3.4-3.4"/><line x1="9.3" y1="16.3" x2="14.7" y2="16.3"/></svg></span></span><span class="nav-label">Wochenbericht</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5h8a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1-1.5 1.5H8A1.5 1.5 0 0 1 6.5 19V5A1.5 1.5 0 0 1 8 3.5Z"/><path d="M9.5 3.5h5v2.2a.8.8 0 0 1-.8.8h-3.4a.8.8 0 0 1-.8-.8V3.5Z"/><path d="m9.3 12.2 1.5 1.5 3.4-3.4"/><line x1="9.3" y1="16.3" x2="14.7" y2="16.3"/></svg></span></span><span class="nav-label">Wochenbericht</span>
</button> </button>
<button class="nav-item tab" data-tab="load" onclick="showTab('load')" title="Auslastung">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="21" x2="5" y2="10"/><line x1="12" y1="21" x2="12" y2="5"/><line x1="19" y1="21" x2="19" y2="14"/><line x1="3" y1="21" x2="21" y2="21"/></svg></span></span><span class="nav-label">Auslastung</span>
</button>
<!-- Abwesenheiten (js/abwesenheit/abwesenheit.js): sichtbar mit einem der
vier Abwesenheits-Rechte (data-recht mit "|" = eines davon genügt). -->
<button class="nav-item tab nur-recht" data-recht="team.abw|team.abwProjekte|team.abwGewerk|team.abwAlle" data-tab="abwesenheit" onclick="showTab('abwesenheit')" title="Abwesenheiten">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="3" x2="8" y2="6.5"/><line x1="16" y1="3" x2="16" y2="6.5"/><line x1="8.5" y1="13" x2="15.5" y2="17.5"/><line x1="15.5" y1="13" x2="8.5" y2="17.5"/></svg></span></span><span class="nav-label">Abwesenheiten</span>
</button>
<!-- "Projektbeteiligte" stand hier bis zur Entfernung der Abteilungs-
trennung (migrateRemoveDepartmentPartition(), CLAUDE.md
"Departments were retired"): eine rein lesende Liste, deren
eigentlicher Zweck — Beteiligte gezielt aus einer ANDEREN Abteilung
in die eigene Sitzung zu holen — mit der Zusammenlegung zu EINEM
gemeinsamen Personenkreis entfiel. Übrig blieb eine Dopplung der
Rollen-/Avatar-Chips, die in Wochenbericht/Board/Auslastung ohnehin
schon sichtbar sind. Auf Eriks Wunsch aus der Seitenleiste entfernt;
der Tab selbst (`#people`, `js/people/people.js`) und sein Slug
`Projektbeteiligte` in VALID_TAB_SLUGS bestehen unverändert fort —
ein alter Link/Deep-Link funktioniert also weiterhin, nur der
direkte Weg über die Navigation fehlt. -->
<!-- Projektbereich: der einzige Navigationspunkt, der nicht immer da
ist. Er erscheint, sobald ein Projekt geöffnet wurde, und trägt
dessen Bezeichnung (syncPlanNavItem() in js/tabs-auth.js). Ohne
ihn wäre im Projektbereich gar kein Punkt hervorgehoben und die
Seitenleiste zeigte nicht mehr, wo man gerade ist. -->
<button class="nav-item tab" data-tab="plan" id="planNavItem" style="display:none" onclick="showTab('plan')" title="Projektbereich">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="14" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="16" x2="16" y2="16"/></svg></span></span><span class="nav-label" id="planNavItemLabel">Projektbereich</span>
</button>
<!-- Planung: eigener, zweiter Abschnitt (nicht Teil von "Navigation"),
wie gewünscht — eine Sandbox für Terminpläne unabhängig von einem
echten Projekt (aus Honorar errechnet oder frei angelegt), siehe
CLAUDE.md "Planung — projektunabhängige Terminplan-Sandbox". -->
<div class="sidebar-section-label nur-recht" data-recht="wz.planung">Planung</div>
<button class="nav-item tab nur-recht" data-recht="wz.planung" data-tab="planung" onclick="showTab('planung')" title="Terminplan"> <button class="nav-item tab nur-recht" data-recht="wz.planung" data-tab="planung" onclick="showTab('planung')" title="Terminplan">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="17" rx="2.5"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="2.5" x2="8" y2="6.5"/><line x1="16" y1="2.5" x2="16" y2="6.5"/><line x1="7" y1="14" x2="12" y2="14"/><line x1="7" y1="17.5" x2="15" y2="17.5"/></svg></span></span><span class="nav-label">Terminplan</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="17" rx="2.5"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="2.5" x2="8" y2="6.5"/><line x1="16" y1="2.5" x2="16" y2="6.5"/><line x1="7" y1="14" x2="12" y2="14"/><line x1="7" y1="17.5" x2="15" y2="17.5"/></svg></span></span><span class="nav-label">Terminplan</span>
</button> </button>
</div>
<!-- Studenten: eigener, dritter Abschnitt (wie "Planung" nicht Teil <div class="sidebar-gruppe">
von "Navigation") — bislang nur ein Werkzeug, der Plan Checker: <div class="sidebar-section-label">Team</div>
lädt einen Bauplan als PDF hoch und lässt ihn von einer KI gegen <button class="nav-item tab" data-tab="load" onclick="showTab('load')" title="Auslastung">
eine Liste von Merkmalen prüfen (Maßketten, Plankopf, ...), <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="21" x2="5" y2="10"/><line x1="12" y1="21" x2="12" y2="5"/><line x1="19" y1="21" x2="19" y2="14"/><line x1="3" y1="21" x2="21" y2="21"/></svg></span></span><span class="nav-label">Auslastung</span>
siehe js/planchecker/planchecker.js. --> </button>
<div class="sidebar-section-label nur-recht" data-recht="wz.checker">Studenten</div> <button class="nav-item tab nur-recht" data-recht="team.abw|team.abwProjekte|team.abwGewerk|team.abwAlle" data-tab="abwesenheit" onclick="showTab('abwesenheit')" title="Abwesenheiten">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="3" x2="8" y2="6.5"/><line x1="16" y1="3" x2="16" y2="6.5"/><line x1="8.5" y1="13" x2="15.5" y2="17.5"/><line x1="15.5" y1="13" x2="8.5" y2="17.5"/></svg></span></span><span class="nav-label">Abwesenheiten</span>
</button>
</div>
<div class="sidebar-gruppe">
<div class="sidebar-section-label">Notizen</div>
<button class="nav-item tab" data-tab="notizen" onclick="showTab('notizen')" title="Notizen">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v12a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1Z"/><path d="M13.5 3.5V7a1 1 0 0 0 1 1h4"/><line x1="8.5" y1="12.5" x2="15.5" y2="12.5"/><line x1="8.5" y1="16" x2="13.5" y2="16"/></svg></span></span><span class="nav-label">Notizen</span>
</button>
</div>
<div class="sidebar-gruppe nur-recht" data-recht="wz.checker">
<div class="sidebar-section-label">Studenten</div>
<button class="nav-item tab nur-recht" data-recht="wz.checker" data-tab="planchecker" onclick="showTab('planchecker')" title="Plan Checker"> <button class="nav-item tab nur-recht" data-recht="wz.checker" data-tab="planchecker" onclick="showTab('planchecker')" title="Plan Checker">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg></span></span><span class="nav-label">Plan Checker</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg></span></span><span class="nav-label">Plan Checker</span>
</button> </button>
</div>
<hr class="sidebar-nav-divider"> <div class="sidebar-gruppe sidebar-unten">
<!-- Einstellungen: seit die firmenweiten Masken in den Admin-Bereich
gewandert sind, steht hier nur noch das eigene Konto — ein
Aufklapp-Menue fuer einen einzigen Punkt waere Ballast. -->
<button class="nav-item tab" data-tab="settings" onclick="showTab('settings')" title="Einstellungen"> <button class="nav-item tab" data-tab="settings" onclick="showTab('settings')" title="Einstellungen">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M12 3.5v2.4M12 18.1v2.4M20.5 12h-2.4M5.9 12H3.5M17.7 6.3l-1.7 1.7M8 15.7l-1.7 1.7M17.7 17.7 16 16M8 8 6.3 6.3"/></svg></span></span><span class="nav-label">Einstellungen</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M12 3.5v2.4M12 18.1v2.4M20.5 12h-2.4M5.9 12H3.5M17.7 6.3l-1.7 1.7M8 15.7l-1.7 1.7M17.7 17.7 16 16M8 8 6.3 6.3"/></svg></span></span><span class="nav-label">Einstellungen</span>
</button> </button>
<!-- Der Bereich „Werkzeuge“ ist entfallen (Eriks Wunsch): „+ Neues
Projekt“ steht in der Projektliste („+ Projekt“), die Wandtafel
braucht nur das Wandtafel-Konto (landet dort automatisch, sonst
über /Wandtafel erreichbar). -->
<!-- Admin: aufklappbare Gruppe ueber die Abschnitte von #admin. Wie
bei "Einstellungen" zuvor ist das KEINE Aktionsliste, sondern
Navigation innerhalb eines Bereichs — deshalb fuehrt schon der
Kopf-Eintrag dorthin (toggleAdminNav()) und die Unterpunkte
schalten nur den sichtbaren Abschnitt um (showAdminSection() in
js/admin/admin.js). Die gesamte Gruppe blendet renderUserBar()
(js/auth.js) fuer Nicht-Administratoren aus; die eigentliche
Sperre sitzt serverseitig auf den Routen. -->
<div id="navAdminSection" style="display:none"> <div id="navAdminSection" style="display:none">
<button class="nav-item nav-group-toggle" id="adminAccordionToggle" onclick="toggleAdminNav()" title="Admin" aria-expanded="false" aria-controls="adminSubnav"> <button class="nav-item nav-group-toggle" id="adminAccordionToggle" onclick="toggleAdminNav()" title="Admin" aria-expanded="false" aria-controls="adminSubnav">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 5.5 6v5.3c0 4.4 2.9 7.4 6.5 8.7 3.6-1.3 6.5-4.3 6.5-8.7V6Z"/><path d="m9 12 2.1 2.1L15.5 9.5"/></svg></span></span><span class="nav-label">Admin</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 5.5 6v5.3c0 4.4 2.9 7.4 6.5 8.7 3.6-1.3 6.5-4.3 6.5-8.7V6Z"/><path d="m9 12 2.1 2.1L15.5 9.5"/></svg></span></span><span class="nav-label">Admin</span>
<span class="nav-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="nav-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button> </button>
<div class="sidebar-subnav" id="adminSubnav"> <div class="sidebar-subnav" id="adminSubnav">
<div class="sidebar-subgruppe">Personen</div>
<button class="menu-item sidebar-subitem" data-admin-section="benutzer" onclick="showAdminSection('benutzer')">Benutzerverwaltung</button> <button class="menu-item sidebar-subitem" data-admin-section="benutzer" onclick="showAdminSection('benutzer')">Benutzerverwaltung</button>
<button class="menu-item sidebar-subitem" data-admin-section="rollen" onclick="showAdminSection('rollen')">Rollen &amp; Rechte</button> <button class="menu-item sidebar-subitem" data-admin-section="rollen" onclick="showAdminSection('rollen')">Rollen &amp; Rechte</button>
<div class="sidebar-subgruppe">Vorgaben</div>
<button class="menu-item sidebar-subitem" data-admin-section="honorar" onclick="showAdminSection('honorar')">Honorar</button> <button class="menu-item sidebar-subitem" data-admin-section="honorar" onclick="showAdminSection('honorar')">Honorar</button>
<button class="menu-item sidebar-subitem" data-admin-section="auslastung" onclick="showAdminSection('auslastung')">Auslastung</button> <button class="menu-item sidebar-subitem" data-admin-section="auslastung" onclick="showAdminSection('auslastung')">Auslastung</button>
<button class="menu-item sidebar-subitem" data-admin-section="termine" onclick="showAdminSection('termine')">Termine &amp; Sitzungen</button> <button class="menu-item sidebar-subitem" data-admin-section="termine" onclick="showAdminSection('termine')">Termine &amp; Sitzungen</button>
<button class="menu-item sidebar-subitem" data-admin-section="feiertage" onclick="showAdminSection('feiertage')">Feiertage</button> <button class="menu-item sidebar-subitem" data-admin-section="feiertage" onclick="showAdminSection('feiertage')">Feiertage</button>
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button> <button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button> <button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
<div class="sidebar-subgruppe">System</div>
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb &amp; Sicherheit</button> <button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb &amp; Sicherheit</button>
<button class="menu-item sidebar-subitem" data-admin-section="teams" onclick="showAdminSection('teams')">Teams</button> <button class="menu-item sidebar-subitem" data-admin-section="teams" onclick="showAdminSection('teams')">Teams</button>
<button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button> <button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button>
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button> <button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button>
</div> </div>
</div> </div>
</div>
</nav> </nav>
<button class="sidebar-collapse-bar" id="sidebarCollapseToggle" onclick="toggleSidebarCollapse()" title="Seitenleiste ein-/ausklappen" aria-pressed="false"> <button class="sidebar-collapse-bar" id="sidebarCollapseToggle" onclick="toggleSidebarCollapse()" title="Seitenleiste ein-/ausklappen" aria-pressed="false">
@@ -299,6 +270,9 @@
<!-- LOAD --> <!-- LOAD -->
<!-- ABWESENHEITEN (js/abwesenheit/abwesenheit.js) --> <!-- ABWESENHEITEN (js/abwesenheit/abwesenheit.js) -->
<div id="abwesenheit" style="display:none"></div> <div id="abwesenheit" style="display:none"></div>
<!-- NOTIZEN (js/notizen/notizen.js): Notizen aller eigenen Projekte und
projektunabhängige Notizen -->
<div id="notizen" style="display:none"></div>
<div id="load" style="display:none"> <div id="load" style="display:none">
<div class="section-title">Auslastung der Projektbeteiligten</div> <div class="section-title">Auslastung der Projektbeteiligten</div>
@@ -1528,41 +1502,67 @@
Art und Titel, Abschnitte Wann / Wo bzw. Wer / Stand / Hängt ab von, Art und Titel, Abschnitte Wann / Wo bzw. Wer / Stand / Hängt ab von,
feste Fußleiste. Alle Feld-ids wie zuvor; befüllt und ausgewertet von feste Fußleiste. Alle Feld-ids wie zuvor; befüllt und ausgewertet von
js/plan/plan-modals.js. Stile: Block "Seitenpanel" in plan-styles.css. --> js/plan/plan-modals.js. Stile: Block "Seitenpanel" in plan-styles.css. -->
<!-- Seitenpanels Aufgabe und Meilenstein als "Planblatt" (Eriks Wahl,
unslop): Kopf mit Art, Projekt und Stand, darunter die Lage im
Terminplan (todoLageNeu()/msLageBildNeu(), js/plan/plan-modals.js), dann
die Angaben als Zeilen mit Beschriftung. Alle Feld-ids wie zuvor.
Stile: Block "Planblatt" am Ende von plan-styles.css. -->
<div class="modal-bg als-panel" id="milestoneModal" style="display:none"> <div class="modal-bg als-panel" id="milestoneModal" style="display:none">
<div class="modal pd"> <div class="modal pd">
<div class="pd-kopf"> <div class="pd-kopf">
<div class="pd-art"><span class="pd-raute" aria-hidden="true"></span><span id="milestoneModalTitle">Meilenstein</span> <div class="pd-art"><span class="pd-raute" aria-hidden="true"></span><span id="milestoneModalTitle">Meilenstein</span>
<span class="modal-kbd">Esc schließt</span> <span class="pd-wo" id="milestoneModalWo"></span>
<button type="button" class="pd-zu" onclick="closeMilestoneModal()" aria-label="Schließen">×</button></div> <span class="modal-kbd"><kbd>Esc</kbd></span>
<button type="button" class="pd-zu" onclick="closeMilestoneModal()" aria-label="Schließen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button></div>
<input type="text" id="milestoneEditName" class="pd-titel" placeholder="z. B. Abgabe Genehmigungsplanung" aria-label="Name des Meilensteins"> <input type="text" id="milestoneEditName" class="pd-titel" placeholder="z. B. Abgabe Genehmigungsplanung" aria-label="Name des Meilensteins">
<div class="pd-stand">
<div class="segmented pd-seg" id="milestoneEditStand" role="group" aria-label="Stand">
<button type="button" data-stand="offen" aria-pressed="true" onclick="msStandSetzen(false)"><span class="segmented-dot"></span>Offen</button>
<button type="button" data-stand="erreicht" aria-pressed="false" onclick="msStandSetzen(true)"><span class="segmented-dot"></span>Erreicht</button>
</div>
<input type="checkbox" id="milestoneEditErreicht" hidden>
<span class="pd-warnung" id="milestoneEditWarnung" hidden></span>
</div>
<div class="pd-lage" id="msLageBild" aria-label="Lage im Terminplan"></div>
</div> </div>
<div class="pd-inhalt"> <div class="pd-inhalt">
<section class="pd-abs"> <div class="pd-zeile">
<h3>Wann</h3> <span class="pd-lab">Datum</span>
<div class="pd-wert">
<!-- sichtbares Anzeigefeld (deutsches Format) + verstecktes ISO-Feld --> <!-- sichtbares Anzeigefeld (deutsches Format) + verstecktes ISO-Feld -->
<div class="field-inline">
<input type="text" id="milestoneEditDateDisplay" class="range-picker-display" placeholder="Datum wählen…" readonly aria-label="Datum" <input type="text" id="milestoneEditDateDisplay" class="range-picker-display" placeholder="Datum wählen…" readonly aria-label="Datum"
onclick="openDatePicker({ anchor: this, inputId: 'milestoneEditDate', displayId: 'milestoneEditDateDisplay' })"> onclick="openDatePicker({ anchor: this, inputId: 'milestoneEditDate', displayId: 'milestoneEditDateDisplay', onApply: msLageBildNeu })">
<span class="field-badge pd-kw" id="milestoneEditKw" hidden></span>
</div>
<input type="hidden" id="milestoneEditDate"> <input type="hidden" id="milestoneEditDate">
</section> </div>
<section class="pd-abs"> </div>
<h3>Wo im Terminplan</h3> <div class="pd-zeile">
<span class="pd-lab">Im Terminplan</span>
<div class="pd-wert">
<div class="segmented pd-seg" id="milestoneEditLage"> <div class="segmented pd-seg" id="milestoneEditLage">
<button type="button" data-lage="zeile" aria-pressed="false" onclick="msLageSetzen('zeile')">Oben, eigene Zeile</button> <button type="button" data-lage="zeile" aria-pressed="false" onclick="msLageSetzen('zeile')">Eigene Zeile oben</button>
<button type="button" data-lage="aufgabe" aria-pressed="false" onclick="msLageSetzen('aufgabe')">Bei einer Aufgabe</button> <button type="button" data-lage="aufgabe" aria-pressed="false" onclick="msLageSetzen('aufgabe')">Bei einer Aufgabe</button>
</div> </div>
<select id="milestoneEditPosition" aria-label="Position bei einer Aufgabe" onchange="msLageSync()"> <select id="milestoneEditPosition" aria-label="Position bei einer Aufgabe" onchange="msLageSync()">
<option value="">Oben in der Projektzeile</option> <option value="">Oben in der Projektzeile</option>
</select> </select>
</section> </div>
<section class="pd-abs"> </div>
<h3>Hängt ab von</h3> <div class="pd-zeile">
<div id="milestoneEditDependsOn" class="todo-depends-list pd-liste"></div> <span class="pd-lab">Hängt ab von</span>
</section> <div class="pd-wert">
<section class="pd-abs"> <div id="milestoneEditDependsOn" class="todo-depends-list pd-liste" onchange="msLageBildNeu()"></div>
<h3>Notiz</h3> </div>
</div>
<div class="pd-zeile">
<span class="pd-lab"><label for="milestoneEditNotiz">Notiz</label></span>
<div class="pd-wert">
<p class="hint pd-hint pd-mail-hinweis" id="milestoneEditMailHinweis" hidden></p> <p class="hint pd-hint pd-mail-hinweis" id="milestoneEditMailHinweis" hidden></p>
<textarea id="milestoneEditNotiz" class="pd-notiz" rows="4" placeholder="Optional, z. B. Hintergrund oder die Mail, aus der der Meilenstein stammt" aria-label="Notiz"></textarea> <textarea id="milestoneEditNotiz" class="pd-notiz" rows="3" placeholder="Hintergrund oder die Mail, aus der der Meilenstein stammt"></textarea>
</section> </div>
</div>
</div> </div>
<div class="pd-fuss"> <div class="pd-fuss">
<button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button> <button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button>
@@ -1575,22 +1575,39 @@
<div class="modal pd"> <div class="modal pd">
<div class="pd-kopf"> <div class="pd-kopf">
<div class="pd-art"><span class="pd-kasten" aria-hidden="true"></span><span id="todoModalTitle">Aufgabe bearbeiten</span> <div class="pd-art"><span class="pd-kasten" aria-hidden="true"></span><span id="todoModalTitle">Aufgabe bearbeiten</span>
<span class="modal-kbd" id="todoModalKbd">Esc · Strg+↵</span> <span class="pd-wo" id="todoModalWo"></span>
<button type="button" class="pd-zu" onclick="closeTodoModal()" aria-label="Schließen">×</button></div> <span class="modal-kbd" id="todoModalKbd"><kbd>Esc</kbd> <kbd>Strg</kbd><kbd>↵</kbd></span>
<button type="button" class="pd-zu" onclick="closeTodoModal()" aria-label="Schließen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button></div>
<input type="text" id="todoEditTitle" class="pd-titel" placeholder="Titel der Aufgabe" aria-label="Titel"> <input type="text" id="todoEditTitle" class="pd-titel" placeholder="Titel der Aufgabe" aria-label="Titel">
<span class="field-error" id="todoEditTitleError" hidden></span> <span class="field-error" id="todoEditTitleError" hidden></span>
<div class="pd-stand">
<div class="segmented pd-seg" id="todoEditStatus">
<button type="button" data-status="offen" aria-pressed="false" onclick="setTodoEditStatus('offen')"><span class="segmented-dot"></span>Offen</button>
<button type="button" data-status="arbeit" aria-pressed="false" onclick="setTodoEditStatus('arbeit')"><span class="segmented-dot"></span>In Arbeit</button>
<button type="button" data-status="fertig" aria-pressed="false" onclick="setTodoEditStatus('fertig')"><span class="segmented-dot"></span>Erledigt</button>
</div>
<div class="progress-row" id="todoEditProgressRow">
<input type="range" id="todoEditProgress" min="0" max="100" step="5" value="0" aria-label="Fortschritt"
oninput="onTodoProgressInput(this)">
<output id="todoEditProgressVal">0 %</output>
</div>
</div>
<div class="pd-lage" id="todoLageBild" aria-label="Lage im Terminplan"></div>
</div> </div>
<div class="pd-inhalt"> <div class="pd-inhalt">
<!-- Nur im Phasen-Modus ("+ Neu" → "Leistungsphase" oder Bearbeiten <!-- Nur im Phasen-Modus ("+ Neu" → "Leistungsphase" oder Bearbeiten
eines Phasenbalkens): Welche Leistungsphase dieser Balken ist. Nur eines Phasenbalkens): Welche Leistungsphase dieser Balken ist. Nur
darüber wird ein Balken zum Phasenkopf, nicht mehr über den Titel. --> darüber wird ein Balken zum Phasenkopf, nicht mehr über den Titel. -->
<section class="pd-abs" id="todoEditPhaseAbs" hidden> <div class="pd-zeile" id="todoEditPhaseAbs" hidden>
<h3>Leistungsphase</h3> <span class="pd-lab"><label for="todoEditLph">Leistungsphase</label></span>
<select id="todoEditLph" aria-label="Leistungsphase" onchange="todoPhaseGewechselt()"></select> <div class="pd-wert">
<select id="todoEditLph" onchange="todoPhaseGewechselt()"></select>
<p class="hint pd-hint">Aufgaben, die im Zeitraum dieser Phase beginnen, stehen im Terminplan darunter.</p> <p class="hint pd-hint">Aufgaben, die im Zeitraum dieser Phase beginnen, stehen im Terminplan darunter.</p>
</section> </div>
<section class="pd-abs"> </div>
<h3>Wann</h3> <div class="pd-zeile">
<span class="pd-lab">Zeitraum</span>
<div class="pd-wert">
<div class="field-inline"> <div class="field-inline">
<input type="text" id="todoEditRange" class="range-picker-display" placeholder="Start – Ende wählen…" readonly aria-label="Zeitraum" <input type="text" id="todoEditRange" class="range-picker-display" placeholder="Start – Ende wählen…" readonly aria-label="Zeitraum"
onclick="openRangePicker({ anchor: this, startInputId: 'todoEditStart', endInputId: 'todoEditEnd', displayId: 'todoEditRange', onApply: refreshTodoDuration })"> onclick="openRangePicker({ anchor: this, startInputId: 'todoEditStart', endInputId: 'todoEditEnd', displayId: 'todoEditRange', onApply: refreshTodoDuration })">
@@ -1599,11 +1616,12 @@
<input type="hidden" id="todoEditStart"> <input type="hidden" id="todoEditStart">
<input type="hidden" id="todoEditEnd"> <input type="hidden" id="todoEditEnd">
<span class="field-error" id="todoEditRangeError" hidden></span> <span class="field-error" id="todoEditRangeError" hidden></span>
<label class="pd-haken"><input type="checkbox" id="todoEditShowInGantt" checked> Im Terminplan als Balken zeigen</label> <label class="pd-haken"><input type="checkbox" id="todoEditShowInGantt" checked> Als Balken im Terminplan</label>
<p class="hint pd-hint">Ohne Zeitraum erscheint die Aufgabe nur unter „Aufgaben“, nicht im Terminplan.</p> </div>
</section> </div>
<section class="pd-abs" id="todoEditWerAbs"> <div class="pd-zeile" id="todoEditWerAbs">
<h3>Wer</h3> <span class="pd-lab">Zuständig</span>
<div class="pd-wert">
<div id="todoEditAssignees" class="person-chips"></div> <div id="todoEditAssignees" class="person-chips"></div>
<!-- Externe Personen: Kollegen mit Konto, die dem Projekt NICHT <!-- Externe Personen: Kollegen mit Konto, die dem Projekt NICHT
zugeordnet sind (Eriks Wunsch). todoExternHinzufuegen()/ zugeordnet sind (Eriks Wunsch). todoExternHinzufuegen()/
@@ -1613,13 +1631,13 @@
<div id="todoEditExterne" class="person-chips"></div> <div id="todoEditExterne" class="person-chips"></div>
<select id="todoEditExterneWahl" aria-label="Externe Person hinzufügen" onchange="todoExternHinzufuegen(this.value)"></select> <select id="todoEditExterneWahl" aria-label="Externe Person hinzufügen" onchange="todoExternHinzufuegen(this.value)"></select>
</div> </div>
</div>
</div>
<!-- Aufwand: −/+, Schnellwahl, Umrechnung in Arbeitstage <!-- Aufwand: −/+, Schnellwahl, Umrechnung in Arbeitstage
(todoAufwandAendern()/todoAufwandSetzen()/todoAufwandInfo(), plan-modals.js) --> (todoAufwandAendern()/todoAufwandSetzen()/todoAufwandInfo(), plan-modals.js) -->
<div class="pd-aufwand"> <div class="pd-zeile" id="todoEditAufwandAbs">
<div class="pd-aufwand-kopf"> <span class="pd-lab"><label for="todoEditAufwand">Aufwand</label></span>
<label for="todoEditAufwand">Aufwand</label> <div class="pd-wert pd-aufwand">
<span>optional · zählt zur Auslastung der Zuständigen</span>
</div>
<div class="pd-aufwand-zeile"> <div class="pd-aufwand-zeile">
<div class="pd-stepper"> <div class="pd-stepper">
<button type="button" class="pd-stepper-knopf" onclick="todoAufwandAendern(-1)" aria-label="Eine Stunde weniger">−</button> <button type="button" class="pd-stepper-knopf" onclick="todoAufwandAendern(-1)" aria-label="Eine Stunde weniger">−</button>
@@ -1637,41 +1655,34 @@
</div> </div>
<span class="pd-aufwand-info" id="todoAufwandInfoText"></span> <span class="pd-aufwand-info" id="todoAufwandInfoText"></span>
</div> </div>
</section>
<section class="pd-abs">
<h3>Stand</h3>
<div class="segmented pd-seg" id="todoEditStatus">
<button type="button" data-status="offen" aria-pressed="false" onclick="setTodoEditStatus('offen')"><span class="segmented-dot"></span>Offen</button>
<button type="button" data-status="arbeit" aria-pressed="false" onclick="setTodoEditStatus('arbeit')"><span class="segmented-dot"></span>In Arbeit</button>
<button type="button" data-status="fertig" aria-pressed="false" onclick="setTodoEditStatus('fertig')"><span class="segmented-dot"></span>Erledigt</button>
</div> </div>
<div class="progress-row" id="todoEditProgressRow"> <div class="pd-zeile">
<input type="range" id="todoEditProgress" min="0" max="100" step="5" value="0" aria-label="Fortschritt" <span class="pd-lab">Hängt ab von</span>
oninput="onTodoProgressInput(this)"> <div class="pd-wert">
<output id="todoEditProgressVal">0 %</output> <div class="pd-vorg" id="todoEditDependsSummary"></div>
</div>
</section>
<section class="pd-abs">
<h3>Notiz</h3>
<p class="hint pd-hint pd-mail-hinweis" id="todoEditMailHinweis" hidden></p>
<textarea id="todoEditNotiz" class="pd-notiz" rows="4" placeholder="Optional, z. B. Hintergrund oder die Mail, aus der die Aufgabe stammt" aria-label="Notiz"></textarea>
</section>
<section class="pd-abs">
<details class="fold" id="todoEditDependsFold"> <details class="fold" id="todoEditDependsFold">
<summary> <summary>
<span class="fold-caret">▶</span> <span class="fold-caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg></span>
<span>Hängt ab von</span> <span>Vorgänger ändern</span>
<span class="fold-count zero" id="todoEditDependsCount">0</span> <span class="fold-count zero" id="todoEditDependsCount">0</span>
</summary> </summary>
<div class="fold-body"> <div class="fold-body">
<div class="fold-search"> <div class="fold-search">
<span class="fold-search-icon">⌕</span> <span class="fold-search-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="6"/><line x1="15.5" y1="15.5" x2="20" y2="20"/></svg></span>
<input type="text" id="todoEditDependsSearch" placeholder="Aufgabe oder Meilenstein suchen…" oninput="filterTodoDepends()"> <input type="text" id="todoEditDependsSearch" placeholder="Aufgabe oder Meilenstein suchen" oninput="filterTodoDepends()">
</div> </div>
<div id="todoEditDependsOn" class="todo-depends-list flat" onchange="refreshTodoDependsCount()"></div> <div id="todoEditDependsOn" class="todo-depends-list flat" onchange="refreshTodoDependsCount()"></div>
</div> </div>
</details> </details>
</section> </div>
</div>
<div class="pd-zeile">
<span class="pd-lab"><label for="todoEditNotiz">Notiz</label></span>
<div class="pd-wert">
<p class="hint pd-hint pd-mail-hinweis" id="todoEditMailHinweis" hidden></p>
<textarea id="todoEditNotiz" class="pd-notiz" rows="3" placeholder="Hintergrund oder die Mail, aus der die Aufgabe stammt"></textarea>
</div>
</div>
</div> </div>
<div class="pd-fuss"> <div class="pd-fuss">
<button type="button" class="pd-loeschen" id="todoEditDelete" onclick="deleteTodoEdit()">Löschen</button> <button type="button" class="pd-loeschen" id="todoEditDelete" onclick="deleteTodoEdit()">Löschen</button>
@@ -1979,21 +1990,22 @@
</main> </main>
</div> </div>
<script src="/js/globals.js?v=9"></script> <script src="/js/globals.js?v=10"></script>
<script src="/js/utils.js?v=31"></script> <script src="/js/utils.js?v=33"></script>
<script src="/js/icons.js?v=2"></script> <script src="/js/icons.js?v=2"></script>
<script src="/js/ui.js?v=9"></script> <script src="/js/ui.js?v=9"></script>
<script src="/js/date-picker.js?v=6"></script> <script src="/js/date-picker.js?v=6"></script>
<script src="/js/server.js?v=7"></script> <script src="/js/server.js?v=7"></script>
<script src="/js/auth.js?v=11"></script> <script src="/js/auth.js?v=11"></script>
<script src="/js/store.js?v=16"></script> <script src="/js/store.js?v=17"></script>
<script src="/js/weeks.js?v=12"></script> <script src="/js/weeks.js?v=12"></script>
<script src="/js/board/board.js?v=41"></script> <script src="/js/board/board.js?v=42"></script>
<script src="/js/wochenbericht/wochenbericht.js?v=29"></script> <script src="/js/wochenbericht/wochenbericht.js?v=31"></script>
<script src="/js/people/people.js?v=8"></script> <script src="/js/people/people.js?v=8"></script>
<script src="/js/planung/planung.js?v=3"></script> <script src="/js/planung/planung.js?v=3"></script>
<script src="/js/planchecker/planchecker.js?v=1"></script> <script src="/js/planchecker/planchecker.js?v=1"></script>
<script src="/js/abwesenheit/abwesenheit.js?v=10"></script> <script src="/js/abwesenheit/abwesenheit.js?v=10"></script>
<script src="/js/notizen/notizen.js?v=3"></script>
<script src="/js/projects/projects.js?v=10"></script> <script src="/js/projects/projects.js?v=10"></script>
<script src="/js/load/load.js?v=23"></script> <script src="/js/load/load.js?v=23"></script>
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil <!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
@@ -2002,7 +2014,7 @@
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und <!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
load.js (faktorFormat), steht deshalb dahinter. --> load.js (faktorFormat), steht deshalb dahinter. -->
<script src="/js/projekt-wizard.js?v=6"></script> <script src="/js/projekt-wizard.js?v=6"></script>
<script src="/js/dashboard/dashboard.js?v=10"></script> <script src="/js/dashboard/dashboard.js?v=14"></script>
<script src="/js/benachrichtigungen.js?v=5"></script> <script src="/js/benachrichtigungen.js?v=5"></script>
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/ <!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek() stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
@@ -2010,12 +2022,12 @@
relativerTag()/tageBis()/initialsFor() (dashboard.js) — alle laden relativerTag()/tageBis()/initialsFor() (dashboard.js) — alle laden
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
Tab-Ordner, keine neue Abhängigkeit nach vorn. --> Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
<script src="/js/wand/wand.js?v=21"></script> <script src="/js/wand/wand.js?v=22"></script>
<script src="/js/settings/settings.js?v=14"></script> <script src="/js/settings/settings.js?v=14"></script>
<script src="/js/admin/admin.js?v=23"></script> <script src="/js/admin/admin.js?v=24"></script>
<script src="/js/admin-users.js?v=10"></script> <script src="/js/admin-users.js?v=10"></script>
<script src="/js/admin/rollen.js?v=1"></script> <script src="/js/admin/rollen.js?v=1"></script>
<script src="/js/einfuehrung.js?v=2"></script> <script src="/js/einfuehrung.js?v=3"></script>
<script src="/js/gantt.js?v=10"></script> <script src="/js/gantt.js?v=10"></script>
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in <!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist, project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist,
@@ -2025,30 +2037,30 @@
js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js
umhuellt. --> umhuellt. -->
<script src="/js/plan/plan-state.js?v=25"></script> <script src="/js/plan/plan-state.js?v=25"></script>
<script src="/js/plan/plan-sections.js?v=12"></script> <script src="/js/plan/plan-sections.js?v=13"></script>
<script src="/js/plan/plan-info.js?v=15"></script> <script src="/js/plan/plan-info.js?v=17"></script>
<script src="/js/plan/plan-history.js?v=4"></script> <script src="/js/plan/plan-history.js?v=4"></script>
<script src="/js/plan/plan-build-gantt.js?v=11"></script> <script src="/js/plan/plan-build-gantt.js?v=11"></script>
<script src="/js/plan/plan-gantt.js?v=22"></script> <script src="/js/plan/plan-gantt.js?v=24"></script>
<script src="/js/plan/plan-drag.js?v=8"></script> <script src="/js/plan/plan-drag.js?v=8"></script>
<script src="/js/plan/plan-modals.js?v=18"></script> <script src="/js/plan/plan-modals.js?v=21"></script>
<script src="/js/plan/plan-tasks.js?v=4"></script> <script src="/js/plan/plan-tasks.js?v=5"></script>
<script src="/js/plan/plan-phasen.js?v=3"></script> <script src="/js/plan/plan-phasen.js?v=3"></script>
<script src="/js/plan/plan-files.js?v=4"></script> <script src="/js/plan/plan-files.js?v=4"></script>
<script src="/js/plan/plan-protokoll.js?v=20"></script> <script src="/js/plan/plan-protokoll.js?v=21"></script>
<script src="/js/plan/plan-templates.js?v=11"></script> <script src="/js/plan/plan-templates.js?v=11"></script>
<script src="/js/plan/plan-protokoll-todos.js?v=2"></script> <script src="/js/plan/plan-protokoll-todos.js?v=3"></script>
<script src="/js/plan/plan-mail-eintrag.js?v=4"></script> <script src="/js/plan/plan-mail-eintrag.js?v=4"></script>
<script src="/js/plan/plan-notizen.js?v=1"></script> <script src="/js/plan/plan-notizen.js?v=2"></script>
<script src="/js/plan/plan-honorar.js?v=9"></script> <script src="/js/plan/plan-honorar.js?v=9"></script>
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen <!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen
Zwischenspeicher verwirft. --> Zwischenspeicher verwirft. -->
<script src="/js/plan/plan-leistung.js?v=1"></script> <script src="/js/plan/plan-leistung.js?v=1"></script>
<script src="/js/plan/plan-settings.js?v=14"></script> <script src="/js/plan/plan-settings.js?v=14"></script>
<script src="/js/plan/plan-schedule.js?v=6"></script> <script src="/js/plan/plan-schedule.js?v=7"></script>
<script src="/js/tabs-auth.js?v=17"></script> <script src="/js/tabs-auth.js?v=19"></script>
<script src="/js/pdf.js?v=31"></script> <script src="/js/pdf.js?v=32"></script>
<script src="/js/backup.js?v=2"></script> <script src="/js/backup.js?v=2"></script>
<script src="/js/import.js?v=1"></script> <script src="/js/import.js?v=1"></script>
<script src="/js/init.js?v=14"></script> <script src="/js/init.js?v=14"></script>
+96 -89
View File
@@ -1,5 +1,8 @@
/* =========================================================================== /* ===========================================================================
GLAS-DARSTELLUNG (Eriks Wahl: Gestaltungsvorschlag "Projektübersicht in GLAS-DARSTELLUNG — beruhigt in der unslop-Überarbeitung „Bauplan“ (Eriks
Wahl „Beruhigen“): Flächen fast deckend, Lichtflächen schwach und nur noch
in den zwei Logo-Blautönen, kleinere Rundungen und neutrale Schatten.
Ursprünglich: (Eriks Wahl: Gestaltungsvorschlag "Projektübersicht in
Glas", Annäherung an Apple Liquid Glass) Glas", Annäherung an Apple Liquid Glass)
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES
@@ -27,107 +30,105 @@
/* ---------- Tokens: hell ---------- */ /* ---------- Tokens: hell ---------- */
html.glas { html.glas {
--bg: #e8eef3; --bg: #eef1f4;
--surface: rgba(255,255,255,.58); --surface: rgba(255,255,255,.82);
--surface-alt: rgba(255,255,255,.40); --surface-alt: rgba(255,255,255,.66);
--surface-sunk: rgba(15,90,133,.07); --surface-sunk: rgba(16,24,32,.05);
--line: rgba(20,45,65,.16); --line: rgba(16,24,32,.13);
--line-soft: rgba(20,45,65,.09); --line-soft: rgba(16,24,32,.07);
--accent: #0f5a85; --accent: #0f5a85;
--accent-hover: #0d5079; --accent-hover: #0d5079;
--accent-active: #0a4565; --accent-active: #0a4565;
--accent-soft: rgba(15,90,133,.11); --accent-soft: rgba(15,90,133,.09);
--band-neu: #0f5a85; --band-neu: #0f5a85;
--text: #14212b; --text: #16191d;
--text-muted: #4b5d6b; --text-muted: #4d545c;
--text-subtle: #6f8291; --text-subtle: #69707a;
--r-md: 10px; --r-md: 8px;
--r-lg: 18px; --r-lg: 14px;
--r-xl: 22px; --r-xl: 16px;
--sh-1: 0 8px 28px rgba(21,58,86,.10), 0 1px 4px rgba(21,58,86,.06); --sh-1: 0 1px 2px rgba(16,24,32,.05);
--sh-2: 0 12px 34px rgba(21,58,86,.13), 0 2px 6px rgba(21,58,86,.07); --sh-2: 0 4px 16px rgba(16,24,32,.07), 0 1px 2px rgba(16,24,32,.04);
--sh-3: 0 20px 60px rgba(21,58,86,.20), 0 4px 14px rgba(21,58,86,.10); --sh-3: 0 12px 32px rgba(16,24,32,.12), 0 2px 6px rgba(16,24,32,.06);
--glas-rahmen: rgba(255,255,255,.62); --glas-rahmen: rgba(255,255,255,.84);
--glas-dicht: rgba(255,255,255,.86); --glas-dicht: rgba(255,255,255,.94);
--glas-deckend: #f7fafc; --glas-deckend: #f7fafc;
--glas-kante: rgba(255,255,255,.80); --glas-kante: rgba(255,255,255,.70);
--glas-kante-2: rgba(255,255,255,.35); --glas-kante-2: rgba(255,255,255,.25);
--glas-blur: 24px; --glas-blur: 16px;
--glas-hell: #94b0cc; --glas-hell: #94b0cc;
--glas-tief: #116896; --glas-tief: #116896;
--glas-logo: .16; --glas-logo: .07;
} }
/* ---------- Tokens: dunkel (System und manuell) ---------- */ /* ---------- Tokens: dunkel (System und manuell) ---------- */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) { html.glas:not([data-theme="light"]) {
--bg: #0b141b; --bg: #111315;
--surface: rgba(30,46,60,.50); --surface: rgba(26,29,33,.80);
--surface-alt: rgba(30,46,60,.34); --surface-alt: rgba(26,29,33,.62);
--surface-sunk: rgba(255,255,255,.06); --surface-sunk: rgba(255,255,255,.05);
--line: rgba(255,255,255,.14); --line: rgba(236,238,240,.13);
--line-soft: rgba(255,255,255,.08); --line-soft: rgba(236,238,240,.07);
--accent: #8cc9e6; --accent: #86bfdf;
--accent-hover: #a3d4ec; --accent-hover: #9dcbe5;
--accent-active: #6fb3d3; --accent-active: #6aa9cc;
--accent-soft: rgba(140,201,230,.15); --accent-soft: rgba(134,191,223,.13);
--band-neu: #116896; --band-neu: #116896;
--text: #e8f0f5; --text: #eceef0;
--text-muted: #a9bccb; --text-muted: #a5abb2;
--text-subtle: #7790a2; --text-subtle: #878d95;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30); --sh-1: 0 1px 2px rgba(0,0,0,.4);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30); --sh-2: 0 4px 16px rgba(0,0,0,.45);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35); --sh-3: 0 16px 40px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58); --glas-rahmen: rgba(24,27,30,.84);
--glas-dicht: rgba(22,34,44,.90); --glas-dicht: rgba(22,24,27,.95);
--glas-deckend: #15212b; --glas-deckend: #181a1d;
--glas-kante: rgba(255,255,255,.18); --glas-kante: rgba(255,255,255,.12);
--glas-kante-2: rgba(255,255,255,.07); --glas-kante-2: rgba(255,255,255,.05);
--glas-hell: #2c5675; --glas-hell: #2c5675;
--glas-tief: #1d6f99; --glas-tief: #1d6f99;
--glas-logo: .10; --glas-logo: .06;
} }
} }
html.glas[data-theme="dark"] { html.glas[data-theme="dark"] {
--bg: #0b141b; --bg: #111315;
--surface: rgba(30,46,60,.50); --surface: rgba(26,29,33,.80);
--surface-alt: rgba(30,46,60,.34); --surface-alt: rgba(26,29,33,.62);
--surface-sunk: rgba(255,255,255,.06); --surface-sunk: rgba(255,255,255,.05);
--line: rgba(255,255,255,.14); --line: rgba(236,238,240,.13);
--line-soft: rgba(255,255,255,.08); --line-soft: rgba(236,238,240,.07);
--accent: #8cc9e6; --accent: #86bfdf;
--accent-hover: #a3d4ec; --accent-hover: #9dcbe5;
--accent-active: #6fb3d3; --accent-active: #6aa9cc;
--accent-soft: rgba(140,201,230,.15); --accent-soft: rgba(134,191,223,.13);
--band-neu: #116896; --band-neu: #116896;
--text: #e8f0f5; --text: #eceef0;
--text-muted: #a9bccb; --text-muted: #a5abb2;
--text-subtle: #7790a2; --text-subtle: #878d95;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30); --sh-1: 0 1px 2px rgba(0,0,0,.4);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30); --sh-2: 0 4px 16px rgba(0,0,0,.45);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35); --sh-3: 0 16px 40px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58); --glas-rahmen: rgba(24,27,30,.84);
--glas-dicht: rgba(22,34,44,.90); --glas-dicht: rgba(22,24,27,.95);
--glas-deckend: #15212b; --glas-deckend: #181a1d;
--glas-kante: rgba(255,255,255,.18); --glas-kante: rgba(255,255,255,.12);
--glas-kante-2: rgba(255,255,255,.07); --glas-kante-2: rgba(255,255,255,.05);
--glas-hell: #2c5675; --glas-hell: #2c5675;
--glas-tief: #1d6f99; --glas-tief: #1d6f99;
--glas-logo: .10; --glas-logo: .06;
} }
/* ---------- Hintergrund: Lichtflächen und Logo ---------- */ /* ---------- Hintergrund: Lichtflächen und Logo ---------- */
html.glas body { html.glas body {
background: background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%), radial-gradient(60% 55% at 8% 4%, color-mix(in srgb, var(--glas-hell) 26%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%), radial-gradient(50% 50% at 92% 12%, color-mix(in srgb, var(--glas-tief) 9%, 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); var(--bg);
background-attachment: fixed; background-attachment: fixed;
} }
@@ -157,6 +158,7 @@ html.glas .abw-team-kopf,
html.glas .report-card, html.glas .report-card,
html.glas .load-card, html.glas .load-card,
html.glas .settings-card, html.glas .settings-card,
html.glas .mn-notizen,
html.glas .dash-karte, html.glas .dash-karte,
html.glas .dash-band, html.glas .dash-band,
html.glas .bericht-panel, html.glas .bericht-panel,
@@ -181,8 +183,8 @@ html.glas .plan-toast,
html.glas .glocke-menue, html.glas .glocke-menue,
html.glas .wiz { html.glas .wiz {
background: var(--surface); background: var(--surface);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%); -webkit-backdrop-filter: blur(var(--glas-blur));
backdrop-filter: blur(var(--glas-blur)) saturate(170%); backdrop-filter: blur(var(--glas-blur));
border: 1px solid var(--glas-kante-2); 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); box-shadow: var(--sh-1), inset 0 1px 0 var(--glas-kante), inset 0 -1px 0 var(--glas-kante-2);
} }
@@ -191,7 +193,7 @@ html.glas .wiz {
html.glas .app-topbar { html.glas .app-topbar {
background: var(--glas-rahmen); background: var(--glas-rahmen);
border-width: 0 0 1px 0; border-width: 0 0 1px 0;
box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(21,58,86,.06); box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(16,24,32,.06);
} }
html.glas .topbar-logo { height: 26px; } html.glas .topbar-logo { height: 26px; }
@@ -248,11 +250,11 @@ html.glas .modal-bg {
margin: 12px 12px 0; margin: 12px 12px 0;
border-radius: var(--r-xl); border-radius: var(--r-xl);
background: var(--glas-rahmen); background: var(--glas-rahmen);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%); -webkit-backdrop-filter: blur(var(--glas-blur));
backdrop-filter: blur(var(--glas-blur)) saturate(170%); backdrop-filter: blur(var(--glas-blur));
border: 1px solid var(--glas-kante-2); border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante); box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
padding: 4px 12px 10px; padding: 4px 12px 12px;
} }
/* Kopf und Reiter sind innerhalb der Karte keine eigenen Flächen mehr. */ /* Kopf und Reiter sind innerhalb der Karte keine eigenen Flächen mehr. */
html.glas .plan-kopf .plan-header, html.glas .plan-kopf .plan-header,
@@ -265,19 +267,25 @@ html.glas .modal-bg {
} }
/* Inhalt bündig unter der Kopfkarte und im selben 12px-Abstand. */ /* 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-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-header { padding: 12px 12px 8px; }
html.glas .plan-kopf .plan-nav { padding: 0; gap: 2px; } html.glas .plan-kopf .plan-nav { padding: 0; gap: 2px; }
/* Reiter wie die Einträge der Seitenleiste (html.glas .nav-item.active):
32 px, helle Fläche mit Haarlinie, Schrift dunkel statt Akzentblau. */
html.glas .plan-kopf .plan-nav-item { html.glas .plan-kopf .plan-nav-item {
border-bottom: 0; border-bottom: 0;
border-radius: 999px; border-radius: 8px;
padding: 8px 16px; height: 32px;
font-size: 13.5px; padding: 0 12px;
font-size: 13px;
} }
html.glas .plan-kopf .plan-nav-item:hover { background: var(--surface-sunk); } html.glas .plan-kopf .plan-nav-item:hover { background: var(--surface-sunk); }
/* Der Kopf ist selbst eine helle Karte — eine weiße Kapsel wie in der
(grau durchscheinenden) Seitenleiste verschwände darauf. Deshalb hier
die umgekehrte Stufe: etwas dunkler als die Karte, mit Haarlinie. */
html.glas .plan-kopf .plan-nav-item.is-active { html.glas .plan-kopf .plan-nav-item.is-active {
background: var(--glas-dicht); background: rgba(16,24,32,.07);
color: var(--accent); color: var(--text);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10); box-shadow: inset 0 0 0 1px rgba(16,24,32,.08);
} }
} }
@media (min-width: 769px) and (prefers-color-scheme: dark) { @media (min-width: 769px) and (prefers-color-scheme: dark) {
@@ -293,13 +301,13 @@ html.glas .modal-bg {
Grau, auf allen anderen Seiten den Glas-Hintergrund. */ Grau, auf allen anderen Seiten den Glas-Hintergrund. */
html.glas .plan-main { background: transparent; } html.glas .plan-main { background: transparent; }
html.glas .nav-item { border-radius: 999px; } html.glas .nav-item { border-radius: 8px; }
html.glas .nav-item:hover, html.glas .nav-item:hover,
html.glas .nav-item:visited:hover { background: var(--surface-sunk); } html.glas .nav-item:visited:hover { background: var(--surface-sunk); }
html.glas .nav-item.active { html.glas .nav-item.active {
background: var(--glas-dicht); background: var(--glas-dicht);
color: var(--text); color: var(--text);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10); box-shadow: 0 0 0 1px var(--line-soft);
} }
html.glas .nav-item.active .nav-icon { color: var(--accent); } html.glas .nav-item.active .nav-icon { color: var(--accent); }
/* Die bestehenden Dunkel-Regeln für die Navigation setzen feste Werte — /* Die bestehenden Dunkel-Regeln für die Navigation setzen feste Werte —
@@ -373,9 +381,8 @@ html.glas .gantt-row.gantt-days .gantt-label { background: var(--gantt-label-gru
die (noch leere) App-Oberfläche, die nicht durchscheinen darf. */ die (noch leere) App-Oberfläche, die nicht durchscheinen darf. */
html.glas .login-seite { html.glas .login-seite {
background: background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%), radial-gradient(60% 55% at 8% 4%, color-mix(in srgb, var(--glas-hell) 26%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%), radial-gradient(50% 50% at 92% 12%, color-mix(in srgb, var(--glas-tief) 9%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
var(--bg); var(--bg);
} }
+7
View File
@@ -76,6 +76,13 @@ function adminNavAbgleichen(){
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]'); const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
if(item) item.hidden = !adminAbschnittErlaubt(sec); if(item) item.hidden = !adminAbschnittErlaubt(sec);
}); });
// Zwischenüberschriften (Personen/Vorgaben/System) nur, wenn darunter
// mindestens ein erlaubter Punkt steht.
document.querySelectorAll('#adminSubnav .sidebar-subgruppe').forEach(kopf => {
let el = kopf.nextElementSibling, sichtbar = false;
while(el && !el.classList.contains('sidebar-subgruppe')){ if(!el.hidden) sichtbar = true; el = el.nextElementSibling; }
kopf.hidden = !sichtbar;
});
} }
// adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen? // adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen?
+1 -1
View File
@@ -748,7 +748,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen. // renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
// Parameter: p (Projekt), locked. Rückgabe: HTML-String. // Parameter: p (Projekt), locked. Rückgabe: HTML-String.
function renderKommentarAnsicht(p, locked){ function renderKommentarAnsicht(p, locked){
const text = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || ''); const text = kommentarAnzeigeHtml(p, kommentarGewerk(), true);
return `<div class="status-edit is-ansicht locked-cell">${text || return `<div class="status-edit is-ansicht locked-cell">${text ||
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`; '<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
} }
+162 -71
View File
@@ -55,8 +55,6 @@ const ABSENCE_TYPE_CLASS = {
// renderDashboard()-Aufruf neu gestartet (vorheriges Intervall zuerst // renderDashboard()-Aufruf neu gestartet (vorheriges Intervall zuerst
// gestoppt), damit beim wiederholten Tab-Wechsel nicht mehrere Intervalle // gestoppt), damit beim wiederholten Tab-Wechsel nicht mehrere Intervalle
// parallel laufen. // parallel laufen.
let dashboardClockTimer = null;
// renderDashboard: lädt GET /my-dashboard und rendert Begrüßung/Uhr, // renderDashboard: lädt GET /my-dashboard und rendert Begrüßung/Uhr,
// Kennzahlen sowie die drei Bereiche in #dashboardContent. Wird nur beim // Kennzahlen sowie die drei Bereiche in #dashboardContent. Wird nur beim
// Wechsel auf den Tab aufgerufen (showTab()), nicht beim initialen // Wechsel auf den Tab aufgerufen (showTab()), nicht beim initialen
@@ -66,6 +64,9 @@ async function renderDashboard(){
if(!container) return; if(!container) return;
container.innerHTML = '<div class="hint">Lade Dashboard…</div>'; container.innerHTML = '<div class="hint">Lade Dashboard…</div>';
// Notizen je Projekt (Anzahl) parallel — fällt sie aus, fehlt nur die Angabe.
const notizenAnfrage = fetch(baseUrl() + '/projekt-notizen/anzahl')
.then(r => r.ok ? r.json() : null).catch(() => null);
let data; let data;
try { try {
const res = await fetch(baseUrl() + '/my-dashboard'); const res = await fetch(baseUrl() + '/my-dashboard');
@@ -76,6 +77,8 @@ async function renderDashboard(){
return; return;
} }
const nz = await notizenAnfrage;
dashNotizen = (nz && nz.ok && nz.anzahl) || {};
const weekLabel = data.weekLabel || null; const weekLabel = data.weekLabel || null;
const tasks = Array.isArray(data.tasks) ? data.tasks : []; const tasks = Array.isArray(data.tasks) ? data.tasks : [];
const projectDates = Array.isArray(data.projectDates) ? data.projectDates : []; const projectDates = Array.isArray(data.projectDates) ? data.projectDates : [];
@@ -103,12 +106,15 @@ async function renderDashboard(){
const heuteFaellig = tasks.filter(t => t.end === heute).length const heuteFaellig = tasks.filter(t => t.end === heute).length
+ projectDates.filter(d => d.heute).length; + projectDates.filter(d => d.heute).length;
const daten = dashDaten();
const sonntag = formatDateISO(new Date(dashWochenMontag().getTime() + 6 * 86400000));
const dieseWoche = daten.eintraege.filter(e => e.datum >= heute && e.datum <= sonntag).length;
let html = ''; let html = '';
html += renderDashboardKopf(weekLabel); html += renderDashboardKopf(weekLabel);
html += renderDashboardBand({ html += renderDashboardBand({
ueberfaellig, heuteFaellig, ueberfaellig, heuteFaellig, dieseWoche,
aufgaben: tasks.length, aufgaben: tasks.length,
termine: projectDates.length,
freigaben: pending.length, freigaben: pending.length,
abwesend: absencesToday, abwesend: absencesToday,
}); });
@@ -120,10 +126,10 @@ async function renderDashboard(){
// Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen, // Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen,
// weil es das ist, was sich seit dem letzten Blick geändert hat. // weil es das ist, was sich seit dem letzten Blick geändert hat.
html += '<div id="dashNeu"></div>'; html += '<div id="dashNeu"></div>';
html += renderDashWoche(daten);
html += '<div class="dash-zonen">'; html += '<div class="dash-zonen">';
// Linke Zone: die eigenen Projekte mit ihren Terminen // Linke Zone: die eigenen Projekte mit ihren Terminen
const daten = dashDaten();
html += '<div>'; html += '<div>';
const zuAlle = dashZuLesen(); const zuAlle = dashZuLesen();
const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p))); const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p)));
@@ -153,7 +159,6 @@ async function renderDashboard(){
if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); } if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); }
dashKalDaten = daten; dashKalDaten = daten;
renderDashKalender(); renderDashKalender();
startDashboardClock();
} }
// ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ---------- // ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ----------
@@ -164,6 +169,8 @@ async function renderDashboard(){
let dashKalDaten = null; let dashKalDaten = null;
let dashKalMonat = null; // { jahr, monat } (monat 0–11) let dashKalMonat = null; // { jahr, monat } (monat 0–11)
let dashKalTag = null; // ISO-Datum let dashKalTag = null; // ISO-Datum
// dashNotizen: { project_uid: {anzahl, zuletzt} } aus GET /projekt-notizen/anzahl.
let dashNotizen = {};
// dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte // dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte
// des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu, // des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu,
@@ -205,14 +212,14 @@ function dashDaten(){
}); });
(plan.milestones || []).forEach(m => { (plan.milestones || []).forEach(m => {
if(!m.date) return; if(!m.date) return;
termine.push({ art: 'meilenstein', titel: m.name || 'Meilenstein', datum: m.date, meins: false, wer: [] }); termine.push({ art: 'meilenstein', titel: m.name || 'Meilenstein', datum: m.date, meins: false, wer: [], erreicht: !!m.erreicht });
}); });
const hatEigene = termine.some(t => t.meins); const hatEigene = termine.some(t => t.meins);
if(!rollen.length && !hatEigene) return; if(!rollen.length && !hatEigene) return;
// Erledigte Vergangenheit interessiert hier nicht: Meilensteine vor heute // Erreichte Meilensteine fallen weg (im Kalender bleiben sie stehen);
// fallen weg (sie haben kein Erledigt-Kennzeichen), offene Aufgaben vor // nicht erreichte vor heute gelten wie offene Aufgaben als überfällig —
// heute bleiben als überfällig stehen. // dieselbe Regel wie die Zahl im Band (GET /my-dashboard).
const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.datum < heute)); const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.erreicht));
relevant.sort((a, b) => (a.datum || '9999').localeCompare(b.datum || '9999')); relevant.sort((a, b) => (a.datum || '9999').localeCompare(b.datum || '9999'));
projekte.push({ p, rollen, termine: relevant, alleTermine: termine }); projekte.push({ p, rollen, termine: relevant, alleTermine: termine });
}); });
@@ -253,6 +260,23 @@ function dashProjektUrl(p, abschnitt){
return url.toString(); return url.toString();
} }
// dashImTabOeffnen: öffnet den Projektbereich im SELBEN Tab (Eriks Wunsch),
// ohne die Seite neu zu laden. Strg/Cmd/Umschalt-Klick und Mittelklick
// behalten das Browser-Verhalten (neuer Tab), dafür bleibt das href stehen.
// Der Abschnitt (#aufgaben …) wird wie in springZuEinstellungen() VOR
// showTab('plan') gesetzt, dessen updateRouteUrl() ihn übernimmt.
function dashImTabOeffnen(ev, a){
if(ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return true;
ev.preventDefault();
const url = new URL(a.href);
const name = decodeURIComponent(url.pathname.split('/').pop() || '');
setPlanTarget(url.searchParams.get('weekLabel') || '', url.searchParams.get('projNr') || '', name);
location.hash = url.hash.slice(1) || 'dashboard';
showTab('plan');
window.scrollTo(0, 0);
return false;
}
// DASH_TERMINE_JE_PROJEKT: so viele Termine stehen je Projekt offen da, // DASH_TERMINE_JE_PROJEKT: so viele Termine stehen je Projekt offen da,
// Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst // Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst
// verdrängt ein Projekt mit 25 gerissenen Fristen alle anderen aus dem Blick // verdrängt ein Projekt mit 25 gerissenen Fristen alle anderen aus dem Blick
@@ -423,9 +447,8 @@ function renderDashProjekte(daten){
const key = dashProjKey(p); const key = dashProjKey(p);
const istZu = zu.has(key); const istZu = zu.has(key);
const rollen = x.rollen.length const rollen = x.rollen.length
? x.rollen.map(r => '<span class="dash-rolle' + (r.state === 2 ? ' ist-neben' : '') + '" title="' ? x.rollen.map(r => esc(r.rolle) + (r.state === 2 ? ', Unterstützung' : '')).join(' / ')
+ (r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung') + '">' + esc(r.rolle) + '</span>').join('') : 'nur eigene Aufgabe';
: '<span class="dash-rolle ist-neben" title="Nicht zugeordnet, aber mit eigener Aufgabe">nur Aufgabe</span>';
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0]; 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 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 const phase = aktLp
@@ -441,12 +464,12 @@ function renderDashProjekte(daten){
? sichtbar.map(t => renderDashTermin(p, t)).join('') ? sichtbar.map(t => renderDashTermin(p, t)).join('')
: '<div class="dash-leer">Keine offenen Termine.</div>'; : '<div class="dash-leer">Keine offenen Termine.</div>';
const mehr = rest > 0 const mehr = rest > 0
? '<a class="dash-proj-mehr" href="' + dashProjektUrl(p, 'aufgaben') + '" target="_blank" rel="noopener">' + rest + ' weitere Termine im Projekt</a>' ? '<a class="dash-proj-mehr" href="' + dashProjektUrl(p, 'aufgaben') + '" onclick="return dashImTabOeffnen(event, this)">' + rest + ' weitere Termine im Projekt</a>'
: ''; : '';
// Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele // Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele
// Termine überfällig bzw. offen sind. // Termine überfällig bzw. offen sind.
const kurz = (ueber.length ? '<span class="dash-proj-zahl ist-rot">' + ueber.length + ' überfällig</span>' : '') const kurz = (ueber.length ? '<span class="dash-proj-zahl ist-rot">' + ueber.length + ' überfällig</span>' : '')
+ '<span class="dash-proj-zahl">' + kommend.length + ' offen</span>'; + '<span class="dash-proj-zahl' + (kommend.length ? ' ist-offen' : '') + '">' + kommend.length + ' offen</span>';
return '<div class="dash-karte dash-proj' + (istZu ? ' ist-zu' : '') + '" data-key="' + esc(key) + '">' return '<div class="dash-karte dash-proj' + (istZu ? ' ist-zu' : '') + '" data-key="' + esc(key) + '">'
+ '<div class="dash-proj-kopf">' + '<div class="dash-proj-kopf">'
+ (daten.projekte.length > 1 ? '<span class="dash-proj-griff" tabindex="0" role="button" title="Ziehen, um die Reihenfolge zu ändern (oder Pfeiltasten)" aria-label="Reihenfolge ändern" onmousedown="dashGriffDruck(this)" ontouchstart="dashGriffDruck(this)" onkeydown="dashGriffTaste(event, this)">' + icon('grip') + '</span>' : '') + (daten.projekte.length > 1 ? '<span class="dash-proj-griff" tabindex="0" role="button" title="Ziehen, um die Reihenfolge zu ändern (oder Pfeiltasten)" aria-label="Reihenfolge ändern" onmousedown="dashGriffDruck(this)" ontouchstart="dashGriffDruck(this)" onkeydown="dashGriffTaste(event, this)">' + icon('grip') + '</span>' : '')
@@ -455,10 +478,10 @@ function renderDashProjekte(daten){
+ '<span class="dash-proj-nr">' + esc(p.nr || '') + '</span><span class="dash-proj-titel">' + esc(p.name || '') + '</span>' + '<span class="dash-proj-nr">' + esc(p.nr || '') + '</span><span class="dash-proj-titel">' + esc(p.name || '') + '</span>'
+ '</button>' + '</button>'
+ '<span class="dash-proj-kurz">' + kurz + '</span>' + '<span class="dash-proj-kurz">' + kurz + '</span>'
+ '<span class="dash-rollen">' + rollen + '</span>' + '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" onclick="return dashImTabOeffnen(event, this)" title="Projekt öffnen">Öffnen</a>'
+ '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" target="_blank" rel="noopener" title="Projekt öffnen">Öffnen</a>'
+ '</div>' + '</div>'
+ '<div class="dash-proj-inhalt">' + '<div class="dash-proj-inhalt">'
+ renderDashProjektInfo(x, rollen)
+ '<div class="dash-proj-sub">' + phase + '</div>' + '<div class="dash-proj-sub">' + phase + '</div>'
+ '<div class="dash-proj-termine">' + zeilen + mehr + '</div>' + '<div class="dash-proj-termine">' + zeilen + mehr + '</div>'
+ '</div>' + '</div>'
@@ -466,6 +489,103 @@ function renderDashProjekte(daten){
}).join('') + '</div>'; }).join('') + '</div>';
} }
// renderDashWoche: Band „Diese Woche“ (Entwurf „Bauplan“): Mo bis Fr als
// Spalten mit den Terminen der eigenen Projekte an diesem Tag — eigene
// Aufgaben, Aufgaben im Projekt, Meilensteine, Überfälliges. Je Tag höchstens
// DASH_WOCHE_JE_TAG, der Rest als „+ N weitere“. Parameter: daten (dashDaten()).
const DASH_WOCHE_JE_TAG = 4;
function renderDashWoche(daten){
if(!daten.geladen || !daten.projekte.length) return '';
const heute = heuteIso();
const mo = dashWochenMontag();
const TAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr'];
let spalten = '';
for(let i = 0; i < 5; i++){
const d = new Date(mo); d.setDate(mo.getDate() + i);
const iso = formatDateISO(d);
const e = daten.eintraege.filter(x => x.datum === iso)
.sort((a, b) => (b.meins ? 1 : 0) - (a.meins ? 1 : 0));
const sicht = e.slice(0, DASH_WOCHE_JE_TAG);
const rest = e.length - sicht.length;
spalten += '<div class="dash-woche-tag' + (iso === heute ? ' ist-heute' : '') + '">'
+ '<div class="dash-woche-kopf"><span>' + TAGE[i] + '</span><span class="dash-woche-datum">' + d.getDate() + '.' + (d.getMonth() + 1) + '.</span></div>'
+ '<div class="dash-woche-liste">'
+ (sicht.map(x => {
const art = x.art === 'meilenstein' ? 'ist-ms' : (x.datum < heute ? 'ist-ueber' : (x.meins ? 'ist-meins' : 'ist-fremd'));
return '<a class="dash-woche-eintrag ' + art + '" href="' + dashProjektUrl(x.projekt, x.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" onclick="return dashImTabOeffnen(event, this)"'
+ ' title="' + esc(x.titel + ' (' + [x.projekt.nr, x.projekt.name].filter(Boolean).join(' ') + ')') + '">'
+ '<span class="dash-woche-titel">' + esc(x.titel) + '</span>'
+ '<span class="dash-woche-projekt">' + esc([x.projekt.nr, x.projekt.name].filter(Boolean).join(' ')) + '</span></a>';
}).join('') || '<span class="dash-woche-leer">keine Termine</span>')
+ (rest > 0 ? '<span class="dash-woche-mehr">+ ' + rest + ' weitere</span>' : '')
+ '</div></div>';
}
const legende = '<div class="dash-woche-legende">'
+ '<span><i class="ist-meins"></i>meine Aufgabe</span><span><i class="ist-fremd"></i>Aufgabe im Projekt</span>'
+ '<span><i class="ist-ms"></i>Meilenstein</span><span><i class="ist-ueber"></i>überfällig</span></div>';
return dashAbschnitt('Diese Woche', null, 'eigene Aufgaben und Termine meiner Projekte',
'<div class="dash-karte dash-woche"><div class="dash-woche-rahmen"><div class="dash-woche-raster">' + spalten + '</div></div>' + legende + '</div>');
}
// renderDashProjektInfo: Zeile „Meine Rolle | Team | Notizen“ im Projektblock
// (Eriks Wunsch): die eigene Rolle, die übrigen Beteiligten als Kürzel-Kreise
// in den Rollenfarben (wie Projektliste, beteiligtenAvatarHtml()) und — nur
// wenn vorhanden — die Zahl der Notizen mit Sprung in den Reiter „Notizen“.
// Parameter: x (Eintrag aus dashDaten()), rollenText. Rückgabe: HTML-String.
const DASH_TEAM_MAX = 5;
function renderDashProjektInfo(x, rollenText){
const p = x.p;
const w = currentWeek();
const ich = currentUser && currentUser.personId;
const personen = {};
((w && w.people) || []).forEach(q => { personen[q.id] = q; });
const eintraege = [];
Object.keys(p.assign || {}).forEach(key => {
const [pid, role] = key.split('|');
const state = Number(p.assign[key]) || 0;
if(state > 0) eintraege.push({ person: personen[pid] || { id: pid, name: pid, fullName: pid }, role, state });
});
const alle = beteiligteJePerson(eintraege);
const ichB = alle.find(b => b.person.id === ich);
const team = alle.filter(b => b.person.id !== ich);
const kreis = b => beteiligtenAvatarHtml(b, typeof roleClass === 'function' ? roleClass(b.role) : '');
const kurzName = b => {
const teile = String(b.person.fullName || b.person.name || '').trim().split(/\s+/);
return teile.length > 1 ? teile[0] + ' ' + teile[teile.length - 1][0] + '.' : teile[0];
};
const namen = team.length > 2
? team.slice(0, 2).map(kurzName).join(', ') + ' +' + (team.length - 2)
: team.map(kurzName).join(', ');
let html = '<div class="dash-proj-info">'
+ '<div class="dash-proj-feld"><span class="dash-proj-feld-titel">Meine Rolle</span>'
+ '<span class="dash-proj-feld-wert">' + (ichB ? kreis(ichB) : '') + '<span>' + rollenText + '</span></span></div>'
+ '<div class="dash-proj-feld"><span class="dash-proj-feld-titel">Team</span>'
+ '<span class="dash-proj-feld-wert">'
+ (team.length
? '<span class="dash-proj-team">' + team.slice(0, DASH_TEAM_MAX).map(kreis).join('') + '</span><span class="dash-proj-namen" title="' + esc(team.map(b => b.title).join('\n')) + '">' + esc(namen) + '</span>'
: '<span class="dash-proj-namen">niemand weiter</span>')
+ '</span></div>';
const n = dashNotizen[p.projectUid];
if(n && n.anzahl > 0){
html += '<a class="dash-proj-feld dash-proj-notizen" href="' + dashProjektUrl(p, 'notizen') + '" onclick="return dashImTabOeffnen(event, this)">'
+ '<span class="dash-proj-feld-titel">Notizen</span>'
+ '<span class="dash-proj-feld-wert">' + icon('fileText') + '<span><b>' + n.anzahl + '</b> ' + (n.anzahl === 1 ? 'Notiz' : 'Notizen') + '</span>'
+ (n.zuletzt ? '<span class="dash-proj-zuletzt">' + esc(dashZuletzt(n.zuletzt)) + '</span>' : '') + '</span></a>';
}
return html + '</div>';
}
// dashZuletzt: „zuletzt heute/gestern“ oder „vom 28.10.“ zu einem Zeitstempel.
function dashZuletzt(zeit){
const d = new Date(zeit);
if(isNaN(d)) return '';
const tage = -tageBis(formatDateISO(d));
if(tage <= 0) return 'zuletzt heute';
if(tage === 1) return 'zuletzt gestern';
return 'vom ' + d.getDate() + '.' + (d.getMonth() + 1) + '.';
}
// renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste // renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste
// des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe, // des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe,
// fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute. // fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute.
@@ -480,7 +600,7 @@ function renderDashTermin(p, t, mitProjekt){
const unter = mitProjekt const unter = mitProjekt
? (p.nr ? p.nr + ' ' : '') + (p.name || '') ? (p.nr ? p.nr + ' ' : '') + (p.name || '')
: (t.art === 'meilenstein' ? 'Meilenstein' : (t.meins ? 'meine Aufgabe' : (t.wer.length ? t.wer.join(', ') : 'niemand zugewiesen'))); : (t.art === 'meilenstein' ? 'Meilenstein' : (t.meins ? 'meine Aufgabe' : (t.wer.length ? t.wer.join(', ') : 'niemand zugewiesen')));
return '<a class="dash-termin" href="' + dashProjektUrl(p, t.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" target="_blank" rel="noopener">' return '<a class="dash-termin" href="' + dashProjektUrl(p, t.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" onclick="return dashImTabOeffnen(event, this)">'
+ '<span class="dash-termin-art ' + klasse + '">' + zeichen + '</span>' + '<span class="dash-termin-art ' + klasse + '">' + zeichen + '</span>'
+ '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(t.titel) + '</span>' + '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(t.titel) + '</span>'
+ '<span class="dash-termin-meta">' + esc(unter) + '</span></span>' + '<span class="dash-termin-meta">' + esc(unter) + '</span></span>'
@@ -576,22 +696,30 @@ function dashKalBlaettern(n){
renderDashKalender(); renderDashKalender();
} }
// renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein // renderDashboardKopf: Titel und Kalenderwoche statt Begrüßung (Entwurf
// rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist // „Bauplan“, Eriks Wahl): „KW 45, Mo 2. bis Fr 6. November 2026“, rechts die
// die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand. // Sitzung. Parameter: weekLabel. Rückgabe: HTML-String.
// Die Inhalte füllt startDashboardClock() live nach.
// Parameter: weekLabel. Rückgabe: HTML-String.
function renderDashboardKopf(weekLabel){ function renderDashboardKopf(weekLabel){
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const mo = dashWochenMontag();
const fr = new Date(mo); fr.setDate(mo.getDate() + 4);
const zeile = 'KW ' + getWeekNumber(mo) + ', Mo ' + mo.getDate() + '.'
+ (mo.getMonth() !== fr.getMonth() ? ' ' + MONATE[mo.getMonth()] : '')
+ ' bis Fr ' + fr.getDate() + '. ' + MONATE[fr.getMonth()] + ' ' + fr.getFullYear();
return '<div class="dash-kopf">' return '<div class="dash-kopf">'
+ '<p class="dash-gruss"><span id="dashboardGreetingText"></span>' + '<div class="dash-kopf-links"><span class="dash-kopf-kw">' + esc(zeile) + '</span>'
+ '<em id="dashboardGreetingDate"></em></p>' + '<h2 class="dash-titel">Meine Aufgaben</h2></div>'
+ '<div class="dash-kopf-rechts">' + (weekLabel ? '<div class="dash-kopf-rechts"><span class="dash-kopf-label">Sitzung</span><span class="dash-sitzung">' + esc(weekLabel) + '</span></div>' : '')
+ (weekLabel ? '<span class="dash-sitzung">' + esc(weekLabel) + '</span>' : '')
+ '<span class="dash-uhr" id="dashboardClock"></span>'
+ '</div>'
+ '</div>'; + '</div>';
} }
// dashWochenMontag: Montag der laufenden Woche (lokale Zeit, 0 Uhr).
function dashWochenMontag(){
const d = parseDateISO(heuteIso());
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
return d;
}
// renderDashboardBand: der Befund des Tages als Satz, daneben die Kennzahlen. // renderDashboardBand: der Befund des Tages als Satz, daneben die Kennzahlen.
// //
// Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine // Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine
@@ -617,17 +745,17 @@ function renderDashboardBand(z){
satz = '<b class="ist-ruhig">Nichts offen, nichts überfällig.</b>'; satz = '<b class="ist-ruhig">Nichts offen, nichts überfällig.</b>';
} }
const zahl = (wert, label) => const zahl = (wert, label, rot) =>
'<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + '"><b>' + wert + '</b><span>' + label + '</span></div>'; '<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + (rot ? ' ist-rot' : '') + '"><span>' + label + '</span><b>' + wert + '</b></div>';
const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0; const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0;
return '<div class="dash-band' + (ruhig ? ' ist-ruhig' : '') + '">' return '<div class="dash-band' + (ruhig ? ' ist-ruhig' : '') + '">'
+ '<p class="dash-befund">' + satz + '</p>' + '<p class="dash-befund">' + satz + '</p>'
+ '<div class="dash-band-zahlen">' + '<div class="dash-band-zahlen">'
+ zahl(z.aufgaben, 'offene Aufgaben') + zahl(z.aufgaben, 'offene Aufgaben')
+ zahl(z.termine, 'Termine im Blick') + zahl(z.ueberfaellig, 'überfällig', z.ueberfaellig > 0)
+ zahl(z.dieseWoche, 'diese Woche fällig')
+ zahl(z.freigaben, 'Freigaben') + zahl(z.freigaben, 'Freigaben')
+ zahl(z.abwesend, 'heute abwesend')
+ '</div></div>'; + '</div></div>';
} }
@@ -649,43 +777,6 @@ function dashLeer(text){
return '<div class="dash-karte"><div class="dash-leer">' + esc(text) + '</div></div>'; return '<div class="dash-karte"><div class="dash-leer">' + esc(text) + '</div></div>';
} }
// greetingForHour:// greetingForHour: liefert je Tageszeit eine passende Begrüßung. Parameter:
// hour (Number, 0-23). Rückgabe: String.
function greetingForHour(hour){
if(hour < 5) return 'Gute Nacht';
if(hour < 11) return 'Guten Morgen';
if(hour < 18) return 'Guten Tag';
if(hour < 22) return 'Guten Abend';
return 'Gute Nacht';
}
// startDashboardClock: (re-)startet das Intervall, das Begrüßung, Datum und
// Uhrzeit im Dashboard-Header aktualisiert. Bricht sich selbst ab, sobald die
// Zielelemente nicht mehr im DOM sind (z. B. weil das Dashboard neu gerendert
// wurde). Keine Parameter, keine Rückgabe.
function startDashboardClock(){
if(dashboardClockTimer) clearInterval(dashboardClockTimer);
const name = (currentUser && (currentUser.displayName || currentUser.username)) || '';
const tick = () => {
const textEl = document.getElementById('dashboardGreetingText');
const dateEl = document.getElementById('dashboardGreetingDate');
const clockEl = document.getElementById('dashboardClock');
if(!textEl || !dateEl || !clockEl){
clearInterval(dashboardClockTimer);
dashboardClockTimer = null;
return;
}
const now = new Date();
// Begrüßung und Datum stehen in EINER Zeile (früher untereinander), das
// Datum als gedämpfter Zusatz — deshalb der führende Gedankenstrich.
textEl.textContent = greetingForHour(now.getHours()) + (name ? ', ' + name : '');
dateEl.textContent = ' — ' + now.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
clockEl.textContent = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
};
tick();
dashboardClockTimer = setInterval(tick, 30000);
}
// relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem // relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem
// ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht // ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht
// sagt, ob das morgen oder in drei Wochen ist. // sagt, ob das morgen oder in drei Wochen ist.
+1 -1
View File
@@ -44,7 +44,7 @@ const EINFUEHRUNG_SCHRITTE = [
text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen <b>Rundgang jederzeit neu starten</b>.' }, text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen <b>Rundgang jederzeit neu starten</b>.' },
{ id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin', { 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(', ') : '') + '.' }, 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!', { id: 'ende', tab: 'dashboard', titel: 'Rundgang abgeschlossen',
text: () => { text: () => {
const hinweise = []; const hinweise = [];
if(currentUser && !currentUser.personId){ if(currentUser && !currentUser.personId){
+1
View File
@@ -147,6 +147,7 @@ const TAB_SLUGS = {
wochenbericht: 'Wochenbericht', wochenbericht: 'Wochenbericht',
load: 'Auslastung', load: 'Auslastung',
abwesenheit: 'Abwesenheiten', abwesenheit: 'Abwesenheiten',
notizen: 'Notizen',
people: 'Projektbeteiligte', people: 'Projektbeteiligte',
planung: 'Planung', planung: 'Planung',
planchecker: 'PlanChecker', planchecker: 'PlanChecker',
+408
View File
@@ -0,0 +1,408 @@
// -----------------------------
// Seitenleiste „Notizen“ (Tab `notizen`, /Notizen)
// -----------------------------
// Eriks Wunsch: Jedes Konto sieht hier die Notizen ALLER seiner Projekte und
// legt auch projektunabhängige Notizen an, die sich beim Bearbeiten einem
// Projekt zuordnen lassen. Gleicher Aufbau wie der Reiter „Notizen“ im
// Projektbereich (js/plan/plan-notizen.js: Liste + Editor, Werkzeugleiste,
// automatisches Speichern), dazu ein Projektfilter und im Editor die Auswahl
// „Projekt“. Eigene Element-ids (mn…), weil der Projekt-Reiter gleichzeitig
// im DOM liegt.
//
// Daten: GET /meine-notizen (Notizen meiner Projekte + meine
// projektunabhängigen, project_uid = ''), sonst dieselben Routen
// /projekt-notizen* wie der Projekt-Reiter; PUT nimmt `uid` zum Zuordnen.
// Projektunabhängige Notizen sieht und ändert nur, wer sie angelegt hat.
(function(){
const SPEICHER_PAUSE_MS = 1200;
const OHNE = '__ohne__';
let liste = [];
let meineProjekte = new Set();
let geladen = false;
let ladeFehler = '';
let filter = ''; // '' = alle, OHNE = ohne Projekt, sonst uid
let suche = '';
let aktivId = null;
let basis = '';
let ungespeichert = false;
let speicherTimer = null;
let speichertGerade = false;
let beobachter = null;
let loeschenFragt = null;
let eigenesSchreiben = 0; // Zeitpunkt der letzten eigenen Änderung (Live-Echo auslassen)
const el = () => document.getElementById('notizen');
const aktiv = () => liste.find(n => n.id === aktivId) || null;
const darf = n => !n || !n.uid ? true : (typeof hatRecht === 'function' && hatRecht('proj.plan'));
const darfProjekte = () => typeof hatRecht === 'function' && hatRecht('proj.plan');
// Projekte der aktuellen Sitzung je projectUid (Name und Nummer).
function projekte(){
const w = typeof currentWeek === 'function' ? currentWeek() : null;
const m = new Map();
((w && w.projects) || []).forEach(p => { if(p.projectUid) m.set(String(p.projectUid), p); });
return m;
}
function projektName(uid, karte){
if(!uid) return 'Ohne Projekt';
const p = (karte || projekte()).get(uid);
return p ? [p.nr, p.name].filter(Boolean).join(' ') : 'Projekt nicht in der aktuellen Sitzung';
}
function zeit(iso){
if(!iso) return '';
const d = new Date(iso);
if(isNaN(d)) return '';
const heute = new Date();
const gestern = new Date(heute); gestern.setDate(heute.getDate() - 1);
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
if(d.toDateString() === heute.toDateString()) return 'heute ' + uhr;
if(d.toDateString() === gestern.toDateString()) return 'gestern ' + uhr;
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: d.getFullYear() === heute.getFullYear() ? undefined : 'numeric' });
}
const klartext = html => typeof statusPlainText === 'function' ? statusPlainText(html) : String(html || '').replace(/<[^>]+>/g, ' ');
function sortieren(){
liste.sort((a, b) => (b.angeheftet - a.angeheftet) || String(b.geaendertAm).localeCompare(String(a.geaendertAm)));
}
// ---------- Laden ----------
function laden(){
ladeFehler = '';
return fetch(baseUrl() + '/meine-notizen')
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(!ok || !d.ok) throw new Error(d.error || 'Laden fehlgeschlagen');
liste = d.notizen || [];
meineProjekte = new Set(d.meineProjekte || []);
sortieren();
geladen = true;
})
.catch(e => { ladeFehler = e.message || 'Die Notizen konnten nicht geladen werden.'; liste = []; geladen = true; });
}
// ---------- Darstellung ----------
window.renderMeineNotizen = function(){
const box = el();
if(!box) return;
box.innerHTML = '<div class="notizen-laedt">Notizen werden geladen…</div>';
jetztSpeichern();
laden().then(aufbauen);
};
function sichtbar(){
const s = suche.trim().toLowerCase();
return liste.filter(n => (!filter || (filter === OHNE ? !n.uid : n.uid === filter))
&& (!s || (n.titel + ' ' + klartext(n.html) + ' ' + projektName(n.uid)).toLowerCase().includes(s)));
}
function filterOptionen(){
const karte = projekte();
const mitNotizen = new Set(liste.filter(n => n.uid).map(n => n.uid));
const uids = Array.from(new Set([...meineProjekte, ...mitNotizen]))
.sort((a, b) => projektName(a, karte).localeCompare(projektName(b, karte), 'de'));
const zahl = f => liste.filter(n => f === OHNE ? !n.uid : n.uid === f).length;
return '<option value="">Alle Projekte (' + liste.length + ')</option>'
+ '<option value="' + OHNE + '"' + (filter === OHNE ? ' selected' : '') + '>Ohne Projekt (' + zahl(OHNE) + ')</option>'
+ uids.map(u => '<option value="' + esc(u) + '"' + (filter === u ? ' selected' : '') + '>' + esc(projektName(u, karte)) + ' (' + zahl(u) + ')</option>').join('');
}
function aufbauen(){
const box = el();
if(!box) return;
const treffer = sichtbar();
if(!treffer.some(n => n.id === aktivId)) aktivId = treffer.length ? treffer[0].id : null;
box.innerHTML = `
<div class="mn-seite">
<div class="mn-kopf">
<div class="mn-kopf-links">
<span class="mn-kopf-unter">Notizen aller meiner Projekte und eigene Notizen ohne Projekt</span>
<h2 class="mn-titel">Notizen</h2>
</div>
<label class="mn-filter">Projekt
<select id="mnFilter" onchange="mnFilter(this.value)">${filterOptionen()}</select>
</label>
</div>
<div class="notizen mn-notizen">
<aside class="notizen-liste">
<div class="notizen-suchzeile">
<input type="search" id="mnSuche" class="notizen-suche" placeholder="Notizen durchsuchen…"
aria-label="Notizen durchsuchen" value="${esc(suche)}" oninput="mnSuchen(this.value)">
<button type="button" class="notizen-neu" onclick="mnNeu()">+ Neu</button>
</div>
<div id="mnEintraege" class="notizen-eintraege"></div>
</aside>
<div class="notizen-editor" id="mnEditor"></div>
</div>
</div>`;
listeZeichnen();
editorZeichnen();
}
function listeZeichnen(){
const box = document.getElementById('mnEintraege');
if(!box) return;
if(ladeFehler){ box.innerHTML = '<div class="notizen-leer ist-fehler">' + esc(ladeFehler) + '</div>'; return; }
const treffer = sichtbar();
if(!liste.length){ box.innerHTML = '<div class="notizen-leer">Noch keine Notizen. Mit „+ Neu“ eine eigene anlegen.</div>'; return; }
if(!treffer.length){ box.innerHTML = '<div class="notizen-leer">' + (suche ? 'Keine Notiz enthält „' + esc(suche) + '“.' : 'Keine Notizen in dieser Auswahl.') + '</div>'; return; }
const karte = projekte();
const zeile = n => {
const vorschau = klartext(n.html).replace(/\s+/g, ' ').trim().slice(0, 90);
return `<button type="button" class="notizen-eintrag${n.id === aktivId ? ' ist-aktiv' : ''}" onclick="mnWaehlen('${esc(n.id)}')">
<span class="notizen-eintrag-titel">${esc(n.titel || 'Ohne Titel')}${n.quelle === 'mail' ? ' <span class="notizen-chip">Mail</span>' : ''}</span>
<span class="notizen-eintrag-text">${esc(vorschau || 'Kein Text')}</span>
<span class="notizen-eintrag-meta"><span class="mn-projekt${n.uid ? '' : ' ist-ohne'}">${esc(projektName(n.uid, karte))}</span> · ${esc(zeit(n.geaendertAm))}</span>
</button>`;
};
const oben = treffer.filter(n => n.angeheftet), rest = treffer.filter(n => !n.angeheftet);
box.innerHTML = (oben.length ? '<div class="notizen-gruppe"><span class="notizen-pin-punkt"></span>Angeheftet</div>' + oben.map(zeile).join('') : '')
+ (rest.length ? (oben.length ? '<div class="notizen-gruppe">Weitere</div>' : '') + rest.map(zeile).join('') : '');
}
// projektAuswahl: Auswahl im Editor — „Ohne Projekt“, meine Projekte, dann
// die übrigen der Sitzung. Ohne Recht proj.plan nur „Ohne Projekt“ (und das
// aktuelle Projekt, damit der Stand lesbar bleibt).
function projektAuswahl(n){
const karte = projekte();
const name = u => projektName(u, karte);
const sort = arr => arr.sort((a, b) => name(a).localeCompare(name(b), 'de'));
const opt = u => '<option value="' + esc(u) + '"' + (n.uid === u ? ' selected' : '') + '>' + esc(name(u)) + '</option>';
const meine = sort(Array.from(meineProjekte).filter(u => karte.has(u)));
const weitere = sort(Array.from(karte.keys()).filter(u => !meineProjekte.has(u)));
const sperren = !darf(n) || (!darfProjekte() && !n.uid);
let html = '<option value=""' + (!n.uid ? ' selected' : '') + '>Ohne Projekt</option>';
if(darfProjekte()){
if(meine.length) html += '<optgroup label="Meine Projekte">' + meine.map(opt).join('') + '</optgroup>';
if(weitere.length) html += '<optgroup label="Weitere Projekte">' + weitere.map(opt).join('') + '</optgroup>';
}
if(n.uid && !karte.has(n.uid)) html += opt(n.uid);
return '<label class="mn-zuordnung">Projekt <select id="mnProjekt" onchange="mnZuordnen(this.value)"'
+ (sperren ? ' disabled title="' + (darfProjekte() ? '' : 'Zum Zuordnen fehlt das Recht, den Terminplan zu bearbeiten.') + '"' : '') + '>' + html + '</select></label>';
}
function editorZeichnen(){
const box = document.getElementById('mnEditor');
if(!box) return;
if(beobachter){ beobachter.disconnect(); beobachter = null; }
const n = aktiv();
if(!n){
box.innerHTML = `<div class="notizen-editor-leer">
<b>${liste.length ? 'Notiz links auswählen' : 'Noch keine Notizen'}</b>
<span>Mit „+ Neu“ eine Notiz anlegen. Sie gehört zunächst nur Ihnen und lässt sich oben rechts einem Projekt zuordnen.</span>
</div>`;
return;
}
const schreiben = darf(n);
basis = n.geaendertAm;
ungespeichert = false;
const projekt = n.uid ? projekte().get(n.uid) : null;
box.innerHTML = `
<div class="notizen-editor-kopf">
<input type="text" id="mnTitel" class="notizen-titel" value="${esc(n.titel)}" placeholder="Titel der Notiz"
aria-label="Titel der Notiz" ${schreiben ? '' : 'readonly'} oninput="mnGeaendert()" onblur="mnJetztSpeichern()">
${schreiben ? `<button type="button" class="notizen-symbol${n.angeheftet ? ' ist-an' : ''}" onclick="mnAnheften()"
aria-pressed="${n.angeheftet}">${n.angeheftet ? 'Angeheftet' : 'Anheften'}</button>
<button type="button" class="notizen-symbol ist-gefahr" id="mnLoeschen" onclick="mnLoeschen()">Löschen</button>` : ''}
</div>
<div class="mn-editor-zeile">
${projektAuswahl(n)}
${projekt ? `<a class="mn-projekt-link" href="#" onclick="return mnProjektOeffnen('${esc(projekt.id)}')">Im Projekt öffnen</a>` : ''}
${!n.uid ? '<span class="mn-hinweis">Nur für Sie sichtbar, bis sie einem Projekt zugeordnet ist</span>' : ''}
</div>
<div class="richtext-wrap notizen-richtext">
${schreiben ? richtextToolbarHtml('#mnText') : ''}
${richtextFeldHtml('notizen-text', sanitizeStatusHtml(n.html || ''), 'Notiz schreiben…', 'id="mnText" onblur="mnJetztSpeichern()"', schreiben)}
</div>
<div class="notizen-fuss">
<span>Angelegt von ${esc(n.erstelltVon)} ${esc(zeit(n.erstelltAm))}${n.geaendertAm !== n.erstelltAm ? ' · zuletzt geändert von ' + esc(n.geaendertVon) + ' ' + esc(zeit(n.geaendertAm)) : ''}</span>
<span id="mnStatus" class="notizen-status"></span>
</div>`;
const text = document.getElementById('mnText');
if(text && schreiben){
beobachter = new MutationObserver(() => mnGeaendert());
beobachter.observe(text, { childList: true, subtree: true, characterData: true, attributes: true });
}
}
function status(text, art){
const s = document.getElementById('mnStatus');
if(!s) return;
s.className = 'notizen-status' + (art ? ' ist-' + art : '');
s.innerHTML = text;
}
function hinweis(text){ if(typeof zeigeHinweis === 'function') zeigeHinweis(text); else status(esc(text), 'fehler'); }
// ---------- Bedienung ----------
window.mnFilter = function(wert){ jetztSpeichern(); filter = wert || ''; aufbauen(); };
window.mnSuchen = function(wert){ suche = wert || ''; listeZeichnen(); };
window.mnWaehlen = function(id){
if(id === aktivId) return;
jetztSpeichern();
aktivId = id;
listeZeichnen();
editorZeichnen();
};
window.mnGeaendert = function(){
const n = aktiv();
if(!n || !darf(n)) return;
ungespeichert = true;
status('Ungespeichert');
clearTimeout(speicherTimer);
speicherTimer = setTimeout(jetztSpeichern, SPEICHER_PAUSE_MS);
};
window.mnJetztSpeichern = function(){ jetztSpeichern(); };
function senden(n, nutzlast){
eigenesSchreiben = Date.now();
return fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(nutzlast)
}).then(r => r.json().then(d => ({ r, d })));
}
function jetztSpeichern(ohneBasis){
clearTimeout(speicherTimer);
const n = aktiv();
if(!n || !ungespeichert || !darf(n)) return Promise.resolve();
if(speichertGerade){ speicherTimer = setTimeout(jetztSpeichern, 400); return Promise.resolve(); }
const titelEl = document.getElementById('mnTitel');
const textEl = document.getElementById('mnText');
const titel = titelEl ? titelEl.value : n.titel;
const html = textEl ? textEl.innerHTML : n.html;
ungespeichert = false;
speichertGerade = true;
status('Speichert…');
return senden(n, { titel, html, basis: ohneBasis ? '' : basis }).then(({ r, d }) => {
if(r.status === 409 && d.konflikt){
ungespeichert = true;
status(esc(d.error) + ' <button type="button" class="notizen-link" onclick="mnKonflikt(true)">Meine Fassung speichern</button>'
+ ' <button type="button" class="notizen-link" onclick="mnKonflikt(false)">Neuen Stand laden</button>', 'warnung');
n._fremd = d.notiz;
return;
}
if(!r.ok || !d.ok) throw new Error(d.error || 'Speichern fehlgeschlagen');
Object.assign(n, d.notiz, { titel, html });
if(n.id === aktivId) basis = d.notiz.geaendertAm;
sortieren();
listeZeichnen();
if(!ungespeichert) status('Gespeichert ✓', 'ok');
}).catch(e => {
ungespeichert = true;
status('Nicht gespeichert: ' + esc(e.message) + ' — wird erneut versucht.', 'fehler');
speicherTimer = setTimeout(jetztSpeichern, 5000);
}).finally(() => { speichertGerade = false; });
}
window.mnKonflikt = function(meine){
const n = aktiv();
if(!n) return;
if(meine){ ungespeichert = true; jetztSpeichern(true); return; }
if(n._fremd) Object.assign(n, n._fremd);
delete n._fremd;
ungespeichert = false;
listeZeichnen();
editorZeichnen();
};
// mnNeu: neue Notiz — im gefilterten Projekt, sonst ohne Projekt.
window.mnNeu = function(){
jetztSpeichern();
const uid = filter && filter !== OHNE && darfProjekte() ? filter : '';
eigenesSchreiben = Date.now();
fetch(baseUrl() + '/projekt-notizen', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ uid, titel: '', html: '' })
}).then(r => r.json()).then(d => {
if(!d.ok) throw new Error(d.error || 'Anlegen fehlgeschlagen');
liste = liste.filter(x => x.id !== d.notiz.id).concat([d.notiz]);
sortieren();
aktivId = d.notiz.id;
suche = '';
aufbauen();
const t = document.getElementById('mnTitel');
if(t) t.focus();
}).catch(e => hinweis('Notiz konnte nicht angelegt werden: ' + e.message));
};
// mnZuordnen: Notiz einem Projekt zuordnen (oder wieder lösen). Offene
// Änderungen werden vorher gespeichert.
window.mnZuordnen = function(uid){
const n = aktiv();
if(!n) return;
jetztSpeichern().then(() => senden(n, { uid: uid || '' })).then(({ r, d }) => {
if(!r.ok || !d.ok) throw new Error(d.error || 'Zuordnen fehlgeschlagen');
Object.assign(n, d.notiz);
if(n.uid) meineProjekte.add(n.uid);
if(filter && filter !== (n.uid || OHNE)) filter = n.uid || OHNE;
aufbauen();
status(n.uid ? 'Dem Projekt zugeordnet ✓' : 'Vom Projekt gelöst ✓', 'ok');
}).catch(e => { hinweis(e.message); editorZeichnen(); });
};
window.mnAnheften = function(){
const n = aktiv();
if(!n) return;
jetztSpeichern().then(() => senden(n, { angeheftet: !n.angeheftet })).then(({ d }) => {
if(!d.ok) throw new Error(d.error || 'Fehler');
n.angeheftet = d.notiz.angeheftet;
sortieren();
listeZeichnen();
editorZeichnen();
}).catch(e => hinweis('Anheften fehlgeschlagen: ' + e.message));
};
window.mnLoeschen = function(){
const n = aktiv();
const knopf = document.getElementById('mnLoeschen');
if(!n || !knopf) return;
if(!loeschenFragt){
knopf.textContent = 'Wirklich löschen?';
knopf.classList.add('ist-fragend');
loeschenFragt = setTimeout(() => {
loeschenFragt = null;
const k = document.getElementById('mnLoeschen');
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
}, 4000);
return;
}
clearTimeout(loeschenFragt); loeschenFragt = null;
clearTimeout(speicherTimer); ungespeichert = false;
eigenesSchreiben = Date.now();
fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), { method: 'DELETE' })
.then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
if(!r.ok || !d.ok) throw new Error(d.error || 'Löschen fehlgeschlagen');
liste = liste.filter(x => x.id !== n.id);
aktivId = null;
aufbauen();
}).catch(e => { status(esc(e.message), 'fehler'); const k = document.getElementById('mnLoeschen'); if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); } });
};
window.mnProjektOeffnen = function(pid){
jetztSpeichern();
location.hash = 'notizen';
if(typeof openProjectPlan === 'function') openProjectPlan(pid);
return false;
};
// ---------- Live: Änderungen aus anderen Browsern ----------
window.meineNotizenLive = function(uid){
if(typeof currentTab === 'undefined' || currentTab !== 'notizen' || !geladen) return;
if(uid && !meineProjekte.has(uid)) return;
const vorher = aktiv();
const tippt = document.activeElement && document.activeElement.closest && document.activeElement.closest('#mnEditor');
// Das Echo der eigenen Speicherung (kommt auch in diesem Tab an) auslassen.
if(ungespeichert || tippt || Date.now() - eigenesSchreiben < 3000) return;
laden().then(() => {
if(vorher && liste.some(x => x.id === vorher.id)) aktivId = vorher.id;
aufbauen();
});
};
// Klick irgendwo anders: die Rückfrage „Wirklich löschen?“ zurücknehmen.
document.addEventListener('pointerdown', e => {
if(!loeschenFragt || (e.target.closest && e.target.closest('#mnLoeschen'))) return;
clearTimeout(loeschenFragt); loeschenFragt = null;
const k = document.getElementById('mnLoeschen');
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
}, true);
window.addEventListener('pagehide', () => { if(ungespeichert) jetztSpeichern(); });
})();
+31 -2
View File
@@ -518,13 +518,14 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
color: '#D70015', margin: [0, 6, 0, 0] }); color: '#D70015', margin: [0, 6, 0, 0] });
} }
const innerContent = { const aufgaben = pdfWbAufgaben(p, w, prevWeek);
const innerContent = { stack: [{
columns: [ columns: [
{ width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) }, { width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) },
{ width: 190, stack: rightStack } { width: 190, stack: rightStack }
], ],
columnGap: 12 columnGap: 12
}; }].concat(aufgaben ? [aufgaben] : []) };
return { return {
table: { table: {
@@ -548,6 +549,34 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
}; };
} }
// pdfWbAufgaben: die Aufgaben der Akte wie auf dem Bildschirm
// (berichtAufgabenDaten() in wochenbericht.js) — erledigt seit der Vorwoche,
// überfällig, nächste zwei Wochen, je höchstens fünf. Rückgabe: pdfmake-Block
// oder null, wenn in keiner Gruppe etwas steht.
function pdfWbAufgaben(p, w, prevWeek){
if(typeof berichtAufgabenDaten !== 'function') return null;
const d = berichtAufgabenDaten(p, w, prevWeek);
const gruppen = [
[prevWeek ? 'Erledigt seit ' + berichtKurzKw(prevWeek.label) : 'Erledigt', d.erledigt, null],
['Überfällig', d.ueberfaellig, '#D70015'],
['Nächste zwei Wochen', d.bald, null]
].filter(g => g[1].length);
if(!gruppen.length) return null;
const body = [];
gruppen.forEach(([titel, liste, farbe]) => {
liste.slice(0, 5).forEach((e, i) => body.push([
{ text: i === 0 ? titel + ' (' + liste.length + ')' : '', style: 'td', bold: true, color: farbe || undefined },
{ text: (e.art === 'meilenstein' ? 'Meilenstein: ' : '') + e.titel + (e.neu ? ' neu' : ''), style: 'td' },
{ text: e.kuerzel, style: 'td' },
{ text: e.datum ? formatDateDE(parseDateISO(e.datum)) : '', style: 'td', alignment: 'right', color: farbe || undefined }
]));
});
return { stack: [
{ text: 'AUFGABEN', fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 10, 0, 3] },
{ table: { widths: [100, '*', 70, 50], body }, layout: pdfWbGridLayout(false) }
] };
}
// pdfWbVorigerKommentar: Block mit dem jüngsten früheren Kommentar des // pdfWbVorigerKommentar: Block mit dem jüngsten früheren Kommentar des
// Gewerks. Steht er in der direkten Vorwoche, heißt er "Vorwoche KW …", // Gewerks. Steht er in der direkten Vorwoche, heißt er "Vorwoche KW …",
// sonst "Zuletzt KW …". Parameter: p (Projekt), w (Sitzung). Rückgabe: // sonst "Zuletzt KW …". Parameter: p (Projekt), w (Sitzung). Rückgabe:
+4 -4
View File
@@ -171,7 +171,7 @@
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt'); const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt');
const upcoming = milestones const upcoming = milestones
.filter(m => m.date && parseDateISO(m.date) >= today) .filter(m => m.date && !m.erreicht && parseDateISO(m.date) >= today)
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date)); .sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
const msZelle = upcoming.length > 0 const msZelle = upcoming.length > 0
? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein') ? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein')
@@ -540,7 +540,7 @@
const ampel = loadAmpelColor(l.quote); const ampel = loadAmpelColor(l.quote);
const breite = Math.min(100, l.quote * 100); const breite = Math.min(100, l.quote * 100);
const absCount = app.countPersonAbsences(l.person.id); const absCount = app.countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : ''; const absBadge = absCount > 0 ? `<div class="hint" style="font-size:11px;margin-top:4px">${absCount} Abwesenheit(en)</div>` : '';
let todoListHtml; let todoListHtml;
if(l.todos.length === 0){ if(l.todos.length === 0){
@@ -566,12 +566,12 @@
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`; : `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`;
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten"> html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3> <h3>${esc(l.person.name)} <span style="font-size:11px;color:var(--text-subtle);font-weight:400">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div> <div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div> <div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${offenText} ${offenText}
${otherBadge} ${otherBadge}
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div> <div class="load-detail" style="font-size:11px;margin-top:4px">${todoListHtml}</div>
${absBadge} ${absBadge}
</div>`; </div>`;
}); });
+9 -5
View File
@@ -93,7 +93,7 @@
for(let i = versionen.length - 1; i >= 0; i--){ for(let i = versionen.length - 1; i >= 0; i--){
const k = kommentarVon(versionen[i].project, null); const k = kommentarVon(versionen[i].project, null);
if(statusPlainText(k.status)){ if(statusPlainText(k.status)){
letzter = { week: versionen[i].week, k, aktuell: i === versionen.length - 1 }; letzter = { week: versionen[i].week, k, project: versionen[i].project, aktuell: i === versionen.length - 1 };
break; break;
} }
} }
@@ -103,7 +103,8 @@
gewerke.forEach(g => { gewerke.forEach(g => {
let vorher = ''; let vorher = '';
versionen.forEach(v => { versionen.forEach(v => {
const k = kommentarVon(v.project, g); // Nur, wenn das Projekt in JENER Sitzung das Gewerk hatte.
const k = (g in kommentareDesProjekts(v.project)) ? kommentarVon(v.project, g) : { status: '' };
const text = statusPlainText(k.status); const text = statusPlainText(k.status);
if(text && text !== vorher && !(letzter && v.week === letzter.week)){ 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 || '' }); verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' });
@@ -184,8 +185,9 @@
if(editEl){ if(editEl){
editEl.className = 'status-edit is-ansicht'; editEl.className = 'status-edit is-ansicht';
editEl.contentEditable = 'false'; editEl.contentEditable = 'false';
// Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js). // Alle Gewerke zusammen; bei mehreren je mit Überschrift, Linie und
const text = kom.letzter ? sanitizeStatusHtml(kom.letzter.k.status) : ''; // Verfasser (kommentarAnzeigeHtml(), js/utils.js, Eriks Wahl „B“).
const text = kom.letzter ? kommentarAnzeigeHtml(kom.letzter.project, null, true) : '';
editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>'; editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>';
} }
const hinweisEl = document.getElementById('planStatusHinweis'); const hinweisEl = document.getElementById('planStatusHinweis');
@@ -200,7 +202,9 @@
const autorEl = document.getElementById('planStatusAutor'); const autorEl = document.getElementById('planStatusAutor');
if(autorEl){ if(autorEl){
const k = kom.letzter ? kom.letzter.k : {}; const k = kom.letzter ? kom.letzter.k : {};
autorEl.innerHTML = k.status && k.statusAuthor // Bei mehreren Gewerken steht der Verfasser schon je Gewerk im Text.
const mehrere = kom.letzter && kommentarGewerkeMitText(kom.letzter.project) > 1;
autorEl.innerHTML = !mehrere && k.status && k.statusAuthor
? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}` ? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}`
+ (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '') + (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '')
: ''; : '';
+164 -3
View File
@@ -65,6 +65,9 @@
document.getElementById('milestoneEditDate').value = msDateIso; document.getElementById('milestoneEditDate').value = msDateIso;
const msDateDisplay = document.getElementById('milestoneEditDateDisplay'); const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : ''; if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
const msErreicht = document.getElementById('milestoneEditErreicht');
if(msErreicht) msErreicht.checked = !isNew && !!currentMs.erreicht;
panelWoSetzen('milestoneModalWo', '');
const positionSelect = document.getElementById('milestoneEditPosition'); const positionSelect = document.getElementById('milestoneEditPosition');
if(positionSelect){ if(positionSelect){
@@ -129,6 +132,7 @@
dependsOnList.innerHTML = listHtml; dependsOnList.innerHTML = listHtml;
} }
msLageBildNeu();
document.getElementById('milestoneModal').style.display = 'flex'; document.getElementById('milestoneModal').style.display = 'flex';
document.getElementById('milestoneEditName').focus(); document.getElementById('milestoneEditName').focus();
}; };
@@ -167,6 +171,7 @@
window.closeMilestoneModal = function(){ window.closeMilestoneModal = function(){
document.getElementById('milestoneModal').style.display = 'none'; document.getElementById('milestoneModal').style.display = 'none';
app.currentMilestoneIndex = null; app.currentMilestoneIndex = null;
if(currentTab === 'wochenbericht') berichtAufgabenNeu();
const danach = msNachSchliessen; const danach = msNachSchliessen;
msNachSchliessen = null; msNachSchliessen = null;
if(danach) setTimeout(danach, 0); if(danach) setTimeout(danach, 0);
@@ -189,12 +194,14 @@
const position = positionSelect ? positionSelect.value : ''; const position = positionSelect ? positionSelect.value : '';
const msNotizEl = document.getElementById('milestoneEditNotiz'); const msNotizEl = document.getElementById('milestoneEditNotiz');
const notiz = msNotizEl ? msNotizEl.value.trim() : ''; const notiz = msNotizEl ? msNotizEl.value.trim() : '';
const msErreichtEl = document.getElementById('milestoneEditErreicht');
const erreicht = !!(msErreichtEl && msErreichtEl.checked);
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = []; if(!Array.isArray(plan.milestones)) plan.milestones = [];
if(app.currentMilestoneIndex === null){ if(app.currentMilestoneIndex === null){
plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz }); plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz, erreicht });
} else if(plan.milestones[app.currentMilestoneIndex]){ } else if(plan.milestones[app.currentMilestoneIndex]){
const m = plan.milestones[app.currentMilestoneIndex]; const m = plan.milestones[app.currentMilestoneIndex];
m.name = name; m.name = name;
@@ -202,6 +209,7 @@
m.dependsOn = dependsOn; m.dependsOn = dependsOn;
m.position = position; m.position = position;
m.notiz = notiz; m.notiz = notiz;
m.erreicht = erreicht;
} }
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date)); plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
app.savePlan(); app.savePlan();
@@ -266,6 +274,140 @@
return progress > 0 ? 'arbeit' : 'offen'; return progress > 0 ? 'arbeit' : 'offen';
} }
// ---------- Planblatt: Kopfzeile, Lage im Terminplan, Stand ----------
// kurzDatum: "2026-11-19" -> "19.11." Parameter: iso. Rückgabe: String.
function kurzDatum(iso){ return formatDateDE(parseDateISO(iso)).slice(0, 6); }
// planKnoten: Aufgabe oder Meilenstein zu einer Knoten-id (Meilensteine mit
// Präfix "m_", wie in dependsOn). Rückgabe: {titel, start, end, ms, fertig} oder null.
function planKnoten(plan, id){
id = String(id || '');
if(id.indexOf('m_') === 0){
const m = (plan.milestones || []).find(x => 'm_' + x.id === id);
return m ? { id, titel: m.name || 'Meilenstein', start: m.date || '', end: m.date || '', ms: true, fertig: !!m.erreicht } : null;
}
const t = (plan.todos || []).find(x => String(x.id) === id);
return t ? { id, titel: t.title || 'Aufgabe', start: t.start || t.end || '', end: t.end || t.start || '', ms: false, fertig: !!t.done } : null;
}
// panelWoSetzen: Projekt (Nummer, Name) und optional Phase in der Kopfzeile.
function panelWoSetzen(elId, phase){
const el = document.getElementById(elId);
if(!el) return;
const p = app.project || {};
el.innerHTML = (p.nr ? `<span class="pd-nr">${esc(p.nr)}</span> ` : '') + esc(p.name || '') + (phase ? ' · ' + esc(phase) : '');
}
// LAGE_TAGE: der Ausschnitt im Panelkopf — sechs Kalenderwochen ab dem
// Montag der Woche VOR dem Eintrag.
const LAGE_TAGE = 42;
// lageBildHtml: Ausschnitt des Terminplans um einen Eintrag. Oben die
// Vorgänger (grau, wenn erledigt), in der Mitte der Eintrag selbst, unten
// die Nachfolger; dazu die Heute-Linie. Nur Anzeige.
// e = {id (Knoten-id oder null), start, end, ms, progress, fertig, deps}.
// Rückgabe: HTML.
function lageBildHtml(e){
const plan = app.ensurePlanLocal();
const s = parseDateISO(e.start || e.end), en = parseDateISO(e.end || e.start);
if(!s || !en) return '<div class="pd-lage-leer">Noch kein Termin gewählt</div>';
const vor = (e.deps || []).map(id => planKnoten(plan, id)).filter(k => k && k.start);
// Ausschnitt beginnt eine Woche vor dem Eintrag, früher, wenn ein
// Vorgänger davor endet (höchstens drei Wochen), damit er sichtbar bleibt.
const mo = new Date(s);
mo.setDate(mo.getDate() - 7);
const grenze = new Date(s); grenze.setDate(grenze.getDate() - 21);
vor.forEach(k => { const v = parseDateISO(k.end); if(v && v < mo) mo.setTime(Math.max(v.getTime() - 2 * 864e5, grenze.getTime())); });
mo.setDate(mo.getDate() - ((mo.getDay() + 6) % 7));
const tag = 100 / LAGE_TAGE;
const x = d => daysBetween(mo, d) * tag;
const nach = e.id ? (plan.todos || []).filter(t => (t.dependsOn || []).includes(e.id)).map(t => planKnoten(plan, t.id))
.concat((plan.milestones || []).filter(m => (m.dependsOn || []).includes(e.id)).map(m => planKnoten(plan, 'm_' + m.id)))
.filter(k => k && k.start) : [];
const form = (k, y, klasse, progress) => {
const a = parseDateISO(k.start), b = parseDateISO(k.end) || a;
if(k.ms){
const l = x(a) + tag / 2;
return l < 0 || l > 100 ? '' : `<span class="pd-lage-raute ${klasse}" style="left:${l}%;top:${y}px" title="${esc(k.titel)}"></span>`;
}
const l = Math.max(0, x(a)), r = Math.min(100, x(b) + tag);
if(r <= l) return '';
return `<span class="pd-lage-bar ${klasse}" style="left:${l}%;width:${r - l}%;top:${y}px" title="${esc(k.titel)}">`
+ (progress > 0 ? `<b style="width:${Math.min(100, progress)}%"></b>` : '') + '</span>';
};
const ich = { titel: '', start: e.start || e.end, end: e.end || e.start, ms: e.ms };
const ichKlasse = 'is-diese' + (e.fertig ? ' is-fertig' : '') + (e.ueber ? ' is-ueber' : '');
let flaeche = vor.slice(0, 3).map(k => form(k, 4, k.fertig ? 'is-fertig' : 'is-vor', 0)).join('')
+ form(ich, e.ms ? 18 : 20, ichKlasse, e.ms ? 0 : e.progress)
+ nach.slice(0, 3).map(k => form(k, 36, 'is-nach', 0)).join('');
const heute = parseDateISO(heuteIso());
const hx = x(heute) + tag / 2;
if(hx >= 0 && hx <= 100) flaeche += `<span class="pd-lage-heute" style="left:${hx}%"></span>`;
const skala = [0, 1, 2, 3, 4, 5].map(i => {
const d = new Date(mo); d.setDate(d.getDate() + i * 7);
return `<span style="left:${i * 100 / 6}%">KW ${String(getWeekNumber(d)).padStart(2, '0')}</span>`;
}).join('');
const legende = [];
if(vor[0]) legende.push(`<span>${esc(vor[0].titel)}${vor[0].fertig ? ', erledigt' : ''}</span>`);
if(hx >= 0 && hx <= 100) legende.push(`<span>heute ${esc(kurzDatum(heuteIso()))}</span>`);
if(nach[0]) legende.push(`<span>danach ${esc(nach[0].titel)} ${esc(kurzDatum(nach[0].start))}</span>`);
return `<div class="pd-lage-skala">${skala}</div><div class="pd-lage-flaeche">${flaeche}</div>`
+ (legende.length ? `<div class="pd-lage-legende">${legende.join('')}</div>` : '');
}
// todoLageNeu: zeichnet die Lage der Aufgabe aus den aktuellen Eingaben.
window.todoLageNeu = function(){
const el = document.getElementById('todoLageBild');
if(!el || !app.project) return;
const plan = app.ensurePlanLocal();
const t = app.currentTodoIndex !== null ? plan.todos[app.currentTodoIndex] : null;
const status = currentTodoEditStatus();
const slider = document.getElementById('todoEditProgress');
el.innerHTML = lageBildHtml({
id: t ? t.id : null,
start: document.getElementById('todoEditStart').value,
end: document.getElementById('todoEditEnd').value,
ms: false, fertig: status === 'fertig',
progress: status === 'fertig' ? 100 : (status === 'arbeit' && slider ? Number(slider.value) : 0),
deps: Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value)
});
};
// msLageBildNeu: Lage, KW-Marke und Überfällig-Hinweis des Meilensteins.
window.msLageBildNeu = function(){
const el = document.getElementById('msLageBild');
if(!el || !app.project) return;
const plan = app.ensurePlanLocal();
const m = app.currentMilestoneIndex !== null ? plan.milestones[app.currentMilestoneIndex] : null;
const datum = document.getElementById('milestoneEditDate').value;
const erreicht = document.getElementById('milestoneEditErreicht').checked;
const heute = heuteIso();
const ueber = !!datum && !erreicht && datum < heute;
el.innerHTML = lageBildHtml({
id: m ? 'm_' + m.id : null, start: datum, end: datum, ms: true, fertig: erreicht, ueber,
deps: Array.from(document.querySelectorAll('#milestoneEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value)
});
const kw = document.getElementById('milestoneEditKw');
const d = parseDateISO(datum);
if(kw){ kw.hidden = !d; kw.textContent = d ? 'KW ' + String(getWeekNumber(d)).padStart(2, '0') : ''; }
document.querySelectorAll('#milestoneEditStand button').forEach(b =>
b.setAttribute('aria-pressed', String((b.dataset.stand === 'erreicht') === erreicht)));
const warn = document.getElementById('milestoneEditWarnung');
if(warn){
const tage = ueber ? daysBetween(parseDateISO(datum), parseDateISO(heute)) : 0;
warn.hidden = !ueber;
warn.textContent = ueber ? 'seit ' + (tage === 1 ? '1 Tag' : tage + ' Tagen') + ' überfällig' : '';
}
};
// msStandSetzen: Umschalter "Offen | Erreicht" — schreibt das (versteckte)
// Feld #milestoneEditErreicht, das saveMilestoneEdit() liest.
window.msStandSetzen = function(erreicht){
document.getElementById('milestoneEditErreicht').checked = !!erreicht;
msLageBildNeu();
};
// applyTodoStatus: schreibt den Status in das Segment und blendet den // applyTodoStatus: schreibt den Status in das Segment und blendet den
// Fortschrittsregler nur im Zustand "In Arbeit" ein (offen = 0 %, // Fortschrittsregler nur im Zustand "In Arbeit" ein (offen = 0 %,
// erledigt = 100 % sind implizit). Parameter: status (String), // erledigt = 100 % sind implizit). Parameter: status (String),
@@ -296,6 +438,7 @@
slider.value = value; slider.value = value;
if(label) label.textContent = value + ' %'; if(label) label.textContent = value + ' %';
} }
todoLageNeu();
} }
// setTodoEditStatus: Klick-Handler des Status-Segments im TODO-Modal. // setTodoEditStatus: Klick-Handler des Status-Segments im TODO-Modal.
@@ -313,6 +456,7 @@
todoProgressMemo = value; todoProgressMemo = value;
const label = document.getElementById('todoEditProgressVal'); const label = document.getElementById('todoEditProgressVal');
if(label) label.textContent = value + ' %'; if(label) label.textContent = value + ' %';
todoLageNeu();
}; };
// currentTodoEditStatus: liest den aktuell gewählten Segment-Status aus dem // currentTodoEditStatus: liest den aktuell gewählten Segment-Status aus dem
@@ -335,6 +479,7 @@
badge.hidden = days <= 0; badge.hidden = days <= 0;
badge.textContent = days > 0 ? (days + ' AT') : ''; badge.textContent = days > 0 ? (days + ' AT') : '';
clearFieldError('todoEditRange'); clearFieldError('todoEditRange');
todoLageNeu();
}; };
// setFieldError / clearFieldError: zeigen einen Validierungshinweis direkt // setFieldError / clearFieldError: zeigen einen Validierungshinweis direkt
@@ -401,6 +546,17 @@
const count = list.querySelectorAll('input[type="checkbox"]:checked').length; const count = list.querySelectorAll('input[type="checkbox"]:checked').length;
badge.textContent = count; badge.textContent = count;
badge.classList.toggle('zero', count === 0); badge.classList.toggle('zero', count === 0);
const summary = document.getElementById('todoEditDependsSummary');
if(summary){
const plan = app.ensurePlanLocal();
summary.innerHTML = Array.from(list.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => planKnoten(plan, cb.value)).filter(Boolean)
.map(k => `<div class="pd-vorg-zeile"><span class="pd-vorg-stand${k.fertig ? ' is-fertig' : ''}${k.ms ? ' is-ms' : ''}" aria-hidden="true"></span>`
+ `<span class="pd-vorg-titel">${esc(k.titel)}</span>`
+ `<span class="pd-vorg-datum">${k.end ? 'bis ' + esc(kurzDatum(k.end)) : 'ohne Termin'}</span></div>`).join('');
summary.hidden = !summary.innerHTML;
}
todoLageNeu();
}; };
// filterTodoDepends: blendet in der Vorgänger-Liste alle Einträge aus, die // filterTodoDepends: blendet in der Vorgänger-Liste alle Einträge aus, die
@@ -459,6 +615,8 @@
const neu = document.getElementById('todoEditSaveNew'); const neu = document.getElementById('todoEditSaveNew');
if(abs) abs.hidden = !an; if(abs) abs.hidden = !an;
if(wer) wer.hidden = an; if(wer) wer.hidden = an;
const aufwandAbs = document.getElementById('todoEditAufwandAbs');
if(aufwandAbs) aufwandAbs.hidden = an;
if(neu) neu.hidden = an; if(neu) neu.hidden = an;
const titelEl = document.getElementById('todoModalTitle'); const titelEl = document.getElementById('todoModalTitle');
if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten'); if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten');
@@ -644,6 +802,7 @@
} }
todoAufwandInfo(); todoAufwandInfo();
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null); todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
panelWoSetzen('todoModalWo', currentTodo && typeof aufgabenPhase === 'function' ? aufgabenPhase(currentTodo, plan.todos) : '');
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0); const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50; todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
@@ -687,9 +846,10 @@
const searchInput = document.getElementById('todoEditDependsSearch'); const searchInput = document.getElementById('todoEditDependsSearch');
if(searchInput) searchInput.value = ''; if(searchInput) searchInput.value = '';
refreshTodoDependsCount(); refreshTodoDependsCount();
// Bereich nur aufklappen, wenn es dort auch etwas zu sehen gibt // Gewählte Vorgänger stehen offen darüber (#todoEditDependsSummary), die
// Auswahlliste bleibt zu, bis man sie ändern will.
const fold = document.getElementById('todoEditDependsFold'); const fold = document.getElementById('todoEditDependsFold');
if(fold) fold.open = !!(currentTodo && Array.isArray(currentTodo.dependsOn) && currentTodo.dependsOn.length > 0); if(fold) fold.open = false;
const assigneesDiv = document.getElementById('todoEditAssignees'); const assigneesDiv = document.getElementById('todoEditAssignees');
if(assigneesDiv){ if(assigneesDiv){
@@ -761,6 +921,7 @@
} }
document.getElementById('todoModal').style.display = 'none'; document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null; app.currentTodoIndex = null;
if(currentTab === 'wochenbericht') berichtAufgabenNeu();
todoModalSnapshot = ''; todoModalSnapshot = '';
const danach = todoNachSchliessen; const danach = todoNachSchliessen;
todoNachSchliessen = null; todoNachSchliessen = null;
+7
View File
@@ -406,5 +406,12 @@
ausDaten(e.clipboardData); ausDaten(e.clipboardData);
}); });
// Beim Verlassen der Seite noch speichern, was offen ist. // Beim Verlassen der Seite noch speichern, was offen ist.
// Klick irgendwo anders: die Rückfrage „Wirklich löschen?“ zurücknehmen.
document.addEventListener('pointerdown', e => {
if(!loeschenFragt || (e.target.closest && e.target.closest('#notizLoeschen'))) return;
clearTimeout(loeschenFragt); loeschenFragt = null;
const k = document.getElementById('notizLoeschen');
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
}, true);
window.addEventListener('pagehide', () => { if(ungespeichert) jetztSpeichern(); }); window.addEventListener('pagehide', () => { if(ungespeichert) jetztSpeichern(); });
})(); })();
+1 -1
View File
@@ -100,7 +100,7 @@
// ein Fallback ist ein akzeptiertes Ergebnis, kein Fehlschlag. // ein Fallback ist ein akzeptiertes Ergebnis, kein Fehlschlag.
function zeigeFallbackHinweis(msg){ function zeigeFallbackHinweis(msg){
const el = document.getElementById('protokollTodosFallback'); const el = document.getElementById('protokollTodosFallback');
if(el){ el.textContent = '⚠️ ' + msg; el.hidden = false; } if(el){ el.textContent = msg; el.hidden = false; }
} }
function verbergeFallbackHinweis(){ function verbergeFallbackHinweis(){
const el = document.getElementById('protokollTodosFallback'); const el = document.getElementById('protokollTodosFallback');
+2 -2
View File
@@ -548,7 +548,7 @@
// ab, verschwindet also nie von selbst, sondern nur wenn man das // ab, verschwindet also nie von selbst, sondern nur wenn man das
// Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar. // Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar.
const importBanner = p.quelleDateiname const importBanner = p.quelleDateiname
? `<div class="protokoll-import-banner">🔎 Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>` ? `<div class="protokoll-import-banner">Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>`
: ''; : '';
// Rein clientseitiges Merkmal (siehe importiereProtokoll() oben) — nur // Rein clientseitiges Merkmal (siehe importiereProtokoll() oben) — nur
// direkt nach GENAU dem Import gesetzt, der die KI angefordert, aber // direkt nach GENAU dem Import gesetzt, der die KI angefordert, aber
@@ -559,7 +559,7 @@
// das war Eriks Rückmeldung, nachdem er von einem KI-Fehlschlag nichts // das war Eriks Rückmeldung, nachdem er von einem KI-Fehlschlag nichts
// in der Oberfläche sah. // in der Oberfläche sah.
const kiFallbackBanner = p._kiFallbackHinweis const kiFallbackBanner = p._kiFallbackHinweis
? `<div class="protokoll-ki-fallback-banner">⚠️ Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>` ? `<div class="protokoll-ki-fallback-banner">Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>`
: ''; : '';
const kopf = ` const kopf = `
${kiFallbackBanner} ${kiFallbackBanner}
+1 -1
View File
@@ -128,7 +128,7 @@
if(moved.size > 0){ if(moved.size > 0){
app.savePlan(); app.savePlan();
renderPlanGantt(); renderPlanGantt();
app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Achtung: zyklische Abhängigkeit gefunden!' : '')); app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Zyklische Abhängigkeit gefunden — bitte Vorgänger prüfen.' : ''));
} else if(hasCycle){ } else if(hasCycle){
app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.'); app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.');
} else { } else {
+3 -4
View File
@@ -107,9 +107,8 @@
if(nav) nav.style.display = ''; if(nav) nav.style.display = '';
} }
// upcomingEntries: offene Aufgaben mit Endtermin und Meilensteine ab heute // upcomingEntries: offene Aufgaben mit Endtermin und nicht erreichte
// (vergangene Meilensteine haben kein Erledigt-Kennzeichen und gelten als // Meilensteine (vergangene gelten als überfällig), chronologisch. idx verweist in plan.todos/plan.milestones, damit
// gewesen), chronologisch. idx verweist in plan.todos/plan.milestones, damit
// ein Klick denselben Dialog öffnet wie im Terminplan. // ein Klick denselben Dialog öffnet wie im Terminplan.
// Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }. // Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }.
function upcomingEntries(){ function upcomingEntries(){
@@ -126,7 +125,7 @@
meins: !!ich && (t.assignees || []).includes(ich) }); meins: !!ich && (t.assignees || []).includes(ich) });
}); });
(plan.milestones || []).forEach((m, idx) => { (plan.milestones || []).forEach((m, idx) => {
if(!m.date || m.date < heute) return; if(!m.date || m.erreicht) return;
rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false }); rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false });
}); });
rows.sort((a, b) => a.iso.localeCompare(b.iso)); rows.sort((a, b) => a.iso.localeCompare(b.iso));
+3 -3
View File
@@ -166,16 +166,16 @@
const d = parseDateISO(m.date); const d = parseDateISO(m.date);
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const overdue = d && d < today; const overdue = d && d < today && !m.erreicht;
const msAction = locked ? '' : ` onclick="openMilestoneModal(${idx})" title="Zum Bearbeiten klicken"`; const msAction = locked ? '' : ` onclick="openMilestoneModal(${idx})" title="Zum Bearbeiten klicken"`;
return `<div class="plan-task-row${locked ? ' is-locked' : ''}"${msAction}> return `<div class="plan-task-row${locked ? ' is-locked' : ''}"${msAction}>
<span class="plan-task-dot"></span> <span class="plan-task-dot"></span>
<span class="plan-task-title">${esc(m.name || 'Meilenstein')}</span> <span class="plan-task-title">${esc(m.name || 'Meilenstein')}</span>
<span class="plan-task-meta${overdue ? ' overdue' : ''}">${esc(d ? formatDateDE(d) : 'ohne Datum')}</span> <span class="plan-task-meta${overdue ? ' overdue' : ''}">${m.erreicht ? 'erreicht · ' : ''}${esc(d ? formatDateDE(d) : 'ohne Datum')}</span>
</div>`; </div>`;
}).join(''); }).join('');
} }
const msCount = document.getElementById('planMilestoneCount'); const msCount = document.getElementById('planMilestoneCount');
if(msCount) msCount.textContent = milestones.length === 0 ? '' : `${milestones.length} gesamt`; if(msCount) msCount.textContent = milestones.length === 0 ? '' : `${milestones.filter(m => m.erreicht).length} von ${milestones.length} erreicht`;
}; };
})(); })();
+5 -1
View File
@@ -541,7 +541,11 @@ function liveEreignis(d){
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht); if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
if(d.typ === 'neu') return liveAllesNeu(); if(d.typ === 'neu') return liveAllesNeu();
if(d.typ === 'personen') return personenNachladen(); if(d.typ === 'personen') return personenNachladen();
if(d.typ === 'notizen' && typeof notizenLiveAenderung === 'function') return notizenLiveAenderung(d.uid); if(d.typ === 'notizen'){
if(typeof meineNotizenLive === 'function') meineNotizenLive(d.uid);
if(typeof notizenLiveAenderung === 'function') notizenLiveAenderung(d.uid);
return;
}
} }
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer // personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
+6 -2
View File
@@ -23,7 +23,7 @@
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der // Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom // Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
// Dashboard ins Projektboard. // Dashboard ins Projektboard.
const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','people','planung','planchecker','dashboard','wand','settings','admin','plan']; const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','notizen','people','planung','planchecker','dashboard','wand','settings','admin','plan'];
function showTab(t){ function showTab(t){
// Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der // Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der
@@ -61,6 +61,7 @@ function showTab(t){
if(t==='wochenbericht') renderWochenbericht(); if(t==='wochenbericht') renderWochenbericht();
if(t==='load') renderLoad(); if(t==='load') renderLoad();
if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); } if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); }
if(t==='notizen' && typeof renderMeineNotizen === 'function') renderMeineNotizen();
if(t==='people') renderPeople(); if(t==='people') renderPeople();
if(t==='planung') renderPlanung(); if(t==='planung') renderPlanung();
if(t==='planchecker') renderPlanChecker(); if(t==='planchecker') renderPlanChecker();
@@ -108,6 +109,9 @@ function syncPlanNavItem(){
const name = app && app.projName ? app.projName : ''; const name = app && app.projName ? app.projName : '';
item.style.display = name ? '' : 'none'; item.style.display = name ? '' : 'none';
const label = document.getElementById('planNavItemLabel'); const label = document.getElementById('planNavItemLabel');
if(label) label.textContent = name || 'Projektbereich'; // Projektnummer in Mono-Ziffern davor (Kennzeichen „Bauplan“).
if(label) label.innerHTML = name
? (app.projNr ? '<span class="nav-projnr">' + esc(app.projNr) + '</span> ' : '') + esc(name)
: 'Projektbereich';
item.title = name ? ('Projektbereich: ' + name) : 'Projektbereich'; item.title = name ? ('Projektbereich: ' + name) : 'Projektbereich';
} }
+39 -8
View File
@@ -385,8 +385,21 @@ function kommentarGewerk(){
// Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des // Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des
// Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder // Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder
// null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }. // null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }.
function kommentarVon(p, g){ // kommentareDesProjekts: die Kommentare nur der Gewerke, die das Projekt hat.
// Ein Kommentar eines Gewerks, das dem Projekt nicht (mehr) gehört — etwa aus
// einer Zeit, als es noch HLS hatte —, gehört nicht in die Gesamtanzeige
// (Eriks Meldung zu 21-4030). Ohne gepflegte Gewerke: alle. Rückgabe: Objekt.
function kommentareDesProjekts(p){
const alle = (p && p.kommentare) || {}; const alle = (p && p.kommentare) || {};
const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : [];
if(!gewerke.length) return alle;
const aus = {};
Object.keys(alle).forEach(k => { if(gewerke.includes(k)) aus[k] = alle[k]; });
return aus;
}
function kommentarVon(p, g){
const alle = g ? ((p && p.kommentare) || {}) : kommentareDesProjekts(p);
if(g){ if(g){
const e = alle[g] || {}; const e = alle[g] || {};
return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen }; return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen };
@@ -408,19 +421,37 @@ function kommentarVon(p, g){
} }
// kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g // kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g
// nur dessen Text; ohne Gewerk die Texte aller Gewerke, bei mehreren je mit // nur dessen Text; ohne Gewerk die Texte aller Gewerke des Projekts. Bei
// dem Gewerk als eigener, dezenter Beschriftung (.kommentar-gewerk) — // mehreren Gewerken Darstellung „B – Überschrift mit Linie“ (Eriks Wahl):
// Systemtext, nicht Teil des Kommentars (anders als die fette "ELT:"-Zeile, // je Gewerk eine Kopfzeile mit dem Gewerk in seiner Farbe, einer feinen Linie
// die kommentarVon() für Klartext-Vorschauen einfügt). Rückgabe: HTML ('' ohne Text). // und (mitMeta) Verfasser und Zeit, darunter der Text — Systemtext, nicht
function kommentarAnzeigeHtml(p, g){ // Teil des Kommentars (anders als die fette "ELT:"-Zeile, die kommentarVon()
// für Klartext-Vorschauen einfügt). Parameter: p, g, mitMeta.
// Rückgabe: HTML ('' ohne Text).
function kommentarAnzeigeHtml(p, g, mitMeta){
if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || ''); if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || '');
const alle = (p && p.kommentare) || {}; const alle = kommentareDesProjekts(p);
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH']; const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); }); Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== ''); const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
if(mitText.length === 0) return ''; if(mitText.length === 0) return '';
if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status); if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status);
return mitText.map(k => `<div class="kommentar-gewerk">${esc(k)}</div><div>${sanitizeStatusHtml(alle[k].status)}</div>`).join(''); return '<div class="kommentar-gewerke">' + mitText.map(k => {
const e = alle[k];
const zeit = e.statusAt && typeof formatCommentStamp === 'function' ? formatCommentStamp(e.statusAt) : '';
const meta = mitMeta ? [e.statusAuthor, zeit].filter(Boolean).join(' · ') : '';
return '<div class="kommentar-block">'
+ '<div class="kommentar-kopf"><span class="kommentar-gewerk gewerk-' + esc(k.toLowerCase()) + '">' + esc(k) + '</span>'
+ '<span class="kommentar-linie"></span>' + (meta ? '<span class="kommentar-meta">' + esc(meta) + '</span>' : '') + '</div>'
+ '<div class="kommentar-text">' + sanitizeStatusHtml(e.status) + '</div></div>';
}).join('') + '</div>';
}
// kommentarGewerkeMitText: wie viele Gewerke des Projekts einen Kommentar
// haben (für die Frage, ob eine gemeinsame Verfasserzeile Sinn ergibt).
function kommentarGewerkeMitText(p){
const alle = kommentareDesProjekts(p);
return Object.keys(alle).filter(k => alle[k] && statusPlainText(alle[k].status) !== '').length;
} }
// kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in // kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in
+1 -1
View File
@@ -822,7 +822,7 @@ function wtTageDaten(w){
// (offen/überfällig) sind über die Kennzahlen oben erreichbar, die heute // (offen/überfällig) sind über die Kennzahlen oben erreichbar, die heute
// Abwesenden über die Kopfleiste. // Abwesenden über die Kopfleiste.
(plan.milestones || []).forEach(m => { (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 }); if(m.date && !m.erreicht && 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 });
}); });
}); });
tage.forEach(t => t.eintraege.sort((a, b) => String(a.sort).localeCompare(String(b.sort)))); tage.forEach(t => t.eintraege.sort((a, b) => String(a.sort).localeCompare(String(b.sort))));
+130
View File
@@ -666,12 +666,14 @@ function renderBerichtListe(){
const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : ''; const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : '';
gruppeVorher = gruppe; gruppeVorher = gruppe;
const name = (p.nr ? p.nr + ' · ' : '') + p.name; const name = (p.nr ? p.nr + ' · ' : '') + p.name;
const ueber = berichtAufgabenDaten(p, w, prevWeek).ueberfaellig.length;
return `${gruppenkopf}<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${kommentarVon(p, wochenberichtGewerk).besprochen ? ' is-ab' : ''}" return `${gruppenkopf}<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${kommentarVon(p, wochenberichtGewerk).besprochen ? ' is-ab' : ''}"
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}" role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}"
aria-current="${istAktiv ? 'true' : 'false'}"> aria-current="${istAktiv ? 'true' : 'false'}">
<span class="bericht-rail-num">${String(position).padStart(2, '0')}</span> <span class="bericht-rail-num">${String(position).padStart(2, '0')}</span>
${info.bucket === 'unchanged' ? '' : `<span class="bericht-rail-dot is-${esc(info.bucket)}" aria-hidden="true"></span>`} ${info.bucket === 'unchanged' ? '' : `<span class="bericht-rail-dot is-${esc(info.bucket)}" aria-hidden="true"></span>`}
<span class="bericht-rail-name">${esc(name)}</span> <span class="bericht-rail-name">${esc(name)}</span>
${ueber ? `<span class="bericht-rail-ueber" title="${ueber} überfällige Aufgabe${ueber === 1 ? '' : 'n'}">${ueber}</span>` : ''}
</div>`; </div>`;
}).join(''); }).join('');
@@ -713,6 +715,7 @@ function renderBerichtDetail(p, w, prevWeek, locked){
<div class="bericht-sub">${subText}</div> <div class="bericht-sub">${subText}</div>
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Heute abwesend: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''} ${fehlen.length ? `<span class="bericht-abwesend-hinweis">Heute abwesend: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''}
${renderBoardDrawer(p, allPeople, kLocked, w.label, Object.assign({}, BERICHT_OPTS, { ohneVerlauf: true }))} ${renderBoardDrawer(p, allPeople, kLocked, w.label, Object.assign({}, BERICHT_OPTS, { ohneVerlauf: true }))}
${renderBerichtAufgaben(p, w, prevWeek, locked)}
<div class="bericht-detail-fuss"> <div class="bericht-detail-fuss">
${!locked && hatRecht('proj.plan') ? `<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')"> ${!locked && hatRecht('proj.plan') ? `<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
Aufgabe aus Kommentar… Aufgabe aus Kommentar…
@@ -876,6 +879,133 @@ function berichtVerlaufNeu(){
el.outerHTML = renderBerichtVerlauf(p, w, isWeekLocked(w)); el.outerHTML = renderBerichtVerlauf(p, w, isWeekLocked(w));
} }
// ---------- Aufgaben des Projekts (Eriks Wahl „B“) ----------
//
// Die drei Fragen der Runde: was ist seit der Vorwoche erledigt, was ist
// überfällig, was steht in den nächsten zwei Wochen an. Nur Anzeige — ein
// Klick öffnet dasselbe Aufgaben-/Meilenstein-Panel wie im Terminplan
// ("Ein Ort je Angabe"). Keine neue Datenhaltung: "erledigt seit" ist der
// Vergleich mit der Kopie des Projekts in der vorigen geladenen Sitzung.
// Phasenbalken (t.lph) zählen nicht, sie stehen als Leistungsphasen in der Karte.
const BERICHT_AUFGABEN_TAGE = 14;
const BERICHT_AUFGABEN_JE_SPALTE = 6;
// berichtAufgabenDaten: Parameter p, w, prevWeek. Rückgabe:
// {erledigt, ueberfaellig, bald, weitere, mitVorwoche} — Listen von
// {art, idx, titel, datum, progress, neu, kuerzel}. Aufgaben mit Zuständigen
// aus dem gewählten Gewerk stehen in jeder Liste zuerst. Auch von js/pdf.js genutzt.
function berichtAufgabenDaten(p, w, prevWeek){
const plan = p.plan || {};
const heute = heuteIso();
const bis = isoPlusTage(BERICHT_AUFGABEN_TAGE);
const vorher = prevWeek && p.projectUid ? (prevWeek.projects || []).find(x => x.projectUid === p.projectUid) : null;
const vorherTodos = new Map(((vorher && vorher.plan && vorher.plan.todos) || []).map(t => [String(t.id), t]));
const leute = new Map((w.people || []).map(person => [person.id, person]));
const g = wochenberichtGewerk;
const erledigt = [], ueberfaellig = [], bald = [];
let weitere = 0;
(plan.todos || []).forEach((t, idx) => {
if(t.lph) return;
const wer = (t.assignees || []).map(id => leute.get(id)).filter(Boolean);
const alt = vorherTodos.get(String(t.id));
const e = {
art: 'aufgabe', idx, titel: t.title || 'Aufgabe', datum: t.end || '',
progress: parseInt(t.progress, 10) || 0, fertig: !!t.done,
neu: !!vorher && !alt,
kuerzel: wer.map(person => person.name).join(', '),
imGewerk: !!g && wer.some(person => personGewerk(person) === g)
};
if(t.done){
if(vorher && (!alt || !alt.done)) erledigt.push(e);
} else if(t.end && t.end < heute) ueberfaellig.push(e);
else if(t.end && t.end <= bis) bald.push(e);
else weitere++;
});
(plan.milestones || []).forEach((m, idx) => {
if(!m.date || m.erreicht) return;
const e = { art: 'meilenstein', idx, titel: m.name || 'Meilenstein', datum: m.date, progress: 0, fertig: false, neu: false, kuerzel: '', imGewerk: false };
if(m.date < heute) ueberfaellig.push(e);
else if(m.date <= bis) bald.push(e);
});
const sortieren = l => l.sort((a, b) => (b.imGewerk - a.imGewerk) || String(a.datum).localeCompare(String(b.datum)));
return { erledigt: sortieren(erledigt), ueberfaellig: sortieren(ueberfaellig), bald: sortieren(bald), weitere, mitVorwoche: !!vorher };
}
// renderBerichtAufgaben: der Abschnitt unter der Projektkarte.
// Parameter: p, w, prevWeek, locked. Rückgabe: HTML.
function renderBerichtAufgaben(p, w, prevWeek, locked){
const d = berichtAufgabenDaten(p, w, prevWeek);
const klickbar = !locked && hatRecht('proj.plan');
const vorKw = prevWeek ? berichtKurzKw(prevWeek.label) : '';
const zeile = e => {
const datum = e.datum ? formatDateDE(parseDateISO(e.datum)).slice(0, 6) : '';
const art = e.art === 'meilenstein'
? '<span class="ba-raute" aria-label="Meilenstein"></span>'
: `<span class="ba-haken${e.fertig ? ' is-an' : ''}" aria-hidden="true"></span>`;
const inhalt = `${art}
<span class="ba-titel">${esc(e.titel)}${e.neu ? '<span class="ba-neu">neu</span>' : ''}${!e.fertig && e.progress > 0 && e.progress < 100 ? `<span class="ba-fort">${e.progress} %</span>` : ''}</span>
<span class="ba-wer">${esc(e.kuerzel)}</span>
<span class="ba-datum">${esc(datum)}</span>`;
return klickbar
? `<button type="button" class="ba-zeile" onclick="berichtEintragOeffnen('${esc(p.id)}','${e.art}',${e.idx})">${inhalt}</button>`
: `<div class="ba-zeile">${inhalt}</div>`;
};
const spalte = (titel, liste, leer, klasse) => `<div class="ba-spalte">
<div class="ba-spalte-kopf${klasse || ''}"><span>${esc(titel)}</span><span class="ba-zahl">${liste.length}</span></div>
${liste.length ? liste.slice(0, BERICHT_AUFGABEN_JE_SPALTE).map(zeile).join('') : `<div class="ba-leer">${esc(leer)}</div>`}
${liste.length > BERICHT_AUFGABEN_JE_SPALTE ? `<div class="ba-leer">${liste.length - BERICHT_AUFGABEN_JE_SPALTE} weitere</div>` : ''}
</div>`;
const hinweis = [];
if(d.weitere) hinweis.push(d.weitere === 1 ? '1 weitere offene Aufgabe später oder ohne Termin' : d.weitere + ' weitere offene Aufgaben später oder ohne Termin');
if(klickbar) hinweis.push('Klick auf eine Zeile öffnet die Aufgabe');
return `<section class="bericht-aufgaben" id="berichtAufgaben" aria-label="Aufgaben">
<div class="bv-kopf">
<h2 class="bv-titel">Aufgaben</h2>
<span class="bv-anzahl">${vorKw ? 'seit ' + esc(vorKw) : 'keine Vorwoche geladen'}${wochenberichtGewerk ? ' · Zuständige aus ' + esc(wochenberichtGewerk) + ' zuerst' : ''}</span>
<span class="bericht-fuss-spacer"></span>
<button type="button" class="secondary small" onclick="berichtAufgabenImProjekt('${esc(p.id)}')">Alle im Projekt</button>
</div>
<div class="ba-spalten">
${spalte(vorKw ? 'Erledigt seit ' + vorKw : 'Erledigt', d.erledigt, d.mitVorwoche ? 'Nichts erledigt' : 'Kein Vergleich möglich')}
${spalte('Überfällig', d.ueberfaellig, 'Nichts überfällig', d.ueberfaellig.length ? ' is-rot' : '')}
${spalte('Nächste zwei Wochen', d.bald, 'Nichts fällig')}
</div>
${hinweis.length ? `<div class="ba-hinweis">${esc(hinweis.join(' · '))}</div>` : ''}
</section>`;
}
// berichtAufgabenNeu: zeichnet nur diesen Abschnitt und die Tagesordnung neu
// (nach dem Schließen des Panels), ein angefangener Kommentar bleibt.
function berichtAufgabenNeu(){
const w = currentWeek();
const p = w && aktuellesBerichtProjekt(w);
const el = document.getElementById('berichtAufgaben');
if(!p || !el) return;
el.outerHTML = renderBerichtAufgaben(p, w, findPreviousWeek(w), isWeekLocked(w));
renderBerichtListe();
}
// berichtEintragOeffnen: öffnet das Aufgaben- bzw. Meilenstein-Panel über dem
// Wochenbericht. Die Panels arbeiten auf window.PlanApp; showTab('plan')
// setzt week/project über openPlanView() ohnehin neu.
function berichtEintragOeffnen(pid, art, idx){
const w = currentWeek();
const p = w && (w.projects || []).find(x => x.id === pid);
if(!p) return;
window.PlanApp.week = w;
window.PlanApp.project = p;
if(art === 'meilenstein') openMilestoneModal(idx);
else openTodoModal(idx);
}
// berichtAufgabenImProjekt: springt in den Bereich "Aufgaben" des Projekts.
function berichtAufgabenImProjekt(pid){
location.hash = 'aufgaben';
openProjectPlan(pid);
}
// berichtVerlaufUebernehmen: schreibt den Kommentar jener Sitzung in das // berichtVerlaufUebernehmen: schreibt den Kommentar jener Sitzung in das
// Kommentarfeld der aktuellen (takeCommentFromHistory(), js/board/board.js). // Kommentarfeld der aktuellen (takeCommentFromHistory(), js/board/board.js).
function berichtVerlaufUebernehmen(weekLabel){ function berichtVerlaufUebernehmen(weekLabel){
+449 -319
View File
File diff suppressed because it is too large. Load diff
+914 -680
View File
File diff suppressed because it is too large. Load diff
+150 -18
View File
@@ -1020,6 +1020,30 @@ function htmlEscape(s){
.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
} }
// textMaskieren: wie htmlEscape(), aber ein '&', das schon eine fertige
// Entity beginnt (&amp; &nbsp; &#39; …), bleibt stehen. Der Text zwischen den
// Tags kommt aus gespeichertem HTML, in dem solche Entities ganz normal
// stehen. htmlEscape() machte daraus bei JEDEM Speichern eine Stufe mehr
// (&nbsp; → &amp;nbsp; → &amp;amp;nbsp; …) — Eriks Meldung nach dem
// Sitzungswechsel KW 45. So bleibt die Bereinigung beliebig oft wiederholbar.
function textMaskieren(s){
return String(s)
.replace(/&(?!(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&amp;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// entitiesEntstapeln: macht gestapelte Maskierung rückgängig
// (&amp;amp;nbsp; → &nbsp;, &amp;amp; → &amp;). Ein „&amp;“ vor einer
// weiteren Entity ist immer das Ergebnis des Fehlers oben, nie Absicht.
function entitiesEntstapeln(html){
let alt, neu = String(html || '');
do {
alt = neu;
neu = neu.replace(/&amp;((?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&$1');
} while(neu !== alt);
return neu;
}
// sicheresStyleAttribut: lässt aus einem style-Attribut nur color und // sicheresStyleAttribut: lässt aus einem style-Attribut nur color und
// font-weight durch — identisch zur Client-Regel. Rückgabe: bereinigter // font-weight durch — identisch zur Client-Regel. Rückgabe: bereinigter
// Wert oder '' (dann wird das Attribut ganz weggelassen). // Wert oder '' (dann wird das Attribut ganz weggelassen).
@@ -1108,7 +1132,7 @@ function sanitizeFormatHtml(html){
if(c !== '<'){ if(c !== '<'){
const naechstes = quelle.indexOf('<', i); const naechstes = quelle.indexOf('<', i);
const text = quelle.slice(i, naechstes === -1 ? quelle.length : naechstes); const text = quelle.slice(i, naechstes === -1 ? quelle.length : naechstes);
if(!verwerfenBis) aus += htmlEscape(text); if(!verwerfenBis) aus += textMaskieren(text);
i += text.length; i += text.length;
continue; continue;
} }
@@ -1226,8 +1250,8 @@ function projektStatements(){
VALUES (?, ?, ?, ?)`); VALUES (?, ?, ?, ?)`);
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id) const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
VALUES (?, ?, ?, ?)`); VALUES (?, ?, ?, ?)`);
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz) const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz, erreicht)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id) const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
VALUES (?, ?, ?, ?)`); VALUES (?, ?, ?, ?)`);
@@ -1312,7 +1336,7 @@ function serializeProject(weekLabel, p, idx, nowIso){
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || ''))); (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) => { (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, String(m.notiz || '').slice(0, 20000)); insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx, String(m.notiz || '').slice(0, 20000), m.erreicht ? 1 : 0);
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || ''))); (Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
}); });
} }
@@ -1540,7 +1564,7 @@ function loadWeeks(nurLabel){
const msMap = {}; const msMap = {};
milestones.forEach(r => { milestones.forEach(r => {
const k = msKey(r.week_label, r.project_id, r.milestone_id); const k = msKey(r.week_label, r.project_id, r.milestone_id);
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '' }; msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '', erreicht: !!r.erreicht };
}); });
milestoneDeps.forEach(r => { milestoneDeps.forEach(r => {
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)]; const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
@@ -1689,7 +1713,7 @@ function dbTableColumns(conn, table){
// Wiederherstellung geschrieben wird, nach einem Versionssprung nicht mehr // Wiederherstellung geschrieben wird, nach einem Versionssprung nicht mehr
// einspielbar. // einspielbar.
const IMPORT_OPTIONAL_COLS = ['updated_at', 'project_uid', 'honorar', 'status_author', 'status_at', const IMPORT_OPTIONAL_COLS = ['updated_at', 'project_uid', 'honorar', 'status_author', 'status_at',
'auftragsstatus', 'besprochen', 'leistungsphase', 'aufwand_stunden', 'notiz']; 'auftragsstatus', 'besprochen', 'leistungsphase', 'aufwand_stunden', 'notiz', 'erreicht'];
// restoreDbFromBackup: validiert die Backup-Datei (muss das bekannte Schema // restoreDbFromBackup: validiert die Backup-Datei (muss das bekannte Schema
// mit identischen Spalten wie die Live-DB besitzen) und ersetzt den // mit identischen Spalten wie die Live-DB besitzen) und ersetzt den
@@ -2611,6 +2635,30 @@ function kommentareVorsatzUmstellen(){
if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.'); if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.');
} }
// entitiesReparieren: führt gestapelte Maskierung (&amp;amp;nbsp; …) in
// allen Sitzungskommentaren und Protokoll-Punkten auf das ursprüngliche
// Zeichen zurück — Folge des Fehlers in sanitizeFormatHtml() vor
// textMaskieren(). Läuft bei jedem Start und nach /import, idempotent.
function entitiesReparieren(){
const teile = [
['project_kommentare', 'status', 'week_label, project_id, gewerk', 'week_label = ? AND project_id = ? AND gewerk = ?', r => [r.week_label, r.project_id, r.gewerk]],
['protokoll_punkte', 'text', 'id', 'id = ?', r => [r.id]]
];
let n = 0;
db.exec('BEGIN');
try {
teile.forEach(([tab, feld, schluessel, wo, werte]) => {
const upd = db.prepare('UPDATE ' + tab + ' SET ' + feld + ' = ? WHERE ' + wo);
db.prepare('SELECT ' + schluessel + ', ' + feld + " AS wert FROM " + tab + " WHERE " + feld + " LIKE '%&amp;%'").all().forEach(r => {
const neu = entitiesEntstapeln(r.wert);
if(neu !== r.wert){ upd.run(neu, ...werte(r)); n++; }
});
});
db.exec('COMMIT');
} catch(e){ db.exec('ROLLBACK'); throw e; }
if(n) console.log('Doppelt maskierte Zeichen in ' + n + ' Kommentaren/Protokoll-Punkten repariert.');
}
function kommentareSpiegelstricheUmstellen(){ function kommentareSpiegelstricheUmstellen(){
const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status != ''").all(); 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 = ?'); const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?');
@@ -2682,6 +2730,12 @@ function migrateAddPlanTodoNotiz(){
db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''"); db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
console.log('plan_milestones um notiz ergänzt.'); console.log('plan_milestones um notiz ergänzt.');
} }
// erreicht: Meilenstein als erreicht/abgeschlossen markiert — sonst zählt
// ein vergangener Meilenstein auf "Meine Aufgaben" als überfällig.
if(!msCols.includes('erreicht')){
db.exec("ALTER TABLE plan_milestones ADD COLUMN erreicht INTEGER NOT NULL DEFAULT 0");
console.log('plan_milestones um erreicht ergänzt.');
}
} }
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach // migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
@@ -2908,8 +2962,12 @@ function ensureCurrentWeekRollover(){
// zur Sitzung und beginnt leer. // zur Sitzung und beginnt leer.
const vorher = p.kommentare || {}; const vorher = p.kommentare || {};
p.kommentare = {}; p.kommentare = {};
// Nur Gewerke, die das Projekt hat — sonst lebte ein Kommentar eines
// längst abgewählten Gewerks Woche für Woche weiter (21-4030).
const gewerke = Array.isArray(p.gewerke) ? p.gewerke : [];
Object.keys(vorher).forEach(g => { Object.keys(vorher).forEach(g => {
const k = vorher[g] || {}; const k = vorher[g] || {};
if(gewerke.length && !gewerke.includes(g)) return;
if(!String(k.status || '').trim()) return; if(!String(k.status || '').trim()) return;
p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '', p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '',
statusAt: k.statusAt || '', besprochen: false }; statusAt: k.statusAt || '', besprochen: false };
@@ -3558,6 +3616,7 @@ einmaligeUmstellung('siemon-anteile-2021', siemon2021Umstellen);
// Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei // Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei
// jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen. // jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen.
kommentareVorsatzUmstellen(); kommentareVorsatzUmstellen();
entitiesReparieren();
ensureSandboxWeek(); ensureSandboxWeek();
ensureUserPersonIdUniqueIndex(); ensureUserPersonIdUniqueIndex();
migrateRetirePeopleTablesIntoUsers(); migrateRetirePeopleTablesIntoUsers();
@@ -5568,14 +5627,13 @@ app.get('/my-dashboard', requireAuth, (req, res) => {
AND t.project_id IN (${platzhalter}) AND t.project_id IN (${platzhalter})
`).all(weekLabel, ...meineProjekte); `).all(weekLabel, ...meineProjekte);
// Meilensteine: sie tragen kein Erledigt-Kennzeichen, deshalb zählt // Meilensteine: nur die noch nicht erreichten.
// allein das Datum.
const milestones = db.prepare(` const milestones = db.prepare(`
SELECT m.milestone_id AS id, m.name AS titel, m.date AS datum, SELECT m.milestone_id AS id, m.name AS titel, m.date AS datum,
m.project_id AS projectId, p.nr AS projectNr, p.name AS projectName m.project_id AS projectId, p.nr AS projectNr, p.name AS projectName
FROM plan_milestones m FROM plan_milestones m
JOIN projects p ON p.week_label = m.week_label AND p.project_id = m.project_id JOIN projects p ON p.week_label = m.week_label AND p.project_id = m.project_id
WHERE m.week_label = ? AND m.date != '' WHERE m.week_label = ? AND m.date != '' AND m.erreicht = 0
AND m.project_id IN (${platzhalter}) AND m.project_id IN (${platzhalter})
`).all(weekLabel, ...meineProjekte); `).all(weekLabel, ...meineProjekte);
@@ -7362,6 +7420,68 @@ function notizAnlegen(req, uid, titel, html, quelle){
return notizLesen(id); return notizLesen(id);
} }
// GET /projekt-notizen/anzahl — je Projekt (project_uid) Anzahl und zuletzt
// geändert, für die Projektblöcke auf „Meine Aufgaben“. Nur Zählung, kein Inhalt.
app.get('/projekt-notizen/anzahl', requireAuth, (req, res) => {
try {
const anzahl = {};
db.prepare("SELECT project_uid AS uid, COUNT(*) AS n, MAX(geaendert_am) AS zuletzt FROM projekt_notizen WHERE project_uid != '' GROUP BY project_uid")
.all().forEach(r => { anzahl[r.uid] = { anzahl: r.n, zuletzt: r.zuletzt || '' }; });
res.json({ ok: true, anzahl });
} catch(err){
console.error('Notizen zählen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Seitenleiste „Notizen“: alle Notizen meiner Projekte ----------
//
// Eriks Wunsch: ein eigener Bereich, in dem jedes Konto die Notizen ALLER
// seiner Projekte sieht und auch projektunabhängige Notizen anlegt, die sich
// später einem Projekt zuordnen lassen. Eine projektunabhängige Notiz ist eine
// Zeile mit project_uid = '' und gehört allein ihrem Verfasser (sonst sieht
// und ändert sie niemand). „Meine Projekte“ = in der jüngsten echten Sitzung
// zugeordnet (assign > 0) oder mit eigener Aufgabe — dieselbe Regel wie das
// Dashboard (dashDaten()).
function meineProjektUids(user){
const ich = user && user.personId;
const uids = new Set();
if(!ich) return uids;
const label = echteSitzungenSortiert().map(w => w.label).pop();
const w = label ? loadWeeks(label)[0] : null;
((w && w.projects) || []).forEach(p => {
const zugeordnet = Object.keys(p.assign || {}).some(k => k.split('|')[0] === ich && Number(p.assign[k]) > 0);
const eigene = ((p.plan && p.plan.todos) || []).some(t => Array.isArray(t.assignees) && t.assignees.includes(ich));
if((zugeordnet || eigene) && p.projectUid) uids.add(String(p.projectUid));
});
return uids;
}
const NOTIZ_SELECT = `SELECT n.*, COALESCE(u1.display_name, 'Gelöschter Benutzer') AS erstellt_name,
COALESCE(u2.display_name, 'Gelöschter Benutzer') AS geaendert_name
FROM projekt_notizen n LEFT JOIN users u1 ON u1.id = n.erstellt_von LEFT JOIN users u2 ON u2.id = n.geaendert_von`;
// GET /meine-notizen — {notizen, meineProjekte:[uid]}: die Notizen meiner
// Projekte plus meine projektunabhängigen.
app.get('/meine-notizen', requireAuth, (req, res) => {
try {
const uids = Array.from(meineProjektUids(req.user));
const rows = db.prepare(NOTIZ_SELECT + ` WHERE (n.project_uid = '' AND n.erstellt_von = ?)`
+ (uids.length ? ' OR n.project_uid IN (' + uids.map(() => '?').join(',') + ')' : '')
+ ' ORDER BY n.angeheftet DESC, n.geaendert_am DESC').all(req.user.id, ...uids);
res.json({ ok: true, notizen: rows.map(notizZeile), meineProjekte: uids });
} catch(err){
console.error('Meine Notizen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// notizDarf: Schreibrecht auf eine Notiz bzw. ein Ziel. Projektunabhängig
// (uid '') nur der Verfasser; an einem Projekt das Recht proj.plan.
function notizDarf(user, uid, erstelltVonId){
if(!uid) return erstelltVonId === undefined || erstelltVonId === user.id;
return hatRecht(user, 'proj.plan');
}
app.get('/projekt-notizen', requireAuth, (req, res) => { app.get('/projekt-notizen', requireAuth, (req, res) => {
try { try {
const uid = String(req.query.uid || ''); const uid = String(req.query.uid || '');
@@ -7377,11 +7497,11 @@ app.get('/projekt-notizen', requireAuth, (req, res) => {
} }
}); });
app.post('/projekt-notizen', requireAuth, requireRecht('proj.plan'), (req, res) => { app.post('/projekt-notizen', requireAuth, (req, res) => {
try { try {
const b = req.body || {}; const b = req.body || {};
const uid = String(b.uid || ''); const uid = String(b.uid || '');
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' }); if(!notizDarf(req.user, uid)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen in Projekten anzulegen.' });
res.json({ ok: true, notiz: notizAnlegen(req, uid, b.titel, b.html, '') }); res.json({ ok: true, notiz: notizAnlegen(req, uid, b.titel, b.html, '') });
} catch(err){ } catch(err){
console.error('Notiz anlegen fehlgeschlagen:', err); console.error('Notiz anlegen fehlgeschlagen:', err);
@@ -7392,11 +7512,20 @@ app.post('/projekt-notizen', requireAuth, requireRecht('proj.plan'), (req, res)
// PUT {titel?, html?, angeheftet?, basis?} — basis = geaendertAm des Stands, // PUT {titel?, html?, angeheftet?, basis?} — basis = geaendertAm des Stands,
// auf dem die Änderung beruht; weicht der gespeicherte ab, 409 mit dem // auf dem die Änderung beruht; weicht der gespeicherte ab, 409 mit dem
// aktuellen Stand (jemand anderes hat inzwischen gespeichert). // aktuellen Stand (jemand anderes hat inzwischen gespeichert).
app.put('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, res) => { // uid (optional): einem Projekt zuordnen bzw. mit '' wieder projektunabhängig
// machen (das nur der Verfasser) — aus der Seitenleiste „Notizen“.
app.put('/projekt-notizen/:id', requireAuth, (req, res) => {
try { try {
const alt = notizLesen(req.params.id); const alt = notizLesen(req.params.id);
if(!alt) return res.status(404).json({ ok: false, error: 'Die Notiz gibt es nicht mehr.' }); if(!alt) return res.status(404).json({ ok: false, error: 'Die Notiz gibt es nicht mehr.' });
if(!alt.uid && alt.erstelltVonId !== req.user.id) return res.status(404).json({ ok: false, error: 'Die Notiz gibt es nicht mehr.' });
if(!notizDarf(req.user, alt.uid, alt.erstelltVonId)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen zu ändern.' });
const b = req.body || {}; const b = req.body || {};
const neueUid = typeof b.uid === 'string' ? b.uid : alt.uid;
if(neueUid !== alt.uid){
if(!neueUid && alt.erstelltVonId !== req.user.id) return res.status(403).json({ ok: false, error: 'Nur wer die Notiz angelegt hat, kann sie vom Projekt lösen.' });
if(!notizDarf(req.user, neueUid, alt.erstelltVonId)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen in Projekten anzulegen.' });
}
if(typeof b.basis === 'string' && b.basis && b.basis !== alt.geaendertAm){ if(typeof b.basis === 'string' && b.basis && b.basis !== alt.geaendertAm){
return res.status(409).json({ ok: false, konflikt: true, notiz: alt, return res.status(409).json({ ok: false, konflikt: true, notiz: alt,
error: alt.geaendertVon + ' hat diese Notiz inzwischen geändert.' }); error: alt.geaendertVon + ' hat diese Notiz inzwischen geändert.' });
@@ -7408,10 +7537,11 @@ app.put('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, re
// Bearbeiter bleiben, damit die Sortierung „zuletzt geändert“ stimmt. // Bearbeiter bleiben, damit die Sortierung „zuletzt geändert“ stimmt.
const nurPin = typeof b.titel !== 'string' && typeof b.html !== 'string'; const nurPin = typeof b.titel !== 'string' && typeof b.html !== 'string';
const jetzt = nurPin ? alt.geaendertAm : new Date().toISOString(); const jetzt = nurPin ? alt.geaendertAm : new Date().toISOString();
db.prepare(`UPDATE projekt_notizen SET titel = ?, html = ?, angeheftet = ?, geaendert_am = ? db.prepare(`UPDATE projekt_notizen SET titel = ?, html = ?, angeheftet = ?, geaendert_am = ?, project_uid = ?
${nurPin ? '' : ', geaendert_von = ' + Number(req.user.id)} WHERE id = ?`) ${nurPin ? '' : ', geaendert_von = ' + Number(req.user.id)} WHERE id = ?`)
.run(titel, html, angeheftet, jetzt, alt.id); .run(titel, html, angeheftet, jetzt, neueUid, alt.id);
ereignisSenden({ typ: 'notizen', uid: alt.uid }, req); ereignisSenden({ typ: 'notizen', uid: alt.uid }, req);
if(neueUid !== alt.uid) ereignisSenden({ typ: 'notizen', uid: neueUid }, req);
res.json({ ok: true, notiz: notizLesen(alt.id) }); res.json({ ok: true, notiz: notizLesen(alt.id) });
} catch(err){ } catch(err){
console.error('Notiz speichern fehlgeschlagen:', err); console.error('Notiz speichern fehlgeschlagen:', err);
@@ -7419,10 +7549,11 @@ app.put('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, re
} }
}); });
app.delete('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, res) => { app.delete('/projekt-notizen/:id', requireAuth, (req, res) => {
try { try {
const alt = notizLesen(req.params.id); const alt = notizLesen(req.params.id);
if(!alt) return res.json({ ok: true }); if(!alt) return res.json({ ok: true });
if(!notizDarf(req.user, alt.uid, alt.erstelltVonId)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen zu löschen.' });
if(alt.erstelltVonId !== req.user.id && !istAdministrator(req.user)){ if(alt.erstelltVonId !== req.user.id && !istAdministrator(req.user)){
return res.status(403).json({ ok: false, error: 'Löschen darf nur, wer die Notiz angelegt hat, oder ein Administrator.' }); return res.status(403).json({ ok: false, error: 'Löschen darf nur, wer die Notiz angelegt hat, oder ein Administrator.' });
} }
@@ -7438,11 +7569,11 @@ app.delete('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req,
// POST /projekt-notizen/aus-mail {uid, text|eml} — Mail als neue Notiz, OHNE // POST /projekt-notizen/aus-mail {uid, text|eml} — Mail als neue Notiz, OHNE
// KI: Titel = Betreff (sonst erste Zeile), oben Absender und Datum, darunter // KI: Titel = Betreff (sonst erste Zeile), oben Absender und Datum, darunter
// der Text (zitierte Vorgänger-Mails abgeschnitten, wie bei „Termin aus Mail“). // der Text (zitierte Vorgänger-Mails abgeschnitten, wie bei „Termin aus Mail“).
app.post('/projekt-notizen/aus-mail', requireAuth, requireRecht('proj.plan'), (req, res) => { app.post('/projekt-notizen/aus-mail', requireAuth, (req, res) => {
try { try {
const b = req.body || {}; const b = req.body || {};
const uid = String(b.uid || ''); const uid = String(b.uid || '');
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' }); if(!notizDarf(req.user, uid)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen in Projekten anzulegen.' });
const mail = (typeof b.eml === 'string' && b.eml.trim()) ? leseEml(b.eml) const mail = (typeof b.eml === 'string' && b.eml.trim()) ? leseEml(b.eml)
: { betreff: '', absender: '', datum: '', text: String(b.text || '') }; : { betreff: '', absender: '', datum: '', text: String(b.text || '') };
const text = mailZitateAbschneiden(mail.text).slice(0, 50000); const text = mailZitateAbschneiden(mail.text).slice(0, 50000);
@@ -9262,6 +9393,7 @@ app.post('/import', requireRecht('a.import'), express.raw({ type: '*/*', limit:
// Ältere Backups tragen Kommentare noch gewerkübergreifend in projects. // Ältere Backups tragen Kommentare noch gewerkübergreifend in projects.
migrateKommentareJeGewerk(); migrateKommentareJeGewerk();
kommentareVorsatzUmstellen(); kommentareVorsatzUmstellen();
entitiesReparieren();
// Ältere Backups kennen das Feld leistungsphase noch nicht. // Ältere Backups kennen das Feld leistungsphase noch nicht.
phasenbalkenAusTitel(); phasenbalkenAusTitel();
console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)'); console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)');
@@ -9292,7 +9424,7 @@ app.get('/', (req,res)=>{
// Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404 // Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404
// enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um // 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. // (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr.
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']); const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Notizen','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
// Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen) // Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen)
const PLAN_SLUG = 'Projektplan'; const PLAN_SLUG = 'Projektplan';