7 Commits
Author SHA1 Message Date
erikandClaude Opus 5.5 4ac3d8d53d Kommentare je Gewerk klar getrennt, abgewählte Gewerke ausgeblendet
- Kommentare mehrerer Gewerke mit Überschrift und Linie: Gewerk in seiner
  Farbe, rechts Verfasser und Zeit, darunter der Text (Projekt-Dashboard,
  Projektliste, Wandtafel, Verlauf-Kacheln)
- Behoben: Im Projekt-Dashboard erschienen Kommentare von Gewerken, die das
  Projekt nicht mehr hat (z. B. HLS bei 21-4030); Anzeige und Verlauf zeigen
  nur noch Gewerke des Projekts
- Sitzungswechsel übernimmt nur noch Kommentare der Gewerke des Projekts

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:32:49 +01:00
erikandClaude Opus 5.5 7d65ff813f Notizen je Projekt statt Reiter Leistungsphasen
- Neuer Reiter „Notizen“ im Projektbereich: Liste mit Suche und
  angehefteten Notizen, Editor mit Formatierung, automatisches Speichern,
  Mail per Drag & Drop oder Strg+V als neue Notiz, live bei allen
- Reiter „Leistungsphasen“ entfernt (Phasen weiter im Terminplan)
- Behoben: Umlaute in .eml-Dateien ohne Transfer-Kodierung gingen
  verloren (betraf auch „Termin aus Mail“)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:00:20 +01:00
erikandClaude Opus 5.5 51dfbe8cd8 Teams-Einstellungen im Admin-Bereich überarbeitet
- Breites, mehrzeiliges Adressfeld mit Prüfung und Hinweis
- Statuszeile, Schalter für die Benachrichtigungen, „Adresse ändern“

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:42:38 +01:00
erikandClaude Opus 5.5 e7243f6f26 Externe Personen bei Aufgaben, Aufwand-Feld, Teams persönlich
- Aufgaben auch Kollegen außerhalb des Projekts zuweisen
  (Abschnitt „Externe Personen“ im Aufgaben-Panel)
- Aufwand-Feld neu gestaltet: −/+, Schnellwahl, Umrechnung in Arbeitstage
- Teams: persönliche Nachricht an die Zuständigen (Flow-Bot) statt Kanal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:36:06 +01:00
erikandClaude Opus 5.5 4c2e04e78b Kleine Verbesserungen und Teams, Outlook Integration
- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine,
  Abwesenheiten als .ics), einzurichten unter Einstellungen
- Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt
  auf die Aufgabe; einzurichten unter Admin → Teams
- Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei
  davor, im Verlauf „Weitere laden“ in Fünferschritten
- Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei
  Meilensteinen, nur aktiv mit eingerichteter KI
- Kalender zeigt die KW, auch statt der Browser-Datumsfelder
- Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:23:02 +01:00
32 changed files with 4388 additions and 1661 deletions

No files matched your search

+332 -15
View File
@@ -1422,8 +1422,9 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
`renderPlanKommentarVerlauf()` in `plan-info.js`): je Gewerk nur, wenn `renderPlanKommentarVerlauf()` in `plan-info.js`): je Gewerk nur, wenn
sich der Text gegenüber der Sitzung davor geändert hat; gleicher Text in sich der Text gegenüber der Sitzung davor geändert hat; gleicher Text in
mehreren Gewerken einer Sitzung (Altbestand aus der Umstellung auf mehreren Gewerken einer Sitzung (Altbestand aus der Umstellung auf
Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Drei sichtbar, Rest Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Gezeigt wird, was
über „Alle N anzeigen“. Keine neue Datenhaltung — jede Sitzung hält ihre geladen ist, darunter „Weitere laden“ (siehe „Sitzungen nach Bedarf
laden“). Keine neue Datenhaltung — jede Sitzung hält ihre
Kopie des Projekts. Stile: Block „Kommentar der Sitzung mit Verlauf“ am Kopie des Projekts. Stile: Block „Kommentar der Sitzung mit Verlauf“ am
Ende von `plan-styles.css`. Ende von `plan-styles.css`.
**Eigene Kachel „Protokolle“** (Eriks Wunsch; links unter „Termine und **Eigene Kachel „Protokolle“** (Eriks Wunsch; links unter „Termine und
@@ -1520,6 +1521,25 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this
view now also follows the app-wide dark-mode palette like everywhere view now also follows the app-wide dark-mode palette like everywhere
else. else.
- **Aufwand im Aufgaben-Panel** (Eriks Wunsch „schöner darstellen“):
Kapsel mit −/+ (±1 h) und Einheit „Std.“, Schnellwahl 2 h / ½ Tag /
1 Tag / 2 Tage / 1 Woche (Tage über `APP_SETTINGS.arbeitsstundenProTag`;
erneuter Klick leert), darunter die Umrechnung in Arbeitstage
(`todoAufwandAendern()`/`todoAufwandSetzen()`/`todoAufwandInfo()`,
`plan-modals.js`). Feld-id `todoEditAufwand` und Speichern unverändert.
Das Eingabefeld ist per `!important` durchsichtig, weil die Glas-Regeln
für Eingabefelder ihm sonst eine eigene Fläche geben.
- **„Externe Personen“ im Aufgaben-Panel** (Eriks Wunsch): unter „Wer“
stehen oben die Projektbeteiligten als Chips, darunter der Abschnitt
„Externe Personen“ — Kollegen mit Konto, die dem Projekt NICHT
zugeordnet sind, über eine Auswahl nach Gewerk (`todoExterneSetzen()`,
`todoExternHinzufuegen()`/`todoExternEntfernen()` in `plan-modals.js`;
Quelle: Teilnehmerkreis der Sitzung ohne die Projektbeteiligten). Sie
landen ganz normal in `t.assignees` — damit zählen sie zu ihrer
Auslastung, erscheinen unter „Meine Aufgaben“, bekommen Glocke und
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.
- **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“): - **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“):
`#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am `#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am
Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der
@@ -1532,6 +1552,37 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
`plan-modals.js`) über dem bisherigen Feld `#milestoneEditPosition`, `plan-modals.js`) über dem bisherigen Feld `#milestoneEditPosition`,
das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert. das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert.
Stile: Block „Seitenpanel“ am Ende von `plan-styles.css`. Stile: Block „Seitenpanel“ am Ende von `plan-styles.css`.
- **Notizen** (Reiter zwischen Terminplan und Aufgaben, Eriks Wahl „A –
Liste + Editor“ aus drei Entwürfen, `js/plan/plan-notizen.js`):
gemeinsame Notizen je Projekt. Links Liste (angeheftete oben, sonst
zuletzt geändert zuerst, Suche über Titel und Text), rechts Editor mit
Titel, Werkzeugleiste des Sitzungskommentars (`richtextToolbarHtml()`)
und Text; **automatisches Speichern** 1,2 s nach der letzten Änderung und
beim Verlassen des Feldes (MutationObserver, weil die Werkzeugleiste kein
input-Ereignis auslöst), Anzeige „Gespeichert ✓“. Anheften, Löschen
zweistufig im Knopf. **Mail** (Text oder `.eml`) per Drag & Drop auf den
Bereich oder Strg+V außerhalb der Felder → neue Notiz OHNE KI (Titel =
Betreff, Von/Datum oben, Zitate abgeschnitten).
Server: Tabelle `projekt_notizen` (an `project_uid`, nicht an der
Sitzung; in `IMPORT_TABLES` + `IMPORT_OPTIONAL_TABLES`), Routen `GET
/projekt-notizen?uid=`, `POST /projekt-notizen`, `PUT
/projekt-notizen/:id` (mit `basis` → 409 bei fremder Änderung; Client
bietet „Meine Fassung speichern“/„Neuen Stand laden“), `DELETE` (nur
Verfasser oder Administrator), `POST /projekt-notizen/aus-mail`. Lesen:
Grundzugang; Schreiben: `proj.plan`. Live-Ereignis `{typ:'notizen',
uid}` → `notizenLiveAenderung()`. Stile: Block „Notizen“ am Ende von
`plan-styles.css`.
**Gefunden dabei:** `leseEml()` zerstörte Umlaute in `.eml`-Dateien ohne
Transfer-Kodierung (7bit/8bit, der Normalfall) — der Text kam schon als
UTF-8-Zeichenkette vom Browser und wurde fälschlich als latin1-Bytes neu
dekodiert. Betraf auch „Termin aus Mail“; behoben.
- **Reiter „Leistungsphasen“ entfernt** (Eriks Wunsch, 30.10.2026): Nav-Knopf,
Abschnitt `#planSection-phasen`, `renderPlanPhasen()` und der CSS-Block
sind weg; ein alter Link auf `#phasen` landet auf dem Dashboard.
`js/plan/plan-phasen.js` enthält nur noch `aufgabenPhase()` (genutzt von
der Aufgabenliste und dem Terminplan-PDF). „+ Leistungsphase“ und
„Terminplan aus Honorar“ bleiben im Terminplan unter „+ Neu“. Der
folgende Absatz beschreibt den entfernten Stand.
- **Leistungsphasen** (eigener Reiter zwischen Terminplan und Aufgaben, - **Leistungsphasen** (eigener Reiter zwischen Terminplan und Aufgaben,
Eriks Wahl „B“, `js/plan/plan-phasen.js`): oben ein Ablaufbalken aller Eriks Wahl „B“, `js/plan/plan-phasen.js`): oben ein Ablaufbalken aller
Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter
@@ -1786,13 +1837,68 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
`plan-gantt.js` both bail out unless `currentTab === 'plan'` — they now run `plan-gantt.js` both bail out unless `currentTab === 'plan'` — they now run
while completely different tabs are on screen. while completely different tabs are on screen.
### Notizen in der Seitenleiste (Tab `notizen`, `js/notizen/notizen.js`)
Eriks Wunsch: ein eigener Bereich „Notizen“ (Seitenleiste, eigene Gruppe
„Notizen“ unter „Team“, `/Notizen`), in dem jedes Konto die Notizen ALLER seiner
Projekte sieht und **projektunabhängige Notizen** anlegt, die sich später
einem Projekt zuordnen lassen. Gleicher Aufbau wie der Projekt-Reiter
(Liste + Editor, Werkzeugleiste, automatisches Speichern, Anheften, Löschen
zweistufig), dazu oben ein **Projektfilter** (Alle / Ohne Projekt / je
Projekt mit Anzahl) und im Editor die Auswahl **„Projekt“** (Ohne Projekt,
„Meine Projekte“, „Weitere Projekte“ der aktuellen Sitzung) plus „Im Projekt
öffnen“. „+ Neu“ legt im gefilterten Projekt an, sonst ohne Projekt. Eigene
Element-ids (`mn…`) und Funktionen (`mnNeu()`, `mnZuordnen()` …), weil der
Projekt-Reiter gleichzeitig im DOM liegt; Stile aus `.notizen-*`
(plan-styles.css) plus Block „NOTIZEN (Seitenleiste)“ am Ende von
`styles.css`.
- **Server:** Eine projektunabhängige Notiz ist eine Zeile in
`projekt_notizen` mit `project_uid = ''`; sie sieht und ändert NUR ihr
Verfasser (`notizDarf()`). `GET /meine-notizen` liefert die Notizen der
eigenen Projekte (`meineProjektUids()`: jüngste echte Sitzung, zugeordnet
oder mit eigener Aufgabe — Regel wie `dashDaten()`) plus die eigenen
projektunabhängigen, dazu `meineProjekte`. `PUT /projekt-notizen/:id` nimmt
`uid` zum Zuordnen (Ziel-Projekt braucht `proj.plan`, Lösen nur der
Verfasser). `POST`/`PUT`/`DELETE`/`aus-mail` prüfen jetzt selbst
(`notizDarf()`) statt pauschal `requireRecht('proj.plan')`: ohne Projekt
genügt die Anmeldung. `GET /projekt-notizen/anzahl` zählt nur
Projekt-Notizen.
- **Live:** `{typ:'notizen'}` geht in `store.js` an `meineNotizenLive()` und
`notizenLiveAenderung()`; das Echo der eigenen Speicherung (kommt auch im
selben Tab an) wird 3 s lang ausgelassen, sonst verschwände die Anzeige
„Gespeichert ✓“.
### Seitenleiste — Gliederung nach Arbeitsbereich (11/2026)
Eriks Wahl „A“ (unslop + Taste-Skill): Die Einträge stehen in Gruppen
(`.sidebar-gruppe` in `App.html`): **Dashboard** · **Projekte**
(Projekte, darunter eingezogen das geöffnete Projekt `#planNavItem` mit
Projektnummer in Mono, Wochenbericht, Terminplan) · **Team** (Auslastung,
Abwesenheiten) · **Notizen** (eigene Gruppe, Eriks Wunsch) · **Studenten** (Plan Checker, ganze Gruppe `nur-recht
wz.checker`) · unten `.sidebar-unten` mit **Einstellungen** und **Admin**.
Die Überschriften „Navigation“ und „Planung“ sind entfallen; der Menüpunkt
heißt weiter „Dashboard“. Admin-Unterpunkte in drei Zwischengruppen
(`.sidebar-subgruppe`: Personen, Vorgaben, System); `adminNavAbgleichen()`
blendet eine Zwischengruppe aus, wenn keiner ihrer Punkte erlaubt ist.
Maße: Einträge 32 px, Icon 18 px + 8 px, Rubrik 16 px über der Gruppe und
4 px über ihren Einträgen. Stile: Block „SEITENLEISTE“ am Ende von
`styles.css`.
**Falle in CSS-Kommentaren:** `*/` beendet einen Kommentar auch mitten im
Text (z. B. „--dash-*/--wt-*“). Der Rest wird dann als Regel gelesen und
verschluckt die nächste echte Regel. So fehlten zeitweise `--font-mono`
(alle Mono-Ziffern) und `--accent` im automatischen Dunkelmodus.
### Termin aus Mail (`js/plan/plan-mail-eintrag.js`) ### Termin aus Mail (`js/plan/plan-mail-eintrag.js`)
Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze
`.eml`-Datei) oder Strg+V (außerhalb von Eingabefeldern) auf die Bereiche `.eml`-Datei) oder Strg+V (außerhalb von Eingabefeldern) auf die Bereiche
**Terminplan** oder **Aufgaben** eines Projekts; eine KI macht daraus **Terminplan** oder **Aufgaben** eines Projekts; eine KI macht daraus
Einträge. **Nur Aufgaben, keine Meilensteine** — Eriks Vorgabe: Der Einträge — **Aufgabe oder Meilenstein**: Die KI schlägt die Art vor
Terminplan ist nur die grafische Darstellung der Aufgabenliste. (`art` in `KI_MAIL_SCHEMA`), in der Auswahlliste `#mailAuswahlModal` (immer,
auch bei nur einem Vorschlag) steht je Zeile der Umschalter
„Aufgabe | Meilenstein“ (Eriks Wunsch). Danach öffnet sich je Eintrag das
passende Panel.
- Beim Hineinziehen erscheint die Fläche `#mailAblage` (fest, `pointer-events: - Beim Hineinziehen erscheint die Fläche `#mailAblage` (fest, `pointer-events:
none`, Zähler für dragenter/-leave). Nicht in gesperrten Sitzungen. Kein none`, Zähler für dragenter/-leave). Nicht in gesperrten Sitzungen. Kein
Konflikt mit dem Balken-Ziehen (das läuft über Maus-Ereignisse). Konflikt mit dem Balken-Ziehen (das läuft über Maus-Ereignisse).
@@ -1805,15 +1911,25 @@ Terminplan ist nur die grafische Darstellung der Aufgabenliste.
„nächsten Freitag“ stimmt. Ohne KI/bei Fehler: ein Eintrag mit dem Betreff „nächsten Freitag“ stimmt. Ohne KI/bei Fehler: ein Eintrag mit dem Betreff
als Titel. **Speichert nichts.** `.msg` (Outlook) wird abgewiesen mit dem als Titel. **Speichert nichts.** `.msg` (Outlook) wird abgewiesen mit dem
Hinweis, Text zu markieren. Hinweis, Text zu markieren.
- **Nur mit eingerichteter KI aktiv** (Eriks Wunsch): `mailKiStatusLaden()`
holt nach der Anmeldung (`afterAuthenticated()`) und nach jeder Änderung
der KI-Zugangsdaten (`putMyAiKey()`) die Anbieter mit Schlüssel; ohne
einen gibt es weder Ablagefläche noch abgefangenes Strg+V.
- Anbieter: zuletzt erfolgreich genutzter (`localStorage - Anbieter: zuletzt erfolgreich genutzter (`localStorage
projuebersicht-mail-ki`), sonst der erste mit Schlüssel. projuebersicht-mail-ki`), sonst der erste mit Schlüssel.
- Jeder Vorschlag öffnet das normale Aufgaben-Panel vorausgefüllt - Umschalter „Aufgabe | Meilenstein“: eigene Stile `.segmented.mail-auswahl-art`
(`openTodoModal(null, prefill)`, neu: `title`, `aufwand`, `notiz`, und Klasse `mail-art-knopf` (sonst greift unter Glas
`mailHinweis`, `nachSchliessen`); bei mehreren erst eine Auswahl `html.glas button:not([class])` mit Rand und Glanzkante, und der gewählte
(`#mailAuswahlModal`), dann nacheinander. Ohne Speichern entsteht nichts. Knopf war von `var(--surface)` kaum zu unterscheiden).
- **Notiz je Aufgabe** (`t.notiz`, `plan_todos.notiz`, Migration - Jeder gewählte Vorschlag öffnet nacheinander das normale Panel
`migrateAddPlanTodoNotiz()`, in `IMPORT_OPTIONAL_COLS`): Feld „Notiz“ im vorausgefüllt: `openTodoModal(null, prefill)` (`title`, `start`/`end`,
Aufgaben-Panel für jede Aufgabe; aus einer Mail mit Absender, Datum, `assignees`, `aufwand`, `notiz`, `mailHinweis`, `nachSchliessen`) bzw.
`openMilestoneModal(null, prefill)` (`name`, `date` = Ende des Vorschlags,
`notiz`, `mailHinweis`, `nachSchliessen`). Ohne Speichern entsteht nichts.
- **Notiz je Aufgabe und Meilenstein** (`t.notiz`/`m.notiz`,
`plan_todos.notiz`/`plan_milestones.notiz`, Migration
`migrateAddPlanTodoNotiz()`, in `IMPORT_OPTIONAL_COLS`): Feld „Notiz“ in
beiden Panels; aus einer Mail mit Absender, Datum,
Betreff, Bezugssatz und Mailtext. In der Aufgabenliste ein Symbol mit Betreff, Bezugssatz und Mailtext. In der Aufgabenliste ein Symbol mit
Tooltip. Stile: Block „Termin aus Mail“ am Ende von `plan-styles.css`. Tooltip. Stile: Block „Termin aus Mail“ am Ende von `plan-styles.css`.
@@ -3458,8 +3574,8 @@ nur, wenn sich der Kommentar DES GEWÄHLTEN GEWERKS gegenüber der Sitzung davor
geändert hat; Sitzungen dazwischen als grauer Vermerk („KW 40 bis KW 43 ohne geändert hat; Sitzungen dazwischen als grauer Vermerk („KW 40 bis KW 43 ohne
neuen Kommentar“); geänderte Leistungsphasen seit dem vorigen Eintrag als neuen Kommentar“); geänderte Leistungsphasen seit dem vorigen Eintrag als
Chips („LPH 5: 60 → 75 %“); Verfasser und Datum; „übernehmen“ Chips („LPH 5: 60 → 75 %“); Verfasser und Datum; „übernehmen“
(`berichtVerlaufUebernehmen()` → `takeCommentFromHistory()`); drei (`berichtVerlaufUebernehmen()` → `takeCommentFromHistory()`); gezeigt wird,
sichtbar, Rest über „Alle N anzeigen“ (`berichtVerlaufUmschalten()` zeichnet was geladen ist, „Weitere laden“ holt ältere (`berichtVerlaufNeu()` zeichnet
nur den Abschnitt neu, ein angefangener Kommentar bleibt). Die dichte Karte nur den Abschnitt neu, ein angefangener Kommentar bleibt). Die dichte Karte
bekommt dafür `opts.ohneVerlauf` (`renderBoardDrawer()`), damit der bekommt dafür `opts.ohneVerlauf` (`renderBoardDrawer()`), damit der
Kachel-Streifen dort nicht zusätzlich erscheint. Projektliste und Kachel-Streifen dort nicht zusätzlich erscheint. Projektliste und
@@ -3540,6 +3656,26 @@ Stile: Block „Verlauf der Vorwochen im Wochenbericht“ (`.bv-*`,
### Dashboard ("Meine Aufgaben", now the default landing tab) ### Dashboard ("Meine Aufgaben", now the default landing tab)
**Stand 11/2026 — Entwurf „Bauplan“ (Eriks Wahl, Vorlage im Artifact
„Projektübersicht neu gestaltet“):** Kopf ohne Begrüßung und Uhr: Zeile
„KW 45, Mo 2. bis Fr 6. November 2026“ (Mono) über dem Titel „Meine
Aufgaben“, rechts die Sitzung (`renderDashboardKopf()`, `dashWochenMontag()`;
`greetingForHour()`/`startDashboardClock()` sind entfallen). Band mit Satz und
vier Zahlen: offene Aufgaben, überfällig (rot), diese Woche fällig (aus
`dashDaten()`, heute bis Sonntag), Freigaben. Neu darunter **„Diese Woche“**
(`renderDashWoche()`): Mo–Fr als Spalten mit den Terminen der eigenen
Projekte, getönt nach eigene Aufgabe / Aufgabe im Projekt / Meilenstein /
überfällig, je Tag höchstens `DASH_WOCHE_JE_TAG` (4). Jeder Projektblock hat
die Zeile **„Meine Rolle | Team | Notizen“** (`renderDashProjektInfo()`, Eriks
Wunsch): Team = übrige Beteiligte als Kürzel-Kreise in Rollenfarben
(`beteiligteJePerson()`/`beteiligtenAvatarHtml()`, höchstens 5, Namen
gekürzt), Notizen nur wenn vorhanden, mit Sprung in den Reiter „Notizen“.
Die Anzahl liefert **`GET /projekt-notizen/anzahl`** (`{uid: {anzahl,
zuletzt}}`, requireAuth), parallel zu `/my-dashboard` geladen
(`dashNotizen`). Die Rollen-Chips im Projektkopf sind dafür entfallen. Der
**Monatskalender bleibt unverändert** (Eriks Vorgabe). Stile: Block „MEINE
AUFGABEN — Entwurf Bauplan“ am Ende von `styles.css`.
**Aktueller Aufbau (Entwurf „B — Monatsblatt“, Eriks Wahl aus drei Varianten):** **Aktueller Aufbau (Entwurf „B — Monatsblatt“, Eriks Wahl aus drei Varianten):**
Kopf und Band wie unten beschrieben; darunter **links „Meine Projekte“**, je Kopf und Band wie unten beschrieben; darunter **links „Meine Projekte“**, je
Projekt ein Block (`renderDashProjekte()`): eigene Rollen als Chips (gestrichelt Projekt ein Block (`renderDashProjekte()`): eigene Rollen als Chips (gestrichelt
@@ -4168,6 +4304,103 @@ across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr."
no special-casing needed. A redirect `GET /Test.html`-style old link isn't no special-casing needed. A redirect `GET /Test.html`-style old link isn't
needed here since nothing ever pointed at this tab before it existed. needed here since nothing ever pointed at this tab before it existed.
## Kalender-Abo für Outlook (iCalendar)
Eriks Wunsch: die eigenen Termine in Outlook sehen, nur zum Lesen.
Einstellungen → Mein Konto → Karte „Kalender in Outlook“
(`renderSettingsKalender()`, `js/settings/settings.js`): Adresse erzeugen,
kopieren, „In Outlook öffnen“ (`webcal:`-Link), Anleitung, „Neue Adresse“
(alte wird ungültig), „Abschalten“.
- **Server:** `users.kalender_token` (Migration in `migrateUsersDepartment()`,
leer = aus). `GET`/`POST /mein-kalender` (requireAuth, nur eigenes Konto;
POST `{aus:true}` schaltet ab, sonst neue Adresse). **`GET
/kalender/<token>.ics` ist ohne Sitzung erreichbar** — der Token (24 Byte
zufällig) IST die Anmeldung, weil Outlook sich nicht einloggen kann.
Unbekannt, deaktiviertes Konto oder Rolle ohne „Anwendung nutzen“ → 404.
- **Inhalt** (`kalenderEreignisse()`, rein und testbar): aus der jüngsten
echten Sitzung die eigenen offenen Aufgaben mit Termin (ohne erledigte und
ohne Phasenbalken), Meilensteine der Projekte, denen man zugeordnet ist
oder in denen man eine eigene Aufgabe hat, dazu die eigenen
Abwesenheiten. Alles ganztägig (`VALUE=DATE`, DTEND = Tag danach), als
„frei“ (Abwesenheit „abwesend“). Notizen gehen in die Beschreibung.
`kalenderText()` maskiert nach RFC 5545 und faltet Zeilen auf 75 Byte.
- **Adresse** (`kalenderUrl()`): der Host, über den der Browser die App
aufruft — wer über `localhost` öffnet, bekommt eine `localhost`-Adresse
(eine automatische Ersetzung durch die Geräte-IP hat Erik verworfen, weil
sie im Docker-Container nicht verlässlich ist).
- **Grenze:** Neues Outlook / Outlook im Browser holen das Abo über
Microsoft-Server aus dem Internet ab — dafür muss der Server von außen
erreichbar sein. Das klassische Outlook am PC holt es im Firmennetz direkt.
## Teams: persönliche Nachricht bei neuen Aufgaben
Eriks Wunsch („nur persönlich“): Wird jemandem eine Aufgabe zugewiesen,
bekommt GENAU DIESE Person in Teams eine Nachricht im Chat mit dem Flow-Bot,
mit Link direkt auf die Aufgabe. Kein Kanal.
- **Einrichten in Teams** (Anleitung aufklappbar unter **Admin → Teams**):
leerer Workflow, Auslöser „Bei Empfang einer Teams-Webhookanforderung“
(Jeder), Aktion „Karte in einem Chat oder Kanal veröffentlichen“ als
Flow-Bot im Chat mit Flow-Bot, Empfänger `triggerBody()?['empfaenger']`,
Karte `string(triggerBody()?['karte'])`. Die Auslöser-Adresse trägt ein
Admin in der App ein (`loadAdminTeams()`, Recht **`a.teams`**). Der
Workflow läuft unter dem Konto, das ihn anlegt (besser Sammelkonto/IT).
- **Was die App schickt:** je neu zuständiger Person `{empfaenger, karte}` —
`empfaenger` = `users.email` (muss dem Teams-Anmeldenamen entsprechen),
`karte` = Adaptive Card (`teamsKarte()`: „Neue Aufgabe für Sie: …“,
Projekt, Fällig/Zeitraum, Aufwand, Zugewiesen von, bei mehreren auch
Zuständig, Knopf „Aufgabe öffnen“). Ohne Konto/E-Mail keine Nachricht
(nur ein Log-Eintrag). Testnachricht geht an die E-Mail des Admins.
- **Admin-Karte** (`loadAdminTeams()`): Statuszeile (eingerichtet + Host
bzw. „noch nicht eingerichtet“), breites mehrzeiliges Adressfeld
(`teamsFeldPruefen()`: Speichern erst bei https ohne Leerzeichen; bei
hinterlegter Adresse erst nach „Adresse ändern“ sichtbar), Schalter für
die Benachrichtigungen (gesperrt ohne Adresse), „Testnachricht an mich
senden“, „Anbindung entfernen“. Stile: Block „Admin → Teams“ am Ende von
`styles.css`.
- **Speicher:** Tabelle `integrationen` (`teams_webhook`,
`teams_neue_aufgaben`), bewusst NICHT `app_settings` (das darf jedes
Konto lesen) und nicht in `IMPORT_TABLES`; die Adresse geht nie an den
Browser zurück. Routen `GET`/`PUT /admin/teams`, `POST /admin/teams/test`.
- **Auslöser:** `neueZuweisungenMelden()` (aus `/save-week` und
`/save-projects`, mit `req`) sammelt je Aufgabe die NEU Zuständigen —
nicht wer sich selbst zuweist, keine Phasenbalken, nie die Sandbox —,
`teamsNeueAufgaben()` sendet im Hintergrund; Fehler nur im Log.
- **Link** (`aufgabenLink()`): `/Projektplan/<Name>?weekLabel=…&projNr=…
&aufgabe=<todoId>#aufgaben`, Host aus der speichernden Anfrage (nur im
Büronetz/VPN erreichbar). `plan-state.js` (`startAufgabe`) öffnet die
jüngste Sitzung, falls der Link eine ältere nennt, und dort das
Aufgaben-Panel; die Adresszeile wird nachgezogen.
- Braucht Internetzugang nach außen.
## Sitzungen nach Bedarf laden (`js/store.js`)
Eriks Wunsch: `/load-all` wuchs jede Woche (alle Sitzungen, ~17 KB je
Woche). Jetzt lädt der Browser beim Start nur die jüngsten
`SITZUNGEN_START` (4: aktuelle + drei davor) über `GET /load-all?wochen=4`;
dieselbe Antwort trägt `wochenListe` = ALLE echten Sitzungen schlank
(`{id, label, date, locked, updatedAt}`, `echteSitzungenSortiert()`), im
Browser `db.wochenListe`. **`db.weeks` hält damit nur die GELADENEN
Sitzungen** — wer über `db.weeks` iteriert, sieht nicht alle.
- **Verlauf** (Wochenbericht „Verlauf der Vorwochen“, Kommentar-Verlauf im
Projekt-Dashboard, Verlauf-Kacheln der Projektliste): zeigt, was geladen
ist; darunter `weitereLadenKnopf()` → `weitereSitzungenLaden()` →
`GET /weeks-aelter?vor=<älteste geladene>&anzahl=5` holt je
`SITZUNGEN_NACHLADEN` (5) ältere. Die früheren Knöpfe „Alle N anzeigen“
sind entfallen. Der Wochenbericht zeichnet danach nur den Verlauf neu
(`berichtVerlaufNeu()`), damit ein angefangener Kommentar bleibt; der
Kachel-Verlauf merkt sich, ob er aufgeklappt war (`verlaufOffen`).
- **Nicht geladene Sitzungen bei Bedarf**: `sitzungSicherLaden(label)` (über
`GET /week`). Genutzt von Admin → Sitzungen (Liste aus `adminSitzungen()`
= alle; Entsperren/Öffnen/Löschen/Sperren nehmen jetzt das Label) und von
`openPlanView()` bei einem Link auf ein Projekt einer älteren Sitzung.
- Live: Ein Ereignis zu einer nicht geladenen Sitzung zieht nur deren Eintrag
in `db.wochenListe` nach, statt alles neu zu laden. `deleteWeekById()`
zählt für „mindestens eine Sitzung“ die `wochenListe`.
- `/load-all` ohne `wochen` liefert weiterhin alles (ältere Browser-Stände).
`loadWeeks()` nimmt dafür auch eine Liste von Labels (`IN (…)`).
- Gemessen: 98 KB statt 158 KB bei 9 Sitzungen; bleibt künftig etwa gleich,
statt mit jeder Woche zu wachsen.
## Gleichzeitiges Arbeiten (Zusammenführen statt Verwerfen, `js/store.js`) ## Gleichzeitiges Arbeiten (Zusammenführen statt Verwerfen, `js/store.js`)
Eine Sitzung wird weiterhin als Ganzes über `POST /save-week` mit Eine Sitzung wird weiterhin als Ganzes über `POST /save-week` mit
@@ -4272,6 +4505,39 @@ minütlich. Ein Klick markiert als gelesen und springt in die Aufgaben des
Projekts — gesucht in der AKTUELLEN Sitzung über die Projektnummer (die Projekts — gesucht in der AKTUELLEN Sitzung über die Projektnummer (die
Projekt-ID wechselt je Sitzung), Planung-Terminpläne über ihre ID. Projekt-ID wechselt je Sitzung), Planung-Terminpläne über ihre ID.
## Gestaltung „Bauplan“ (unslop-Überarbeitung, 11/2026)
Eriks Wunsch: das gesamte Design mit dem Skill „unslop“ überarbeiten, Wahl
„volle Überarbeitung“, Glas „beruhigen“. Inhalte, Aufbau und Funktionen sind
unverändert; geändert sind nur Stylesheets, ein paar Texte und Inline-Größen.
- **Richtung:** Planköpfe und Raster eines Bauplans — Flächen fast neutral
(Graphit, leicht kühl), ein Akzent (Logo-Blau `#0f5a85`, dunkel
`#86bfdf`), Farbe nur in Inhalten (Terminplan, Auslastung, Status).
Löst die frühere Dunkel-Palette in Wandtafel-Blau ab.
- **Regeln, die neue Stile einhalten sollten:** Schriftgrößen 11/12/13/15/19/24
(keine halben Pixel), Gewichte 400/500, 600 nur selten; keine gesperrten
Großbuchstaben-Labels; keine negative Laufweite unter 40px; Abstände im
4px-Raster (2 und 6 nur in kleinen Bedienelementen); Rundungen 4–6
(Schildchen/Chips), 8 (Knöpfe, Eingaben, Umschalter), 12–16 (Karten);
Kapselform nur für dünne Balken, Schalter, Zählpunkte und Avatare; keine
farbigen Schatten/Leuchten, keine Emoji, keine Ausrufezeichen in Texten.
- **Kennzeichen:** Kalenderwochen, Projektnummern und Kennzahlen in
Monospace-Ziffern (`--font-mono`, tabellarisch) — Block „BAUPLAN“ am Ende
von `styles.css` (auch: ruhige aktive Navigation, Knopf-Übergänge).
- **Dunkel:** Hauptknöpfe in `--knopf` (`#276f9c`, weiße Schrift 5,6:1), weil
weiße Schrift auf dem hellen Akzent nur ~2:1 erreicht.
- **Glas beruhigt** (`glas.css`): Flächen fast deckend, nur noch zwei
schwache Lichtflächen in den Logo-Blautönen (Mint entfallen), Rundungen
8/14/16, neutrale Schatten, kein `saturate()`, aktive Einträge mit Haarlinie
statt farbigem Schatten.
- **Ausgenommen:** die Wandtafel (eigenes Design, `--dash-*`/`--wt-*`) und
die Login-Seite bei den Schriftgrößen.
- Das Umbauskript arbeitete je CSS-Regel; geprüft: gleiche Regelzahl wie
vorher (kein Parse-Verlust), keine Überläufe/Großbuchstaben auf Dashboard,
Projekte, Wochenbericht, Auslastung, Abwesenheiten, Einstellungen und in
den Projektbereichen. Methode nach „unslop“ von Damir Sokolovsky
(@heydamir), CC BY 4.0.
## Glas-Darstellung (`public/glas.css`) ## Glas-Darstellung (`public/glas.css`)
Eriks Wahl aus dem Gestaltungsvorschlag „Projektübersicht in Glas“ (Annäherung Eriks Wahl aus dem Gestaltungsvorschlag „Projektübersicht in Glas“ (Annäherung
@@ -4324,6 +4590,17 @@ Rechner.
kein `backdrop-filter` — eigenes Design, Kiosk-Rechner) und kein `backdrop-filter` — eigenes Design, Kiosk-Rechner) und
`prefers-reduced-transparency` (deckende Flächen). `prefers-reduced-transparency` (deckende Flächen).
## Datums-Kalender mit KW (`js/date-picker.js`)
Eriks Wunsch: Jeder Kalender beim Anlegen/Bearbeiten zeigt die
Kalenderwoche. `renderRangePicker()` hat eine erste Spalte „KW“
(`getWeekNumber()`, `.range-picker-kw`, Popup 316px breit). Native
`<input type="date">` (Feiertage, Abwesenheiten in der Benutzerverwaltung,
„Aufgabe aus Kommentar“, Assistent, TODOs aus Protokoll) öffnen über einen
globalen Klick-Abfänger denselben Kalender statt des Browser-Kalenders; der
Wert wird ins Feld geschrieben und `input`/`change` ausgelöst. Ausnahme per
Attribut `data-nativ`.
## Dunkelmodus nachgeschärft (Kalender, Sitzungskommentar) ## Dunkelmodus nachgeschärft (Kalender, Sitzungskommentar)
Eriks Wahl „A“ aus einem Kontrast-Vergleich; Block „DUNKELMODUS Eriks Wahl „A“ aus einem Kontrast-Vergleich; Block „DUNKELMODUS
@@ -4460,8 +4737,8 @@ Speicherüberlauf. Vier Ebenen, jede fängt ab, was die darüber verpasst:
(`wlan-energiesparen-aus.service`), `wlan-waechter.timer` jede Minute — (`wlan-energiesparen-aus.service`), `wlan-waechter.timer` jede Minute —
ohne Gateway ab 2 min neu verbinden, nach 10 min `systemctl reboot`. ohne Gateway ab 2 min neu verbinden, nach 10 min `systemctl reboot`.
- **Prüfen:** `Docker/pi-pruefen.sh` (Speicher, Neustarts, OOM, Strom). - **Prüfen:** `Docker/pi-pruefen.sh` (Speicher, Neustarts, OOM, Strom).
- Offen (Schritt 4 des Plans): `/load-all` liefert weiter ALLE Sitzungen — - **Sitzungen nach Bedarf** (Schritt 4, umgesetzt 30.10.2026): siehe
wächst wöchentlich auf Server und Browser. „Sitzungen nach Bedarf laden“ unten.
## Docker ## Docker
@@ -4579,6 +4856,46 @@ Abweichungen:
Rendern des Wochenberichts mit. `esc()` wandelt jetzt über `String()` um Rendern des Wochenberichts mit. `esc()` wandelt jetzt über `String()` um
(nur `null`/`undefined` werden zu `''`). (nur `null`/`undefined` werden zu `''`).
## Behobener Fehler: wachsende „&amp;amp;…“ in Kommentaren (11/2026)
Eriks Meldung nach dem Sitzungswechsel KW 45: In Kommentaren stand
„&amp;amp;amp;amp;nbsp;“. Ursache: `sanitizeFormatHtml()` (server.js)
maskierte den Text zwischen den Tags mit `htmlEscape()` — auch ein `&`, das
schon eine fertige Entity begann. Weil `serializeProject()` die Kommentare
bei JEDEM Speichern eines Projekts erneut bereinigt (auch wenn nur eine
Aufgabe geändert wurde), kam pro Speichern eine Stufe dazu (&nbsp; →
&amp;nbsp; → &amp;amp;nbsp; …). Jetzt maskiert `textMaskieren()` ein `&` nur,
wenn es keine Entity beginnt — beliebig oft wiederholbar (geprüft: alle
Kommentare nach dreimaligem Bereinigen unverändert). `entitiesReparieren()`
(beim Start und nach `/import`, idempotent) führt gestapelte Stufen in
`project_kommentare.status` und `protokoll_punkte.text` aller Sitzungen über
`entitiesEntstapeln()` zurück.
## Behobener Fehler: Kommentare abgewählter Gewerke (11/2026)
Eriks Meldung zu 21-4030: Im Projekt-Dashboard stand ein HLS-Kommentar,
obwohl das Projekt seit KW 31 nur noch ELT hat. Ursache: Die einmalige
Umstellung `kommentareInAktuelleSitzungUebernehmen()` hatte je Gewerk den
jüngsten früheren Kommentar übernommen, ohne die aktuellen Gewerke des
Projekts zu prüfen; der Sitzungswechsel trug ihn weiter. Jetzt:
`kommentareDesProjekts()` (js/utils.js) — die Gesamtanzeige
(`kommentarVon(p, null)`, `kommentarAnzeigeHtml()`) und der Verlauf im
Projekt-Dashboard (`planKommentarDaten()`, je Sitzung mit DEREN Gewerken)
zeigen nur Gewerke des Projekts; `ensureCurrentWeekRollover()` übernimmt nur
Kommentare dieser Gewerke. Gelöscht wird nichts — die verirrte Kopie fällt
beim nächsten Sitzungswechsel aus der aktuellen Sitzung heraus.
**Darstellung mehrerer Gewerke „B – Überschrift mit Linie“** (Eriks Wahl
aus dem Artifact „Kommentare je Gewerk“): `kommentarAnzeigeHtml(p, null,
mitMeta)` zeichnet je Gewerk eine Kopfzeile (Gewerk in seiner Farbe
`--gewerk-elt/-hls/-arch`, feine Linie, mit `mitMeta` Verfasser und Zeit)
und darunter den Text; bei nur einem Gewerk wie bisher nur den Text.
Genutzt von der Kachel „Kommentar der Sitzung“ im Projekt-Dashboard (dort
entfällt dann die gemeinsame Verfasserzeile, `kommentarGewerkeMitText()`),
der Kommentaransicht der Projektliste/Wandtafel (`renderKommentarAnsicht()`)
und den Verlauf-Kacheln (ohne Verfasser). Stile `.kommentar-gewerke`,
`.kommentar-kopf` in `styles.css`.
## Behobene Fehler (Testlauf 10/2026, fünf Test-Agents) ## Behobene Fehler (Testlauf 10/2026, fünf Test-Agents)
- **Gesperrte Sitzungen sind für Nicht-Admins auch inhaltlich unveränderlich**: - **Gesperrte Sitzungen sind für Nicht-Admins auch inhaltlich unveränderlich**:
+157 -114
View File
@@ -38,13 +38,13 @@
<link rel="icon" type="image/png" href="/logo.png"> <link rel="icon" type="image/png" href="/logo.png">
<link rel="alternate icon" href="/logo.ico"> <link rel="alternate icon" href="/logo.ico">
<link rel="apple-touch-icon" href="/logo.png"> <link rel="apple-touch-icon" href="/logo.png">
<link rel="stylesheet" href="/styles.css?v=106"> <link rel="stylesheet" href="/styles.css?v=123">
<link rel="stylesheet" href="/plan-styles.css?v=43"> <link rel="stylesheet" href="/plan-styles.css?v=56">
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit <!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen, der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
unter Einstellungen → Mein Konto (localStorage projuebersicht-glas). --> unter Einstellungen → Mein Konto (localStorage projuebersicht-glas). -->
<link rel="stylesheet" href="/glas.css?v=14"> <link rel="stylesheet" href="/glas.css?v=18">
<script> <script>
try { if(localStorage.getItem('projuebersicht-glas') !== 'aus') document.documentElement.classList.add('glas'); } try { if(localStorage.getItem('projuebersicht-glas') !== 'aus') document.documentElement.classList.add('glas'); }
catch(e){ document.documentElement.classList.add('glas'); } catch(e){ document.documentElement.classList.add('glas'); }
@@ -129,115 +129,87 @@
</button> </button>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav" aria-label="Hauptnavigation">
<!-- Gliederung nach Arbeitsbereich (Eriks Wahl „A“, unslop-Überarbeitung
11/2026): oben das Dashboard, dann Projekte, Team, Notizen und Studenten;
Einstellungen und Admin sitzen unten (.sidebar-unten, margin-top:
auto) und rutschen nur bei aufgeklapptem Admin in den Bildlauf. -->
<div class="sidebar-gruppe">
<button class="nav-item tab active" data-tab="dashboard" onclick="showTab('dashboard')" title="Dashboard"> <button class="nav-item tab active" data-tab="dashboard" onclick="showTab('dashboard')" title="Dashboard">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="m4.5 7.5 1.3 1.3L8.5 6"/><line x1="13" y1="6" x2="21" y2="6"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="m4.5 17.5 1.3 1.3 2.7-2.8"/><line x1="13" y1="16" x2="21" y2="16"/></svg></span></span><span class="nav-label">Dashboard</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="m4.5 7.5 1.3 1.3L8.5 6"/><line x1="13" y1="6" x2="21" y2="6"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="m4.5 17.5 1.3 1.3 2.7-2.8"/><line x1="13" y1="16" x2="21" y2="16"/></svg></span></span><span class="nav-label">Dashboard</span>
</button> </button>
</div>
<hr class="sidebar-nav-divider"> <div class="sidebar-gruppe">
<div class="sidebar-section-label">Projekte</div>
<div class="sidebar-section-label">Navigation</div>
<!-- Die eine Projektliste. Der frühere zweite Eintrag "Projekte" —
dieselben Projekte, dieselben Angaben, eine zweite Bearbeitungs-
maske — ist hier aufgegangen: Reihenfolge, Gruppenwechsel und
Löschen stehen in dieser Liste, die Projektangaben im
Projektbereich, die wöchentliche Erfassung im Wochenbericht. -->
<button class="nav-item tab" data-tab="board" onclick="showTab('board')" title="Projekte"> <button class="nav-item tab" data-tab="board" onclick="showTab('board')" title="Projekte">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2.5"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg></span></span><span class="nav-label">Projekte</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2.5"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg></span></span><span class="nav-label">Projekte</span>
</button> </button>
<!-- Geöffnetes Projekt: eingezogen unter „Projekte“, nur sichtbar,
solange ein Projekt offen ist (syncPlanNavItem(), js/tabs-auth.js). -->
<button class="nav-item tab nav-unterpunkt" data-tab="plan" id="planNavItem" style="display:none" onclick="showTab('plan')" title="Projektbereich">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="14" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="16" x2="16" y2="16"/></svg></span></span><span class="nav-label" id="planNavItemLabel">Projektbereich</span>
</button>
<button class="nav-item tab" data-tab="wochenbericht" onclick="showTab('wochenbericht')" title="Wochenbericht"> <button class="nav-item tab" data-tab="wochenbericht" onclick="showTab('wochenbericht')" title="Wochenbericht">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5h8a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1-1.5 1.5H8A1.5 1.5 0 0 1 6.5 19V5A1.5 1.5 0 0 1 8 3.5Z"/><path d="M9.5 3.5h5v2.2a.8.8 0 0 1-.8.8h-3.4a.8.8 0 0 1-.8-.8V3.5Z"/><path d="m9.3 12.2 1.5 1.5 3.4-3.4"/><line x1="9.3" y1="16.3" x2="14.7" y2="16.3"/></svg></span></span><span class="nav-label">Wochenbericht</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5h8a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1-1.5 1.5H8A1.5 1.5 0 0 1 6.5 19V5A1.5 1.5 0 0 1 8 3.5Z"/><path d="M9.5 3.5h5v2.2a.8.8 0 0 1-.8.8h-3.4a.8.8 0 0 1-.8-.8V3.5Z"/><path d="m9.3 12.2 1.5 1.5 3.4-3.4"/><line x1="9.3" y1="16.3" x2="14.7" y2="16.3"/></svg></span></span><span class="nav-label">Wochenbericht</span>
</button> </button>
<button class="nav-item tab" data-tab="load" onclick="showTab('load')" title="Auslastung">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="21" x2="5" y2="10"/><line x1="12" y1="21" x2="12" y2="5"/><line x1="19" y1="21" x2="19" y2="14"/><line x1="3" y1="21" x2="21" y2="21"/></svg></span></span><span class="nav-label">Auslastung</span>
</button>
<!-- Abwesenheiten (js/abwesenheit/abwesenheit.js): sichtbar mit einem der
vier Abwesenheits-Rechte (data-recht mit "|" = eines davon genügt). -->
<button class="nav-item tab nur-recht" data-recht="team.abw|team.abwProjekte|team.abwGewerk|team.abwAlle" data-tab="abwesenheit" onclick="showTab('abwesenheit')" title="Abwesenheiten">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="3" x2="8" y2="6.5"/><line x1="16" y1="3" x2="16" y2="6.5"/><line x1="8.5" y1="13" x2="15.5" y2="17.5"/><line x1="15.5" y1="13" x2="8.5" y2="17.5"/></svg></span></span><span class="nav-label">Abwesenheiten</span>
</button>
<!-- "Projektbeteiligte" stand hier bis zur Entfernung der Abteilungs-
trennung (migrateRemoveDepartmentPartition(), CLAUDE.md
"Departments were retired"): eine rein lesende Liste, deren
eigentlicher Zweck — Beteiligte gezielt aus einer ANDEREN Abteilung
in die eigene Sitzung zu holen — mit der Zusammenlegung zu EINEM
gemeinsamen Personenkreis entfiel. Übrig blieb eine Dopplung der
Rollen-/Avatar-Chips, die in Wochenbericht/Board/Auslastung ohnehin
schon sichtbar sind. Auf Eriks Wunsch aus der Seitenleiste entfernt;
der Tab selbst (`#people`, `js/people/people.js`) und sein Slug
`Projektbeteiligte` in VALID_TAB_SLUGS bestehen unverändert fort —
ein alter Link/Deep-Link funktioniert also weiterhin, nur der
direkte Weg über die Navigation fehlt. -->
<!-- Projektbereich: der einzige Navigationspunkt, der nicht immer da
ist. Er erscheint, sobald ein Projekt geöffnet wurde, und trägt
dessen Bezeichnung (syncPlanNavItem() in js/tabs-auth.js). Ohne
ihn wäre im Projektbereich gar kein Punkt hervorgehoben und die
Seitenleiste zeigte nicht mehr, wo man gerade ist. -->
<button class="nav-item tab" data-tab="plan" id="planNavItem" style="display:none" onclick="showTab('plan')" title="Projektbereich">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="14" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="16" x2="16" y2="16"/></svg></span></span><span class="nav-label" id="planNavItemLabel">Projektbereich</span>
</button>
<!-- Planung: eigener, zweiter Abschnitt (nicht Teil von "Navigation"),
wie gewünscht — eine Sandbox für Terminpläne unabhängig von einem
echten Projekt (aus Honorar errechnet oder frei angelegt), siehe
CLAUDE.md "Planung — projektunabhängige Terminplan-Sandbox". -->
<div class="sidebar-section-label nur-recht" data-recht="wz.planung">Planung</div>
<button class="nav-item tab nur-recht" data-recht="wz.planung" data-tab="planung" onclick="showTab('planung')" title="Terminplan"> <button class="nav-item tab nur-recht" data-recht="wz.planung" data-tab="planung" onclick="showTab('planung')" title="Terminplan">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="17" rx="2.5"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="2.5" x2="8" y2="6.5"/><line x1="16" y1="2.5" x2="16" y2="6.5"/><line x1="7" y1="14" x2="12" y2="14"/><line x1="7" y1="17.5" x2="15" y2="17.5"/></svg></span></span><span class="nav-label">Terminplan</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="17" rx="2.5"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="2.5" x2="8" y2="6.5"/><line x1="16" y1="2.5" x2="16" y2="6.5"/><line x1="7" y1="14" x2="12" y2="14"/><line x1="7" y1="17.5" x2="15" y2="17.5"/></svg></span></span><span class="nav-label">Terminplan</span>
</button> </button>
</div>
<!-- Studenten: eigener, dritter Abschnitt (wie "Planung" nicht Teil <div class="sidebar-gruppe">
von "Navigation") — bislang nur ein Werkzeug, der Plan Checker: <div class="sidebar-section-label">Team</div>
lädt einen Bauplan als PDF hoch und lässt ihn von einer KI gegen <button class="nav-item tab" data-tab="load" onclick="showTab('load')" title="Auslastung">
eine Liste von Merkmalen prüfen (Maßketten, Plankopf, ...), <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="21" x2="5" y2="10"/><line x1="12" y1="21" x2="12" y2="5"/><line x1="19" y1="21" x2="19" y2="14"/><line x1="3" y1="21" x2="21" y2="21"/></svg></span></span><span class="nav-label">Auslastung</span>
siehe js/planchecker/planchecker.js. --> </button>
<div class="sidebar-section-label nur-recht" data-recht="wz.checker">Studenten</div> <button class="nav-item tab nur-recht" data-recht="team.abw|team.abwProjekte|team.abwGewerk|team.abwAlle" data-tab="abwesenheit" onclick="showTab('abwesenheit')" title="Abwesenheiten">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="3" x2="8" y2="6.5"/><line x1="16" y1="3" x2="16" y2="6.5"/><line x1="8.5" y1="13" x2="15.5" y2="17.5"/><line x1="15.5" y1="13" x2="8.5" y2="17.5"/></svg></span></span><span class="nav-label">Abwesenheiten</span>
</button>
</div>
<div class="sidebar-gruppe">
<div class="sidebar-section-label">Notizen</div>
<button class="nav-item tab" data-tab="notizen" onclick="showTab('notizen')" title="Notizen">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v12a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1Z"/><path d="M13.5 3.5V7a1 1 0 0 0 1 1h4"/><line x1="8.5" y1="12.5" x2="15.5" y2="12.5"/><line x1="8.5" y1="16" x2="13.5" y2="16"/></svg></span></span><span class="nav-label">Notizen</span>
</button>
</div>
<div class="sidebar-gruppe nur-recht" data-recht="wz.checker">
<div class="sidebar-section-label">Studenten</div>
<button class="nav-item tab nur-recht" data-recht="wz.checker" data-tab="planchecker" onclick="showTab('planchecker')" title="Plan Checker"> <button class="nav-item tab nur-recht" data-recht="wz.checker" data-tab="planchecker" onclick="showTab('planchecker')" title="Plan Checker">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg></span></span><span class="nav-label">Plan Checker</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg></span></span><span class="nav-label">Plan Checker</span>
</button> </button>
</div>
<hr class="sidebar-nav-divider"> <div class="sidebar-gruppe sidebar-unten">
<!-- Einstellungen: seit die firmenweiten Masken in den Admin-Bereich
gewandert sind, steht hier nur noch das eigene Konto — ein
Aufklapp-Menue fuer einen einzigen Punkt waere Ballast. -->
<button class="nav-item tab" data-tab="settings" onclick="showTab('settings')" title="Einstellungen"> <button class="nav-item tab" data-tab="settings" onclick="showTab('settings')" title="Einstellungen">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M12 3.5v2.4M12 18.1v2.4M20.5 12h-2.4M5.9 12H3.5M17.7 6.3l-1.7 1.7M8 15.7l-1.7 1.7M17.7 17.7 16 16M8 8 6.3 6.3"/></svg></span></span><span class="nav-label">Einstellungen</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M12 3.5v2.4M12 18.1v2.4M20.5 12h-2.4M5.9 12H3.5M17.7 6.3l-1.7 1.7M8 15.7l-1.7 1.7M17.7 17.7 16 16M8 8 6.3 6.3"/></svg></span></span><span class="nav-label">Einstellungen</span>
</button> </button>
<!-- Der Bereich „Werkzeuge“ ist entfallen (Eriks Wunsch): „+ Neues
Projekt“ steht in der Projektliste („+ Projekt“), die Wandtafel
braucht nur das Wandtafel-Konto (landet dort automatisch, sonst
über /Wandtafel erreichbar). -->
<!-- Admin: aufklappbare Gruppe ueber die Abschnitte von #admin. Wie
bei "Einstellungen" zuvor ist das KEINE Aktionsliste, sondern
Navigation innerhalb eines Bereichs — deshalb fuehrt schon der
Kopf-Eintrag dorthin (toggleAdminNav()) und die Unterpunkte
schalten nur den sichtbaren Abschnitt um (showAdminSection() in
js/admin/admin.js). Die gesamte Gruppe blendet renderUserBar()
(js/auth.js) fuer Nicht-Administratoren aus; die eigentliche
Sperre sitzt serverseitig auf den Routen. -->
<div id="navAdminSection" style="display:none"> <div id="navAdminSection" style="display:none">
<button class="nav-item nav-group-toggle" id="adminAccordionToggle" onclick="toggleAdminNav()" title="Admin" aria-expanded="false" aria-controls="adminSubnav"> <button class="nav-item nav-group-toggle" id="adminAccordionToggle" onclick="toggleAdminNav()" title="Admin" aria-expanded="false" aria-controls="adminSubnav">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 5.5 6v5.3c0 4.4 2.9 7.4 6.5 8.7 3.6-1.3 6.5-4.3 6.5-8.7V6Z"/><path d="m9 12 2.1 2.1L15.5 9.5"/></svg></span></span><span class="nav-label">Admin</span> <span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 5.5 6v5.3c0 4.4 2.9 7.4 6.5 8.7 3.6-1.3 6.5-4.3 6.5-8.7V6Z"/><path d="m9 12 2.1 2.1L15.5 9.5"/></svg></span></span><span class="nav-label">Admin</span>
<span class="nav-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span> <span class="nav-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button> </button>
<div class="sidebar-subnav" id="adminSubnav"> <div class="sidebar-subnav" id="adminSubnav">
<div class="sidebar-subgruppe">Personen</div>
<button class="menu-item sidebar-subitem" data-admin-section="benutzer" onclick="showAdminSection('benutzer')">Benutzerverwaltung</button> <button class="menu-item sidebar-subitem" data-admin-section="benutzer" onclick="showAdminSection('benutzer')">Benutzerverwaltung</button>
<button class="menu-item sidebar-subitem" data-admin-section="rollen" onclick="showAdminSection('rollen')">Rollen &amp; Rechte</button> <button class="menu-item sidebar-subitem" data-admin-section="rollen" onclick="showAdminSection('rollen')">Rollen &amp; Rechte</button>
<div class="sidebar-subgruppe">Vorgaben</div>
<button class="menu-item sidebar-subitem" data-admin-section="honorar" onclick="showAdminSection('honorar')">Honorar</button> <button class="menu-item sidebar-subitem" data-admin-section="honorar" onclick="showAdminSection('honorar')">Honorar</button>
<button class="menu-item sidebar-subitem" data-admin-section="auslastung" onclick="showAdminSection('auslastung')">Auslastung</button> <button class="menu-item sidebar-subitem" data-admin-section="auslastung" onclick="showAdminSection('auslastung')">Auslastung</button>
<button class="menu-item sidebar-subitem" data-admin-section="termine" onclick="showAdminSection('termine')">Termine &amp; Sitzungen</button> <button class="menu-item sidebar-subitem" data-admin-section="termine" onclick="showAdminSection('termine')">Termine &amp; Sitzungen</button>
<button class="menu-item sidebar-subitem" data-admin-section="feiertage" onclick="showAdminSection('feiertage')">Feiertage</button> <button class="menu-item sidebar-subitem" data-admin-section="feiertage" onclick="showAdminSection('feiertage')">Feiertage</button>
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button> <button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button> <button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
<div class="sidebar-subgruppe">System</div>
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb &amp; Sicherheit</button> <button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb &amp; Sicherheit</button>
<button class="menu-item sidebar-subitem" data-admin-section="teams" onclick="showAdminSection('teams')">Teams</button>
<button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button> <button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button>
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button> <button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button>
</div> </div>
</div> </div>
</div>
</nav> </nav>
<button class="sidebar-collapse-bar" id="sidebarCollapseToggle" onclick="toggleSidebarCollapse()" title="Seitenleiste ein-/ausklappen" aria-pressed="false"> <button class="sidebar-collapse-bar" id="sidebarCollapseToggle" onclick="toggleSidebarCollapse()" title="Seitenleiste ein-/ausklappen" aria-pressed="false">
@@ -298,6 +270,9 @@
<!-- LOAD --> <!-- LOAD -->
<!-- ABWESENHEITEN (js/abwesenheit/abwesenheit.js) --> <!-- ABWESENHEITEN (js/abwesenheit/abwesenheit.js) -->
<div id="abwesenheit" style="display:none"></div> <div id="abwesenheit" style="display:none"></div>
<!-- NOTIZEN (js/notizen/notizen.js): Notizen aller eigenen Projekte und
projektunabhängige Notizen -->
<div id="notizen" style="display:none"></div>
<div id="load" style="display:none"> <div id="load" style="display:none">
<div class="section-title">Auslastung der Projektbeteiligten</div> <div class="section-title">Auslastung der Projektbeteiligten</div>
@@ -455,6 +430,16 @@
mit Fehlertext, der bis zum nächsten Testergebnis stehen bleibt mit Fehlertext, der bis zum nächsten Testergebnis stehen bleibt
(Design-Entwürfe mit Erik abgestimmt: Artifacts d7ab2945, (Design-Entwürfe mit Erik abgestimmt: Artifacts d7ab2945,
01fe1346). --> 01fe1346). -->
<!-- Kalender-Abo für Outlook (renderSettingsKalender(), js/settings/settings.js;
Server: /mein-kalender, /kalender/<token>.ics) -->
<section class="settings-card" id="settingsKalenderCard">
<h3 class="settings-card-title">Kalender in Outlook</h3>
<p class="hint">Ihre offenen Aufgaben, die Meilensteine Ihrer Projekte und Ihre
Abwesenheiten als Kalender in Outlook — nur zum Ansehen, geändert wird weiter hier.
Outlook aktualisiert ihn selbst (je nach Outlook stündlich bis alle paar Stunden).</p>
<div id="settingsKalender"></div>
</section>
<section class="settings-card" id="settingsAiCard"> <section class="settings-card" id="settingsAiCard">
<h3 class="settings-card-title">KI-Zugangsdaten</h3> <h3 class="settings-card-title">KI-Zugangsdaten</h3>
<p class="hint">Für die automatische Punkt-Erkennung beim Protokoll-Import <p class="hint">Für die automatische Punkt-Erkennung beim Protokoll-Import
@@ -665,6 +650,36 @@
</div> </div>
</section> </section>
<!-- Teams-Anbindung (loadAdminTeams(), js/admin/admin.js; Server: /admin/teams*) -->
<section class="settings-card" id="adminTeamsCard" data-admin-section="teams" style="display:none">
<h3 class="settings-card-title">Teams <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
<p class="hint">Bekommt jemand eine Aufgabe zugewiesen, erhält genau diese Person in Teams
eine persönliche Nachricht (Chat mit dem Flow-Bot) mit Link direkt auf die Aufgabe.
Gefunden wird sie über die E-Mail-Adresse ihres Kontos — die muss ihrem Teams-Anmeldenamen
entsprechen. Wer kein Konto oder keine E-Mail hat, bekommt nichts.</p>
<div id="adminTeamsInhalt"></div>
<details class="kalender-anleitung">
<summary>So richten Sie den Workflow in Teams ein</summary>
<ol>
<li>In Teams die App <i>Workflows</i> öffnen (oder im Browser make.powerautomate.com) und
einen <b>leeren</b> Workflow anlegen.</li>
<li><b>Auslöser:</b> „Bei Empfang einer Teams-Webhookanforderung“, bei
<i>Wer kann den Flow auslösen</i> „Jeder“ wählen.</li>
<li><b>Aktion:</b> „Karte in einem Chat oder Kanal veröffentlichen“ —
<i>Posten als</i>: Flow-Bot, <i>Posten in</i>: Chat mit Flow-Bot,
<i>Empfänger</i>: als Ausdruck <code>triggerBody()?['empfaenger']</code>,
<i>Adaptive Karte</i>: als Ausdruck <code>string(triggerBody()?['karte'])</code>.</li>
<li>Speichern. Beim Auslöser steht jetzt eine lange Adresse (https://…) — kopieren,
hier einfügen und speichern.</li>
<li>„Testnachricht senden“ — die Nachricht kommt an Sie selbst.</li>
</ol>
<p class="hint">Der Workflow läuft unter dem Konto, das ihn anlegt. Am besten legt ihn ein
Sammelkonto oder die IT an — sonst hört er auf, wenn dieses Konto gesperrt wird.
Der Server braucht Internetzugang nach außen; der Link in der Nachricht funktioniert
nur im Büronetz (bzw. per VPN). Die Bezeichnungen in Teams können je nach Version
leicht abweichen.</p>
</details>
</section>
<section class="settings-card" id="adminBetriebCard" data-admin-section="betrieb" style="display:none"> <section class="settings-card" id="adminBetriebCard" data-admin-section="betrieb" style="display:none">
<h3 class="settings-card-title">Betrieb &amp; Sicherheit <span class="settings-scope">firmenweit · nur Administratoren</span></h3> <h3 class="settings-card-title">Betrieb &amp; Sicherheit <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
<div class="settings-field-grid"> <div class="settings-field-grid">
@@ -851,7 +866,7 @@
<nav class="plan-nav" id="planNav"> <nav class="plan-nav" id="planNav">
<button class="plan-nav-item" data-section="dashboard" onclick="showPlanSection('dashboard')">Dashboard</button> <button class="plan-nav-item" data-section="dashboard" onclick="showPlanSection('dashboard')">Dashboard</button>
<button class="plan-nav-item" data-section="terminplan" onclick="showPlanSection('terminplan')">Terminplan</button> <button class="plan-nav-item" data-section="terminplan" onclick="showPlanSection('terminplan')">Terminplan</button>
<button class="plan-nav-item" data-section="phasen" onclick="showPlanSection('phasen')">Leistungsphasen</button> <button class="plan-nav-item" data-section="notizen" onclick="showPlanSection('notizen')">Notizen</button>
<button class="plan-nav-item" data-section="aufgaben" onclick="showPlanSection('aufgaben')">Aufgaben</button> <button class="plan-nav-item" data-section="aufgaben" onclick="showPlanSection('aufgaben')">Aufgaben</button>
<button class="plan-nav-item" data-section="leistung" onclick="showPlanSection('leistung')">Leistung</button> <button class="plan-nav-item" data-section="leistung" onclick="showPlanSection('leistung')">Leistung</button>
<button class="plan-nav-item" data-section="dateien" onclick="showPlanSection('dateien')">Dateien</button> <button class="plan-nav-item" data-section="dateien" onclick="showPlanSection('dateien')">Dateien</button>
@@ -1000,17 +1015,12 @@
</section> </section>
<!-- ====================== Bereich: Aufgaben ===================== --> <!-- ====================== Bereich: Aufgaben ===================== -->
<!-- ================== Bereich: Leistungsphasen ================== --> <!-- ======================= Bereich: Notizen ======================= -->
<!-- Eriks Wahl "B": die Phasenbalken des Terminplans als eigener <!-- Eriks Wahl „A – Liste + Editor“: gemeinsame Notizen je Projekt
Bereich — Ablaufbalken über alle Phasen, darunter eine Kachel je (renderPlanNotizen(), js/plan/plan-notizen.js; Server:
Phase (renderPlanPhasen(), js/plan/plan-phasen.js). In der /projekt-notizen*). Ersetzt den früheren Reiter „Leistungsphasen“. -->
Aufgabenliste stehen sie dafür nicht mehr. --> <section class="plan-section" id="planSection-notizen" data-section="notizen">
<section class="plan-section" id="planSection-phasen" data-section="phasen"> <div id="planNotizenWrap"></div>
<div class="plan-main-header">
<button onclick="addPlanPhase()" id="addPhaseBtnPhasen">+ Leistungsphase</button>
<button class="secondary" onclick="openHonorarModal()" id="honorarBtnPhasen" title="Terminplan aus Honorar und Leistungsphasen-Anteilen errechnen">Terminplan aus Honorar</button>
</div>
<div id="planPhasenWrap"></div>
</section> </section>
<section class="plan-section" id="planSection-aufgaben" data-section="aufgaben"> <section class="plan-section" id="planSection-aufgaben" data-section="aufgaben">
@@ -1522,6 +1532,11 @@
<h3>Hängt ab von</h3> <h3>Hängt ab von</h3>
<div id="milestoneEditDependsOn" class="todo-depends-list pd-liste"></div> <div id="milestoneEditDependsOn" class="todo-depends-list pd-liste"></div>
</section> </section>
<section class="pd-abs">
<h3>Notiz</h3>
<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>
</div> </div>
<div class="pd-fuss"> <div class="pd-fuss">
<button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button> <button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button>
@@ -1564,12 +1579,38 @@
<section class="pd-abs" id="todoEditWerAbs"> <section class="pd-abs" id="todoEditWerAbs">
<h3>Wer</h3> <h3>Wer</h3>
<div id="todoEditAssignees" class="person-chips"></div> <div id="todoEditAssignees" class="person-chips"></div>
<label class="pd-aufwand" for="todoEditAufwand"> <!-- Externe Personen: Kollegen mit Konto, die dem Projekt NICHT
<span>Aufwand</span> zugeordnet sind (Eriks Wunsch). todoExternHinzufuegen()/
<input type="number" id="todoEditAufwand" min="0" step="0.5" placeholder="0" inputmode="decimal"> todoExternEntfernen(), js/plan/plan-modals.js -->
<span>Stunden, optional</span> <div class="pd-externe">
</label> <div class="pd-externe-kopf">Externe Personen <span class="hint">nicht im Projekt</span></div>
<p class="hint pd-hint">Zählt zur Auslastung der Zuständigen, verteilt auf den Zeitraum der Aufgabe.</p> <div id="todoEditExterne" class="person-chips"></div>
<select id="todoEditExterneWahl" aria-label="Externe Person hinzufügen" onchange="todoExternHinzufuegen(this.value)"></select>
</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-aufwand-zeile">
<div class="pd-stepper">
<button type="button" class="pd-stepper-knopf" onclick="todoAufwandAendern(-1)" aria-label="Eine Stunde weniger">−</button>
<input type="number" id="todoEditAufwand" min="0" step="0.5" placeholder="0" inputmode="decimal" oninput="todoAufwandInfo()">
<span class="pd-einheit">Std.</span>
<button type="button" class="pd-stepper-knopf" onclick="todoAufwandAendern(1)" aria-label="Eine Stunde mehr">+</button>
</div>
<div class="pd-aufwand-schnell" id="todoAufwandSchnell" role="group" aria-label="Schnellwahl">
<button type="button" data-h="2" onclick="todoAufwandSetzen(2)">2 h</button>
<button type="button" data-h="4" onclick="todoAufwandSetzen(4)">½ Tag</button>
<button type="button" data-tage="1" onclick="todoAufwandSetzen('tag', 1)">1 Tag</button>
<button type="button" data-tage="2" onclick="todoAufwandSetzen('tag', 2)">2 Tage</button>
<button type="button" data-tage="5" onclick="todoAufwandSetzen('tag', 5)">1 Woche</button>
</div>
</div>
<span class="pd-aufwand-info" id="todoAufwandInfoText"></span>
</div>
</section> </section>
<section class="pd-abs"> <section class="pd-abs">
<h3>Stand</h3> <h3>Stand</h3>
@@ -1912,21 +1953,22 @@
</main> </main>
</div> </div>
<script src="/js/globals.js?v=8"></script> <script src="/js/globals.js?v=10"></script>
<script src="/js/utils.js?v=30"></script> <script src="/js/utils.js?v=33"></script>
<script src="/js/icons.js?v=2"></script> <script src="/js/icons.js?v=2"></script>
<script src="/js/ui.js?v=9"></script> <script src="/js/ui.js?v=9"></script>
<script src="/js/date-picker.js?v=5"></script> <script src="/js/date-picker.js?v=6"></script>
<script src="/js/server.js?v=7"></script> <script src="/js/server.js?v=7"></script>
<script src="/js/auth.js?v=10"></script> <script src="/js/auth.js?v=11"></script>
<script src="/js/store.js?v=14"></script> <script src="/js/store.js?v=17"></script>
<script src="/js/weeks.js?v=11"></script> <script src="/js/weeks.js?v=12"></script>
<script src="/js/board/board.js?v=41"></script> <script src="/js/board/board.js?v=42"></script>
<script src="/js/wochenbericht/wochenbericht.js?v=28"></script> <script src="/js/wochenbericht/wochenbericht.js?v=29"></script>
<script src="/js/people/people.js?v=8"></script> <script src="/js/people/people.js?v=8"></script>
<script src="/js/planung/planung.js?v=3"></script> <script src="/js/planung/planung.js?v=3"></script>
<script src="/js/planchecker/planchecker.js?v=1"></script> <script src="/js/planchecker/planchecker.js?v=1"></script>
<script src="/js/abwesenheit/abwesenheit.js?v=9"></script> <script src="/js/abwesenheit/abwesenheit.js?v=10"></script>
<script src="/js/notizen/notizen.js?v=3"></script>
<script src="/js/projects/projects.js?v=10"></script> <script src="/js/projects/projects.js?v=10"></script>
<script src="/js/load/load.js?v=23"></script> <script src="/js/load/load.js?v=23"></script>
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil <!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
@@ -1935,7 +1977,7 @@
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und <!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
load.js (faktorFormat), steht deshalb dahinter. --> load.js (faktorFormat), steht deshalb dahinter. -->
<script src="/js/projekt-wizard.js?v=6"></script> <script src="/js/projekt-wizard.js?v=6"></script>
<script src="/js/dashboard/dashboard.js?v=10"></script> <script src="/js/dashboard/dashboard.js?v=13"></script>
<script src="/js/benachrichtigungen.js?v=5"></script> <script src="/js/benachrichtigungen.js?v=5"></script>
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/ <!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek() stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
@@ -1944,11 +1986,11 @@
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
Tab-Ordner, keine neue Abhängigkeit nach vorn. --> Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
<script src="/js/wand/wand.js?v=21"></script> <script src="/js/wand/wand.js?v=21"></script>
<script src="/js/settings/settings.js?v=12"></script> <script src="/js/settings/settings.js?v=14"></script>
<script src="/js/admin/admin.js?v=19"></script> <script src="/js/admin/admin.js?v=24"></script>
<script src="/js/admin-users.js?v=10"></script> <script src="/js/admin-users.js?v=10"></script>
<script src="/js/admin/rollen.js?v=1"></script> <script src="/js/admin/rollen.js?v=1"></script>
<script src="/js/einfuehrung.js?v=2"></script> <script src="/js/einfuehrung.js?v=3"></script>
<script src="/js/gantt.js?v=10"></script> <script src="/js/gantt.js?v=10"></script>
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in <!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist, project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist,
@@ -1957,29 +1999,30 @@
(das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach (das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach
js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js
umhuellt. --> umhuellt. -->
<script src="/js/plan/plan-state.js?v=21"></script> <script src="/js/plan/plan-state.js?v=25"></script>
<script src="/js/plan/plan-sections.js?v=10"></script> <script src="/js/plan/plan-sections.js?v=12"></script>
<script src="/js/plan/plan-info.js?v=14"></script> <script src="/js/plan/plan-info.js?v=17"></script>
<script src="/js/plan/plan-history.js?v=4"></script> <script src="/js/plan/plan-history.js?v=4"></script>
<script src="/js/plan/plan-build-gantt.js?v=11"></script> <script src="/js/plan/plan-build-gantt.js?v=11"></script>
<script src="/js/plan/plan-gantt.js?v=21"></script> <script src="/js/plan/plan-gantt.js?v=23"></script>
<script src="/js/plan/plan-drag.js?v=8"></script> <script src="/js/plan/plan-drag.js?v=8"></script>
<script src="/js/plan/plan-modals.js?v=14"></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-tasks.js?v=4"></script>
<script src="/js/plan/plan-phasen.js?v=2"></script> <script src="/js/plan/plan-phasen.js?v=3"></script>
<script src="/js/plan/plan-files.js?v=4"></script> <script src="/js/plan/plan-files.js?v=4"></script>
<script src="/js/plan/plan-protokoll.js?v=20"></script> <script src="/js/plan/plan-protokoll.js?v=21"></script>
<script src="/js/plan/plan-templates.js?v=11"></script> <script src="/js/plan/plan-templates.js?v=11"></script>
<script src="/js/plan/plan-protokoll-todos.js?v=2"></script> <script src="/js/plan/plan-protokoll-todos.js?v=3"></script>
<script src="/js/plan/plan-mail-eintrag.js?v=1"></script> <script src="/js/plan/plan-mail-eintrag.js?v=4"></script>
<script src="/js/plan/plan-notizen.js?v=2"></script>
<script src="/js/plan/plan-honorar.js?v=9"></script> <script src="/js/plan/plan-honorar.js?v=9"></script>
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen <!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen
Zwischenspeicher verwirft. --> Zwischenspeicher verwirft. -->
<script src="/js/plan/plan-leistung.js?v=1"></script> <script src="/js/plan/plan-leistung.js?v=1"></script>
<script src="/js/plan/plan-settings.js?v=14"></script> <script src="/js/plan/plan-settings.js?v=14"></script>
<script src="/js/plan/plan-schedule.js?v=6"></script> <script src="/js/plan/plan-schedule.js?v=7"></script>
<script src="/js/tabs-auth.js?v=17"></script> <script src="/js/tabs-auth.js?v=19"></script>
<script src="/js/pdf.js?v=31"></script> <script src="/js/pdf.js?v=31"></script>
<script src="/js/backup.js?v=2"></script> <script src="/js/backup.js?v=2"></script>
<script src="/js/import.js?v=1"></script> <script src="/js/import.js?v=1"></script>
+96 -89
View File
@@ -1,5 +1,8 @@
/* =========================================================================== /* ===========================================================================
GLAS-DARSTELLUNG (Eriks Wahl: Gestaltungsvorschlag "Projektübersicht in GLAS-DARSTELLUNG — beruhigt in der unslop-Überarbeitung „Bauplan“ (Eriks
Wahl „Beruhigen“): Flächen fast deckend, Lichtflächen schwach und nur noch
in den zwei Logo-Blautönen, kleinere Rundungen und neutrale Schatten.
Ursprünglich: (Eriks Wahl: Gestaltungsvorschlag "Projektübersicht in
Glas", Annäherung an Apple Liquid Glass) Glas", Annäherung an Apple Liquid Glass)
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES
@@ -27,107 +30,105 @@
/* ---------- Tokens: hell ---------- */ /* ---------- Tokens: hell ---------- */
html.glas { html.glas {
--bg: #e8eef3; --bg: #eef1f4;
--surface: rgba(255,255,255,.58); --surface: rgba(255,255,255,.82);
--surface-alt: rgba(255,255,255,.40); --surface-alt: rgba(255,255,255,.66);
--surface-sunk: rgba(15,90,133,.07); --surface-sunk: rgba(16,24,32,.05);
--line: rgba(20,45,65,.16); --line: rgba(16,24,32,.13);
--line-soft: rgba(20,45,65,.09); --line-soft: rgba(16,24,32,.07);
--accent: #0f5a85; --accent: #0f5a85;
--accent-hover: #0d5079; --accent-hover: #0d5079;
--accent-active: #0a4565; --accent-active: #0a4565;
--accent-soft: rgba(15,90,133,.11); --accent-soft: rgba(15,90,133,.09);
--band-neu: #0f5a85; --band-neu: #0f5a85;
--text: #14212b; --text: #16191d;
--text-muted: #4b5d6b; --text-muted: #4d545c;
--text-subtle: #6f8291; --text-subtle: #69707a;
--r-md: 10px; --r-md: 8px;
--r-lg: 18px; --r-lg: 14px;
--r-xl: 22px; --r-xl: 16px;
--sh-1: 0 8px 28px rgba(21,58,86,.10), 0 1px 4px rgba(21,58,86,.06); --sh-1: 0 1px 2px rgba(16,24,32,.05);
--sh-2: 0 12px 34px rgba(21,58,86,.13), 0 2px 6px rgba(21,58,86,.07); --sh-2: 0 4px 16px rgba(16,24,32,.07), 0 1px 2px rgba(16,24,32,.04);
--sh-3: 0 20px 60px rgba(21,58,86,.20), 0 4px 14px rgba(21,58,86,.10); --sh-3: 0 12px 32px rgba(16,24,32,.12), 0 2px 6px rgba(16,24,32,.06);
--glas-rahmen: rgba(255,255,255,.62); --glas-rahmen: rgba(255,255,255,.84);
--glas-dicht: rgba(255,255,255,.86); --glas-dicht: rgba(255,255,255,.94);
--glas-deckend: #f7fafc; --glas-deckend: #f7fafc;
--glas-kante: rgba(255,255,255,.80); --glas-kante: rgba(255,255,255,.70);
--glas-kante-2: rgba(255,255,255,.35); --glas-kante-2: rgba(255,255,255,.25);
--glas-blur: 24px; --glas-blur: 16px;
--glas-hell: #94b0cc; --glas-hell: #94b0cc;
--glas-tief: #116896; --glas-tief: #116896;
--glas-logo: .16; --glas-logo: .07;
} }
/* ---------- Tokens: dunkel (System und manuell) ---------- */ /* ---------- Tokens: dunkel (System und manuell) ---------- */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) { html.glas:not([data-theme="light"]) {
--bg: #0b141b; --bg: #111315;
--surface: rgba(30,46,60,.50); --surface: rgba(26,29,33,.80);
--surface-alt: rgba(30,46,60,.34); --surface-alt: rgba(26,29,33,.62);
--surface-sunk: rgba(255,255,255,.06); --surface-sunk: rgba(255,255,255,.05);
--line: rgba(255,255,255,.14); --line: rgba(236,238,240,.13);
--line-soft: rgba(255,255,255,.08); --line-soft: rgba(236,238,240,.07);
--accent: #8cc9e6; --accent: #86bfdf;
--accent-hover: #a3d4ec; --accent-hover: #9dcbe5;
--accent-active: #6fb3d3; --accent-active: #6aa9cc;
--accent-soft: rgba(140,201,230,.15); --accent-soft: rgba(134,191,223,.13);
--band-neu: #116896; --band-neu: #116896;
--text: #e8f0f5; --text: #eceef0;
--text-muted: #a9bccb; --text-muted: #a5abb2;
--text-subtle: #7790a2; --text-subtle: #878d95;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30); --sh-1: 0 1px 2px rgba(0,0,0,.4);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30); --sh-2: 0 4px 16px rgba(0,0,0,.45);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35); --sh-3: 0 16px 40px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58); --glas-rahmen: rgba(24,27,30,.84);
--glas-dicht: rgba(22,34,44,.90); --glas-dicht: rgba(22,24,27,.95);
--glas-deckend: #15212b; --glas-deckend: #181a1d;
--glas-kante: rgba(255,255,255,.18); --glas-kante: rgba(255,255,255,.12);
--glas-kante-2: rgba(255,255,255,.07); --glas-kante-2: rgba(255,255,255,.05);
--glas-hell: #2c5675; --glas-hell: #2c5675;
--glas-tief: #1d6f99; --glas-tief: #1d6f99;
--glas-logo: .10; --glas-logo: .06;
} }
} }
html.glas[data-theme="dark"] { html.glas[data-theme="dark"] {
--bg: #0b141b; --bg: #111315;
--surface: rgba(30,46,60,.50); --surface: rgba(26,29,33,.80);
--surface-alt: rgba(30,46,60,.34); --surface-alt: rgba(26,29,33,.62);
--surface-sunk: rgba(255,255,255,.06); --surface-sunk: rgba(255,255,255,.05);
--line: rgba(255,255,255,.14); --line: rgba(236,238,240,.13);
--line-soft: rgba(255,255,255,.08); --line-soft: rgba(236,238,240,.07);
--accent: #8cc9e6; --accent: #86bfdf;
--accent-hover: #a3d4ec; --accent-hover: #9dcbe5;
--accent-active: #6fb3d3; --accent-active: #6aa9cc;
--accent-soft: rgba(140,201,230,.15); --accent-soft: rgba(134,191,223,.13);
--band-neu: #116896; --band-neu: #116896;
--text: #e8f0f5; --text: #eceef0;
--text-muted: #a9bccb; --text-muted: #a5abb2;
--text-subtle: #7790a2; --text-subtle: #878d95;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30); --sh-1: 0 1px 2px rgba(0,0,0,.4);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30); --sh-2: 0 4px 16px rgba(0,0,0,.45);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35); --sh-3: 0 16px 40px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58); --glas-rahmen: rgba(24,27,30,.84);
--glas-dicht: rgba(22,34,44,.90); --glas-dicht: rgba(22,24,27,.95);
--glas-deckend: #15212b; --glas-deckend: #181a1d;
--glas-kante: rgba(255,255,255,.18); --glas-kante: rgba(255,255,255,.12);
--glas-kante-2: rgba(255,255,255,.07); --glas-kante-2: rgba(255,255,255,.05);
--glas-hell: #2c5675; --glas-hell: #2c5675;
--glas-tief: #1d6f99; --glas-tief: #1d6f99;
--glas-logo: .10; --glas-logo: .06;
} }
/* ---------- Hintergrund: Lichtflächen und Logo ---------- */ /* ---------- Hintergrund: Lichtflächen und Logo ---------- */
html.glas body { html.glas body {
background: background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%), radial-gradient(60% 55% at 8% 4%, color-mix(in srgb, var(--glas-hell) 26%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%), radial-gradient(50% 50% at 92% 12%, color-mix(in srgb, var(--glas-tief) 9%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
radial-gradient(40% 40% at 28% 82%, color-mix(in srgb, var(--glas-hell) 42%, transparent), transparent 70%),
var(--bg); var(--bg);
background-attachment: fixed; background-attachment: fixed;
} }
@@ -157,6 +158,7 @@ html.glas .abw-team-kopf,
html.glas .report-card, html.glas .report-card,
html.glas .load-card, html.glas .load-card,
html.glas .settings-card, html.glas .settings-card,
html.glas .mn-notizen,
html.glas .dash-karte, html.glas .dash-karte,
html.glas .dash-band, html.glas .dash-band,
html.glas .bericht-panel, html.glas .bericht-panel,
@@ -181,8 +183,8 @@ html.glas .plan-toast,
html.glas .glocke-menue, html.glas .glocke-menue,
html.glas .wiz { html.glas .wiz {
background: var(--surface); background: var(--surface);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%); -webkit-backdrop-filter: blur(var(--glas-blur));
backdrop-filter: blur(var(--glas-blur)) saturate(170%); backdrop-filter: blur(var(--glas-blur));
border: 1px solid var(--glas-kante-2); border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-1), inset 0 1px 0 var(--glas-kante), inset 0 -1px 0 var(--glas-kante-2); box-shadow: var(--sh-1), inset 0 1px 0 var(--glas-kante), inset 0 -1px 0 var(--glas-kante-2);
} }
@@ -191,7 +193,7 @@ html.glas .wiz {
html.glas .app-topbar { html.glas .app-topbar {
background: var(--glas-rahmen); background: var(--glas-rahmen);
border-width: 0 0 1px 0; border-width: 0 0 1px 0;
box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(21,58,86,.06); box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(16,24,32,.06);
} }
html.glas .topbar-logo { height: 26px; } html.glas .topbar-logo { height: 26px; }
@@ -248,11 +250,11 @@ html.glas .modal-bg {
margin: 12px 12px 0; margin: 12px 12px 0;
border-radius: var(--r-xl); border-radius: var(--r-xl);
background: var(--glas-rahmen); background: var(--glas-rahmen);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%); -webkit-backdrop-filter: blur(var(--glas-blur));
backdrop-filter: blur(var(--glas-blur)) saturate(170%); backdrop-filter: blur(var(--glas-blur));
border: 1px solid var(--glas-kante-2); border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante); box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
padding: 4px 12px 10px; padding: 4px 12px 12px;
} }
/* Kopf und Reiter sind innerhalb der Karte keine eigenen Flächen mehr. */ /* Kopf und Reiter sind innerhalb der Karte keine eigenen Flächen mehr. */
html.glas .plan-kopf .plan-header, html.glas .plan-kopf .plan-header,
@@ -265,19 +267,25 @@ html.glas .modal-bg {
} }
/* Inhalt bündig unter der Kopfkarte und im selben 12px-Abstand. */ /* Inhalt bündig unter der Kopfkarte und im selben 12px-Abstand. */
html.glas .plan-page .plan-main-content { padding-left: 12px; padding-right: 12px; padding-top: 12px; } html.glas .plan-page .plan-main-content { padding-left: 12px; padding-right: 12px; padding-top: 12px; }
html.glas .plan-kopf .plan-header { padding: 10px 12px 6px; } html.glas .plan-kopf .plan-header { padding: 12px 12px 8px; }
html.glas .plan-kopf .plan-nav { padding: 0; gap: 2px; } html.glas .plan-kopf .plan-nav { padding: 0; gap: 2px; }
/* Reiter wie die Einträge der Seitenleiste (html.glas .nav-item.active):
32 px, helle Fläche mit Haarlinie, Schrift dunkel statt Akzentblau. */
html.glas .plan-kopf .plan-nav-item { html.glas .plan-kopf .plan-nav-item {
border-bottom: 0; border-bottom: 0;
border-radius: 999px; border-radius: 8px;
padding: 8px 16px; height: 32px;
font-size: 13.5px; padding: 0 12px;
font-size: 13px;
} }
html.glas .plan-kopf .plan-nav-item:hover { background: var(--surface-sunk); } html.glas .plan-kopf .plan-nav-item:hover { background: var(--surface-sunk); }
/* Der Kopf ist selbst eine helle Karte — eine weiße Kapsel wie in der
(grau durchscheinenden) Seitenleiste verschwände darauf. Deshalb hier
die umgekehrte Stufe: etwas dunkler als die Karte, mit Haarlinie. */
html.glas .plan-kopf .plan-nav-item.is-active { html.glas .plan-kopf .plan-nav-item.is-active {
background: var(--glas-dicht); background: rgba(16,24,32,.07);
color: var(--accent); color: var(--text);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10); box-shadow: inset 0 0 0 1px rgba(16,24,32,.08);
} }
} }
@media (min-width: 769px) and (prefers-color-scheme: dark) { @media (min-width: 769px) and (prefers-color-scheme: dark) {
@@ -293,13 +301,13 @@ html.glas .modal-bg {
Grau, auf allen anderen Seiten den Glas-Hintergrund. */ Grau, auf allen anderen Seiten den Glas-Hintergrund. */
html.glas .plan-main { background: transparent; } html.glas .plan-main { background: transparent; }
html.glas .nav-item { border-radius: 999px; } html.glas .nav-item { border-radius: 8px; }
html.glas .nav-item:hover, html.glas .nav-item:hover,
html.glas .nav-item:visited:hover { background: var(--surface-sunk); } html.glas .nav-item:visited:hover { background: var(--surface-sunk); }
html.glas .nav-item.active { html.glas .nav-item.active {
background: var(--glas-dicht); background: var(--glas-dicht);
color: var(--text); color: var(--text);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10); box-shadow: 0 0 0 1px var(--line-soft);
} }
html.glas .nav-item.active .nav-icon { color: var(--accent); } html.glas .nav-item.active .nav-icon { color: var(--accent); }
/* Die bestehenden Dunkel-Regeln für die Navigation setzen feste Werte — /* Die bestehenden Dunkel-Regeln für die Navigation setzen feste Werte —
@@ -373,9 +381,8 @@ html.glas .gantt-row.gantt-days .gantt-label { background: var(--gantt-label-gru
die (noch leere) App-Oberfläche, die nicht durchscheinen darf. */ die (noch leere) App-Oberfläche, die nicht durchscheinen darf. */
html.glas .login-seite { html.glas .login-seite {
background: background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%), radial-gradient(60% 55% at 8% 4%, color-mix(in srgb, var(--glas-hell) 26%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%), radial-gradient(50% 50% at 92% 12%, color-mix(in srgb, var(--glas-tief) 9%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
var(--bg); var(--bg);
} }
+2 -2
View File
@@ -265,9 +265,9 @@ function abwTeamHtml(){
<div class="section-title" style="margin:0">Team im ${esc(abwMonat.toLocaleDateString('de-DE', { month: 'long' }))}</div> <div class="section-title" style="margin:0">Team im ${esc(abwMonat.toLocaleDateString('de-DE', { month: 'long' }))}</div>
<span class="abw-team-steuer"> <span class="abw-team-steuer">
<span class="abw-monat"> <span class="abw-monat">
<button type="button" class="abw-rund" onclick="abwMonatWechseln(-1)" aria-label="Voriger Monat">‹</button> <button type="button" class="abw-rund" onclick="abwMonatWechseln(-1)" aria-label="Voriger Monat" title="Voriger Monat">${icon('chevronLeft')}</button>
<strong>${esc(monatName)}</strong> <strong>${esc(monatName)}</strong>
<button type="button" class="abw-rund" onclick="abwMonatWechseln(1)" aria-label="Nächster Monat">›</button> <button type="button" class="abw-rund" onclick="abwMonatWechseln(1)" aria-label="Nächster Monat" title="Nächster Monat">${icon('chevronRight')}</button>
<button type="button" class="secondary small" onclick="abwMonatWechseln(0)">Heute</button> <button type="button" class="secondary small" onclick="abwMonatWechseln(0)">Heute</button>
</span> </span>
<span class="abw-reiter" role="group" aria-label="Gewerk">${['Alle'].concat(gewerke).map(g => `<button type="button" class="${g === abwFilter ? 'ist-an' : ''}" onclick="abwFilterSetzen('${g}')">${g}</button>`).join('')}</span> <span class="abw-reiter" role="group" aria-label="Gewerk">${['Alle'].concat(gewerke).map(g => `<button type="button" class="${g === abwFilter ? 'ist-an' : ''}" onclick="abwFilterSetzen('${g}')">${g}</button>`).join('')}</span>
+145 -14
View File
@@ -64,6 +64,7 @@ const ADMIN_SECTIONS = [
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' }, { key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' }, { key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' }, { key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' },
{ key: 'teams', title: 'Teams', recht: 'a.teams' },
{ key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' }, { key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] }, { key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] },
]; ];
@@ -75,6 +76,13 @@ function adminNavAbgleichen(){
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]'); const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
if(item) item.hidden = !adminAbschnittErlaubt(sec); if(item) item.hidden = !adminAbschnittErlaubt(sec);
}); });
// Zwischenüberschriften (Personen/Vorgaben/System) nur, wenn darunter
// mindestens ein erlaubter Punkt steht.
document.querySelectorAll('#adminSubnav .sidebar-subgruppe').forEach(kopf => {
let el = kopf.nextElementSibling, sichtbar = false;
while(el && !el.classList.contains('sidebar-subgruppe')){ if(!el.hidden) sichtbar = true; el = el.nextElementSibling; }
kopf.hidden = !sichtbar;
});
} }
// adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen? // adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen?
@@ -159,6 +167,7 @@ function showAdminSection(key){
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso // Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
// frisch holen. // frisch holen.
if(key === 'papierkorb') loadAdminPapierkorb(); if(key === 'papierkorb') loadAdminPapierkorb();
if(key === 'teams') loadAdminTeams();
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte(); if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
try { try {
@@ -287,6 +296,14 @@ function juengsteWoche(){
return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop(); return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop();
} }
// adminSitzungen: ALLE Sitzungen für die Liste — geladene als volles Objekt
// (aktueller Sperrstand), nicht geladene aus db.wochenListe (schlank). Seit
// beim Start nur die jüngsten Sitzungen geladen werden (js/store.js).
function adminSitzungen(){
const liste = (db.wochenListe && db.wochenListe.length) ? db.wochenListe : db.weeks;
return liste.map(k => db.weeks.find(w => w.label === k.label) || k);
}
// renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks), // renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks),
// neueste zuerst, mit Datum, Zustand und den passenden Aktionen. // neueste zuerst, mit Datum, Zustand und den passenden Aktionen.
// //
@@ -321,7 +338,7 @@ function renderAdminSitzungen(){
if(!el || !Array.isArray(db.weeks)) return; if(!el || !Array.isArray(db.weeks)) return;
const juengste = juengsteWoche(); const juengste = juengsteWoche();
const sortiert = [...db.weeks].sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label))); const sortiert = adminSitzungen().sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
el.innerHTML = sortiert.map(w => { el.innerHTML = sortiert.map(w => {
const aktuell = w.id === db.currentWeek; const aktuell = w.id === db.currentWeek;
const istJuengste = juengste && w.id === juengste.id; const istJuengste = juengste && w.id === juengste.id;
@@ -337,20 +354,20 @@ function renderAdminSitzungen(){
// jede Aktion und hätte keinen Weg zurück außer über eine andere, // jede Aktion und hätte keinen Weg zurück außer über eine andere,
// zufällige applyAutoLock()-auslösende Handlung. // zufällige applyAutoLock()-auslösende Handlung.
chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>'; chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>';
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`; aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.label)}')">Fertig – wieder sperren</button>`;
} else if(w.locked){ } else if(w.locked){
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>'; chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>` : ''; aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.label)}')">Entsperren</button>` : '';
} else { } else {
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>'; chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`; aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.label)}')">Öffnen</button>`;
} }
return `<div class="template-admin-row"> return `<div class="template-admin-row">
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span> <span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
${chip} ${chip}
${aktionen} ${aktionen}
${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>` : ''} ${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.label)}')" title="Sitzung löschen">×</button>` : ''}
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -363,15 +380,22 @@ function renderAdminSitzungen(){
// nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert // nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert
// hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund // hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund
// zur aktuellen zu machen. // zur aktuellen zu machen.
function adminUnlockWeek(weekId){ // Alle vier nehmen das Label: Die Sitzung ist evtl. nicht geladen und wird
unlockWeek(db.weeks.find(w => w.id === weekId), renderAdminSitzungen); // dafür erst nachgeholt (sitzungSicherLaden(), js/store.js).
function adminMitSitzung(label, fn){
sitzungSicherLaden(label).then(w => {
if(!w){ alert('Die Sitzung ' + label + ' wurde am Server nicht gefunden.'); renderAdminSitzungen(); return; }
fn(w);
}).catch(() => alert('Die Sitzung ' + label + ' konnte nicht geladen werden.'));
} }
function adminDeleteWeek(weekId){ function adminUnlockWeek(label){
deleteWeekById(weekId, renderAdminSitzungen); adminMitSitzung(label, w => unlockWeek(w, () => { sitzungKurzAbgleichen(w); renderAdminSitzungen(); }));
} }
function adminOpenWeek(weekId){ function adminDeleteWeek(label){
switchWeek(weekId); adminMitSitzung(label, w => deleteWeekById(w.id, renderAdminSitzungen));
showTab('board'); }
function adminOpenWeek(label){
adminMitSitzung(label, w => { switchWeek(w.id); showTab('board'); });
} }
// adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene // adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene
@@ -383,10 +407,11 @@ function adminOpenWeek(weekId){
// markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT // markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT
// die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in // die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in
// js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe. // js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe.
function adminRelockWeek(weekId){ function adminRelockWeek(label){
const w = db.weeks.find(x => x.id === weekId); const w = db.weeks.find(x => x.label === label);
if(!w || w.locked) return; if(!w || w.locked) return;
w.locked = true; w.locked = true;
sitzungKurzAbgleichen(w);
markWeekDirty(w.label); markWeekDirty(w.label);
save({ week: false }); save({ week: false });
const juengste = juengsteWoche(); const juengste = juengsteWoche();
@@ -1083,3 +1108,109 @@ async function adminProjektEndgueltigLoeschen(id){
alert('Löschen fehlgeschlagen: ' + err.message); alert('Löschen fehlgeschlagen: ' + err.message);
} }
} }
// ---------- Teams (Admin → Teams, Server: /admin/teams*) ----------
// Die Webhook-Adresse ist ein Geheimnis und kommt nie zurück in den Browser;
// angezeigt wird nur, ob eine eingetragen ist (und an welchen Host).
async function loadAdminTeams(){
const el = document.getElementById('adminTeamsInhalt');
if(!el) return;
let d;
try { d = await fetch(baseUrl() + '/admin/teams').then(r => r.json()); }
catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
if(!d || !d.ok){ el.innerHTML = '<span class="settings-warn">Keine Berechtigung.</span>'; return; }
// Ist schon eine Adresse hinterlegt, bleibt das Eingabefeld zu, bis man
// „Ändern“ wählt — die Adresse selbst kommt ohnehin nie zurück.
const feldOffen = !d.konfiguriert;
el.innerHTML = `
<div class="teams-status${d.konfiguriert ? ' ist-an' : ''}">
<span class="teams-punkt" aria-hidden="true"></span>
<span class="teams-status-text">${d.konfiguriert
? '<b>Eingerichtet</b><span>Workflow bei ' + esc(d.host) + '</span>'
: '<b>Noch nicht eingerichtet</b><span>Adresse aus dem Teams-Workflow unten einfügen</span>'}</span>
${d.konfiguriert ? '<button type="button" class="teams-text-knopf" onclick="teamsFeldZeigen()">Adresse ändern</button>' : ''}
</div>
<div class="teams-feld" id="teamsFeld"${feldOffen ? '' : ' hidden'}>
<label for="teamsWebhook">${d.konfiguriert ? 'Neue Workflow-Adresse' : 'Workflow-Adresse'}</label>
<textarea id="teamsWebhook" rows="3" spellcheck="false" autocomplete="off"
placeholder="https://… (beim Auslöser des Teams-Workflows kopieren und hier einfügen)"
oninput="teamsFeldPruefen()"></textarea>
<div class="teams-feld-fuss">
<span class="teams-feld-hinweis" id="teamsFeldHinweis">Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.</span>
${d.konfiguriert ? '<button type="button" class="secondary small" onclick="loadAdminTeams()">Abbrechen</button>' : ''}
<button type="button" class="small" id="teamsSpeichernKnopf" disabled onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Speichern</button>
</div>
</div>
<label class="teams-schalter">
<input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''} ${d.konfiguriert ? '' : 'disabled'}
onchange="teamsSpeichern({ neueAufgaben: this.checked })">
<span class="teams-schalter-bahn" aria-hidden="true"></span>
<span class="teams-schalter-text"><b>Zuständige über neue Aufgaben benachrichtigen</b>
<span>Persönliche Nachricht mit Link zur Aufgabe</span></span>
</label>
<div class="teams-aktionen">
<button type="button" class="secondary small" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht an mich senden</button>
${d.konfiguriert ? '<button type="button" class="teams-text-knopf ist-gefahr" onclick="teamsEntfernen()">Anbindung entfernen</button>' : ''}
</div>
<p class="field-error" id="teamsFehler" hidden></p>`;
}
// teamsFeldZeigen: blendet das Eingabefeld für eine neue Adresse ein.
function teamsFeldZeigen(){
const f = document.getElementById('teamsFeld');
if(f){ f.hidden = false; document.getElementById('teamsWebhook').focus(); }
}
// teamsFeldPruefen: Speichern erst bei einer https-Adresse; Hinweis passt sich an.
function teamsFeldPruefen(){
const wert = (document.getElementById('teamsWebhook').value || '').trim();
const ok = /^https:\/\/\S+$/.test(wert);
const knopf = document.getElementById('teamsSpeichernKnopf');
const hinweis = document.getElementById('teamsFeldHinweis');
if(knopf) knopf.disabled = !ok;
if(hinweis){
hinweis.classList.toggle('ist-fehler', !!wert && !ok);
hinweis.textContent = !wert ? 'Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.'
: ok ? 'Sieht gut aus — jetzt speichern und danach eine Testnachricht senden.'
: 'Die Adresse muss mit https:// beginnen und darf keine Leerzeichen enthalten.';
}
}
async function teamsSpeichern(patch){
if('webhook' in patch && !String(patch.webhook || '').trim()){ alert('Bitte die Adresse aus dem Teams-Workflow einfügen.'); return; }
const r = await fetch(baseUrl() + '/admin/teams', {
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(patch)
});
const d = await r.json().catch(() => ({}));
if(!r.ok || !d.ok){ alert(d.error || 'Speichern fehlgeschlagen.'); return; }
// Nach dem ersten Eintragen der Adresse die Meldungen gleich einschalten.
if('webhook' in patch && !document.getElementById('teamsNeueAufgaben').checked){
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ neueAufgaben: true }) });
}
loadAdminTeams();
}
async function teamsEntfernen(){
if(!confirm('Teams-Anbindung entfernen? Es werden keine Nachrichten mehr gesendet.')) return;
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ webhook: '', neueAufgaben: false }) });
loadAdminTeams();
}
async function teamsTesten(){
const knopf = document.getElementById('teamsTestKnopf');
const fehler = document.getElementById('teamsFehler');
if(knopf){ knopf.disabled = true; knopf.textContent = 'Wird gesendet…'; }
try {
const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json());
if(fehler){ fehler.hidden = d.ok; fehler.textContent = d.ok ? '' : 'Nicht gesendet: ' + (d.error || 'unbekannter Fehler'); }
if(d.ok) alert('Testnachricht gesendet an ' + (d.an || 'Sie') + ' — bitte im Teams-Chat mit dem Flow-Bot nachsehen.');
} catch(e){
if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; }
} finally {
if(knopf){ knopf.disabled = false; knopf.textContent = 'Testnachricht senden'; }
}
}
+2
View File
@@ -155,6 +155,8 @@ function afterAuthenticated(onAuthenticated){
hideLoginOverlay(); hideLoginOverlay();
renderUserBar(); renderUserBar();
onAuthenticated(); onAuthenticated();
// "Termin aus Mail" (js/plan/plan-mail-eintrag.js) nur mit eingerichteter KI.
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
if(currentUser && currentUser.mustChangePassword) openPasswordModal(true); if(currentUser && currentUser.mustChangePassword) openPasswordModal(true);
// Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind // Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind
// und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst // und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst
+1 -1
View File
@@ -748,7 +748,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen. // renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
// Parameter: p (Projekt), locked. Rückgabe: HTML-String. // Parameter: p (Projekt), locked. Rückgabe: HTML-String.
function renderKommentarAnsicht(p, locked){ function renderKommentarAnsicht(p, locked){
const text = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || ''); const text = kommentarAnzeigeHtml(p, kommentarGewerk(), true);
return `<div class="status-edit is-ansicht locked-cell">${text || return `<div class="status-edit is-ansicht locked-cell">${text ||
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`; '<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
} }
+157 -66
View File
@@ -55,8 +55,6 @@ const ABSENCE_TYPE_CLASS = {
// renderDashboard()-Aufruf neu gestartet (vorheriges Intervall zuerst // renderDashboard()-Aufruf neu gestartet (vorheriges Intervall zuerst
// gestoppt), damit beim wiederholten Tab-Wechsel nicht mehrere Intervalle // gestoppt), damit beim wiederholten Tab-Wechsel nicht mehrere Intervalle
// parallel laufen. // parallel laufen.
let dashboardClockTimer = null;
// renderDashboard: lädt GET /my-dashboard und rendert Begrüßung/Uhr, // renderDashboard: lädt GET /my-dashboard und rendert Begrüßung/Uhr,
// Kennzahlen sowie die drei Bereiche in #dashboardContent. Wird nur beim // Kennzahlen sowie die drei Bereiche in #dashboardContent. Wird nur beim
// Wechsel auf den Tab aufgerufen (showTab()), nicht beim initialen // Wechsel auf den Tab aufgerufen (showTab()), nicht beim initialen
@@ -66,6 +64,9 @@ async function renderDashboard(){
if(!container) return; if(!container) return;
container.innerHTML = '<div class="hint">Lade Dashboard…</div>'; container.innerHTML = '<div class="hint">Lade Dashboard…</div>';
// Notizen je Projekt (Anzahl) parallel — fällt sie aus, fehlt nur die Angabe.
const notizenAnfrage = fetch(baseUrl() + '/projekt-notizen/anzahl')
.then(r => r.ok ? r.json() : null).catch(() => null);
let data; let data;
try { try {
const res = await fetch(baseUrl() + '/my-dashboard'); const res = await fetch(baseUrl() + '/my-dashboard');
@@ -76,6 +77,8 @@ async function renderDashboard(){
return; return;
} }
const nz = await notizenAnfrage;
dashNotizen = (nz && nz.ok && nz.anzahl) || {};
const weekLabel = data.weekLabel || null; const weekLabel = data.weekLabel || null;
const tasks = Array.isArray(data.tasks) ? data.tasks : []; const tasks = Array.isArray(data.tasks) ? data.tasks : [];
const projectDates = Array.isArray(data.projectDates) ? data.projectDates : []; const projectDates = Array.isArray(data.projectDates) ? data.projectDates : [];
@@ -103,12 +106,15 @@ async function renderDashboard(){
const heuteFaellig = tasks.filter(t => t.end === heute).length const heuteFaellig = tasks.filter(t => t.end === heute).length
+ projectDates.filter(d => d.heute).length; + projectDates.filter(d => d.heute).length;
const daten = dashDaten();
const sonntag = formatDateISO(new Date(dashWochenMontag().getTime() + 6 * 86400000));
const dieseWoche = daten.eintraege.filter(e => e.datum >= heute && e.datum <= sonntag).length;
let html = ''; let html = '';
html += renderDashboardKopf(weekLabel); html += renderDashboardKopf(weekLabel);
html += renderDashboardBand({ html += renderDashboardBand({
ueberfaellig, heuteFaellig, ueberfaellig, heuteFaellig, dieseWoche,
aufgaben: tasks.length, aufgaben: tasks.length,
termine: projectDates.length,
freigaben: pending.length, freigaben: pending.length,
abwesend: absencesToday, abwesend: absencesToday,
}); });
@@ -120,10 +126,10 @@ async function renderDashboard(){
// Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen, // Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen,
// weil es das ist, was sich seit dem letzten Blick geändert hat. // weil es das ist, was sich seit dem letzten Blick geändert hat.
html += '<div id="dashNeu"></div>'; html += '<div id="dashNeu"></div>';
html += renderDashWoche(daten);
html += '<div class="dash-zonen">'; html += '<div class="dash-zonen">';
// Linke Zone: die eigenen Projekte mit ihren Terminen // Linke Zone: die eigenen Projekte mit ihren Terminen
const daten = dashDaten();
html += '<div>'; html += '<div>';
const zuAlle = dashZuLesen(); const zuAlle = dashZuLesen();
const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p))); const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p)));
@@ -153,7 +159,6 @@ async function renderDashboard(){
if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); } if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); }
dashKalDaten = daten; dashKalDaten = daten;
renderDashKalender(); renderDashKalender();
startDashboardClock();
} }
// ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ---------- // ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ----------
@@ -164,6 +169,8 @@ async function renderDashboard(){
let dashKalDaten = null; let dashKalDaten = null;
let dashKalMonat = null; // { jahr, monat } (monat 0–11) let dashKalMonat = null; // { jahr, monat } (monat 0–11)
let dashKalTag = null; // ISO-Datum let dashKalTag = null; // ISO-Datum
// dashNotizen: { project_uid: {anzahl, zuletzt} } aus GET /projekt-notizen/anzahl.
let dashNotizen = {};
// dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte // dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte
// des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu, // des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu,
@@ -253,6 +260,23 @@ function dashProjektUrl(p, abschnitt){
return url.toString(); return url.toString();
} }
// dashImTabOeffnen: öffnet den Projektbereich im SELBEN Tab (Eriks Wunsch),
// ohne die Seite neu zu laden. Strg/Cmd/Umschalt-Klick und Mittelklick
// behalten das Browser-Verhalten (neuer Tab), dafür bleibt das href stehen.
// Der Abschnitt (#aufgaben …) wird wie in springZuEinstellungen() VOR
// showTab('plan') gesetzt, dessen updateRouteUrl() ihn übernimmt.
function dashImTabOeffnen(ev, a){
if(ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return true;
ev.preventDefault();
const url = new URL(a.href);
const name = decodeURIComponent(url.pathname.split('/').pop() || '');
setPlanTarget(url.searchParams.get('weekLabel') || '', url.searchParams.get('projNr') || '', name);
location.hash = url.hash.slice(1) || 'dashboard';
showTab('plan');
window.scrollTo(0, 0);
return false;
}
// DASH_TERMINE_JE_PROJEKT: so viele Termine stehen je Projekt offen da, // DASH_TERMINE_JE_PROJEKT: so viele Termine stehen je Projekt offen da,
// Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst // Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst
// verdrängt ein Projekt mit 25 gerissenen Fristen alle anderen aus dem Blick // verdrängt ein Projekt mit 25 gerissenen Fristen alle anderen aus dem Blick
@@ -423,9 +447,8 @@ function renderDashProjekte(daten){
const key = dashProjKey(p); const key = dashProjKey(p);
const istZu = zu.has(key); const istZu = zu.has(key);
const rollen = x.rollen.length const rollen = x.rollen.length
? x.rollen.map(r => '<span class="dash-rolle' + (r.state === 2 ? ' ist-neben' : '') + '" title="' ? x.rollen.map(r => esc(r.rolle) + (r.state === 2 ? ', Unterstützung' : '')).join(' / ')
+ (r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung') + '">' + esc(r.rolle) + '</span>').join('') : 'nur eigene Aufgabe';
: '<span class="dash-rolle ist-neben" title="Nicht zugeordnet, aber mit eigener Aufgabe">nur Aufgabe</span>';
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0]; const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
const pct = aktLp && p.lph && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? Math.max(0, Math.min(100, Number(p.lph[aktLp]) || 0)) : null; const pct = aktLp && p.lph && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? Math.max(0, Math.min(100, Number(p.lph[aktLp]) || 0)) : null;
const phase = aktLp const phase = aktLp
@@ -441,12 +464,12 @@ function renderDashProjekte(daten){
? sichtbar.map(t => renderDashTermin(p, t)).join('') ? sichtbar.map(t => renderDashTermin(p, t)).join('')
: '<div class="dash-leer">Keine offenen Termine.</div>'; : '<div class="dash-leer">Keine offenen Termine.</div>';
const mehr = rest > 0 const mehr = rest > 0
? '<a class="dash-proj-mehr" href="' + dashProjektUrl(p, 'aufgaben') + '" target="_blank" rel="noopener">' + rest + ' weitere Termine im Projekt</a>' ? '<a class="dash-proj-mehr" href="' + dashProjektUrl(p, 'aufgaben') + '" onclick="return dashImTabOeffnen(event, this)">' + rest + ' weitere Termine im Projekt</a>'
: ''; : '';
// Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele // Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele
// Termine überfällig bzw. offen sind. // Termine überfällig bzw. offen sind.
const kurz = (ueber.length ? '<span class="dash-proj-zahl ist-rot">' + ueber.length + ' überfällig</span>' : '') const kurz = (ueber.length ? '<span class="dash-proj-zahl ist-rot">' + ueber.length + ' überfällig</span>' : '')
+ '<span class="dash-proj-zahl">' + kommend.length + ' offen</span>'; + '<span class="dash-proj-zahl' + (kommend.length ? ' ist-offen' : '') + '">' + kommend.length + ' offen</span>';
return '<div class="dash-karte dash-proj' + (istZu ? ' ist-zu' : '') + '" data-key="' + esc(key) + '">' return '<div class="dash-karte dash-proj' + (istZu ? ' ist-zu' : '') + '" data-key="' + esc(key) + '">'
+ '<div class="dash-proj-kopf">' + '<div class="dash-proj-kopf">'
+ (daten.projekte.length > 1 ? '<span class="dash-proj-griff" tabindex="0" role="button" title="Ziehen, um die Reihenfolge zu ändern (oder Pfeiltasten)" aria-label="Reihenfolge ändern" onmousedown="dashGriffDruck(this)" ontouchstart="dashGriffDruck(this)" onkeydown="dashGriffTaste(event, this)">' + icon('grip') + '</span>' : '') + (daten.projekte.length > 1 ? '<span class="dash-proj-griff" tabindex="0" role="button" title="Ziehen, um die Reihenfolge zu ändern (oder Pfeiltasten)" aria-label="Reihenfolge ändern" onmousedown="dashGriffDruck(this)" ontouchstart="dashGriffDruck(this)" onkeydown="dashGriffTaste(event, this)">' + icon('grip') + '</span>' : '')
@@ -455,10 +478,10 @@ function renderDashProjekte(daten){
+ '<span class="dash-proj-nr">' + esc(p.nr || '') + '</span><span class="dash-proj-titel">' + esc(p.name || '') + '</span>' + '<span class="dash-proj-nr">' + esc(p.nr || '') + '</span><span class="dash-proj-titel">' + esc(p.name || '') + '</span>'
+ '</button>' + '</button>'
+ '<span class="dash-proj-kurz">' + kurz + '</span>' + '<span class="dash-proj-kurz">' + kurz + '</span>'
+ '<span class="dash-rollen">' + rollen + '</span>' + '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" onclick="return dashImTabOeffnen(event, this)" title="Projekt öffnen">Öffnen</a>'
+ '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" target="_blank" rel="noopener" title="Projekt öffnen">Öffnen</a>'
+ '</div>' + '</div>'
+ '<div class="dash-proj-inhalt">' + '<div class="dash-proj-inhalt">'
+ renderDashProjektInfo(x, rollen)
+ '<div class="dash-proj-sub">' + phase + '</div>' + '<div class="dash-proj-sub">' + phase + '</div>'
+ '<div class="dash-proj-termine">' + zeilen + mehr + '</div>' + '<div class="dash-proj-termine">' + zeilen + mehr + '</div>'
+ '</div>' + '</div>'
@@ -466,6 +489,103 @@ function renderDashProjekte(daten){
}).join('') + '</div>'; }).join('') + '</div>';
} }
// renderDashWoche: Band „Diese Woche“ (Entwurf „Bauplan“): Mo bis Fr als
// Spalten mit den Terminen der eigenen Projekte an diesem Tag — eigene
// Aufgaben, Aufgaben im Projekt, Meilensteine, Überfälliges. Je Tag höchstens
// DASH_WOCHE_JE_TAG, der Rest als „+ N weitere“. Parameter: daten (dashDaten()).
const DASH_WOCHE_JE_TAG = 4;
function renderDashWoche(daten){
if(!daten.geladen || !daten.projekte.length) return '';
const heute = heuteIso();
const mo = dashWochenMontag();
const TAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr'];
let spalten = '';
for(let i = 0; i < 5; i++){
const d = new Date(mo); d.setDate(mo.getDate() + i);
const iso = formatDateISO(d);
const e = daten.eintraege.filter(x => x.datum === iso)
.sort((a, b) => (b.meins ? 1 : 0) - (a.meins ? 1 : 0));
const sicht = e.slice(0, DASH_WOCHE_JE_TAG);
const rest = e.length - sicht.length;
spalten += '<div class="dash-woche-tag' + (iso === heute ? ' ist-heute' : '') + '">'
+ '<div class="dash-woche-kopf"><span>' + TAGE[i] + '</span><span class="dash-woche-datum">' + d.getDate() + '.' + (d.getMonth() + 1) + '.</span></div>'
+ '<div class="dash-woche-liste">'
+ (sicht.map(x => {
const art = x.art === 'meilenstein' ? 'ist-ms' : (x.datum < heute ? 'ist-ueber' : (x.meins ? 'ist-meins' : 'ist-fremd'));
return '<a class="dash-woche-eintrag ' + art + '" href="' + dashProjektUrl(x.projekt, x.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" onclick="return dashImTabOeffnen(event, this)"'
+ ' title="' + esc(x.titel + ' (' + [x.projekt.nr, x.projekt.name].filter(Boolean).join(' ') + ')') + '">'
+ '<span class="dash-woche-titel">' + esc(x.titel) + '</span>'
+ '<span class="dash-woche-projekt">' + esc([x.projekt.nr, x.projekt.name].filter(Boolean).join(' ')) + '</span></a>';
}).join('') || '<span class="dash-woche-leer">keine Termine</span>')
+ (rest > 0 ? '<span class="dash-woche-mehr">+ ' + rest + ' weitere</span>' : '')
+ '</div></div>';
}
const legende = '<div class="dash-woche-legende">'
+ '<span><i class="ist-meins"></i>meine Aufgabe</span><span><i class="ist-fremd"></i>Aufgabe im Projekt</span>'
+ '<span><i class="ist-ms"></i>Meilenstein</span><span><i class="ist-ueber"></i>überfällig</span></div>';
return dashAbschnitt('Diese Woche', null, 'eigene Aufgaben und Termine meiner Projekte',
'<div class="dash-karte dash-woche"><div class="dash-woche-rahmen"><div class="dash-woche-raster">' + spalten + '</div></div>' + legende + '</div>');
}
// renderDashProjektInfo: Zeile „Meine Rolle | Team | Notizen“ im Projektblock
// (Eriks Wunsch): die eigene Rolle, die übrigen Beteiligten als Kürzel-Kreise
// in den Rollenfarben (wie Projektliste, beteiligtenAvatarHtml()) und — nur
// wenn vorhanden — die Zahl der Notizen mit Sprung in den Reiter „Notizen“.
// Parameter: x (Eintrag aus dashDaten()), rollenText. Rückgabe: HTML-String.
const DASH_TEAM_MAX = 5;
function renderDashProjektInfo(x, rollenText){
const p = x.p;
const w = currentWeek();
const ich = currentUser && currentUser.personId;
const personen = {};
((w && w.people) || []).forEach(q => { personen[q.id] = q; });
const eintraege = [];
Object.keys(p.assign || {}).forEach(key => {
const [pid, role] = key.split('|');
const state = Number(p.assign[key]) || 0;
if(state > 0) eintraege.push({ person: personen[pid] || { id: pid, name: pid, fullName: pid }, role, state });
});
const alle = beteiligteJePerson(eintraege);
const ichB = alle.find(b => b.person.id === ich);
const team = alle.filter(b => b.person.id !== ich);
const kreis = b => beteiligtenAvatarHtml(b, typeof roleClass === 'function' ? roleClass(b.role) : '');
const kurzName = b => {
const teile = String(b.person.fullName || b.person.name || '').trim().split(/\s+/);
return teile.length > 1 ? teile[0] + ' ' + teile[teile.length - 1][0] + '.' : teile[0];
};
const namen = team.length > 2
? team.slice(0, 2).map(kurzName).join(', ') + ' +' + (team.length - 2)
: team.map(kurzName).join(', ');
let html = '<div class="dash-proj-info">'
+ '<div class="dash-proj-feld"><span class="dash-proj-feld-titel">Meine Rolle</span>'
+ '<span class="dash-proj-feld-wert">' + (ichB ? kreis(ichB) : '') + '<span>' + rollenText + '</span></span></div>'
+ '<div class="dash-proj-feld"><span class="dash-proj-feld-titel">Team</span>'
+ '<span class="dash-proj-feld-wert">'
+ (team.length
? '<span class="dash-proj-team">' + team.slice(0, DASH_TEAM_MAX).map(kreis).join('') + '</span><span class="dash-proj-namen" title="' + esc(team.map(b => b.title).join('\n')) + '">' + esc(namen) + '</span>'
: '<span class="dash-proj-namen">niemand weiter</span>')
+ '</span></div>';
const n = dashNotizen[p.projectUid];
if(n && n.anzahl > 0){
html += '<a class="dash-proj-feld dash-proj-notizen" href="' + dashProjektUrl(p, 'notizen') + '" onclick="return dashImTabOeffnen(event, this)">'
+ '<span class="dash-proj-feld-titel">Notizen</span>'
+ '<span class="dash-proj-feld-wert">' + icon('fileText') + '<span><b>' + n.anzahl + '</b> ' + (n.anzahl === 1 ? 'Notiz' : 'Notizen') + '</span>'
+ (n.zuletzt ? '<span class="dash-proj-zuletzt">' + esc(dashZuletzt(n.zuletzt)) + '</span>' : '') + '</span></a>';
}
return html + '</div>';
}
// dashZuletzt: „zuletzt heute/gestern“ oder „vom 28.10.“ zu einem Zeitstempel.
function dashZuletzt(zeit){
const d = new Date(zeit);
if(isNaN(d)) return '';
const tage = -tageBis(formatDateISO(d));
if(tage <= 0) return 'zuletzt heute';
if(tage === 1) return 'zuletzt gestern';
return 'vom ' + d.getDate() + '.' + (d.getMonth() + 1) + '.';
}
// renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste // renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste
// des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe, // des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe,
// fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute. // fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute.
@@ -480,7 +600,7 @@ function renderDashTermin(p, t, mitProjekt){
const unter = mitProjekt const unter = mitProjekt
? (p.nr ? p.nr + ' ' : '') + (p.name || '') ? (p.nr ? p.nr + ' ' : '') + (p.name || '')
: (t.art === 'meilenstein' ? 'Meilenstein' : (t.meins ? 'meine Aufgabe' : (t.wer.length ? t.wer.join(', ') : 'niemand zugewiesen'))); : (t.art === 'meilenstein' ? 'Meilenstein' : (t.meins ? 'meine Aufgabe' : (t.wer.length ? t.wer.join(', ') : 'niemand zugewiesen')));
return '<a class="dash-termin" href="' + dashProjektUrl(p, t.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" target="_blank" rel="noopener">' return '<a class="dash-termin" href="' + dashProjektUrl(p, t.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" onclick="return dashImTabOeffnen(event, this)">'
+ '<span class="dash-termin-art ' + klasse + '">' + zeichen + '</span>' + '<span class="dash-termin-art ' + klasse + '">' + zeichen + '</span>'
+ '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(t.titel) + '</span>' + '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(t.titel) + '</span>'
+ '<span class="dash-termin-meta">' + esc(unter) + '</span></span>' + '<span class="dash-termin-meta">' + esc(unter) + '</span></span>'
@@ -576,22 +696,30 @@ function dashKalBlaettern(n){
renderDashKalender(); renderDashKalender();
} }
// renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein // renderDashboardKopf: Titel und Kalenderwoche statt Begrüßung (Entwurf
// rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist // „Bauplan“, Eriks Wahl): „KW 45, Mo 2. bis Fr 6. November 2026“, rechts die
// die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand. // Sitzung. Parameter: weekLabel. Rückgabe: HTML-String.
// Die Inhalte füllt startDashboardClock() live nach.
// Parameter: weekLabel. Rückgabe: HTML-String.
function renderDashboardKopf(weekLabel){ function renderDashboardKopf(weekLabel){
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const mo = dashWochenMontag();
const fr = new Date(mo); fr.setDate(mo.getDate() + 4);
const zeile = 'KW ' + getWeekNumber(mo) + ', Mo ' + mo.getDate() + '.'
+ (mo.getMonth() !== fr.getMonth() ? ' ' + MONATE[mo.getMonth()] : '')
+ ' bis Fr ' + fr.getDate() + '. ' + MONATE[fr.getMonth()] + ' ' + fr.getFullYear();
return '<div class="dash-kopf">' return '<div class="dash-kopf">'
+ '<p class="dash-gruss"><span id="dashboardGreetingText"></span>' + '<div class="dash-kopf-links"><span class="dash-kopf-kw">' + esc(zeile) + '</span>'
+ '<em id="dashboardGreetingDate"></em></p>' + '<h2 class="dash-titel">Meine Aufgaben</h2></div>'
+ '<div class="dash-kopf-rechts">' + (weekLabel ? '<div class="dash-kopf-rechts"><span class="dash-kopf-label">Sitzung</span><span class="dash-sitzung">' + esc(weekLabel) + '</span></div>' : '')
+ (weekLabel ? '<span class="dash-sitzung">' + esc(weekLabel) + '</span>' : '')
+ '<span class="dash-uhr" id="dashboardClock"></span>'
+ '</div>'
+ '</div>'; + '</div>';
} }
// dashWochenMontag: Montag der laufenden Woche (lokale Zeit, 0 Uhr).
function dashWochenMontag(){
const d = parseDateISO(heuteIso());
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
return d;
}
// renderDashboardBand: der Befund des Tages als Satz, daneben die Kennzahlen. // renderDashboardBand: der Befund des Tages als Satz, daneben die Kennzahlen.
// //
// Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine // Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine
@@ -617,17 +745,17 @@ function renderDashboardBand(z){
satz = '<b class="ist-ruhig">Nichts offen, nichts überfällig.</b>'; satz = '<b class="ist-ruhig">Nichts offen, nichts überfällig.</b>';
} }
const zahl = (wert, label) => const zahl = (wert, label, rot) =>
'<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + '"><b>' + wert + '</b><span>' + label + '</span></div>'; '<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + (rot ? ' ist-rot' : '') + '"><span>' + label + '</span><b>' + wert + '</b></div>';
const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0; const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0;
return '<div class="dash-band' + (ruhig ? ' ist-ruhig' : '') + '">' return '<div class="dash-band' + (ruhig ? ' ist-ruhig' : '') + '">'
+ '<p class="dash-befund">' + satz + '</p>' + '<p class="dash-befund">' + satz + '</p>'
+ '<div class="dash-band-zahlen">' + '<div class="dash-band-zahlen">'
+ zahl(z.aufgaben, 'offene Aufgaben') + zahl(z.aufgaben, 'offene Aufgaben')
+ zahl(z.termine, 'Termine im Blick') + zahl(z.ueberfaellig, 'überfällig', z.ueberfaellig > 0)
+ zahl(z.dieseWoche, 'diese Woche fällig')
+ zahl(z.freigaben, 'Freigaben') + zahl(z.freigaben, 'Freigaben')
+ zahl(z.abwesend, 'heute abwesend')
+ '</div></div>'; + '</div></div>';
} }
@@ -649,43 +777,6 @@ function dashLeer(text){
return '<div class="dash-karte"><div class="dash-leer">' + esc(text) + '</div></div>'; return '<div class="dash-karte"><div class="dash-leer">' + esc(text) + '</div></div>';
} }
// greetingForHour:// greetingForHour: liefert je Tageszeit eine passende Begrüßung. Parameter:
// hour (Number, 0-23). Rückgabe: String.
function greetingForHour(hour){
if(hour < 5) return 'Gute Nacht';
if(hour < 11) return 'Guten Morgen';
if(hour < 18) return 'Guten Tag';
if(hour < 22) return 'Guten Abend';
return 'Gute Nacht';
}
// startDashboardClock: (re-)startet das Intervall, das Begrüßung, Datum und
// Uhrzeit im Dashboard-Header aktualisiert. Bricht sich selbst ab, sobald die
// Zielelemente nicht mehr im DOM sind (z. B. weil das Dashboard neu gerendert
// wurde). Keine Parameter, keine Rückgabe.
function startDashboardClock(){
if(dashboardClockTimer) clearInterval(dashboardClockTimer);
const name = (currentUser && (currentUser.displayName || currentUser.username)) || '';
const tick = () => {
const textEl = document.getElementById('dashboardGreetingText');
const dateEl = document.getElementById('dashboardGreetingDate');
const clockEl = document.getElementById('dashboardClock');
if(!textEl || !dateEl || !clockEl){
clearInterval(dashboardClockTimer);
dashboardClockTimer = null;
return;
}
const now = new Date();
// Begrüßung und Datum stehen in EINER Zeile (früher untereinander), das
// Datum als gedämpfter Zusatz — deshalb der führende Gedankenstrich.
textEl.textContent = greetingForHour(now.getHours()) + (name ? ', ' + name : '');
dateEl.textContent = ' — ' + now.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
clockEl.textContent = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
};
tick();
dashboardClockTimer = setInterval(tick, 30000);
}
// relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem // relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem
// ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht // ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht
// sagt, ob das morgen oder in drei Wochen ist. // sagt, ob das morgen oder in drei Wochen ist.
+27 -3
View File
@@ -40,7 +40,7 @@ function openDatePicker(opts){
document.body.appendChild(pop); document.body.appendChild(pop);
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 }; const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px'; pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 332)) + 'px';
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px'; pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
renderRangePicker(); renderRangePicker();
@@ -80,7 +80,7 @@ function openRangePicker(opts){
// Position: unterhalb des Ankers, innerhalb des Viewports halten // Position: unterhalb des Ankers, innerhalb des Viewports halten
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 }; const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px'; pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 332)) + 'px';
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px'; pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
renderRangePicker(); renderRangePicker();
@@ -160,6 +160,14 @@ function applyRangePicker(){
const selected = start || end; const selected = start || end;
if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : ''); if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : '');
if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : ''); if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : '');
// Für umgeleitete native Datumsfelder (siehe unten): deren onchange/
// oninput-Handler sollen wie bei einer Eingabe von Hand laufen.
[startInput, endInput].forEach(el => {
if(el && el.type === 'date'){
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
});
if(opts.displayId){ if(opts.displayId){
const disp = document.getElementById(opts.displayId); const disp = document.getElementById(opts.displayId);
if(disp){ if(disp){
@@ -199,10 +207,15 @@ function renderRangePicker(){
</div> </div>
<div class="range-picker-hint">${hint}</div> <div class="range-picker-hint">${hint}</div>
<div class="range-picker-grid">`; <div class="range-picker-grid">`;
// Erste Spalte: Kalenderwoche (Eriks Wunsch — im Büro wird in KW gedacht).
html += '<div class="range-picker-weekday range-picker-kw-kopf">KW</div>';
['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`); ['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`);
const kwZelle = d => `<div class="range-picker-kw">${getWeekNumber(d)}</div>`;
if(firstWeekday > 0) html += kwZelle(first);
for(let i = 0; i < firstWeekday; i++) html += '<div></div>'; for(let i = 0; i < firstWeekday; i++) html += '<div></div>';
for(let day = 1; day <= daysInMonth; day++){ for(let day = 1; day <= daysInMonth; day++){
const d = new Date(st.viewYear, st.viewMonth, day); const d = new Date(st.viewYear, st.viewMonth, day);
if(d.getDay() === 1) html += kwZelle(d);
const iso = formatDateISO(d); const iso = formatDateISO(d);
const classes = ['range-picker-day']; const classes = ['range-picker-day'];
if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend'); if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend');
@@ -218,4 +231,15 @@ function renderRangePicker(){
${st.mode !== 'single' && st.start && !st.end ? '<button type="button" class="small" onclick="rangePickerState.end = rangePickerState.start; applyRangePicker()">Nur dieser Tag</button>' : ''} ${st.mode !== 'single' && st.start && !st.end ? '<button type="button" class="small" onclick="rangePickerState.end = rangePickerState.start; applyRangePicker()">Nur dieser Tag</button>' : ''}
</div>`; </div>`;
pop.innerHTML = html; pop.innerHTML = html;
} }
// Native Datumsfelder (<input type="date">) öffnen statt des Browser-
// Kalenders (kennt keine KW) denselben Kalender wie der Rest der App. Das
// Feld selbst bleibt — Tippen funktioniert weiter, der Picker schreibt den
// ISO-Wert hinein und löst input/change aus. Ausnahme: data-nativ.
document.addEventListener('click', e => {
const input = e.target && e.target.closest ? e.target.closest('input[type="date"]') : null;
if(!input || input.disabled || input.readOnly || input.hasAttribute('data-nativ')) return;
e.preventDefault();
if(!input.id) input.id = 'datum_' + Math.random().toString(36).slice(2, 9);
openDatePicker({ anchor: input, inputId: input.id });
}, true);
+1 -1
View File
@@ -44,7 +44,7 @@ const EINFUEHRUNG_SCHRITTE = [
text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen <b>Rundgang jederzeit neu starten</b>.' }, text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen <b>Rundgang jederzeit neu starten</b>.' },
{ id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin', { id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin',
text: () => 'Du darfst Teile der Verwaltung pflegen: ' + esc(typeof ADMIN_SECTIONS !== 'undefined' ? ADMIN_SECTIONS.filter(adminAbschnittErlaubt).map(s => s.title).join(', ') : '') + '.' }, text: () => 'Du darfst Teile der Verwaltung pflegen: ' + esc(typeof ADMIN_SECTIONS !== 'undefined' ? ADMIN_SECTIONS.filter(adminAbschnittErlaubt).map(s => s.title).join(', ') : '') + '.' },
{ id: 'ende', tab: 'dashboard', titel: 'Fertig!', { id: 'ende', tab: 'dashboard', titel: 'Rundgang abgeschlossen',
text: () => { text: () => {
const hinweise = []; const hinweise = [];
if(currentUser && !currentUser.personId){ if(currentUser && !currentUser.personId){
+4
View File
@@ -108,6 +108,9 @@ let saveSyncTimer = null;
// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung. // In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung.
// globalPeople ist ein flaches Array (keine Abteilungstrennung mehr). // globalPeople ist ein flaches Array (keine Abteilungstrennung mehr).
let db = { weeks: [], currentWeek: null, globalPeople: [], let db = { weeks: [], currentWeek: null, globalPeople: [],
// wochenListe: ALLE Sitzungen schlank (id, label, date, locked, updatedAt) —
// db.weeks hält nur die geladenen (siehe SITZUNGEN_START, js/store.js).
wochenListe: [],
// sandboxWeek: Platzhalter bis zum ersten erfolgreichen load() (js/store.js) // sandboxWeek: Platzhalter bis zum ersten erfolgreichen load() (js/store.js)
// befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht // befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht
// crasht, sondern nur eine leere Planung-Liste zeigt. // crasht, sondern nur eine leere Planung-Liste zeigt.
@@ -144,6 +147,7 @@ const TAB_SLUGS = {
wochenbericht: 'Wochenbericht', wochenbericht: 'Wochenbericht',
load: 'Auslastung', load: 'Auslastung',
abwesenheit: 'Abwesenheiten', abwesenheit: 'Abwesenheiten',
notizen: 'Notizen',
people: 'Projektbeteiligte', people: 'Projektbeteiligte',
planung: 'Planung', planung: 'Planung',
planchecker: 'PlanChecker', planchecker: 'PlanChecker',
+408
View File
@@ -0,0 +1,408 @@
// -----------------------------
// Seitenleiste „Notizen“ (Tab `notizen`, /Notizen)
// -----------------------------
// Eriks Wunsch: Jedes Konto sieht hier die Notizen ALLER seiner Projekte und
// legt auch projektunabhängige Notizen an, die sich beim Bearbeiten einem
// Projekt zuordnen lassen. Gleicher Aufbau wie der Reiter „Notizen“ im
// Projektbereich (js/plan/plan-notizen.js: Liste + Editor, Werkzeugleiste,
// automatisches Speichern), dazu ein Projektfilter und im Editor die Auswahl
// „Projekt“. Eigene Element-ids (mn…), weil der Projekt-Reiter gleichzeitig
// im DOM liegt.
//
// Daten: GET /meine-notizen (Notizen meiner Projekte + meine
// projektunabhängigen, project_uid = ''), sonst dieselben Routen
// /projekt-notizen* wie der Projekt-Reiter; PUT nimmt `uid` zum Zuordnen.
// Projektunabhängige Notizen sieht und ändert nur, wer sie angelegt hat.
(function(){
const SPEICHER_PAUSE_MS = 1200;
const OHNE = '__ohne__';
let liste = [];
let meineProjekte = new Set();
let geladen = false;
let ladeFehler = '';
let filter = ''; // '' = alle, OHNE = ohne Projekt, sonst uid
let suche = '';
let aktivId = null;
let basis = '';
let ungespeichert = false;
let speicherTimer = null;
let speichertGerade = false;
let beobachter = null;
let loeschenFragt = null;
let eigenesSchreiben = 0; // Zeitpunkt der letzten eigenen Änderung (Live-Echo auslassen)
const el = () => document.getElementById('notizen');
const aktiv = () => liste.find(n => n.id === aktivId) || null;
const darf = n => !n || !n.uid ? true : (typeof hatRecht === 'function' && hatRecht('proj.plan'));
const darfProjekte = () => typeof hatRecht === 'function' && hatRecht('proj.plan');
// Projekte der aktuellen Sitzung je projectUid (Name und Nummer).
function projekte(){
const w = typeof currentWeek === 'function' ? currentWeek() : null;
const m = new Map();
((w && w.projects) || []).forEach(p => { if(p.projectUid) m.set(String(p.projectUid), p); });
return m;
}
function projektName(uid, karte){
if(!uid) return 'Ohne Projekt';
const p = (karte || projekte()).get(uid);
return p ? [p.nr, p.name].filter(Boolean).join(' ') : 'Projekt nicht in der aktuellen Sitzung';
}
function zeit(iso){
if(!iso) return '';
const d = new Date(iso);
if(isNaN(d)) return '';
const heute = new Date();
const gestern = new Date(heute); gestern.setDate(heute.getDate() - 1);
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
if(d.toDateString() === heute.toDateString()) return 'heute ' + uhr;
if(d.toDateString() === gestern.toDateString()) return 'gestern ' + uhr;
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: d.getFullYear() === heute.getFullYear() ? undefined : 'numeric' });
}
const klartext = html => typeof statusPlainText === 'function' ? statusPlainText(html) : String(html || '').replace(/<[^>]+>/g, ' ');
function sortieren(){
liste.sort((a, b) => (b.angeheftet - a.angeheftet) || String(b.geaendertAm).localeCompare(String(a.geaendertAm)));
}
// ---------- Laden ----------
function laden(){
ladeFehler = '';
return fetch(baseUrl() + '/meine-notizen')
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(!ok || !d.ok) throw new Error(d.error || 'Laden fehlgeschlagen');
liste = d.notizen || [];
meineProjekte = new Set(d.meineProjekte || []);
sortieren();
geladen = true;
})
.catch(e => { ladeFehler = e.message || 'Die Notizen konnten nicht geladen werden.'; liste = []; geladen = true; });
}
// ---------- Darstellung ----------
window.renderMeineNotizen = function(){
const box = el();
if(!box) return;
box.innerHTML = '<div class="notizen-laedt">Notizen werden geladen…</div>';
jetztSpeichern();
laden().then(aufbauen);
};
function sichtbar(){
const s = suche.trim().toLowerCase();
return liste.filter(n => (!filter || (filter === OHNE ? !n.uid : n.uid === filter))
&& (!s || (n.titel + ' ' + klartext(n.html) + ' ' + projektName(n.uid)).toLowerCase().includes(s)));
}
function filterOptionen(){
const karte = projekte();
const mitNotizen = new Set(liste.filter(n => n.uid).map(n => n.uid));
const uids = Array.from(new Set([...meineProjekte, ...mitNotizen]))
.sort((a, b) => projektName(a, karte).localeCompare(projektName(b, karte), 'de'));
const zahl = f => liste.filter(n => f === OHNE ? !n.uid : n.uid === f).length;
return '<option value="">Alle Projekte (' + liste.length + ')</option>'
+ '<option value="' + OHNE + '"' + (filter === OHNE ? ' selected' : '') + '>Ohne Projekt (' + zahl(OHNE) + ')</option>'
+ uids.map(u => '<option value="' + esc(u) + '"' + (filter === u ? ' selected' : '') + '>' + esc(projektName(u, karte)) + ' (' + zahl(u) + ')</option>').join('');
}
function aufbauen(){
const box = el();
if(!box) return;
const treffer = sichtbar();
if(!treffer.some(n => n.id === aktivId)) aktivId = treffer.length ? treffer[0].id : null;
box.innerHTML = `
<div class="mn-seite">
<div class="mn-kopf">
<div class="mn-kopf-links">
<span class="mn-kopf-unter">Notizen aller meiner Projekte und eigene Notizen ohne Projekt</span>
<h2 class="mn-titel">Notizen</h2>
</div>
<label class="mn-filter">Projekt
<select id="mnFilter" onchange="mnFilter(this.value)">${filterOptionen()}</select>
</label>
</div>
<div class="notizen mn-notizen">
<aside class="notizen-liste">
<div class="notizen-suchzeile">
<input type="search" id="mnSuche" class="notizen-suche" placeholder="Notizen durchsuchen…"
aria-label="Notizen durchsuchen" value="${esc(suche)}" oninput="mnSuchen(this.value)">
<button type="button" class="notizen-neu" onclick="mnNeu()">+ Neu</button>
</div>
<div id="mnEintraege" class="notizen-eintraege"></div>
</aside>
<div class="notizen-editor" id="mnEditor"></div>
</div>
</div>`;
listeZeichnen();
editorZeichnen();
}
function listeZeichnen(){
const box = document.getElementById('mnEintraege');
if(!box) return;
if(ladeFehler){ box.innerHTML = '<div class="notizen-leer ist-fehler">' + esc(ladeFehler) + '</div>'; return; }
const treffer = sichtbar();
if(!liste.length){ box.innerHTML = '<div class="notizen-leer">Noch keine Notizen. Mit „+ Neu“ eine eigene anlegen.</div>'; return; }
if(!treffer.length){ box.innerHTML = '<div class="notizen-leer">' + (suche ? 'Keine Notiz enthält „' + esc(suche) + '“.' : 'Keine Notizen in dieser Auswahl.') + '</div>'; return; }
const karte = projekte();
const zeile = n => {
const vorschau = klartext(n.html).replace(/\s+/g, ' ').trim().slice(0, 90);
return `<button type="button" class="notizen-eintrag${n.id === aktivId ? ' ist-aktiv' : ''}" onclick="mnWaehlen('${esc(n.id)}')">
<span class="notizen-eintrag-titel">${esc(n.titel || 'Ohne Titel')}${n.quelle === 'mail' ? ' <span class="notizen-chip">Mail</span>' : ''}</span>
<span class="notizen-eintrag-text">${esc(vorschau || 'Kein Text')}</span>
<span class="notizen-eintrag-meta"><span class="mn-projekt${n.uid ? '' : ' ist-ohne'}">${esc(projektName(n.uid, karte))}</span> · ${esc(zeit(n.geaendertAm))}</span>
</button>`;
};
const oben = treffer.filter(n => n.angeheftet), rest = treffer.filter(n => !n.angeheftet);
box.innerHTML = (oben.length ? '<div class="notizen-gruppe"><span class="notizen-pin-punkt"></span>Angeheftet</div>' + oben.map(zeile).join('') : '')
+ (rest.length ? (oben.length ? '<div class="notizen-gruppe">Weitere</div>' : '') + rest.map(zeile).join('') : '');
}
// projektAuswahl: Auswahl im Editor — „Ohne Projekt“, meine Projekte, dann
// die übrigen der Sitzung. Ohne Recht proj.plan nur „Ohne Projekt“ (und das
// aktuelle Projekt, damit der Stand lesbar bleibt).
function projektAuswahl(n){
const karte = projekte();
const name = u => projektName(u, karte);
const sort = arr => arr.sort((a, b) => name(a).localeCompare(name(b), 'de'));
const opt = u => '<option value="' + esc(u) + '"' + (n.uid === u ? ' selected' : '') + '>' + esc(name(u)) + '</option>';
const meine = sort(Array.from(meineProjekte).filter(u => karte.has(u)));
const weitere = sort(Array.from(karte.keys()).filter(u => !meineProjekte.has(u)));
const sperren = !darf(n) || (!darfProjekte() && !n.uid);
let html = '<option value=""' + (!n.uid ? ' selected' : '') + '>Ohne Projekt</option>';
if(darfProjekte()){
if(meine.length) html += '<optgroup label="Meine Projekte">' + meine.map(opt).join('') + '</optgroup>';
if(weitere.length) html += '<optgroup label="Weitere Projekte">' + weitere.map(opt).join('') + '</optgroup>';
}
if(n.uid && !karte.has(n.uid)) html += opt(n.uid);
return '<label class="mn-zuordnung">Projekt <select id="mnProjekt" onchange="mnZuordnen(this.value)"'
+ (sperren ? ' disabled title="' + (darfProjekte() ? '' : 'Zum Zuordnen fehlt das Recht, den Terminplan zu bearbeiten.') + '"' : '') + '>' + html + '</select></label>';
}
function editorZeichnen(){
const box = document.getElementById('mnEditor');
if(!box) return;
if(beobachter){ beobachter.disconnect(); beobachter = null; }
const n = aktiv();
if(!n){
box.innerHTML = `<div class="notizen-editor-leer">
<b>${liste.length ? 'Notiz links auswählen' : 'Noch keine Notizen'}</b>
<span>Mit „+ Neu“ eine Notiz anlegen. Sie gehört zunächst nur Ihnen und lässt sich oben rechts einem Projekt zuordnen.</span>
</div>`;
return;
}
const schreiben = darf(n);
basis = n.geaendertAm;
ungespeichert = false;
const projekt = n.uid ? projekte().get(n.uid) : null;
box.innerHTML = `
<div class="notizen-editor-kopf">
<input type="text" id="mnTitel" class="notizen-titel" value="${esc(n.titel)}" placeholder="Titel der Notiz"
aria-label="Titel der Notiz" ${schreiben ? '' : 'readonly'} oninput="mnGeaendert()" onblur="mnJetztSpeichern()">
${schreiben ? `<button type="button" class="notizen-symbol${n.angeheftet ? ' ist-an' : ''}" onclick="mnAnheften()"
aria-pressed="${n.angeheftet}">${n.angeheftet ? 'Angeheftet' : 'Anheften'}</button>
<button type="button" class="notizen-symbol ist-gefahr" id="mnLoeschen" onclick="mnLoeschen()">Löschen</button>` : ''}
</div>
<div class="mn-editor-zeile">
${projektAuswahl(n)}
${projekt ? `<a class="mn-projekt-link" href="#" onclick="return mnProjektOeffnen('${esc(projekt.id)}')">Im Projekt öffnen</a>` : ''}
${!n.uid ? '<span class="mn-hinweis">Nur für Sie sichtbar, bis sie einem Projekt zugeordnet ist</span>' : ''}
</div>
<div class="richtext-wrap notizen-richtext">
${schreiben ? richtextToolbarHtml('#mnText') : ''}
${richtextFeldHtml('notizen-text', sanitizeStatusHtml(n.html || ''), 'Notiz schreiben…', 'id="mnText" onblur="mnJetztSpeichern()"', schreiben)}
</div>
<div class="notizen-fuss">
<span>Angelegt von ${esc(n.erstelltVon)} ${esc(zeit(n.erstelltAm))}${n.geaendertAm !== n.erstelltAm ? ' · zuletzt geändert von ' + esc(n.geaendertVon) + ' ' + esc(zeit(n.geaendertAm)) : ''}</span>
<span id="mnStatus" class="notizen-status"></span>
</div>`;
const text = document.getElementById('mnText');
if(text && schreiben){
beobachter = new MutationObserver(() => mnGeaendert());
beobachter.observe(text, { childList: true, subtree: true, characterData: true, attributes: true });
}
}
function status(text, art){
const s = document.getElementById('mnStatus');
if(!s) return;
s.className = 'notizen-status' + (art ? ' ist-' + art : '');
s.innerHTML = text;
}
function hinweis(text){ if(typeof zeigeHinweis === 'function') zeigeHinweis(text); else status(esc(text), 'fehler'); }
// ---------- Bedienung ----------
window.mnFilter = function(wert){ jetztSpeichern(); filter = wert || ''; aufbauen(); };
window.mnSuchen = function(wert){ suche = wert || ''; listeZeichnen(); };
window.mnWaehlen = function(id){
if(id === aktivId) return;
jetztSpeichern();
aktivId = id;
listeZeichnen();
editorZeichnen();
};
window.mnGeaendert = function(){
const n = aktiv();
if(!n || !darf(n)) return;
ungespeichert = true;
status('Ungespeichert');
clearTimeout(speicherTimer);
speicherTimer = setTimeout(jetztSpeichern, SPEICHER_PAUSE_MS);
};
window.mnJetztSpeichern = function(){ jetztSpeichern(); };
function senden(n, nutzlast){
eigenesSchreiben = Date.now();
return fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(nutzlast)
}).then(r => r.json().then(d => ({ r, d })));
}
function jetztSpeichern(ohneBasis){
clearTimeout(speicherTimer);
const n = aktiv();
if(!n || !ungespeichert || !darf(n)) return Promise.resolve();
if(speichertGerade){ speicherTimer = setTimeout(jetztSpeichern, 400); return Promise.resolve(); }
const titelEl = document.getElementById('mnTitel');
const textEl = document.getElementById('mnText');
const titel = titelEl ? titelEl.value : n.titel;
const html = textEl ? textEl.innerHTML : n.html;
ungespeichert = false;
speichertGerade = true;
status('Speichert…');
return senden(n, { titel, html, basis: ohneBasis ? '' : basis }).then(({ r, d }) => {
if(r.status === 409 && d.konflikt){
ungespeichert = true;
status(esc(d.error) + ' <button type="button" class="notizen-link" onclick="mnKonflikt(true)">Meine Fassung speichern</button>'
+ ' <button type="button" class="notizen-link" onclick="mnKonflikt(false)">Neuen Stand laden</button>', 'warnung');
n._fremd = d.notiz;
return;
}
if(!r.ok || !d.ok) throw new Error(d.error || 'Speichern fehlgeschlagen');
Object.assign(n, d.notiz, { titel, html });
if(n.id === aktivId) basis = d.notiz.geaendertAm;
sortieren();
listeZeichnen();
if(!ungespeichert) status('Gespeichert ✓', 'ok');
}).catch(e => {
ungespeichert = true;
status('Nicht gespeichert: ' + esc(e.message) + ' — wird erneut versucht.', 'fehler');
speicherTimer = setTimeout(jetztSpeichern, 5000);
}).finally(() => { speichertGerade = false; });
}
window.mnKonflikt = function(meine){
const n = aktiv();
if(!n) return;
if(meine){ ungespeichert = true; jetztSpeichern(true); return; }
if(n._fremd) Object.assign(n, n._fremd);
delete n._fremd;
ungespeichert = false;
listeZeichnen();
editorZeichnen();
};
// mnNeu: neue Notiz — im gefilterten Projekt, sonst ohne Projekt.
window.mnNeu = function(){
jetztSpeichern();
const uid = filter && filter !== OHNE && darfProjekte() ? filter : '';
eigenesSchreiben = Date.now();
fetch(baseUrl() + '/projekt-notizen', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ uid, titel: '', html: '' })
}).then(r => r.json()).then(d => {
if(!d.ok) throw new Error(d.error || 'Anlegen fehlgeschlagen');
liste = liste.filter(x => x.id !== d.notiz.id).concat([d.notiz]);
sortieren();
aktivId = d.notiz.id;
suche = '';
aufbauen();
const t = document.getElementById('mnTitel');
if(t) t.focus();
}).catch(e => hinweis('Notiz konnte nicht angelegt werden: ' + e.message));
};
// mnZuordnen: Notiz einem Projekt zuordnen (oder wieder lösen). Offene
// Änderungen werden vorher gespeichert.
window.mnZuordnen = function(uid){
const n = aktiv();
if(!n) return;
jetztSpeichern().then(() => senden(n, { uid: uid || '' })).then(({ r, d }) => {
if(!r.ok || !d.ok) throw new Error(d.error || 'Zuordnen fehlgeschlagen');
Object.assign(n, d.notiz);
if(n.uid) meineProjekte.add(n.uid);
if(filter && filter !== (n.uid || OHNE)) filter = n.uid || OHNE;
aufbauen();
status(n.uid ? 'Dem Projekt zugeordnet ✓' : 'Vom Projekt gelöst ✓', 'ok');
}).catch(e => { hinweis(e.message); editorZeichnen(); });
};
window.mnAnheften = function(){
const n = aktiv();
if(!n) return;
jetztSpeichern().then(() => senden(n, { angeheftet: !n.angeheftet })).then(({ d }) => {
if(!d.ok) throw new Error(d.error || 'Fehler');
n.angeheftet = d.notiz.angeheftet;
sortieren();
listeZeichnen();
editorZeichnen();
}).catch(e => hinweis('Anheften fehlgeschlagen: ' + e.message));
};
window.mnLoeschen = function(){
const n = aktiv();
const knopf = document.getElementById('mnLoeschen');
if(!n || !knopf) return;
if(!loeschenFragt){
knopf.textContent = 'Wirklich löschen?';
knopf.classList.add('ist-fragend');
loeschenFragt = setTimeout(() => {
loeschenFragt = null;
const k = document.getElementById('mnLoeschen');
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
}, 4000);
return;
}
clearTimeout(loeschenFragt); loeschenFragt = null;
clearTimeout(speicherTimer); ungespeichert = false;
eigenesSchreiben = Date.now();
fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), { method: 'DELETE' })
.then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
if(!r.ok || !d.ok) throw new Error(d.error || 'Löschen fehlgeschlagen');
liste = liste.filter(x => x.id !== n.id);
aktivId = null;
aufbauen();
}).catch(e => { status(esc(e.message), 'fehler'); const k = document.getElementById('mnLoeschen'); if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); } });
};
window.mnProjektOeffnen = function(pid){
jetztSpeichern();
location.hash = 'notizen';
if(typeof openProjectPlan === 'function') openProjectPlan(pid);
return false;
};
// ---------- Live: Änderungen aus anderen Browsern ----------
window.meineNotizenLive = function(uid){
if(typeof currentTab === 'undefined' || currentTab !== 'notizen' || !geladen) return;
if(uid && !meineProjekte.has(uid)) return;
const vorher = aktiv();
const tippt = document.activeElement && document.activeElement.closest && document.activeElement.closest('#mnEditor');
// Das Echo der eigenen Speicherung (kommt auch in diesem Tab an) auslassen.
if(ungespeichert || tippt || Date.now() - eigenesSchreiben < 3000) return;
laden().then(() => {
if(vorher && liste.some(x => x.id === vorher.id)) aktivId = vorher.id;
aufbauen();
});
};
// Klick irgendwo anders: die Rückfrage „Wirklich löschen?“ zurücknehmen.
document.addEventListener('pointerdown', e => {
if(!loeschenFragt || (e.target.closest && e.target.closest('#mnLoeschen'))) return;
clearTimeout(loeschenFragt); loeschenFragt = null;
const k = document.getElementById('mnLoeschen');
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
}, true);
window.addEventListener('pagehide', () => { if(ungespeichert) jetztSpeichern(); });
})();
+3 -4
View File
@@ -68,7 +68,6 @@
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei // Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich. // Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
if(typeof renderPlanTasks === 'function') renderPlanTasks(); if(typeof renderPlanTasks === 'function') renderPlanTasks();
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(typeof renderPlanDashboard === 'function') renderPlanDashboard(); if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
}; };
@@ -541,7 +540,7 @@
const ampel = loadAmpelColor(l.quote); const ampel = loadAmpelColor(l.quote);
const breite = Math.min(100, l.quote * 100); const breite = Math.min(100, l.quote * 100);
const absCount = app.countPersonAbsences(l.person.id); const absCount = app.countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : ''; const absBadge = absCount > 0 ? `<div class="hint" style="font-size:11px;margin-top:4px">${absCount} Abwesenheit(en)</div>` : '';
let todoListHtml; let todoListHtml;
if(l.todos.length === 0){ if(l.todos.length === 0){
@@ -567,12 +566,12 @@
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`; : `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`;
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten"> html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3> <h3>${esc(l.person.name)} <span style="font-size:11px;color:var(--text-subtle);font-weight:400">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div> <div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div> <div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${offenText} ${offenText}
${otherBadge} ${otherBadge}
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div> <div class="load-detail" style="font-size:11px;margin-top:4px">${todoListHtml}</div>
${absBadge} ${absBadge}
</div>`; </div>`;
}); });
+18 -21
View File
@@ -93,7 +93,7 @@
for(let i = versionen.length - 1; i >= 0; i--){ for(let i = versionen.length - 1; i >= 0; i--){
const k = kommentarVon(versionen[i].project, null); const k = kommentarVon(versionen[i].project, null);
if(statusPlainText(k.status)){ if(statusPlainText(k.status)){
letzter = { week: versionen[i].week, k, aktuell: i === versionen.length - 1 }; letzter = { week: versionen[i].week, k, project: versionen[i].project, aktuell: i === versionen.length - 1 };
break; break;
} }
} }
@@ -103,7 +103,8 @@
gewerke.forEach(g => { gewerke.forEach(g => {
let vorher = ''; let vorher = '';
versionen.forEach(v => { versionen.forEach(v => {
const k = kommentarVon(v.project, g); // Nur, wenn das Projekt in JENER Sitzung das Gewerk hatte.
const k = (g in kommentareDesProjekts(v.project)) ? kommentarVon(v.project, g) : { status: '' };
const text = statusPlainText(k.status); const text = statusPlainText(k.status);
if(text && text !== vorher && !(letzter && v.week === letzter.week)){ if(text && text !== vorher && !(letzter && v.week === letzter.week)){
verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' }); verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' });
@@ -132,18 +133,17 @@
return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2]; return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2];
} }
const PLAN_VERLAUF_KURZ = 3;
let planVerlaufAlle = false;
let planVerlaufProjekt = null;
function renderPlanKommentarVerlauf(verlauf){ function renderPlanKommentarVerlauf(verlauf){
const el = document.getElementById('planStatusVerlauf'); const el = document.getElementById('planStatusVerlauf');
if(!el) return; if(!el) return;
const uid = app.project && (app.project.projectUid || app.project.id); // Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
if(uid !== planVerlaufProjekt){ planVerlaufProjekt = uid; planVerlaufAlle = false; } // aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
if(!verlauf.length){ el.hidden = true; el.innerHTML = ''; return; } // js/store.js). Die Sandbox hat keine früheren Fassungen.
const weitere = app.isSandboxPlan() ? '' : weitereLadenKnopf();
if(!verlauf.length && !weitere){ el.hidden = true; el.innerHTML = ''; return; }
el.hidden = false; el.hidden = false;
const zeigen = planVerlaufAlle ? verlauf : verlauf.slice(0, PLAN_VERLAUF_KURZ); const zeigen = verlauf;
const zeilen = zeigen.map((e, i) => ` const zeilen = zeigen.map((e, i) => `
<li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}"> <li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}">
<span class="plan-verlauf-kw">${esc(kurzKw(e.week.label))}</span> <span class="plan-verlauf-kw">${esc(kurzKw(e.week.label))}</span>
@@ -153,21 +153,15 @@
<div class="plan-verlauf-meta">${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}</div> <div class="plan-verlauf-meta">${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}</div>
</div> </div>
</li>`).join(''); </li>`).join('');
const mehr = verlauf.length > PLAN_VERLAUF_KURZ const mehr = weitere ? `<div class="plan-verlauf-mehr-zeile">${weitere}</div>` : '';
? `<button type="button" class="plan-verlauf-mehr" onclick="planVerlaufUmschalten()">${planVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + verlauf.length + ' anzeigen'}</button>`
: '';
el.innerHTML = ` el.innerHTML = `
<div class="plan-verlauf-kopf"> <div class="plan-verlauf-kopf">
<span class="plan-verlauf-titel">Verlauf</span> <span class="plan-verlauf-titel">Verlauf</span>
<span class="plan-verlauf-anzahl">${verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span> <span class="plan-verlauf-anzahl">${verlauf.length === 0 ? 'in den geladenen Sitzungen kein früherer Kommentar' : verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span>
</div> </div>
<ol class="plan-verlauf-liste">${zeilen}</ol>${mehr}`; ${zeilen ? `<ol class="plan-verlauf-liste">${zeilen}</ol>` : ''}${mehr}`;
} }
window.planVerlaufUmschalten = function(){
planVerlaufAlle = !planVerlaufAlle;
window.renderPlanInfo();
};
window.renderPlanInfo = function(){ window.renderPlanInfo = function(){
@@ -191,8 +185,9 @@
if(editEl){ if(editEl){
editEl.className = 'status-edit is-ansicht'; editEl.className = 'status-edit is-ansicht';
editEl.contentEditable = 'false'; editEl.contentEditable = 'false';
// Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js). // Alle Gewerke zusammen; bei mehreren je mit Überschrift, Linie und
const text = kom.letzter ? sanitizeStatusHtml(kom.letzter.k.status) : ''; // Verfasser (kommentarAnzeigeHtml(), js/utils.js, Eriks Wahl „B“).
const text = kom.letzter ? kommentarAnzeigeHtml(kom.letzter.project, null, true) : '';
editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>'; editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>';
} }
const hinweisEl = document.getElementById('planStatusHinweis'); const hinweisEl = document.getElementById('planStatusHinweis');
@@ -207,7 +202,9 @@
const autorEl = document.getElementById('planStatusAutor'); const autorEl = document.getElementById('planStatusAutor');
if(autorEl){ if(autorEl){
const k = kom.letzter ? kom.letzter.k : {}; const k = kom.letzter ? kom.letzter.k : {};
autorEl.innerHTML = k.status && k.statusAuthor // Bei mehreren Gewerken steht der Verfasser schon je Gewerk im Text.
const mehrere = kom.letzter && kommentarGewerkeMitText(kom.letzter.project) > 1;
autorEl.innerHTML = !mehrere && k.status && k.statusAuthor
? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}` ? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}`
+ (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '') + (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '')
: ''; : '';
+56 -23
View File
@@ -1,21 +1,31 @@
// plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per // plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per
// Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf // Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf
// den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus // den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus
// Aufgaben. Der Terminplan ist nur die grafische Darstellung der // Einträge.
// Aufgabenliste — deshalb entstehen hier ausschließlich Aufgaben
// (plan.todos), keine Meilensteine.
// //
// Nichts wird ohne Prüfung angelegt: Jeder Vorschlag öffnet das gewohnte // Nichts wird ohne Prüfung angelegt: Die Vorschläge stehen erst in einer
// Aufgaben-Panel vorausgefüllt (openTodoModal(null, prefill)), die Mail // Liste — je Zeile abhaken und „Aufgabe | Meilenstein“ wählen (Eriks
// steht als Notiz darin (t.notiz). Mehrere Vorschläge erst als Liste zum // Wunsch; die KI schlägt die Art vor) —, dann öffnet sich je Eintrag das
// Abhaken, dann nacheinander im Panel. Server: POST /plan-eintrag-aus-mail // gewohnte Panel vorausgefüllt (openTodoModal/openMilestoneModal mit
// (speichert nichts). // prefill), die Mail steht als Notiz darin (t.notiz bzw. m.notiz).
// Server: POST /plan-eintrag-aus-mail (speichert nichts).
(function(){ (function(){
const app = window.PlanApp; const app = window.PlanApp;
const ANBIETER_KEY = 'projuebersicht-mail-ki'; const ANBIETER_KEY = 'projuebersicht-mail-ki';
const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben']; const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben'];
let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit) let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit)
let laeuft = false; let laeuft = false;
// kiAnbieter: die Anbieter mit hinterlegtem Schlüssel. Ohne eingerichtete
// KI ist die Funktion ganz aus (Eriks Wunsch) — keine Ablagefläche, kein
// Abfangen von Strg+V. Geladen nach der Anmeldung und nach jeder Änderung
// unter Einstellungen → KI-Zugangsdaten (mailKiStatusLaden()).
let kiAnbieter = [];
window.mailKiStatusLaden = function(){
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
kiAnbieter = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
}).catch(() => { kiAnbieter = []; });
};
function aktiverAbschnitt(){ function aktiverAbschnitt(){
if(typeof currentTab === 'undefined' || currentTab !== 'plan') return ''; if(typeof currentTab === 'undefined' || currentTab !== 'plan') return '';
@@ -24,7 +34,7 @@
} }
function ablageErlaubt(){ function ablageErlaubt(){
return ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked(); return kiAnbieter.length > 0 && ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked();
} }
function bringtMail(dt){ function bringtMail(dt){
@@ -44,7 +54,7 @@
el.hidden = true; el.hidden = true;
el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')} el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')}
<b id="mailAblageTitel">Mail hier ablegen</b> <b id="mailAblageTitel">Mail hier ablegen</b>
<span id="mailAblageText">Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.</span></div>`; <span id="mailAblageText"></span></div>`;
document.body.appendChild(el); document.body.appendChild(el);
} }
return el; return el;
@@ -139,12 +149,9 @@
// ---------- Auswerten ---------- // ---------- Auswerten ----------
function anbieterWaehlen(){ function anbieterWaehlen(){
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => { let zuletzt = '';
const bereit = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured); try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
let zuletzt = ''; return Promise.resolve(kiAnbieter.includes(zuletzt) ? zuletzt : (kiAnbieter[0] || ''));
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
return bereit.includes(zuletzt) ? zuletzt : (bereit[0] || '');
}).catch(() => '');
} }
function projektPersonen(){ function projektPersonen(){
@@ -229,7 +236,8 @@
app.showToast('In der Mail steht nichts für den Terminplan.'); app.showToast('In der Mail steht nichts für den Terminplan.');
return; return;
} }
if(eintraege.length === 1){ nacheinanderOeffnen(d, eintraege); return; } // Auch bei nur einem Vorschlag erst die Liste — dort wird gewählt, ob es
// eine Aufgabe oder ein Meilenstein wird.
auswahlZeigen(d, eintraege); auswahlZeigen(d, eintraege);
} }
@@ -240,6 +248,16 @@
const e = rest.shift(); const e = rest.shift();
if(!e){ renderPlanGantt(); return; } if(!e){ renderPlanGantt(); return; }
const nr = gesamt - rest.length; const nr = gesamt - rest.length;
if(e.art === 'meilenstein'){
openMilestoneModal(null, {
name: e.titel,
date: e.ende || e.start,
notiz: notizText(d.mail || {}, e),
mailHinweis: (gesamt > 1 ? `Vorschlag ${nr} von ${gesamt}. ` : '') + hinweisText(d),
nachSchliessen: naechster
});
return;
}
openTodoModal(null, { openTodoModal(null, {
title: e.titel, title: e.titel,
start: e.start, start: e.start,
@@ -271,20 +289,35 @@
const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende)); const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende));
return a === b ? a : a + ' – ' + b; return a === b ? a : a + ' – ' + b;
}; };
const arten = eintraege.map(e => e.art === 'meilenstein' ? 'meilenstein' : 'aufgabe');
bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail"> bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail">
<h2>${eintraege.length} Einträge in der Mail</h2> <h2>${eintraege.length === 1 ? '1 Eintrag' : eintraege.length + ' Einträge'} in der Mail</h2>
<p class="hint">${esc(hinweisText(d))} Die ausgewählten öffnen sich nacheinander im Aufgaben-Panel.</p> <p class="hint">${esc(hinweisText(d))} Wähle je Eintrag, ob er eine Aufgabe oder ein Meilenstein wird. Die ausgewählten öffnen sich danach nacheinander zum Prüfen.</p>
<div class="mail-auswahl-liste">${eintraege.map((e, i) => ` <div class="mail-auswahl-liste">${eintraege.map((e, i) => `
<label class="mail-auswahl-zeile"><input type="checkbox" checked data-i="${i}"> <div class="mail-auswahl-zeile">
<span class="mail-auswahl-titel">${esc(e.titel)}</span> <input type="checkbox" checked data-i="${i}" aria-label="${esc(e.titel)} übernehmen">
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></label>`).join('')}</div> <span class="mail-auswahl-text"><span class="mail-auswahl-titel">${esc(e.titel)}</span>
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></span>
<span class="segmented mail-auswahl-art" role="group" aria-label="Art" data-i="${i}">
<button type="button" class="mail-art-knopf" data-art="aufgabe" aria-pressed="${arten[i] === 'aufgabe'}">Aufgabe</button>
<button type="button" class="mail-art-knopf" data-art="meilenstein" aria-pressed="${arten[i] === 'meilenstein'}">Meilenstein</button>
</span>
</div>`).join('')}</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button> <button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button>
<button type="button" data-aktion="weiter">Prüfen und anlegen</button> <button type="button" data-aktion="weiter">Prüfen und anlegen</button>
</div></div>`; </div></div>`;
bg.querySelectorAll('.mail-auswahl-art button').forEach(b => {
b.onclick = () => {
const i = Number(b.parentElement.dataset.i);
arten[i] = b.dataset.art;
b.parentElement.querySelectorAll('button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
};
});
bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; }; bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; };
bg.querySelector('[data-aktion="weiter"]').onclick = () => { bg.querySelector('[data-aktion="weiter"]').onclick = () => {
const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked')).map(cb => eintraege[Number(cb.dataset.i)]); const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => { const i = Number(cb.dataset.i); return Object.assign({}, eintraege[i], { art: arten[i] }); });
bg.style.display = 'none'; bg.style.display = 'none';
if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt); if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt);
}; };
+136 -34
View File
@@ -33,8 +33,15 @@
// (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum, // (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum,
// Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und // Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und
// TODOs) aus dem Plan. // TODOs) aus dem Plan.
window.openMilestoneModal = function(index){ // msNachSchliessen: wie todoNachSchliessen — "Termin aus Mail" öffnet
// damit nach dem Schließen den nächsten Vorschlag.
let msNachSchliessen = null;
// openMilestoneModal: Parameter index (null = neu), prefill (optional, nur
// bei neu: name, date, notiz, mailHinweis, nachSchliessen).
window.openMilestoneModal = function(index, prefill){
if(app.isLocked()) return; if(app.isLocked()) return;
const pre = prefill || {};
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = []; if(!Array.isArray(plan.milestones)) plan.milestones = [];
// Migration alter Meilensteine // Migration alter Meilensteine
@@ -43,10 +50,18 @@
const isNew = index === null || plan.milestones[index] === undefined; const isNew = index === null || plan.milestones[index] === undefined;
const currentMs = isNew ? null : plan.milestones[index]; const currentMs = isNew ? null : plan.milestones[index];
document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || ''); document.getElementById('milestoneEditName').value = isNew ? (pre.name || '') : (currentMs.name || '');
const msNotiz = document.getElementById('milestoneEditNotiz');
if(msNotiz) msNotiz.value = isNew ? (pre.notiz || '') : (currentMs.notiz || '');
const msMailHinweis = document.getElementById('milestoneEditMailHinweis');
if(msMailHinweis){
msMailHinweis.hidden = !(isNew && pre.mailHinweis);
msMailHinweis.textContent = (isNew && pre.mailHinweis) || '';
}
msNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null;
// Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) — // Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) —
// dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite. // dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite.
const msDateIso = isNew ? '' : (currentMs.date || ''); const msDateIso = isNew ? (pre.date || '') : (currentMs.date || '');
document.getElementById('milestoneEditDate').value = msDateIso; document.getElementById('milestoneEditDate').value = msDateIso;
const msDateDisplay = document.getElementById('milestoneEditDateDisplay'); const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : ''; if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
@@ -152,6 +167,9 @@
window.closeMilestoneModal = function(){ window.closeMilestoneModal = function(){
document.getElementById('milestoneModal').style.display = 'none'; document.getElementById('milestoneModal').style.display = 'none';
app.currentMilestoneIndex = null; app.currentMilestoneIndex = null;
const danach = msNachSchliessen;
msNachSchliessen = null;
if(danach) setTimeout(danach, 0);
}; };
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position, // saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
@@ -169,18 +187,21 @@
const positionSelect = document.getElementById('milestoneEditPosition'); const positionSelect = document.getElementById('milestoneEditPosition');
const position = positionSelect ? positionSelect.value : ''; const position = positionSelect ? positionSelect.value : '';
const msNotizEl = document.getElementById('milestoneEditNotiz');
const notiz = msNotizEl ? msNotizEl.value.trim() : '';
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = []; if(!Array.isArray(plan.milestones)) plan.milestones = [];
if(app.currentMilestoneIndex === null){ if(app.currentMilestoneIndex === null){
plan.milestones.push({ id: uid(), name, date, dependsOn, position }); plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz });
} else if(plan.milestones[app.currentMilestoneIndex]){ } else if(plan.milestones[app.currentMilestoneIndex]){
const m = plan.milestones[app.currentMilestoneIndex]; const m = plan.milestones[app.currentMilestoneIndex];
m.name = name; m.name = name;
m.date = date; m.date = date;
m.dependsOn = dependsOn; m.dependsOn = dependsOn;
m.position = position; m.position = position;
m.notiz = notiz;
} }
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date)); plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
app.savePlan(); app.savePlan();
@@ -467,6 +488,104 @@
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die // Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim // Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
// Anlegen. // Anlegen.
// ---------- Aufwand: −/+, Schnellwahl, Umrechnung ----------
// Ein Arbeitstag = APP_SETTINGS.arbeitsstundenProTag (Admin → Termine).
function stundenProTag(){ return Number(APP_SETTINGS && APP_SETTINGS.arbeitsstundenProTag) || 8; }
function aufwandLesen(){
const el = document.getElementById('todoEditAufwand');
return el ? Math.max(0, parseFloat(String(el.value).replace(',', '.')) || 0) : 0;
}
function aufwandSchreiben(h){
const el = document.getElementById('todoEditAufwand');
if(!el) return;
const w = Math.max(0, Math.round(h * 2) / 2);
el.value = w > 0 ? String(w) : '';
todoAufwandInfo();
}
window.todoAufwandAendern = function(delta){ aufwandSchreiben(aufwandLesen() + delta); };
// todoAufwandSetzen(h) oder todoAufwandSetzen('tag', n): fester Wert; ein
// zweiter Klick auf den gewählten Wert leert das Feld.
window.todoAufwandSetzen = function(a, n){
const h = a === 'tag' ? n * stundenProTag() : Number(a);
aufwandSchreiben(aufwandLesen() === h ? 0 : h);
};
// todoAufwandInfo: Umrechnung in Arbeitstage und Markierung der Schnellwahl.
window.todoAufwandInfo = function(){
const h = aufwandLesen();
const tag = stundenProTag();
const info = document.getElementById('todoAufwandInfoText');
if(info){
const tage = h / tag;
const zahl = x => (Math.round(x * 10) / 10).toLocaleString('de-DE');
info.textContent = h > 0 ? '≈ ' + zahl(tage) + (Math.abs(tage - 1) < 0.05 ? ' Arbeitstag' : ' Arbeitstage') + ' (' + zahl(tag) + ' h je Tag)' : 'Ohne Aufwand zählt die Aufgabe nicht zur Auslastung.';
}
document.querySelectorAll('#todoAufwandSchnell button').forEach(b => {
const wert = b.dataset.tage ? Number(b.dataset.tage) * tag : Number(b.dataset.h);
b.setAttribute('aria-pressed', String(h > 0 && Math.abs(wert - h) < 0.01));
});
};
// ---------- Zuständige: Projekt und „Externe Personen“ ----------
// todoProjektPersonen: die dem Projekt zugeordneten Personen (je einmal).
function todoProjektPersonen(){
const gesehen = new Set();
const liste = [];
projectAssignees(app.project, app.week).forEach(a => {
if(gesehen.has(a.person.id)) return;
gesehen.add(a.person.id);
liste.push(a.person);
});
return liste;
}
function todoPersonById(id){
return ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
}
function todoPersonChip(person, gedrueckt, extern){
const short = person.name || initialsForName(person.fullName || '') || '?';
const full = person.fullName || person.name || '';
const klick = extern ? `todoExternEntfernen('${esc(person.id)}')` : 'toggleTodoAssignee(this)';
const titel = extern ? ' title="Nicht im Projekt — Klick entfernt die Person"' : '';
return `<button type="button" class="person-chip${extern ? ' ist-fremd' : ''}"${titel} data-person-id="${esc(person.id)}" aria-pressed="${gedrueckt ? 'true' : 'false'}" onclick="${klick}">`
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}${extern ? ' <span class="person-chip-x" aria-hidden="true">×</span>' : ''}</button>`;
}
// todoExterneSetzen: zeichnet die gewählten externen Personen und die
// Auswahl der übrigen Kollegen (Teilnehmerkreis der Sitzung ohne Projekt
// und ohne bereits gewählte), nach Gewerk gruppiert.
function todoExterneSetzen(ids){
const box = document.getElementById('todoEditExterne');
const wahl = document.getElementById('todoEditExterneWahl');
if(!box || !wahl) return;
box.innerHTML = ids.map(id => todoPersonChip(todoPersonById(id), true, true)).join('');
const projektIds = new Set(todoProjektPersonen().map(p => p.id));
const frei = ((app.week && app.week.people) || [])
.filter(p => p && p.id && !projektIds.has(p.id) && !ids.includes(p.id))
.sort((a, b) => String(a.fullName || a.name).localeCompare(String(b.fullName || b.name), 'de'));
const gruppen = new Map();
frei.forEach(p => {
const g = (typeof personGewerk === 'function' && personGewerk(p)) || 'Ohne Gewerk';
if(!gruppen.has(g)) gruppen.set(g, []);
gruppen.get(g).push(p);
});
const reihenfolge = [...(typeof DEPARTMENTS !== 'undefined' ? Object.keys(DEPARTMENTS) : []), 'Ohne Gewerk'];
const schluessel = [...gruppen.keys()].sort((a, b) => reihenfolge.indexOf(a) - reihenfolge.indexOf(b));
wahl.innerHTML = `<option value="">${frei.length ? '+ Person hinzufügen…' : 'Keine weiteren Kollegen'}</option>`
+ schluessel.map(g => `<optgroup label="${esc(g)}">${gruppen.get(g).map(p =>
`<option value="${esc(p.id)}">${esc(p.fullName || p.name)}${p.name && p.fullName ? ' (' + esc(p.name) + ')' : ''}</option>`).join('')}</optgroup>`).join('');
wahl.disabled = !frei.length;
}
function todoExterneIds(){
return Array.from(document.querySelectorAll('#todoEditExterne .person-chip')).map(b => b.dataset.personId);
}
window.todoExternHinzufuegen = function(id){
if(!id) return;
const ids = todoExterneIds();
if(!ids.includes(id)) ids.push(id);
todoExterneSetzen(ids);
};
window.todoExternEntfernen = function(id){
todoExterneSetzen(todoExterneIds().filter(x => x !== id));
};
window.openTodoModal = function(index, prefill){ window.openTodoModal = function(index, prefill){
if(app.isLocked()) return; if(app.isLocked()) return;
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
@@ -523,6 +642,7 @@
const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand); const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand);
aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : ''; aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : '';
} }
todoAufwandInfo();
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null); todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0); const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
@@ -573,39 +693,20 @@
const assigneesDiv = document.getElementById('todoEditAssignees'); const assigneesDiv = document.getElementById('todoEditAssignees');
if(assigneesDiv){ if(assigneesDiv){
const assignees = projectAssignees(app.project, app.week);
const currentAssignees = currentTodo const currentAssignees = currentTodo
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : []) ? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
: (Array.isArray(pre.assignees) ? pre.assignees : []); : (Array.isArray(pre.assignees) ? pre.assignees : []);
const seenPeople = new Set(); const projektLeute = todoProjektPersonen();
const uniquePeople = []; const projektIds = new Set(projektLeute.map(p => p.id));
assignees.forEach(a => {
if(!seenPeople.has(a.person.id)){
seenPeople.add(a.person.id);
uniquePeople.push(a.person);
}
});
// Bereits Zuständige, die (nicht mehr) dem Projekt zugeordnet sind —
// etwa über "Aufgabe aus Kommentar" aus dem ganzen Teilnehmerkreis
// gewählt — ebenfalls als Chip zeigen. Ohne sie gingen sie beim
// Speichern stillschweigend verloren, weil saveTodoEdit() nur die
// gedrückten Chips liest.
currentAssignees.forEach(id => {
if(seenPeople.has(id)) return;
seenPeople.add(id);
const p = ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
uniquePeople.push(Object.assign({}, p, { nichtImProjekt: true }));
});
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die // Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
// Personenspalten im Projektboard. // Personenspalten im Projektboard.
assigneesDiv.innerHTML = uniquePeople.map(person => { assigneesDiv.innerHTML = projektLeute.map(person =>
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false'; todoPersonChip(person, currentAssignees.includes(person.id), false)
const short = person.name || initialsForName(person.fullName || '') || '?'; ).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
const full = person.fullName || person.name || ''; // Zuständige außerhalb des Projekts stehen unter „Externe Personen“ —
const fremd = person.nichtImProjekt ? ' title="Nicht (mehr) dem Projekt zugeordnet"' : ''; // auch solche, die das Projekt inzwischen verlassen haben (sonst gingen
return `<button type="button" class="person-chip${person.nichtImProjekt ? ' ist-fremd' : ''}"${fremd} data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">` // sie beim Speichern stillschweigend verloren).
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`; todoExterneSetzen(currentAssignees.filter(id => !projektIds.has(id)));
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
} }
const deleteBtn = document.getElementById('todoEditDelete'); const deleteBtn = document.getElementById('todoEditDelete');
@@ -643,7 +744,7 @@
function todoFormSnapshot(){ function todoFormSnapshot(){
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; }; const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value); const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId); const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"], #todoEditExterne .person-chip')).map(b => b.dataset.personId);
const gantt = document.getElementById('todoEditShowInGantt'); const gantt = document.getElementById('todoEditShowInGantt');
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'), return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
currentTodoEditStatus(), read('todoEditProgress'), deps, people, currentTodoEditStatus(), read('todoEditProgress'), deps, people,
@@ -706,7 +807,8 @@
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : []; const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
const assigneesDiv = document.getElementById('todoEditAssignees'); const assigneesDiv = document.getElementById('todoEditAssignees');
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : []; const assignees = (assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [])
.concat(todoExterneIds());
const showInGanttEl = document.getElementById('todoEditShowInGantt'); const showInGanttEl = document.getElementById('todoEditShowInGantt');
const showInGantt = showInGanttEl ? showInGanttEl.checked : true; const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
+417
View File
@@ -0,0 +1,417 @@
// -----------------------------
// Projektbereich — Notizen (Eriks Wahl „A – Liste + Editor“)
// -----------------------------
// Gemeinsame Notizen je Projekt: links die Liste (angeheftete oben, danach
// zuletzt geänderte zuerst, Suche über Titel und Text), rechts die gewählte
// Notiz direkt bearbeitbar — Titel, dieselbe Werkzeugleiste wie beim
// Sitzungskommentar (richtextToolbarHtml(), js/utils.js) und der Text.
// Gespeichert wird automatisch kurz nach dem Tippen und beim Verlassen des
// Feldes; „Gespeichert ✓“ zeigt den Stand. Mail per Drag & Drop (Text oder
// .eml) bzw. Strg+V außerhalb von Eingabefeldern legt eine neue Notiz an,
// ohne KI. Live: andere Browser erfahren Änderungen über das Ereignis
// {typ:'notizen'} (notizenLiveAenderung(), aus js/store.js).
//
// Daten am Server: Tabelle projekt_notizen, Routen /projekt-notizen*
// (server.js). Die Notizen hängen am Projekt (projectUid), nicht an der
// Sitzung. Schreiben: Recht proj.plan; Löschen: Verfasser oder Admin.
(function(){
const app = window.PlanApp;
const SPEICHER_PAUSE_MS = 1200;
let liste = []; // Notizen des geladenen Projekts
let uidGeladen = null; // projectUid, zu dem `liste` gehört
let aktivId = null; // gewählte Notiz
let suche = '';
let basis = ''; // geaendertAm, auf dem die Bearbeitung beruht
let ungespeichert = false;
let speicherTimer = null;
let speichertGerade = false;
let beobachter = null; // MutationObserver auf dem Textfeld
let loeschenFragt = null; // Timer der zweistufigen Löschen-Abfrage
let ladeFehler = '';
const kannSchreiben = () => typeof hatRecht === 'function' && hatRecht('proj.plan');
const projektUid = () => app.project ? String(app.project.projectUid || app.project.id || '') : '';
const wrap = () => document.getElementById('planNotizenWrap');
const aktiv = () => liste.find(n => n.id === aktivId) || null;
function zeit(iso){
if(!iso) return '';
const d = new Date(iso);
if(isNaN(d)) return '';
const heute = new Date();
const gleicherTag = d.toDateString() === heute.toDateString();
const gestern = new Date(heute); gestern.setDate(heute.getDate() - 1);
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
if(gleicherTag) 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' });
}
function kurzname(name){
const teile = String(name || '').trim().split(/\s+/);
return teile.length > 1 ? teile[0][0] + '. ' + teile.slice(1).join(' ') : (name || '');
}
function klartext(html){
return 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(uid){
uidGeladen = uid;
ladeFehler = '';
return fetch(baseUrl() + '/projekt-notizen?uid=' + encodeURIComponent(uid))
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(uid !== uidGeladen) return;
if(!ok || !d.ok) throw new Error(d.error || 'Laden fehlgeschlagen');
liste = d.notizen || [];
sortieren();
})
.catch(e => { ladeFehler = e.message || 'Die Notizen konnten nicht geladen werden.'; liste = []; });
}
// ---------- Darstellung ----------
window.renderPlanNotizen = function(){
const el = wrap();
if(!el || !app.project) return;
const uid = projektUid();
if(uid !== uidGeladen){
jetztSpeichern();
aktivId = null; liste = []; suche = '';
el.innerHTML = '<div class="notizen-laedt">Notizen werden geladen…</div>';
laden(uid).then(() => { if(projektUid() === uid) aufbauen(); });
return;
}
aufbauen();
};
function aufbauen(){
const el = wrap();
if(!el) return;
if(!aktiv() && liste.length) aktivId = liste[0].id;
el.innerHTML = `
<div class="notizen" id="notizenBereich">
<aside class="notizen-liste">
<div class="notizen-suchzeile">
<input type="search" id="notizenSuche" class="notizen-suche" placeholder="Notizen durchsuchen…"
aria-label="Notizen durchsuchen" value="${esc(suche)}" oninput="notizenSuchen(this.value)">
${kannSchreiben() ? '<button type="button" class="notizen-neu" onclick="notizNeu()">+ Neu</button>' : ''}
</div>
<div id="notizenEintraege" class="notizen-eintraege"></div>
${kannSchreiben() ? '<div class="notizen-ablage">Mail hierher ziehen = neue Notiz</div>' : ''}
</aside>
<div class="notizen-editor" id="notizenEditor"></div>
</div>`;
listeZeichnen();
editorZeichnen();
}
function listeZeichnen(){
const box = document.getElementById('notizenEintraege');
if(!box) return;
if(ladeFehler){ box.innerHTML = '<div class="notizen-leer ist-fehler">' + esc(ladeFehler) + '</div>'; return; }
const s = suche.trim().toLowerCase();
const treffer = s ? liste.filter(n => (n.titel + ' ' + klartext(n.html)).toLowerCase().includes(s)) : liste;
if(!liste.length){ box.innerHTML = '<div class="notizen-leer">Noch keine Notizen.</div>'; return; }
if(!treffer.length){ box.innerHTML = '<div class="notizen-leer">Keine Notiz enthält „' + esc(suche) + '“.</div>'; return; }
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="notizWaehlen('${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">${esc(kurzname(n.geaendertVon))} · ${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('') : '');
}
function editorZeichnen(){
const el = document.getElementById('notizenEditor');
if(!el) return;
if(beobachter){ beobachter.disconnect(); beobachter = null; }
const n = aktiv();
if(!n){
el.innerHTML = `<div class="notizen-editor-leer">
<b>${liste.length ? 'Notiz links auswählen' : 'Noch keine Notizen in diesem Projekt'}</b>
<span>${kannSchreiben() ? 'Mit „+ Neu“ anlegen oder eine Mail auf diesen Bereich ziehen.' : 'Zum Anlegen fehlt das Recht, den Terminplan zu bearbeiten.'}</span>
</div>`;
return;
}
const schreiben = kannSchreiben();
basis = n.geaendertAm;
ungespeichert = false;
el.innerHTML = `
<div class="notizen-editor-kopf">
<input type="text" id="notizTitel" class="notizen-titel" value="${esc(n.titel)}" placeholder="Titel der Notiz"
aria-label="Titel der Notiz" ${schreiben ? '' : 'readonly'} oninput="notizGeaendert()" onblur="notizJetztSpeichern()">
${schreiben ? `<button type="button" class="notizen-symbol${n.angeheftet ? ' ist-an' : ''}" onclick="notizAnheften()"
title="${n.angeheftet ? 'Nicht mehr anheften' : 'Oben anheften'}" aria-pressed="${n.angeheftet}">${n.angeheftet ? 'Angeheftet' : 'Anheften'}</button>
<button type="button" class="notizen-symbol ist-gefahr" id="notizLoeschen" onclick="notizLoeschen()">Löschen</button>` : ''}
</div>
<div class="richtext-wrap notizen-richtext">
${schreiben ? richtextToolbarHtml('.notizen-text') : ''}
${richtextFeldHtml('notizen-text', sanitizeStatusHtml(n.html || ''), 'Notiz schreiben…', 'id="notizText" onblur="notizJetztSpeichern()"', 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="notizStatus" class="notizen-status"></span>
</div>`;
const text = document.getElementById('notizText');
// Auch Fett/Farbe aus der Werkzeugleiste ändern den Inhalt, ohne ein
// input-Ereignis auszulösen — daher ein MutationObserver.
if(text && schreiben){
beobachter = new MutationObserver(() => notizGeaendert());
beobachter.observe(text, { childList: true, subtree: true, characterData: true, attributes: true });
}
}
function status(text, art){
const el = document.getElementById('notizStatus');
if(!el) return;
el.className = 'notizen-status' + (art ? ' ist-' + art : '');
el.innerHTML = text;
}
// ---------- Bearbeiten und Speichern ----------
window.notizenSuchen = function(wert){ suche = wert || ''; listeZeichnen(); };
window.notizWaehlen = function(id){
if(id === aktivId) return;
jetztSpeichern();
aktivId = id;
listeZeichnen();
editorZeichnen();
};
window.notizGeaendert = function(){
if(!kannSchreiben() || !aktiv()) return;
ungespeichert = true;
status('Ungespeichert');
clearTimeout(speicherTimer);
speicherTimer = setTimeout(jetztSpeichern, SPEICHER_PAUSE_MS);
};
window.notizJetztSpeichern = function(){ jetztSpeichern(); };
// jetztSpeichern: schickt die gewählte Notiz, falls geändert. ohneBasis =
// bewusst überschreiben (nach einem Konflikt „Meine Fassung behalten“).
function jetztSpeichern(ohneBasis){
clearTimeout(speicherTimer);
const n = aktiv();
if(!n || !ungespeichert || !kannSchreiben()) return Promise.resolve();
if(speichertGerade){ speicherTimer = setTimeout(jetztSpeichern, 400); return Promise.resolve(); }
const titelEl = document.getElementById('notizTitel');
const textEl = document.getElementById('notizText');
const titel = titelEl ? titelEl.value : n.titel;
const html = textEl ? textEl.innerHTML : n.html;
ungespeichert = false;
speichertGerade = true;
status('Speichert…');
return fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ titel, html, basis: ohneBasis ? '' : basis })
}).then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
if(r.status === 409 && d.konflikt){
ungespeichert = true;
status(esc(d.error) + ' <button type="button" class="notizen-link" onclick="notizKonflikt(true)">Meine Fassung speichern</button>'
+ ' <button type="button" class="notizen-link" onclick="notizKonflikt(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.notizKonflikt = 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();
};
window.notizNeu = function(){
const uid = projektUid();
if(!uid || !kannSchreiben()) return;
jetztSpeichern();
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');
neueNotizZeigen(d.notiz);
const t = document.getElementById('notizTitel');
if(t) t.focus();
}).catch(e => app.showToast('Notiz konnte nicht angelegt werden: ' + e.message));
};
function neueNotizZeigen(notiz){
if(!notiz || notiz.uid !== uidGeladen) return;
liste = liste.filter(x => x.id !== notiz.id);
liste.push(notiz);
sortieren();
aktivId = notiz.id;
suche = '';
const s = document.getElementById('notizenSuche');
if(s) s.value = '';
listeZeichnen();
editorZeichnen();
}
window.notizAnheften = function(){
const n = aktiv();
if(!n) return;
jetztSpeichern().then(() => fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ angeheftet: !n.angeheftet })
})).then(r => r.json()).then(d => {
if(!d.ok) throw new Error(d.error || 'Fehler');
n.angeheftet = d.notiz.angeheftet;
sortieren();
listeZeichnen();
const knopf = document.querySelector('.notizen-editor-kopf .notizen-symbol:not(.ist-gefahr)');
if(knopf){
knopf.classList.toggle('ist-an', n.angeheftet);
knopf.textContent = n.angeheftet ? 'Angeheftet' : 'Anheften';
knopf.title = n.angeheftet ? 'Nicht mehr anheften' : 'Oben anheften';
knopf.setAttribute('aria-pressed', String(n.angeheftet));
}
}).catch(e => app.showToast('Anheften fehlgeschlagen: ' + e.message));
};
// notizLoeschen: zweistufig im Knopf selbst (kein confirm()).
window.notizLoeschen = function(){
const n = aktiv();
const knopf = document.getElementById('notizLoeschen');
if(!n || !knopf) return;
if(!loeschenFragt){
knopf.textContent = 'Wirklich löschen?';
knopf.classList.add('ist-fragend');
loeschenFragt = setTimeout(() => {
loeschenFragt = null;
const k = document.getElementById('notizLoeschen');
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
}, 4000);
return;
}
clearTimeout(loeschenFragt); loeschenFragt = null;
clearTimeout(speicherTimer); ungespeichert = false;
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 = liste.length ? liste[0].id : null;
listeZeichnen();
editorZeichnen();
}).catch(e => { status(esc(e.message), 'fehler'); const k = document.getElementById('notizLoeschen'); if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); } });
};
// ---------- Live: Änderungen aus anderen Browsern ----------
// Liste neu holen; die gewählte Notiz nur ersetzen, wenn hier gerade
// nichts ungespeichert ist und niemand in ihr tippt.
window.notizenLiveAenderung = function(uid){
if(!uid || uid !== uidGeladen) return;
const n = aktiv();
laden(uid).then(() => {
if(!wrap()) return;
if(aktivId && !liste.some(x => x.id === aktivId)){ aktivId = liste.length ? liste[0].id : null; editorZeichnen(); }
listeZeichnen();
const frisch = aktiv();
const tippt = document.activeElement && document.activeElement.closest && document.activeElement.closest('#notizenEditor');
if(frisch && n && frisch.id === n.id && !ungespeichert && !tippt && frisch.geaendertAm !== basis) editorZeichnen();
});
};
// ---------- Mail ablegen (Drag & Drop, Strg+V) ----------
function imNotizenBereich(){
const s = document.querySelector('.plan-section.is-active');
return typeof currentTab !== 'undefined' && currentTab === 'plan' && s && s.dataset.section === 'notizen' && !!app.project;
}
function ausMail(nutzlast){
const uid = projektUid();
if(!uid) return;
jetztSpeichern();
fetch(baseUrl() + '/projekt-notizen/aus-mail', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(Object.assign({ uid }, nutzlast))
}).then(r => r.json()).then(d => {
if(!d.ok) throw new Error(d.error || 'Fehlgeschlagen');
neueNotizZeigen(d.notiz);
app.showToast('Notiz aus der Mail angelegt.');
}).catch(e => app.showToast('Mail konnte nicht übernommen werden: ' + e.message));
}
function ausDaten(dt){
const dateien = Array.from(dt.files || []);
const eml = dateien.find(f => /\.eml$/i.test(f.name || '') || f.type === 'message/rfc822');
if(eml){
if(eml.size > 5 * 1024 * 1024){ app.showToast('Die Mail ist zu groß (mehr als 5 MB).'); return; }
eml.text().then(t => ausMail({ eml: t }));
return;
}
if(dateien.some(f => /\.msg$/i.test(f.name || ''))){
app.showToast('Outlook-Dateien (.msg) lassen sich nicht lesen. Bitte den Mailtext markieren und hineinziehen.');
return;
}
let text = (dt.getData('text/plain') || '').trim();
if(!text){
const div = document.createElement('div');
div.innerHTML = (dt.getData('text/html') || '').replace(/<(script|style)[\s\S]*?<\/\1>/gi, '');
text = (div.innerText || div.textContent || '').trim();
}
if(text) ausMail({ text });
}
const bereich = () => document.getElementById('planSection-notizen');
document.addEventListener('dragover', e => {
if(!imNotizenBereich() || !kannSchreiben() || !bereich().contains(e.target)) return;
if(e.target.closest && e.target.closest('#notizenEditor [contenteditable="true"], #notizTitel')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
bereich().classList.add('notizen-zieht');
});
document.addEventListener('dragleave', e => {
const b = bereich();
if(b && (!e.relatedTarget || !b.contains(e.relatedTarget))) b.classList.remove('notizen-zieht');
});
document.addEventListener('drop', e => {
const b = bereich();
if(b) b.classList.remove('notizen-zieht');
if(!imNotizenBereich() || !kannSchreiben() || !b.contains(e.target)) return;
// In das Textfeld gezogen: normal einfügen lassen, keine neue Notiz.
if(e.target.closest && e.target.closest('#notizenEditor [contenteditable="true"], #notizTitel')) return;
e.preventDefault();
ausDaten(e.dataTransfer);
});
document.addEventListener('paste', e => {
if(!imNotizenBereich() || !kannSchreiben()) return;
const ziel = e.target;
if(ziel && ziel.closest && ziel.closest('input, textarea, select, [contenteditable="true"], .modal-bg')) return;
if(!e.clipboardData) return;
e.preventDefault();
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(); });
})();
+5 -163
View File
@@ -1,21 +1,10 @@
// ----------------------------- // -----------------------------
// Projektbereich — Leistungsphasen // Projektbereich — Zuordnung Aufgabe → Leistungsphase
// ----------------------------- // -----------------------------
// Bereich "Leistungsphasen" (Eriks Wahl "B"): die Phasenbalken des // Der frühere Reiter „Leistungsphasen“ (Ablaufbalken + Kachel je Phase) ist
// Terminplans (Aufgaben mit t.lph, balkenPhase() in load.js) als eigener // auf Eriks Wunsch entfallen; an seiner Stelle steht „Notizen“. Geblieben ist
// Bereich statt als Zeilen zwischen den Aufgaben. Oben ein Ablaufbalken über // nur aufgabenPhase(), die die Aufgabenliste (plan-tasks.js) und das
// alle Phasen mit Heute-Linie, darunter eine Kachel je Phase: Fortschritt // Terminplan-PDF (pdf.js) nutzen.
// (aus dem Wochenbericht, p.lph), Zeitraum (aus dem Balken), Aufwand
// (Summe der Stunden ihrer Aufgaben) und die Aufgaben, die in ihrem
// Zeitraum beginnen — dieselbe Zuordnung wie die Gliederung im Terminplan
// (plan-build-gantt.js).
//
// Keine eigene Datenhaltung: Bearbeiten öffnet dasselbe Panel wie im
// Terminplan (openTodoModal() im Phasen-Modus), Fortschritt bleibt im
// Wochenbericht ("Ein Ort je Angabe").
//
// renderPlanPhasen() wird von renderPlanGantt() (plan-gantt.js) und beim
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) aufgerufen.
// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört — // aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört —
// der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die // der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die
@@ -31,150 +20,3 @@ function aufgabenPhase(t, todos){
.find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end)); .find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end));
return k ? balkenPhase(k) : ''; return k ? balkenPhase(k) : '';
} }
(function(){
const app = window.PlanApp;
const LPH_NAMEN = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung',
'W+M': 'Wartung und Monitoring'
};
const kurzDatum = d => d ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) : '';
const langDatum = d => d ? formatDateDE(d) : '';
// phasenDaten: je Phase alles, was Ablaufbalken und Kachel brauchen.
// Rückgabe: Array, sortiert läuft → geplant → fertig, darin nach Beginn.
function phasenDaten(){
const p = app.project;
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
// Phasen: beauftragte plus alle, die einen Balken haben.
const namen = LPHS.filter(l => beauftragt.includes(l) || todos.some(t => balkenPhase(t) === l));
const amtierend = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || null;
const laufend = typeof arbeitsPhase === 'function' ? arbeitsPhase(p, amtierend) : amtierend;
return namen.map(l => {
const idx = todos.findIndex(t => balkenPhase(t) === l);
const balken = idx >= 0 ? todos[idx] : null;
const start = balken ? parseDateISO(balken.start) : null;
const ende = balken ? parseDateISO(balken.end) : null;
const prozent = typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0;
const aufgaben = todos.filter(t => aufgabenPhase(t, todos) === l);
// Aufwand = Summe der Stunden ihrer Aufgaben (dieselbe Quelle wie die
// Auslastung, js/load/load.js), nicht mehr aus dem Honorar.
const stunden = Math.round(aufgaben.reduce((s, t) => s + (Number(t.aufwand) || 0), 0) * 10) / 10;
const status = prozent >= 100 ? 'fertig' : (l === laufend ? 'laeuft' : 'geplant');
return { l, idx, balken, start, ende, prozent, stunden, aufgaben, status,
beauftragt: !beauftragt.length || beauftragt.includes(l) };
}).sort((a, b) => {
const rang = { laeuft: 0, geplant: 1, fertig: 2 };
if(rang[a.status] !== rang[b.status]) return rang[a.status] - rang[b.status];
return LPHS.indexOf(a.l) - LPHS.indexOf(b.l);
});
}
// ablaufHtml: der schmale Balken über alle Phasen mit Zeitraum. Phasen
// ohne Zeitraum fehlen hier (sie stehen als Kachel mit Hinweis darunter).
function ablaufHtml(daten){
const mit = daten.filter(d => d.start && d.ende && d.ende >= d.start)
.sort((a, b) => a.start - b.start);
if(!mit.length) return '';
const von = mit[0].start.getTime();
const bis = Math.max(...mit.map(d => d.ende.getTime()));
const spanne = Math.max(1, bis - von);
const pos = t => Math.min(100, Math.max(0, (t - von) / spanne * 100));
const heute = new Date(); heute.setHours(0, 0, 0, 0);
const heutePos = heute.getTime() >= von && heute.getTime() <= bis ? pos(heute.getTime()) : null;
const segmente = mit.map(d => {
const links = pos(d.start.getTime());
const breite = Math.max(1.5, pos(d.ende.getTime() + 86400000) - links);
const titel = `${d.l}: ${langDatum(d.start)} – ${langDatum(d.ende)}, ${d.prozent} %`;
return `<button type="button" class="phasen-ablauf-seg is-${d.status}" style="left:${links}%;width:${breite}%;--fort:${d.prozent}%"
title="${esc(titel)}" onclick="phasenKachelZeigen('${esc(d.l)}')"><span>${esc(breite > 6 ? d.l : d.l.replace('LPH ', ''))}</span></button>`;
}).join('');
const heuteHtml = heutePos === null ? '' : `<span class="phasen-ablauf-heute" style="left:${heutePos}%" title="heute"></span>`;
const monat = d => d.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' });
return `<div class="plan-panel phasen-ablauf">
<div class="phasen-kopf">Ablauf</div>
<div class="phasen-ablauf-spur">${segmente}${heuteHtml}</div>
<div class="phasen-ablauf-achse"><span>${esc(monat(mit[0].start))}</span>${heutePos === null ? '' : `<span class="phasen-ablauf-heute-text" style="left:${heutePos}%">heute, ${esc(kurzDatum(heute))}</span>`}<span>${esc(monat(new Date(bis)))}</span></div>
</div>`;
}
// kachelHtml: eine Kachel je Phase.
function kachelHtml(d, locked){
const statusText = { laeuft: 'läuft', geplant: 'geplant', fertig: 'fertig' }[d.status];
const erledigt = d.aufgaben.filter(t => t.done).length;
const aufgabenText = d.aufgaben.length ? `${erledigt} von ${d.aufgaben.length} erledigt` : 'keine';
const zeitraum = d.start && d.ende
? `${d.start.getFullYear() === d.ende.getFullYear() ? kurzDatum(d.start) : langDatum(d.start)} – ${langDatum(d.ende)}`
: '<span class="phasen-leer">kein Zeitraum</span>';
const bearbeiten = locked ? ''
: (d.balken
? `<button class="secondary small" onclick="openTodoModal(${d.idx})">Bearbeiten</button>`
: `<button class="secondary small" onclick="addPlanPhase('${esc(d.l)}')">Zeitraum festlegen</button>`);
const ansehen = d.aufgaben.length
? `<button class="secondary small" onclick="phasenAufgabenZeigen('${esc(d.l)}')">Aufgaben ansehen</button>` : '';
return `<div class="phasen-kachel is-${d.status}" data-phase="${esc(d.l)}">
<div class="phasen-kachel-kopf">
<div class="phasen-ring" style="--fort:${d.prozent}%"><span>${d.prozent} %</span></div>
<div class="phasen-kachel-name">
<b>${esc(d.l)}</b>
<span>${esc(LPH_NAMEN[d.l] || '')}</span>
</div>
<span class="phasen-chip is-${d.status}">${statusText}</span>
</div>
${d.beauftragt ? '' : '<div class="phasen-hinweis">nicht beauftragt</div>'}
<div class="phasen-werte">
<span><small>Zeitraum</small><b>${zeitraum}</b></span>
<span><small>Aufwand</small><b>${d.stunden ? d.stunden.toLocaleString('de-DE') + ' h' : '<span class="phasen-leer">keine Stunden</span>'}</b></span>
<span><small>Aufgaben</small><b>${aufgabenText}</b></span>
</div>
${bearbeiten || ansehen ? `<div class="phasen-knoepfe">${bearbeiten}${ansehen}</div>` : ''}
</div>`;
}
// renderPlanPhasen: baut den Bereich neu auf. Keine Parameter.
window.renderPlanPhasen = function(){
const el = document.getElementById('planPhasenWrap');
if(!el || !app.project) return;
const locked = app.isLocked();
const daten = phasenDaten();
if(!daten.length){
el.innerHTML = `<div class="plan-panel"><div class="plan-task-empty">${locked
? 'Für dieses Projekt sind keine Leistungsphasen hinterlegt.'
: 'Noch keine Leistungsphase. Unter „Einstellungen“ beauftragen oder über „+ Leistungsphase“ bzw. „Terminplan aus Honorar“ anlegen.'}</div></div>`;
return;
}
const summeH = daten.reduce((s, d) => s + d.stunden, 0);
const enden = daten.filter(d => d.ende).map(d => d.ende.getTime());
const laufend = daten.find(d => d.status === 'laeuft');
const teile = [daten.filter(d => d.beauftragt).length + ' beauftragt'];
if(laufend) teile.push('läuft: ' + laufend.l);
if(summeH) teile.push(summeH.toLocaleString('de-DE') + ' h in Aufgaben');
if(enden.length) teile.push('Ende voraussichtlich ' + langDatum(new Date(Math.max(...enden))));
el.innerHTML = `<div class="phasen-zusammenfassung">${esc(teile.join(' · '))}<span>Fortschritt aus dem Wochenbericht</span></div>
${ablaufHtml(daten)}
<div class="phasen-raster">${daten.map(d => kachelHtml(d, locked)).join('')}</div>`;
};
// phasenKachelZeigen: Klick auf ein Segment im Ablauf — Kachel ins Bild
// holen und kurz hervorheben.
window.phasenKachelZeigen = function(l){
const k = document.querySelector(`#planPhasenWrap .phasen-kachel[data-phase="${CSS.escape(l)}"]`);
if(!k) return;
k.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
k.classList.remove('ist-markiert'); void k.offsetWidth; k.classList.add('ist-markiert');
};
// phasenAufgabenZeigen: in den Bereich Aufgaben, gefiltert auf diese Phase.
window.phasenAufgabenZeigen = function(l){
if(typeof setzeAufgabenFilter === 'function') setzeAufgabenFilter(l);
showPlanSection('aufgaben');
};
})();
+1 -1
View File
@@ -100,7 +100,7 @@
// ein Fallback ist ein akzeptiertes Ergebnis, kein Fehlschlag. // ein Fallback ist ein akzeptiertes Ergebnis, kein Fehlschlag.
function zeigeFallbackHinweis(msg){ function zeigeFallbackHinweis(msg){
const el = document.getElementById('protokollTodosFallback'); const el = document.getElementById('protokollTodosFallback');
if(el){ el.textContent = '⚠️ ' + msg; el.hidden = false; } if(el){ el.textContent = msg; el.hidden = false; }
} }
function verbergeFallbackHinweis(){ function verbergeFallbackHinweis(){
const el = document.getElementById('protokollTodosFallback'); const el = document.getElementById('protokollTodosFallback');
+2 -2
View File
@@ -548,7 +548,7 @@
// ab, verschwindet also nie von selbst, sondern nur wenn man das // ab, verschwindet also nie von selbst, sondern nur wenn man das
// Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar. // Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar.
const importBanner = p.quelleDateiname const importBanner = p.quelleDateiname
? `<div class="protokoll-import-banner">🔎 Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>` ? `<div class="protokoll-import-banner">Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>`
: ''; : '';
// Rein clientseitiges Merkmal (siehe importiereProtokoll() oben) — nur // Rein clientseitiges Merkmal (siehe importiereProtokoll() oben) — nur
// direkt nach GENAU dem Import gesetzt, der die KI angefordert, aber // direkt nach GENAU dem Import gesetzt, der die KI angefordert, aber
@@ -559,7 +559,7 @@
// das war Eriks Rückmeldung, nachdem er von einem KI-Fehlschlag nichts // das war Eriks Rückmeldung, nachdem er von einem KI-Fehlschlag nichts
// in der Oberfläche sah. // in der Oberfläche sah.
const kiFallbackBanner = p._kiFallbackHinweis const kiFallbackBanner = p._kiFallbackHinweis
? `<div class="protokoll-ki-fallback-banner">⚠️ Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>` ? `<div class="protokoll-ki-fallback-banner">Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>`
: ''; : '';
const kopf = ` const kopf = `
${kiFallbackBanner} ${kiFallbackBanner}
+1 -1
View File
@@ -128,7 +128,7 @@
if(moved.size > 0){ if(moved.size > 0){
app.savePlan(); app.savePlan();
renderPlanGantt(); renderPlanGantt();
app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Achtung: zyklische Abhängigkeit gefunden!' : '')); app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Zyklische Abhängigkeit gefunden — bitte Vorgänger prüfen.' : ''));
} else if(hasCycle){ } else if(hasCycle){
app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.'); app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.');
} else { } else {
+2 -2
View File
@@ -36,7 +36,7 @@
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der // PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
// Navigationsleiste. Muss zu den data-section-Attributen in // Navigationsleiste. Muss zu den data-section-Attributen in
// App.html passen. // App.html passen.
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'phasen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen']; const PLAN_SECTIONS = ['dashboard', 'terminplan', 'notizen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen // showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist // Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
@@ -63,8 +63,8 @@
if(!app.project) return; if(!app.project) return;
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt(); if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks(); if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
if(key === 'notizen' && typeof renderPlanNotizen === 'function') renderPlanNotizen();
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung(); if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle(); if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings(); if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings();
+44 -4
View File
@@ -99,8 +99,21 @@ window.PlanApp = {
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten // showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt // Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
// gefunden und gerendert wurde. // gefunden und gerendert wurde.
// startAufgabe: Link mit ?aufgabe=<id> (z. B. aus einer Teams-Nachricht):
// diese Aufgabe nach dem Öffnen des Projekts gleich im Aufgaben-Panel
// zeigen — einmalig, beim ersten passenden Öffnen.
let startAufgabe = '';
try { startAufgabe = new URLSearchParams(window.location.search).get('aufgabe') || ''; } catch(e){ /* egal */ }
window.openPlanView = function(){ window.openPlanView = function(){
app.showPlanNav(); app.showPlanNav();
// Ein Aufgaben-Link stammt aus der Sitzung, in der zugewiesen wurde. Ist
// inzwischen eine neuere da, dort öffnen — Aufgaben behalten über den
// Sitzungswechsel ihre id (ensureCurrentWeekRollover()).
if(startAufgabe && app.weekLabel !== SANDBOX_WEEK_LABEL && Array.isArray(db.wochenListe) && db.wochenListe.length){
const juengste = db.wochenListe[db.wochenListe.length - 1].label;
if(juengste && juengste !== app.weekLabel) app.weekLabel = juengste;
}
// Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte // Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js) // "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
@@ -143,6 +156,19 @@ window.PlanApp = {
} }
app.week = db.weeks.find(w => w.label === app.weekLabel) || null; app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
if(!app.week){ if(!app.week){
// Eine ältere Sitzung, die beim Start nicht mitgeladen wurde (nur die
// jüngsten, js/store.js): einzeln nachholen und dann neu öffnen.
const bekannt = (db.wochenListe || []).some(w => w.label === app.weekLabel);
if(bekannt && typeof sitzungSicherLaden === 'function'){
const t = document.getElementById('planSubtitle');
if(t) t.textContent = 'Sitzung ' + app.weekLabel + ' wird geladen…';
const label = app.weekLabel;
sitzungSicherLaden(label).then(w => {
if(w && app.weekLabel === label && currentTab === 'plan') showTab('plan');
else if(!w) planFail('Nicht gefunden', 'Sitzung "' + label + '" konnte nicht geladen werden.');
}).catch(() => planFail('Nicht gefunden', 'Sitzung "' + label + '" konnte nicht geladen werden.'));
return false;
}
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.'); planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
return false; return false;
} }
@@ -258,12 +284,10 @@ window.PlanApp = {
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten // Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche: // Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
// Terminplan (…Btn), Aufgaben (…BtnList) und Leistungsphasen (…BtnPhasen), // Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html.
// siehe App.html.
const locked = isLocked(); const locked = isLocked();
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn', ['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList', 'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList']
'honorarBtnPhasen', 'addPhaseBtnPhasen']
.forEach(id => { .forEach(id => {
const btn = document.getElementById(id); const btn = document.getElementById(id);
if(btn) btn.style.display = locked ? 'none' : ''; if(btn) btn.style.display = locked ? 'none' : '';
@@ -286,6 +310,22 @@ window.PlanApp = {
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle // js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
// Bereiche befüllt sind, wird einer davon eingeblendet. // Bereiche befüllt sind, wird einer davon eingeblendet.
app.initPlanSections(); app.initPlanSections();
if(startAufgabe){
const id = startAufgabe;
startAufgabe = '';
// Adresszeile auf die tatsächlich geöffnete Sitzung bringen (der Link
// nannte evtl. eine ältere, siehe openPlanView()).
if(typeof updateRouteUrl === 'function') updateRouteUrl('plan');
const idx = (plan.todos || []).findIndex(t => String(t.id) === id);
if(idx < 0){ showToast('Die Aufgabe aus dem Link gibt es in diesem Projekt nicht mehr.'); return; }
if(location.hash !== '#aufgaben') showPlanSection('aufgaben');
// Panel erst nach dem Zeichnen öffnen (das Gantt misst seine Breite).
setTimeout(() => {
if(isLocked()){ showToast('Aufgabe: ' + (plan.todos[idx].title || '') + ' — diese Sitzung ist gesperrt.'); return; }
openTodoModal(idx);
}, 50);
}
} }
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine) // Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
+75
View File
@@ -24,9 +24,82 @@
// gerufen. Keine Parameter, keine Rueckgabe. // gerufen. Keine Parameter, keine Rueckgabe.
function renderSettings(){ function renderSettings(){
renderSettingsAccount(); renderSettingsAccount();
renderSettingsKalender();
renderSettingsAiKeys(); renderSettingsAiKeys();
} }
// ---------- Kalender in Outlook ----------
// Geheime Abo-Adresse je Konto (GET/POST /mein-kalender). Ohne Adresse ein
// Knopf zum Einrichten; mit Adresse: Kopieren, „In Outlook öffnen“ (webcal:),
// Anleitung, neue Adresse (alte wird ungültig) und Abschalten.
let kalenderUrlAktuell = null;
async function renderSettingsKalender(){
const el = document.getElementById('settingsKalender');
if(!el) return;
if(kalenderUrlAktuell === null){
try {
const d = await fetch(baseUrl() + '/mein-kalender').then(r => r.json());
kalenderUrlAktuell = (d && d.url) || '';
} catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
}
const url = kalenderUrlAktuell;
if(!currentUser || !currentUser.personId){
el.innerHTML = '<p class="settings-warn">Ohne Personen-Zuordnung enthält der Kalender nur Ihre Abwesenheiten.</p>';
} else el.innerHTML = '';
if(!url){
el.innerHTML += '<button type="button" onclick="kalenderAdresse(false)">Kalender-Adresse erzeugen</button>';
return;
}
const webcal = url.replace(/^https?:/, 'webcal:');
el.innerHTML += `
<div class="kalender-adresse">
<input type="text" id="kalenderUrlFeld" readonly value="${esc(url)}" onclick="this.select()" aria-label="Kalender-Adresse">
<button type="button" class="secondary" onclick="kalenderKopieren()">Kopieren</button>
<a class="button-link" href="${esc(webcal)}">In Outlook öffnen</a>
</div>
<details class="kalender-anleitung">
<summary>So abonnieren Sie den Kalender</summary>
<ol>
<li><b>Outlook am PC (klassisch):</b> „In Outlook öffnen“ klicken und bestätigen — oder
Kalender → <i>Kalender hinzufügen</i> → <i>Aus dem Internet</i> → Adresse einfügen.</li>
<li><b>Neues Outlook / Outlook im Browser:</b> Kalender → <i>Kalender hinzufügen</i> →
<i>Aus dem Internet abonnieren</i> → Adresse einfügen. Das funktioniert nur, wenn der
Server aus dem Internet erreichbar ist — Microsoft holt den Kalender von außen ab.</li>
</ol>
<p class="hint">Die Adresse ist Ihr persönlicher Schlüssel: Wer sie kennt, sieht Ihre Termine.
Nicht weitergeben. Bei Verdacht „Neue Adresse“ wählen — die alte gilt dann nicht mehr.</p>
</details>
<div class="kalender-aktionen">
<button type="button" class="secondary small" onclick="kalenderAdresse(false, true)">Neue Adresse</button>
<button type="button" class="secondary small" onclick="kalenderAdresse(true)">Abschalten</button>
</div>`;
}
async function kalenderAdresse(aus, nachfragen){
if(aus && !confirm('Kalender-Abo abschalten? Outlook zeigt danach keine neuen Termine mehr.')) return;
if(nachfragen && !confirm('Neue Adresse erzeugen? Die bisherige gilt dann nicht mehr — in Outlook muss der Kalender neu abonniert werden.')) return;
try {
const r = await fetch(baseUrl() + '/mein-kalender', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ aus: !!aus })
});
const d = await r.json();
if(!r.ok || !d.ok) throw new Error(d.error || 'Fehler');
kalenderUrlAktuell = d.url || '';
renderSettingsKalender();
showSettingsNote(aus ? 'Kalender-Abo abgeschaltet.' : 'Kalender-Adresse erzeugt.');
} catch(e){ alert('Das hat nicht geklappt: ' + e.message); }
}
function kalenderKopieren(){
const feld = document.getElementById('kalenderUrlFeld');
if(!feld) return;
const fertig = () => showSettingsNote('Adresse kopiert.');
if(navigator.clipboard && window.isSecureContext){
navigator.clipboard.writeText(feld.value).then(fertig).catch(() => { feld.select(); document.execCommand('copy'); fertig(); });
} else { feld.select(); document.execCommand('copy'); fertig(); }
}
// ---------- Mein Konto ---------- // ---------- Mein Konto ----------
// renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an. // renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an.
@@ -234,6 +307,8 @@ function putMyAiKey(provider, patch){
body: JSON.stringify(body) body: JSON.stringify(body)
}).then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => { }).then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.'); if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
// "Termin aus Mail" ist nur mit eingerichteter KI aktiv — Stand neu holen.
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
return json; return json;
}); });
} }
+114 -9
View File
@@ -541,6 +541,11 @@ function liveEreignis(d){
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht); if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
if(d.typ === 'neu') return liveAllesNeu(); if(d.typ === 'neu') return liveAllesNeu();
if(d.typ === 'personen') return personenNachladen(); if(d.typ === 'personen') return personenNachladen();
if(d.typ === 'notizen'){
if(typeof meineNotizenLive === 'function') meineNotizenLive(d.uid);
if(typeof notizenLiveAenderung === 'function') notizenLiveAenderung(d.uid);
return;
}
} }
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer // personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
@@ -596,7 +601,13 @@ function liveWocheNachladen(label, geloescht){
// Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach. // Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach.
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; } if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label); const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label);
if(!w) return liveAllesNeu(); if(!w){
// Eine ältere, hier nicht geladene Sitzung (z. B. entsperrt): nur ihren
// Eintrag in der Sitzungsliste nachziehen, nicht alles neu laden.
const kurz = (db.wochenListe || []).find(x => x.label === label);
if(kurz) return wocheVomServer(label).then(f => { if(f) Object.assign(kurz, sitzungKurz(f)); ansichtNachBearbeitung(); }).catch(() => {});
return liveAllesNeu();
}
wocheVomServer(label).then(frisch => { wocheVomServer(label).then(frisch => {
if(!frisch) return liveAllesNeu(); if(!frisch) return liveAllesNeu();
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; } if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
@@ -648,6 +659,102 @@ function handleWeekConflict(week, err){
else load(); else load();
} }
// ---------------------------------------------------------------------------
// Sitzungen nach Bedarf (Eriks Wunsch: beim Start nur die aktuelle und drei
// davor; im Verlauf „Weitere laden“ holt je fünf ältere nach)
// ---------------------------------------------------------------------------
// db.weeks hält nur die GELADENEN Sitzungen, db.wochenListe ALLE schlank
// ({id, label, date, locked, updatedAt}, älteste zuerst). Wer eine nicht
// geladene Sitzung braucht (Admin → Sitzungen, Deep-Link auf ein Projekt
// einer älteren Sitzung), holt sie über sitzungSicherLaden().
const SITZUNGEN_START = 4;
const SITZUNGEN_NACHLADEN = 5;
function wocheAbsichern(w){
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return w;
}
function sitzungKurz(w){
return { id: w.id, label: w.label, date: w.date || '', locked: !!w.locked, updatedAt: w.updatedAt || '' };
}
// sitzungKurzAbgleichen: den schlanken Eintrag einer Sitzung in
// db.wochenListe auf den Stand des vollen Objekts bringen (Sperre, Revision).
function sitzungKurzAbgleichen(w){
const k = (db.wochenListe || []).find(x => x.label === w.label);
if(k) Object.assign(k, sitzungKurz(w));
}
// wochenEinfuegen: geladene Sitzungen in db.weeks aufnehmen (ohne Doppel),
// sortieren und als Basis fürs Zusammenführen merken.
function wochenEinfuegen(liste){
(liste || []).forEach(w => {
if(!w || typeof w.label !== 'string' || db.weeks.some(x => x.label === w.label)) return;
db.weeks.push(wocheAbsichern(w));
basisMerken(w);
});
db.weeks.sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
}
// aeltereSitzungenAnzahl: wie viele Sitzungen älter als die älteste geladene
// noch nicht geladen sind.
function aeltereSitzungenAnzahl(){
const liste = db.wochenListe || [];
if(!db.weeks.length) return 0;
const aelteste = db.weeks[0].label;
const idx = liste.findIndex(w => w.label === aelteste);
return idx < 0 ? 0 : idx;
}
// weitereSitzungenLaden: holt die nächsten älteren Sitzungen und zeichnet
// danach neu — die ganze Ansicht, oder nur, was nachher (Name einer globalen
// Funktion) zeichnet. Der Wochenbericht nutzt das, damit ein angefangener
// Kommentar nicht durch ein Neuzeichnen verloren geht. Rückgabe: Promise.
let weitereLaufen = null;
function weitereSitzungenLaden(anzahl, nachher){
if(weitereLaufen) return weitereLaufen;
if(!db.weeks.length || aeltereSitzungenAnzahl() === 0) return Promise.resolve();
const vor = db.weeks[0].label;
weitereLaufen = fetch(baseUrl() + '/weeks-aelter?vor=' + encodeURIComponent(vor) + '&anzahl=' + (anzahl || SITZUNGEN_NACHLADEN))
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(d => {
wochenEinfuegen(d.weeks);
if(nachher && typeof window[nachher] === 'function'){ window[nachher](); return; }
ansichtAuffrischen();
// Kommentar-Verlauf im Projekt-Dashboard hängt an renderPlanInfo().
if(typeof currentTab !== 'undefined' && currentTab === 'plan' && typeof renderPlanInfo === 'function') renderPlanInfo();
})
.catch(err => { console.error('Ältere Sitzungen laden fehlgeschlagen:', err); alert('Ältere Sitzungen konnten nicht geladen werden.'); })
.finally(() => { weitereLaufen = null; });
return weitereLaufen;
}
// weitereLadenKnopf: Knopf „Weitere laden“ für einen Verlauf — nur, solange
// es ältere, nicht geladene Sitzungen gibt. Parameter: nachher (optional,
// siehe weitereSitzungenLaden()). Rückgabe: HTML-String ('' sonst).
function weitereLadenKnopf(nachher){
const n = aeltereSitzungenAnzahl();
if(!n) return '';
const k = Math.min(n, SITZUNGEN_NACHLADEN);
return `<button type="button" class="secondary small verlauf-weitere" onmousedown="event.preventDefault()" onclick="event.stopPropagation(); weitereSitzungenLaden(0${nachher ? ", '" + nachher + "'" : ''})"
title="Noch ${n} ältere Sitzung${n === 1 ? '' : 'en'} am Server">Weitere laden (${k} ${k === 1 ? 'Sitzung' : 'Sitzungen'})</button>`;
}
// sitzungSicherLaden: liefert eine Sitzung, lädt sie bei Bedarf einzeln
// nach. Rückgabe: Promise<Woche|null>.
function sitzungSicherLaden(label){
const da = db.weeks.find(w => w.label === label);
if(da) return Promise.resolve(da);
return wocheVomServer(label).then(w => {
if(!w) return null;
wochenEinfuegen([w]);
return db.weeks.find(x => x.label === label) || null;
});
}
// reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die // reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde). // übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von // Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
@@ -745,7 +852,9 @@ function load(silent){
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.'); alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
return; return;
} }
const url = baseUrl() + '/load-all'; // Nur die jüngsten SITZUNGEN_START Sitzungen (aktuelle + drei davor);
// ältere holt weitereSitzungenLaden() bei Bedarf (Eriks Wunsch).
const url = baseUrl() + '/load-all?wochen=' + SITZUNGEN_START;
console.log('Loading from', url); console.log('Loading from', url);
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen: // Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala, // renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
@@ -775,13 +884,9 @@ function load(silent){
db.weeks = (Array.isArray(data.weeks) ? data.weeks : []) db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string') .filter(w => w && typeof w.label === 'string')
.map(w => { .map(wocheAbsichern);
// Felder absichern, damit alte/kaputte Dokumente nicht crashen // Alle Sitzungen schlank (Name, Datum, Sperre) — auch die nicht geladenen.
if(!Array.isArray(w.people)) w.people = []; db.wochenListe = Array.isArray(data.wochenListe) ? data.wochenListe : db.weeks.map(sitzungKurz);
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return w;
});
// Server-Stand als Basis für späteres Zusammenführen merken. // Server-Stand als Basis für späteres Zusammenführen merken.
wochenBasis.clear(); wochenBasis.clear();
+6 -2
View File
@@ -23,7 +23,7 @@
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der // Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom // Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
// Dashboard ins Projektboard. // Dashboard ins Projektboard.
const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','people','planung','planchecker','dashboard','wand','settings','admin','plan']; const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','notizen','people','planung','planchecker','dashboard','wand','settings','admin','plan'];
function showTab(t){ function showTab(t){
// Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der // Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der
@@ -61,6 +61,7 @@ function showTab(t){
if(t==='wochenbericht') renderWochenbericht(); if(t==='wochenbericht') renderWochenbericht();
if(t==='load') renderLoad(); if(t==='load') renderLoad();
if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); } if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); }
if(t==='notizen' && typeof renderMeineNotizen === 'function') renderMeineNotizen();
if(t==='people') renderPeople(); if(t==='people') renderPeople();
if(t==='planung') renderPlanung(); if(t==='planung') renderPlanung();
if(t==='planchecker') renderPlanChecker(); if(t==='planchecker') renderPlanChecker();
@@ -108,6 +109,9 @@ function syncPlanNavItem(){
const name = app && app.projName ? app.projName : ''; const name = app && app.projName ? app.projName : '';
item.style.display = name ? '' : 'none'; item.style.display = name ? '' : 'none';
const label = document.getElementById('planNavItemLabel'); const label = document.getElementById('planNavItemLabel');
if(label) label.textContent = name || 'Projektbereich'; // Projektnummer in Mono-Ziffern davor (Kennzeichen „Bauplan“).
if(label) label.innerHTML = name
? (app.projNr ? '<span class="nav-projnr">' + esc(app.projNr) + '</span> ' : '') + esc(name)
: 'Projektbereich';
item.title = name ? ('Projektbereich: ' + name) : 'Projektbereich'; item.title = name ? ('Projektbereich: ' + name) : 'Projektbereich';
} }
+56 -11
View File
@@ -385,8 +385,21 @@ function kommentarGewerk(){
// Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des // Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des
// Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder // Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder
// null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }. // null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }.
function kommentarVon(p, g){ // kommentareDesProjekts: die Kommentare nur der Gewerke, die das Projekt hat.
// Ein Kommentar eines Gewerks, das dem Projekt nicht (mehr) gehört — etwa aus
// einer Zeit, als es noch HLS hatte —, gehört nicht in die Gesamtanzeige
// (Eriks Meldung zu 21-4030). Ohne gepflegte Gewerke: alle. Rückgabe: Objekt.
function kommentareDesProjekts(p){
const alle = (p && p.kommentare) || {}; const alle = (p && p.kommentare) || {};
const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : [];
if(!gewerke.length) return alle;
const aus = {};
Object.keys(alle).forEach(k => { if(gewerke.includes(k)) aus[k] = alle[k]; });
return aus;
}
function kommentarVon(p, g){
const alle = g ? ((p && p.kommentare) || {}) : kommentareDesProjekts(p);
if(g){ if(g){
const e = alle[g] || {}; const e = alle[g] || {};
return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen }; return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen };
@@ -408,19 +421,37 @@ function kommentarVon(p, g){
} }
// kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g // kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g
// nur dessen Text; ohne Gewerk die Texte aller Gewerke, bei mehreren je mit // nur dessen Text; ohne Gewerk die Texte aller Gewerke des Projekts. Bei
// dem Gewerk als eigener, dezenter Beschriftung (.kommentar-gewerk) — // mehreren Gewerken Darstellung „B – Überschrift mit Linie“ (Eriks Wahl):
// Systemtext, nicht Teil des Kommentars (anders als die fette "ELT:"-Zeile, // je Gewerk eine Kopfzeile mit dem Gewerk in seiner Farbe, einer feinen Linie
// die kommentarVon() für Klartext-Vorschauen einfügt). Rückgabe: HTML ('' ohne Text). // und (mitMeta) Verfasser und Zeit, darunter der Text — Systemtext, nicht
function kommentarAnzeigeHtml(p, g){ // Teil des Kommentars (anders als die fette "ELT:"-Zeile, die kommentarVon()
// für Klartext-Vorschauen einfügt). Parameter: p, g, mitMeta.
// Rückgabe: HTML ('' ohne Text).
function kommentarAnzeigeHtml(p, g, mitMeta){
if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || ''); if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || '');
const alle = (p && p.kommentare) || {}; const alle = kommentareDesProjekts(p);
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH']; const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); }); Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== ''); const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
if(mitText.length === 0) return ''; if(mitText.length === 0) return '';
if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status); if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status);
return mitText.map(k => `<div class="kommentar-gewerk">${esc(k)}</div><div>${sanitizeStatusHtml(alle[k].status)}</div>`).join(''); return '<div class="kommentar-gewerke">' + mitText.map(k => {
const e = alle[k];
const zeit = e.statusAt && typeof formatCommentStamp === 'function' ? formatCommentStamp(e.statusAt) : '';
const meta = mitMeta ? [e.statusAuthor, zeit].filter(Boolean).join(' · ') : '';
return '<div class="kommentar-block">'
+ '<div class="kommentar-kopf"><span class="kommentar-gewerk gewerk-' + esc(k.toLowerCase()) + '">' + esc(k) + '</span>'
+ '<span class="kommentar-linie"></span>' + (meta ? '<span class="kommentar-meta">' + esc(meta) + '</span>' : '') + '</div>'
+ '<div class="kommentar-text">' + sanitizeStatusHtml(e.status) + '</div></div>';
}).join('') + '</div>';
}
// kommentarGewerkeMitText: wie viele Gewerke des Projekts einen Kommentar
// haben (für die Frage, ob eine gemeinsame Verfasserzeile Sinn ergibt).
function kommentarGewerkeMitText(p){
const alle = kommentareDesProjekts(p);
return Object.keys(alle).filter(k => alle[k] && statusPlainText(alle[k].status) !== '').length;
} }
// kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in // kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in
@@ -1187,8 +1218,19 @@ function verlaufBeteiligte(version){
// die eine geänderte Zahl sonst nicht. // die eine geänderte Zahl sonst nicht.
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell" // Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
// gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML. // gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML.
// verlaufOffen: project_uids, deren Verlauf aufgeklappt ist — übersteht das
// Neuzeichnen nach „Weitere laden“ (sonst klappte er dabei wieder zu).
const verlaufOffen = new Set();
function verlaufUmschalten(uid, offen){ if(offen) verlaufOffen.add(uid); else verlaufOffen.delete(uid); }
function renderProjektVerlauf(p, weekLabel, locked){ function renderProjektVerlauf(p, weekLabel, locked){
const versionen = projectVersionsForUid(p.projectUid); const versionen = projectVersionsForUid(p.projectUid);
// Gezeigt wird, was geladen ist (anfangs die drei Sitzungen vor der
// aktuellen); „Weitere laden“ holt je fünf ältere (js/store.js).
const weitere = typeof weitereLadenKnopf === 'function' ? weitereLadenKnopf() : '';
const uidAttr = esc(String(p.projectUid || '')).replace(/'/g, '&#39;');
const offen = verlaufOffen.has(p.projectUid) ? ' open' : '';
const umschalten = ` ontoggle="verlaufUmschalten('${uidAttr}', this.open)"`;
// Neueste zuerst, damit links die jüngste Vergangenheit steht. // Neueste zuerst, damit links die jüngste Vergangenheit steht.
// //
// Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im // Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im
@@ -1199,13 +1241,15 @@ function renderProjektVerlauf(p, weekLabel, locked){
const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse(); const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse();
if(frueher.length === 0){ if(frueher.length === 0){
return `<details class="timeline-block"> return `<details class="timeline-block"${offen}${umschalten}>
<summary onmousedown="event.preventDefault()"> <summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span> <span class="comment-history-caret">▶</span>
<span>Verlauf</span> <span>Verlauf</span>
<span class="comment-history-count zero">0</span> <span class="comment-history-count zero">0</span>
</summary> </summary>
<div class="timeline-body"><span class="hint" style="margin:0">Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.</span></div> <div class="timeline-body"><span class="hint" style="margin:0">${weitere
? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
: 'Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.'}</span>${weitere}</div>
</details>`; </details>`;
} }
@@ -1276,7 +1320,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
</article>`; </article>`;
}).join(''); }).join('');
return `<details class="timeline-block"> return `<details class="timeline-block"${offen}${umschalten}>
<summary onmousedown="event.preventDefault()"> <summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span> <span class="comment-history-caret">▶</span>
<span>Verlauf</span> <span>Verlauf</span>
@@ -1284,6 +1328,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
</summary> </summary>
<div class="timeline-body"> <div class="timeline-body">
<div class="timeline-rail">${kacheln}</div> <div class="timeline-rail">${kacheln}</div>
${weitere ? `<div class="verlauf-weitere-zeile">${weitere}</div>` : ''}
</div> </div>
</details>`; </details>`;
} }
+4 -1
View File
@@ -138,7 +138,9 @@ function unlockCurrentWeek(){ unlockWeek(currentWeek()); }
// üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich // üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich
// darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe. // darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe.
function deleteWeekById(weekId, nachDelete){ function deleteWeekById(weekId, nachDelete){
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; } // Gezählt werden ALLE Sitzungen, nicht nur die geladenen (db.wochenListe).
const gesamt = (db.wochenListe && db.wochenListe.length) || db.weeks.length;
if(gesamt<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
const w = db.weeks.find(x => x.id === weekId); const w = db.weeks.find(x => x.id === weekId);
if(!w) return; if(!w) return;
if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return; if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return;
@@ -151,6 +153,7 @@ function deleteWeekById(weekId, nachDelete){
console.log('Woche gelöscht:', w.label); console.log('Woche gelöscht:', w.label);
const warAktuell = (weekId === db.currentWeek); const warAktuell = (weekId === db.currentWeek);
db.weeks = db.weeks.filter(x=>x.id!==weekId); db.weeks = db.weeks.filter(x=>x.id!==weekId);
if(Array.isArray(db.wochenListe)) db.wochenListe = db.wochenListe.filter(x => x.label !== w.label);
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
// Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus // Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus
// irgendeinem Grund gar keine mehr existierende), auf die jüngste // irgendeinem Grund gar keine mehr existierende), auf die jüngste
+10 -14
View File
@@ -361,7 +361,6 @@ function aktuellesBerichtProjekt(w){
// Parameter: uid (project_uid). Keine Rückgabe. // Parameter: uid (project_uid). Keine Rückgabe.
function waehleBerichtProjekt(uid){ function waehleBerichtProjekt(uid){
berichtAuswahl = uid; berichtAuswahl = uid;
berichtVerlaufAlle = false; // neues Projekt zeigt wieder nur die jüngsten Einträge
renderWochenbericht(); renderWochenbericht();
const detail = document.getElementById('berichtDetail'); const detail = document.getElementById('berichtDetail');
if(detail) detail.scrollIntoView({ block: 'nearest' }); if(detail) detail.scrollIntoView({ block: 'nearest' });
@@ -742,10 +741,9 @@ function renderBerichtDetail(p, w, prevWeek, locked){
// jedem Eintrag alle Werte zu wiederholen. Keine neue Datenhaltung — jede // jedem Eintrag alle Werte zu wiederholen. Keine neue Datenhaltung — jede
// Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()). // Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()).
const BERICHT_VERLAUF_KURZ = 3; // Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
// berichtVerlaufAlle: "Alle N anzeigen" aufgeklappt; waehleBerichtProjekt() // aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
// setzt es beim Projektwechsel zurück. // js/store.js) und zeichnet nur diesen Abschnitt neu (berichtVerlaufNeu()).
let berichtVerlaufAlle = false;
// berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende) // berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende)
function berichtKurzKw(label){ function berichtKurzKw(label){
@@ -828,11 +826,12 @@ function renderBerichtVerlauf(p, w, locked){
const gText = g || 'alle Gewerke'; const gText = g || 'alle Gewerke';
let inhalt; let inhalt;
if(eintraege.length === 0){ if(eintraege.length === 0){
inhalt = `<div class="hint" style="margin:0">${sitzungen === 0 inhalt = `<div class="hint" style="margin:0">${sitzungen === 0 && !aeltereSitzungenAnzahl()
? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.' ? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.'
: sitzungen === 0 ? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
: 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}</div>`; : 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}</div>`;
} else { } else {
const zeigen = berichtVerlaufAlle ? eintraege : eintraege.slice(0, BERICHT_VERLAUF_KURZ); const zeigen = eintraege;
inhalt = `<ol class="bv-liste">${zeigen.map((e, i) => { inhalt = `<ol class="bv-liste">${zeigen.map((e, i) => {
const datum = berichtVerlaufDatum(e.at); const datum = berichtVerlaufDatum(e.at);
const wer = [e.autor, datum].filter(Boolean).join(', '); const wer = [e.autor, datum].filter(Boolean).join(', ');
@@ -854,9 +853,7 @@ function renderBerichtVerlauf(p, w, locked){
</li>`; </li>`;
}).join('')}</ol>`; }).join('')}</ol>`;
} }
const mehr = eintraege.length > BERICHT_VERLAUF_KURZ const mehr = weitereLadenKnopf('berichtVerlaufNeu');
? `<button type="button" class="bv-mehr" onclick="berichtVerlaufUmschalten()">${berichtVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + eintraege.length + ' anzeigen'}</button>`
: '';
const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare'; const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare';
return `<section class="bericht-verlauf" id="berichtVerlauf" aria-label="Verlauf der Vorwochen"> return `<section class="bericht-verlauf" id="berichtVerlauf" aria-label="Verlauf der Vorwochen">
<div class="bv-kopf"> <div class="bv-kopf">
@@ -869,10 +866,9 @@ function renderBerichtVerlauf(p, w, locked){
</section>`; </section>`;
} }
// berichtVerlaufUmschalten: "Alle N anzeigen" / "Weniger anzeigen". Zeichnet // berichtVerlaufNeu: zeichnet nur den Verlauf neu (nach „Weitere laden“),
// nur den Verlauf neu, damit ein angefangener Kommentar unberührt bleibt. // damit ein angefangener Kommentar unberührt bleibt.
function berichtVerlaufUmschalten(){ function berichtVerlaufNeu(){
berichtVerlaufAlle = !berichtVerlaufAlle;
const w = currentWeek(); const w = currentWeek();
const p = w && aktuellesBerichtProjekt(w); const p = w && aktuellesBerichtProjekt(w);
const el = document.getElementById('berichtVerlauf'); const el = document.getElementById('berichtVerlauf');
+453 -370
View File
File diff suppressed because it is too large. Load diff
+959 -669
View File
File diff suppressed because it is too large. Load diff
+694 -25
View File
@@ -514,6 +514,7 @@ db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones (
date TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '',
position TEXT NOT NULL DEFAULT '', position TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
notiz TEXT NOT NULL DEFAULT '',
PRIMARY KEY (week_label, project_id, milestone_id) PRIMARY KEY (week_label, project_id, milestone_id)
)`); )`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps ( db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
@@ -779,7 +780,8 @@ const RECHTE = [
['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''], ['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''],
['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''], ['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''],
['a.papierkorb', 'Papierkorb', ''], ['a.backup', 'Datenbank-Backup', ''], ['a.papierkorb', 'Papierkorb', ''], ['a.backup', 'Datenbank-Backup', ''],
['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''] ]}, ['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''],
['a.teams', 'Teams-Anbindung', ''] ]},
]; ];
const RECHT_LABEL = new Map(RECHTE.flatMap(g => g.rechte.map(r => [r[0], r[1]]))); const RECHT_LABEL = new Map(RECHTE.flatMap(g => g.rechte.map(r => [r[0], r[1]])));
const RECHT_KEYS = new Set(RECHT_LABEL.keys()); const RECHT_KEYS = new Set(RECHT_LABEL.keys());
@@ -1018,6 +1020,30 @@ function htmlEscape(s){
.replace(/"/g, '&quot;').replace(/'/g, '&#39;'); .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
} }
// textMaskieren: wie htmlEscape(), aber ein '&', das schon eine fertige
// Entity beginnt (&amp; &nbsp; &#39; …), bleibt stehen. Der Text zwischen den
// Tags kommt aus gespeichertem HTML, in dem solche Entities ganz normal
// stehen. htmlEscape() machte daraus bei JEDEM Speichern eine Stufe mehr
// (&nbsp; → &amp;nbsp; → &amp;amp;nbsp; …) — Eriks Meldung nach dem
// Sitzungswechsel KW 45. So bleibt die Bereinigung beliebig oft wiederholbar.
function textMaskieren(s){
return String(s)
.replace(/&(?!(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&amp;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// entitiesEntstapeln: macht gestapelte Maskierung rückgängig
// (&amp;amp;nbsp; → &nbsp;, &amp;amp; → &amp;). Ein „&amp;“ vor einer
// weiteren Entity ist immer das Ergebnis des Fehlers oben, nie Absicht.
function entitiesEntstapeln(html){
let alt, neu = String(html || '');
do {
alt = neu;
neu = neu.replace(/&amp;((?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&$1');
} while(neu !== alt);
return neu;
}
// sicheresStyleAttribut: lässt aus einem style-Attribut nur color und // sicheresStyleAttribut: lässt aus einem style-Attribut nur color und
// font-weight durch — identisch zur Client-Regel. Rückgabe: bereinigter // font-weight durch — identisch zur Client-Regel. Rückgabe: bereinigter
// Wert oder '' (dann wird das Attribut ganz weggelassen). // Wert oder '' (dann wird das Attribut ganz weggelassen).
@@ -1106,7 +1132,7 @@ function sanitizeFormatHtml(html){
if(c !== '<'){ if(c !== '<'){
const naechstes = quelle.indexOf('<', i); const naechstes = quelle.indexOf('<', i);
const text = quelle.slice(i, naechstes === -1 ? quelle.length : naechstes); const text = quelle.slice(i, naechstes === -1 ? quelle.length : naechstes);
if(!verwerfenBis) aus += htmlEscape(text); if(!verwerfenBis) aus += textMaskieren(text);
i += text.length; i += text.length;
continue; continue;
} }
@@ -1224,8 +1250,8 @@ function projektStatements(){
VALUES (?, ?, ?, ?)`); VALUES (?, ?, ?, ?)`);
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id) const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
VALUES (?, ?, ?, ?)`); VALUES (?, ?, ?, ?)`);
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order) const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?)`); VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id) const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
VALUES (?, ?, ?, ?)`); VALUES (?, ?, ?, ?)`);
@@ -1310,7 +1336,7 @@ function serializeProject(weekLabel, p, idx, nowIso){
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || ''))); (Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
}); });
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => { (Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => {
insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx); insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx, String(m.notiz || '').slice(0, 20000));
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || ''))); (Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
}); });
} }
@@ -1409,11 +1435,15 @@ function loadGlobalPeople(fuerUser){
function loadWeeks(nurLabel){ function loadWeeks(nurLabel){
// nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die // nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die
// Live-Ereignisse, die sonst jedes Mal alle Sitzungen zusammensetzen müssten. // Live-Ereignisse, die sonst jedes Mal alle Sitzungen zusammensetzen müssten.
// Auch eine Liste von Labels (für /load-all und /weeks-aelter, die nur
// einige Sitzungen liefern).
const labels = nurLabel === undefined ? null : (Array.isArray(nurLabel) ? nurLabel : [nurLabel]).map(String);
if(labels && labels.length === 0) return [];
const alle = (tabelle, reihenfolge) => { const alle = (tabelle, reihenfolge) => {
const spalte = tabelle === 'weeks' ? 'label' : 'week_label'; const spalte = tabelle === 'weeks' ? 'label' : 'week_label';
const wo = nurLabel === undefined ? '' : ` WHERE ${spalte} = ?`; const wo = labels ? ` WHERE ${spalte} IN (${labels.map(() => '?').join(',')})` : '';
const st = db.prepare(`SELECT * FROM ${tabelle}${wo}${reihenfolge ? ' ' + reihenfolge : ''}`); const st = db.prepare(`SELECT * FROM ${tabelle}${wo}${reihenfolge ? ' ' + reihenfolge : ''}`);
return nurLabel === undefined ? st.all() : st.all(String(nurLabel)); return labels ? st.all(...labels) : st.all();
}; };
const weeks = alle('weeks', 'ORDER BY label'); const weeks = alle('weeks', 'ORDER BY label');
const weekPeople = alle('week_people', 'ORDER BY array_order'); const weekPeople = alle('week_people', 'ORDER BY array_order');
@@ -1534,7 +1564,7 @@ function loadWeeks(nurLabel){
const msMap = {}; const msMap = {};
milestones.forEach(r => { milestones.forEach(r => {
const k = msKey(r.week_label, r.project_id, r.milestone_id); const k = msKey(r.week_label, r.project_id, r.milestone_id);
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [] }; msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '' };
}); });
milestoneDeps.forEach(r => { milestoneDeps.forEach(r => {
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)]; const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
@@ -1664,11 +1694,11 @@ const IMPORT_TABLES = ['weeks','week_people','week_people_roles','attendance','p
'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps', 'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps',
'plan_milestones','plan_milestone_deps', 'plan_milestones','plan_milestone_deps',
'project_protokolle','protokoll_teilnehmer','protokoll_punkte', 'project_protokolle','protokoll_teilnehmer','protokoll_punkte',
'project_kommentare']; 'project_kommentare', 'projekt_notizen'];
// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen. // IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen.
// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk() // Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk()
// verteilt die alten Kommentare danach aus projects.status. // verteilt die alten Kommentare danach aus projects.status.
const IMPORT_OPTIONAL_TABLES = ['project_kommentare']; const IMPORT_OPTIONAL_TABLES = ['project_kommentare', 'projekt_notizen'];
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen // dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info. // jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
@@ -2605,6 +2635,30 @@ function kommentareVorsatzUmstellen(){
if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.'); if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.');
} }
// entitiesReparieren: führt gestapelte Maskierung (&amp;amp;nbsp; …) in
// allen Sitzungskommentaren und Protokoll-Punkten auf das ursprüngliche
// Zeichen zurück — Folge des Fehlers in sanitizeFormatHtml() vor
// textMaskieren(). Läuft bei jedem Start und nach /import, idempotent.
function entitiesReparieren(){
const teile = [
['project_kommentare', 'status', 'week_label, project_id, gewerk', 'week_label = ? AND project_id = ? AND gewerk = ?', r => [r.week_label, r.project_id, r.gewerk]],
['protokoll_punkte', 'text', 'id', 'id = ?', r => [r.id]]
];
let n = 0;
db.exec('BEGIN');
try {
teile.forEach(([tab, feld, schluessel, wo, werte]) => {
const upd = db.prepare('UPDATE ' + tab + ' SET ' + feld + ' = ? WHERE ' + wo);
db.prepare('SELECT ' + schluessel + ', ' + feld + " AS wert FROM " + tab + " WHERE " + feld + " LIKE '%&amp;%'").all().forEach(r => {
const neu = entitiesEntstapeln(r.wert);
if(neu !== r.wert){ upd.run(neu, ...werte(r)); n++; }
});
});
db.exec('COMMIT');
} catch(e){ db.exec('ROLLBACK'); throw e; }
if(n) console.log('Doppelt maskierte Zeichen in ' + n + ' Kommentaren/Protokoll-Punkten repariert.');
}
function kommentareSpiegelstricheUmstellen(){ function kommentareSpiegelstricheUmstellen(){
const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status != ''").all(); const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status != ''").all();
const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?'); const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?');
@@ -2671,6 +2725,11 @@ function migrateAddPlanTodoNotiz(){
db.exec("ALTER TABLE plan_todos ADD COLUMN notiz TEXT NOT NULL DEFAULT ''"); db.exec("ALTER TABLE plan_todos ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
console.log('plan_todos um notiz ergänzt.'); console.log('plan_todos um notiz ergänzt.');
} }
const msCols = db.prepare("SELECT name FROM pragma_table_info('plan_milestones')").all().map(r => r.name);
if(!msCols.includes('notiz')){
db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
console.log('plan_milestones um notiz ergänzt.');
}
} }
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach // migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
@@ -2897,8 +2956,12 @@ function ensureCurrentWeekRollover(){
// zur Sitzung und beginnt leer. // zur Sitzung und beginnt leer.
const vorher = p.kommentare || {}; const vorher = p.kommentare || {};
p.kommentare = {}; p.kommentare = {};
// Nur Gewerke, die das Projekt hat — sonst lebte ein Kommentar eines
// längst abgewählten Gewerks Woche für Woche weiter (21-4030).
const gewerke = Array.isArray(p.gewerke) ? p.gewerke : [];
Object.keys(vorher).forEach(g => { Object.keys(vorher).forEach(g => {
const k = vorher[g] || {}; const k = vorher[g] || {};
if(gewerke.length && !gewerke.includes(g)) return;
if(!String(k.status || '').trim()) return; if(!String(k.status || '').trim()) return;
p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '', p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '',
statusAt: k.statusAt || '', besprochen: false }; statusAt: k.statusAt || '', besprochen: false };
@@ -3038,6 +3101,12 @@ function migrateUsersDepartment(){
// dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine // dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine
// Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer = // Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer =
// wie in der Projektliste. Je Konto, damit sie auf jedem Gerät gilt. // wie in der Projektliste. Je Konto, damit sie auf jedem Gerät gilt.
// kalender_token: geheime Adresse des Kalender-Abos (/kalender/<token>.ics),
// leer = abgeschaltet. Siehe "Kalender-Abo für Outlook".
if(!cols.includes('kalender_token')){
db.exec("ALTER TABLE users ADD COLUMN kalender_token TEXT NOT NULL DEFAULT ''");
console.log('Benutzer-Tabelle um kalender_token ergänzt.');
}
if(!cols.includes('dashboard_reihenfolge')){ if(!cols.includes('dashboard_reihenfolge')){
db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''"); db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''");
console.log('Benutzer-Tabelle um dashboard_reihenfolge ergänzt.'); console.log('Benutzer-Tabelle um dashboard_reihenfolge ergänzt.');
@@ -3541,6 +3610,7 @@ einmaligeUmstellung('siemon-anteile-2021', siemon2021Umstellen);
// Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei // Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei
// jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen. // jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen.
kommentareVorsatzUmstellen(); kommentareVorsatzUmstellen();
entitiesReparieren();
ensureSandboxWeek(); ensureSandboxWeek();
ensureUserPersonIdUniqueIndex(); ensureUserPersonIdUniqueIndex();
migrateRetirePeopleTablesIntoUsers(); migrateRetirePeopleTablesIntoUsers();
@@ -4652,7 +4722,7 @@ app.post('/save-week', requireAuth, (req, res) => {
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save). // aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
const updatedAt = saveWeekRelational(week); const updatedAt = saveWeekRelational(week);
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user); if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user); if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user, req);
console.log('Saved week:', week.label); console.log('Saved week:', week.label);
ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req); ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req);
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() }); return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
@@ -4662,6 +4732,161 @@ app.post('/save-week', requireAuth, (req, res) => {
} }
}); });
// ---------- Teams: persönliche Nachricht bei neuen Aufgaben ----------
//
// Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, bekommt GENAU DIESE
// Person in Teams eine Nachricht (Einzelchat mit dem Flow-Bot) mit Link
// direkt auf die Aufgabe im Projekt — nicht der ganze Kanal (Eriks Wahl
// „nur persönlich“). Dafür legt man in Teams einen Workflow an: Auslöser
// „Bei Empfang einer Teams-Webhookanforderung“, Aktion „Karte in einem Chat
// oder Kanal veröffentlichen“ als Flow-Bot im Chat, Empfänger
// triggerBody()?['empfaenger'], Karte string(triggerBody()?['karte']). Die
// Adresse des Auslösers trägt ein Admin unter Admin → Teams ein (Recht
// a.teams). Die App schickt je Person {empfaenger: E-Mail, karte: Adaptive
// Card}; Empfänger ist users.email (muss dem Teams-Anmeldenamen
// entsprechen). Personen ohne Konto oder E-Mail bekommen nichts. Die Adresse ist ein
// Geheimnis (wer sie kennt, kann in den Kanal schreiben) — sie steht in
// `integrationen`, nicht in app_settings (das jedes Konto lesen darf), und
// geht nie an den Browser zurück. Nicht in IMPORT_TABLES.
//
// Senden braucht Internetzugang nach außen; ein Fehler wird nur geloggt,
// das Speichern der Sitzung scheitert nie daran.
db.exec(`CREATE TABLE IF NOT EXISTS integrationen (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL DEFAULT ''
)`);
function integrationLesen(k){
const r = db.prepare('SELECT wert FROM integrationen WHERE schluessel = ?').get(k);
return r ? r.wert : '';
}
function integrationSchreiben(k, v){
db.prepare('INSERT INTO integrationen (schluessel, wert) VALUES (?, ?) ON CONFLICT(schluessel) DO UPDATE SET wert = excluded.wert')
.run(k, String(v));
}
// teamsKarte: die Adaptive Card selbst (der Workflow legt sie in den Chat).
function teamsKarte(titel, zeilen, fakten, link, linkText){
const body = [{ type: 'TextBlock', text: titel, weight: 'Bolder', size: 'Medium', wrap: true }];
zeilen.forEach(z => body.push({ type: 'TextBlock', text: z, wrap: true, spacing: 'Small' }));
if(fakten.length) body.push({ type: 'FactSet', facts: fakten.map(([title, value]) => ({ title, value })) });
return {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard', version: '1.4', body,
actions: link ? [{ type: 'Action.OpenUrl', title: linkText || 'Öffnen', url: link }] : []
};
}
// teamsSenden: an die eingetragene Adresse schicken. Rückgabe: Promise —
// wirft bei Fehler (für den Test-Knopf); Aufrufer im Hintergrund fangen ab.
async function teamsSenden(nachricht, url){
const ziel = url || integrationLesen('teams_webhook');
if(!ziel) throw new Error('Keine Teams-Adresse eingetragen.');
const res = await fetchMitTimeout(ziel, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nachricht)
});
if(!res.ok){
const t = await res.text().catch(() => '');
throw new Error('Teams antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : ''));
}
}
// aufgabenLink: Adresse der Aufgabe im Projekt (der Browser öffnet sie direkt
// im Aufgaben-Panel, Parameter aufgabe=). Host aus der speichernden Anfrage —
// also die Adresse, unter der die App im Büro aufgerufen wird.
function aufgabenLink(req, weekLabel, p, todoId){
if(!req) return '';
const u = new URL('/Projektplan/' + encodeURIComponent(p.name || ''), req.protocol + '://' + req.get('host'));
u.searchParams.set('weekLabel', weekLabel);
u.searchParams.set('projNr', p.nr || '');
u.searchParams.set('aufgabe', String(todoId));
u.hash = 'aufgaben';
return u.toString();
}
function teamsDatum(iso){
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
return m ? m[3] + '.' + m[2] + '.' + m[1] : '';
}
// teamsNeueAufgaben: je Aufgabe mit neu Zuständigen EINE Nachricht. neu =
// Map todoId -> { p, t, personen:[ids] }. Läuft im Hintergrund.
// teamsNeueAufgaben: je neu zuständiger PERSON eine Nachricht an sie selbst.
// neu = Map todoId -> { p, t, personen:[person_ids] }. Läuft im Hintergrund.
function teamsNeueAufgaben(weekLabel, neu, user, req){
if(!neu.size || integrationLesen('teams_neue_aufgaben') !== '1' || !integrationLesen('teams_webhook')) return;
const namen = new Map(db.prepare('SELECT person_id, name, full_name FROM week_people WHERE week_label = ?').all(weekLabel)
.map(r => [r.person_id, r.full_name || r.name || r.person_id]));
const konto = db.prepare(`SELECT email FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND email != '' AND ${sqlOhneWandtafel()}`);
const von = (user && (user.displayName || user.username)) || '';
neu.forEach(({ p, t, personen }) => {
const wer = personen.map(id => namen.get(id) || id).join(', ');
const zeitraum = t.start && t.end && t.start !== t.end ? teamsDatum(t.start) + ' – ' + teamsDatum(t.end) : '';
const fakten = [['Projekt', [p.nr, p.name].filter(Boolean).join(' ')]];
if(personen.length > 1) fakten.push(['Zuständig', wer]);
if(t.end) fakten.push(['Fällig', teamsDatum(t.end)]);
if(zeitraum) fakten.push(['Zeitraum', zeitraum]);
if(Number(t.aufwand) > 0) fakten.push(['Aufwand', String(t.aufwand).replace('.', ',') + ' h']);
if(von) fakten.push(['Zugewiesen von', von]);
const karte = teamsKarte('Neue Aufgabe für Sie: ' + (t.title || 'Aufgabe'), [], fakten,
aufgabenLink(req, weekLabel, p, t.id), 'Aufgabe öffnen');
personen.forEach(pid => {
const k = konto.get(pid);
if(!k){ console.log('Teams: keine E-Mail für', namen.get(pid) || pid, '— keine Nachricht'); return; }
teamsSenden({ empfaenger: k.email, karte })
.catch(err => console.warn('Teams-Nachricht an', k.email, 'nicht gesendet:', err.message));
});
});
}
app.get('/admin/teams', requireRecht('a.teams'), (req, res) => {
const url = integrationLesen('teams_webhook');
let host = '';
try { host = url ? new URL(url).host : ''; } catch(e){ /* egal */ }
res.json({ ok: true, konfiguriert: !!url, host, neueAufgaben: integrationLesen('teams_neue_aufgaben') === '1' });
});
// PUT {webhook?, neueAufgaben?} — webhook '' entfernt die Adresse.
app.put('/admin/teams', requireRecht('a.teams'), (req, res) => {
try {
const b = req.body || {};
if(typeof b.webhook === 'string'){
const w = b.webhook.trim();
if(w){
let u;
try { u = new URL(w); } catch(e){ return res.status(400).json({ ok: false, error: 'Das ist keine gültige Adresse.' }); }
if(u.protocol !== 'https:') return res.status(400).json({ ok: false, error: 'Die Teams-Adresse muss mit https:// beginnen.' });
}
integrationSchreiben('teams_webhook', w);
}
if(typeof b.neueAufgaben === 'boolean') integrationSchreiben('teams_neue_aufgaben', b.neueAufgaben ? '1' : '0');
res.json({ ok: true });
} catch(err){
console.error('Teams-Einstellung speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// POST /admin/teams/test — Testnachricht; Fehler als {ok:false, error} mit
// HTTP 200 (ein fehlgeschlagener Test ist ein erwartetes Ergebnis).
// Die Testnachricht geht an den Admin selbst (seine E-Mail aus dem Konto).
app.post('/admin/teams/test', requireRecht('a.teams'), async (req, res) => {
try {
const email = (db.prepare('SELECT email FROM users WHERE id = ?').get(req.user.id) || {}).email || '';
if(!email) return res.json({ ok: false, error: 'Ihr Konto hat keine E-Mail-Adresse — die braucht Teams, um Sie zu finden (Einstellungen → Mein Konto).' });
const link = req.protocol + '://' + req.get('host') + '/';
await teamsSenden({ empfaenger: email, karte: teamsKarte('Projektübersicht ist mit Teams verbunden',
['Ab jetzt bekommt jede Person hier eine Nachricht, wenn ihr eine Aufgabe zugewiesen wird — mit Link direkt zur Aufgabe.'],
[['Eingerichtet von', req.user.displayName || req.user.username]], link, 'Projektübersicht öffnen') });
res.json({ ok: true, an: email });
} catch(err){
res.json({ ok: false, error: err.message });
}
});
// neueZuweisungenMelden: legt für jede Person, die einer offenen Aufgabe neu // neueZuweisungenMelden: legt für jede Person, die einer offenen Aufgabe neu
// als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der // als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der
// Person ein aktives Konto gehört (users.person_id; Kiosk-Konten nicht) und // Person ein aktives Konto gehört (users.person_id; Kiosk-Konten nicht) und
@@ -4669,7 +4894,7 @@ app.post('/save-week', requireAuth, (req, res) => {
// Speichern und darf es nie scheitern lassen (Fehler werden nur geloggt). // Speichern und darf es nie scheitern lassen (Fehler werden nur geloggt).
// Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId" // Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId"
// vor dem Speichern), user (req.user). Keine Rückgabe. // vor dem Speichern), user (req.user). Keine Rückgabe.
function neueZuweisungenMelden(week, alt, user){ function neueZuweisungenMelden(week, alt, user, req){
try { try {
const konto = db.prepare(`SELECT id FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND ${sqlOhneWandtafel()}`); const konto = db.prepare(`SELECT id FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND ${sqlOhneWandtafel()}`);
const schonDa = db.prepare('SELECT 1 FROM benachrichtigungen WHERE user_id = ? AND todo_id = ? AND gelesen = 0'); const schonDa = db.prepare('SELECT 1 FROM benachrichtigungen WHERE user_id = ? AND todo_id = ? AND gelesen = 0');
@@ -4679,12 +4904,21 @@ function neueZuweisungenMelden(week, alt, user){
const jetzt = new Date().toISOString(); const jetzt = new Date().toISOString();
const von = (user && (user.displayName || user.username)) || ''; const von = (user && (user.displayName || user.username)) || '';
let anzahl = 0; let anzahl = 0;
// Für Teams: je Aufgabe die neu Zuständigen — auch Personen ohne Konto,
// aber nicht, wer sich selbst zuweist; nie aus der Planung-Sandbox.
const fuerTeams = new Map();
const sandbox = week.label === SANDBOX_WEEK_LABEL;
const ichPerson = user && user.personId ? String(user.personId) : '';
(Array.isArray(week.projects) ? week.projects : []).forEach(p => { (Array.isArray(week.projects) ? week.projects : []).forEach(p => {
const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : []; const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : [];
todos.forEach(t => { todos.forEach(t => {
if(!t || t.done) return; if(!t || t.done) return;
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => { (Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => {
if(alt.has(String(t.id) + '|' + String(pid))) return; if(alt.has(String(t.id) + '|' + String(pid))) return;
if(!sandbox && !t.lph && String(pid) !== ichPerson){
if(!fuerTeams.has(String(t.id))) fuerTeams.set(String(t.id), { p, t, personen: [] });
fuerTeams.get(String(t.id)).personen.push(String(pid));
}
const k = konto.get(String(pid)); const k = konto.get(String(pid));
if(!k || (user && k.id === user.id)) return; if(!k || (user && k.id === user.id)) return;
if(schonDa.get(k.id, String(t.id))) return; if(schonDa.get(k.id, String(t.id))) return;
@@ -4695,6 +4929,7 @@ function neueZuweisungenMelden(week, alt, user){
}); });
}); });
if(anzahl) console.log('Benachrichtigungen angelegt:', anzahl, '(neue Aufgaben-Zuweisungen)'); if(anzahl) console.log('Benachrichtigungen angelegt:', anzahl, '(neue Aufgaben-Zuweisungen)');
teamsNeueAufgaben(String(week.label || ''), fuerTeams, user, req);
} catch(err){ } catch(err){
console.error('Benachrichtigungen konnten nicht angelegt werden:', err.message); console.error('Benachrichtigungen konnten nicht angelegt werden:', err.message);
} }
@@ -4897,7 +5132,7 @@ app.post('/save-projects', requireAuth, (req, res) => {
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, label); db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, label);
db.exec('COMMIT'); db.exec('COMMIT');
} catch(err){ db.exec('ROLLBACK'); throw err; } } catch(err){ db.exec('ROLLBACK'); throw err; }
neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user); neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user, req);
const ids = new Set(liste.map(e => String(e.project.id))); const ids = new Set(liste.map(e => String(e.project.id)));
const frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id))); const frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id)));
ereignisSenden({ typ: 'projekte', label, updatedAt: jetzt, projekte: frisch }, req); ereignisSenden({ typ: 'projekte', label, updatedAt: jetzt, projekte: frisch }, req);
@@ -4915,6 +5150,25 @@ app.post('/save-projects', requireAuth, (req, res) => {
// winzig, deshalb fragt der Browser ihn alle 20 Sekunden ab und holt nur bei // winzig, deshalb fragt der Browser ihn alle 20 Sekunden ab und holt nur bei
// einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim // einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim
// gleichzeitigen Arbeiten, js/store.js). Für die Wandtafel ebenfalls lesbar. // gleichzeitigen Arbeiten, js/store.js). Für die Wandtafel ebenfalls lesbar.
// GET /weeks-aelter?vor=<label>&anzahl=5: die nächsten älteren Sitzungen vor
// <label> vollständig ("Weitere laden" im Verlauf), dazu wie viele danach
// noch übrig sind.
app.get('/weeks-aelter', requireAuthOderKiosk, (req, res) => {
try {
const liste = echteSitzungenSortiert();
const vor = String(req.query.vor || '');
const anzahl = Math.min(50, Math.max(1, Math.floor(Number(req.query.anzahl)) || 5));
const idx = liste.findIndex(w => w.label === vor);
const bis = idx < 0 ? liste.length : idx;
const von = Math.max(0, bis - anzahl);
const weeks = loadWeeks(liste.slice(von, bis).map(w => w.label));
res.json({ ok: true, weeks, uebrig: von });
} catch(err){
console.error('Ältere Sitzungen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
app.get('/week-revisionen', requireAuthOderKiosk, (req, res) => { app.get('/week-revisionen', requireAuthOderKiosk, (req, res) => {
try { try {
const revisionen = {}; const revisionen = {};
@@ -5017,7 +5271,8 @@ function projektlisteAenderungen(week){
const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean)); const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean));
const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid))); const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid)));
if(!fehlend.length) return { neu, entfernt: [] }; if(!fehlend.length) return { neu, entfernt: [] };
const w = loadWeeks().find(x => x.label === label); // Nur diese eine Sitzung laden, nicht alle (loadWeeks(label)).
const w = loadWeeks(label)[0];
const fehlendIds = new Set(fehlend.map(r => r.project_id)); const fehlendIds = new Set(fehlend.map(r => r.project_id));
return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] }; return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] };
} }
@@ -5176,9 +5431,25 @@ app.post('/unlock-week', requireRecht('s.entsperren'), (req, res) => {
// Route: Lädt alle Wochen (jetzt eine einzige globale Reihe) plus die // Route: Lädt alle Wochen (jetzt eine einzige globale Reihe) plus die
// firmenweite Personenliste. Liefert strukturierte JSON-Objekte (aus den // firmenweite Personenliste. Liefert strukturierte JSON-Objekte (aus den
// relationalen Tabellen zusammengesetzt); nach Label sortiert. // relationalen Tabellen zusammengesetzt); nach Label sortiert.
// echteSitzungenSortiert: alle echten Sitzungen (ohne Sandbox) als schlanke
// Liste, älteste zuerst — {id, label, date, locked, updatedAt}.
function echteSitzungenSortiert(){
return db.prepare('SELECT week_id, label, date, locked, updated_at FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
.map(r => ({ id: r.week_id, label: r.label, date: r.date || '', locked: !!r.locked, updatedAt: r.updated_at || '' }))
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
}
// GET /load-all?wochen=N: Mit wochen nur die jüngsten N Sitzungen vollständig
// (Eriks Wunsch: aktuelle + drei davor), dazu wochenListe = ALLE Sitzungen
// schlank (Name, Datum, Sperre) für Admin → Sitzungen und das Nachladen.
// Ohne wochen wie früher alle (z. B. ältere Browser-Stände).
app.get('/load-all', requireAuthOderKiosk, (req, res) => { app.get('/load-all', requireAuthOderKiosk, (req, res) => {
try { try {
const allWeeks = loadWeeks(); const anzahl = Math.floor(Number(req.query.wochen));
const wochenListe = echteSitzungenSortiert();
const allWeeks = anzahl > 0
? loadWeeks(wochenListe.slice(-anzahl).map(w => w.label).concat([SANDBOX_WEEK_LABEL]))
: loadWeeks();
// Die reservierte Sandbox-"Sitzung" (Planung, siehe SANDBOX_WEEK_LABEL) // Die reservierte Sandbox-"Sitzung" (Planung, siehe SANDBOX_WEEK_LABEL)
// ist keine echte Sitzung — sie wird separat als sandboxPlans // ist keine echte Sitzung — sie wird separat als sandboxPlans
// ausgeliefert, statt in weeks[] zu landen. So kommt sie clientseitig // ausgeliefert, statt in weeks[] zu landen. So kommt sie clientseitig
@@ -5189,7 +5460,7 @@ app.get('/load-all', requireAuthOderKiosk, (req, res) => {
const sandboxPlans = sandbox ? sandbox.projects : []; const sandboxPlans = sandbox ? sandbox.projects : [];
const sandboxUpdatedAt = sandbox ? sandbox.updatedAt : ''; const sandboxUpdatedAt = sandbox ? sandbox.updatedAt : '';
const people = loadGlobalPeople(req.user); const people = loadGlobalPeople(req.user);
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt }); res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt, wochenListe });
} catch(err){ } catch(err){
console.error('Load error', err); console.error('Load error', err);
res.status(500).json({ ok: false, error: String(err) }); res.status(500).json({ ok: false, error: String(err) });
@@ -6866,12 +7137,17 @@ function mailTeilLesen(roh, tiefe){
if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' }; if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' };
if(!typ.startsWith('text/')) return { plain: '', html: '' }; if(!typ.startsWith('text/')) return { plain: '', html: '' };
const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase(); const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase();
let buf; let text;
if(enc === 'base64') buf = Buffer.from(rumpf.replace(/\s+/g, ''), 'base64'); if(enc === 'base64' || enc === 'quoted-printable'){
else if(enc === 'quoted-printable'){ const buf = enc === 'base64'
buf = Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1'); ? Buffer.from(rumpf.replace(/\s+/g, ''), 'base64')
} else buf = Buffer.from(rumpf, 'latin1'); : Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
const text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8'); text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
} else {
// 7bit/8bit: Der Browser hat die Datei bereits als Text gelesen (UTF-8) —
// ein Umweg über Bytes zerstörte hier die Umlaute.
text = rumpf;
}
return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' }; return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' };
} }
@@ -6927,6 +7203,7 @@ const KI_MAIL_SCHEMA = {
items: { items: {
type: 'object', type: 'object',
properties: { properties: {
art: { type: 'string', enum: ['aufgabe', 'meilenstein'] },
titel: { type: 'string' }, titel: { type: 'string' },
start: { type: 'string' }, start: { type: 'string' },
ende: { type: 'string' }, ende: { type: 'string' },
@@ -6934,7 +7211,7 @@ const KI_MAIL_SCHEMA = {
aufwandStunden: { type: 'number' }, aufwandStunden: { type: 'number' },
zitat: { type: 'string' } zitat: { type: 'string' }
}, },
required: ['titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'], required: ['art', 'titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'],
additionalProperties: false additionalProperties: false
} }
} }
@@ -6947,7 +7224,8 @@ const KI_MAIL_SCHEMA = {
// Mails bei temperature:0 einzeln prüfen (siehe CLAUDE.md "### Protokolle"). // Mails bei temperature:0 einzeln prüfen (siehe CLAUDE.md "### Protokolle").
const KI_MAIL_SYSTEM_PROMPT = 'Du bekommst eine E-Mail aus einem Ingenieurbüro und Angaben zum Projekt. Mache daraus ' const KI_MAIL_SYSTEM_PROMPT = 'Du bekommst eine E-Mail aus einem Ingenieurbüro und Angaben zum Projekt. Mache daraus '
+ 'Einträge für den Terminplan des Projekts: je Termin, Frist oder zu erledigender Arbeit einen Eintrag. ' + 'Einträge für den Terminplan des Projekts: je Termin, Frist oder zu erledigender Arbeit einen Eintrag. '
+ 'Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" ' + 'art: "meilenstein" für einen festen Stichtag ohne eigene Arbeit (Abgabe, Termin, Freigabe, Besprechung), '
+ '"aufgabe" für zu erledigende Arbeit. Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" '
+ 'rechnest du vom Datum der Mail aus. Ein einzelner Termin oder eine Frist: start und ende gleich. ' + 'rechnest du vom Datum der Mail aus. Ein einzelner Termin oder eine Frist: start und ende gleich. '
+ 'Ohne Datum in der Mail bleiben start und ende leer. zustaendig: nur Namen aus der Liste der Beteiligten, ' + 'Ohne Datum in der Mail bleiben start und ende leer. zustaendig: nur Namen aus der Liste der Beteiligten, '
+ 'die in der Mail als verantwortlich genannt sind, sonst leer. aufwandStunden nur wenn genannt, sonst 0. zitat: der Satz aus der Mail, ' + 'die in der Mail als verantwortlich genannt sind, sonst leer. aufwandStunden nur wenn genannt, sonst 0. zitat: der Satz aus der Mail, '
@@ -6976,6 +7254,7 @@ function normalisiereMailEintrag(e){
if(ende && !start) start = ende; if(ende && !start) start = ende;
if(start && ende && ende < start){ const t = start; start = ende; ende = t; } if(start && ende && ende < start){ const t = start; start = ende; ende = t; }
return { return {
art: (e && e.art) === 'meilenstein' ? 'meilenstein' : 'aufgabe',
titel: String((e && e.titel) || '').trim().slice(0, 200), titel: String((e && e.titel) || '').trim().slice(0, 200),
start, ende, start, ende,
zustaendig: Array.isArray(e && e.zustaendig) ? e.zustaendig.map(String).filter(Boolean) : [], zustaendig: Array.isArray(e && e.zustaendig) ? e.zustaendig.map(String).filter(Boolean) : [],
@@ -7084,6 +7363,228 @@ app.post('/plan-eintrag-aus-mail', requireAuth, requireRecht('proj.plan'), async
} }
}); });
// ---------- Projekt-Notizen (Reiter „Notizen“, js/plan/plan-notizen.js) ----------
//
// Eriks Wunsch (Entwurf A „Liste + Editor“): gemeinsame Notizen je Projekt,
// formatiert wie der Sitzungskommentar, anheftbar, Mail per Drag & Drop.
// Die Notizen hängen am PROJEKT (project_uid), nicht an einer Sitzung —
// sie überstehen den Wochenwechsel, ohne kopiert zu werden, und gelten auch
// für Planung-Terminpläne (Sandbox). Gespeichert wird je Notiz, nicht über
// /save-week. Lesen: jedes Konto mit Grundzugang; Schreiben: Recht
// proj.plan; Löschen: Verfasser oder Administrator.
db.exec(`CREATE TABLE IF NOT EXISTS projekt_notizen (
id TEXT PRIMARY KEY,
project_uid TEXT NOT NULL,
titel TEXT NOT NULL DEFAULT '',
html TEXT NOT NULL DEFAULT '',
angeheftet INTEGER NOT NULL DEFAULT 0,
quelle TEXT NOT NULL DEFAULT '',
erstellt_von INTEGER NOT NULL DEFAULT 0,
erstellt_am TEXT NOT NULL DEFAULT '',
geaendert_von INTEGER NOT NULL DEFAULT 0,
geaendert_am TEXT NOT NULL DEFAULT ''
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_projekt_notizen_uid ON projekt_notizen(project_uid)');
const NOTIZ_MAX_HTML = 200000;
function notizZeile(r){
return {
id: r.id, uid: r.project_uid, titel: r.titel, html: r.html, angeheftet: !!r.angeheftet, quelle: r.quelle,
erstelltVon: r.erstellt_name || '', erstelltVonId: r.erstellt_von, erstelltAm: r.erstellt_am,
geaendertVon: r.geaendert_name || '', geaendertAm: r.geaendert_am
};
}
function notizLesen(id){
const r = db.prepare(`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
WHERE n.id = ?`).get(String(id));
return r ? notizZeile(r) : null;
}
function istAdministrator(user){ return !!(user && (rollenCache.get(user.role) || {}).fest); }
function notizAnlegen(req, uid, titel, html, quelle){
const id = crypto.randomUUID();
const jetzt = new Date().toISOString();
db.prepare(`INSERT INTO projekt_notizen (id, project_uid, titel, html, angeheftet, quelle, erstellt_von, erstellt_am, geaendert_von, geaendert_am)
VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
.run(id, uid, String(titel || '').slice(0, 300), String(html || '').slice(0, NOTIZ_MAX_HTML), quelle || '', req.user.id, jetzt, req.user.id, jetzt);
ereignisSenden({ typ: 'notizen', uid }, req);
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 || '');
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' });
const rows = db.prepare(`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
WHERE n.project_uid = ? ORDER BY n.angeheftet DESC, n.geaendert_am DESC`).all(uid);
res.json({ ok: true, notizen: rows.map(notizZeile) });
} catch(err){
console.error('Notizen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
app.post('/projekt-notizen', requireAuth, (req, res) => {
try {
const b = req.body || {};
const uid = String(b.uid || '');
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);
res.status(500).json({ ok: false, error: String(err) });
}
});
// 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).
// 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.' });
}
const titel = typeof b.titel === 'string' ? b.titel.slice(0, 300) : alt.titel;
const html = typeof b.html === 'string' ? b.html.slice(0, NOTIZ_MAX_HTML) : alt.html;
const angeheftet = typeof b.angeheftet === 'boolean' ? (b.angeheftet ? 1 : 0) : (alt.angeheftet ? 1 : 0);
// Anheften allein ist keine inhaltliche Änderung: Zeitstempel und
// 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 = ?, project_uid = ?
${nurPin ? '' : ', geaendert_von = ' + Number(req.user.id)} WHERE 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);
res.status(500).json({ ok: false, error: String(err) });
}
});
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.' });
}
db.prepare('DELETE FROM projekt_notizen WHERE id = ?').run(alt.id);
ereignisSenden({ typ: 'notizen', uid: alt.uid }, req);
res.json({ ok: true });
} catch(err){
console.error('Notiz löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// 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, (req, res) => {
try {
const b = req.body || {};
const uid = String(b.uid || '');
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);
if(!text && !mail.betreff) return res.status(400).json({ ok: false, error: 'In der Mail wurde kein Text gefunden.' });
const ersteZeile = (text.split('\n').map(z => z.trim()).find(Boolean) || 'Mail').slice(0, 120);
const escHtml = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kopf = [mail.absender ? '<b>Von:</b> ' + escHtml(mail.absender) : '',
mail.datum ? '<b>Datum:</b> ' + escHtml(teamsDatum(mail.datum)) : ''].filter(Boolean).join('<br>');
const html = (kopf ? kopf + '<br><br>' : '') + escHtml(text).replace(/\n/g, '<br>');
res.json({ ok: true, notiz: notizAnlegen(req, uid, mail.betreff || ersteZeile, html, 'mail') });
} catch(err){
console.error('Notiz aus Mail fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Plan Checker (plan_checker_merkmale) ---------- // ---------- Plan Checker (plan_checker_merkmale) ----------
// //
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt // Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt
@@ -8265,6 +8766,169 @@ app.get('/app-settings', requireAuthOderKiosk, (req, res) => {
// "### Protokolle". GET liefert je Anbieter, OB ein Schlüssel gesetzt ist // "### Protokolle". GET liefert je Anbieter, OB ein Schlüssel gesetzt ist
// (nie der Schlüssel selbst, wie ein Passwort) sowie die — nicht geheime — // (nie der Schlüssel selbst, wie ein Passwort) sowie die — nicht geheime —
// apiUrl zum Weiterbearbeiten. // apiUrl zum Weiterbearbeiten.
// ---------- Kalender-Abo für Outlook (iCalendar) ----------
//
// Eriks Wunsch: die eigenen Termine in Outlook sehen. Jedes Konto bekommt
// unter Einstellungen → Mein Konto eine geheime Adresse
// /kalender/<token>.ics; Outlook abonniert sie („Aus dem Internet“) und holt
// sie selbst regelmäßig. NUR LESEN — geändert wird weiter in der App.
// Inhalt aus der jüngsten echten Sitzung: eigene offene Aufgaben mit Termin,
// Meilensteine der eigenen Projekte (zugeordnet oder mit eigener Aufgabe)
// und die eigenen Abwesenheiten. Ganztägige Einträge (VALUE=DATE), damit
// keine Zeitzone im Spiel ist.
//
// Der Token IST die Anmeldung (Outlook kann sich nicht einloggen) — deshalb
// lang und zufällig, jederzeit neu erzeugbar (alte Adresse ungültig) oder
// abschaltbar. Deaktivierte Konten und reine Wandtafel-Konten bekommen 404.
function kalenderUrl(req, token){
return req.protocol + '://' + req.get('host') + '/kalender/' + token + '.ics';
}
app.get('/mein-kalender', requireAuth, (req, res) => {
const row = db.prepare('SELECT kalender_token FROM users WHERE id = ?').get(req.user.id);
const token = row && row.kalender_token;
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
});
// POST {aus: true} schaltet ab, sonst wird eine NEUE Adresse erzeugt (die
// bisherige wird damit ungültig).
app.post('/mein-kalender', requireAuth, (req, res) => {
try {
const aus = !!(req.body && req.body.aus);
const token = aus ? '' : crypto.randomBytes(24).toString('base64url');
db.prepare('UPDATE users SET kalender_token = ? WHERE id = ?').run(token, req.user.id);
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
} catch(err){
console.error('Kalender-Adresse speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// icsText: Text nach RFC 5545 maskieren.
function icsText(s){
return String(s || '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
}
// icsZeile: Zeilen über 75 Byte falten (Folgezeilen beginnen mit Leerzeichen).
function icsZeile(z){
if(Buffer.byteLength(z, 'utf8') <= 75) return z;
const teile = [];
let rest = z;
// Erste Zeile höchstens 75 Byte, Folgezeilen 74 (plus führendes Leerzeichen).
const grenze = () => teile.length ? 74 : 75;
while(Buffer.byteLength(rest, 'utf8') > grenze()){
let n = Math.min(rest.length, grenze());
while(Buffer.byteLength(rest.slice(0, n), 'utf8') > grenze()) n--;
// Kein Ersatzpaar (Emoji o. ä.) zerschneiden.
const c = rest.charCodeAt(n - 1);
if(c >= 0xD800 && c <= 0xDBFF) n--;
teile.push(rest.slice(0, n));
rest = rest.slice(n);
}
teile.push(rest);
return teile.join('\r\n ');
}
function icsDatum(iso){ return String(iso || '').replace(/-/g, ''); }
// icsTagDanach: DTEND ganztägiger Einträge ist der Tag NACH dem letzten Tag.
function icsTagDanach(iso){
const d = new Date(iso + 'T12:00:00');
d.setDate(d.getDate() + 1);
return d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
}
const ICS_DATUM = /^\d{4}-\d{2}-\d{2}$/;
// kalenderEreignisse: die Einträge einer Person aus einer Sitzung plus ihren
// Abwesenheiten. Rein (ohne Datenbank), damit testbar. Parameter: ich
// (person_id), w (Sitzung oder null), abwesenheiten (Zeilen aus
// user_absences). Rückgabe: [{uid, start, ende, titel, projekt, text, frei}].
function kalenderEreignisse(ich, w, abwesenheiten){
const ereignisse = [];
if(w && ich){
(w.projects || []).forEach(p => {
const projekt = [p.nr, p.name].filter(Boolean).join(' ');
const zugeordnet = Object.keys(p.assign || {}).some(k => k.split('|')[0] === ich && Number(p.assign[k]) > 0);
const plan = p.plan || {};
let eigeneAufgabe = false;
(plan.todos || []).forEach(t => {
if(!Array.isArray(t.assignees) || !t.assignees.includes(ich)) return;
eigeneAufgabe = true;
if(t.done || t.lph) return;
const start = ICS_DATUM.test(t.start || '') ? t.start : (ICS_DATUM.test(t.end || '') ? t.end : '');
const ende = ICS_DATUM.test(t.end || '') ? t.end : start;
if(!start) return;
ereignisse.push({
uid: 'aufgabe-' + t.id, start, ende,
titel: t.title || 'Aufgabe', projekt,
text: 'Aufgabe in ' + projekt + (Number(t.progress) > 0 ? ' · ' + Number(t.progress) + ' % erledigt' : '')
+ (t.notiz ? '\n\n' + String(t.notiz).slice(0, 1500) : '')
});
});
if(!zugeordnet && !eigeneAufgabe) return;
(plan.milestones || []).forEach(m => {
if(!ICS_DATUM.test(m.date || '')) return;
ereignisse.push({
uid: 'meilenstein-' + (p.projectUid || p.id) + '-' + m.id, start: m.date, ende: m.date,
titel: '◆ ' + (m.name || 'Meilenstein'), projekt,
text: 'Meilenstein in ' + projekt + (m.notiz ? '\n\n' + String(m.notiz).slice(0, 1500) : '')
});
});
});
}
(abwesenheiten || []).forEach(a => {
if(!ICS_DATUM.test(a.start || '')) return;
ereignisse.push({
uid: 'abwesenheit-' + a.id, start: a.start, ende: ICS_DATUM.test(a.end || '') ? a.end : a.start,
titel: a.type || 'Abwesend', projekt: '', text: a.note || '', frei: true
});
});
return ereignisse;
}
// kalenderText: die iCalendar-Datei (CRLF, gefaltete Zeilen).
function kalenderText(name, ereignisse){
const jetzt = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
const zeilen = [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Projektuebersicht//Kalender//DE', 'CALSCALE:GREGORIAN',
'METHOD:PUBLISH', 'X-WR-CALNAME:' + icsText('Projektübersicht – ' + (name || '')),
'X-WR-TIMEZONE:Europe/Berlin', 'REFRESH-INTERVAL;VALUE=DURATION:PT1H', 'X-PUBLISHED-TTL:PT1H'
];
ereignisse.forEach(e => {
zeilen.push('BEGIN:VEVENT',
'UID:' + icsText(e.uid) + '@projektuebersicht',
'DTSTAMP:' + jetzt,
'DTSTART;VALUE=DATE:' + icsDatum(e.start),
'DTEND;VALUE=DATE:' + icsTagDanach(e.ende < e.start ? e.start : e.ende),
'SUMMARY:' + icsText(e.titel + (e.projekt ? ' (' + e.projekt + ')' : '')),
'DESCRIPTION:' + icsText(e.text),
// Als „frei“ eintragen: Ganztägige Aufgaben sollen die Verfügbarkeit
// in Outlook nicht blockieren; Abwesenheiten zeigen „abwesend“.
'TRANSP:TRANSPARENT',
e.frei ? 'X-MICROSOFT-CDO-BUSYSTATUS:OOF' : 'X-MICROSOFT-CDO-BUSYSTATUS:FREE',
'END:VEVENT');
});
zeilen.push('END:VCALENDAR');
return zeilen.map(icsZeile).join('\r\n') + '\r\n';
}
app.get('/kalender/:datei', (req, res) => {
try {
const token = String(req.params.datei || '').replace(/\.ics$/i, '');
if(token.length < 20) return res.status(404).end();
const u = db.prepare('SELECT id, display_name, person_id, role, active FROM users WHERE kalender_token = ?').get(token);
if(!u || !u.active || !hatRecht({ id: u.id, role: u.role }, 'app')) return res.status(404).end();
const label = echteSitzungenSortiert().map(w => w.label).pop();
const w = label ? loadWeeks(label)[0] : null;
const abw = db.prepare('SELECT id, start, end, type, note FROM user_absences WHERE user_id = ?').all(u.id);
res.set('Content-Type', 'text/calendar; charset=utf-8');
res.set('Cache-Control', 'no-cache');
res.set('Content-Disposition', 'inline; filename="projektuebersicht.ics"');
res.send(kalenderText(u.display_name, kalenderEreignisse(u.person_id || '', w, abw)));
} catch(err){
console.error('Kalender ausliefern fehlgeschlagen:', err);
res.status(500).end();
}
});
app.get('/my-ai-settings', requireAuth, (req, res) => { app.get('/my-ai-settings', requireAuth, (req, res) => {
try { try {
const chatgpt = readUserAiKey(req.user.id, 'chatgpt'); const chatgpt = readUserAiKey(req.user.id, 'chatgpt');
@@ -8724,6 +9388,7 @@ app.post('/import', requireRecht('a.import'), express.raw({ type: '*/*', limit:
// Ältere Backups tragen Kommentare noch gewerkübergreifend in projects. // Ältere Backups tragen Kommentare noch gewerkübergreifend in projects.
migrateKommentareJeGewerk(); migrateKommentareJeGewerk();
kommentareVorsatzUmstellen(); kommentareVorsatzUmstellen();
entitiesReparieren();
// Ältere Backups kennen das Feld leistungsphase noch nicht. // Ältere Backups kennen das Feld leistungsphase noch nicht.
phasenbalkenAusTitel(); phasenbalkenAusTitel();
console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)'); console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)');
@@ -8754,7 +9419,7 @@ app.get('/', (req,res)=>{
// Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404 // Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404
// enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um // enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um
// (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr. // (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr.
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']); const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Notizen','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
// Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen) // Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen)
const PLAN_SLUG = 'Projektplan'; const PLAN_SLUG = 'Projektplan';
@@ -8879,6 +9544,10 @@ function betriebAufraeumen(){
try { try {
const grenze = Date.now() - loginBlockMs(); const grenze = Date.now() - loginBlockMs();
loginAttempts.forEach((e, k) => { if(e.first < grenze) loginAttempts.delete(k); }); loginAttempts.forEach((e, k) => { if(e.first < grenze) loginAttempts.delete(k); });
// Merkliste der last_seen_at-Schreibzeiten (attachUser) nicht endlos
// wachsen lassen: Einträge älter als eine Stunde braucht niemand mehr.
const stundeHer = Date.now() - 60 * 60 * 1000;
sitzungZuletztGeschrieben.forEach((t, k) => { if(t < stundeHer) sitzungZuletztGeschrieben.delete(k); });
db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
} catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); } } catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); }
} }