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

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

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
sich der Text gegenüber der Sitzung davor geändert hat; gleicher Text in
mehreren Gewerken einer Sitzung (Altbestand aus der Umstellung auf
Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Drei sichtbar, Rest
über „Alle N anzeigen“. Keine neue Datenhaltung — jede Sitzung hält ihre
Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Gezeigt wird, was
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
Ende von `plan-styles.css`.
**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
`.eml`-Datei) oder Strg+V (außerhalb von Eingabefeldern) auf die Bereiche
**Terminplan** oder **Aufgaben** eines Projekts; eine KI macht daraus
Einträge. **Nur Aufgaben, keine Meilensteine** — Eriks Vorgabe: Der
Terminplan ist nur die grafische Darstellung der Aufgabenliste.
Einträge — **Aufgabe oder Meilenstein**: Die KI schlägt die Art vor
(`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:
none`, Zähler für dragenter/-leave). Nicht in gesperrten Sitzungen. Kein
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
als Titel. **Speichert nichts.** `.msg` (Outlook) wird abgewiesen mit dem
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
projuebersicht-mail-ki`), sonst der erste mit Schlüssel.
- Jeder Vorschlag öffnet das normale Aufgaben-Panel vorausgefüllt
(`openTodoModal(null, prefill)`, neu: `title`, `aufwand`, `notiz`,
`mailHinweis`, `nachSchliessen`); bei mehreren erst eine Auswahl
(`#mailAuswahlModal`), dann nacheinander. Ohne Speichern entsteht nichts.
- **Notiz je Aufgabe** (`t.notiz`, `plan_todos.notiz`, Migration
`migrateAddPlanTodoNotiz()`, in `IMPORT_OPTIONAL_COLS`): Feld „Notiz“ im
Aufgaben-Panel für jede Aufgabe; aus einer Mail mit Absender, Datum,
- Umschalter „Aufgabe | Meilenstein“: eigene Stile `.segmented.mail-auswahl-art`
und Klasse `mail-art-knopf` (sonst greift unter Glas
`html.glas button:not([class])` mit Rand und Glanzkante, und der gewählte
Knopf war von `var(--surface)` kaum zu unterscheiden).
- Jeder gewählte Vorschlag öffnet nacheinander das normale Panel
vorausgefüllt: `openTodoModal(null, prefill)` (`title`, `start`/`end`,
`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
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
neuen Kommentar“); geänderte Leistungsphasen seit dem vorigen Eintrag als
Chips („LPH 5: 60 → 75 %“); Verfasser und Datum; „übernehmen“
(`berichtVerlaufUebernehmen()` → `takeCommentFromHistory()`); drei
sichtbar, Rest über „Alle N anzeigen“ (`berichtVerlaufUmschalten()` zeichnet
(`berichtVerlaufUebernehmen()` → `takeCommentFromHistory()`); gezeigt wird,
was geladen ist, „Weitere laden“ holt ältere (`berichtVerlaufNeu()` zeichnet
nur den Abschnitt neu, ein angefangener Kommentar bleibt). Die dichte Karte
bekommt dafür `opts.ohneVerlauf` (`renderBoardDrawer()`), damit der
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
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`)
Eine Sitzung wird weiterhin als Ganzes über `POST /save-week` mit
@@ -4324,6 +4426,17 @@ Rechner.
kein `backdrop-filter` — eigenes Design, Kiosk-Rechner) und
`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)
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 —
ohne Gateway ab 2 min neu verbinden, nach 10 min `systemctl reboot`.
- **Prüfen:** `Docker/pi-pruefen.sh` (Speicher, Neustarts, OOM, Strom).
- Offen (Schritt 4 des Plans): `/load-all` liefert weiter ALLE Sitzungen —
wächst wöchentlich auf Server und Browser.
- **Sitzungen nach Bedarf** (Schritt 4, umgesetzt 30.10.2026): siehe
„Sitzungen nach Bedarf laden“ unten.
## Docker
+54 -16
View File
@@ -38,8 +38,8 @@
<link rel="icon" type="image/png" href="/logo.png">
<link rel="alternate icon" href="/logo.ico">
<link rel="apple-touch-icon" href="/logo.png">
<link rel="stylesheet" href="/styles.css?v=106">
<link rel="stylesheet" href="/plan-styles.css?v=43">
<link rel="stylesheet" href="/styles.css?v=110">
<link rel="stylesheet" href="/plan-styles.css?v=45">
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
@@ -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="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="teams" onclick="showAdminSection('teams')">Teams</button>
<button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button>
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button>
</div>
@@ -455,6 +456,16 @@
mit Fehlertext, der bis zum nächsten Testergebnis stehen bleibt
(Design-Entwürfe mit Erik abgestimmt: Artifacts d7ab2945,
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">
<h3 class="settings-card-title">KI-Zugangsdaten</h3>
<p class="hint">Für die automatische Punkt-Erkennung beim Protokoll-Import
@@ -665,6 +676,28 @@
</div>
</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">
<h3 class="settings-card-title">Betrieb &amp; Sicherheit <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
<div class="settings-field-grid">
@@ -1522,6 +1555,11 @@
<h3>Hängt ab von</h3>
<div id="milestoneEditDependsOn" class="todo-depends-list pd-liste"></div>
</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 class="pd-fuss">
<button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button>
@@ -1912,21 +1950,21 @@
</main>
</div>
<script src="/js/globals.js?v=8"></script>
<script src="/js/utils.js?v=30"></script>
<script src="/js/globals.js?v=9"></script>
<script src="/js/utils.js?v=31"></script>
<script src="/js/icons.js?v=2"></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/auth.js?v=10"></script>
<script src="/js/store.js?v=14"></script>
<script src="/js/weeks.js?v=11"></script>
<script src="/js/auth.js?v=11"></script>
<script src="/js/store.js?v=15"></script>
<script src="/js/weeks.js?v=12"></script>
<script src="/js/board/board.js?v=41"></script>
<script src="/js/wochenbericht/wochenbericht.js?v=28"></script>
<script src="/js/wochenbericht/wochenbericht.js?v=29"></script>
<script src="/js/people/people.js?v=8"></script>
<script src="/js/planung/planung.js?v=3"></script>
<script src="/js/planchecker/planchecker.js?v=1"></script>
<script src="/js/abwesenheit/abwesenheit.js?v=9"></script>
<script src="/js/abwesenheit/abwesenheit.js?v=10"></script>
<script src="/js/projects/projects.js?v=10"></script>
<script src="/js/load/load.js?v=23"></script>
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
@@ -1944,8 +1982,8 @@
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
<script src="/js/wand/wand.js?v=21"></script>
<script src="/js/settings/settings.js?v=12"></script>
<script src="/js/admin/admin.js?v=19"></script>
<script src="/js/settings/settings.js?v=14"></script>
<script src="/js/admin/admin.js?v=21"></script>
<script src="/js/admin-users.js?v=10"></script>
<script src="/js/admin/rollen.js?v=1"></script>
<script src="/js/einfuehrung.js?v=2"></script>
@@ -1957,21 +1995,21 @@
(das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach
js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js
umhuellt. -->
<script src="/js/plan/plan-state.js?v=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-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-build-gantt.js?v=11"></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-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-phasen.js?v=2"></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-templates.js?v=11"></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>
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
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>
<span class="abw-team-steuer">
<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>
<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>
</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: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' },
{ key: 'teams', title: 'Teams', recht: 'a.teams' },
{ key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' },
{ 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
// frisch holen.
if(key === 'papierkorb') loadAdminPapierkorb();
if(key === 'teams') loadAdminTeams();
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
try {
@@ -287,6 +289,14 @@ function juengsteWoche(){
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),
// neueste zuerst, mit Datum, Zustand und den passenden Aktionen.
//
@@ -321,7 +331,7 @@ function renderAdminSitzungen(){
if(!el || !Array.isArray(db.weeks)) return;
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 => {
const aktuell = w.id === db.currentWeek;
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,
// zufällige applyAutoLock()-auslösende Handlung.
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){
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 {
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">
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
${chip}
${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>`;
}).join('');
}
@@ -363,15 +373,22 @@ function renderAdminSitzungen(){
// nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert
// hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund
// zur aktuellen zu machen.
function adminUnlockWeek(weekId){
unlockWeek(db.weeks.find(w => w.id === weekId), renderAdminSitzungen);
// Alle vier nehmen das Label: Die Sitzung ist evtl. nicht geladen und wird
// 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){
deleteWeekById(weekId, renderAdminSitzungen);
function adminUnlockWeek(label){
adminMitSitzung(label, w => unlockWeek(w, () => { sitzungKurzAbgleichen(w); renderAdminSitzungen(); }));
}
function adminOpenWeek(weekId){
switchWeek(weekId);
showTab('board');
function adminDeleteWeek(label){
adminMitSitzung(label, w => deleteWeekById(w.id, renderAdminSitzungen));
}
function adminOpenWeek(label){
adminMitSitzung(label, w => { switchWeek(w.id); showTab('board'); });
}
// 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
// die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in
// js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe.
function adminRelockWeek(weekId){
const w = db.weeks.find(x => x.id === weekId);
function adminRelockWeek(label){
const w = db.weeks.find(x => x.label === label);
if(!w || w.locked) return;
w.locked = true;
sitzungKurzAbgleichen(w);
markWeekDirty(w.label);
save({ week: false });
const juengste = juengsteWoche();
@@ -1083,3 +1101,65 @@ async function adminProjektEndgueltigLoeschen(id){
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();
renderUserBar();
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);
// Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind
// und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst
+27 -3
View File
@@ -40,7 +40,7 @@ function openDatePicker(opts){
document.body.appendChild(pop);
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';
renderRangePicker();
@@ -80,7 +80,7 @@ function openRangePicker(opts){
// 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 };
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';
renderRangePicker();
@@ -160,6 +160,14 @@ function applyRangePicker(){
const selected = start || end;
if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (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){
const disp = document.getElementById(opts.displayId);
if(disp){
@@ -199,10 +207,15 @@ function renderRangePicker(){
</div>
<div class="range-picker-hint">${hint}</div>
<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>`);
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 day = 1; day <= daysInMonth; day++){
const d = new Date(st.viewYear, st.viewMonth, day);
if(d.getDay() === 1) html += kwZelle(d);
const iso = formatDateISO(d);
const classes = ['range-picker-day'];
if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend');
@@ -218,4 +231,15 @@ function renderRangePicker(){
${st.mode !== 'single' && st.start && !st.end ? '<button type="button" class="small" onclick="rangePickerState.end = rangePickerState.start; applyRangePicker()">Nur dieser Tag</button>' : ''}
</div>`;
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.
// globalPeople ist ein flaches Array (keine Abteilungstrennung mehr).
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)
// befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht
// 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];
}
const PLAN_VERLAUF_KURZ = 3;
let planVerlaufAlle = false;
let planVerlaufProjekt = null;
function renderPlanKommentarVerlauf(verlauf){
const el = document.getElementById('planStatusVerlauf');
if(!el) return;
const uid = app.project && (app.project.projectUid || app.project.id);
if(uid !== planVerlaufProjekt){ planVerlaufProjekt = uid; planVerlaufAlle = false; }
if(!verlauf.length){ el.hidden = true; el.innerHTML = ''; return; }
// Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
// aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
// 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;
const zeigen = planVerlaufAlle ? verlauf : verlauf.slice(0, PLAN_VERLAUF_KURZ);
const zeigen = verlauf;
const zeilen = zeigen.map((e, i) => `
<li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}">
<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>
</li>`).join('');
const mehr = verlauf.length > PLAN_VERLAUF_KURZ
? `<button type="button" class="plan-verlauf-mehr" onclick="planVerlaufUmschalten()">${planVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + verlauf.length + ' anzeigen'}</button>`
: '';
const mehr = weitere ? `<div class="plan-verlauf-mehr-zeile">${weitere}</div>` : '';
el.innerHTML = `
<div class="plan-verlauf-kopf">
<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>
<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(){
+56 -23
View File
@@ -1,21 +1,31 @@
// plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per
// Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf
// den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus
// Aufgaben. Der Terminplan ist nur die grafische Darstellung der
// Aufgabenliste — deshalb entstehen hier ausschließlich Aufgaben
// (plan.todos), keine Meilensteine.
// Einträge.
//
// Nichts wird ohne Prüfung angelegt: Jeder Vorschlag öffnet das gewohnte
// Aufgaben-Panel vorausgefüllt (openTodoModal(null, prefill)), die Mail
// steht als Notiz darin (t.notiz). Mehrere Vorschläge erst als Liste zum
// Abhaken, dann nacheinander im Panel. Server: POST /plan-eintrag-aus-mail
// (speichert nichts).
// Nichts wird ohne Prüfung angelegt: Die Vorschläge stehen erst in einer
// Liste — je Zeile abhaken und „Aufgabe | Meilenstein“ wählen (Eriks
// Wunsch; die KI schlägt die Art vor) —, dann öffnet sich je Eintrag das
// gewohnte Panel vorausgefüllt (openTodoModal/openMilestoneModal mit
// prefill), die Mail steht als Notiz darin (t.notiz bzw. m.notiz).
// Server: POST /plan-eintrag-aus-mail (speichert nichts).
(function(){
const app = window.PlanApp;
const ANBIETER_KEY = 'projuebersicht-mail-ki';
const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben'];
let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit)
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(){
if(typeof currentTab === 'undefined' || currentTab !== 'plan') return '';
@@ -24,7 +34,7 @@
}
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){
@@ -44,7 +54,7 @@
el.hidden = true;
el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')}
<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);
}
return el;
@@ -139,12 +149,9 @@
// ---------- Auswerten ----------
function anbieterWaehlen(){
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
const bereit = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
let zuletzt = '';
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
return bereit.includes(zuletzt) ? zuletzt : (bereit[0] || '');
}).catch(() => '');
let zuletzt = '';
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
return Promise.resolve(kiAnbieter.includes(zuletzt) ? zuletzt : (kiAnbieter[0] || ''));
}
function projektPersonen(){
@@ -229,7 +236,8 @@
app.showToast('In der Mail steht nichts für den Terminplan.');
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);
}
@@ -240,6 +248,16 @@
const e = rest.shift();
if(!e){ renderPlanGantt(); return; }
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, {
title: e.titel,
start: e.start,
@@ -271,20 +289,35 @@
const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende));
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">
<h2>${eintraege.length} Einträge in der Mail</h2>
<p class="hint">${esc(hinweisText(d))} Die ausgewählten öffnen sich nacheinander im Aufgaben-Panel.</p>
<h2>${eintraege.length === 1 ? '1 Eintrag' : eintraege.length + ' Einträge'} in der Mail</h2>
<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) => `
<label class="mail-auswahl-zeile"><input type="checkbox" checked data-i="${i}">
<span class="mail-auswahl-titel">${esc(e.titel)}</span>
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></label>`).join('')}</div>
<div class="mail-auswahl-zeile">
<input type="checkbox" checked data-i="${i}" aria-label="${esc(e.titel)} übernehmen">
<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">
<button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button>
<button type="button" data-aktion="weiter">Prüfen und anlegen</button>
</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="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';
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,
// Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und
// 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;
const pre = prefill || {};
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = [];
// Migration alter Meilensteine
@@ -43,10 +50,18 @@
const isNew = index === null || plan.milestones[index] === undefined;
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) —
// 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;
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
@@ -152,6 +167,9 @@
window.closeMilestoneModal = function(){
document.getElementById('milestoneModal').style.display = 'none';
app.currentMilestoneIndex = null;
const danach = msNachSchliessen;
msNachSchliessen = null;
if(danach) setTimeout(danach, 0);
};
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
@@ -169,18 +187,21 @@
const positionSelect = document.getElementById('milestoneEditPosition');
const position = positionSelect ? positionSelect.value : '';
const msNotizEl = document.getElementById('milestoneEditNotiz');
const notiz = msNotizEl ? msNotizEl.value.trim() : '';
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = [];
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]){
const m = plan.milestones[app.currentMilestoneIndex];
m.name = name;
m.date = date;
m.dependsOn = dependsOn;
m.position = position;
m.notiz = notiz;
}
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
app.savePlan();
+42
View File
@@ -99,8 +99,21 @@ window.PlanApp = {
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
// 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(){
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
// "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;
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.');
return false;
}
@@ -286,6 +312,22 @@ window.PlanApp = {
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
// Bereiche befüllt sind, wird einer davon eingeblendet.
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)
+75
View File
@@ -24,9 +24,82 @@
// gerufen. Keine Parameter, keine Rueckgabe.
function renderSettings(){
renderSettingsAccount();
renderSettingsKalender();
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 ----------
// renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an.
@@ -234,6 +307,8 @@ function putMyAiKey(provider, patch){
body: JSON.stringify(body)
}).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.');
// "Termin aus Mail" ist nur mit eingerichteter KI aktiv — Stand neu holen.
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
return json;
});
}
+109 -9
View File
@@ -596,7 +596,13 @@ function liveWocheNachladen(label, geloescht){
// Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach.
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
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 => {
if(!frisch) return liveAllesNeu();
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
@@ -648,6 +654,102 @@ function handleWeekConflict(week, err){
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
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
// 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.');
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);
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
@@ -775,13 +879,9 @@ function load(silent){
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string')
.map(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;
});
.map(wocheAbsichern);
// Alle Sitzungen schlank (Name, Datum, Sperre) — auch die nicht geladenen.
db.wochenListe = Array.isArray(data.wochenListe) ? data.wochenListe : db.weeks.map(sitzungKurz);
// Server-Stand als Basis für späteres Zusammenführen merken.
wochenBasis.clear();
+17 -3
View File
@@ -1187,8 +1187,19 @@ function verlaufBeteiligte(version){
// die eine geänderte Zahl sonst nicht.
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
// 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){
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.
//
// 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();
if(frueher.length === 0){
return `<details class="timeline-block">
return `<details class="timeline-block"${offen}${umschalten}>
<summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span>
<span>Verlauf</span>
<span class="comment-history-count zero">0</span>
</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>`;
}
@@ -1276,7 +1289,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
</article>`;
}).join('');
return `<details class="timeline-block">
return `<details class="timeline-block"${offen}${umschalten}>
<summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span>
<span>Verlauf</span>
@@ -1284,6 +1297,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
</summary>
<div class="timeline-body">
<div class="timeline-rail">${kacheln}</div>
${weitere ? `<div class="verlauf-weitere-zeile">${weitere}</div>` : ''}
</div>
</details>`;
}
+4 -1
View File
@@ -138,7 +138,9 @@ function unlockCurrentWeek(){ unlockWeek(currentWeek()); }
// üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich
// darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe.
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);
if(!w) 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);
const warAktuell = (weekId === db.currentWeek);
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)));
// Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus
// 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.
function waehleBerichtProjekt(uid){
berichtAuswahl = uid;
berichtVerlaufAlle = false; // neues Projekt zeigt wieder nur die jüngsten Einträge
renderWochenbericht();
const detail = document.getElementById('berichtDetail');
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
// Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()).
const BERICHT_VERLAUF_KURZ = 3;
// berichtVerlaufAlle: "Alle N anzeigen" aufgeklappt; waehleBerichtProjekt()
// setzt es beim Projektwechsel zurück.
let berichtVerlaufAlle = false;
// Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
// aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
// js/store.js) und zeichnet nur diesen Abschnitt neu (berichtVerlaufNeu()).
// berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende)
function berichtKurzKw(label){
@@ -828,11 +826,12 @@ function renderBerichtVerlauf(p, w, locked){
const gText = g || 'alle Gewerke';
let inhalt;
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.'
: 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>`;
} else {
const zeigen = berichtVerlaufAlle ? eintraege : eintraege.slice(0, BERICHT_VERLAUF_KURZ);
const zeigen = eintraege;
inhalt = `<ol class="bv-liste">${zeigen.map((e, i) => {
const datum = berichtVerlaufDatum(e.at);
const wer = [e.autor, datum].filter(Boolean).join(', ');
@@ -854,9 +853,7 @@ function renderBerichtVerlauf(p, w, locked){
</li>`;
}).join('')}</ol>`;
}
const mehr = eintraege.length > BERICHT_VERLAUF_KURZ
? `<button type="button" class="bv-mehr" onclick="berichtVerlaufUmschalten()">${berichtVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + eintraege.length + ' anzeigen'}</button>`
: '';
const mehr = weitereLadenKnopf('berichtVerlaufNeu');
const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare';
return `<section class="bericht-verlauf" id="berichtVerlauf" aria-label="Verlauf der Vorwochen">
<div class="bv-kopf">
@@ -869,10 +866,9 @@ function renderBerichtVerlauf(p, w, locked){
</section>`;
}
// berichtVerlaufUmschalten: "Alle N anzeigen" / "Weniger anzeigen". Zeichnet
// nur den Verlauf neu, damit ein angefangener Kommentar unberührt bleibt.
function berichtVerlaufUmschalten(){
berichtVerlaufAlle = !berichtVerlaufAlle;
// berichtVerlaufNeu: zeichnet nur den Verlauf neu (nach „Weitere laden“),
// damit ein angefangener Kommentar unberührt bleibt.
function berichtVerlaufNeu(){
const w = currentWeek();
const p = w && aktuellesBerichtProjekt(w);
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-zeile {
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-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 {
position: fixed;
z-index: 1100;
width: 288px;
width: 316px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
@@ -3343,9 +3343,17 @@ html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,2
.range-picker-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-columns: 26px repeat(7, 1fr);
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 {
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-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-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 small { color: var(--text-muted); font-size: 12.5px; }
#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;
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 '',
position TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
notiz TEXT NOT NULL DEFAULT '',
PRIMARY KEY (week_label, project_id, milestone_id)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
@@ -779,7 +780,8 @@ const RECHTE = [
['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''],
['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''],
['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_KEYS = new Set(RECHT_LABEL.keys());
@@ -1224,8 +1226,8 @@ function projektStatements(){
VALUES (?, ?, ?, ?)`);
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
VALUES (?, ?, ?, ?)`);
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
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(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 || '')));
});
}
@@ -1409,11 +1411,15 @@ function loadGlobalPeople(fuerUser){
function loadWeeks(nurLabel){
// nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die
// 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 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 : ''}`);
return nurLabel === undefined ? st.all() : st.all(String(nurLabel));
return labels ? st.all(...labels) : st.all();
};
const weeks = alle('weeks', 'ORDER BY label');
const weekPeople = alle('week_people', 'ORDER BY array_order');
@@ -1534,7 +1540,7 @@ function loadWeeks(nurLabel){
const msMap = {};
milestones.forEach(r => {
const k = msKey(r.week_label, r.project_id, r.milestone_id);
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [] };
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '' };
});
milestoneDeps.forEach(r => {
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 ''");
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
@@ -3038,6 +3049,12 @@ function migrateUsersDepartment(){
// dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine
// Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer =
// 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')){
db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''");
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).
const updatedAt = saveWeekRelational(week);
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);
ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req);
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
// als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der
// 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).
// Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId"
// vor dem Speichern), user (req.user). Keine Rückgabe.
function neueZuweisungenMelden(week, alt, user){
function neueZuweisungenMelden(week, alt, user, req){
try {
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');
@@ -4679,12 +4834,21 @@ function neueZuweisungenMelden(week, alt, user){
const jetzt = new Date().toISOString();
const von = (user && (user.displayName || user.username)) || '';
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 => {
const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : [];
todos.forEach(t => {
if(!t || t.done) return;
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => {
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));
if(!k || (user && k.id === user.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)');
teamsNeueAufgaben(String(week.label || ''), fuerTeams, user, req);
} catch(err){
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.exec('COMMIT');
} 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 frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id)));
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
// einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim
// 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) => {
try {
const revisionen = {};
@@ -5017,7 +5201,8 @@ function projektlisteAenderungen(week){
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)));
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));
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
// firmenweite Personenliste. Liefert strukturierte JSON-Objekte (aus den
// 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) => {
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)
// ist keine echte Sitzung — sie wird separat als sandboxPlans
// 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 sandboxUpdatedAt = sandbox ? sandbox.updatedAt : '';
const people = loadGlobalPeople(req.user);
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt });
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt, wochenListe });
} catch(err){
console.error('Load error', err);
res.status(500).json({ ok: false, error: String(err) });
@@ -6927,6 +7128,7 @@ const KI_MAIL_SCHEMA = {
items: {
type: 'object',
properties: {
art: { type: 'string', enum: ['aufgabe', 'meilenstein'] },
titel: { type: 'string' },
start: { type: 'string' },
ende: { type: 'string' },
@@ -6934,7 +7136,7 @@ const KI_MAIL_SCHEMA = {
aufwandStunden: { type: 'number' },
zitat: { type: 'string' }
},
required: ['titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'],
required: ['art', 'titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'],
additionalProperties: false
}
}
@@ -6947,7 +7149,8 @@ const KI_MAIL_SCHEMA = {
// 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 '
+ '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. '
+ '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, '
@@ -6976,6 +7179,7 @@ function normalisiereMailEintrag(e){
if(ende && !start) start = ende;
if(start && ende && ende < start){ const t = start; start = ende; ende = t; }
return {
art: (e && e.art) === 'meilenstein' ? 'meilenstein' : 'aufgabe',
titel: String((e && e.titel) || '').trim().slice(0, 200),
start, ende,
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
// (nie der Schlüssel selbst, wie ein Passwort) sowie die — nicht geheime —
// 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) => {
try {
const chatgpt = readUserAiKey(req.user.id, 'chatgpt');
@@ -8879,6 +9246,10 @@ function betriebAufraeumen(){
try {
const grenze = Date.now() - loginBlockMs();
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)');
} catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); }
}