Kleine Verbesserungen und Teams, Outlook Integration

- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine,
  Abwesenheiten als .ics), einzurichten unter Einstellungen
- Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt
  auf die Aufgabe; einzurichten unter Admin → Teams
- Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei
  davor, im Verlauf „Weitere laden“ in Fünferschritten
- Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei
  Meilensteinen, nur aktiv mit eingerichteter KI
- Kalender zeigt die KW, auch statt der Browser-Datumsfelder
- Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-30 08:23:02 +01:00
1 parent a2266662ad
commit 4c2e04e78b
19 files changed
+1099 -138

No files matched your search

+128 -15
View File
@@ -1422,8 +1422,9 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
`renderPlanKommentarVerlauf()` in `plan-info.js`): je Gewerk nur, wenn `renderPlanKommentarVerlauf()` in `plan-info.js`): je Gewerk nur, wenn
sich der Text gegenüber der Sitzung davor geändert hat; gleicher Text in sich der Text gegenüber der Sitzung davor geändert hat; gleicher Text in
mehreren Gewerken einer Sitzung (Altbestand aus der Umstellung auf mehreren Gewerken einer Sitzung (Altbestand aus der Umstellung auf
Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Drei sichtbar, Rest Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Gezeigt wird, was
über „Alle N anzeigen“. Keine neue Datenhaltung — jede Sitzung hält ihre geladen ist, darunter „Weitere laden“ (siehe „Sitzungen nach Bedarf
laden“). Keine neue Datenhaltung — jede Sitzung hält ihre
Kopie des Projekts. Stile: Block „Kommentar der Sitzung mit Verlauf“ am Kopie des Projekts. Stile: Block „Kommentar der Sitzung mit Verlauf“ am
Ende von `plan-styles.css`. Ende von `plan-styles.css`.
**Eigene Kachel „Protokolle“** (Eriks Wunsch; links unter „Termine und **Eigene Kachel „Protokolle“** (Eriks Wunsch; links unter „Termine und
@@ -1791,8 +1792,11 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze
`.eml`-Datei) oder Strg+V (außerhalb von Eingabefeldern) auf die Bereiche `.eml`-Datei) oder Strg+V (außerhalb von Eingabefeldern) auf die Bereiche
**Terminplan** oder **Aufgaben** eines Projekts; eine KI macht daraus **Terminplan** oder **Aufgaben** eines Projekts; eine KI macht daraus
Einträge. **Nur Aufgaben, keine Meilensteine** — Eriks Vorgabe: Der Einträge — **Aufgabe oder Meilenstein**: Die KI schlägt die Art vor
Terminplan ist nur die grafische Darstellung der Aufgabenliste. (`art` in `KI_MAIL_SCHEMA`), in der Auswahlliste `#mailAuswahlModal` (immer,
auch bei nur einem Vorschlag) steht je Zeile der Umschalter
„Aufgabe | Meilenstein“ (Eriks Wunsch). Danach öffnet sich je Eintrag das
passende Panel.
- Beim Hineinziehen erscheint die Fläche `#mailAblage` (fest, `pointer-events: - Beim Hineinziehen erscheint die Fläche `#mailAblage` (fest, `pointer-events:
none`, Zähler für dragenter/-leave). Nicht in gesperrten Sitzungen. Kein none`, Zähler für dragenter/-leave). Nicht in gesperrten Sitzungen. Kein
Konflikt mit dem Balken-Ziehen (das läuft über Maus-Ereignisse). Konflikt mit dem Balken-Ziehen (das läuft über Maus-Ereignisse).
@@ -1805,15 +1809,25 @@ Terminplan ist nur die grafische Darstellung der Aufgabenliste.
„nächsten Freitag“ stimmt. Ohne KI/bei Fehler: ein Eintrag mit dem Betreff „nächsten Freitag“ stimmt. Ohne KI/bei Fehler: ein Eintrag mit dem Betreff
als Titel. **Speichert nichts.** `.msg` (Outlook) wird abgewiesen mit dem als Titel. **Speichert nichts.** `.msg` (Outlook) wird abgewiesen mit dem
Hinweis, Text zu markieren. Hinweis, Text zu markieren.
- **Nur mit eingerichteter KI aktiv** (Eriks Wunsch): `mailKiStatusLaden()`
holt nach der Anmeldung (`afterAuthenticated()`) und nach jeder Änderung
der KI-Zugangsdaten (`putMyAiKey()`) die Anbieter mit Schlüssel; ohne
einen gibt es weder Ablagefläche noch abgefangenes Strg+V.
- Anbieter: zuletzt erfolgreich genutzter (`localStorage - Anbieter: zuletzt erfolgreich genutzter (`localStorage
projuebersicht-mail-ki`), sonst der erste mit Schlüssel. projuebersicht-mail-ki`), sonst der erste mit Schlüssel.
- Jeder Vorschlag öffnet das normale Aufgaben-Panel vorausgefüllt - Umschalter „Aufgabe | Meilenstein“: eigene Stile `.segmented.mail-auswahl-art`
(`openTodoModal(null, prefill)`, neu: `title`, `aufwand`, `notiz`, und Klasse `mail-art-knopf` (sonst greift unter Glas
`mailHinweis`, `nachSchliessen`); bei mehreren erst eine Auswahl `html.glas button:not([class])` mit Rand und Glanzkante, und der gewählte
(`#mailAuswahlModal`), dann nacheinander. Ohne Speichern entsteht nichts. Knopf war von `var(--surface)` kaum zu unterscheiden).
- **Notiz je Aufgabe** (`t.notiz`, `plan_todos.notiz`, Migration - Jeder gewählte Vorschlag öffnet nacheinander das normale Panel
`migrateAddPlanTodoNotiz()`, in `IMPORT_OPTIONAL_COLS`): Feld „Notiz“ im vorausgefüllt: `openTodoModal(null, prefill)` (`title`, `start`/`end`,
Aufgaben-Panel für jede Aufgabe; aus einer Mail mit Absender, Datum, `assignees`, `aufwand`, `notiz`, `mailHinweis`, `nachSchliessen`) bzw.
`openMilestoneModal(null, prefill)` (`name`, `date` = Ende des Vorschlags,
`notiz`, `mailHinweis`, `nachSchliessen`). Ohne Speichern entsteht nichts.
- **Notiz je Aufgabe und Meilenstein** (`t.notiz`/`m.notiz`,
`plan_todos.notiz`/`plan_milestones.notiz`, Migration
`migrateAddPlanTodoNotiz()`, in `IMPORT_OPTIONAL_COLS`): Feld „Notiz“ in
beiden Panels; aus einer Mail mit Absender, Datum,
Betreff, Bezugssatz und Mailtext. In der Aufgabenliste ein Symbol mit Betreff, Bezugssatz und Mailtext. In der Aufgabenliste ein Symbol mit
Tooltip. Stile: Block „Termin aus Mail“ am Ende von `plan-styles.css`. Tooltip. Stile: Block „Termin aus Mail“ am Ende von `plan-styles.css`.
@@ -3458,8 +3472,8 @@ nur, wenn sich der Kommentar DES GEWÄHLTEN GEWERKS gegenüber der Sitzung davor
geändert hat; Sitzungen dazwischen als grauer Vermerk („KW 40 bis KW 43 ohne geändert hat; Sitzungen dazwischen als grauer Vermerk („KW 40 bis KW 43 ohne
neuen Kommentar“); geänderte Leistungsphasen seit dem vorigen Eintrag als neuen Kommentar“); geänderte Leistungsphasen seit dem vorigen Eintrag als
Chips („LPH 5: 60 → 75 %“); Verfasser und Datum; „übernehmen“ Chips („LPH 5: 60 → 75 %“); Verfasser und Datum; „übernehmen“
(`berichtVerlaufUebernehmen()` → `takeCommentFromHistory()`); drei (`berichtVerlaufUebernehmen()` → `takeCommentFromHistory()`); gezeigt wird,
sichtbar, Rest über „Alle N anzeigen“ (`berichtVerlaufUmschalten()` zeichnet was geladen ist, „Weitere laden“ holt ältere (`berichtVerlaufNeu()` zeichnet
nur den Abschnitt neu, ein angefangener Kommentar bleibt). Die dichte Karte nur den Abschnitt neu, ein angefangener Kommentar bleibt). Die dichte Karte
bekommt dafür `opts.ohneVerlauf` (`renderBoardDrawer()`), damit der bekommt dafür `opts.ohneVerlauf` (`renderBoardDrawer()`), damit der
Kachel-Streifen dort nicht zusätzlich erscheint. Projektliste und Kachel-Streifen dort nicht zusätzlich erscheint. Projektliste und
@@ -4168,6 +4182,94 @@ across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr."
no special-casing needed. A redirect `GET /Test.html`-style old link isn't no special-casing needed. A redirect `GET /Test.html`-style old link isn't
needed here since nothing ever pointed at this tab before it existed. needed here since nothing ever pointed at this tab before it existed.
## Kalender-Abo für Outlook (iCalendar)
Eriks Wunsch: die eigenen Termine in Outlook sehen, nur zum Lesen.
Einstellungen → Mein Konto → Karte „Kalender in Outlook“
(`renderSettingsKalender()`, `js/settings/settings.js`): Adresse erzeugen,
kopieren, „In Outlook öffnen“ (`webcal:`-Link), Anleitung, „Neue Adresse“
(alte wird ungültig), „Abschalten“.
- **Server:** `users.kalender_token` (Migration in `migrateUsersDepartment()`,
leer = aus). `GET`/`POST /mein-kalender` (requireAuth, nur eigenes Konto;
POST `{aus:true}` schaltet ab, sonst neue Adresse). **`GET
/kalender/<token>.ics` ist ohne Sitzung erreichbar** — der Token (24 Byte
zufällig) IST die Anmeldung, weil Outlook sich nicht einloggen kann.
Unbekannt, deaktiviertes Konto oder Rolle ohne „Anwendung nutzen“ → 404.
- **Inhalt** (`kalenderEreignisse()`, rein und testbar): aus der jüngsten
echten Sitzung die eigenen offenen Aufgaben mit Termin (ohne erledigte und
ohne Phasenbalken), Meilensteine der Projekte, denen man zugeordnet ist
oder in denen man eine eigene Aufgabe hat, dazu die eigenen
Abwesenheiten. Alles ganztägig (`VALUE=DATE`, DTEND = Tag danach), als
„frei“ (Abwesenheit „abwesend“). Notizen gehen in die Beschreibung.
`kalenderText()` maskiert nach RFC 5545 und faltet Zeilen auf 75 Byte.
- **Adresse** (`kalenderUrl()`): der Host, über den der Browser die App
aufruft — wer über `localhost` öffnet, bekommt eine `localhost`-Adresse
(eine automatische Ersetzung durch die Geräte-IP hat Erik verworfen, weil
sie im Docker-Container nicht verlässlich ist).
- **Grenze:** Neues Outlook / Outlook im Browser holen das Abo über
Microsoft-Server aus dem Internet ab — dafür muss der Server von außen
erreichbar sein. Das klassische Outlook am PC holt es im Firmennetz direkt.
## Teams: Nachricht bei neuen Aufgaben
Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, erscheint in einem
Teams-Kanal eine Karte mit Link direkt auf die Aufgabe.
- **Einrichten:** In Teams im Kanal *Workflows* → „Beim Empfang einer
Webhook-Anfrage in einem Kanal posten“; die erzeugte https-Adresse unter
**Admin → Teams** eintragen (`loadAdminTeams()`, `js/admin/admin.js`,
Recht **`a.teams`**, neu im Katalog „Admin-Bereich“), „Testnachricht
senden“. Anleitung steht aufklappbar in der Karte.
- **Speicher:** Tabelle `integrationen` (schluessel/wert; `teams_webhook`,
`teams_neue_aufgaben`), bewusst NICHT `app_settings` (das darf jedes
Konto lesen) und nicht in `IMPORT_TABLES`. Die Adresse geht nie an den
Browser zurück — `GET /admin/teams` liefert nur `konfiguriert`/`host`.
`PUT /admin/teams {webhook?, neueAufgaben?}` (nur https), `POST
/admin/teams/test` (Fehler als `{ok:false}` mit HTTP 200).
- **Auslöser:** `neueZuweisungenMelden()` (aus `/save-week` und
`/save-projects`, jetzt mit `req`) sammelt je Aufgabe die NEU Zuständigen
— auch Personen ohne Konto, nicht wer sich selbst zuweist, keine
Phasenbalken, nie die Sandbox — und `teamsNeueAufgaben()` schickt je
Aufgabe EINE Adaptive Card (`teamsKarte()`: Titel, Projekt, Zuständig,
Fällig/Zeitraum, Aufwand, Zugewiesen von, Knopf „Aufgabe öffnen“). Im
Hintergrund; Fehler nur im Log, das Speichern scheitert nie daran.
- **Link** (`aufgabenLink()`): `/Projektplan/<Name>?weekLabel=…&projNr=…
&aufgabe=<todoId>#aufgaben`, Host aus der speichernden Anfrage (= Adresse
der App im Büro; funktioniert nur im Büronetz/VPN). Im Browser liest
`plan-state.js` `aufgabe` beim Laden (`startAufgabe`): öffnet die
JÜNGSTE Sitzung, falls der Link eine ältere nennt (Aufgaben-ids bleiben
beim Sitzungswechsel gleich), springt nach „Aufgaben“ und öffnet das
Aufgaben-Panel; Adresszeile wird nachgezogen.
- Braucht Internetzugang nach außen (Microsoft-Server).
## Sitzungen nach Bedarf laden (`js/store.js`)
Eriks Wunsch: `/load-all` wuchs jede Woche (alle Sitzungen, ~17 KB je
Woche). Jetzt lädt der Browser beim Start nur die jüngsten
`SITZUNGEN_START` (4: aktuelle + drei davor) über `GET /load-all?wochen=4`;
dieselbe Antwort trägt `wochenListe` = ALLE echten Sitzungen schlank
(`{id, label, date, locked, updatedAt}`, `echteSitzungenSortiert()`), im
Browser `db.wochenListe`. **`db.weeks` hält damit nur die GELADENEN
Sitzungen** — wer über `db.weeks` iteriert, sieht nicht alle.
- **Verlauf** (Wochenbericht „Verlauf der Vorwochen“, Kommentar-Verlauf im
Projekt-Dashboard, Verlauf-Kacheln der Projektliste): zeigt, was geladen
ist; darunter `weitereLadenKnopf()` → `weitereSitzungenLaden()` →
`GET /weeks-aelter?vor=<älteste geladene>&anzahl=5` holt je
`SITZUNGEN_NACHLADEN` (5) ältere. Die früheren Knöpfe „Alle N anzeigen“
sind entfallen. Der Wochenbericht zeichnet danach nur den Verlauf neu
(`berichtVerlaufNeu()`), damit ein angefangener Kommentar bleibt; der
Kachel-Verlauf merkt sich, ob er aufgeklappt war (`verlaufOffen`).
- **Nicht geladene Sitzungen bei Bedarf**: `sitzungSicherLaden(label)` (über
`GET /week`). Genutzt von Admin → Sitzungen (Liste aus `adminSitzungen()`
= alle; Entsperren/Öffnen/Löschen/Sperren nehmen jetzt das Label) und von
`openPlanView()` bei einem Link auf ein Projekt einer älteren Sitzung.
- Live: Ein Ereignis zu einer nicht geladenen Sitzung zieht nur deren Eintrag
in `db.wochenListe` nach, statt alles neu zu laden. `deleteWeekById()`
zählt für „mindestens eine Sitzung“ die `wochenListe`.
- `/load-all` ohne `wochen` liefert weiterhin alles (ältere Browser-Stände).
`loadWeeks()` nimmt dafür auch eine Liste von Labels (`IN (…)`).
- Gemessen: 98 KB statt 158 KB bei 9 Sitzungen; bleibt künftig etwa gleich,
statt mit jeder Woche zu wachsen.
## Gleichzeitiges Arbeiten (Zusammenführen statt Verwerfen, `js/store.js`) ## Gleichzeitiges Arbeiten (Zusammenführen statt Verwerfen, `js/store.js`)
Eine Sitzung wird weiterhin als Ganzes über `POST /save-week` mit Eine Sitzung wird weiterhin als Ganzes über `POST /save-week` mit
@@ -4324,6 +4426,17 @@ Rechner.
kein `backdrop-filter` — eigenes Design, Kiosk-Rechner) und kein `backdrop-filter` — eigenes Design, Kiosk-Rechner) und
`prefers-reduced-transparency` (deckende Flächen). `prefers-reduced-transparency` (deckende Flächen).
## Datums-Kalender mit KW (`js/date-picker.js`)
Eriks Wunsch: Jeder Kalender beim Anlegen/Bearbeiten zeigt die
Kalenderwoche. `renderRangePicker()` hat eine erste Spalte „KW“
(`getWeekNumber()`, `.range-picker-kw`, Popup 316px breit). Native
`<input type="date">` (Feiertage, Abwesenheiten in der Benutzerverwaltung,
„Aufgabe aus Kommentar“, Assistent, TODOs aus Protokoll) öffnen über einen
globalen Klick-Abfänger denselben Kalender statt des Browser-Kalenders; der
Wert wird ins Feld geschrieben und `input`/`change` ausgelöst. Ausnahme per
Attribut `data-nativ`.
## Dunkelmodus nachgeschärft (Kalender, Sitzungskommentar) ## Dunkelmodus nachgeschärft (Kalender, Sitzungskommentar)
Eriks Wahl „A“ aus einem Kontrast-Vergleich; Block „DUNKELMODUS Eriks Wahl „A“ aus einem Kontrast-Vergleich; Block „DUNKELMODUS
@@ -4460,8 +4573,8 @@ Speicherüberlauf. Vier Ebenen, jede fängt ab, was die darüber verpasst:
(`wlan-energiesparen-aus.service`), `wlan-waechter.timer` jede Minute — (`wlan-energiesparen-aus.service`), `wlan-waechter.timer` jede Minute —
ohne Gateway ab 2 min neu verbinden, nach 10 min `systemctl reboot`. ohne Gateway ab 2 min neu verbinden, nach 10 min `systemctl reboot`.
- **Prüfen:** `Docker/pi-pruefen.sh` (Speicher, Neustarts, OOM, Strom). - **Prüfen:** `Docker/pi-pruefen.sh` (Speicher, Neustarts, OOM, Strom).
- Offen (Schritt 4 des Plans): `/load-all` liefert weiter ALLE Sitzungen — - **Sitzungen nach Bedarf** (Schritt 4, umgesetzt 30.10.2026): siehe
wächst wöchentlich auf Server und Browser. „Sitzungen nach Bedarf laden“ unten.
## Docker ## Docker
+54 -16
View File
@@ -38,8 +38,8 @@
<link rel="icon" type="image/png" href="/logo.png"> <link rel="icon" type="image/png" href="/logo.png">
<link rel="alternate icon" href="/logo.ico"> <link rel="alternate icon" href="/logo.ico">
<link rel="apple-touch-icon" href="/logo.png"> <link rel="apple-touch-icon" href="/logo.png">
<link rel="stylesheet" href="/styles.css?v=106"> <link rel="stylesheet" href="/styles.css?v=110">
<link rel="stylesheet" href="/plan-styles.css?v=43"> <link rel="stylesheet" href="/plan-styles.css?v=45">
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit <!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen, der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
@@ -234,6 +234,7 @@
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button> <button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button> <button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb &amp; Sicherheit</button> <button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb &amp; Sicherheit</button>
<button class="menu-item sidebar-subitem" data-admin-section="teams" onclick="showAdminSection('teams')">Teams</button>
<button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button> <button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button>
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button> <button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button>
</div> </div>
@@ -455,6 +456,16 @@
mit Fehlertext, der bis zum nächsten Testergebnis stehen bleibt mit Fehlertext, der bis zum nächsten Testergebnis stehen bleibt
(Design-Entwürfe mit Erik abgestimmt: Artifacts d7ab2945, (Design-Entwürfe mit Erik abgestimmt: Artifacts d7ab2945,
01fe1346). --> 01fe1346). -->
<!-- Kalender-Abo für Outlook (renderSettingsKalender(), js/settings/settings.js;
Server: /mein-kalender, /kalender/<token>.ics) -->
<section class="settings-card" id="settingsKalenderCard">
<h3 class="settings-card-title">Kalender in Outlook</h3>
<p class="hint">Ihre offenen Aufgaben, die Meilensteine Ihrer Projekte und Ihre
Abwesenheiten als Kalender in Outlook — nur zum Ansehen, geändert wird weiter hier.
Outlook aktualisiert ihn selbst (je nach Outlook stündlich bis alle paar Stunden).</p>
<div id="settingsKalender"></div>
</section>
<section class="settings-card" id="settingsAiCard"> <section class="settings-card" id="settingsAiCard">
<h3 class="settings-card-title">KI-Zugangsdaten</h3> <h3 class="settings-card-title">KI-Zugangsdaten</h3>
<p class="hint">Für die automatische Punkt-Erkennung beim Protokoll-Import <p class="hint">Für die automatische Punkt-Erkennung beim Protokoll-Import
@@ -665,6 +676,28 @@
</div> </div>
</section> </section>
<!-- Teams-Anbindung (loadAdminTeams(), js/admin/admin.js; Server: /admin/teams*) -->
<section class="settings-card" id="adminTeamsCard" data-admin-section="teams" style="display:none">
<h3 class="settings-card-title">Teams <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
<p class="hint">Bekommt jemand eine Aufgabe zugewiesen, erscheint in einem Teams-Kanal eine
Nachricht mit Link direkt auf die Aufgabe im Projekt.</p>
<div id="adminTeamsInhalt"></div>
<details class="kalender-anleitung">
<summary>So richten Sie die Adresse in Teams ein</summary>
<ol>
<li>In Teams den gewünschten Kanal öffnen, oben bei den drei Punkten
<i>Workflows</i> wählen.</li>
<li>Die Vorlage <i>„Beim Empfang einer Webhook-Anfrage in einem Kanal posten“</i>
auswählen, einen Namen vergeben (z. B. „Projektübersicht“) und bestätigen.</li>
<li>Teams zeigt am Ende eine lange Adresse (beginnt mit https://). Diese kopieren,
hier einfügen und speichern.</li>
<li>„Testnachricht senden“ — im Kanal sollte eine Karte erscheinen.</li>
</ol>
<p class="hint">Der Server braucht dafür Internetzugang nach außen. Der Link in der
Nachricht führt auf die Adresse, unter der die App im Büro aufgerufen wird — er
funktioniert also nur im Büronetz (bzw. per VPN).</p>
</details>
</section>
<section class="settings-card" id="adminBetriebCard" data-admin-section="betrieb" style="display:none"> <section class="settings-card" id="adminBetriebCard" data-admin-section="betrieb" style="display:none">
<h3 class="settings-card-title">Betrieb &amp; Sicherheit <span class="settings-scope">firmenweit · nur Administratoren</span></h3> <h3 class="settings-card-title">Betrieb &amp; Sicherheit <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
<div class="settings-field-grid"> <div class="settings-field-grid">
@@ -1522,6 +1555,11 @@
<h3>Hängt ab von</h3> <h3>Hängt ab von</h3>
<div id="milestoneEditDependsOn" class="todo-depends-list pd-liste"></div> <div id="milestoneEditDependsOn" class="todo-depends-list pd-liste"></div>
</section> </section>
<section class="pd-abs">
<h3>Notiz</h3>
<p class="hint pd-hint pd-mail-hinweis" id="milestoneEditMailHinweis" hidden></p>
<textarea id="milestoneEditNotiz" class="pd-notiz" rows="4" placeholder="Optional, z. B. Hintergrund oder die Mail, aus der der Meilenstein stammt" aria-label="Notiz"></textarea>
</section>
</div> </div>
<div class="pd-fuss"> <div class="pd-fuss">
<button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button> <button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button>
@@ -1912,21 +1950,21 @@
</main> </main>
</div> </div>
<script src="/js/globals.js?v=8"></script> <script src="/js/globals.js?v=9"></script>
<script src="/js/utils.js?v=30"></script> <script src="/js/utils.js?v=31"></script>
<script src="/js/icons.js?v=2"></script> <script src="/js/icons.js?v=2"></script>
<script src="/js/ui.js?v=9"></script> <script src="/js/ui.js?v=9"></script>
<script src="/js/date-picker.js?v=5"></script> <script src="/js/date-picker.js?v=6"></script>
<script src="/js/server.js?v=7"></script> <script src="/js/server.js?v=7"></script>
<script src="/js/auth.js?v=10"></script> <script src="/js/auth.js?v=11"></script>
<script src="/js/store.js?v=14"></script> <script src="/js/store.js?v=15"></script>
<script src="/js/weeks.js?v=11"></script> <script src="/js/weeks.js?v=12"></script>
<script src="/js/board/board.js?v=41"></script> <script src="/js/board/board.js?v=41"></script>
<script src="/js/wochenbericht/wochenbericht.js?v=28"></script> <script src="/js/wochenbericht/wochenbericht.js?v=29"></script>
<script src="/js/people/people.js?v=8"></script> <script src="/js/people/people.js?v=8"></script>
<script src="/js/planung/planung.js?v=3"></script> <script src="/js/planung/planung.js?v=3"></script>
<script src="/js/planchecker/planchecker.js?v=1"></script> <script src="/js/planchecker/planchecker.js?v=1"></script>
<script src="/js/abwesenheit/abwesenheit.js?v=9"></script> <script src="/js/abwesenheit/abwesenheit.js?v=10"></script>
<script src="/js/projects/projects.js?v=10"></script> <script src="/js/projects/projects.js?v=10"></script>
<script src="/js/load/load.js?v=23"></script> <script src="/js/load/load.js?v=23"></script>
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil <!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
@@ -1944,8 +1982,8 @@
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
Tab-Ordner, keine neue Abhängigkeit nach vorn. --> Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
<script src="/js/wand/wand.js?v=21"></script> <script src="/js/wand/wand.js?v=21"></script>
<script src="/js/settings/settings.js?v=12"></script> <script src="/js/settings/settings.js?v=14"></script>
<script src="/js/admin/admin.js?v=19"></script> <script src="/js/admin/admin.js?v=21"></script>
<script src="/js/admin-users.js?v=10"></script> <script src="/js/admin-users.js?v=10"></script>
<script src="/js/admin/rollen.js?v=1"></script> <script src="/js/admin/rollen.js?v=1"></script>
<script src="/js/einfuehrung.js?v=2"></script> <script src="/js/einfuehrung.js?v=2"></script>
@@ -1957,21 +1995,21 @@
(das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach (das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach
js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js
umhuellt. --> umhuellt. -->
<script src="/js/plan/plan-state.js?v=21"></script> <script src="/js/plan/plan-state.js?v=24"></script>
<script src="/js/plan/plan-sections.js?v=10"></script> <script src="/js/plan/plan-sections.js?v=10"></script>
<script src="/js/plan/plan-info.js?v=14"></script> <script src="/js/plan/plan-info.js?v=15"></script>
<script src="/js/plan/plan-history.js?v=4"></script> <script src="/js/plan/plan-history.js?v=4"></script>
<script src="/js/plan/plan-build-gantt.js?v=11"></script> <script src="/js/plan/plan-build-gantt.js?v=11"></script>
<script src="/js/plan/plan-gantt.js?v=21"></script> <script src="/js/plan/plan-gantt.js?v=21"></script>
<script src="/js/plan/plan-drag.js?v=8"></script> <script src="/js/plan/plan-drag.js?v=8"></script>
<script src="/js/plan/plan-modals.js?v=14"></script> <script src="/js/plan/plan-modals.js?v=15"></script>
<script src="/js/plan/plan-tasks.js?v=4"></script> <script src="/js/plan/plan-tasks.js?v=4"></script>
<script src="/js/plan/plan-phasen.js?v=2"></script> <script src="/js/plan/plan-phasen.js?v=2"></script>
<script src="/js/plan/plan-files.js?v=4"></script> <script src="/js/plan/plan-files.js?v=4"></script>
<script src="/js/plan/plan-protokoll.js?v=20"></script> <script src="/js/plan/plan-protokoll.js?v=20"></script>
<script src="/js/plan/plan-templates.js?v=11"></script> <script src="/js/plan/plan-templates.js?v=11"></script>
<script src="/js/plan/plan-protokoll-todos.js?v=2"></script> <script src="/js/plan/plan-protokoll-todos.js?v=2"></script>
<script src="/js/plan/plan-mail-eintrag.js?v=1"></script> <script src="/js/plan/plan-mail-eintrag.js?v=4"></script>
<script src="/js/plan/plan-honorar.js?v=9"></script> <script src="/js/plan/plan-honorar.js?v=9"></script>
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen <!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen
+2 -2
View File
@@ -265,9 +265,9 @@ function abwTeamHtml(){
<div class="section-title" style="margin:0">Team im ${esc(abwMonat.toLocaleDateString('de-DE', { month: 'long' }))}</div> <div class="section-title" style="margin:0">Team im ${esc(abwMonat.toLocaleDateString('de-DE', { month: 'long' }))}</div>
<span class="abw-team-steuer"> <span class="abw-team-steuer">
<span class="abw-monat"> <span class="abw-monat">
<button type="button" class="abw-rund" onclick="abwMonatWechseln(-1)" aria-label="Voriger Monat">‹</button> <button type="button" class="abw-rund" onclick="abwMonatWechseln(-1)" aria-label="Voriger Monat" title="Voriger Monat">${icon('chevronLeft')}</button>
<strong>${esc(monatName)}</strong> <strong>${esc(monatName)}</strong>
<button type="button" class="abw-rund" onclick="abwMonatWechseln(1)" aria-label="Nächster Monat">›</button> <button type="button" class="abw-rund" onclick="abwMonatWechseln(1)" aria-label="Nächster Monat" title="Nächster Monat">${icon('chevronRight')}</button>
<button type="button" class="secondary small" onclick="abwMonatWechseln(0)">Heute</button> <button type="button" class="secondary small" onclick="abwMonatWechseln(0)">Heute</button>
</span> </span>
<span class="abw-reiter" role="group" aria-label="Gewerk">${['Alle'].concat(gewerke).map(g => `<button type="button" class="${g === abwFilter ? 'ist-an' : ''}" onclick="abwFilterSetzen('${g}')">${g}</button>`).join('')}</span> <span class="abw-reiter" role="group" aria-label="Gewerk">${['Alle'].concat(gewerke).map(g => `<button type="button" class="${g === abwFilter ? 'ist-an' : ''}" onclick="abwFilterSetzen('${g}')">${g}</button>`).join('')}</span>
+94 -14
View File
@@ -64,6 +64,7 @@ const ADMIN_SECTIONS = [
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' }, { key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' }, { key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' }, { key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' },
{ key: 'teams', title: 'Teams', recht: 'a.teams' },
{ key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' }, { key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] }, { key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] },
]; ];
@@ -159,6 +160,7 @@ function showAdminSection(key){
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso // Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
// frisch holen. // frisch holen.
if(key === 'papierkorb') loadAdminPapierkorb(); if(key === 'papierkorb') loadAdminPapierkorb();
if(key === 'teams') loadAdminTeams();
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte(); if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
try { try {
@@ -287,6 +289,14 @@ function juengsteWoche(){
return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop(); return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop();
} }
// adminSitzungen: ALLE Sitzungen für die Liste — geladene als volles Objekt
// (aktueller Sperrstand), nicht geladene aus db.wochenListe (schlank). Seit
// beim Start nur die jüngsten Sitzungen geladen werden (js/store.js).
function adminSitzungen(){
const liste = (db.wochenListe && db.wochenListe.length) ? db.wochenListe : db.weeks;
return liste.map(k => db.weeks.find(w => w.label === k.label) || k);
}
// renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks), // renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks),
// neueste zuerst, mit Datum, Zustand und den passenden Aktionen. // neueste zuerst, mit Datum, Zustand und den passenden Aktionen.
// //
@@ -321,7 +331,7 @@ function renderAdminSitzungen(){
if(!el || !Array.isArray(db.weeks)) return; if(!el || !Array.isArray(db.weeks)) return;
const juengste = juengsteWoche(); const juengste = juengsteWoche();
const sortiert = [...db.weeks].sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label))); const sortiert = adminSitzungen().sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
el.innerHTML = sortiert.map(w => { el.innerHTML = sortiert.map(w => {
const aktuell = w.id === db.currentWeek; const aktuell = w.id === db.currentWeek;
const istJuengste = juengste && w.id === juengste.id; const istJuengste = juengste && w.id === juengste.id;
@@ -337,20 +347,20 @@ function renderAdminSitzungen(){
// jede Aktion und hätte keinen Weg zurück außer über eine andere, // jede Aktion und hätte keinen Weg zurück außer über eine andere,
// zufällige applyAutoLock()-auslösende Handlung. // zufällige applyAutoLock()-auslösende Handlung.
chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>'; chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>';
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`; aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.label)}')">Fertig – wieder sperren</button>`;
} else if(w.locked){ } else if(w.locked){
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>'; chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>` : ''; aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.label)}')">Entsperren</button>` : '';
} else { } else {
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>'; chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`; aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.label)}')">Öffnen</button>`;
} }
return `<div class="template-admin-row"> return `<div class="template-admin-row">
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span> <span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
${chip} ${chip}
${aktionen} ${aktionen}
${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>` : ''} ${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.label)}')" title="Sitzung löschen">×</button>` : ''}
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -363,15 +373,22 @@ function renderAdminSitzungen(){
// nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert // nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert
// hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund // hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund
// zur aktuellen zu machen. // zur aktuellen zu machen.
function adminUnlockWeek(weekId){ // Alle vier nehmen das Label: Die Sitzung ist evtl. nicht geladen und wird
unlockWeek(db.weeks.find(w => w.id === weekId), renderAdminSitzungen); // dafür erst nachgeholt (sitzungSicherLaden(), js/store.js).
function adminMitSitzung(label, fn){
sitzungSicherLaden(label).then(w => {
if(!w){ alert('Die Sitzung ' + label + ' wurde am Server nicht gefunden.'); renderAdminSitzungen(); return; }
fn(w);
}).catch(() => alert('Die Sitzung ' + label + ' konnte nicht geladen werden.'));
} }
function adminDeleteWeek(weekId){ function adminUnlockWeek(label){
deleteWeekById(weekId, renderAdminSitzungen); adminMitSitzung(label, w => unlockWeek(w, () => { sitzungKurzAbgleichen(w); renderAdminSitzungen(); }));
} }
function adminOpenWeek(weekId){ function adminDeleteWeek(label){
switchWeek(weekId); adminMitSitzung(label, w => deleteWeekById(w.id, renderAdminSitzungen));
showTab('board'); }
function adminOpenWeek(label){
adminMitSitzung(label, w => { switchWeek(w.id); showTab('board'); });
} }
// adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene // adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene
@@ -383,10 +400,11 @@ function adminOpenWeek(weekId){
// markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT // markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT
// die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in // die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in
// js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe. // js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe.
function adminRelockWeek(weekId){ function adminRelockWeek(label){
const w = db.weeks.find(x => x.id === weekId); const w = db.weeks.find(x => x.label === label);
if(!w || w.locked) return; if(!w || w.locked) return;
w.locked = true; w.locked = true;
sitzungKurzAbgleichen(w);
markWeekDirty(w.label); markWeekDirty(w.label);
save({ week: false }); save({ week: false });
const juengste = juengsteWoche(); const juengste = juengsteWoche();
@@ -1083,3 +1101,65 @@ async function adminProjektEndgueltigLoeschen(id){
alert('Löschen fehlgeschlagen: ' + err.message); alert('Löschen fehlgeschlagen: ' + err.message);
} }
} }
// ---------- Teams (Admin → Teams, Server: /admin/teams*) ----------
// Die Webhook-Adresse ist ein Geheimnis und kommt nie zurück in den Browser;
// angezeigt wird nur, ob eine eingetragen ist (und an welchen Host).
async function loadAdminTeams(){
const el = document.getElementById('adminTeamsInhalt');
if(!el) return;
let d;
try { d = await fetch(baseUrl() + '/admin/teams').then(r => r.json()); }
catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
if(!d || !d.ok){ el.innerHTML = '<span class="settings-warn">Keine Berechtigung.</span>'; return; }
el.innerHTML = `
<div class="settings-kv-row"><span class="settings-kv-key">Status</span>
<span>${d.konfiguriert ? '<span class="sitzung-chip ist-geoeffnet">Verbunden</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 })"> Neue Aufgaben melden</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>
<p class="field-error" id="teamsFehler" hidden></p>`;
}
async function teamsSpeichern(patch){
if('webhook' in patch && !String(patch.webhook || '').trim()){ alert('Bitte die Adresse aus dem Teams-Workflow einfügen.'); return; }
const r = await fetch(baseUrl() + '/admin/teams', {
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(patch)
});
const d = await r.json().catch(() => ({}));
if(!r.ok || !d.ok){ alert(d.error || 'Speichern fehlgeschlagen.'); return; }
// Nach dem ersten Eintragen der Adresse die Meldungen gleich einschalten.
if('webhook' in patch && !document.getElementById('teamsNeueAufgaben').checked){
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ neueAufgaben: true }) });
}
loadAdminTeams();
}
async function teamsEntfernen(){
if(!confirm('Teams-Anbindung entfernen? Es werden keine Nachrichten mehr gesendet.')) return;
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ webhook: '', neueAufgaben: false }) });
loadAdminTeams();
}
async function teamsTesten(){
const knopf = document.getElementById('teamsTestKnopf');
const fehler = document.getElementById('teamsFehler');
if(knopf){ knopf.disabled = true; knopf.textContent = 'Wird gesendet…'; }
try {
const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json());
if(fehler){ fehler.hidden = d.ok; fehler.textContent = d.ok ? '' : 'Nicht gesendet: ' + (d.error || 'unbekannter Fehler'); }
if(d.ok) alert('Testnachricht gesendet — bitte im Teams-Kanal nachsehen.');
} catch(e){
if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; }
} finally {
if(knopf){ knopf.disabled = false; knopf.textContent = 'Testnachricht senden'; }
}
}
+2
View File
@@ -155,6 +155,8 @@ function afterAuthenticated(onAuthenticated){
hideLoginOverlay(); hideLoginOverlay();
renderUserBar(); renderUserBar();
onAuthenticated(); onAuthenticated();
// "Termin aus Mail" (js/plan/plan-mail-eintrag.js) nur mit eingerichteter KI.
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
if(currentUser && currentUser.mustChangePassword) openPasswordModal(true); if(currentUser && currentUser.mustChangePassword) openPasswordModal(true);
// Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind // Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind
// und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst // und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst
+26 -2
View File
@@ -40,7 +40,7 @@ function openDatePicker(opts){
document.body.appendChild(pop); document.body.appendChild(pop);
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 }; const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px'; pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 332)) + 'px';
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px'; pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
renderRangePicker(); renderRangePicker();
@@ -80,7 +80,7 @@ function openRangePicker(opts){
// Position: unterhalb des Ankers, innerhalb des Viewports halten // Position: unterhalb des Ankers, innerhalb des Viewports halten
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 }; const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px'; pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 332)) + 'px';
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px'; pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
renderRangePicker(); renderRangePicker();
@@ -160,6 +160,14 @@ function applyRangePicker(){
const selected = start || end; const selected = start || end;
if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : ''); if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : '');
if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : ''); if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : '');
// Für umgeleitete native Datumsfelder (siehe unten): deren onchange/
// oninput-Handler sollen wie bei einer Eingabe von Hand laufen.
[startInput, endInput].forEach(el => {
if(el && el.type === 'date'){
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
});
if(opts.displayId){ if(opts.displayId){
const disp = document.getElementById(opts.displayId); const disp = document.getElementById(opts.displayId);
if(disp){ if(disp){
@@ -199,10 +207,15 @@ function renderRangePicker(){
</div> </div>
<div class="range-picker-hint">${hint}</div> <div class="range-picker-hint">${hint}</div>
<div class="range-picker-grid">`; <div class="range-picker-grid">`;
// Erste Spalte: Kalenderwoche (Eriks Wunsch — im Büro wird in KW gedacht).
html += '<div class="range-picker-weekday range-picker-kw-kopf">KW</div>';
['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`); ['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`);
const kwZelle = d => `<div class="range-picker-kw">${getWeekNumber(d)}</div>`;
if(firstWeekday > 0) html += kwZelle(first);
for(let i = 0; i < firstWeekday; i++) html += '<div></div>'; for(let i = 0; i < firstWeekday; i++) html += '<div></div>';
for(let day = 1; day <= daysInMonth; day++){ for(let day = 1; day <= daysInMonth; day++){
const d = new Date(st.viewYear, st.viewMonth, day); const d = new Date(st.viewYear, st.viewMonth, day);
if(d.getDay() === 1) html += kwZelle(d);
const iso = formatDateISO(d); const iso = formatDateISO(d);
const classes = ['range-picker-day']; const classes = ['range-picker-day'];
if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend'); if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend');
@@ -219,3 +232,14 @@ function renderRangePicker(){
</div>`; </div>`;
pop.innerHTML = html; pop.innerHTML = html;
} }
// Native Datumsfelder (<input type="date">) öffnen statt des Browser-
// Kalenders (kennt keine KW) denselben Kalender wie der Rest der App. Das
// Feld selbst bleibt — Tippen funktioniert weiter, der Picker schreibt den
// ISO-Wert hinein und löst input/change aus. Ausnahme: data-nativ.
document.addEventListener('click', e => {
const input = e.target && e.target.closest ? e.target.closest('input[type="date"]') : null;
if(!input || input.disabled || input.readOnly || input.hasAttribute('data-nativ')) return;
e.preventDefault();
if(!input.id) input.id = 'datum_' + Math.random().toString(36).slice(2, 9);
openDatePicker({ anchor: input, inputId: input.id });
}, true);
+3
View File
@@ -108,6 +108,9 @@ let saveSyncTimer = null;
// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung. // In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung.
// globalPeople ist ein flaches Array (keine Abteilungstrennung mehr). // globalPeople ist ein flaches Array (keine Abteilungstrennung mehr).
let db = { weeks: [], currentWeek: null, globalPeople: [], let db = { weeks: [], currentWeek: null, globalPeople: [],
// wochenListe: ALLE Sitzungen schlank (id, label, date, locked, updatedAt) —
// db.weeks hält nur die geladenen (siehe SITZUNGEN_START, js/store.js).
wochenListe: [],
// sandboxWeek: Platzhalter bis zum ersten erfolgreichen load() (js/store.js) // sandboxWeek: Platzhalter bis zum ersten erfolgreichen load() (js/store.js)
// befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht // befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht
// crasht, sondern nur eine leere Planung-Liste zeigt. // crasht, sondern nur eine leere Planung-Liste zeigt.
+9 -16
View File
@@ -132,18 +132,17 @@
return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2]; return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2];
} }
const PLAN_VERLAUF_KURZ = 3;
let planVerlaufAlle = false;
let planVerlaufProjekt = null;
function renderPlanKommentarVerlauf(verlauf){ function renderPlanKommentarVerlauf(verlauf){
const el = document.getElementById('planStatusVerlauf'); const el = document.getElementById('planStatusVerlauf');
if(!el) return; if(!el) return;
const uid = app.project && (app.project.projectUid || app.project.id); // Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
if(uid !== planVerlaufProjekt){ planVerlaufProjekt = uid; planVerlaufAlle = false; } // aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
if(!verlauf.length){ el.hidden = true; el.innerHTML = ''; return; } // js/store.js). Die Sandbox hat keine früheren Fassungen.
const weitere = app.isSandboxPlan() ? '' : weitereLadenKnopf();
if(!verlauf.length && !weitere){ el.hidden = true; el.innerHTML = ''; return; }
el.hidden = false; el.hidden = false;
const zeigen = planVerlaufAlle ? verlauf : verlauf.slice(0, PLAN_VERLAUF_KURZ); const zeigen = verlauf;
const zeilen = zeigen.map((e, i) => ` const zeilen = zeigen.map((e, i) => `
<li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}"> <li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}">
<span class="plan-verlauf-kw">${esc(kurzKw(e.week.label))}</span> <span class="plan-verlauf-kw">${esc(kurzKw(e.week.label))}</span>
@@ -153,21 +152,15 @@
<div class="plan-verlauf-meta">${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}</div> <div class="plan-verlauf-meta">${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}</div>
</div> </div>
</li>`).join(''); </li>`).join('');
const mehr = verlauf.length > PLAN_VERLAUF_KURZ const mehr = weitere ? `<div class="plan-verlauf-mehr-zeile">${weitere}</div>` : '';
? `<button type="button" class="plan-verlauf-mehr" onclick="planVerlaufUmschalten()">${planVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + verlauf.length + ' anzeigen'}</button>`
: '';
el.innerHTML = ` el.innerHTML = `
<div class="plan-verlauf-kopf"> <div class="plan-verlauf-kopf">
<span class="plan-verlauf-titel">Verlauf</span> <span class="plan-verlauf-titel">Verlauf</span>
<span class="plan-verlauf-anzahl">${verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span> <span class="plan-verlauf-anzahl">${verlauf.length === 0 ? 'in den geladenen Sitzungen kein früherer Kommentar' : verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span>
</div> </div>
<ol class="plan-verlauf-liste">${zeilen}</ol>${mehr}`; ${zeilen ? `<ol class="plan-verlauf-liste">${zeilen}</ol>` : ''}${mehr}`;
} }
window.planVerlaufUmschalten = function(){
planVerlaufAlle = !planVerlaufAlle;
window.renderPlanInfo();
};
window.renderPlanInfo = function(){ window.renderPlanInfo = function(){
+56 -23
View File
@@ -1,21 +1,31 @@
// plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per // plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per
// Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf // Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf
// den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus // den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus
// Aufgaben. Der Terminplan ist nur die grafische Darstellung der // Einträge.
// Aufgabenliste — deshalb entstehen hier ausschließlich Aufgaben
// (plan.todos), keine Meilensteine.
// //
// Nichts wird ohne Prüfung angelegt: Jeder Vorschlag öffnet das gewohnte // Nichts wird ohne Prüfung angelegt: Die Vorschläge stehen erst in einer
// Aufgaben-Panel vorausgefüllt (openTodoModal(null, prefill)), die Mail // Liste — je Zeile abhaken und „Aufgabe | Meilenstein“ wählen (Eriks
// steht als Notiz darin (t.notiz). Mehrere Vorschläge erst als Liste zum // Wunsch; die KI schlägt die Art vor) —, dann öffnet sich je Eintrag das
// Abhaken, dann nacheinander im Panel. Server: POST /plan-eintrag-aus-mail // gewohnte Panel vorausgefüllt (openTodoModal/openMilestoneModal mit
// (speichert nichts). // prefill), die Mail steht als Notiz darin (t.notiz bzw. m.notiz).
// Server: POST /plan-eintrag-aus-mail (speichert nichts).
(function(){ (function(){
const app = window.PlanApp; const app = window.PlanApp;
const ANBIETER_KEY = 'projuebersicht-mail-ki'; const ANBIETER_KEY = 'projuebersicht-mail-ki';
const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben']; const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben'];
let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit) let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit)
let laeuft = false; let laeuft = false;
// kiAnbieter: die Anbieter mit hinterlegtem Schlüssel. Ohne eingerichtete
// KI ist die Funktion ganz aus (Eriks Wunsch) — keine Ablagefläche, kein
// Abfangen von Strg+V. Geladen nach der Anmeldung und nach jeder Änderung
// unter Einstellungen → KI-Zugangsdaten (mailKiStatusLaden()).
let kiAnbieter = [];
window.mailKiStatusLaden = function(){
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
kiAnbieter = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
}).catch(() => { kiAnbieter = []; });
};
function aktiverAbschnitt(){ function aktiverAbschnitt(){
if(typeof currentTab === 'undefined' || currentTab !== 'plan') return ''; if(typeof currentTab === 'undefined' || currentTab !== 'plan') return '';
@@ -24,7 +34,7 @@
} }
function ablageErlaubt(){ function ablageErlaubt(){
return ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked(); return kiAnbieter.length > 0 && ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked();
} }
function bringtMail(dt){ function bringtMail(dt){
@@ -44,7 +54,7 @@
el.hidden = true; el.hidden = true;
el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')} el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')}
<b id="mailAblageTitel">Mail hier ablegen</b> <b id="mailAblageTitel">Mail hier ablegen</b>
<span id="mailAblageText">Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.</span></div>`; <span id="mailAblageText"></span></div>`;
document.body.appendChild(el); document.body.appendChild(el);
} }
return el; return el;
@@ -139,12 +149,9 @@
// ---------- Auswerten ---------- // ---------- Auswerten ----------
function anbieterWaehlen(){ function anbieterWaehlen(){
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => { let zuletzt = '';
const bereit = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured); try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
let zuletzt = ''; return Promise.resolve(kiAnbieter.includes(zuletzt) ? zuletzt : (kiAnbieter[0] || ''));
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
return bereit.includes(zuletzt) ? zuletzt : (bereit[0] || '');
}).catch(() => '');
} }
function projektPersonen(){ function projektPersonen(){
@@ -229,7 +236,8 @@
app.showToast('In der Mail steht nichts für den Terminplan.'); app.showToast('In der Mail steht nichts für den Terminplan.');
return; return;
} }
if(eintraege.length === 1){ nacheinanderOeffnen(d, eintraege); return; } // Auch bei nur einem Vorschlag erst die Liste — dort wird gewählt, ob es
// eine Aufgabe oder ein Meilenstein wird.
auswahlZeigen(d, eintraege); auswahlZeigen(d, eintraege);
} }
@@ -240,6 +248,16 @@
const e = rest.shift(); const e = rest.shift();
if(!e){ renderPlanGantt(); return; } if(!e){ renderPlanGantt(); return; }
const nr = gesamt - rest.length; const nr = gesamt - rest.length;
if(e.art === 'meilenstein'){
openMilestoneModal(null, {
name: e.titel,
date: e.ende || e.start,
notiz: notizText(d.mail || {}, e),
mailHinweis: (gesamt > 1 ? `Vorschlag ${nr} von ${gesamt}. ` : '') + hinweisText(d),
nachSchliessen: naechster
});
return;
}
openTodoModal(null, { openTodoModal(null, {
title: e.titel, title: e.titel,
start: e.start, start: e.start,
@@ -271,20 +289,35 @@
const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende)); const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende));
return a === b ? a : a + ' – ' + b; return a === b ? a : a + ' – ' + b;
}; };
const arten = eintraege.map(e => e.art === 'meilenstein' ? 'meilenstein' : 'aufgabe');
bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail"> bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail">
<h2>${eintraege.length} Einträge in der Mail</h2> <h2>${eintraege.length === 1 ? '1 Eintrag' : eintraege.length + ' Einträge'} in der Mail</h2>
<p class="hint">${esc(hinweisText(d))} Die ausgewählten öffnen sich nacheinander im Aufgaben-Panel.</p> <p class="hint">${esc(hinweisText(d))} Wähle je Eintrag, ob er eine Aufgabe oder ein Meilenstein wird. Die ausgewählten öffnen sich danach nacheinander zum Prüfen.</p>
<div class="mail-auswahl-liste">${eintraege.map((e, i) => ` <div class="mail-auswahl-liste">${eintraege.map((e, i) => `
<label class="mail-auswahl-zeile"><input type="checkbox" checked data-i="${i}"> <div class="mail-auswahl-zeile">
<span class="mail-auswahl-titel">${esc(e.titel)}</span> <input type="checkbox" checked data-i="${i}" aria-label="${esc(e.titel)} übernehmen">
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></label>`).join('')}</div> <span class="mail-auswahl-text"><span class="mail-auswahl-titel">${esc(e.titel)}</span>
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></span>
<span class="segmented mail-auswahl-art" role="group" aria-label="Art" data-i="${i}">
<button type="button" class="mail-art-knopf" data-art="aufgabe" aria-pressed="${arten[i] === 'aufgabe'}">Aufgabe</button>
<button type="button" class="mail-art-knopf" data-art="meilenstein" aria-pressed="${arten[i] === 'meilenstein'}">Meilenstein</button>
</span>
</div>`).join('')}</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button> <button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button>
<button type="button" data-aktion="weiter">Prüfen und anlegen</button> <button type="button" data-aktion="weiter">Prüfen und anlegen</button>
</div></div>`; </div></div>`;
bg.querySelectorAll('.mail-auswahl-art button').forEach(b => {
b.onclick = () => {
const i = Number(b.parentElement.dataset.i);
arten[i] = b.dataset.art;
b.parentElement.querySelectorAll('button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
};
});
bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; }; bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; };
bg.querySelector('[data-aktion="weiter"]').onclick = () => { bg.querySelector('[data-aktion="weiter"]').onclick = () => {
const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked')).map(cb => eintraege[Number(cb.dataset.i)]); const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => { const i = Number(cb.dataset.i); return Object.assign({}, eintraege[i], { art: arten[i] }); });
bg.style.display = 'none'; bg.style.display = 'none';
if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt); if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt);
}; };
+25 -4
View File
@@ -33,8 +33,15 @@
// (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum, // (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum,
// Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und // Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und
// TODOs) aus dem Plan. // TODOs) aus dem Plan.
window.openMilestoneModal = function(index){ // msNachSchliessen: wie todoNachSchliessen — "Termin aus Mail" öffnet
// damit nach dem Schließen den nächsten Vorschlag.
let msNachSchliessen = null;
// openMilestoneModal: Parameter index (null = neu), prefill (optional, nur
// bei neu: name, date, notiz, mailHinweis, nachSchliessen).
window.openMilestoneModal = function(index, prefill){
if(app.isLocked()) return; if(app.isLocked()) return;
const pre = prefill || {};
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = []; if(!Array.isArray(plan.milestones)) plan.milestones = [];
// Migration alter Meilensteine // Migration alter Meilensteine
@@ -43,10 +50,18 @@
const isNew = index === null || plan.milestones[index] === undefined; const isNew = index === null || plan.milestones[index] === undefined;
const currentMs = isNew ? null : plan.milestones[index]; const currentMs = isNew ? null : plan.milestones[index];
document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || ''); document.getElementById('milestoneEditName').value = isNew ? (pre.name || '') : (currentMs.name || '');
const msNotiz = document.getElementById('milestoneEditNotiz');
if(msNotiz) msNotiz.value = isNew ? (pre.notiz || '') : (currentMs.notiz || '');
const msMailHinweis = document.getElementById('milestoneEditMailHinweis');
if(msMailHinweis){
msMailHinweis.hidden = !(isNew && pre.mailHinweis);
msMailHinweis.textContent = (isNew && pre.mailHinweis) || '';
}
msNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null;
// Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) — // Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) —
// dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite. // dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite.
const msDateIso = isNew ? '' : (currentMs.date || ''); const msDateIso = isNew ? (pre.date || '') : (currentMs.date || '');
document.getElementById('milestoneEditDate').value = msDateIso; document.getElementById('milestoneEditDate').value = msDateIso;
const msDateDisplay = document.getElementById('milestoneEditDateDisplay'); const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : ''; if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
@@ -152,6 +167,9 @@
window.closeMilestoneModal = function(){ window.closeMilestoneModal = function(){
document.getElementById('milestoneModal').style.display = 'none'; document.getElementById('milestoneModal').style.display = 'none';
app.currentMilestoneIndex = null; app.currentMilestoneIndex = null;
const danach = msNachSchliessen;
msNachSchliessen = null;
if(danach) setTimeout(danach, 0);
}; };
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position, // saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
@@ -169,18 +187,21 @@
const positionSelect = document.getElementById('milestoneEditPosition'); const positionSelect = document.getElementById('milestoneEditPosition');
const position = positionSelect ? positionSelect.value : ''; const position = positionSelect ? positionSelect.value : '';
const msNotizEl = document.getElementById('milestoneEditNotiz');
const notiz = msNotizEl ? msNotizEl.value.trim() : '';
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = []; if(!Array.isArray(plan.milestones)) plan.milestones = [];
if(app.currentMilestoneIndex === null){ if(app.currentMilestoneIndex === null){
plan.milestones.push({ id: uid(), name, date, dependsOn, position }); plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz });
} else if(plan.milestones[app.currentMilestoneIndex]){ } else if(plan.milestones[app.currentMilestoneIndex]){
const m = plan.milestones[app.currentMilestoneIndex]; const m = plan.milestones[app.currentMilestoneIndex];
m.name = name; m.name = name;
m.date = date; m.date = date;
m.dependsOn = dependsOn; m.dependsOn = dependsOn;
m.position = position; m.position = position;
m.notiz = notiz;
} }
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date)); plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
app.savePlan(); app.savePlan();
+42
View File
@@ -99,8 +99,21 @@ window.PlanApp = {
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten // showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt // Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
// gefunden und gerendert wurde. // gefunden und gerendert wurde.
// startAufgabe: Link mit ?aufgabe=<id> (z. B. aus einer Teams-Nachricht):
// diese Aufgabe nach dem Öffnen des Projekts gleich im Aufgaben-Panel
// zeigen — einmalig, beim ersten passenden Öffnen.
let startAufgabe = '';
try { startAufgabe = new URLSearchParams(window.location.search).get('aufgabe') || ''; } catch(e){ /* egal */ }
window.openPlanView = function(){ window.openPlanView = function(){
app.showPlanNav(); app.showPlanNav();
// Ein Aufgaben-Link stammt aus der Sitzung, in der zugewiesen wurde. Ist
// inzwischen eine neuere da, dort öffnen — Aufgaben behalten über den
// Sitzungswechsel ihre id (ensureCurrentWeekRollover()).
if(startAufgabe && app.weekLabel !== SANDBOX_WEEK_LABEL && Array.isArray(db.wochenListe) && db.wochenListe.length){
const juengste = db.wochenListe[db.wochenListe.length - 1].label;
if(juengste && juengste !== app.weekLabel) app.weekLabel = juengste;
}
// Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte // Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js) // "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
@@ -143,6 +156,19 @@ window.PlanApp = {
} }
app.week = db.weeks.find(w => w.label === app.weekLabel) || null; app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
if(!app.week){ if(!app.week){
// Eine ältere Sitzung, die beim Start nicht mitgeladen wurde (nur die
// jüngsten, js/store.js): einzeln nachholen und dann neu öffnen.
const bekannt = (db.wochenListe || []).some(w => w.label === app.weekLabel);
if(bekannt && typeof sitzungSicherLaden === 'function'){
const t = document.getElementById('planSubtitle');
if(t) t.textContent = 'Sitzung ' + app.weekLabel + ' wird geladen…';
const label = app.weekLabel;
sitzungSicherLaden(label).then(w => {
if(w && app.weekLabel === label && currentTab === 'plan') showTab('plan');
else if(!w) planFail('Nicht gefunden', 'Sitzung "' + label + '" konnte nicht geladen werden.');
}).catch(() => planFail('Nicht gefunden', 'Sitzung "' + label + '" konnte nicht geladen werden.'));
return false;
}
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.'); planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
return false; return false;
} }
@@ -286,6 +312,22 @@ window.PlanApp = {
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle // js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
// Bereiche befüllt sind, wird einer davon eingeblendet. // Bereiche befüllt sind, wird einer davon eingeblendet.
app.initPlanSections(); app.initPlanSections();
if(startAufgabe){
const id = startAufgabe;
startAufgabe = '';
// Adresszeile auf die tatsächlich geöffnete Sitzung bringen (der Link
// nannte evtl. eine ältere, siehe openPlanView()).
if(typeof updateRouteUrl === 'function') updateRouteUrl('plan');
const idx = (plan.todos || []).findIndex(t => String(t.id) === id);
if(idx < 0){ showToast('Die Aufgabe aus dem Link gibt es in diesem Projekt nicht mehr.'); return; }
if(location.hash !== '#aufgaben') showPlanSection('aufgaben');
// Panel erst nach dem Zeichnen öffnen (das Gantt misst seine Breite).
setTimeout(() => {
if(isLocked()){ showToast('Aufgabe: ' + (plan.todos[idx].title || '') + ' — diese Sitzung ist gesperrt.'); return; }
openTodoModal(idx);
}, 50);
}
} }
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine) // Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
+75
View File
@@ -24,9 +24,82 @@
// gerufen. Keine Parameter, keine Rueckgabe. // gerufen. Keine Parameter, keine Rueckgabe.
function renderSettings(){ function renderSettings(){
renderSettingsAccount(); renderSettingsAccount();
renderSettingsKalender();
renderSettingsAiKeys(); renderSettingsAiKeys();
} }
// ---------- Kalender in Outlook ----------
// Geheime Abo-Adresse je Konto (GET/POST /mein-kalender). Ohne Adresse ein
// Knopf zum Einrichten; mit Adresse: Kopieren, „In Outlook öffnen“ (webcal:),
// Anleitung, neue Adresse (alte wird ungültig) und Abschalten.
let kalenderUrlAktuell = null;
async function renderSettingsKalender(){
const el = document.getElementById('settingsKalender');
if(!el) return;
if(kalenderUrlAktuell === null){
try {
const d = await fetch(baseUrl() + '/mein-kalender').then(r => r.json());
kalenderUrlAktuell = (d && d.url) || '';
} catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
}
const url = kalenderUrlAktuell;
if(!currentUser || !currentUser.personId){
el.innerHTML = '<p class="settings-warn">Ohne Personen-Zuordnung enthält der Kalender nur Ihre Abwesenheiten.</p>';
} else el.innerHTML = '';
if(!url){
el.innerHTML += '<button type="button" onclick="kalenderAdresse(false)">Kalender-Adresse erzeugen</button>';
return;
}
const webcal = url.replace(/^https?:/, 'webcal:');
el.innerHTML += `
<div class="kalender-adresse">
<input type="text" id="kalenderUrlFeld" readonly value="${esc(url)}" onclick="this.select()" aria-label="Kalender-Adresse">
<button type="button" class="secondary" onclick="kalenderKopieren()">Kopieren</button>
<a class="button-link" href="${esc(webcal)}">In Outlook öffnen</a>
</div>
<details class="kalender-anleitung">
<summary>So abonnieren Sie den Kalender</summary>
<ol>
<li><b>Outlook am PC (klassisch):</b> „In Outlook öffnen“ klicken und bestätigen — oder
Kalender → <i>Kalender hinzufügen</i> → <i>Aus dem Internet</i> → Adresse einfügen.</li>
<li><b>Neues Outlook / Outlook im Browser:</b> Kalender → <i>Kalender hinzufügen</i> →
<i>Aus dem Internet abonnieren</i> → Adresse einfügen. Das funktioniert nur, wenn der
Server aus dem Internet erreichbar ist — Microsoft holt den Kalender von außen ab.</li>
</ol>
<p class="hint">Die Adresse ist Ihr persönlicher Schlüssel: Wer sie kennt, sieht Ihre Termine.
Nicht weitergeben. Bei Verdacht „Neue Adresse“ wählen — die alte gilt dann nicht mehr.</p>
</details>
<div class="kalender-aktionen">
<button type="button" class="secondary small" onclick="kalenderAdresse(false, true)">Neue Adresse</button>
<button type="button" class="secondary small" onclick="kalenderAdresse(true)">Abschalten</button>
</div>`;
}
async function kalenderAdresse(aus, nachfragen){
if(aus && !confirm('Kalender-Abo abschalten? Outlook zeigt danach keine neuen Termine mehr.')) return;
if(nachfragen && !confirm('Neue Adresse erzeugen? Die bisherige gilt dann nicht mehr — in Outlook muss der Kalender neu abonniert werden.')) return;
try {
const r = await fetch(baseUrl() + '/mein-kalender', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ aus: !!aus })
});
const d = await r.json();
if(!r.ok || !d.ok) throw new Error(d.error || 'Fehler');
kalenderUrlAktuell = d.url || '';
renderSettingsKalender();
showSettingsNote(aus ? 'Kalender-Abo abgeschaltet.' : 'Kalender-Adresse erzeugt.');
} catch(e){ alert('Das hat nicht geklappt: ' + e.message); }
}
function kalenderKopieren(){
const feld = document.getElementById('kalenderUrlFeld');
if(!feld) return;
const fertig = () => showSettingsNote('Adresse kopiert.');
if(navigator.clipboard && window.isSecureContext){
navigator.clipboard.writeText(feld.value).then(fertig).catch(() => { feld.select(); document.execCommand('copy'); fertig(); });
} else { feld.select(); document.execCommand('copy'); fertig(); }
}
// ---------- Mein Konto ---------- // ---------- Mein Konto ----------
// renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an. // renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an.
@@ -234,6 +307,8 @@ function putMyAiKey(provider, patch){
body: JSON.stringify(body) body: JSON.stringify(body)
}).then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => { }).then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.'); if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
// "Termin aus Mail" ist nur mit eingerichteter KI aktiv — Stand neu holen.
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
return json; return json;
}); });
} }
+109 -9
View File
@@ -596,7 +596,13 @@ function liveWocheNachladen(label, geloescht){
// Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach. // Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach.
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; } if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label); const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label);
if(!w) return liveAllesNeu(); if(!w){
// Eine ältere, hier nicht geladene Sitzung (z. B. entsperrt): nur ihren
// Eintrag in der Sitzungsliste nachziehen, nicht alles neu laden.
const kurz = (db.wochenListe || []).find(x => x.label === label);
if(kurz) return wocheVomServer(label).then(f => { if(f) Object.assign(kurz, sitzungKurz(f)); ansichtNachBearbeitung(); }).catch(() => {});
return liveAllesNeu();
}
wocheVomServer(label).then(frisch => { wocheVomServer(label).then(frisch => {
if(!frisch) return liveAllesNeu(); if(!frisch) return liveAllesNeu();
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; } if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
@@ -648,6 +654,102 @@ function handleWeekConflict(week, err){
else load(); else load();
} }
// ---------------------------------------------------------------------------
// Sitzungen nach Bedarf (Eriks Wunsch: beim Start nur die aktuelle und drei
// davor; im Verlauf „Weitere laden“ holt je fünf ältere nach)
// ---------------------------------------------------------------------------
// db.weeks hält nur die GELADENEN Sitzungen, db.wochenListe ALLE schlank
// ({id, label, date, locked, updatedAt}, älteste zuerst). Wer eine nicht
// geladene Sitzung braucht (Admin → Sitzungen, Deep-Link auf ein Projekt
// einer älteren Sitzung), holt sie über sitzungSicherLaden().
const SITZUNGEN_START = 4;
const SITZUNGEN_NACHLADEN = 5;
function wocheAbsichern(w){
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return w;
}
function sitzungKurz(w){
return { id: w.id, label: w.label, date: w.date || '', locked: !!w.locked, updatedAt: w.updatedAt || '' };
}
// sitzungKurzAbgleichen: den schlanken Eintrag einer Sitzung in
// db.wochenListe auf den Stand des vollen Objekts bringen (Sperre, Revision).
function sitzungKurzAbgleichen(w){
const k = (db.wochenListe || []).find(x => x.label === w.label);
if(k) Object.assign(k, sitzungKurz(w));
}
// wochenEinfuegen: geladene Sitzungen in db.weeks aufnehmen (ohne Doppel),
// sortieren und als Basis fürs Zusammenführen merken.
function wochenEinfuegen(liste){
(liste || []).forEach(w => {
if(!w || typeof w.label !== 'string' || db.weeks.some(x => x.label === w.label)) return;
db.weeks.push(wocheAbsichern(w));
basisMerken(w);
});
db.weeks.sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
}
// aeltereSitzungenAnzahl: wie viele Sitzungen älter als die älteste geladene
// noch nicht geladen sind.
function aeltereSitzungenAnzahl(){
const liste = db.wochenListe || [];
if(!db.weeks.length) return 0;
const aelteste = db.weeks[0].label;
const idx = liste.findIndex(w => w.label === aelteste);
return idx < 0 ? 0 : idx;
}
// weitereSitzungenLaden: holt die nächsten älteren Sitzungen und zeichnet
// danach neu — die ganze Ansicht, oder nur, was nachher (Name einer globalen
// Funktion) zeichnet. Der Wochenbericht nutzt das, damit ein angefangener
// Kommentar nicht durch ein Neuzeichnen verloren geht. Rückgabe: Promise.
let weitereLaufen = null;
function weitereSitzungenLaden(anzahl, nachher){
if(weitereLaufen) return weitereLaufen;
if(!db.weeks.length || aeltereSitzungenAnzahl() === 0) return Promise.resolve();
const vor = db.weeks[0].label;
weitereLaufen = fetch(baseUrl() + '/weeks-aelter?vor=' + encodeURIComponent(vor) + '&anzahl=' + (anzahl || SITZUNGEN_NACHLADEN))
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(d => {
wochenEinfuegen(d.weeks);
if(nachher && typeof window[nachher] === 'function'){ window[nachher](); return; }
ansichtAuffrischen();
// Kommentar-Verlauf im Projekt-Dashboard hängt an renderPlanInfo().
if(typeof currentTab !== 'undefined' && currentTab === 'plan' && typeof renderPlanInfo === 'function') renderPlanInfo();
})
.catch(err => { console.error('Ältere Sitzungen laden fehlgeschlagen:', err); alert('Ältere Sitzungen konnten nicht geladen werden.'); })
.finally(() => { weitereLaufen = null; });
return weitereLaufen;
}
// weitereLadenKnopf: Knopf „Weitere laden“ für einen Verlauf — nur, solange
// es ältere, nicht geladene Sitzungen gibt. Parameter: nachher (optional,
// siehe weitereSitzungenLaden()). Rückgabe: HTML-String ('' sonst).
function weitereLadenKnopf(nachher){
const n = aeltereSitzungenAnzahl();
if(!n) return '';
const k = Math.min(n, SITZUNGEN_NACHLADEN);
return `<button type="button" class="secondary small verlauf-weitere" onmousedown="event.preventDefault()" onclick="event.stopPropagation(); weitereSitzungenLaden(0${nachher ? ", '" + nachher + "'" : ''})"
title="Noch ${n} ältere Sitzung${n === 1 ? '' : 'en'} am Server">Weitere laden (${k} ${k === 1 ? 'Sitzung' : 'Sitzungen'})</button>`;
}
// sitzungSicherLaden: liefert eine Sitzung, lädt sie bei Bedarf einzeln
// nach. Rückgabe: Promise<Woche|null>.
function sitzungSicherLaden(label){
const da = db.weeks.find(w => w.label === label);
if(da) return Promise.resolve(da);
return wocheVomServer(label).then(w => {
if(!w) return null;
wochenEinfuegen([w]);
return db.weeks.find(x => x.label === label) || null;
});
}
// reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die // reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde). // übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von // Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
@@ -745,7 +847,9 @@ function load(silent){
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.'); alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
return; return;
} }
const url = baseUrl() + '/load-all'; // Nur die jüngsten SITZUNGEN_START Sitzungen (aktuelle + drei davor);
// ältere holt weitereSitzungenLaden() bei Bedarf (Eriks Wunsch).
const url = baseUrl() + '/load-all?wochen=' + SITZUNGEN_START;
console.log('Loading from', url); console.log('Loading from', url);
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen: // Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala, // renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
@@ -775,13 +879,9 @@ function load(silent){
db.weeks = (Array.isArray(data.weeks) ? data.weeks : []) db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string') .filter(w => w && typeof w.label === 'string')
.map(w => { .map(wocheAbsichern);
// Felder absichern, damit alte/kaputte Dokumente nicht crashen // Alle Sitzungen schlank (Name, Datum, Sperre) — auch die nicht geladenen.
if(!Array.isArray(w.people)) w.people = []; db.wochenListe = Array.isArray(data.wochenListe) ? data.wochenListe : db.weeks.map(sitzungKurz);
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return w;
});
// Server-Stand als Basis für späteres Zusammenführen merken. // Server-Stand als Basis für späteres Zusammenführen merken.
wochenBasis.clear(); wochenBasis.clear();
+17 -3
View File
@@ -1187,8 +1187,19 @@ function verlaufBeteiligte(version){
// die eine geänderte Zahl sonst nicht. // die eine geänderte Zahl sonst nicht.
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell" // Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
// gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML. // gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML.
// verlaufOffen: project_uids, deren Verlauf aufgeklappt ist — übersteht das
// Neuzeichnen nach „Weitere laden“ (sonst klappte er dabei wieder zu).
const verlaufOffen = new Set();
function verlaufUmschalten(uid, offen){ if(offen) verlaufOffen.add(uid); else verlaufOffen.delete(uid); }
function renderProjektVerlauf(p, weekLabel, locked){ function renderProjektVerlauf(p, weekLabel, locked){
const versionen = projectVersionsForUid(p.projectUid); const versionen = projectVersionsForUid(p.projectUid);
// Gezeigt wird, was geladen ist (anfangs die drei Sitzungen vor der
// aktuellen); „Weitere laden“ holt je fünf ältere (js/store.js).
const weitere = typeof weitereLadenKnopf === 'function' ? weitereLadenKnopf() : '';
const uidAttr = esc(String(p.projectUid || '')).replace(/'/g, '&#39;');
const offen = verlaufOffen.has(p.projectUid) ? ' open' : '';
const umschalten = ` ontoggle="verlaufUmschalten('${uidAttr}', this.open)"`;
// Neueste zuerst, damit links die jüngste Vergangenheit steht. // Neueste zuerst, damit links die jüngste Vergangenheit steht.
// //
// Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im // Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im
@@ -1199,13 +1210,15 @@ function renderProjektVerlauf(p, weekLabel, locked){
const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse(); const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse();
if(frueher.length === 0){ if(frueher.length === 0){
return `<details class="timeline-block"> return `<details class="timeline-block"${offen}${umschalten}>
<summary onmousedown="event.preventDefault()"> <summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span> <span class="comment-history-caret">▶</span>
<span>Verlauf</span> <span>Verlauf</span>
<span class="comment-history-count zero">0</span> <span class="comment-history-count zero">0</span>
</summary> </summary>
<div class="timeline-body"><span class="hint" style="margin:0">Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.</span></div> <div class="timeline-body"><span class="hint" style="margin:0">${weitere
? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
: 'Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.'}</span>${weitere}</div>
</details>`; </details>`;
} }
@@ -1276,7 +1289,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
</article>`; </article>`;
}).join(''); }).join('');
return `<details class="timeline-block"> return `<details class="timeline-block"${offen}${umschalten}>
<summary onmousedown="event.preventDefault()"> <summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span> <span class="comment-history-caret">▶</span>
<span>Verlauf</span> <span>Verlauf</span>
@@ -1284,6 +1297,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
</summary> </summary>
<div class="timeline-body"> <div class="timeline-body">
<div class="timeline-rail">${kacheln}</div> <div class="timeline-rail">${kacheln}</div>
${weitere ? `<div class="verlauf-weitere-zeile">${weitere}</div>` : ''}
</div> </div>
</details>`; </details>`;
} }
+4 -1
View File
@@ -138,7 +138,9 @@ function unlockCurrentWeek(){ unlockWeek(currentWeek()); }
// üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich // üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich
// darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe. // darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe.
function deleteWeekById(weekId, nachDelete){ function deleteWeekById(weekId, nachDelete){
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; } // Gezählt werden ALLE Sitzungen, nicht nur die geladenen (db.wochenListe).
const gesamt = (db.wochenListe && db.wochenListe.length) || db.weeks.length;
if(gesamt<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
const w = db.weeks.find(x => x.id === weekId); const w = db.weeks.find(x => x.id === weekId);
if(!w) return; if(!w) return;
if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return; if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return;
@@ -151,6 +153,7 @@ function deleteWeekById(weekId, nachDelete){
console.log('Woche gelöscht:', w.label); console.log('Woche gelöscht:', w.label);
const warAktuell = (weekId === db.currentWeek); const warAktuell = (weekId === db.currentWeek);
db.weeks = db.weeks.filter(x=>x.id!==weekId); db.weeks = db.weeks.filter(x=>x.id!==weekId);
if(Array.isArray(db.wochenListe)) db.wochenListe = db.wochenListe.filter(x => x.label !== w.label);
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
// Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus // Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus
// irgendeinem Grund gar keine mehr existierende), auf die jüngste // irgendeinem Grund gar keine mehr existierende), auf die jüngste
+10 -14
View File
@@ -361,7 +361,6 @@ function aktuellesBerichtProjekt(w){
// Parameter: uid (project_uid). Keine Rückgabe. // Parameter: uid (project_uid). Keine Rückgabe.
function waehleBerichtProjekt(uid){ function waehleBerichtProjekt(uid){
berichtAuswahl = uid; berichtAuswahl = uid;
berichtVerlaufAlle = false; // neues Projekt zeigt wieder nur die jüngsten Einträge
renderWochenbericht(); renderWochenbericht();
const detail = document.getElementById('berichtDetail'); const detail = document.getElementById('berichtDetail');
if(detail) detail.scrollIntoView({ block: 'nearest' }); if(detail) detail.scrollIntoView({ block: 'nearest' });
@@ -742,10 +741,9 @@ function renderBerichtDetail(p, w, prevWeek, locked){
// jedem Eintrag alle Werte zu wiederholen. Keine neue Datenhaltung — jede // jedem Eintrag alle Werte zu wiederholen. Keine neue Datenhaltung — jede
// Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()). // Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()).
const BERICHT_VERLAUF_KURZ = 3; // Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
// berichtVerlaufAlle: "Alle N anzeigen" aufgeklappt; waehleBerichtProjekt() // aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
// setzt es beim Projektwechsel zurück. // js/store.js) und zeichnet nur diesen Abschnitt neu (berichtVerlaufNeu()).
let berichtVerlaufAlle = false;
// berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende) // berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende)
function berichtKurzKw(label){ function berichtKurzKw(label){
@@ -828,11 +826,12 @@ function renderBerichtVerlauf(p, w, locked){
const gText = g || 'alle Gewerke'; const gText = g || 'alle Gewerke';
let inhalt; let inhalt;
if(eintraege.length === 0){ if(eintraege.length === 0){
inhalt = `<div class="hint" style="margin:0">${sitzungen === 0 inhalt = `<div class="hint" style="margin:0">${sitzungen === 0 && !aeltereSitzungenAnzahl()
? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.' ? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.'
: sitzungen === 0 ? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
: 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}</div>`; : 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}</div>`;
} else { } else {
const zeigen = berichtVerlaufAlle ? eintraege : eintraege.slice(0, BERICHT_VERLAUF_KURZ); const zeigen = eintraege;
inhalt = `<ol class="bv-liste">${zeigen.map((e, i) => { inhalt = `<ol class="bv-liste">${zeigen.map((e, i) => {
const datum = berichtVerlaufDatum(e.at); const datum = berichtVerlaufDatum(e.at);
const wer = [e.autor, datum].filter(Boolean).join(', '); const wer = [e.autor, datum].filter(Boolean).join(', ');
@@ -854,9 +853,7 @@ function renderBerichtVerlauf(p, w, locked){
</li>`; </li>`;
}).join('')}</ol>`; }).join('')}</ol>`;
} }
const mehr = eintraege.length > BERICHT_VERLAUF_KURZ const mehr = weitereLadenKnopf('berichtVerlaufNeu');
? `<button type="button" class="bv-mehr" onclick="berichtVerlaufUmschalten()">${berichtVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + eintraege.length + ' anzeigen'}</button>`
: '';
const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare'; const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare';
return `<section class="bericht-verlauf" id="berichtVerlauf" aria-label="Verlauf der Vorwochen"> return `<section class="bericht-verlauf" id="berichtVerlauf" aria-label="Verlauf der Vorwochen">
<div class="bv-kopf"> <div class="bv-kopf">
@@ -869,10 +866,9 @@ function renderBerichtVerlauf(p, w, locked){
</section>`; </section>`;
} }
// berichtVerlaufUmschalten: "Alle N anzeigen" / "Weniger anzeigen". Zeichnet // berichtVerlaufNeu: zeichnet nur den Verlauf neu (nach „Weitere laden“),
// nur den Verlauf neu, damit ein angefangener Kommentar unberührt bleibt. // damit ein angefangener Kommentar unberührt bleibt.
function berichtVerlaufUmschalten(){ function berichtVerlaufNeu(){
berichtVerlaufAlle = !berichtVerlaufAlle;
const w = currentWeek(); const w = currentWeek();
const p = w && aktuellesBerichtProjekt(w); const p = w && aktuellesBerichtProjekt(w);
const el = document.getElementById('berichtVerlauf'); const el = document.getElementById('berichtVerlauf');
+21 -1
View File
@@ -1786,6 +1786,26 @@ html[data-theme="dark"] { --gantt-phase: #a59ef2; }
.mail-auswahl-liste { display: flex; flex-direction: column; gap: 4px; max-height: 50vh; overflow-y: auto; margin: 12px 0; } .mail-auswahl-liste { display: flex; flex-direction: column; gap: 4px; max-height: 50vh; overflow-y: auto; margin: 12px 0; }
.mail-auswahl-zeile { .mail-auswahl-zeile {
display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
} }
.mail-auswahl-text { display: grid; gap: 2px; min-width: 0; }
.mail-auswahl-titel { overflow-wrap: anywhere; }
.mail-auswahl-datum { color: var(--text-subtle); font-size: .88em; white-space: nowrap; } .mail-auswahl-datum { color: var(--text-subtle); font-size: .88em; white-space: nowrap; }
.mail-auswahl-art { flex: none; }
/* Eigene Flächen statt der .segmented-Vorgabe: deren gewählter Knopf ist
var(--surface) — unter Glas fast durchsichtig, gewählt und nicht gewählt
sahen gleich aus; dazu trug jeder Knopf einen eigenen Rand. */
.segmented.mail-auswahl-art button {
padding: 4px 12px; font-size: 12px;
border: 0; box-shadow: none;
background: transparent; color: var(--text-muted);
}
.segmented.mail-auswahl-art button:hover { background: var(--seg-hover); color: var(--text); }
.segmented.mail-auswahl-art button[aria-pressed="true"] {
background: var(--accent-soft); color: var(--accent); font-weight: 600;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
@media (max-width: 520px) {
.mail-auswahl-zeile { grid-template-columns: auto 1fr; }
.mail-auswahl-art { grid-column: 2; justify-self: start; }
}
+36 -3
View File
@@ -3313,7 +3313,7 @@ html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,2
.range-picker { .range-picker {
position: fixed; position: fixed;
z-index: 1100; z-index: 1100;
width: 288px; width: 316px;
background: var(--surface); background: var(--surface);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--r-lg); border-radius: var(--r-lg);
@@ -3343,9 +3343,17 @@ html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,2
.range-picker-grid { .range-picker-grid {
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: 26px repeat(7, 1fr);
gap: 2px; gap: 2px;
} }
/* KW-Spalte: klein und gedämpft, damit die Tage die Hauptsache bleiben. */
.range-picker-kw {
display: grid; place-items: center;
font-size: 10px; font-weight: 600; color: var(--text-subtle);
border-right: 1px solid var(--line-soft);
font-variant-numeric: tabular-nums;
}
.range-picker-kw-kopf { border-right: 1px solid var(--line-soft); }
.range-picker-weekday { .range-picker-weekday {
text-align: center; text-align: center;
@@ -6128,7 +6136,14 @@ html.glas .tour-blase { background: var(--glas-dicht, var(--surface)); -webkit-b
.abw-team-steuer { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; } .abw-team-steuer { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.abw-monat { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; } .abw-monat { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
.abw-monat strong { min-width: 120px; text-align: center; font-weight: 600; } .abw-monat strong { min-width: 120px; text-align: center; font-weight: 600; }
.abw-rund { width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer; display: grid; place-items: center; padding: 0; } .abw-rund { width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer; display: grid; place-items: center; padding: 0; flex: none; }
/* Eigener Hover: sonst färbt die allgemeine button:hover-Regel den Kreis
akzentblau. Pfeile als Symbol (icon()) statt der Schriftzeichen ‹ ›, die im
Kreis nicht mittig saßen. */
.abw-rund:hover { background: var(--surface-sunk); border-color: var(--text-subtle); }
.abw-rund:active { background: var(--surface-sunk); }
.abw-rund .icon { display: grid; place-items: center; }
.abw-rund svg { width: 16px; height: 16px; display: block; }
.abw-gewerk-kopf { display: flex; align-items: baseline; gap: 10px; } .abw-gewerk-kopf { display: flex; align-items: baseline; gap: 10px; }
.abw-gewerk-kopf small { color: var(--text-muted); font-size: 12.5px; } .abw-gewerk-kopf small { color: var(--text-muted); font-size: 12.5px; }
#abwesenheit .abw-gewerk-kopf + .abw-tl-karte { margin-top: 8px; } #abwesenheit .abw-gewerk-kopf + .abw-tl-karte { margin-top: 8px; }
@@ -6199,3 +6214,21 @@ html.glas .abw-tl-wer { background: var(--glas-deckend, var(--surface)); }
color: #fff; border-color: transparent; color: #fff; border-color: transparent;
text-shadow: 0 0 2px rgba(0,0,0,.55), 0 0 1px rgba(0,0,0,.6); text-shadow: 0 0 2px rgba(0,0,0,.55), 0 0 1px rgba(0,0,0,.6);
} }
/* „Weitere laden“ im Verlauf (weitereLadenKnopf(), js/store.js): holt je
fünf ältere Sitzungen nach — beim Start sind nur die jüngsten geladen. */
.verlauf-weitere-zeile, .plan-verlauf-mehr-zeile { display: flex; justify-content: center; margin-top: 10px; }
button.verlauf-weitere { white-space: nowrap; }
/* ---------- Einstellungen: Kalender in Outlook ---------- */
.kalender-adresse { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 6px; }
.kalender-adresse input { flex: 1; min-width: 220px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.kalender-adresse .button-link {
display: inline-flex; align-items: center; padding: 7px 16px; border-radius: var(--r-pill);
background: var(--accent); color: #fff; font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap;
}
.kalender-adresse .button-link:hover { background: var(--accent-hover); }
.kalender-anleitung { margin: 6px 0 10px; font-size: 13px; }
.kalender-anleitung summary { cursor: pointer; color: var(--accent); }
.kalender-anleitung ol { margin: 8px 0; padding-left: 20px; display: grid; gap: 6px; }
.kalender-aktionen { display: flex; gap: 8px; }
+386 -15
View File
@@ -514,6 +514,7 @@ db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones (
date TEXT NOT NULL DEFAULT '', date TEXT NOT NULL DEFAULT '',
position TEXT NOT NULL DEFAULT '', position TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
notiz TEXT NOT NULL DEFAULT '',
PRIMARY KEY (week_label, project_id, milestone_id) PRIMARY KEY (week_label, project_id, milestone_id)
)`); )`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps ( db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
@@ -779,7 +780,8 @@ const RECHTE = [
['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''], ['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''],
['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''], ['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''],
['a.papierkorb', 'Papierkorb', ''], ['a.backup', 'Datenbank-Backup', ''], ['a.papierkorb', 'Papierkorb', ''], ['a.backup', 'Datenbank-Backup', ''],
['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''] ]}, ['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''],
['a.teams', 'Teams-Anbindung', ''] ]},
]; ];
const RECHT_LABEL = new Map(RECHTE.flatMap(g => g.rechte.map(r => [r[0], r[1]]))); const RECHT_LABEL = new Map(RECHTE.flatMap(g => g.rechte.map(r => [r[0], r[1]])));
const RECHT_KEYS = new Set(RECHT_LABEL.keys()); const RECHT_KEYS = new Set(RECHT_LABEL.keys());
@@ -1224,8 +1226,8 @@ function projektStatements(){
VALUES (?, ?, ?, ?)`); VALUES (?, ?, ?, ?)`);
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id) const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
VALUES (?, ?, ?, ?)`); VALUES (?, ?, ?, ?)`);
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order) const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?)`); VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id) const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
VALUES (?, ?, ?, ?)`); VALUES (?, ?, ?, ?)`);
@@ -1310,7 +1312,7 @@ function serializeProject(weekLabel, p, idx, nowIso){
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || ''))); (Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
}); });
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => { (Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => {
insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx); insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx, String(m.notiz || '').slice(0, 20000));
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || ''))); (Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
}); });
} }
@@ -1409,11 +1411,15 @@ function loadGlobalPeople(fuerUser){
function loadWeeks(nurLabel){ function loadWeeks(nurLabel){
// nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die // nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die
// Live-Ereignisse, die sonst jedes Mal alle Sitzungen zusammensetzen müssten. // Live-Ereignisse, die sonst jedes Mal alle Sitzungen zusammensetzen müssten.
// Auch eine Liste von Labels (für /load-all und /weeks-aelter, die nur
// einige Sitzungen liefern).
const labels = nurLabel === undefined ? null : (Array.isArray(nurLabel) ? nurLabel : [nurLabel]).map(String);
if(labels && labels.length === 0) return [];
const alle = (tabelle, reihenfolge) => { const alle = (tabelle, reihenfolge) => {
const spalte = tabelle === 'weeks' ? 'label' : 'week_label'; const spalte = tabelle === 'weeks' ? 'label' : 'week_label';
const wo = nurLabel === undefined ? '' : ` WHERE ${spalte} = ?`; const wo = labels ? ` WHERE ${spalte} IN (${labels.map(() => '?').join(',')})` : '';
const st = db.prepare(`SELECT * FROM ${tabelle}${wo}${reihenfolge ? ' ' + reihenfolge : ''}`); const st = db.prepare(`SELECT * FROM ${tabelle}${wo}${reihenfolge ? ' ' + reihenfolge : ''}`);
return nurLabel === undefined ? st.all() : st.all(String(nurLabel)); return labels ? st.all(...labels) : st.all();
}; };
const weeks = alle('weeks', 'ORDER BY label'); const weeks = alle('weeks', 'ORDER BY label');
const weekPeople = alle('week_people', 'ORDER BY array_order'); const weekPeople = alle('week_people', 'ORDER BY array_order');
@@ -1534,7 +1540,7 @@ function loadWeeks(nurLabel){
const msMap = {}; const msMap = {};
milestones.forEach(r => { milestones.forEach(r => {
const k = msKey(r.week_label, r.project_id, r.milestone_id); const k = msKey(r.week_label, r.project_id, r.milestone_id);
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [] }; msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '' };
}); });
milestoneDeps.forEach(r => { milestoneDeps.forEach(r => {
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)]; const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
@@ -2671,6 +2677,11 @@ function migrateAddPlanTodoNotiz(){
db.exec("ALTER TABLE plan_todos ADD COLUMN notiz TEXT NOT NULL DEFAULT ''"); db.exec("ALTER TABLE plan_todos ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
console.log('plan_todos um notiz ergänzt.'); console.log('plan_todos um notiz ergänzt.');
} }
const msCols = db.prepare("SELECT name FROM pragma_table_info('plan_milestones')").all().map(r => r.name);
if(!msCols.includes('notiz')){
db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
console.log('plan_milestones um notiz ergänzt.');
}
} }
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach // migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
@@ -3038,6 +3049,12 @@ function migrateUsersDepartment(){
// dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine // dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine
// Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer = // Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer =
// wie in der Projektliste. Je Konto, damit sie auf jedem Gerät gilt. // wie in der Projektliste. Je Konto, damit sie auf jedem Gerät gilt.
// kalender_token: geheime Adresse des Kalender-Abos (/kalender/<token>.ics),
// leer = abgeschaltet. Siehe "Kalender-Abo für Outlook".
if(!cols.includes('kalender_token')){
db.exec("ALTER TABLE users ADD COLUMN kalender_token TEXT NOT NULL DEFAULT ''");
console.log('Benutzer-Tabelle um kalender_token ergänzt.');
}
if(!cols.includes('dashboard_reihenfolge')){ if(!cols.includes('dashboard_reihenfolge')){
db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''"); db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''");
console.log('Benutzer-Tabelle um dashboard_reihenfolge ergänzt.'); console.log('Benutzer-Tabelle um dashboard_reihenfolge ergänzt.');
@@ -4652,7 +4669,7 @@ app.post('/save-week', requireAuth, (req, res) => {
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save). // aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
const updatedAt = saveWeekRelational(week); const updatedAt = saveWeekRelational(week);
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user); if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user); if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user, req);
console.log('Saved week:', week.label); console.log('Saved week:', week.label);
ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req); ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req);
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() }); return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
@@ -4662,6 +4679,144 @@ app.post('/save-week', requireAuth, (req, res) => {
} }
}); });
// ---------- Teams: Nachricht bei neuen Aufgaben ----------
//
// Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, erscheint in einem
// Teams-Kanal eine Nachricht mit Link direkt auf die Aufgabe im Projekt.
// Eingerichtet in Teams über „Workflows“ → „Beim Empfang einer
// Webhook-Anfrage in einem Kanal posten“; die dort erzeugte Adresse trägt
// ein Admin unter Admin → Teams ein (Recht a.teams). Die Adresse ist ein
// Geheimnis (wer sie kennt, kann in den Kanal schreiben) — sie steht in
// `integrationen`, nicht in app_settings (das jedes Konto lesen darf), und
// geht nie an den Browser zurück. Nicht in IMPORT_TABLES.
//
// Senden braucht Internetzugang nach außen; ein Fehler wird nur geloggt,
// das Speichern der Sitzung scheitert nie daran.
db.exec(`CREATE TABLE IF NOT EXISTS integrationen (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL DEFAULT ''
)`);
function integrationLesen(k){
const r = db.prepare('SELECT wert FROM integrationen WHERE schluessel = ?').get(k);
return r ? r.wert : '';
}
function integrationSchreiben(k, v){
db.prepare('INSERT INTO integrationen (schluessel, wert) VALUES (?, ?) ON CONFLICT(schluessel) DO UPDATE SET wert = excluded.wert')
.run(k, String(v));
}
// teamsKarte: Adaptive Card im Umschlag, den der Teams-Workflow erwartet.
function teamsKarte(titel, zeilen, fakten, link, linkText){
const body = [{ type: 'TextBlock', text: titel, weight: 'Bolder', size: 'Medium', wrap: true }];
zeilen.forEach(z => body.push({ type: 'TextBlock', text: z, wrap: true, spacing: 'Small' }));
if(fakten.length) body.push({ type: 'FactSet', facts: fakten.map(([title, value]) => ({ title, value })) });
const content = {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard', version: '1.4', body,
actions: link ? [{ type: 'Action.OpenUrl', title: linkText || 'Öffnen', url: link }] : []
};
return { type: 'message', attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', contentUrl: null, content }] };
}
// teamsSenden: an die eingetragene Adresse schicken. Rückgabe: Promise —
// wirft bei Fehler (für den Test-Knopf); Aufrufer im Hintergrund fangen ab.
async function teamsSenden(nachricht, url){
const ziel = url || integrationLesen('teams_webhook');
if(!ziel) throw new Error('Keine Teams-Adresse eingetragen.');
const res = await fetchMitTimeout(ziel, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nachricht)
});
if(!res.ok){
const t = await res.text().catch(() => '');
throw new Error('Teams antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : ''));
}
}
// aufgabenLink: Adresse der Aufgabe im Projekt (der Browser öffnet sie direkt
// im Aufgaben-Panel, Parameter aufgabe=). Host aus der speichernden Anfrage —
// also die Adresse, unter der die App im Büro aufgerufen wird.
function aufgabenLink(req, weekLabel, p, todoId){
if(!req) return '';
const u = new URL('/Projektplan/' + encodeURIComponent(p.name || ''), req.protocol + '://' + req.get('host'));
u.searchParams.set('weekLabel', weekLabel);
u.searchParams.set('projNr', p.nr || '');
u.searchParams.set('aufgabe', String(todoId));
u.hash = 'aufgaben';
return u.toString();
}
function teamsDatum(iso){
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
return m ? m[3] + '.' + m[2] + '.' + m[1] : '';
}
// teamsNeueAufgaben: je Aufgabe mit neu Zuständigen EINE Nachricht. neu =
// Map todoId -> { p, t, personen:[ids] }. Läuft im Hintergrund.
function teamsNeueAufgaben(weekLabel, neu, user, req){
if(!neu.size || integrationLesen('teams_neue_aufgaben') !== '1' || !integrationLesen('teams_webhook')) return;
const namen = new Map(db.prepare('SELECT person_id, name, full_name FROM week_people WHERE week_label = ?').all(weekLabel)
.map(r => [r.person_id, r.full_name || r.name || r.person_id]));
const von = (user && (user.displayName || user.username)) || '';
neu.forEach(({ p, t, personen }) => {
const wer = personen.map(id => namen.get(id) || id).join(', ');
const zeitraum = t.start && t.end && t.start !== t.end ? teamsDatum(t.start) + ' – ' + teamsDatum(t.end) : '';
const fakten = [['Projekt', [p.nr, p.name].filter(Boolean).join(' ')], ['Zuständig', wer]];
if(t.end) fakten.push(['Fällig', teamsDatum(t.end)]);
if(zeitraum) fakten.push(['Zeitraum', zeitraum]);
if(Number(t.aufwand) > 0) fakten.push(['Aufwand', String(t.aufwand).replace('.', ',') + ' h']);
if(von) fakten.push(['Zugewiesen von', von]);
const karte = teamsKarte('Neue Aufgabe: ' + (t.title || 'Aufgabe'), [], fakten,
aufgabenLink(req, weekLabel, p, t.id), 'Aufgabe öffnen');
teamsSenden(karte).catch(err => console.warn('Teams-Nachricht nicht gesendet:', err.message));
});
}
app.get('/admin/teams', requireRecht('a.teams'), (req, res) => {
const url = integrationLesen('teams_webhook');
let host = '';
try { host = url ? new URL(url).host : ''; } catch(e){ /* egal */ }
res.json({ ok: true, konfiguriert: !!url, host, neueAufgaben: integrationLesen('teams_neue_aufgaben') === '1' });
});
// PUT {webhook?, neueAufgaben?} — webhook '' entfernt die Adresse.
app.put('/admin/teams', requireRecht('a.teams'), (req, res) => {
try {
const b = req.body || {};
if(typeof b.webhook === 'string'){
const w = b.webhook.trim();
if(w){
let u;
try { u = new URL(w); } catch(e){ return res.status(400).json({ ok: false, error: 'Das ist keine gültige Adresse.' }); }
if(u.protocol !== 'https:') return res.status(400).json({ ok: false, error: 'Die Teams-Adresse muss mit https:// beginnen.' });
}
integrationSchreiben('teams_webhook', w);
}
if(typeof b.neueAufgaben === 'boolean') integrationSchreiben('teams_neue_aufgaben', b.neueAufgaben ? '1' : '0');
res.json({ ok: true });
} catch(err){
console.error('Teams-Einstellung speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// POST /admin/teams/test — Testnachricht; Fehler als {ok:false, error} mit
// HTTP 200 (ein fehlgeschlagener Test ist ein erwartetes Ergebnis).
app.post('/admin/teams/test', requireRecht('a.teams'), async (req, res) => {
try {
const link = req.protocol + '://' + req.get('host') + '/';
await teamsSenden(teamsKarte('Projektübersicht ist mit Teams verbunden',
['Ab jetzt erscheinen hier neue Aufgaben mit einem Link direkt zur Aufgabe im Projekt.'],
[['Eingerichtet von', req.user.displayName || req.user.username]], link, 'Projektübersicht öffnen'));
res.json({ ok: true });
} catch(err){
res.json({ ok: false, error: err.message });
}
});
// neueZuweisungenMelden: legt für jede Person, die einer offenen Aufgabe neu // neueZuweisungenMelden: legt für jede Person, die einer offenen Aufgabe neu
// als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der // als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der
// Person ein aktives Konto gehört (users.person_id; Kiosk-Konten nicht) und // Person ein aktives Konto gehört (users.person_id; Kiosk-Konten nicht) und
@@ -4669,7 +4824,7 @@ app.post('/save-week', requireAuth, (req, res) => {
// Speichern und darf es nie scheitern lassen (Fehler werden nur geloggt). // Speichern und darf es nie scheitern lassen (Fehler werden nur geloggt).
// Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId" // Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId"
// vor dem Speichern), user (req.user). Keine Rückgabe. // vor dem Speichern), user (req.user). Keine Rückgabe.
function neueZuweisungenMelden(week, alt, user){ function neueZuweisungenMelden(week, alt, user, req){
try { try {
const konto = db.prepare(`SELECT id FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND ${sqlOhneWandtafel()}`); const konto = db.prepare(`SELECT id FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND ${sqlOhneWandtafel()}`);
const schonDa = db.prepare('SELECT 1 FROM benachrichtigungen WHERE user_id = ? AND todo_id = ? AND gelesen = 0'); const schonDa = db.prepare('SELECT 1 FROM benachrichtigungen WHERE user_id = ? AND todo_id = ? AND gelesen = 0');
@@ -4679,12 +4834,21 @@ function neueZuweisungenMelden(week, alt, user){
const jetzt = new Date().toISOString(); const jetzt = new Date().toISOString();
const von = (user && (user.displayName || user.username)) || ''; const von = (user && (user.displayName || user.username)) || '';
let anzahl = 0; let anzahl = 0;
// Für Teams: je Aufgabe die neu Zuständigen — auch Personen ohne Konto,
// aber nicht, wer sich selbst zuweist; nie aus der Planung-Sandbox.
const fuerTeams = new Map();
const sandbox = week.label === SANDBOX_WEEK_LABEL;
const ichPerson = user && user.personId ? String(user.personId) : '';
(Array.isArray(week.projects) ? week.projects : []).forEach(p => { (Array.isArray(week.projects) ? week.projects : []).forEach(p => {
const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : []; const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : [];
todos.forEach(t => { todos.forEach(t => {
if(!t || t.done) return; if(!t || t.done) return;
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => { (Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => {
if(alt.has(String(t.id) + '|' + String(pid))) return; if(alt.has(String(t.id) + '|' + String(pid))) return;
if(!sandbox && !t.lph && String(pid) !== ichPerson){
if(!fuerTeams.has(String(t.id))) fuerTeams.set(String(t.id), { p, t, personen: [] });
fuerTeams.get(String(t.id)).personen.push(String(pid));
}
const k = konto.get(String(pid)); const k = konto.get(String(pid));
if(!k || (user && k.id === user.id)) return; if(!k || (user && k.id === user.id)) return;
if(schonDa.get(k.id, String(t.id))) return; if(schonDa.get(k.id, String(t.id))) return;
@@ -4695,6 +4859,7 @@ function neueZuweisungenMelden(week, alt, user){
}); });
}); });
if(anzahl) console.log('Benachrichtigungen angelegt:', anzahl, '(neue Aufgaben-Zuweisungen)'); if(anzahl) console.log('Benachrichtigungen angelegt:', anzahl, '(neue Aufgaben-Zuweisungen)');
teamsNeueAufgaben(String(week.label || ''), fuerTeams, user, req);
} catch(err){ } catch(err){
console.error('Benachrichtigungen konnten nicht angelegt werden:', err.message); console.error('Benachrichtigungen konnten nicht angelegt werden:', err.message);
} }
@@ -4897,7 +5062,7 @@ app.post('/save-projects', requireAuth, (req, res) => {
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, label); db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, label);
db.exec('COMMIT'); db.exec('COMMIT');
} catch(err){ db.exec('ROLLBACK'); throw err; } } catch(err){ db.exec('ROLLBACK'); throw err; }
neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user); neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user, req);
const ids = new Set(liste.map(e => String(e.project.id))); const ids = new Set(liste.map(e => String(e.project.id)));
const frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id))); const frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id)));
ereignisSenden({ typ: 'projekte', label, updatedAt: jetzt, projekte: frisch }, req); ereignisSenden({ typ: 'projekte', label, updatedAt: jetzt, projekte: frisch }, req);
@@ -4915,6 +5080,25 @@ app.post('/save-projects', requireAuth, (req, res) => {
// winzig, deshalb fragt der Browser ihn alle 20 Sekunden ab und holt nur bei // winzig, deshalb fragt der Browser ihn alle 20 Sekunden ab und holt nur bei
// einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim // einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim
// gleichzeitigen Arbeiten, js/store.js). Für die Wandtafel ebenfalls lesbar. // gleichzeitigen Arbeiten, js/store.js). Für die Wandtafel ebenfalls lesbar.
// GET /weeks-aelter?vor=<label>&anzahl=5: die nächsten älteren Sitzungen vor
// <label> vollständig ("Weitere laden" im Verlauf), dazu wie viele danach
// noch übrig sind.
app.get('/weeks-aelter', requireAuthOderKiosk, (req, res) => {
try {
const liste = echteSitzungenSortiert();
const vor = String(req.query.vor || '');
const anzahl = Math.min(50, Math.max(1, Math.floor(Number(req.query.anzahl)) || 5));
const idx = liste.findIndex(w => w.label === vor);
const bis = idx < 0 ? liste.length : idx;
const von = Math.max(0, bis - anzahl);
const weeks = loadWeeks(liste.slice(von, bis).map(w => w.label));
res.json({ ok: true, weeks, uebrig: von });
} catch(err){
console.error('Ältere Sitzungen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
app.get('/week-revisionen', requireAuthOderKiosk, (req, res) => { app.get('/week-revisionen', requireAuthOderKiosk, (req, res) => {
try { try {
const revisionen = {}; const revisionen = {};
@@ -5017,7 +5201,8 @@ function projektlisteAenderungen(week){
const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean)); const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean));
const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid))); const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid)));
if(!fehlend.length) return { neu, entfernt: [] }; if(!fehlend.length) return { neu, entfernt: [] };
const w = loadWeeks().find(x => x.label === label); // Nur diese eine Sitzung laden, nicht alle (loadWeeks(label)).
const w = loadWeeks(label)[0];
const fehlendIds = new Set(fehlend.map(r => r.project_id)); const fehlendIds = new Set(fehlend.map(r => r.project_id));
return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] }; return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] };
} }
@@ -5176,9 +5361,25 @@ app.post('/unlock-week', requireRecht('s.entsperren'), (req, res) => {
// Route: Lädt alle Wochen (jetzt eine einzige globale Reihe) plus die // Route: Lädt alle Wochen (jetzt eine einzige globale Reihe) plus die
// firmenweite Personenliste. Liefert strukturierte JSON-Objekte (aus den // firmenweite Personenliste. Liefert strukturierte JSON-Objekte (aus den
// relationalen Tabellen zusammengesetzt); nach Label sortiert. // relationalen Tabellen zusammengesetzt); nach Label sortiert.
// echteSitzungenSortiert: alle echten Sitzungen (ohne Sandbox) als schlanke
// Liste, älteste zuerst — {id, label, date, locked, updatedAt}.
function echteSitzungenSortiert(){
return db.prepare('SELECT week_id, label, date, locked, updated_at FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
.map(r => ({ id: r.week_id, label: r.label, date: r.date || '', locked: !!r.locked, updatedAt: r.updated_at || '' }))
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
}
// GET /load-all?wochen=N: Mit wochen nur die jüngsten N Sitzungen vollständig
// (Eriks Wunsch: aktuelle + drei davor), dazu wochenListe = ALLE Sitzungen
// schlank (Name, Datum, Sperre) für Admin → Sitzungen und das Nachladen.
// Ohne wochen wie früher alle (z. B. ältere Browser-Stände).
app.get('/load-all', requireAuthOderKiosk, (req, res) => { app.get('/load-all', requireAuthOderKiosk, (req, res) => {
try { try {
const allWeeks = loadWeeks(); const anzahl = Math.floor(Number(req.query.wochen));
const wochenListe = echteSitzungenSortiert();
const allWeeks = anzahl > 0
? loadWeeks(wochenListe.slice(-anzahl).map(w => w.label).concat([SANDBOX_WEEK_LABEL]))
: loadWeeks();
// Die reservierte Sandbox-"Sitzung" (Planung, siehe SANDBOX_WEEK_LABEL) // Die reservierte Sandbox-"Sitzung" (Planung, siehe SANDBOX_WEEK_LABEL)
// ist keine echte Sitzung — sie wird separat als sandboxPlans // ist keine echte Sitzung — sie wird separat als sandboxPlans
// ausgeliefert, statt in weeks[] zu landen. So kommt sie clientseitig // ausgeliefert, statt in weeks[] zu landen. So kommt sie clientseitig
@@ -5189,7 +5390,7 @@ app.get('/load-all', requireAuthOderKiosk, (req, res) => {
const sandboxPlans = sandbox ? sandbox.projects : []; const sandboxPlans = sandbox ? sandbox.projects : [];
const sandboxUpdatedAt = sandbox ? sandbox.updatedAt : ''; const sandboxUpdatedAt = sandbox ? sandbox.updatedAt : '';
const people = loadGlobalPeople(req.user); const people = loadGlobalPeople(req.user);
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt }); res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt, wochenListe });
} catch(err){ } catch(err){
console.error('Load error', err); console.error('Load error', err);
res.status(500).json({ ok: false, error: String(err) }); res.status(500).json({ ok: false, error: String(err) });
@@ -6927,6 +7128,7 @@ const KI_MAIL_SCHEMA = {
items: { items: {
type: 'object', type: 'object',
properties: { properties: {
art: { type: 'string', enum: ['aufgabe', 'meilenstein'] },
titel: { type: 'string' }, titel: { type: 'string' },
start: { type: 'string' }, start: { type: 'string' },
ende: { type: 'string' }, ende: { type: 'string' },
@@ -6934,7 +7136,7 @@ const KI_MAIL_SCHEMA = {
aufwandStunden: { type: 'number' }, aufwandStunden: { type: 'number' },
zitat: { type: 'string' } zitat: { type: 'string' }
}, },
required: ['titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'], required: ['art', 'titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'],
additionalProperties: false additionalProperties: false
} }
} }
@@ -6947,7 +7149,8 @@ const KI_MAIL_SCHEMA = {
// Mails bei temperature:0 einzeln prüfen (siehe CLAUDE.md "### Protokolle"). // Mails bei temperature:0 einzeln prüfen (siehe CLAUDE.md "### Protokolle").
const KI_MAIL_SYSTEM_PROMPT = 'Du bekommst eine E-Mail aus einem Ingenieurbüro und Angaben zum Projekt. Mache daraus ' const KI_MAIL_SYSTEM_PROMPT = 'Du bekommst eine E-Mail aus einem Ingenieurbüro und Angaben zum Projekt. Mache daraus '
+ 'Einträge für den Terminplan des Projekts: je Termin, Frist oder zu erledigender Arbeit einen Eintrag. ' + 'Einträge für den Terminplan des Projekts: je Termin, Frist oder zu erledigender Arbeit einen Eintrag. '
+ 'Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" ' + 'art: "meilenstein" für einen festen Stichtag ohne eigene Arbeit (Abgabe, Termin, Freigabe, Besprechung), '
+ '"aufgabe" für zu erledigende Arbeit. Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" '
+ 'rechnest du vom Datum der Mail aus. Ein einzelner Termin oder eine Frist: start und ende gleich. ' + 'rechnest du vom Datum der Mail aus. Ein einzelner Termin oder eine Frist: start und ende gleich. '
+ 'Ohne Datum in der Mail bleiben start und ende leer. zustaendig: nur Namen aus der Liste der Beteiligten, ' + 'Ohne Datum in der Mail bleiben start und ende leer. zustaendig: nur Namen aus der Liste der Beteiligten, '
+ 'die in der Mail als verantwortlich genannt sind, sonst leer. aufwandStunden nur wenn genannt, sonst 0. zitat: der Satz aus der Mail, ' + 'die in der Mail als verantwortlich genannt sind, sonst leer. aufwandStunden nur wenn genannt, sonst 0. zitat: der Satz aus der Mail, '
@@ -6976,6 +7179,7 @@ function normalisiereMailEintrag(e){
if(ende && !start) start = ende; if(ende && !start) start = ende;
if(start && ende && ende < start){ const t = start; start = ende; ende = t; } if(start && ende && ende < start){ const t = start; start = ende; ende = t; }
return { return {
art: (e && e.art) === 'meilenstein' ? 'meilenstein' : 'aufgabe',
titel: String((e && e.titel) || '').trim().slice(0, 200), titel: String((e && e.titel) || '').trim().slice(0, 200),
start, ende, start, ende,
zustaendig: Array.isArray(e && e.zustaendig) ? e.zustaendig.map(String).filter(Boolean) : [], zustaendig: Array.isArray(e && e.zustaendig) ? e.zustaendig.map(String).filter(Boolean) : [],
@@ -8265,6 +8469,169 @@ app.get('/app-settings', requireAuthOderKiosk, (req, res) => {
// "### Protokolle". GET liefert je Anbieter, OB ein Schlüssel gesetzt ist // "### Protokolle". GET liefert je Anbieter, OB ein Schlüssel gesetzt ist
// (nie der Schlüssel selbst, wie ein Passwort) sowie die — nicht geheime — // (nie der Schlüssel selbst, wie ein Passwort) sowie die — nicht geheime —
// apiUrl zum Weiterbearbeiten. // apiUrl zum Weiterbearbeiten.
// ---------- Kalender-Abo für Outlook (iCalendar) ----------
//
// Eriks Wunsch: die eigenen Termine in Outlook sehen. Jedes Konto bekommt
// unter Einstellungen → Mein Konto eine geheime Adresse
// /kalender/<token>.ics; Outlook abonniert sie („Aus dem Internet“) und holt
// sie selbst regelmäßig. NUR LESEN — geändert wird weiter in der App.
// Inhalt aus der jüngsten echten Sitzung: eigene offene Aufgaben mit Termin,
// Meilensteine der eigenen Projekte (zugeordnet oder mit eigener Aufgabe)
// und die eigenen Abwesenheiten. Ganztägige Einträge (VALUE=DATE), damit
// keine Zeitzone im Spiel ist.
//
// Der Token IST die Anmeldung (Outlook kann sich nicht einloggen) — deshalb
// lang und zufällig, jederzeit neu erzeugbar (alte Adresse ungültig) oder
// abschaltbar. Deaktivierte Konten und reine Wandtafel-Konten bekommen 404.
function kalenderUrl(req, token){
return req.protocol + '://' + req.get('host') + '/kalender/' + token + '.ics';
}
app.get('/mein-kalender', requireAuth, (req, res) => {
const row = db.prepare('SELECT kalender_token FROM users WHERE id = ?').get(req.user.id);
const token = row && row.kalender_token;
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
});
// POST {aus: true} schaltet ab, sonst wird eine NEUE Adresse erzeugt (die
// bisherige wird damit ungültig).
app.post('/mein-kalender', requireAuth, (req, res) => {
try {
const aus = !!(req.body && req.body.aus);
const token = aus ? '' : crypto.randomBytes(24).toString('base64url');
db.prepare('UPDATE users SET kalender_token = ? WHERE id = ?').run(token, req.user.id);
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
} catch(err){
console.error('Kalender-Adresse speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// icsText: Text nach RFC 5545 maskieren.
function icsText(s){
return String(s || '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
}
// icsZeile: Zeilen über 75 Byte falten (Folgezeilen beginnen mit Leerzeichen).
function icsZeile(z){
if(Buffer.byteLength(z, 'utf8') <= 75) return z;
const teile = [];
let rest = z;
// Erste Zeile höchstens 75 Byte, Folgezeilen 74 (plus führendes Leerzeichen).
const grenze = () => teile.length ? 74 : 75;
while(Buffer.byteLength(rest, 'utf8') > grenze()){
let n = Math.min(rest.length, grenze());
while(Buffer.byteLength(rest.slice(0, n), 'utf8') > grenze()) n--;
// Kein Ersatzpaar (Emoji o. ä.) zerschneiden.
const c = rest.charCodeAt(n - 1);
if(c >= 0xD800 && c <= 0xDBFF) n--;
teile.push(rest.slice(0, n));
rest = rest.slice(n);
}
teile.push(rest);
return teile.join('\r\n ');
}
function icsDatum(iso){ return String(iso || '').replace(/-/g, ''); }
// icsTagDanach: DTEND ganztägiger Einträge ist der Tag NACH dem letzten Tag.
function icsTagDanach(iso){
const d = new Date(iso + 'T12:00:00');
d.setDate(d.getDate() + 1);
return d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
}
const ICS_DATUM = /^\d{4}-\d{2}-\d{2}$/;
// kalenderEreignisse: die Einträge einer Person aus einer Sitzung plus ihren
// Abwesenheiten. Rein (ohne Datenbank), damit testbar. Parameter: ich
// (person_id), w (Sitzung oder null), abwesenheiten (Zeilen aus
// user_absences). Rückgabe: [{uid, start, ende, titel, projekt, text, frei}].
function kalenderEreignisse(ich, w, abwesenheiten){
const ereignisse = [];
if(w && ich){
(w.projects || []).forEach(p => {
const projekt = [p.nr, p.name].filter(Boolean).join(' ');
const zugeordnet = Object.keys(p.assign || {}).some(k => k.split('|')[0] === ich && Number(p.assign[k]) > 0);
const plan = p.plan || {};
let eigeneAufgabe = false;
(plan.todos || []).forEach(t => {
if(!Array.isArray(t.assignees) || !t.assignees.includes(ich)) return;
eigeneAufgabe = true;
if(t.done || t.lph) return;
const start = ICS_DATUM.test(t.start || '') ? t.start : (ICS_DATUM.test(t.end || '') ? t.end : '');
const ende = ICS_DATUM.test(t.end || '') ? t.end : start;
if(!start) return;
ereignisse.push({
uid: 'aufgabe-' + t.id, start, ende,
titel: t.title || 'Aufgabe', projekt,
text: 'Aufgabe in ' + projekt + (Number(t.progress) > 0 ? ' · ' + Number(t.progress) + ' % erledigt' : '')
+ (t.notiz ? '\n\n' + String(t.notiz).slice(0, 1500) : '')
});
});
if(!zugeordnet && !eigeneAufgabe) return;
(plan.milestones || []).forEach(m => {
if(!ICS_DATUM.test(m.date || '')) return;
ereignisse.push({
uid: 'meilenstein-' + (p.projectUid || p.id) + '-' + m.id, start: m.date, ende: m.date,
titel: '◆ ' + (m.name || 'Meilenstein'), projekt,
text: 'Meilenstein in ' + projekt + (m.notiz ? '\n\n' + String(m.notiz).slice(0, 1500) : '')
});
});
});
}
(abwesenheiten || []).forEach(a => {
if(!ICS_DATUM.test(a.start || '')) return;
ereignisse.push({
uid: 'abwesenheit-' + a.id, start: a.start, ende: ICS_DATUM.test(a.end || '') ? a.end : a.start,
titel: a.type || 'Abwesend', projekt: '', text: a.note || '', frei: true
});
});
return ereignisse;
}
// kalenderText: die iCalendar-Datei (CRLF, gefaltete Zeilen).
function kalenderText(name, ereignisse){
const jetzt = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
const zeilen = [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Projektuebersicht//Kalender//DE', 'CALSCALE:GREGORIAN',
'METHOD:PUBLISH', 'X-WR-CALNAME:' + icsText('Projektübersicht – ' + (name || '')),
'X-WR-TIMEZONE:Europe/Berlin', 'REFRESH-INTERVAL;VALUE=DURATION:PT1H', 'X-PUBLISHED-TTL:PT1H'
];
ereignisse.forEach(e => {
zeilen.push('BEGIN:VEVENT',
'UID:' + icsText(e.uid) + '@projektuebersicht',
'DTSTAMP:' + jetzt,
'DTSTART;VALUE=DATE:' + icsDatum(e.start),
'DTEND;VALUE=DATE:' + icsTagDanach(e.ende < e.start ? e.start : e.ende),
'SUMMARY:' + icsText(e.titel + (e.projekt ? ' (' + e.projekt + ')' : '')),
'DESCRIPTION:' + icsText(e.text),
// Als „frei“ eintragen: Ganztägige Aufgaben sollen die Verfügbarkeit
// in Outlook nicht blockieren; Abwesenheiten zeigen „abwesend“.
'TRANSP:TRANSPARENT',
e.frei ? 'X-MICROSOFT-CDO-BUSYSTATUS:OOF' : 'X-MICROSOFT-CDO-BUSYSTATUS:FREE',
'END:VEVENT');
});
zeilen.push('END:VCALENDAR');
return zeilen.map(icsZeile).join('\r\n') + '\r\n';
}
app.get('/kalender/:datei', (req, res) => {
try {
const token = String(req.params.datei || '').replace(/\.ics$/i, '');
if(token.length < 20) return res.status(404).end();
const u = db.prepare('SELECT id, display_name, person_id, role, active FROM users WHERE kalender_token = ?').get(token);
if(!u || !u.active || !hatRecht({ id: u.id, role: u.role }, 'app')) return res.status(404).end();
const label = echteSitzungenSortiert().map(w => w.label).pop();
const w = label ? loadWeeks(label)[0] : null;
const abw = db.prepare('SELECT id, start, end, type, note FROM user_absences WHERE user_id = ?').all(u.id);
res.set('Content-Type', 'text/calendar; charset=utf-8');
res.set('Cache-Control', 'no-cache');
res.set('Content-Disposition', 'inline; filename="projektuebersicht.ics"');
res.send(kalenderText(u.display_name, kalenderEreignisse(u.person_id || '', w, abw)));
} catch(err){
console.error('Kalender ausliefern fehlgeschlagen:', err);
res.status(500).end();
}
});
app.get('/my-ai-settings', requireAuth, (req, res) => { app.get('/my-ai-settings', requireAuth, (req, res) => {
try { try {
const chatgpt = readUserAiKey(req.user.id, 'chatgpt'); const chatgpt = readUserAiKey(req.user.id, 'chatgpt');
@@ -8879,6 +9246,10 @@ function betriebAufraeumen(){
try { try {
const grenze = Date.now() - loginBlockMs(); const grenze = Date.now() - loginBlockMs();
loginAttempts.forEach((e, k) => { if(e.first < grenze) loginAttempts.delete(k); }); loginAttempts.forEach((e, k) => { if(e.first < grenze) loginAttempts.delete(k); });
// Merkliste der last_seen_at-Schreibzeiten (attachUser) nicht endlos
// wachsen lassen: Einträge älter als eine Stunde braucht niemand mehr.
const stundeHer = Date.now() - 60 * 60 * 1000;
sitzungZuletztGeschrieben.forEach((t, k) => { if(t < stundeHer) sitzungZuletztGeschrieben.delete(k); });
db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
} catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); } } catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); }
} }