3 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
27 changed files with 2930 additions and 1368 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
Projekt, erscheint beim Öffnen automatisch dort. Keine Daten­änderung
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“):
`#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am
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
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`)
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
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
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)
**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):**
Kopf und Band wie unten beschrieben; darunter **links „Meine Projekte“**, je
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
gerissen ist. Ohne die Grenze nach vorn stünden Termine aus einem Jahr in
der Liste und die dringenden gingen darin unter.
- **Meilensteine haben kein Erledigt-Kennzeichen** (`plan_milestones` hat
keine `done`-Spalte), deshalb zählt bei ihnen allein das Datum; bei Aufgaben
gilt zusätzlich `done = 0` und ein gesetztes Enddatum.
- **Meilensteine haben ein Kennzeichen „Erreicht“** (`m.erreicht`,
`plan_milestones.erreicht`, Migration in `migrateAddPlanTodoNotiz()`, in
`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
`loadGlobalPeople()`: Die globale Liste enthält nur Personen mit
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
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`)
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
(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)
- **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="alternate icon" href="/logo.ico">
<link rel="apple-touch-icon" href="/logo.png">
<link rel="stylesheet" href="/styles.css?v=111">
<link rel="stylesheet" href="/plan-styles.css?v=51">
<link rel="stylesheet" href="/styles.css?v=124">
<link rel="stylesheet" href="/plan-styles.css?v=57">
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
unter Einstellungen → Mein Konto (localStorage projuebersicht-glas). -->
<link rel="stylesheet" href="/glas.css?v=14">
<link rel="stylesheet" href="/glas.css?v=18">
<script>
try { if(localStorage.getItem('projuebersicht-glas') !== 'aus') document.documentElement.classList.add('glas'); }
catch(e){ document.documentElement.classList.add('glas'); }
@@ -129,116 +129,87 @@
</button>
</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">
<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>
</div>
<hr class="sidebar-nav-divider">
<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. -->
<div class="sidebar-gruppe">
<div class="sidebar-section-label">Projekte</div>
<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>
</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">
<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 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">
<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>
</div>
<!-- Studenten: eigener, dritter Abschnitt (wie "Planung" nicht Teil
von "Navigation") — bislang nur ein Werkzeug, der Plan Checker:
lädt einen Bauplan als PDF hoch und lässt ihn von einer KI gegen
eine Liste von Merkmalen prüfen (Maßketten, Plankopf, ...),
siehe js/planchecker/planchecker.js. -->
<div class="sidebar-section-label nur-recht" data-recht="wz.checker">Studenten</div>
<div class="sidebar-gruppe">
<div class="sidebar-section-label">Team</div>
<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>
<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">
<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>
</div>
<hr class="sidebar-nav-divider">
<!-- 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. -->
<div class="sidebar-gruppe sidebar-unten">
<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>
</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">
<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-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>
<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="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="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="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="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="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="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button>
</div>
</div>
</div>
</nav>
<button class="sidebar-collapse-bar" id="sidebarCollapseToggle" onclick="toggleSidebarCollapse()" title="Seitenleiste ein-/ausklappen" aria-pressed="false">
@@ -299,6 +270,9 @@
<!-- LOAD -->
<!-- ABWESENHEITEN (js/abwesenheit/abwesenheit.js) -->
<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 class="section-title">Auslastung der Projektbeteiligten</div>
@@ -1528,41 +1502,67 @@
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
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 pd">
<div class="pd-kopf">
<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>
<button type="button" class="pd-zu" onclick="closeMilestoneModal()" aria-label="Schließen">×</button></div>
<span class="pd-wo" id="milestoneModalWo"></span>
<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">
<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 class="pd-inhalt">
<section class="pd-abs">
<h3>Wann</h3>
<div class="pd-zeile">
<span class="pd-lab">Datum</span>
<div class="pd-wert">
<!-- 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"
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">
</section>
<section class="pd-abs">
<h3>Wo im Terminplan</h3>
</div>
</div>
<div class="pd-zeile">
<span class="pd-lab">Im Terminplan</span>
<div class="pd-wert">
<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>
</div>
<select id="milestoneEditPosition" aria-label="Position bei einer Aufgabe" onchange="msLageSync()">
<option value="">Oben in der Projektzeile</option>
</select>
</section>
<section class="pd-abs">
<h3>Hängt ab von</h3>
<div id="milestoneEditDependsOn" class="todo-depends-list pd-liste"></div>
</section>
<section class="pd-abs">
<h3>Notiz</h3>
</div>
</div>
<div class="pd-zeile">
<span class="pd-lab">Hängt ab von</span>
<div class="pd-wert">
<div id="milestoneEditDependsOn" class="todo-depends-list pd-liste" onchange="msLageBildNeu()"></div>
</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>
<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>
</section>
<textarea id="milestoneEditNotiz" class="pd-notiz" rows="3" placeholder="Hintergrund oder die Mail, aus der der Meilenstein stammt"></textarea>
</div>
</div>
</div>
<div class="pd-fuss">
<button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button>
@@ -1575,22 +1575,39 @@
<div class="modal pd">
<div class="pd-kopf">
<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>
<button type="button" class="pd-zu" onclick="closeTodoModal()" aria-label="Schließen">×</button></div>
<span class="pd-wo" id="todoModalWo"></span>
<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">
<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 class="pd-inhalt">
<!-- Nur im Phasen-Modus ("+ Neu" → "Leistungsphase" oder Bearbeiten
eines Phasenbalkens): Welche Leistungsphase dieser Balken ist. Nur
darüber wird ein Balken zum Phasenkopf, nicht mehr über den Titel. -->
<section class="pd-abs" id="todoEditPhaseAbs" hidden>
<h3>Leistungsphase</h3>
<select id="todoEditLph" aria-label="Leistungsphase" onchange="todoPhaseGewechselt()"></select>
<div class="pd-zeile" id="todoEditPhaseAbs" hidden>
<span class="pd-lab"><label for="todoEditLph">Leistungsphase</label></span>
<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>
</section>
<section class="pd-abs">
<h3>Wann</h3>
</div>
</div>
<div class="pd-zeile">
<span class="pd-lab">Zeitraum</span>
<div class="pd-wert">
<div class="field-inline">
<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 })">
@@ -1599,11 +1616,12 @@
<input type="hidden" id="todoEditStart">
<input type="hidden" id="todoEditEnd">
<span class="field-error" id="todoEditRangeError" hidden></span>
<label class="pd-haken"><input type="checkbox" id="todoEditShowInGantt" checked> Im Terminplan als Balken zeigen</label>
<p class="hint pd-hint">Ohne Zeitraum erscheint die Aufgabe nur unter „Aufgaben“, nicht im Terminplan.</p>
</section>
<section class="pd-abs" id="todoEditWerAbs">
<h3>Wer</h3>
<label class="pd-haken"><input type="checkbox" id="todoEditShowInGantt" checked> Als Balken im Terminplan</label>
</div>
</div>
<div class="pd-zeile" id="todoEditWerAbs">
<span class="pd-lab">Zuständig</span>
<div class="pd-wert">
<div id="todoEditAssignees" class="person-chips"></div>
<!-- Externe Personen: Kollegen mit Konto, die dem Projekt NICHT
zugeordnet sind (Eriks Wunsch). todoExternHinzufuegen()/
@@ -1613,13 +1631,13 @@
<div id="todoEditExterne" class="person-chips"></div>
<select id="todoEditExterneWahl" aria-label="Externe Person hinzufügen" onchange="todoExternHinzufuegen(this.value)"></select>
</div>
</div>
</div>
<!-- Aufwand: −/+, Schnellwahl, Umrechnung in Arbeitstage
(todoAufwandAendern()/todoAufwandSetzen()/todoAufwandInfo(), plan-modals.js) -->
<div class="pd-aufwand">
<div class="pd-aufwand-kopf">
<label for="todoEditAufwand">Aufwand</label>
<span>optional · zählt zur Auslastung der Zuständigen</span>
</div>
<div class="pd-zeile" id="todoEditAufwandAbs">
<span class="pd-lab"><label for="todoEditAufwand">Aufwand</label></span>
<div class="pd-wert pd-aufwand">
<div class="pd-aufwand-zeile">
<div class="pd-stepper">
<button type="button" class="pd-stepper-knopf" onclick="todoAufwandAendern(-1)" aria-label="Eine Stunde weniger">−</button>
@@ -1637,41 +1655,34 @@
</div>
<span class="pd-aufwand-info" id="todoAufwandInfoText"></span>
</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 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>
</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">
<div class="pd-zeile">
<span class="pd-lab">Hängt ab von</span>
<div class="pd-wert">
<div class="pd-vorg" id="todoEditDependsSummary"></div>
<details class="fold" id="todoEditDependsFold">
<summary>
<span class="fold-caret">▶</span>
<span>Hängt ab von</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>Vorgänger ändern</span>
<span class="fold-count zero" id="todoEditDependsCount">0</span>
</summary>
<div class="fold-body">
<div class="fold-search">
<span class="fold-search-icon">⌕</span>
<input type="text" id="todoEditDependsSearch" placeholder="Aufgabe oder Meilenstein suchen…" oninput="filterTodoDepends()">
<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()">
</div>
<div id="todoEditDependsOn" class="todo-depends-list flat" onchange="refreshTodoDependsCount()"></div>
</div>
</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 class="pd-fuss">
<button type="button" class="pd-loeschen" id="todoEditDelete" onclick="deleteTodoEdit()">Löschen</button>
@@ -1979,21 +1990,22 @@
</main>
</div>
<script src="/js/globals.js?v=9"></script>
<script src="/js/utils.js?v=31"></script>
<script src="/js/globals.js?v=10"></script>
<script src="/js/utils.js?v=33"></script>
<script src="/js/icons.js?v=2"></script>
<script src="/js/ui.js?v=9"></script>
<script src="/js/date-picker.js?v=6"></script>
<script src="/js/server.js?v=7"></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/board/board.js?v=41"></script>
<script src="/js/wochenbericht/wochenbericht.js?v=29"></script>
<script src="/js/board/board.js?v=42"></script>
<script src="/js/wochenbericht/wochenbericht.js?v=31"></script>
<script src="/js/people/people.js?v=8"></script>
<script src="/js/planung/planung.js?v=3"></script>
<script src="/js/planchecker/planchecker.js?v=1"></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/load/load.js?v=23"></script>
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
@@ -2002,7 +2014,7 @@
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
load.js (faktorFormat), steht deshalb dahinter. -->
<script src="/js/projekt-wizard.js?v=6"></script>
<script src="/js/dashboard/dashboard.js?v=11"></script>
<script src="/js/dashboard/dashboard.js?v=14"></script>
<script src="/js/benachrichtigungen.js?v=5"></script>
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
@@ -2010,12 +2022,12 @@
relativerTag()/tageBis()/initialsFor() (dashboard.js) — alle laden
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
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/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/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>
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
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
umhuellt. -->
<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-info.js?v=15"></script>
<script src="/js/plan/plan-sections.js?v=13"></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-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-modals.js?v=18"></script>
<script src="/js/plan/plan-tasks.js?v=4"></script>
<script src="/js/plan/plan-modals.js?v=21"></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-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-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-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>
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen
Zwischenspeicher verwirft. -->
<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-schedule.js?v=6"></script>
<script src="/js/tabs-auth.js?v=17"></script>
<script src="/js/pdf.js?v=31"></script>
<script src="/js/plan/plan-schedule.js?v=7"></script>
<script src="/js/tabs-auth.js?v=19"></script>
<script src="/js/pdf.js?v=32"></script>
<script src="/js/backup.js?v=2"></script>
<script src="/js/import.js?v=1"></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)
---------------------------------------------------------------------------
Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES
@@ -27,107 +30,105 @@
/* ---------- Tokens: hell ---------- */
html.glas {
--bg: #e8eef3;
--surface: rgba(255,255,255,.58);
--surface-alt: rgba(255,255,255,.40);
--surface-sunk: rgba(15,90,133,.07);
--line: rgba(20,45,65,.16);
--line-soft: rgba(20,45,65,.09);
--bg: #eef1f4;
--surface: rgba(255,255,255,.82);
--surface-alt: rgba(255,255,255,.66);
--surface-sunk: rgba(16,24,32,.05);
--line: rgba(16,24,32,.13);
--line-soft: rgba(16,24,32,.07);
--accent: #0f5a85;
--accent-hover: #0d5079;
--accent-active: #0a4565;
--accent-soft: rgba(15,90,133,.11);
--accent-soft: rgba(15,90,133,.09);
--band-neu: #0f5a85;
--text: #14212b;
--text-muted: #4b5d6b;
--text-subtle: #6f8291;
--text: #16191d;
--text-muted: #4d545c;
--text-subtle: #69707a;
--r-md: 10px;
--r-lg: 18px;
--r-xl: 22px;
--r-md: 8px;
--r-lg: 14px;
--r-xl: 16px;
--sh-1: 0 8px 28px rgba(21,58,86,.10), 0 1px 4px rgba(21,58,86,.06);
--sh-2: 0 12px 34px rgba(21,58,86,.13), 0 2px 6px rgba(21,58,86,.07);
--sh-3: 0 20px 60px rgba(21,58,86,.20), 0 4px 14px rgba(21,58,86,.10);
--sh-1: 0 1px 2px rgba(16,24,32,.05);
--sh-2: 0 4px 16px rgba(16,24,32,.07), 0 1px 2px rgba(16,24,32,.04);
--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-dicht: rgba(255,255,255,.86);
--glas-rahmen: rgba(255,255,255,.84);
--glas-dicht: rgba(255,255,255,.94);
--glas-deckend: #f7fafc;
--glas-kante: rgba(255,255,255,.80);
--glas-kante-2: rgba(255,255,255,.35);
--glas-blur: 24px;
--glas-kante: rgba(255,255,255,.70);
--glas-kante-2: rgba(255,255,255,.25);
--glas-blur: 16px;
--glas-hell: #94b0cc;
--glas-tief: #116896;
--glas-logo: .16;
--glas-logo: .07;
}
/* ---------- Tokens: dunkel (System und manuell) ---------- */
@media (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) {
--bg: #0b141b;
--surface: rgba(30,46,60,.50);
--surface-alt: rgba(30,46,60,.34);
--surface-sunk: rgba(255,255,255,.06);
--line: rgba(255,255,255,.14);
--line-soft: rgba(255,255,255,.08);
--accent: #8cc9e6;
--accent-hover: #a3d4ec;
--accent-active: #6fb3d3;
--accent-soft: rgba(140,201,230,.15);
--bg: #111315;
--surface: rgba(26,29,33,.80);
--surface-alt: rgba(26,29,33,.62);
--surface-sunk: rgba(255,255,255,.05);
--line: rgba(236,238,240,.13);
--line-soft: rgba(236,238,240,.07);
--accent: #86bfdf;
--accent-hover: #9dcbe5;
--accent-active: #6aa9cc;
--accent-soft: rgba(134,191,223,.13);
--band-neu: #116896;
--text: #e8f0f5;
--text-muted: #a9bccb;
--text-subtle: #7790a2;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58);
--glas-dicht: rgba(22,34,44,.90);
--glas-deckend: #15212b;
--glas-kante: rgba(255,255,255,.18);
--glas-kante-2: rgba(255,255,255,.07);
--text: #eceef0;
--text-muted: #a5abb2;
--text-subtle: #878d95;
--sh-1: 0 1px 2px rgba(0,0,0,.4);
--sh-2: 0 4px 16px rgba(0,0,0,.45);
--sh-3: 0 16px 40px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
--glas-rahmen: rgba(24,27,30,.84);
--glas-dicht: rgba(22,24,27,.95);
--glas-deckend: #181a1d;
--glas-kante: rgba(255,255,255,.12);
--glas-kante-2: rgba(255,255,255,.05);
--glas-hell: #2c5675;
--glas-tief: #1d6f99;
--glas-logo: .10;
--glas-logo: .06;
}
}
html.glas[data-theme="dark"] {
--bg: #0b141b;
--surface: rgba(30,46,60,.50);
--surface-alt: rgba(30,46,60,.34);
--surface-sunk: rgba(255,255,255,.06);
--line: rgba(255,255,255,.14);
--line-soft: rgba(255,255,255,.08);
--accent: #8cc9e6;
--accent-hover: #a3d4ec;
--accent-active: #6fb3d3;
--accent-soft: rgba(140,201,230,.15);
--bg: #111315;
--surface: rgba(26,29,33,.80);
--surface-alt: rgba(26,29,33,.62);
--surface-sunk: rgba(255,255,255,.05);
--line: rgba(236,238,240,.13);
--line-soft: rgba(236,238,240,.07);
--accent: #86bfdf;
--accent-hover: #9dcbe5;
--accent-active: #6aa9cc;
--accent-soft: rgba(134,191,223,.13);
--band-neu: #116896;
--text: #e8f0f5;
--text-muted: #a9bccb;
--text-subtle: #7790a2;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58);
--glas-dicht: rgba(22,34,44,.90);
--glas-deckend: #15212b;
--glas-kante: rgba(255,255,255,.18);
--glas-kante-2: rgba(255,255,255,.07);
--text: #eceef0;
--text-muted: #a5abb2;
--text-subtle: #878d95;
--sh-1: 0 1px 2px rgba(0,0,0,.4);
--sh-2: 0 4px 16px rgba(0,0,0,.45);
--sh-3: 0 16px 40px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
--glas-rahmen: rgba(24,27,30,.84);
--glas-dicht: rgba(22,24,27,.95);
--glas-deckend: #181a1d;
--glas-kante: rgba(255,255,255,.12);
--glas-kante-2: rgba(255,255,255,.05);
--glas-hell: #2c5675;
--glas-tief: #1d6f99;
--glas-logo: .10;
--glas-logo: .06;
}
/* ---------- Hintergrund: Lichtflächen und Logo ---------- */
html.glas body {
background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
radial-gradient(40% 40% at 28% 82%, color-mix(in srgb, var(--glas-hell) 42%, transparent), transparent 70%),
radial-gradient(60% 55% at 8% 4%, color-mix(in srgb, var(--glas-hell) 26%, transparent), transparent 70%),
radial-gradient(50% 50% at 92% 12%, color-mix(in srgb, var(--glas-tief) 9%, transparent), transparent 70%),
var(--bg);
background-attachment: fixed;
}
@@ -157,6 +158,7 @@ html.glas .abw-team-kopf,
html.glas .report-card,
html.glas .load-card,
html.glas .settings-card,
html.glas .mn-notizen,
html.glas .dash-karte,
html.glas .dash-band,
html.glas .bericht-panel,
@@ -181,8 +183,8 @@ html.glas .plan-toast,
html.glas .glocke-menue,
html.glas .wiz {
background: var(--surface);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%);
backdrop-filter: blur(var(--glas-blur)) saturate(170%);
-webkit-backdrop-filter: blur(var(--glas-blur));
backdrop-filter: blur(var(--glas-blur));
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);
}
@@ -191,7 +193,7 @@ html.glas .wiz {
html.glas .app-topbar {
background: var(--glas-rahmen);
border-width: 0 0 1px 0;
box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(21,58,86,.06);
box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(16,24,32,.06);
}
html.glas .topbar-logo { height: 26px; }
@@ -248,11 +250,11 @@ html.glas .modal-bg {
margin: 12px 12px 0;
border-radius: var(--r-xl);
background: var(--glas-rahmen);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%);
backdrop-filter: blur(var(--glas-blur)) saturate(170%);
-webkit-backdrop-filter: blur(var(--glas-blur));
backdrop-filter: blur(var(--glas-blur));
border: 1px solid var(--glas-kante-2);
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. */
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. */
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; }
/* 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 {
border-bottom: 0;
border-radius: 999px;
padding: 8px 16px;
font-size: 13.5px;
border-radius: 8px;
height: 32px;
padding: 0 12px;
font-size: 13px;
}
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 {
background: var(--glas-dicht);
color: var(--accent);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10);
background: rgba(16,24,32,.07);
color: var(--text);
box-shadow: inset 0 0 0 1px rgba(16,24,32,.08);
}
}
@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. */
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:visited:hover { background: var(--surface-sunk); }
html.glas .nav-item.active {
background: var(--glas-dicht);
color: var(--text);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10);
box-shadow: 0 0 0 1px var(--line-soft);
}
html.glas .nav-item.active .nav-icon { color: var(--accent); }
/* 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. */
html.glas .login-seite {
background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
radial-gradient(60% 55% at 8% 4%, color-mix(in srgb, var(--glas-hell) 26%, transparent), transparent 70%),
radial-gradient(50% 50% at 92% 12%, color-mix(in srgb, var(--glas-tief) 9%, transparent), transparent 70%),
var(--bg);
}
+7
View File
@@ -76,6 +76,13 @@ function adminNavAbgleichen(){
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
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?
+1 -1
View File
@@ -748,7 +748,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
function renderKommentarAnsicht(p, locked){
const text = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || '');
const text = kommentarAnzeigeHtml(p, kommentarGewerk(), true);
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>`;
}
+142 -68
View File
@@ -55,8 +55,6 @@ const ABSENCE_TYPE_CLASS = {
// renderDashboard()-Aufruf neu gestartet (vorheriges Intervall zuerst
// gestoppt), damit beim wiederholten Tab-Wechsel nicht mehrere Intervalle
// parallel laufen.
let dashboardClockTimer = null;
// renderDashboard: lädt GET /my-dashboard und rendert Begrüßung/Uhr,
// Kennzahlen sowie die drei Bereiche in #dashboardContent. Wird nur beim
// Wechsel auf den Tab aufgerufen (showTab()), nicht beim initialen
@@ -66,6 +64,9 @@ async function renderDashboard(){
if(!container) return;
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;
try {
const res = await fetch(baseUrl() + '/my-dashboard');
@@ -76,6 +77,8 @@ async function renderDashboard(){
return;
}
const nz = await notizenAnfrage;
dashNotizen = (nz && nz.ok && nz.anzahl) || {};
const weekLabel = data.weekLabel || null;
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
const projectDates = Array.isArray(data.projectDates) ? data.projectDates : [];
@@ -103,12 +106,15 @@ async function renderDashboard(){
const heuteFaellig = tasks.filter(t => t.end === 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 = '';
html += renderDashboardKopf(weekLabel);
html += renderDashboardBand({
ueberfaellig, heuteFaellig,
ueberfaellig, heuteFaellig, dieseWoche,
aufgaben: tasks.length,
termine: projectDates.length,
freigaben: pending.length,
abwesend: absencesToday,
});
@@ -120,10 +126,10 @@ async function renderDashboard(){
// Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen,
// weil es das ist, was sich seit dem letzten Blick geändert hat.
html += '<div id="dashNeu"></div>';
html += renderDashWoche(daten);
html += '<div class="dash-zonen">';
// Linke Zone: die eigenen Projekte mit ihren Terminen
const daten = dashDaten();
html += '<div>';
const zuAlle = dashZuLesen();
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); }
dashKalDaten = daten;
renderDashKalender();
startDashboardClock();
}
// ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ----------
@@ -164,6 +169,8 @@ async function renderDashboard(){
let dashKalDaten = null;
let dashKalMonat = null; // { jahr, monat } (monat 0–11)
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
// des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu,
@@ -205,14 +212,14 @@ function dashDaten(){
});
(plan.milestones || []).forEach(m => {
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);
if(!rollen.length && !hatEigene) return;
// Erledigte Vergangenheit interessiert hier nicht: Meilensteine vor heute
// fallen weg (sie haben kein Erledigt-Kennzeichen), offene Aufgaben vor
// heute bleiben als überfällig stehen.
const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.datum < heute));
// Erreichte Meilensteine fallen weg (im Kalender bleiben sie stehen);
// nicht erreichte vor heute gelten wie offene Aufgaben als überfällig —
// dieselbe Regel wie die Zahl im Band (GET /my-dashboard).
const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.erreicht));
relevant.sort((a, b) => (a.datum || '9999').localeCompare(b.datum || '9999'));
projekte.push({ p, rollen, termine: relevant, alleTermine: termine });
});
@@ -440,9 +447,8 @@ function renderDashProjekte(daten){
const key = dashProjKey(p);
const istZu = zu.has(key);
const rollen = x.rollen.length
? x.rollen.map(r => '<span class="dash-rolle' + (r.state === 2 ? ' ist-neben' : '') + '" title="'
+ (r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung') + '">' + esc(r.rolle) + '</span>').join('')
: '<span class="dash-rolle ist-neben" title="Nicht zugeordnet, aber mit eigener Aufgabe">nur Aufgabe</span>';
? x.rollen.map(r => esc(r.rolle) + (r.state === 2 ? ', Unterstützung' : '')).join(' / ')
: 'nur eigene Aufgabe';
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
const pct = aktLp && p.lph && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? Math.max(0, Math.min(100, Number(p.lph[aktLp]) || 0)) : null;
const phase = aktLp
@@ -463,7 +469,7 @@ function renderDashProjekte(daten){
// Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele
// Termine überfällig bzw. offen sind.
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) + '">'
+ '<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>' : '')
@@ -472,10 +478,10 @@ function renderDashProjekte(daten){
+ '<span class="dash-proj-nr">' + esc(p.nr || '') + '</span><span class="dash-proj-titel">' + esc(p.name || '') + '</span>'
+ '</button>'
+ '<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>'
+ '</div>'
+ '<div class="dash-proj-inhalt">'
+ renderDashProjektInfo(x, rollen)
+ '<div class="dash-proj-sub">' + phase + '</div>'
+ '<div class="dash-proj-termine">' + zeilen + mehr + '</div>'
+ '</div>'
@@ -483,6 +489,103 @@ function renderDashProjekte(daten){
}).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
// des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe,
// fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute.
@@ -593,22 +696,30 @@ function dashKalBlaettern(n){
renderDashKalender();
}
// renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein
// rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist
// die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand.
// Die Inhalte füllt startDashboardClock() live nach.
// Parameter: weekLabel. Rückgabe: HTML-String.
// renderDashboardKopf: Titel und Kalenderwoche statt Begrüßung (Entwurf
// „Bauplan“, Eriks Wahl): „KW 45, Mo 2. bis Fr 6. November 2026“, rechts die
// Sitzung. Parameter: weekLabel. Rückgabe: HTML-String.
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">'
+ '<p class="dash-gruss"><span id="dashboardGreetingText"></span>'
+ '<em id="dashboardGreetingDate"></em></p>'
+ '<div class="dash-kopf-rechts">'
+ (weekLabel ? '<span class="dash-sitzung">' + esc(weekLabel) + '</span>' : '')
+ '<span class="dash-uhr" id="dashboardClock"></span>'
+ '</div>'
+ '<div class="dash-kopf-links"><span class="dash-kopf-kw">' + esc(zeile) + '</span>'
+ '<h2 class="dash-titel">Meine Aufgaben</h2></div>'
+ (weekLabel ? '<div class="dash-kopf-rechts"><span class="dash-kopf-label">Sitzung</span><span class="dash-sitzung">' + esc(weekLabel) + '</span></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.
//
// Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine
@@ -634,17 +745,17 @@ function renderDashboardBand(z){
satz = '<b class="ist-ruhig">Nichts offen, nichts überfällig.</b>';
}
const zahl = (wert, label) =>
'<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + '"><b>' + wert + '</b><span>' + label + '</span></div>';
const zahl = (wert, label, rot) =>
'<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;
return '<div class="dash-band' + (ruhig ? ' ist-ruhig' : '') + '">'
+ '<p class="dash-befund">' + satz + '</p>'
+ '<div class="dash-band-zahlen">'
+ 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.abwesend, 'heute abwesend')
+ '</div></div>';
}
@@ -666,43 +777,6 @@ function dashLeer(text){
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
// ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht
// 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>.' },
{ id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin',
text: () => 'Du darfst Teile der Verwaltung pflegen: ' + esc(typeof ADMIN_SECTIONS !== 'undefined' ? ADMIN_SECTIONS.filter(adminAbschnittErlaubt).map(s => s.title).join(', ') : '') + '.' },
{ id: 'ende', tab: 'dashboard', titel: 'Fertig!',
{ id: 'ende', tab: 'dashboard', titel: 'Rundgang abgeschlossen',
text: () => {
const hinweise = [];
if(currentUser && !currentUser.personId){
+1
View File
@@ -147,6 +147,7 @@ const TAB_SLUGS = {
wochenbericht: 'Wochenbericht',
load: 'Auslastung',
abwesenheit: 'Abwesenheiten',
notizen: 'Notizen',
people: 'Projektbeteiligte',
planung: 'Planung',
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] });
}
const innerContent = {
const aufgaben = pdfWbAufgaben(p, w, prevWeek);
const innerContent = { stack: [{
columns: [
{ width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) },
{ width: 190, stack: rightStack }
],
columnGap: 12
};
}].concat(aufgaben ? [aufgaben] : []) };
return {
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
// Gewerks. Steht er in der direkten Vorwoche, heißt er "Vorwoche KW …",
// 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 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));
const msZelle = upcoming.length > 0
? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein')
@@ -540,7 +540,7 @@
const ampel = loadAmpelColor(l.quote);
const breite = Math.min(100, l.quote * 100);
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;
if(l.todos.length === 0){
@@ -566,12 +566,12 @@
: `<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">
<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="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${offenText}
${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}
</div>`;
});
+9 -5
View File
@@ -93,7 +93,7 @@
for(let i = versionen.length - 1; i >= 0; i--){
const k = kommentarVon(versionen[i].project, null);
if(statusPlainText(k.status)){
letzter = { week: versionen[i].week, k, aktuell: i === versionen.length - 1 };
letzter = { week: versionen[i].week, k, project: versionen[i].project, aktuell: i === versionen.length - 1 };
break;
}
}
@@ -103,7 +103,8 @@
gewerke.forEach(g => {
let vorher = '';
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);
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 || '' });
@@ -184,8 +185,9 @@
if(editEl){
editEl.className = 'status-edit is-ansicht';
editEl.contentEditable = 'false';
// Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js).
const text = kom.letzter ? sanitizeStatusHtml(kom.letzter.k.status) : '';
// Alle Gewerke zusammen; bei mehreren je mit Überschrift, Linie und
// 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>';
}
const hinweisEl = document.getElementById('planStatusHinweis');
@@ -200,7 +202,9 @@
const autorEl = document.getElementById('planStatusAutor');
if(autorEl){
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)}`
+ (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '')
: '';
+164 -3
View File
@@ -65,6 +65,9 @@
document.getElementById('milestoneEditDate').value = msDateIso;
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
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');
if(positionSelect){
@@ -129,6 +132,7 @@
dependsOnList.innerHTML = listHtml;
}
msLageBildNeu();
document.getElementById('milestoneModal').style.display = 'flex';
document.getElementById('milestoneEditName').focus();
};
@@ -167,6 +171,7 @@
window.closeMilestoneModal = function(){
document.getElementById('milestoneModal').style.display = 'none';
app.currentMilestoneIndex = null;
if(currentTab === 'wochenbericht') berichtAufgabenNeu();
const danach = msNachSchliessen;
msNachSchliessen = null;
if(danach) setTimeout(danach, 0);
@@ -189,12 +194,14 @@
const position = positionSelect ? positionSelect.value : '';
const msNotizEl = document.getElementById('milestoneEditNotiz');
const notiz = msNotizEl ? msNotizEl.value.trim() : '';
const msErreichtEl = document.getElementById('milestoneEditErreicht');
const erreicht = !!(msErreichtEl && msErreichtEl.checked);
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = [];
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]){
const m = plan.milestones[app.currentMilestoneIndex];
m.name = name;
@@ -202,6 +209,7 @@
m.dependsOn = dependsOn;
m.position = position;
m.notiz = notiz;
m.erreicht = erreicht;
}
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
app.savePlan();
@@ -266,6 +274,140 @@
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
// Fortschrittsregler nur im Zustand "In Arbeit" ein (offen = 0 %,
// erledigt = 100 % sind implizit). Parameter: status (String),
@@ -296,6 +438,7 @@
slider.value = value;
if(label) label.textContent = value + ' %';
}
todoLageNeu();
}
// setTodoEditStatus: Klick-Handler des Status-Segments im TODO-Modal.
@@ -313,6 +456,7 @@
todoProgressMemo = value;
const label = document.getElementById('todoEditProgressVal');
if(label) label.textContent = value + ' %';
todoLageNeu();
};
// currentTodoEditStatus: liest den aktuell gewählten Segment-Status aus dem
@@ -335,6 +479,7 @@
badge.hidden = days <= 0;
badge.textContent = days > 0 ? (days + ' AT') : '';
clearFieldError('todoEditRange');
todoLageNeu();
};
// setFieldError / clearFieldError: zeigen einen Validierungshinweis direkt
@@ -401,6 +546,17 @@
const count = list.querySelectorAll('input[type="checkbox"]:checked').length;
badge.textContent = count;
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
@@ -459,6 +615,8 @@
const neu = document.getElementById('todoEditSaveNew');
if(abs) abs.hidden = !an;
if(wer) wer.hidden = an;
const aufwandAbs = document.getElementById('todoEditAufwandAbs');
if(aufwandAbs) aufwandAbs.hidden = an;
if(neu) neu.hidden = an;
const titelEl = document.getElementById('todoModalTitle');
if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten');
@@ -644,6 +802,7 @@
}
todoAufwandInfo();
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);
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
@@ -687,9 +846,10 @@
const searchInput = document.getElementById('todoEditDependsSearch');
if(searchInput) searchInput.value = '';
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');
if(fold) fold.open = !!(currentTodo && Array.isArray(currentTodo.dependsOn) && currentTodo.dependsOn.length > 0);
if(fold) fold.open = false;
const assigneesDiv = document.getElementById('todoEditAssignees');
if(assigneesDiv){
@@ -761,6 +921,7 @@
}
document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null;
if(currentTab === 'wochenbericht') berichtAufgabenNeu();
todoModalSnapshot = '';
const danach = todoNachSchliessen;
todoNachSchliessen = null;
+7
View File
@@ -406,5 +406,12 @@
ausDaten(e.clipboardData);
});
// 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(); });
})();
+1 -1
View File
@@ -100,7 +100,7 @@
// ein Fallback ist ein akzeptiertes Ergebnis, kein Fehlschlag.
function zeigeFallbackHinweis(msg){
const el = document.getElementById('protokollTodosFallback');
if(el){ el.textContent = '⚠️ ' + msg; el.hidden = false; }
if(el){ el.textContent = msg; el.hidden = false; }
}
function verbergeFallbackHinweis(){
const el = document.getElementById('protokollTodosFallback');
+2 -2
View File
@@ -548,7 +548,7 @@
// ab, verschwindet also nie von selbst, sondern nur wenn man das
// Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar.
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
// 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
// in der Oberfläche sah.
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 = `
${kiFallbackBanner}
+1 -1
View File
@@ -128,7 +128,7 @@
if(moved.size > 0){
app.savePlan();
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){
app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.');
} else {
+3 -4
View File
@@ -107,9 +107,8 @@
if(nav) nav.style.display = '';
}
// upcomingEntries: offene Aufgaben mit Endtermin und Meilensteine ab heute
// (vergangene Meilensteine haben kein Erledigt-Kennzeichen und gelten als
// gewesen), chronologisch. idx verweist in plan.todos/plan.milestones, damit
// upcomingEntries: offene Aufgaben mit Endtermin und nicht erreichte
// Meilensteine (vergangene gelten als überfällig), chronologisch. idx verweist in plan.todos/plan.milestones, damit
// ein Klick denselben Dialog öffnet wie im Terminplan.
// Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }.
function upcomingEntries(){
@@ -126,7 +125,7 @@
meins: !!ich && (t.assignees || []).includes(ich) });
});
(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.sort((a, b) => a.iso.localeCompare(b.iso));
+3 -3
View File
@@ -166,16 +166,16 @@
const d = parseDateISO(m.date);
const today = new Date();
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"`;
return `<div class="plan-task-row${locked ? ' is-locked' : ''}"${msAction}>
<span class="plan-task-dot"></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>`;
}).join('');
}
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 === 'neu') return liveAllesNeu();
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
+6 -2
View File
@@ -23,7 +23,7 @@
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
// Dashboard ins Projektboard.
const TAB_PANELS = ['board','wochenbericht','load','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){
// 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==='load') renderLoad();
if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); }
if(t==='notizen' && typeof renderMeineNotizen === 'function') renderMeineNotizen();
if(t==='people') renderPeople();
if(t==='planung') renderPlanung();
if(t==='planchecker') renderPlanChecker();
@@ -108,6 +109,9 @@ function syncPlanNavItem(){
const name = app && app.projName ? app.projName : '';
item.style.display = name ? '' : 'none';
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';
}
+39 -8
View File
@@ -385,8 +385,21 @@ function kommentarGewerk(){
// Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des
// Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder
// null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }.
function kommentarVon(p, g){
// 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 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){
const e = alle[g] || {};
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
// nur dessen Text; ohne Gewerk die Texte aller Gewerke, bei mehreren je mit
// dem Gewerk als eigener, dezenter Beschriftung (.kommentar-gewerk) —
// Systemtext, nicht Teil des Kommentars (anders als die fette "ELT:"-Zeile,
// die kommentarVon() für Klartext-Vorschauen einfügt). Rückgabe: HTML ('' ohne Text).
function kommentarAnzeigeHtml(p, g){
// nur dessen Text; ohne Gewerk die Texte aller Gewerke des Projekts. Bei
// mehreren Gewerken Darstellung „B – Überschrift mit Linie“ (Eriks Wahl):
// je Gewerk eine Kopfzeile mit dem Gewerk in seiner Farbe, einer feinen Linie
// und (mitMeta) Verfasser und Zeit, darunter der Text — Systemtext, nicht
// 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 || '');
const alle = (p && p.kommentare) || {};
const alle = kommentareDesProjekts(p);
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
if(mitText.length === 0) return '';
if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status);
return mitText.map(k => `<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
+1 -1
View File
@@ -822,7 +822,7 @@ function wtTageDaten(w){
// (offen/überfällig) sind über die Kennzahlen oben erreichbar, die heute
// Abwesenden über die Kopfleiste.
(plan.milestones || []).forEach(m => {
if(m.date && m.date >= heute && spalteFuer[m.date]) spalteFuer[m.date].eintraege.push({ art: 'ms', titel: m.name || 'Meilenstein', projekt: hinweisFuer(m.date) + projekt, pid: pr.id, abschnitt: 'terminplan', sort: m.date });
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))));
+130
View File
@@ -666,12 +666,14 @@ function renderBerichtListe(){
const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : '';
gruppeVorher = gruppe;
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' : ''}"
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}"
aria-current="${istAktiv ? 'true' : 'false'}">
<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>`}
<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>`;
}).join('');
@@ -713,6 +715,7 @@ function renderBerichtDetail(p, w, prevWeek, locked){
<div class="bericht-sub">${subText}</div>
${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 }))}
${renderBerichtAufgaben(p, w, prevWeek, locked)}
<div class="bericht-detail-fuss">
${!locked && hatRecht('proj.plan') ? `<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
Aufgabe aus Kommentar…
@@ -876,6 +879,133 @@ function berichtVerlaufNeu(){
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
// Kommentarfeld der aktuellen (takeCommentFromHistory(), js/board/board.js).
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;');
}
// 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
// font-weight durch — identisch zur Client-Regel. Rückgabe: bereinigter
// Wert oder '' (dann wird das Attribut ganz weggelassen).
@@ -1108,7 +1132,7 @@ function sanitizeFormatHtml(html){
if(c !== '<'){
const naechstes = quelle.indexOf('<', i);
const text = quelle.slice(i, naechstes === -1 ? quelle.length : naechstes);
if(!verwerfenBis) aus += htmlEscape(text);
if(!verwerfenBis) aus += textMaskieren(text);
i += text.length;
continue;
}
@@ -1226,8 +1250,8 @@ function projektStatements(){
VALUES (?, ?, ?, ?)`);
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
VALUES (?, ?, ?, ?)`);
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz, erreicht)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
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(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 || '')));
});
}
@@ -1540,7 +1564,7 @@ function loadWeeks(nurLabel){
const msMap = {};
milestones.forEach(r => {
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 => {
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
// einspielbar.
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
// 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.');
}
// 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(){
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 = ?');
@@ -2682,6 +2730,12 @@ function migrateAddPlanTodoNotiz(){
db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
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
@@ -2908,8 +2962,12 @@ function ensureCurrentWeekRollover(){
// zur Sitzung und beginnt leer.
const vorher = 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 => {
const k = vorher[g] || {};
if(gewerke.length && !gewerke.includes(g)) return;
if(!String(k.status || '').trim()) return;
p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '',
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
// jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen.
kommentareVorsatzUmstellen();
entitiesReparieren();
ensureSandboxWeek();
ensureUserPersonIdUniqueIndex();
migrateRetirePeopleTablesIntoUsers();
@@ -5568,14 +5627,13 @@ app.get('/my-dashboard', requireAuth, (req, res) => {
AND t.project_id IN (${platzhalter})
`).all(weekLabel, ...meineProjekte);
// Meilensteine: sie tragen kein Erledigt-Kennzeichen, deshalb zählt
// allein das Datum.
// Meilensteine: nur die noch nicht erreichten.
const milestones = db.prepare(`
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
FROM plan_milestones m
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})
`).all(weekLabel, ...meineProjekte);
@@ -7362,6 +7420,68 @@ function notizAnlegen(req, uid, titel, html, quelle){
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) => {
try {
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 {
const b = req.body || {};
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, '') });
} catch(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,
// auf dem die Änderung beruht; weicht der gespeicherte ab, 409 mit dem
// 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 {
const alt = notizLesen(req.params.id);
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 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){
return res.status(409).json({ ok: false, konflikt: true, notiz: alt,
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.
const nurPin = typeof b.titel !== 'string' && typeof b.html !== 'string';
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 = ?`)
.run(titel, html, angeheftet, jetzt, alt.id);
.run(titel, html, angeheftet, jetzt, neueUid, alt.id);
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) });
} catch(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 {
const alt = notizLesen(req.params.id);
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)){
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
// KI: Titel = Betreff (sonst erste Zeile), oben Absender und Datum, darunter
// 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 {
const b = req.body || {};
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)
: { betreff: '', absender: '', datum: '', text: String(b.text || '') };
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.
migrateKommentareJeGewerk();
kommentareVorsatzUmstellen();
entitiesReparieren();
// Ältere Backups kennen das Feld leistungsphase noch nicht.
phasenbalkenAusTitel();
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
// enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um
// (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr.
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','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)
const PLAN_SLUG = 'Projektplan';