5 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
24 changed files with 3075 additions and 1512 deletions

No files matched your search

+183
View File
@@ -1552,6 +1552,37 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
`plan-modals.js`) über dem bisherigen Feld `#milestoneEditPosition`,
das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert.
Stile: Block „Seitenpanel“ am Ende von `plan-styles.css`.
- **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,
Eriks Wahl „B“, `js/plan/plan-phasen.js`): oben ein Ablaufbalken aller
Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter
@@ -1806,6 +1837,58 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
`plan-gantt.js` both bail out unless `currentTab === 'plan'` — they now run
while completely different tabs are on screen.
### Notizen in der Seitenleiste (Tab `notizen`, `js/notizen/notizen.js`)
Eriks Wunsch: ein eigener Bereich „Notizen“ (Seitenleiste, eigene Gruppe
„Notizen“ unter „Team“, `/Notizen`), in dem jedes Konto die Notizen ALLER seiner
Projekte sieht und **projektunabhängige Notizen** anlegt, die sich später
einem Projekt zuordnen lassen. Gleicher Aufbau wie der Projekt-Reiter
(Liste + Editor, Werkzeugleiste, automatisches Speichern, Anheften, Löschen
zweistufig), dazu oben ein **Projektfilter** (Alle / Ohne Projekt / je
Projekt mit Anzahl) und im Editor die Auswahl **„Projekt“** (Ohne Projekt,
„Meine Projekte“, „Weitere Projekte“ der aktuellen Sitzung) plus „Im Projekt
öffnen“. „+ Neu“ legt im gefilterten Projekt an, sonst ohne Projekt. Eigene
Element-ids (`mn…`) und Funktionen (`mnNeu()`, `mnZuordnen()` …), weil der
Projekt-Reiter gleichzeitig im DOM liegt; Stile aus `.notizen-*`
(plan-styles.css) plus Block „NOTIZEN (Seitenleiste)“ am Ende von
`styles.css`.
- **Server:** Eine projektunabhängige Notiz ist eine Zeile in
`projekt_notizen` mit `project_uid = ''`; sie sieht und ändert NUR ihr
Verfasser (`notizDarf()`). `GET /meine-notizen` liefert die Notizen der
eigenen Projekte (`meineProjektUids()`: jüngste echte Sitzung, zugeordnet
oder mit eigener Aufgabe — Regel wie `dashDaten()`) plus die eigenen
projektunabhängigen, dazu `meineProjekte`. `PUT /projekt-notizen/:id` nimmt
`uid` zum Zuordnen (Ziel-Projekt braucht `proj.plan`, Lösen nur der
Verfasser). `POST`/`PUT`/`DELETE`/`aus-mail` prüfen jetzt selbst
(`notizDarf()`) statt pauschal `requireRecht('proj.plan')`: ohne Projekt
genügt die Anmeldung. `GET /projekt-notizen/anzahl` zählt nur
Projekt-Notizen.
- **Live:** `{typ:'notizen'}` geht in `store.js` an `meineNotizenLive()` und
`notizenLiveAenderung()`; das Echo der eigenen Speicherung (kommt auch im
selben Tab an) wird 3 s lang ausgelassen, sonst verschwände die Anzeige
„Gespeichert ✓“.
### Seitenleiste — Gliederung nach Arbeitsbereich (11/2026)
Eriks Wahl „A“ (unslop + Taste-Skill): Die Einträge stehen in Gruppen
(`.sidebar-gruppe` in `App.html`): **Dashboard** · **Projekte**
(Projekte, darunter eingezogen das geöffnete Projekt `#planNavItem` mit
Projektnummer in Mono, Wochenbericht, Terminplan) · **Team** (Auslastung,
Abwesenheiten) · **Notizen** (eigene Gruppe, Eriks Wunsch) · **Studenten** (Plan Checker, ganze Gruppe `nur-recht
wz.checker`) · unten `.sidebar-unten` mit **Einstellungen** und **Admin**.
Die Überschriften „Navigation“ und „Planung“ sind entfallen; der Menüpunkt
heißt weiter „Dashboard“. Admin-Unterpunkte in drei Zwischengruppen
(`.sidebar-subgruppe`: Personen, Vorgaben, System); `adminNavAbgleichen()`
blendet eine Zwischengruppe aus, wenn keiner ihrer Punkte erlaubt ist.
Maße: Einträge 32 px, Icon 18 px + 8 px, Rubrik 16 px über der Gruppe und
4 px über ihren Einträgen. Stile: Block „SEITENLEISTE“ am Ende von
`styles.css`.
**Falle in CSS-Kommentaren:** `*/` beendet einen Kommentar auch mitten im
Text (z. B. „--dash-*/--wt-*“). Der Rest wird dann als Regel gelesen und
verschluckt die nächste echte Regel. So fehlten zeitweise `--font-mono`
(alle Mono-Ziffern) und `--accent` im automatischen Dunkelmodus.
### Termin aus Mail (`js/plan/plan-mail-eintrag.js`)
Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze
@@ -3573,6 +3656,26 @@ Stile: Block „Verlauf der Vorwochen im Wochenbericht“ (`.bv-*`,
### Dashboard ("Meine Aufgaben", now the default landing tab)
**Stand 11/2026 — Entwurf „Bauplan“ (Eriks Wahl, Vorlage im Artifact
„Projektübersicht neu gestaltet“):** Kopf ohne Begrüßung und Uhr: Zeile
„KW 45, Mo 2. bis Fr 6. November 2026“ (Mono) über dem Titel „Meine
Aufgaben“, rechts die Sitzung (`renderDashboardKopf()`, `dashWochenMontag()`;
`greetingForHour()`/`startDashboardClock()` sind entfallen). Band mit Satz und
vier Zahlen: offene Aufgaben, überfällig (rot), diese Woche fällig (aus
`dashDaten()`, heute bis Sonntag), Freigaben. Neu darunter **„Diese Woche“**
(`renderDashWoche()`): Mo–Fr als Spalten mit den Terminen der eigenen
Projekte, getönt nach eigene Aufgabe / Aufgabe im Projekt / Meilenstein /
überfällig, je Tag höchstens `DASH_WOCHE_JE_TAG` (4). Jeder Projektblock hat
die Zeile **„Meine Rolle | Team | Notizen“** (`renderDashProjektInfo()`, Eriks
Wunsch): Team = übrige Beteiligte als Kürzel-Kreise in Rollenfarben
(`beteiligteJePerson()`/`beteiligtenAvatarHtml()`, höchstens 5, Namen
gekürzt), Notizen nur wenn vorhanden, mit Sprung in den Reiter „Notizen“.
Die Anzahl liefert **`GET /projekt-notizen/anzahl`** (`{uid: {anzahl,
zuletzt}}`, requireAuth), parallel zu `/my-dashboard` geladen
(`dashNotizen`). Die Rollen-Chips im Projektkopf sind dafür entfallen. Der
**Monatskalender bleibt unverändert** (Eriks Vorgabe). Stile: Block „MEINE
AUFGABEN — Entwurf Bauplan“ am Ende von `styles.css`.
**Aktueller Aufbau (Entwurf „B — Monatsblatt“, Eriks Wahl aus drei Varianten):**
Kopf und Band wie unten beschrieben; darunter **links „Meine Projekte“**, je
Projekt ein Block (`renderDashProjekte()`): eigene Rollen als Chips (gestrichelt
@@ -4247,6 +4350,13 @@ mit Link direkt auf die Aufgabe. Kein Kanal.
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
@@ -4395,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
Projekt-ID wechselt je Sitzung), Planung-Terminpläne über ihre ID.
## Gestaltung „Bauplan“ (unslop-Überarbeitung, 11/2026)
Eriks Wunsch: das gesamte Design mit dem Skill „unslop“ überarbeiten, Wahl
„volle Überarbeitung“, Glas „beruhigen“. Inhalte, Aufbau und Funktionen sind
unverändert; geändert sind nur Stylesheets, ein paar Texte und Inline-Größen.
- **Richtung:** Planköpfe und Raster eines Bauplans — Flächen fast neutral
(Graphit, leicht kühl), ein Akzent (Logo-Blau `#0f5a85`, dunkel
`#86bfdf`), Farbe nur in Inhalten (Terminplan, Auslastung, Status).
Löst die frühere Dunkel-Palette in Wandtafel-Blau ab.
- **Regeln, die neue Stile einhalten sollten:** Schriftgrößen 11/12/13/15/19/24
(keine halben Pixel), Gewichte 400/500, 600 nur selten; keine gesperrten
Großbuchstaben-Labels; keine negative Laufweite unter 40px; Abstände im
4px-Raster (2 und 6 nur in kleinen Bedienelementen); Rundungen 4–6
(Schildchen/Chips), 8 (Knöpfe, Eingaben, Umschalter), 12–16 (Karten);
Kapselform nur für dünne Balken, Schalter, Zählpunkte und Avatare; keine
farbigen Schatten/Leuchten, keine Emoji, keine Ausrufezeichen in Texten.
- **Kennzeichen:** Kalenderwochen, Projektnummern und Kennzahlen in
Monospace-Ziffern (`--font-mono`, tabellarisch) — Block „BAUPLAN“ am Ende
von `styles.css` (auch: ruhige aktive Navigation, Knopf-Übergänge).
- **Dunkel:** Hauptknöpfe in `--knopf` (`#276f9c`, weiße Schrift 5,6:1), weil
weiße Schrift auf dem hellen Akzent nur ~2:1 erreicht.
- **Glas beruhigt** (`glas.css`): Flächen fast deckend, nur noch zwei
schwache Lichtflächen in den Logo-Blautönen (Mint entfallen), Rundungen
8/14/16, neutrale Schatten, kein `saturate()`, aktive Einträge mit Haarlinie
statt farbigem Schatten.
- **Ausgenommen:** die Wandtafel (eigenes Design, `--dash-*`/`--wt-*`) und
die Login-Seite bei den Schriftgrößen.
- Das Umbauskript arbeitete je CSS-Regel; geprüft: gleiche Regelzahl wie
vorher (kein Parse-Verlust), keine Überläufe/Großbuchstaben auf Dashboard,
Projekte, Wochenbericht, Auslastung, Abwesenheiten, Einstellungen und in
den Projektbereichen. Methode nach „unslop“ von Damir Sokolovsky
(@heydamir), CC BY 4.0.
## Glas-Darstellung (`public/glas.css`)
Eriks Wahl aus dem Gestaltungsvorschlag „Projektübersicht in Glas“ (Annäherung
@@ -4713,6 +4856,46 @@ Abweichungen:
Rendern des Wochenberichts mit. `esc()` wandelt jetzt über `String()` um
(nur `null`/`undefined` werden zu `''`).
## Behobener Fehler: wachsende „&amp;amp;…“ in Kommentaren (11/2026)
Eriks Meldung nach dem Sitzungswechsel KW 45: In Kommentaren stand
„&amp;amp;amp;amp;nbsp;“. Ursache: `sanitizeFormatHtml()` (server.js)
maskierte den Text zwischen den Tags mit `htmlEscape()` — auch ein `&`, das
schon eine fertige Entity begann. Weil `serializeProject()` die Kommentare
bei JEDEM Speichern eines Projekts erneut bereinigt (auch wenn nur eine
Aufgabe geändert wurde), kam pro Speichern eine Stufe dazu (&nbsp; →
&amp;nbsp; → &amp;amp;nbsp; …). Jetzt maskiert `textMaskieren()` ein `&` nur,
wenn es keine Entity beginnt — beliebig oft wiederholbar (geprüft: alle
Kommentare nach dreimaligem Bereinigen unverändert). `entitiesReparieren()`
(beim Start und nach `/import`, idempotent) führt gestapelte Stufen in
`project_kommentare.status` und `protokoll_punkte.text` aller Sitzungen über
`entitiesEntstapeln()` zurück.
## Behobener Fehler: Kommentare abgewählter Gewerke (11/2026)
Eriks Meldung zu 21-4030: Im Projekt-Dashboard stand ein HLS-Kommentar,
obwohl das Projekt seit KW 31 nur noch ELT hat. Ursache: Die einmalige
Umstellung `kommentareInAktuelleSitzungUebernehmen()` hatte je Gewerk den
jüngsten früheren Kommentar übernommen, ohne die aktuellen Gewerke des
Projekts zu prüfen; der Sitzungswechsel trug ihn weiter. Jetzt:
`kommentareDesProjekts()` (js/utils.js) — die Gesamtanzeige
(`kommentarVon(p, null)`, `kommentarAnzeigeHtml()`) und der Verlauf im
Projekt-Dashboard (`planKommentarDaten()`, je Sitzung mit DEREN Gewerken)
zeigen nur Gewerke des Projekts; `ensureCurrentWeekRollover()` übernimmt nur
Kommentare dieser Gewerke. Gelöscht wird nichts — die verirrte Kopie fällt
beim nächsten Sitzungswechsel aus der aktuellen Sitzung heraus.
**Darstellung mehrerer Gewerke „B – Überschrift mit Linie“** (Eriks Wahl
aus dem Artifact „Kommentare je Gewerk“): `kommentarAnzeigeHtml(p, null,
mitMeta)` zeichnet je Gewerk eine Kopfzeile (Gewerk in seiner Farbe
`--gewerk-elt/-hls/-arch`, feine Linie, mit `mitMeta` Verfasser und Zeit)
und darunter den Text; bei nur einem Gewerk wie bisher nur den Text.
Genutzt von der Kachel „Kommentar der Sitzung“ im Projekt-Dashboard (dort
entfällt dann die gemeinsame Verfasserzeile, `kommentarGewerkeMitText()`),
der Kommentaransicht der Projektliste/Wandtafel (`renderKommentarAnsicht()`)
und den Verlauf-Kacheln (ohne Verfasser). Stile `.kommentar-gewerke`,
`.kommentar-kopf` in `styles.css`.
## Behobene Fehler (Testlauf 10/2026, fünf Test-Agents)
- **Gesperrte Sitzungen sind für Nicht-Admins auch inhaltlich unveränderlich**:
+71 -100
View File
@@ -38,13 +38,13 @@
<link rel="icon" type="image/png" href="/logo.png">
<link rel="alternate icon" href="/logo.ico">
<link rel="apple-touch-icon" href="/logo.png">
<link rel="stylesheet" href="/styles.css?v=110">
<link rel="stylesheet" href="/plan-styles.css?v=48">
<link rel="stylesheet" href="/styles.css?v=123">
<link rel="stylesheet" href="/plan-styles.css?v=56">
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
unter Einstellungen → Mein Konto (localStorage projuebersicht-glas). -->
<link rel="stylesheet" href="/glas.css?v=14">
<link rel="stylesheet" href="/glas.css?v=18">
<script>
try { if(localStorage.getItem('projuebersicht-glas') !== 'aus') document.documentElement.classList.add('glas'); }
catch(e){ document.documentElement.classList.add('glas'); }
@@ -129,116 +129,87 @@
</button>
</div>
<nav class="sidebar-nav">
<nav class="sidebar-nav" aria-label="Hauptnavigation">
<!-- Gliederung nach Arbeitsbereich (Eriks Wahl „A“, unslop-Überarbeitung
11/2026): oben das Dashboard, dann Projekte, Team, Notizen und Studenten;
Einstellungen und Admin sitzen unten (.sidebar-unten, margin-top:
auto) und rutschen nur bei aufgeklapptem Admin in den Bildlauf. -->
<div class="sidebar-gruppe">
<button class="nav-item tab active" data-tab="dashboard" onclick="showTab('dashboard')" title="Dashboard">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="m4.5 7.5 1.3 1.3L8.5 6"/><line x1="13" y1="6" x2="21" y2="6"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="m4.5 17.5 1.3 1.3 2.7-2.8"/><line x1="13" y1="16" x2="21" y2="16"/></svg></span></span><span class="nav-label">Dashboard</span>
</button>
</div>
<hr class="sidebar-nav-divider">
<div class="sidebar-section-label">Navigation</div>
<!-- Die eine Projektliste. Der frühere zweite Eintrag "Projekte" —
dieselben Projekte, dieselben Angaben, eine zweite Bearbeitungs-
maske — ist hier aufgegangen: Reihenfolge, Gruppenwechsel und
Löschen stehen in dieser Liste, die Projektangaben im
Projektbereich, die wöchentliche Erfassung im Wochenbericht. -->
<div class="sidebar-gruppe">
<div class="sidebar-section-label">Projekte</div>
<button class="nav-item tab" data-tab="board" onclick="showTab('board')" title="Projekte">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2.5"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg></span></span><span class="nav-label">Projekte</span>
</button>
<!-- Geöffnetes Projekt: eingezogen unter „Projekte“, nur sichtbar,
solange ein Projekt offen ist (syncPlanNavItem(), js/tabs-auth.js). -->
<button class="nav-item tab nav-unterpunkt" data-tab="plan" id="planNavItem" style="display:none" onclick="showTab('plan')" title="Projektbereich">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="14" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="16" x2="16" y2="16"/></svg></span></span><span class="nav-label" id="planNavItemLabel">Projektbereich</span>
</button>
<button class="nav-item tab" data-tab="wochenbericht" onclick="showTab('wochenbericht')" title="Wochenbericht">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5h8a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1-1.5 1.5H8A1.5 1.5 0 0 1 6.5 19V5A1.5 1.5 0 0 1 8 3.5Z"/><path d="M9.5 3.5h5v2.2a.8.8 0 0 1-.8.8h-3.4a.8.8 0 0 1-.8-.8V3.5Z"/><path d="m9.3 12.2 1.5 1.5 3.4-3.4"/><line x1="9.3" y1="16.3" x2="14.7" y2="16.3"/></svg></span></span><span class="nav-label">Wochenbericht</span>
</button>
<button class="nav-item tab" data-tab="load" onclick="showTab('load')" title="Auslastung">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="21" x2="5" y2="10"/><line x1="12" y1="21" x2="12" y2="5"/><line x1="19" y1="21" x2="19" y2="14"/><line x1="3" y1="21" x2="21" y2="21"/></svg></span></span><span class="nav-label">Auslastung</span>
</button>
<!-- Abwesenheiten (js/abwesenheit/abwesenheit.js): sichtbar mit einem der
vier Abwesenheits-Rechte (data-recht mit "|" = eines davon genügt). -->
<button class="nav-item tab nur-recht" data-recht="team.abw|team.abwProjekte|team.abwGewerk|team.abwAlle" data-tab="abwesenheit" onclick="showTab('abwesenheit')" title="Abwesenheiten">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="3" x2="8" y2="6.5"/><line x1="16" y1="3" x2="16" y2="6.5"/><line x1="8.5" y1="13" x2="15.5" y2="17.5"/><line x1="15.5" y1="13" x2="8.5" y2="17.5"/></svg></span></span><span class="nav-label">Abwesenheiten</span>
</button>
<!-- "Projektbeteiligte" stand hier bis zur Entfernung der Abteilungs-
trennung (migrateRemoveDepartmentPartition(), CLAUDE.md
"Departments were retired"): eine rein lesende Liste, deren
eigentlicher Zweck — Beteiligte gezielt aus einer ANDEREN Abteilung
in die eigene Sitzung zu holen — mit der Zusammenlegung zu EINEM
gemeinsamen Personenkreis entfiel. Übrig blieb eine Dopplung der
Rollen-/Avatar-Chips, die in Wochenbericht/Board/Auslastung ohnehin
schon sichtbar sind. Auf Eriks Wunsch aus der Seitenleiste entfernt;
der Tab selbst (`#people`, `js/people/people.js`) und sein Slug
`Projektbeteiligte` in VALID_TAB_SLUGS bestehen unverändert fort —
ein alter Link/Deep-Link funktioniert also weiterhin, nur der
direkte Weg über die Navigation fehlt. -->
<!-- Projektbereich: der einzige Navigationspunkt, der nicht immer da
ist. Er erscheint, sobald ein Projekt geöffnet wurde, und trägt
dessen Bezeichnung (syncPlanNavItem() in js/tabs-auth.js). Ohne
ihn wäre im Projektbereich gar kein Punkt hervorgehoben und die
Seitenleiste zeigte nicht mehr, wo man gerade ist. -->
<button class="nav-item tab" data-tab="plan" id="planNavItem" style="display:none" onclick="showTab('plan')" title="Projektbereich">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="14" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="16" x2="16" y2="16"/></svg></span></span><span class="nav-label" id="planNavItemLabel">Projektbereich</span>
</button>
<!-- Planung: eigener, zweiter Abschnitt (nicht Teil von "Navigation"),
wie gewünscht — eine Sandbox für Terminpläne unabhängig von einem
echten Projekt (aus Honorar errechnet oder frei angelegt), siehe
CLAUDE.md "Planung — projektunabhängige Terminplan-Sandbox". -->
<div class="sidebar-section-label nur-recht" data-recht="wz.planung">Planung</div>
<button class="nav-item tab nur-recht" data-recht="wz.planung" data-tab="planung" onclick="showTab('planung')" title="Terminplan">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="17" rx="2.5"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="2.5" x2="8" y2="6.5"/><line x1="16" y1="2.5" x2="16" y2="6.5"/><line x1="7" y1="14" x2="12" y2="14"/><line x1="7" y1="17.5" x2="15" y2="17.5"/></svg></span></span><span class="nav-label">Terminplan</span>
</button>
</div>
<!-- Studenten: eigener, dritter Abschnitt (wie "Planung" nicht Teil
von "Navigation") — bislang nur ein Werkzeug, der Plan Checker:
lädt einen Bauplan als PDF hoch und lässt ihn von einer KI gegen
eine Liste von Merkmalen prüfen (Maßketten, Plankopf, ...),
siehe js/planchecker/planchecker.js. -->
<div class="sidebar-section-label nur-recht" data-recht="wz.checker">Studenten</div>
<div class="sidebar-gruppe">
<div class="sidebar-section-label">Team</div>
<button class="nav-item tab" data-tab="load" onclick="showTab('load')" title="Auslastung">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="21" x2="5" y2="10"/><line x1="12" y1="21" x2="12" y2="5"/><line x1="19" y1="21" x2="19" y2="14"/><line x1="3" y1="21" x2="21" y2="21"/></svg></span></span><span class="nav-label">Auslastung</span>
</button>
<button class="nav-item tab nur-recht" data-recht="team.abw|team.abwProjekte|team.abwGewerk|team.abwAlle" data-tab="abwesenheit" onclick="showTab('abwesenheit')" title="Abwesenheiten">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="3" x2="8" y2="6.5"/><line x1="16" y1="3" x2="16" y2="6.5"/><line x1="8.5" y1="13" x2="15.5" y2="17.5"/><line x1="15.5" y1="13" x2="8.5" y2="17.5"/></svg></span></span><span class="nav-label">Abwesenheiten</span>
</button>
</div>
<div class="sidebar-gruppe">
<div class="sidebar-section-label">Notizen</div>
<button class="nav-item tab" data-tab="notizen" onclick="showTab('notizen')" title="Notizen">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v12a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1Z"/><path d="M13.5 3.5V7a1 1 0 0 0 1 1h4"/><line x1="8.5" y1="12.5" x2="15.5" y2="12.5"/><line x1="8.5" y1="16" x2="13.5" y2="16"/></svg></span></span><span class="nav-label">Notizen</span>
</button>
</div>
<div class="sidebar-gruppe nur-recht" data-recht="wz.checker">
<div class="sidebar-section-label">Studenten</div>
<button class="nav-item tab nur-recht" data-recht="wz.checker" data-tab="planchecker" onclick="showTab('planchecker')" title="Plan Checker">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg></span></span><span class="nav-label">Plan Checker</span>
</button>
</div>
<hr class="sidebar-nav-divider">
<!-- Einstellungen: seit die firmenweiten Masken in den Admin-Bereich
gewandert sind, steht hier nur noch das eigene Konto — ein
Aufklapp-Menue fuer einen einzigen Punkt waere Ballast. -->
<div class="sidebar-gruppe sidebar-unten">
<button class="nav-item tab" data-tab="settings" onclick="showTab('settings')" title="Einstellungen">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M12 3.5v2.4M12 18.1v2.4M20.5 12h-2.4M5.9 12H3.5M17.7 6.3l-1.7 1.7M8 15.7l-1.7 1.7M17.7 17.7 16 16M8 8 6.3 6.3"/></svg></span></span><span class="nav-label">Einstellungen</span>
</button>
<!-- Der Bereich „Werkzeuge“ ist entfallen (Eriks Wunsch): „+ Neues
Projekt“ steht in der Projektliste („+ Projekt“), die Wandtafel
braucht nur das Wandtafel-Konto (landet dort automatisch, sonst
über /Wandtafel erreichbar). -->
<!-- Admin: aufklappbare Gruppe ueber die Abschnitte von #admin. Wie
bei "Einstellungen" zuvor ist das KEINE Aktionsliste, sondern
Navigation innerhalb eines Bereichs — deshalb fuehrt schon der
Kopf-Eintrag dorthin (toggleAdminNav()) und die Unterpunkte
schalten nur den sichtbaren Abschnitt um (showAdminSection() in
js/admin/admin.js). Die gesamte Gruppe blendet renderUserBar()
(js/auth.js) fuer Nicht-Administratoren aus; die eigentliche
Sperre sitzt serverseitig auf den Routen. -->
<div id="navAdminSection" style="display:none">
<button class="nav-item nav-group-toggle" id="adminAccordionToggle" onclick="toggleAdminNav()" title="Admin" aria-expanded="false" aria-controls="adminSubnav">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 5.5 6v5.3c0 4.4 2.9 7.4 6.5 8.7 3.6-1.3 6.5-4.3 6.5-8.7V6Z"/><path d="m9 12 2.1 2.1L15.5 9.5"/></svg></span></span><span class="nav-label">Admin</span>
<span class="nav-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="sidebar-subnav" id="adminSubnav">
<div class="sidebar-subgruppe">Personen</div>
<button class="menu-item sidebar-subitem" data-admin-section="benutzer" onclick="showAdminSection('benutzer')">Benutzerverwaltung</button>
<button class="menu-item sidebar-subitem" data-admin-section="rollen" onclick="showAdminSection('rollen')">Rollen &amp; Rechte</button>
<div class="sidebar-subgruppe">Vorgaben</div>
<button class="menu-item sidebar-subitem" data-admin-section="honorar" onclick="showAdminSection('honorar')">Honorar</button>
<button class="menu-item sidebar-subitem" data-admin-section="auslastung" onclick="showAdminSection('auslastung')">Auslastung</button>
<button class="menu-item sidebar-subitem" data-admin-section="termine" onclick="showAdminSection('termine')">Termine &amp; Sitzungen</button>
<button class="menu-item sidebar-subitem" data-admin-section="feiertage" onclick="showAdminSection('feiertage')">Feiertage</button>
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
<div class="sidebar-subgruppe">System</div>
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb &amp; Sicherheit</button>
<button class="menu-item sidebar-subitem" data-admin-section="teams" onclick="showAdminSection('teams')">Teams</button>
<button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button>
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button>
</div>
</div>
</div>
</nav>
<button class="sidebar-collapse-bar" id="sidebarCollapseToggle" onclick="toggleSidebarCollapse()" title="Seitenleiste ein-/ausklappen" aria-pressed="false">
@@ -299,6 +270,9 @@
<!-- LOAD -->
<!-- ABWESENHEITEN (js/abwesenheit/abwesenheit.js) -->
<div id="abwesenheit" style="display:none"></div>
<!-- NOTIZEN (js/notizen/notizen.js): Notizen aller eigenen Projekte und
projektunabhängige Notizen -->
<div id="notizen" style="display:none"></div>
<div id="load" style="display:none">
<div class="section-title">Auslastung der Projektbeteiligten</div>
@@ -892,7 +866,7 @@
<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="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="leistung" onclick="showPlanSection('leistung')">Leistung</button>
<button class="plan-nav-item" data-section="dateien" onclick="showPlanSection('dateien')">Dateien</button>
@@ -1041,17 +1015,12 @@
</section>
<!-- ====================== Bereich: Aufgaben ===================== -->
<!-- ================== Bereich: Leistungsphasen ================== -->
<!-- Eriks Wahl "B": die Phasenbalken des Terminplans als eigener
Bereich — Ablaufbalken über alle Phasen, darunter eine Kachel je
Phase (renderPlanPhasen(), js/plan/plan-phasen.js). In der
Aufgabenliste stehen sie dafür nicht mehr. -->
<section class="plan-section" id="planSection-phasen" data-section="phasen">
<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>
<!-- ======================= Bereich: Notizen ======================= -->
<!-- Eriks Wahl „A – Liste + Editor“: gemeinsame Notizen je Projekt
(renderPlanNotizen(), js/plan/plan-notizen.js; Server:
/projekt-notizen*). Ersetzt den früheren Reiter „Leistungsphasen“. -->
<section class="plan-section" id="planSection-notizen" data-section="notizen">
<div id="planNotizenWrap"></div>
</section>
<section class="plan-section" id="planSection-aufgaben" data-section="aufgaben">
@@ -1984,21 +1953,22 @@
</main>
</div>
<script src="/js/globals.js?v=9"></script>
<script src="/js/utils.js?v=31"></script>
<script src="/js/globals.js?v=10"></script>
<script src="/js/utils.js?v=33"></script>
<script src="/js/icons.js?v=2"></script>
<script src="/js/ui.js?v=9"></script>
<script src="/js/date-picker.js?v=6"></script>
<script src="/js/server.js?v=7"></script>
<script src="/js/auth.js?v=11"></script>
<script src="/js/store.js?v=15"></script>
<script src="/js/store.js?v=17"></script>
<script src="/js/weeks.js?v=12"></script>
<script src="/js/board/board.js?v=41"></script>
<script src="/js/board/board.js?v=42"></script>
<script src="/js/wochenbericht/wochenbericht.js?v=29"></script>
<script src="/js/people/people.js?v=8"></script>
<script src="/js/planung/planung.js?v=3"></script>
<script src="/js/planchecker/planchecker.js?v=1"></script>
<script src="/js/abwesenheit/abwesenheit.js?v=10"></script>
<script src="/js/notizen/notizen.js?v=3"></script>
<script src="/js/projects/projects.js?v=10"></script>
<script src="/js/load/load.js?v=23"></script>
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
@@ -2007,7 +1977,7 @@
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
load.js (faktorFormat), steht deshalb dahinter. -->
<script src="/js/projekt-wizard.js?v=6"></script>
<script src="/js/dashboard/dashboard.js?v=10"></script>
<script src="/js/dashboard/dashboard.js?v=13"></script>
<script src="/js/benachrichtigungen.js?v=5"></script>
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
@@ -2017,10 +1987,10 @@
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
<script src="/js/wand/wand.js?v=21"></script>
<script src="/js/settings/settings.js?v=14"></script>
<script src="/js/admin/admin.js?v=22"></script>
<script src="/js/admin/admin.js?v=24"></script>
<script src="/js/admin-users.js?v=10"></script>
<script src="/js/admin/rollen.js?v=1"></script>
<script src="/js/einfuehrung.js?v=2"></script>
<script src="/js/einfuehrung.js?v=3"></script>
<script src="/js/gantt.js?v=10"></script>
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist,
@@ -2029,29 +1999,30 @@
(das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach
js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js
umhuellt. -->
<script src="/js/plan/plan-state.js?v=24"></script>
<script src="/js/plan/plan-sections.js?v=10"></script>
<script src="/js/plan/plan-info.js?v=15"></script>
<script src="/js/plan/plan-state.js?v=25"></script>
<script src="/js/plan/plan-sections.js?v=12"></script>
<script src="/js/plan/plan-info.js?v=17"></script>
<script src="/js/plan/plan-history.js?v=4"></script>
<script src="/js/plan/plan-build-gantt.js?v=11"></script>
<script src="/js/plan/plan-gantt.js?v=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-modals.js?v=18"></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-protokoll.js?v=20"></script>
<script src="/js/plan/plan-protokoll.js?v=21"></script>
<script src="/js/plan/plan-templates.js?v=11"></script>
<script src="/js/plan/plan-protokoll-todos.js?v=2"></script>
<script src="/js/plan/plan-protokoll-todos.js?v=3"></script>
<script src="/js/plan/plan-mail-eintrag.js?v=4"></script>
<script src="/js/plan/plan-notizen.js?v=2"></script>
<script src="/js/plan/plan-honorar.js?v=9"></script>
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen
Zwischenspeicher verwirft. -->
<script src="/js/plan/plan-leistung.js?v=1"></script>
<script src="/js/plan/plan-settings.js?v=14"></script>
<script src="/js/plan/plan-schedule.js?v=6"></script>
<script src="/js/tabs-auth.js?v=17"></script>
<script src="/js/plan/plan-schedule.js?v=7"></script>
<script src="/js/tabs-auth.js?v=19"></script>
<script src="/js/pdf.js?v=31"></script>
<script src="/js/backup.js?v=2"></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)
---------------------------------------------------------------------------
Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES
@@ -27,107 +30,105 @@
/* ---------- Tokens: hell ---------- */
html.glas {
--bg: #e8eef3;
--surface: rgba(255,255,255,.58);
--surface-alt: rgba(255,255,255,.40);
--surface-sunk: rgba(15,90,133,.07);
--line: rgba(20,45,65,.16);
--line-soft: rgba(20,45,65,.09);
--bg: #eef1f4;
--surface: rgba(255,255,255,.82);
--surface-alt: rgba(255,255,255,.66);
--surface-sunk: rgba(16,24,32,.05);
--line: rgba(16,24,32,.13);
--line-soft: rgba(16,24,32,.07);
--accent: #0f5a85;
--accent-hover: #0d5079;
--accent-active: #0a4565;
--accent-soft: rgba(15,90,133,.11);
--accent-soft: rgba(15,90,133,.09);
--band-neu: #0f5a85;
--text: #14212b;
--text-muted: #4b5d6b;
--text-subtle: #6f8291;
--text: #16191d;
--text-muted: #4d545c;
--text-subtle: #69707a;
--r-md: 10px;
--r-lg: 18px;
--r-xl: 22px;
--r-md: 8px;
--r-lg: 14px;
--r-xl: 16px;
--sh-1: 0 8px 28px rgba(21,58,86,.10), 0 1px 4px rgba(21,58,86,.06);
--sh-2: 0 12px 34px rgba(21,58,86,.13), 0 2px 6px rgba(21,58,86,.07);
--sh-3: 0 20px 60px rgba(21,58,86,.20), 0 4px 14px rgba(21,58,86,.10);
--sh-1: 0 1px 2px rgba(16,24,32,.05);
--sh-2: 0 4px 16px rgba(16,24,32,.07), 0 1px 2px rgba(16,24,32,.04);
--sh-3: 0 12px 32px rgba(16,24,32,.12), 0 2px 6px rgba(16,24,32,.06);
--glas-rahmen: rgba(255,255,255,.62);
--glas-dicht: rgba(255,255,255,.86);
--glas-rahmen: rgba(255,255,255,.84);
--glas-dicht: rgba(255,255,255,.94);
--glas-deckend: #f7fafc;
--glas-kante: rgba(255,255,255,.80);
--glas-kante-2: rgba(255,255,255,.35);
--glas-blur: 24px;
--glas-kante: rgba(255,255,255,.70);
--glas-kante-2: rgba(255,255,255,.25);
--glas-blur: 16px;
--glas-hell: #94b0cc;
--glas-tief: #116896;
--glas-logo: .16;
--glas-logo: .07;
}
/* ---------- Tokens: dunkel (System und manuell) ---------- */
@media (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) {
--bg: #0b141b;
--surface: rgba(30,46,60,.50);
--surface-alt: rgba(30,46,60,.34);
--surface-sunk: rgba(255,255,255,.06);
--line: rgba(255,255,255,.14);
--line-soft: rgba(255,255,255,.08);
--accent: #8cc9e6;
--accent-hover: #a3d4ec;
--accent-active: #6fb3d3;
--accent-soft: rgba(140,201,230,.15);
--bg: #111315;
--surface: rgba(26,29,33,.80);
--surface-alt: rgba(26,29,33,.62);
--surface-sunk: rgba(255,255,255,.05);
--line: rgba(236,238,240,.13);
--line-soft: rgba(236,238,240,.07);
--accent: #86bfdf;
--accent-hover: #9dcbe5;
--accent-active: #6aa9cc;
--accent-soft: rgba(134,191,223,.13);
--band-neu: #116896;
--text: #e8f0f5;
--text-muted: #a9bccb;
--text-subtle: #7790a2;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58);
--glas-dicht: rgba(22,34,44,.90);
--glas-deckend: #15212b;
--glas-kante: rgba(255,255,255,.18);
--glas-kante-2: rgba(255,255,255,.07);
--text: #eceef0;
--text-muted: #a5abb2;
--text-subtle: #878d95;
--sh-1: 0 1px 2px rgba(0,0,0,.4);
--sh-2: 0 4px 16px rgba(0,0,0,.45);
--sh-3: 0 16px 40px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
--glas-rahmen: rgba(24,27,30,.84);
--glas-dicht: rgba(22,24,27,.95);
--glas-deckend: #181a1d;
--glas-kante: rgba(255,255,255,.12);
--glas-kante-2: rgba(255,255,255,.05);
--glas-hell: #2c5675;
--glas-tief: #1d6f99;
--glas-logo: .10;
--glas-logo: .06;
}
}
html.glas[data-theme="dark"] {
--bg: #0b141b;
--surface: rgba(30,46,60,.50);
--surface-alt: rgba(30,46,60,.34);
--surface-sunk: rgba(255,255,255,.06);
--line: rgba(255,255,255,.14);
--line-soft: rgba(255,255,255,.08);
--accent: #8cc9e6;
--accent-hover: #a3d4ec;
--accent-active: #6fb3d3;
--accent-soft: rgba(140,201,230,.15);
--bg: #111315;
--surface: rgba(26,29,33,.80);
--surface-alt: rgba(26,29,33,.62);
--surface-sunk: rgba(255,255,255,.05);
--line: rgba(236,238,240,.13);
--line-soft: rgba(236,238,240,.07);
--accent: #86bfdf;
--accent-hover: #9dcbe5;
--accent-active: #6aa9cc;
--accent-soft: rgba(134,191,223,.13);
--band-neu: #116896;
--text: #e8f0f5;
--text-muted: #a9bccb;
--text-subtle: #7790a2;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58);
--glas-dicht: rgba(22,34,44,.90);
--glas-deckend: #15212b;
--glas-kante: rgba(255,255,255,.18);
--glas-kante-2: rgba(255,255,255,.07);
--text: #eceef0;
--text-muted: #a5abb2;
--text-subtle: #878d95;
--sh-1: 0 1px 2px rgba(0,0,0,.4);
--sh-2: 0 4px 16px rgba(0,0,0,.45);
--sh-3: 0 16px 40px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
--glas-rahmen: rgba(24,27,30,.84);
--glas-dicht: rgba(22,24,27,.95);
--glas-deckend: #181a1d;
--glas-kante: rgba(255,255,255,.12);
--glas-kante-2: rgba(255,255,255,.05);
--glas-hell: #2c5675;
--glas-tief: #1d6f99;
--glas-logo: .10;
--glas-logo: .06;
}
/* ---------- Hintergrund: Lichtflächen und Logo ---------- */
html.glas body {
background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
radial-gradient(40% 40% at 28% 82%, color-mix(in srgb, var(--glas-hell) 42%, transparent), transparent 70%),
radial-gradient(60% 55% at 8% 4%, color-mix(in srgb, var(--glas-hell) 26%, transparent), transparent 70%),
radial-gradient(50% 50% at 92% 12%, color-mix(in srgb, var(--glas-tief) 9%, transparent), transparent 70%),
var(--bg);
background-attachment: fixed;
}
@@ -157,6 +158,7 @@ html.glas .abw-team-kopf,
html.glas .report-card,
html.glas .load-card,
html.glas .settings-card,
html.glas .mn-notizen,
html.glas .dash-karte,
html.glas .dash-band,
html.glas .bericht-panel,
@@ -181,8 +183,8 @@ html.glas .plan-toast,
html.glas .glocke-menue,
html.glas .wiz {
background: var(--surface);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%);
backdrop-filter: blur(var(--glas-blur)) saturate(170%);
-webkit-backdrop-filter: blur(var(--glas-blur));
backdrop-filter: blur(var(--glas-blur));
border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-1), inset 0 1px 0 var(--glas-kante), inset 0 -1px 0 var(--glas-kante-2);
}
@@ -191,7 +193,7 @@ html.glas .wiz {
html.glas .app-topbar {
background: var(--glas-rahmen);
border-width: 0 0 1px 0;
box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(21,58,86,.06);
box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(16,24,32,.06);
}
html.glas .topbar-logo { height: 26px; }
@@ -248,11 +250,11 @@ html.glas .modal-bg {
margin: 12px 12px 0;
border-radius: var(--r-xl);
background: var(--glas-rahmen);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%);
backdrop-filter: blur(var(--glas-blur)) saturate(170%);
-webkit-backdrop-filter: blur(var(--glas-blur));
backdrop-filter: blur(var(--glas-blur));
border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
padding: 4px 12px 10px;
padding: 4px 12px 12px;
}
/* Kopf und Reiter sind innerhalb der Karte keine eigenen Flächen mehr. */
html.glas .plan-kopf .plan-header,
@@ -265,19 +267,25 @@ html.glas .modal-bg {
}
/* Inhalt bündig unter der Kopfkarte und im selben 12px-Abstand. */
html.glas .plan-page .plan-main-content { padding-left: 12px; padding-right: 12px; padding-top: 12px; }
html.glas .plan-kopf .plan-header { padding: 10px 12px 6px; }
html.glas .plan-kopf .plan-header { padding: 12px 12px 8px; }
html.glas .plan-kopf .plan-nav { padding: 0; gap: 2px; }
/* Reiter wie die Einträge der Seitenleiste (html.glas .nav-item.active):
32 px, helle Fläche mit Haarlinie, Schrift dunkel statt Akzentblau. */
html.glas .plan-kopf .plan-nav-item {
border-bottom: 0;
border-radius: 999px;
padding: 8px 16px;
font-size: 13.5px;
border-radius: 8px;
height: 32px;
padding: 0 12px;
font-size: 13px;
}
html.glas .plan-kopf .plan-nav-item:hover { background: var(--surface-sunk); }
/* Der Kopf ist selbst eine helle Karte — eine weiße Kapsel wie in der
(grau durchscheinenden) Seitenleiste verschwände darauf. Deshalb hier
die umgekehrte Stufe: etwas dunkler als die Karte, mit Haarlinie. */
html.glas .plan-kopf .plan-nav-item.is-active {
background: var(--glas-dicht);
color: var(--accent);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10);
background: rgba(16,24,32,.07);
color: var(--text);
box-shadow: inset 0 0 0 1px rgba(16,24,32,.08);
}
}
@media (min-width: 769px) and (prefers-color-scheme: dark) {
@@ -293,13 +301,13 @@ html.glas .modal-bg {
Grau, auf allen anderen Seiten den Glas-Hintergrund. */
html.glas .plan-main { background: transparent; }
html.glas .nav-item { border-radius: 999px; }
html.glas .nav-item { border-radius: 8px; }
html.glas .nav-item:hover,
html.glas .nav-item:visited:hover { background: var(--surface-sunk); }
html.glas .nav-item.active {
background: var(--glas-dicht);
color: var(--text);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10);
box-shadow: 0 0 0 1px var(--line-soft);
}
html.glas .nav-item.active .nav-icon { color: var(--accent); }
/* Die bestehenden Dunkel-Regeln für die Navigation setzen feste Werte —
@@ -373,9 +381,8 @@ html.glas .gantt-row.gantt-days .gantt-label { background: var(--gantt-label-gru
die (noch leere) App-Oberfläche, die nicht durchscheinen darf. */
html.glas .login-seite {
background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
radial-gradient(60% 55% at 8% 4%, color-mix(in srgb, var(--glas-hell) 26%, transparent), transparent 70%),
radial-gradient(50% 50% at 92% 12%, color-mix(in srgb, var(--glas-tief) 9%, transparent), transparent 70%),
var(--bg);
}
+62 -11
View File
@@ -76,6 +76,13 @@ function adminNavAbgleichen(){
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
if(item) item.hidden = !adminAbschnittErlaubt(sec);
});
// Zwischenüberschriften (Personen/Vorgaben/System) nur, wenn darunter
// mindestens ein erlaubter Punkt steht.
document.querySelectorAll('#adminSubnav .sidebar-subgruppe').forEach(kopf => {
let el = kopf.nextElementSibling, sichtbar = false;
while(el && !el.classList.contains('sidebar-subgruppe')){ if(!el.hidden) sichtbar = true; el = el.nextElementSibling; }
kopf.hidden = !sichtbar;
});
}
// adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen?
@@ -1113,22 +1120,66 @@ async function loadAdminTeams(){
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="settings-kv-row"><span class="settings-kv-key">Status</span>
<span>${d.konfiguriert ? '<span class="sitzung-chip ist-geoeffnet">Eingerichtet</span> <span class="hint" style="margin:0">' + esc(d.host) + '</span>'
: '<span class="sitzung-chip ist-gesperrt">Nicht eingerichtet</span>'}</span></div>
<label class="settings-field"><span>${d.konfiguriert ? 'Neue Adresse (ersetzt die bisherige)' : 'Adresse aus dem Teams-Workflow'}</span>
<input type="password" id="teamsWebhook" placeholder="https://…" autocomplete="off"></label>
<label class="settings-glas-schalter"><input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''}
onchange="teamsSpeichern({ neueAufgaben: this.checked })"> Zuständige über neue Aufgaben benachrichtigen</label>
<div class="kalender-aktionen" style="margin-top:10px">
<button type="button" onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Adresse speichern</button>
<button type="button" class="secondary" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht senden</button>
${d.konfiguriert ? '<button type="button" class="secondary" onclick="teamsEntfernen()">Entfernen</button>' : ''}
<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', {
+1 -1
View File
@@ -748,7 +748,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
function renderKommentarAnsicht(p, locked){
const text = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || '');
const text = kommentarAnzeigeHtml(p, kommentarGewerk(), true);
return `<div class="status-edit is-ansicht locked-cell">${text ||
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
}
+157 -66
View File
@@ -55,8 +55,6 @@ const ABSENCE_TYPE_CLASS = {
// renderDashboard()-Aufruf neu gestartet (vorheriges Intervall zuerst
// gestoppt), damit beim wiederholten Tab-Wechsel nicht mehrere Intervalle
// parallel laufen.
let dashboardClockTimer = null;
// renderDashboard: lädt GET /my-dashboard und rendert Begrüßung/Uhr,
// Kennzahlen sowie die drei Bereiche in #dashboardContent. Wird nur beim
// Wechsel auf den Tab aufgerufen (showTab()), nicht beim initialen
@@ -66,6 +64,9 @@ async function renderDashboard(){
if(!container) return;
container.innerHTML = '<div class="hint">Lade Dashboard…</div>';
// Notizen je Projekt (Anzahl) parallel — fällt sie aus, fehlt nur die Angabe.
const notizenAnfrage = fetch(baseUrl() + '/projekt-notizen/anzahl')
.then(r => r.ok ? r.json() : null).catch(() => null);
let data;
try {
const res = await fetch(baseUrl() + '/my-dashboard');
@@ -76,6 +77,8 @@ async function renderDashboard(){
return;
}
const nz = await notizenAnfrage;
dashNotizen = (nz && nz.ok && nz.anzahl) || {};
const weekLabel = data.weekLabel || null;
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
const projectDates = Array.isArray(data.projectDates) ? data.projectDates : [];
@@ -103,12 +106,15 @@ async function renderDashboard(){
const heuteFaellig = tasks.filter(t => t.end === heute).length
+ projectDates.filter(d => d.heute).length;
const daten = dashDaten();
const sonntag = formatDateISO(new Date(dashWochenMontag().getTime() + 6 * 86400000));
const dieseWoche = daten.eintraege.filter(e => e.datum >= heute && e.datum <= sonntag).length;
let html = '';
html += renderDashboardKopf(weekLabel);
html += renderDashboardBand({
ueberfaellig, heuteFaellig,
ueberfaellig, heuteFaellig, dieseWoche,
aufgaben: tasks.length,
termine: projectDates.length,
freigaben: pending.length,
abwesend: absencesToday,
});
@@ -120,10 +126,10 @@ async function renderDashboard(){
// Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen,
// weil es das ist, was sich seit dem letzten Blick geändert hat.
html += '<div id="dashNeu"></div>';
html += renderDashWoche(daten);
html += '<div class="dash-zonen">';
// Linke Zone: die eigenen Projekte mit ihren Terminen
const daten = dashDaten();
html += '<div>';
const zuAlle = dashZuLesen();
const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p)));
@@ -153,7 +159,6 @@ async function renderDashboard(){
if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); }
dashKalDaten = daten;
renderDashKalender();
startDashboardClock();
}
// ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ----------
@@ -164,6 +169,8 @@ async function renderDashboard(){
let dashKalDaten = null;
let dashKalMonat = null; // { jahr, monat } (monat 0–11)
let dashKalTag = null; // ISO-Datum
// dashNotizen: { project_uid: {anzahl, zuletzt} } aus GET /projekt-notizen/anzahl.
let dashNotizen = {};
// dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte
// des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu,
@@ -253,6 +260,23 @@ function dashProjektUrl(p, abschnitt){
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,
// Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst
// 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 istZu = zu.has(key);
const rollen = x.rollen.length
? x.rollen.map(r => '<span class="dash-rolle' + (r.state === 2 ? ' ist-neben' : '') + '" title="'
+ (r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung') + '">' + esc(r.rolle) + '</span>').join('')
: '<span class="dash-rolle ist-neben" title="Nicht zugeordnet, aber mit eigener Aufgabe">nur Aufgabe</span>';
? x.rollen.map(r => esc(r.rolle) + (r.state === 2 ? ', Unterstützung' : '')).join(' / ')
: 'nur eigene Aufgabe';
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
const pct = aktLp && p.lph && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? Math.max(0, Math.min(100, Number(p.lph[aktLp]) || 0)) : null;
const phase = aktLp
@@ -441,12 +464,12 @@ function renderDashProjekte(daten){
? sichtbar.map(t => renderDashTermin(p, t)).join('')
: '<div class="dash-leer">Keine offenen Termine.</div>';
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
// Termine überfällig bzw. offen sind.
const kurz = (ueber.length ? '<span class="dash-proj-zahl ist-rot">' + ueber.length + ' überfällig</span>' : '')
+ '<span class="dash-proj-zahl">' + kommend.length + ' offen</span>';
+ '<span class="dash-proj-zahl' + (kommend.length ? ' ist-offen' : '') + '">' + kommend.length + ' offen</span>';
return '<div class="dash-karte dash-proj' + (istZu ? ' ist-zu' : '') + '" data-key="' + esc(key) + '">'
+ '<div class="dash-proj-kopf">'
+ (daten.projekte.length > 1 ? '<span class="dash-proj-griff" tabindex="0" role="button" title="Ziehen, um die Reihenfolge zu ändern (oder Pfeiltasten)" aria-label="Reihenfolge ändern" onmousedown="dashGriffDruck(this)" ontouchstart="dashGriffDruck(this)" onkeydown="dashGriffTaste(event, this)">' + icon('grip') + '</span>' : '')
@@ -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>'
+ '</button>'
+ '<span class="dash-proj-kurz">' + kurz + '</span>'
+ '<span class="dash-rollen">' + rollen + '</span>'
+ '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" target="_blank" rel="noopener" title="Projekt öffnen">Öffnen</a>'
+ '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" onclick="return dashImTabOeffnen(event, this)" title="Projekt öffnen">Öffnen</a>'
+ '</div>'
+ '<div class="dash-proj-inhalt">'
+ renderDashProjektInfo(x, rollen)
+ '<div class="dash-proj-sub">' + phase + '</div>'
+ '<div class="dash-proj-termine">' + zeilen + mehr + '</div>'
+ '</div>'
@@ -466,6 +489,103 @@ function renderDashProjekte(daten){
}).join('') + '</div>';
}
// renderDashWoche: Band „Diese Woche“ (Entwurf „Bauplan“): Mo bis Fr als
// Spalten mit den Terminen der eigenen Projekte an diesem Tag — eigene
// Aufgaben, Aufgaben im Projekt, Meilensteine, Überfälliges. Je Tag höchstens
// DASH_WOCHE_JE_TAG, der Rest als „+ N weitere“. Parameter: daten (dashDaten()).
const DASH_WOCHE_JE_TAG = 4;
function renderDashWoche(daten){
if(!daten.geladen || !daten.projekte.length) return '';
const heute = heuteIso();
const mo = dashWochenMontag();
const TAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr'];
let spalten = '';
for(let i = 0; i < 5; i++){
const d = new Date(mo); d.setDate(mo.getDate() + i);
const iso = formatDateISO(d);
const e = daten.eintraege.filter(x => x.datum === iso)
.sort((a, b) => (b.meins ? 1 : 0) - (a.meins ? 1 : 0));
const sicht = e.slice(0, DASH_WOCHE_JE_TAG);
const rest = e.length - sicht.length;
spalten += '<div class="dash-woche-tag' + (iso === heute ? ' ist-heute' : '') + '">'
+ '<div class="dash-woche-kopf"><span>' + TAGE[i] + '</span><span class="dash-woche-datum">' + d.getDate() + '.' + (d.getMonth() + 1) + '.</span></div>'
+ '<div class="dash-woche-liste">'
+ (sicht.map(x => {
const art = x.art === 'meilenstein' ? 'ist-ms' : (x.datum < heute ? 'ist-ueber' : (x.meins ? 'ist-meins' : 'ist-fremd'));
return '<a class="dash-woche-eintrag ' + art + '" href="' + dashProjektUrl(x.projekt, x.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" onclick="return dashImTabOeffnen(event, this)"'
+ ' title="' + esc(x.titel + ' (' + [x.projekt.nr, x.projekt.name].filter(Boolean).join(' ') + ')') + '">'
+ '<span class="dash-woche-titel">' + esc(x.titel) + '</span>'
+ '<span class="dash-woche-projekt">' + esc([x.projekt.nr, x.projekt.name].filter(Boolean).join(' ')) + '</span></a>';
}).join('') || '<span class="dash-woche-leer">keine Termine</span>')
+ (rest > 0 ? '<span class="dash-woche-mehr">+ ' + rest + ' weitere</span>' : '')
+ '</div></div>';
}
const legende = '<div class="dash-woche-legende">'
+ '<span><i class="ist-meins"></i>meine Aufgabe</span><span><i class="ist-fremd"></i>Aufgabe im Projekt</span>'
+ '<span><i class="ist-ms"></i>Meilenstein</span><span><i class="ist-ueber"></i>überfällig</span></div>';
return dashAbschnitt('Diese Woche', null, 'eigene Aufgaben und Termine meiner Projekte',
'<div class="dash-karte dash-woche"><div class="dash-woche-rahmen"><div class="dash-woche-raster">' + spalten + '</div></div>' + legende + '</div>');
}
// renderDashProjektInfo: Zeile „Meine Rolle | Team | Notizen“ im Projektblock
// (Eriks Wunsch): die eigene Rolle, die übrigen Beteiligten als Kürzel-Kreise
// in den Rollenfarben (wie Projektliste, beteiligtenAvatarHtml()) und — nur
// wenn vorhanden — die Zahl der Notizen mit Sprung in den Reiter „Notizen“.
// Parameter: x (Eintrag aus dashDaten()), rollenText. Rückgabe: HTML-String.
const DASH_TEAM_MAX = 5;
function renderDashProjektInfo(x, rollenText){
const p = x.p;
const w = currentWeek();
const ich = currentUser && currentUser.personId;
const personen = {};
((w && w.people) || []).forEach(q => { personen[q.id] = q; });
const eintraege = [];
Object.keys(p.assign || {}).forEach(key => {
const [pid, role] = key.split('|');
const state = Number(p.assign[key]) || 0;
if(state > 0) eintraege.push({ person: personen[pid] || { id: pid, name: pid, fullName: pid }, role, state });
});
const alle = beteiligteJePerson(eintraege);
const ichB = alle.find(b => b.person.id === ich);
const team = alle.filter(b => b.person.id !== ich);
const kreis = b => beteiligtenAvatarHtml(b, typeof roleClass === 'function' ? roleClass(b.role) : '');
const kurzName = b => {
const teile = String(b.person.fullName || b.person.name || '').trim().split(/\s+/);
return teile.length > 1 ? teile[0] + ' ' + teile[teile.length - 1][0] + '.' : teile[0];
};
const namen = team.length > 2
? team.slice(0, 2).map(kurzName).join(', ') + ' +' + (team.length - 2)
: team.map(kurzName).join(', ');
let html = '<div class="dash-proj-info">'
+ '<div class="dash-proj-feld"><span class="dash-proj-feld-titel">Meine Rolle</span>'
+ '<span class="dash-proj-feld-wert">' + (ichB ? kreis(ichB) : '') + '<span>' + rollenText + '</span></span></div>'
+ '<div class="dash-proj-feld"><span class="dash-proj-feld-titel">Team</span>'
+ '<span class="dash-proj-feld-wert">'
+ (team.length
? '<span class="dash-proj-team">' + team.slice(0, DASH_TEAM_MAX).map(kreis).join('') + '</span><span class="dash-proj-namen" title="' + esc(team.map(b => b.title).join('\n')) + '">' + esc(namen) + '</span>'
: '<span class="dash-proj-namen">niemand weiter</span>')
+ '</span></div>';
const n = dashNotizen[p.projectUid];
if(n && n.anzahl > 0){
html += '<a class="dash-proj-feld dash-proj-notizen" href="' + dashProjektUrl(p, 'notizen') + '" onclick="return dashImTabOeffnen(event, this)">'
+ '<span class="dash-proj-feld-titel">Notizen</span>'
+ '<span class="dash-proj-feld-wert">' + icon('fileText') + '<span><b>' + n.anzahl + '</b> ' + (n.anzahl === 1 ? 'Notiz' : 'Notizen') + '</span>'
+ (n.zuletzt ? '<span class="dash-proj-zuletzt">' + esc(dashZuletzt(n.zuletzt)) + '</span>' : '') + '</span></a>';
}
return html + '</div>';
}
// dashZuletzt: „zuletzt heute/gestern“ oder „vom 28.10.“ zu einem Zeitstempel.
function dashZuletzt(zeit){
const d = new Date(zeit);
if(isNaN(d)) return '';
const tage = -tageBis(formatDateISO(d));
if(tage <= 0) return 'zuletzt heute';
if(tage === 1) return 'zuletzt gestern';
return 'vom ' + d.getDate() + '.' + (d.getMonth() + 1) + '.';
}
// renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste
// des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe,
// fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute.
@@ -480,7 +600,7 @@ function renderDashTermin(p, t, mitProjekt){
const unter = mitProjekt
? (p.nr ? p.nr + ' ' : '') + (p.name || '')
: (t.art === 'meilenstein' ? 'Meilenstein' : (t.meins ? 'meine Aufgabe' : (t.wer.length ? t.wer.join(', ') : 'niemand zugewiesen')));
return '<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-text"><span class="dash-termin-titel">' + esc(t.titel) + '</span>'
+ '<span class="dash-termin-meta">' + esc(unter) + '</span></span>'
@@ -576,22 +696,30 @@ function dashKalBlaettern(n){
renderDashKalender();
}
// renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein
// rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist
// die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand.
// Die Inhalte füllt startDashboardClock() live nach.
// Parameter: weekLabel. Rückgabe: HTML-String.
// renderDashboardKopf: Titel und Kalenderwoche statt Begrüßung (Entwurf
// „Bauplan“, Eriks Wahl): „KW 45, Mo 2. bis Fr 6. November 2026“, rechts die
// Sitzung. Parameter: weekLabel. Rückgabe: HTML-String.
function renderDashboardKopf(weekLabel){
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
const mo = dashWochenMontag();
const fr = new Date(mo); fr.setDate(mo.getDate() + 4);
const zeile = 'KW ' + getWeekNumber(mo) + ', Mo ' + mo.getDate() + '.'
+ (mo.getMonth() !== fr.getMonth() ? ' ' + MONATE[mo.getMonth()] : '')
+ ' bis Fr ' + fr.getDate() + '. ' + MONATE[fr.getMonth()] + ' ' + fr.getFullYear();
return '<div class="dash-kopf">'
+ '<p class="dash-gruss"><span id="dashboardGreetingText"></span>'
+ '<em id="dashboardGreetingDate"></em></p>'
+ '<div class="dash-kopf-rechts">'
+ (weekLabel ? '<span class="dash-sitzung">' + esc(weekLabel) + '</span>' : '')
+ '<span class="dash-uhr" id="dashboardClock"></span>'
+ '</div>'
+ '<div class="dash-kopf-links"><span class="dash-kopf-kw">' + esc(zeile) + '</span>'
+ '<h2 class="dash-titel">Meine Aufgaben</h2></div>'
+ (weekLabel ? '<div class="dash-kopf-rechts"><span class="dash-kopf-label">Sitzung</span><span class="dash-sitzung">' + esc(weekLabel) + '</span></div>' : '')
+ '</div>';
}
// dashWochenMontag: Montag der laufenden Woche (lokale Zeit, 0 Uhr).
function dashWochenMontag(){
const d = parseDateISO(heuteIso());
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
return d;
}
// renderDashboardBand: der Befund des Tages als Satz, daneben die Kennzahlen.
//
// Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine
@@ -617,17 +745,17 @@ function renderDashboardBand(z){
satz = '<b class="ist-ruhig">Nichts offen, nichts überfällig.</b>';
}
const zahl = (wert, label) =>
'<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + '"><b>' + wert + '</b><span>' + label + '</span></div>';
const zahl = (wert, label, rot) =>
'<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + (rot ? ' ist-rot' : '') + '"><span>' + label + '</span><b>' + wert + '</b></div>';
const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0;
return '<div class="dash-band' + (ruhig ? ' ist-ruhig' : '') + '">'
+ '<p class="dash-befund">' + satz + '</p>'
+ '<div class="dash-band-zahlen">'
+ zahl(z.aufgaben, 'offene Aufgaben')
+ zahl(z.termine, 'Termine im Blick')
+ zahl(z.ueberfaellig, 'überfällig', z.ueberfaellig > 0)
+ zahl(z.dieseWoche, 'diese Woche fällig')
+ zahl(z.freigaben, 'Freigaben')
+ zahl(z.abwesend, 'heute abwesend')
+ '</div></div>';
}
@@ -649,43 +777,6 @@ function dashLeer(text){
return '<div class="dash-karte"><div class="dash-leer">' + esc(text) + '</div></div>';
}
// greetingForHour:// greetingForHour: liefert je Tageszeit eine passende Begrüßung. Parameter:
// hour (Number, 0-23). Rückgabe: String.
function greetingForHour(hour){
if(hour < 5) return 'Gute Nacht';
if(hour < 11) return 'Guten Morgen';
if(hour < 18) return 'Guten Tag';
if(hour < 22) return 'Guten Abend';
return 'Gute Nacht';
}
// startDashboardClock: (re-)startet das Intervall, das Begrüßung, Datum und
// Uhrzeit im Dashboard-Header aktualisiert. Bricht sich selbst ab, sobald die
// Zielelemente nicht mehr im DOM sind (z. B. weil das Dashboard neu gerendert
// wurde). Keine Parameter, keine Rückgabe.
function startDashboardClock(){
if(dashboardClockTimer) clearInterval(dashboardClockTimer);
const name = (currentUser && (currentUser.displayName || currentUser.username)) || '';
const tick = () => {
const textEl = document.getElementById('dashboardGreetingText');
const dateEl = document.getElementById('dashboardGreetingDate');
const clockEl = document.getElementById('dashboardClock');
if(!textEl || !dateEl || !clockEl){
clearInterval(dashboardClockTimer);
dashboardClockTimer = null;
return;
}
const now = new Date();
// Begrüßung und Datum stehen in EINER Zeile (früher untereinander), das
// Datum als gedämpfter Zusatz — deshalb der führende Gedankenstrich.
textEl.textContent = greetingForHour(now.getHours()) + (name ? ', ' + name : '');
dateEl.textContent = ' — ' + now.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
clockEl.textContent = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
};
tick();
dashboardClockTimer = setInterval(tick, 30000);
}
// relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem
// ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht
// sagt, ob das morgen oder in drei Wochen ist.
+1 -1
View File
@@ -44,7 +44,7 @@ const EINFUEHRUNG_SCHRITTE = [
text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen <b>Rundgang jederzeit neu starten</b>.' },
{ id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin',
text: () => 'Du darfst Teile der Verwaltung pflegen: ' + esc(typeof ADMIN_SECTIONS !== 'undefined' ? ADMIN_SECTIONS.filter(adminAbschnittErlaubt).map(s => s.title).join(', ') : '') + '.' },
{ id: 'ende', tab: 'dashboard', titel: 'Fertig!',
{ id: 'ende', tab: 'dashboard', titel: 'Rundgang abgeschlossen',
text: () => {
const hinweise = [];
if(currentUser && !currentUser.personId){
+1
View File
@@ -147,6 +147,7 @@ const TAB_SLUGS = {
wochenbericht: 'Wochenbericht',
load: 'Auslastung',
abwesenheit: 'Abwesenheiten',
notizen: 'Notizen',
people: 'Projektbeteiligte',
planung: 'Planung',
planchecker: 'PlanChecker',
+408
View File
@@ -0,0 +1,408 @@
// -----------------------------
// Seitenleiste „Notizen“ (Tab `notizen`, /Notizen)
// -----------------------------
// Eriks Wunsch: Jedes Konto sieht hier die Notizen ALLER seiner Projekte und
// legt auch projektunabhängige Notizen an, die sich beim Bearbeiten einem
// Projekt zuordnen lassen. Gleicher Aufbau wie der Reiter „Notizen“ im
// Projektbereich (js/plan/plan-notizen.js: Liste + Editor, Werkzeugleiste,
// automatisches Speichern), dazu ein Projektfilter und im Editor die Auswahl
// „Projekt“. Eigene Element-ids (mn…), weil der Projekt-Reiter gleichzeitig
// im DOM liegt.
//
// Daten: GET /meine-notizen (Notizen meiner Projekte + meine
// projektunabhängigen, project_uid = ''), sonst dieselben Routen
// /projekt-notizen* wie der Projekt-Reiter; PUT nimmt `uid` zum Zuordnen.
// Projektunabhängige Notizen sieht und ändert nur, wer sie angelegt hat.
(function(){
const SPEICHER_PAUSE_MS = 1200;
const OHNE = '__ohne__';
let liste = [];
let meineProjekte = new Set();
let geladen = false;
let ladeFehler = '';
let filter = ''; // '' = alle, OHNE = ohne Projekt, sonst uid
let suche = '';
let aktivId = null;
let basis = '';
let ungespeichert = false;
let speicherTimer = null;
let speichertGerade = false;
let beobachter = null;
let loeschenFragt = null;
let eigenesSchreiben = 0; // Zeitpunkt der letzten eigenen Änderung (Live-Echo auslassen)
const el = () => document.getElementById('notizen');
const aktiv = () => liste.find(n => n.id === aktivId) || null;
const darf = n => !n || !n.uid ? true : (typeof hatRecht === 'function' && hatRecht('proj.plan'));
const darfProjekte = () => typeof hatRecht === 'function' && hatRecht('proj.plan');
// Projekte der aktuellen Sitzung je projectUid (Name und Nummer).
function projekte(){
const w = typeof currentWeek === 'function' ? currentWeek() : null;
const m = new Map();
((w && w.projects) || []).forEach(p => { if(p.projectUid) m.set(String(p.projectUid), p); });
return m;
}
function projektName(uid, karte){
if(!uid) return 'Ohne Projekt';
const p = (karte || projekte()).get(uid);
return p ? [p.nr, p.name].filter(Boolean).join(' ') : 'Projekt nicht in der aktuellen Sitzung';
}
function zeit(iso){
if(!iso) return '';
const d = new Date(iso);
if(isNaN(d)) return '';
const heute = new Date();
const gestern = new Date(heute); gestern.setDate(heute.getDate() - 1);
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
if(d.toDateString() === heute.toDateString()) return 'heute ' + uhr;
if(d.toDateString() === gestern.toDateString()) return 'gestern ' + uhr;
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: d.getFullYear() === heute.getFullYear() ? undefined : 'numeric' });
}
const klartext = html => typeof statusPlainText === 'function' ? statusPlainText(html) : String(html || '').replace(/<[^>]+>/g, ' ');
function sortieren(){
liste.sort((a, b) => (b.angeheftet - a.angeheftet) || String(b.geaendertAm).localeCompare(String(a.geaendertAm)));
}
// ---------- Laden ----------
function laden(){
ladeFehler = '';
return fetch(baseUrl() + '/meine-notizen')
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(!ok || !d.ok) throw new Error(d.error || 'Laden fehlgeschlagen');
liste = d.notizen || [];
meineProjekte = new Set(d.meineProjekte || []);
sortieren();
geladen = true;
})
.catch(e => { ladeFehler = e.message || 'Die Notizen konnten nicht geladen werden.'; liste = []; geladen = true; });
}
// ---------- Darstellung ----------
window.renderMeineNotizen = function(){
const box = el();
if(!box) return;
box.innerHTML = '<div class="notizen-laedt">Notizen werden geladen…</div>';
jetztSpeichern();
laden().then(aufbauen);
};
function sichtbar(){
const s = suche.trim().toLowerCase();
return liste.filter(n => (!filter || (filter === OHNE ? !n.uid : n.uid === filter))
&& (!s || (n.titel + ' ' + klartext(n.html) + ' ' + projektName(n.uid)).toLowerCase().includes(s)));
}
function filterOptionen(){
const karte = projekte();
const mitNotizen = new Set(liste.filter(n => n.uid).map(n => n.uid));
const uids = Array.from(new Set([...meineProjekte, ...mitNotizen]))
.sort((a, b) => projektName(a, karte).localeCompare(projektName(b, karte), 'de'));
const zahl = f => liste.filter(n => f === OHNE ? !n.uid : n.uid === f).length;
return '<option value="">Alle Projekte (' + liste.length + ')</option>'
+ '<option value="' + OHNE + '"' + (filter === OHNE ? ' selected' : '') + '>Ohne Projekt (' + zahl(OHNE) + ')</option>'
+ uids.map(u => '<option value="' + esc(u) + '"' + (filter === u ? ' selected' : '') + '>' + esc(projektName(u, karte)) + ' (' + zahl(u) + ')</option>').join('');
}
function aufbauen(){
const box = el();
if(!box) return;
const treffer = sichtbar();
if(!treffer.some(n => n.id === aktivId)) aktivId = treffer.length ? treffer[0].id : null;
box.innerHTML = `
<div class="mn-seite">
<div class="mn-kopf">
<div class="mn-kopf-links">
<span class="mn-kopf-unter">Notizen aller meiner Projekte und eigene Notizen ohne Projekt</span>
<h2 class="mn-titel">Notizen</h2>
</div>
<label class="mn-filter">Projekt
<select id="mnFilter" onchange="mnFilter(this.value)">${filterOptionen()}</select>
</label>
</div>
<div class="notizen mn-notizen">
<aside class="notizen-liste">
<div class="notizen-suchzeile">
<input type="search" id="mnSuche" class="notizen-suche" placeholder="Notizen durchsuchen…"
aria-label="Notizen durchsuchen" value="${esc(suche)}" oninput="mnSuchen(this.value)">
<button type="button" class="notizen-neu" onclick="mnNeu()">+ Neu</button>
</div>
<div id="mnEintraege" class="notizen-eintraege"></div>
</aside>
<div class="notizen-editor" id="mnEditor"></div>
</div>
</div>`;
listeZeichnen();
editorZeichnen();
}
function listeZeichnen(){
const box = document.getElementById('mnEintraege');
if(!box) return;
if(ladeFehler){ box.innerHTML = '<div class="notizen-leer ist-fehler">' + esc(ladeFehler) + '</div>'; return; }
const treffer = sichtbar();
if(!liste.length){ box.innerHTML = '<div class="notizen-leer">Noch keine Notizen. Mit „+ Neu“ eine eigene anlegen.</div>'; return; }
if(!treffer.length){ box.innerHTML = '<div class="notizen-leer">' + (suche ? 'Keine Notiz enthält „' + esc(suche) + '“.' : 'Keine Notizen in dieser Auswahl.') + '</div>'; return; }
const karte = projekte();
const zeile = n => {
const vorschau = klartext(n.html).replace(/\s+/g, ' ').trim().slice(0, 90);
return `<button type="button" class="notizen-eintrag${n.id === aktivId ? ' ist-aktiv' : ''}" onclick="mnWaehlen('${esc(n.id)}')">
<span class="notizen-eintrag-titel">${esc(n.titel || 'Ohne Titel')}${n.quelle === 'mail' ? ' <span class="notizen-chip">Mail</span>' : ''}</span>
<span class="notizen-eintrag-text">${esc(vorschau || 'Kein Text')}</span>
<span class="notizen-eintrag-meta"><span class="mn-projekt${n.uid ? '' : ' ist-ohne'}">${esc(projektName(n.uid, karte))}</span> · ${esc(zeit(n.geaendertAm))}</span>
</button>`;
};
const oben = treffer.filter(n => n.angeheftet), rest = treffer.filter(n => !n.angeheftet);
box.innerHTML = (oben.length ? '<div class="notizen-gruppe"><span class="notizen-pin-punkt"></span>Angeheftet</div>' + oben.map(zeile).join('') : '')
+ (rest.length ? (oben.length ? '<div class="notizen-gruppe">Weitere</div>' : '') + rest.map(zeile).join('') : '');
}
// projektAuswahl: Auswahl im Editor — „Ohne Projekt“, meine Projekte, dann
// die übrigen der Sitzung. Ohne Recht proj.plan nur „Ohne Projekt“ (und das
// aktuelle Projekt, damit der Stand lesbar bleibt).
function projektAuswahl(n){
const karte = projekte();
const name = u => projektName(u, karte);
const sort = arr => arr.sort((a, b) => name(a).localeCompare(name(b), 'de'));
const opt = u => '<option value="' + esc(u) + '"' + (n.uid === u ? ' selected' : '') + '>' + esc(name(u)) + '</option>';
const meine = sort(Array.from(meineProjekte).filter(u => karte.has(u)));
const weitere = sort(Array.from(karte.keys()).filter(u => !meineProjekte.has(u)));
const sperren = !darf(n) || (!darfProjekte() && !n.uid);
let html = '<option value=""' + (!n.uid ? ' selected' : '') + '>Ohne Projekt</option>';
if(darfProjekte()){
if(meine.length) html += '<optgroup label="Meine Projekte">' + meine.map(opt).join('') + '</optgroup>';
if(weitere.length) html += '<optgroup label="Weitere Projekte">' + weitere.map(opt).join('') + '</optgroup>';
}
if(n.uid && !karte.has(n.uid)) html += opt(n.uid);
return '<label class="mn-zuordnung">Projekt <select id="mnProjekt" onchange="mnZuordnen(this.value)"'
+ (sperren ? ' disabled title="' + (darfProjekte() ? '' : 'Zum Zuordnen fehlt das Recht, den Terminplan zu bearbeiten.') + '"' : '') + '>' + html + '</select></label>';
}
function editorZeichnen(){
const box = document.getElementById('mnEditor');
if(!box) return;
if(beobachter){ beobachter.disconnect(); beobachter = null; }
const n = aktiv();
if(!n){
box.innerHTML = `<div class="notizen-editor-leer">
<b>${liste.length ? 'Notiz links auswählen' : 'Noch keine Notizen'}</b>
<span>Mit „+ Neu“ eine Notiz anlegen. Sie gehört zunächst nur Ihnen und lässt sich oben rechts einem Projekt zuordnen.</span>
</div>`;
return;
}
const schreiben = darf(n);
basis = n.geaendertAm;
ungespeichert = false;
const projekt = n.uid ? projekte().get(n.uid) : null;
box.innerHTML = `
<div class="notizen-editor-kopf">
<input type="text" id="mnTitel" class="notizen-titel" value="${esc(n.titel)}" placeholder="Titel der Notiz"
aria-label="Titel der Notiz" ${schreiben ? '' : 'readonly'} oninput="mnGeaendert()" onblur="mnJetztSpeichern()">
${schreiben ? `<button type="button" class="notizen-symbol${n.angeheftet ? ' ist-an' : ''}" onclick="mnAnheften()"
aria-pressed="${n.angeheftet}">${n.angeheftet ? 'Angeheftet' : 'Anheften'}</button>
<button type="button" class="notizen-symbol ist-gefahr" id="mnLoeschen" onclick="mnLoeschen()">Löschen</button>` : ''}
</div>
<div class="mn-editor-zeile">
${projektAuswahl(n)}
${projekt ? `<a class="mn-projekt-link" href="#" onclick="return mnProjektOeffnen('${esc(projekt.id)}')">Im Projekt öffnen</a>` : ''}
${!n.uid ? '<span class="mn-hinweis">Nur für Sie sichtbar, bis sie einem Projekt zugeordnet ist</span>' : ''}
</div>
<div class="richtext-wrap notizen-richtext">
${schreiben ? richtextToolbarHtml('#mnText') : ''}
${richtextFeldHtml('notizen-text', sanitizeStatusHtml(n.html || ''), 'Notiz schreiben…', 'id="mnText" onblur="mnJetztSpeichern()"', schreiben)}
</div>
<div class="notizen-fuss">
<span>Angelegt von ${esc(n.erstelltVon)} ${esc(zeit(n.erstelltAm))}${n.geaendertAm !== n.erstelltAm ? ' · zuletzt geändert von ' + esc(n.geaendertVon) + ' ' + esc(zeit(n.geaendertAm)) : ''}</span>
<span id="mnStatus" class="notizen-status"></span>
</div>`;
const text = document.getElementById('mnText');
if(text && schreiben){
beobachter = new MutationObserver(() => mnGeaendert());
beobachter.observe(text, { childList: true, subtree: true, characterData: true, attributes: true });
}
}
function status(text, art){
const s = document.getElementById('mnStatus');
if(!s) return;
s.className = 'notizen-status' + (art ? ' ist-' + art : '');
s.innerHTML = text;
}
function hinweis(text){ if(typeof zeigeHinweis === 'function') zeigeHinweis(text); else status(esc(text), 'fehler'); }
// ---------- Bedienung ----------
window.mnFilter = function(wert){ jetztSpeichern(); filter = wert || ''; aufbauen(); };
window.mnSuchen = function(wert){ suche = wert || ''; listeZeichnen(); };
window.mnWaehlen = function(id){
if(id === aktivId) return;
jetztSpeichern();
aktivId = id;
listeZeichnen();
editorZeichnen();
};
window.mnGeaendert = function(){
const n = aktiv();
if(!n || !darf(n)) return;
ungespeichert = true;
status('Ungespeichert');
clearTimeout(speicherTimer);
speicherTimer = setTimeout(jetztSpeichern, SPEICHER_PAUSE_MS);
};
window.mnJetztSpeichern = function(){ jetztSpeichern(); };
function senden(n, nutzlast){
eigenesSchreiben = Date.now();
return fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(nutzlast)
}).then(r => r.json().then(d => ({ r, d })));
}
function jetztSpeichern(ohneBasis){
clearTimeout(speicherTimer);
const n = aktiv();
if(!n || !ungespeichert || !darf(n)) return Promise.resolve();
if(speichertGerade){ speicherTimer = setTimeout(jetztSpeichern, 400); return Promise.resolve(); }
const titelEl = document.getElementById('mnTitel');
const textEl = document.getElementById('mnText');
const titel = titelEl ? titelEl.value : n.titel;
const html = textEl ? textEl.innerHTML : n.html;
ungespeichert = false;
speichertGerade = true;
status('Speichert…');
return senden(n, { titel, html, basis: ohneBasis ? '' : basis }).then(({ r, d }) => {
if(r.status === 409 && d.konflikt){
ungespeichert = true;
status(esc(d.error) + ' <button type="button" class="notizen-link" onclick="mnKonflikt(true)">Meine Fassung speichern</button>'
+ ' <button type="button" class="notizen-link" onclick="mnKonflikt(false)">Neuen Stand laden</button>', 'warnung');
n._fremd = d.notiz;
return;
}
if(!r.ok || !d.ok) throw new Error(d.error || 'Speichern fehlgeschlagen');
Object.assign(n, d.notiz, { titel, html });
if(n.id === aktivId) basis = d.notiz.geaendertAm;
sortieren();
listeZeichnen();
if(!ungespeichert) status('Gespeichert ✓', 'ok');
}).catch(e => {
ungespeichert = true;
status('Nicht gespeichert: ' + esc(e.message) + ' — wird erneut versucht.', 'fehler');
speicherTimer = setTimeout(jetztSpeichern, 5000);
}).finally(() => { speichertGerade = false; });
}
window.mnKonflikt = function(meine){
const n = aktiv();
if(!n) return;
if(meine){ ungespeichert = true; jetztSpeichern(true); return; }
if(n._fremd) Object.assign(n, n._fremd);
delete n._fremd;
ungespeichert = false;
listeZeichnen();
editorZeichnen();
};
// mnNeu: neue Notiz — im gefilterten Projekt, sonst ohne Projekt.
window.mnNeu = function(){
jetztSpeichern();
const uid = filter && filter !== OHNE && darfProjekte() ? filter : '';
eigenesSchreiben = Date.now();
fetch(baseUrl() + '/projekt-notizen', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ uid, titel: '', html: '' })
}).then(r => r.json()).then(d => {
if(!d.ok) throw new Error(d.error || 'Anlegen fehlgeschlagen');
liste = liste.filter(x => x.id !== d.notiz.id).concat([d.notiz]);
sortieren();
aktivId = d.notiz.id;
suche = '';
aufbauen();
const t = document.getElementById('mnTitel');
if(t) t.focus();
}).catch(e => hinweis('Notiz konnte nicht angelegt werden: ' + e.message));
};
// mnZuordnen: Notiz einem Projekt zuordnen (oder wieder lösen). Offene
// Änderungen werden vorher gespeichert.
window.mnZuordnen = function(uid){
const n = aktiv();
if(!n) return;
jetztSpeichern().then(() => senden(n, { uid: uid || '' })).then(({ r, d }) => {
if(!r.ok || !d.ok) throw new Error(d.error || 'Zuordnen fehlgeschlagen');
Object.assign(n, d.notiz);
if(n.uid) meineProjekte.add(n.uid);
if(filter && filter !== (n.uid || OHNE)) filter = n.uid || OHNE;
aufbauen();
status(n.uid ? 'Dem Projekt zugeordnet ✓' : 'Vom Projekt gelöst ✓', 'ok');
}).catch(e => { hinweis(e.message); editorZeichnen(); });
};
window.mnAnheften = function(){
const n = aktiv();
if(!n) return;
jetztSpeichern().then(() => senden(n, { angeheftet: !n.angeheftet })).then(({ d }) => {
if(!d.ok) throw new Error(d.error || 'Fehler');
n.angeheftet = d.notiz.angeheftet;
sortieren();
listeZeichnen();
editorZeichnen();
}).catch(e => hinweis('Anheften fehlgeschlagen: ' + e.message));
};
window.mnLoeschen = function(){
const n = aktiv();
const knopf = document.getElementById('mnLoeschen');
if(!n || !knopf) return;
if(!loeschenFragt){
knopf.textContent = 'Wirklich löschen?';
knopf.classList.add('ist-fragend');
loeschenFragt = setTimeout(() => {
loeschenFragt = null;
const k = document.getElementById('mnLoeschen');
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
}, 4000);
return;
}
clearTimeout(loeschenFragt); loeschenFragt = null;
clearTimeout(speicherTimer); ungespeichert = false;
eigenesSchreiben = Date.now();
fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), { method: 'DELETE' })
.then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
if(!r.ok || !d.ok) throw new Error(d.error || 'Löschen fehlgeschlagen');
liste = liste.filter(x => x.id !== n.id);
aktivId = null;
aufbauen();
}).catch(e => { status(esc(e.message), 'fehler'); const k = document.getElementById('mnLoeschen'); if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); } });
};
window.mnProjektOeffnen = function(pid){
jetztSpeichern();
location.hash = 'notizen';
if(typeof openProjectPlan === 'function') openProjectPlan(pid);
return false;
};
// ---------- Live: Änderungen aus anderen Browsern ----------
window.meineNotizenLive = function(uid){
if(typeof currentTab === 'undefined' || currentTab !== 'notizen' || !geladen) return;
if(uid && !meineProjekte.has(uid)) return;
const vorher = aktiv();
const tippt = document.activeElement && document.activeElement.closest && document.activeElement.closest('#mnEditor');
// Das Echo der eigenen Speicherung (kommt auch in diesem Tab an) auslassen.
if(ungespeichert || tippt || Date.now() - eigenesSchreiben < 3000) return;
laden().then(() => {
if(vorher && liste.some(x => x.id === vorher.id)) aktivId = vorher.id;
aufbauen();
});
};
// Klick irgendwo anders: die Rückfrage „Wirklich löschen?“ zurücknehmen.
document.addEventListener('pointerdown', e => {
if(!loeschenFragt || (e.target.closest && e.target.closest('#mnLoeschen'))) return;
clearTimeout(loeschenFragt); loeschenFragt = null;
const k = document.getElementById('mnLoeschen');
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
}, true);
window.addEventListener('pagehide', () => { if(ungespeichert) jetztSpeichern(); });
})();
+3 -4
View File
@@ -68,7 +68,6 @@
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
if(typeof renderPlanTasks === 'function') renderPlanTasks();
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
};
@@ -541,7 +540,7 @@
const ampel = loadAmpelColor(l.quote);
const breite = Math.min(100, l.quote * 100);
const absCount = app.countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : '';
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:11px;margin-top:4px">${absCount} Abwesenheit(en)</div>` : '';
let todoListHtml;
if(l.todos.length === 0){
@@ -567,12 +566,12 @@
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`;
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
<h3>${esc(l.person.name)} <span style="font-size:11px;color:var(--text-subtle);font-weight:400">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${offenText}
${otherBadge}
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
<div class="load-detail" style="font-size:11px;margin-top:4px">${todoListHtml}</div>
${absBadge}
</div>`;
});
+9 -5
View File
@@ -93,7 +93,7 @@
for(let i = versionen.length - 1; i >= 0; i--){
const k = kommentarVon(versionen[i].project, null);
if(statusPlainText(k.status)){
letzter = { week: versionen[i].week, k, aktuell: i === versionen.length - 1 };
letzter = { week: versionen[i].week, k, project: versionen[i].project, aktuell: i === versionen.length - 1 };
break;
}
}
@@ -103,7 +103,8 @@
gewerke.forEach(g => {
let vorher = '';
versionen.forEach(v => {
const k = kommentarVon(v.project, g);
// Nur, wenn das Projekt in JENER Sitzung das Gewerk hatte.
const k = (g in kommentareDesProjekts(v.project)) ? kommentarVon(v.project, g) : { status: '' };
const text = statusPlainText(k.status);
if(text && text !== vorher && !(letzter && v.week === letzter.week)){
verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' });
@@ -184,8 +185,9 @@
if(editEl){
editEl.className = 'status-edit is-ansicht';
editEl.contentEditable = 'false';
// Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js).
const text = kom.letzter ? sanitizeStatusHtml(kom.letzter.k.status) : '';
// Alle Gewerke zusammen; bei mehreren je mit Überschrift, Linie und
// Verfasser (kommentarAnzeigeHtml(), js/utils.js, Eriks Wahl „B“).
const text = kom.letzter ? kommentarAnzeigeHtml(kom.letzter.project, null, true) : '';
editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>';
}
const hinweisEl = document.getElementById('planStatusHinweis');
@@ -200,7 +202,9 @@
const autorEl = document.getElementById('planStatusAutor');
if(autorEl){
const k = kom.letzter ? kom.letzter.k : {};
autorEl.innerHTML = k.status && k.statusAuthor
// Bei mehreren Gewerken steht der Verfasser schon je Gewerk im Text.
const mehrere = kom.letzter && kommentarGewerkeMitText(kom.letzter.project) > 1;
autorEl.innerHTML = !mehrere && k.status && k.statusAuthor
? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}`
+ (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '')
: '';
+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
// Terminplans (Aufgaben mit t.lph, balkenPhase() in load.js) als eigener
// Bereich statt als Zeilen zwischen den Aufgaben. Oben ein Ablaufbalken über
// alle Phasen mit Heute-Linie, darunter eine Kachel je Phase: Fortschritt
// (aus dem Wochenbericht, p.lph), Zeitraum (aus dem Balken), Aufwand
// (Summe der Stunden ihrer Aufgaben) und die Aufgaben, die in ihrem
// Zeitraum beginnen — dieselbe Zuordnung wie die Gliederung im Terminplan
// (plan-build-gantt.js).
//
// Keine eigene Datenhaltung: Bearbeiten öffnet dasselbe Panel wie im
// Terminplan (openTodoModal() im Phasen-Modus), Fortschritt bleibt im
// Wochenbericht ("Ein Ort je Angabe").
//
// renderPlanPhasen() wird von renderPlanGantt() (plan-gantt.js) und beim
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) aufgerufen.
// Der frühere Reiter „Leistungsphasen“ (Ablaufbalken + Kachel je Phase) ist
// auf Eriks Wunsch entfallen; an seiner Stelle steht „Notizen“. Geblieben ist
// nur aufgabenPhase(), die die Aufgabenliste (plan-tasks.js) und das
// Terminplan-PDF (pdf.js) nutzen.
// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört —
// 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));
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.
function zeigeFallbackHinweis(msg){
const el = document.getElementById('protokollTodosFallback');
if(el){ el.textContent = '⚠️ ' + msg; el.hidden = false; }
if(el){ el.textContent = msg; el.hidden = false; }
}
function verbergeFallbackHinweis(){
const el = document.getElementById('protokollTodosFallback');
+2 -2
View File
@@ -548,7 +548,7 @@
// ab, verschwindet also nie von selbst, sondern nur wenn man das
// Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar.
const importBanner = p.quelleDateiname
? `<div class="protokoll-import-banner">🔎 Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>`
? `<div class="protokoll-import-banner">Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>`
: '';
// Rein clientseitiges Merkmal (siehe importiereProtokoll() oben) — nur
// direkt nach GENAU dem Import gesetzt, der die KI angefordert, aber
@@ -559,7 +559,7 @@
// das war Eriks Rückmeldung, nachdem er von einem KI-Fehlschlag nichts
// in der Oberfläche sah.
const kiFallbackBanner = p._kiFallbackHinweis
? `<div class="protokoll-ki-fallback-banner">⚠️ Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>`
? `<div class="protokoll-ki-fallback-banner">Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>`
: '';
const kopf = `
${kiFallbackBanner}
+1 -1
View File
@@ -128,7 +128,7 @@
if(moved.size > 0){
app.savePlan();
renderPlanGantt();
app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Achtung: zyklische Abhängigkeit gefunden!' : ''));
app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Zyklische Abhängigkeit gefunden — bitte Vorgänger prüfen.' : ''));
} else if(hasCycle){
app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.');
} else {
+2 -2
View File
@@ -36,7 +36,7 @@
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
// Navigationsleiste. Muss zu den data-section-Attributen in
// 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
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
@@ -63,8 +63,8 @@
if(!app.project) return;
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
if(key === 'notizen' && typeof renderPlanNotizen === 'function') renderPlanNotizen();
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings();
+2 -4
View File
@@ -284,12 +284,10 @@ window.PlanApp = {
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
// Terminplan (…Btn), Aufgaben (…BtnList) und Leistungsphasen (…BtnPhasen),
// siehe App.html.
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html.
const locked = isLocked();
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList',
'honorarBtnPhasen', 'addPhaseBtnPhasen']
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList']
.forEach(id => {
const btn = document.getElementById(id);
if(btn) btn.style.display = locked ? 'none' : '';
+5
View File
@@ -541,6 +541,11 @@ function liveEreignis(d){
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
if(d.typ === 'neu') return liveAllesNeu();
if(d.typ === 'personen') return personenNachladen();
if(d.typ === 'notizen'){
if(typeof meineNotizenLive === 'function') meineNotizenLive(d.uid);
if(typeof notizenLiveAenderung === 'function') notizenLiveAenderung(d.uid);
return;
}
}
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
+6 -2
View File
@@ -23,7 +23,7 @@
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
// Dashboard ins Projektboard.
const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','people','planung','planchecker','dashboard','wand','settings','admin','plan'];
const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','notizen','people','planung','planchecker','dashboard','wand','settings','admin','plan'];
function showTab(t){
// Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der
@@ -61,6 +61,7 @@ function showTab(t){
if(t==='wochenbericht') renderWochenbericht();
if(t==='load') renderLoad();
if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); }
if(t==='notizen' && typeof renderMeineNotizen === 'function') renderMeineNotizen();
if(t==='people') renderPeople();
if(t==='planung') renderPlanung();
if(t==='planchecker') renderPlanChecker();
@@ -108,6 +109,9 @@ function syncPlanNavItem(){
const name = app && app.projName ? app.projName : '';
item.style.display = name ? '' : 'none';
const label = document.getElementById('planNavItemLabel');
if(label) label.textContent = name || 'Projektbereich';
// Projektnummer in Mono-Ziffern davor (Kennzeichen „Bauplan“).
if(label) label.innerHTML = name
? (app.projNr ? '<span class="nav-projnr">' + esc(app.projNr) + '</span> ' : '') + esc(name)
: 'Projektbereich';
item.title = name ? ('Projektbereich: ' + name) : 'Projektbereich';
}
+39 -8
View File
@@ -385,8 +385,21 @@ function kommentarGewerk(){
// Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des
// Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder
// null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }.
function kommentarVon(p, g){
// kommentareDesProjekts: die Kommentare nur der Gewerke, die das Projekt hat.
// Ein Kommentar eines Gewerks, das dem Projekt nicht (mehr) gehört — etwa aus
// einer Zeit, als es noch HLS hatte —, gehört nicht in die Gesamtanzeige
// (Eriks Meldung zu 21-4030). Ohne gepflegte Gewerke: alle. Rückgabe: Objekt.
function kommentareDesProjekts(p){
const alle = (p && p.kommentare) || {};
const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : [];
if(!gewerke.length) return alle;
const aus = {};
Object.keys(alle).forEach(k => { if(gewerke.includes(k)) aus[k] = alle[k]; });
return aus;
}
function kommentarVon(p, g){
const alle = g ? ((p && p.kommentare) || {}) : kommentareDesProjekts(p);
if(g){
const e = alle[g] || {};
return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen };
@@ -408,19 +421,37 @@ function kommentarVon(p, g){
}
// kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g
// nur dessen Text; ohne Gewerk die Texte aller Gewerke, bei mehreren je mit
// dem Gewerk als eigener, dezenter Beschriftung (.kommentar-gewerk) —
// Systemtext, nicht Teil des Kommentars (anders als die fette "ELT:"-Zeile,
// die kommentarVon() für Klartext-Vorschauen einfügt). Rückgabe: HTML ('' ohne Text).
function kommentarAnzeigeHtml(p, g){
// nur dessen Text; ohne Gewerk die Texte aller Gewerke des Projekts. Bei
// mehreren Gewerken Darstellung „B – Überschrift mit Linie“ (Eriks Wahl):
// je Gewerk eine Kopfzeile mit dem Gewerk in seiner Farbe, einer feinen Linie
// und (mitMeta) Verfasser und Zeit, darunter der Text — Systemtext, nicht
// Teil des Kommentars (anders als die fette "ELT:"-Zeile, die kommentarVon()
// für Klartext-Vorschauen einfügt). Parameter: p, g, mitMeta.
// Rückgabe: HTML ('' ohne Text).
function kommentarAnzeigeHtml(p, g, mitMeta){
if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || '');
const alle = (p && p.kommentare) || {};
const alle = kommentareDesProjekts(p);
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
if(mitText.length === 0) return '';
if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status);
return mitText.map(k => `<div class="kommentar-gewerk">${esc(k)}</div><div>${sanitizeStatusHtml(alle[k].status)}</div>`).join('');
return '<div class="kommentar-gewerke">' + mitText.map(k => {
const e = alle[k];
const zeit = e.statusAt && typeof formatCommentStamp === 'function' ? formatCommentStamp(e.statusAt) : '';
const meta = mitMeta ? [e.statusAuthor, zeit].filter(Boolean).join(' · ') : '';
return '<div class="kommentar-block">'
+ '<div class="kommentar-kopf"><span class="kommentar-gewerk gewerk-' + esc(k.toLowerCase()) + '">' + esc(k) + '</span>'
+ '<span class="kommentar-linie"></span>' + (meta ? '<span class="kommentar-meta">' + esc(meta) + '</span>' : '') + '</div>'
+ '<div class="kommentar-text">' + sanitizeStatusHtml(e.status) + '</div></div>';
}).join('') + '</div>';
}
// kommentarGewerkeMitText: wie viele Gewerke des Projekts einen Kommentar
// haben (für die Frage, ob eine gemeinsame Verfasserzeile Sinn ergibt).
function kommentarGewerkeMitText(p){
const alle = kommentareDesProjekts(p);
return Object.keys(alle).filter(k => alle[k] && statusPlainText(alle[k].status) !== '').length;
}
// kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in
+384 -371
View File
File diff suppressed because it is too large. Load diff
+928 -671
View File
File diff suppressed because it is too large. Load diff
+291 -10
View File
@@ -1020,6 +1020,30 @@ function htmlEscape(s){
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// textMaskieren: wie htmlEscape(), aber ein '&', das schon eine fertige
// Entity beginnt (&amp; &nbsp; &#39; …), bleibt stehen. Der Text zwischen den
// Tags kommt aus gespeichertem HTML, in dem solche Entities ganz normal
// stehen. htmlEscape() machte daraus bei JEDEM Speichern eine Stufe mehr
// (&nbsp; → &amp;nbsp; → &amp;amp;nbsp; …) — Eriks Meldung nach dem
// Sitzungswechsel KW 45. So bleibt die Bereinigung beliebig oft wiederholbar.
function textMaskieren(s){
return String(s)
.replace(/&(?!(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&amp;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// entitiesEntstapeln: macht gestapelte Maskierung rückgängig
// (&amp;amp;nbsp; → &nbsp;, &amp;amp; → &amp;). Ein „&amp;“ vor einer
// weiteren Entity ist immer das Ergebnis des Fehlers oben, nie Absicht.
function entitiesEntstapeln(html){
let alt, neu = String(html || '');
do {
alt = neu;
neu = neu.replace(/&amp;((?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&$1');
} while(neu !== alt);
return neu;
}
// sicheresStyleAttribut: lässt aus einem style-Attribut nur color und
// font-weight durch — identisch zur Client-Regel. Rückgabe: bereinigter
// Wert oder '' (dann wird das Attribut ganz weggelassen).
@@ -1108,7 +1132,7 @@ function sanitizeFormatHtml(html){
if(c !== '<'){
const naechstes = quelle.indexOf('<', i);
const text = quelle.slice(i, naechstes === -1 ? quelle.length : naechstes);
if(!verwerfenBis) aus += htmlEscape(text);
if(!verwerfenBis) aus += textMaskieren(text);
i += text.length;
continue;
}
@@ -1670,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',
'plan_milestones','plan_milestone_deps',
'project_protokolle','protokoll_teilnehmer','protokoll_punkte',
'project_kommentare'];
'project_kommentare', 'projekt_notizen'];
// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen.
// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk()
// verteilt die alten Kommentare danach aus projects.status.
const IMPORT_OPTIONAL_TABLES = ['project_kommentare'];
const IMPORT_OPTIONAL_TABLES = ['project_kommentare', 'projekt_notizen'];
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
@@ -2611,6 +2635,30 @@ function kommentareVorsatzUmstellen(){
if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.');
}
// entitiesReparieren: führt gestapelte Maskierung (&amp;amp;nbsp; …) in
// allen Sitzungskommentaren und Protokoll-Punkten auf das ursprüngliche
// Zeichen zurück — Folge des Fehlers in sanitizeFormatHtml() vor
// textMaskieren(). Läuft bei jedem Start und nach /import, idempotent.
function entitiesReparieren(){
const teile = [
['project_kommentare', 'status', 'week_label, project_id, gewerk', 'week_label = ? AND project_id = ? AND gewerk = ?', r => [r.week_label, r.project_id, r.gewerk]],
['protokoll_punkte', 'text', 'id', 'id = ?', r => [r.id]]
];
let n = 0;
db.exec('BEGIN');
try {
teile.forEach(([tab, feld, schluessel, wo, werte]) => {
const upd = db.prepare('UPDATE ' + tab + ' SET ' + feld + ' = ? WHERE ' + wo);
db.prepare('SELECT ' + schluessel + ', ' + feld + " AS wert FROM " + tab + " WHERE " + feld + " LIKE '%&amp;%'").all().forEach(r => {
const neu = entitiesEntstapeln(r.wert);
if(neu !== r.wert){ upd.run(neu, ...werte(r)); n++; }
});
});
db.exec('COMMIT');
} catch(e){ db.exec('ROLLBACK'); throw e; }
if(n) console.log('Doppelt maskierte Zeichen in ' + n + ' Kommentaren/Protokoll-Punkten repariert.');
}
function kommentareSpiegelstricheUmstellen(){
const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status != ''").all();
const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?');
@@ -2908,8 +2956,12 @@ function ensureCurrentWeekRollover(){
// zur Sitzung und beginnt leer.
const vorher = p.kommentare || {};
p.kommentare = {};
// Nur Gewerke, die das Projekt hat — sonst lebte ein Kommentar eines
// längst abgewählten Gewerks Woche für Woche weiter (21-4030).
const gewerke = Array.isArray(p.gewerke) ? p.gewerke : [];
Object.keys(vorher).forEach(g => {
const k = vorher[g] || {};
if(gewerke.length && !gewerke.includes(g)) return;
if(!String(k.status || '').trim()) return;
p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '',
statusAt: k.statusAt || '', besprochen: false };
@@ -3558,6 +3610,7 @@ einmaligeUmstellung('siemon-anteile-2021', siemon2021Umstellen);
// Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei
// jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen.
kommentareVorsatzUmstellen();
entitiesReparieren();
ensureSandboxWeek();
ensureUserPersonIdUniqueIndex();
migrateRetirePeopleTablesIntoUsers();
@@ -7084,12 +7137,17 @@ function mailTeilLesen(roh, tiefe){
if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' };
if(!typ.startsWith('text/')) return { plain: '', html: '' };
const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase();
let buf;
if(enc === 'base64') buf = Buffer.from(rumpf.replace(/\s+/g, ''), 'base64');
else if(enc === 'quoted-printable'){
buf = Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
} else buf = Buffer.from(rumpf, 'latin1');
const text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
let text;
if(enc === 'base64' || enc === 'quoted-printable'){
const buf = enc === 'base64'
? Buffer.from(rumpf.replace(/\s+/g, ''), 'base64')
: Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
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: '' };
}
@@ -7305,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) ----------
//
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt
@@ -9108,6 +9388,7 @@ app.post('/import', requireRecht('a.import'), express.raw({ type: '*/*', limit:
// Ältere Backups tragen Kommentare noch gewerkübergreifend in projects.
migrateKommentareJeGewerk();
kommentareVorsatzUmstellen();
entitiesReparieren();
// Ältere Backups kennen das Feld leistungsphase noch nicht.
phasenbalkenAusTitel();
console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)');
@@ -9138,7 +9419,7 @@ app.get('/', (req,res)=>{
// Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404
// enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um
// (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr.
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Notizen','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
// Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen)
const PLAN_SLUG = 'Projektplan';