Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d65ff813f | ||
|
|
51dfbe8cd8 | ||
|
|
e7243f6f26 | ||
|
|
4c2e04e78b |
No files matched your search
@@ -1422,8 +1422,9 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
|
|||||||
`renderPlanKommentarVerlauf()` in `plan-info.js`): je Gewerk nur, wenn
|
`renderPlanKommentarVerlauf()` in `plan-info.js`): je Gewerk nur, wenn
|
||||||
sich der Text gegenüber der Sitzung davor geändert hat; gleicher Text in
|
sich der Text gegenüber der Sitzung davor geändert hat; gleicher Text in
|
||||||
mehreren Gewerken einer Sitzung (Altbestand aus der Umstellung auf
|
mehreren Gewerken einer Sitzung (Altbestand aus der Umstellung auf
|
||||||
Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Drei sichtbar, Rest
|
Kommentare je Gewerk) wird EIN Eintrag „HLS, ARCH“. Gezeigt wird, was
|
||||||
über „Alle N anzeigen“. Keine neue Datenhaltung — jede Sitzung hält ihre
|
geladen ist, darunter „Weitere laden“ (siehe „Sitzungen nach Bedarf
|
||||||
|
laden“). Keine neue Datenhaltung — jede Sitzung hält ihre
|
||||||
Kopie des Projekts. Stile: Block „Kommentar der Sitzung mit Verlauf“ am
|
Kopie des Projekts. Stile: Block „Kommentar der Sitzung mit Verlauf“ am
|
||||||
Ende von `plan-styles.css`.
|
Ende von `plan-styles.css`.
|
||||||
**Eigene Kachel „Protokolle“** (Eriks Wunsch; links unter „Termine und
|
**Eigene Kachel „Protokolle“** (Eriks Wunsch; links unter „Termine und
|
||||||
@@ -1520,6 +1521,25 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
|
|||||||
system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this
|
system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this
|
||||||
view now also follows the app-wide dark-mode palette like everywhere
|
view now also follows the app-wide dark-mode palette like everywhere
|
||||||
else.
|
else.
|
||||||
|
- **Aufwand im Aufgaben-Panel** (Eriks Wunsch „schöner darstellen“):
|
||||||
|
Kapsel mit −/+ (±1 h) und Einheit „Std.“, Schnellwahl 2 h / ½ Tag /
|
||||||
|
1 Tag / 2 Tage / 1 Woche (Tage über `APP_SETTINGS.arbeitsstundenProTag`;
|
||||||
|
erneuter Klick leert), darunter die Umrechnung in Arbeitstage
|
||||||
|
(`todoAufwandAendern()`/`todoAufwandSetzen()`/`todoAufwandInfo()`,
|
||||||
|
`plan-modals.js`). Feld-id `todoEditAufwand` und Speichern unverändert.
|
||||||
|
Das Eingabefeld ist per `!important` durchsichtig, weil die Glas-Regeln
|
||||||
|
für Eingabefelder ihm sonst eine eigene Fläche geben.
|
||||||
|
- **„Externe Personen“ im Aufgaben-Panel** (Eriks Wunsch): unter „Wer“
|
||||||
|
stehen oben die Projektbeteiligten als Chips, darunter der Abschnitt
|
||||||
|
„Externe Personen“ — Kollegen mit Konto, die dem Projekt NICHT
|
||||||
|
zugeordnet sind, über eine Auswahl nach Gewerk (`todoExterneSetzen()`,
|
||||||
|
`todoExternHinzufuegen()`/`todoExternEntfernen()` in `plan-modals.js`;
|
||||||
|
Quelle: Teilnehmerkreis der Sitzung ohne die Projektbeteiligten). Sie
|
||||||
|
landen ganz normal in `t.assignees` — damit zählen sie zu ihrer
|
||||||
|
Auslastung, erscheinen unter „Meine Aufgaben“, bekommen Glocke und
|
||||||
|
Teams-Nachricht. Wer einer Aufgabe zugewiesen ist, aber (nicht mehr) im
|
||||||
|
Projekt, erscheint beim Öffnen automatisch dort. Keine Datenänderung
|
||||||
|
am Projekt: die Person wird NICHT unter „Beteiligte“ eingetragen.
|
||||||
- **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“):
|
- **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“):
|
||||||
`#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am
|
`#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am
|
||||||
Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der
|
Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der
|
||||||
@@ -1532,6 +1552,37 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
|
|||||||
`plan-modals.js`) über dem bisherigen Feld `#milestoneEditPosition`,
|
`plan-modals.js`) über dem bisherigen Feld `#milestoneEditPosition`,
|
||||||
das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert.
|
das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert.
|
||||||
Stile: Block „Seitenpanel“ am Ende von `plan-styles.css`.
|
Stile: Block „Seitenpanel“ am Ende von `plan-styles.css`.
|
||||||
|
- **Notizen** (Reiter zwischen Terminplan und Aufgaben, Eriks Wahl „A –
|
||||||
|
Liste + Editor“ aus drei Entwürfen, `js/plan/plan-notizen.js`):
|
||||||
|
gemeinsame Notizen je Projekt. Links Liste (angeheftete oben, sonst
|
||||||
|
zuletzt geändert zuerst, Suche über Titel und Text), rechts Editor mit
|
||||||
|
Titel, Werkzeugleiste des Sitzungskommentars (`richtextToolbarHtml()`)
|
||||||
|
und Text; **automatisches Speichern** 1,2 s nach der letzten Änderung und
|
||||||
|
beim Verlassen des Feldes (MutationObserver, weil die Werkzeugleiste kein
|
||||||
|
input-Ereignis auslöst), Anzeige „Gespeichert ✓“. Anheften, Löschen
|
||||||
|
zweistufig im Knopf. **Mail** (Text oder `.eml`) per Drag & Drop auf den
|
||||||
|
Bereich oder Strg+V außerhalb der Felder → neue Notiz OHNE KI (Titel =
|
||||||
|
Betreff, Von/Datum oben, Zitate abgeschnitten).
|
||||||
|
Server: Tabelle `projekt_notizen` (an `project_uid`, nicht an der
|
||||||
|
Sitzung; in `IMPORT_TABLES` + `IMPORT_OPTIONAL_TABLES`), Routen `GET
|
||||||
|
/projekt-notizen?uid=`, `POST /projekt-notizen`, `PUT
|
||||||
|
/projekt-notizen/:id` (mit `basis` → 409 bei fremder Änderung; Client
|
||||||
|
bietet „Meine Fassung speichern“/„Neuen Stand laden“), `DELETE` (nur
|
||||||
|
Verfasser oder Administrator), `POST /projekt-notizen/aus-mail`. Lesen:
|
||||||
|
Grundzugang; Schreiben: `proj.plan`. Live-Ereignis `{typ:'notizen',
|
||||||
|
uid}` → `notizenLiveAenderung()`. Stile: Block „Notizen“ am Ende von
|
||||||
|
`plan-styles.css`.
|
||||||
|
**Gefunden dabei:** `leseEml()` zerstörte Umlaute in `.eml`-Dateien ohne
|
||||||
|
Transfer-Kodierung (7bit/8bit, der Normalfall) — der Text kam schon als
|
||||||
|
UTF-8-Zeichenkette vom Browser und wurde fälschlich als latin1-Bytes neu
|
||||||
|
dekodiert. Betraf auch „Termin aus Mail“; behoben.
|
||||||
|
- **Reiter „Leistungsphasen“ entfernt** (Eriks Wunsch, 30.10.2026): Nav-Knopf,
|
||||||
|
Abschnitt `#planSection-phasen`, `renderPlanPhasen()` und der CSS-Block
|
||||||
|
sind weg; ein alter Link auf `#phasen` landet auf dem Dashboard.
|
||||||
|
`js/plan/plan-phasen.js` enthält nur noch `aufgabenPhase()` (genutzt von
|
||||||
|
der Aufgabenliste und dem Terminplan-PDF). „+ Leistungsphase“ und
|
||||||
|
„Terminplan aus Honorar“ bleiben im Terminplan unter „+ Neu“. Der
|
||||||
|
folgende Absatz beschreibt den entfernten Stand.
|
||||||
- **Leistungsphasen** (eigener Reiter zwischen Terminplan und Aufgaben,
|
- **Leistungsphasen** (eigener Reiter zwischen Terminplan und Aufgaben,
|
||||||
Eriks Wahl „B“, `js/plan/plan-phasen.js`): oben ein Ablaufbalken aller
|
Eriks Wahl „B“, `js/plan/plan-phasen.js`): oben ein Ablaufbalken aller
|
||||||
Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter
|
Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter
|
||||||
@@ -1791,8 +1842,11 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
|
|||||||
Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze
|
Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze
|
||||||
`.eml`-Datei) oder Strg+V (außerhalb von Eingabefeldern) auf die Bereiche
|
`.eml`-Datei) oder Strg+V (außerhalb von Eingabefeldern) auf die Bereiche
|
||||||
**Terminplan** oder **Aufgaben** eines Projekts; eine KI macht daraus
|
**Terminplan** oder **Aufgaben** eines Projekts; eine KI macht daraus
|
||||||
Einträge. **Nur Aufgaben, keine Meilensteine** — Eriks Vorgabe: Der
|
Einträge — **Aufgabe oder Meilenstein**: Die KI schlägt die Art vor
|
||||||
Terminplan ist nur die grafische Darstellung der Aufgabenliste.
|
(`art` in `KI_MAIL_SCHEMA`), in der Auswahlliste `#mailAuswahlModal` (immer,
|
||||||
|
auch bei nur einem Vorschlag) steht je Zeile der Umschalter
|
||||||
|
„Aufgabe | Meilenstein“ (Eriks Wunsch). Danach öffnet sich je Eintrag das
|
||||||
|
passende Panel.
|
||||||
- Beim Hineinziehen erscheint die Fläche `#mailAblage` (fest, `pointer-events:
|
- Beim Hineinziehen erscheint die Fläche `#mailAblage` (fest, `pointer-events:
|
||||||
none`, Zähler für dragenter/-leave). Nicht in gesperrten Sitzungen. Kein
|
none`, Zähler für dragenter/-leave). Nicht in gesperrten Sitzungen. Kein
|
||||||
Konflikt mit dem Balken-Ziehen (das läuft über Maus-Ereignisse).
|
Konflikt mit dem Balken-Ziehen (das läuft über Maus-Ereignisse).
|
||||||
@@ -1805,15 +1859,25 @@ Terminplan ist nur die grafische Darstellung der Aufgabenliste.
|
|||||||
„nächsten Freitag“ stimmt. Ohne KI/bei Fehler: ein Eintrag mit dem Betreff
|
„nächsten Freitag“ stimmt. Ohne KI/bei Fehler: ein Eintrag mit dem Betreff
|
||||||
als Titel. **Speichert nichts.** `.msg` (Outlook) wird abgewiesen mit dem
|
als Titel. **Speichert nichts.** `.msg` (Outlook) wird abgewiesen mit dem
|
||||||
Hinweis, Text zu markieren.
|
Hinweis, Text zu markieren.
|
||||||
|
- **Nur mit eingerichteter KI aktiv** (Eriks Wunsch): `mailKiStatusLaden()`
|
||||||
|
holt nach der Anmeldung (`afterAuthenticated()`) und nach jeder Änderung
|
||||||
|
der KI-Zugangsdaten (`putMyAiKey()`) die Anbieter mit Schlüssel; ohne
|
||||||
|
einen gibt es weder Ablagefläche noch abgefangenes Strg+V.
|
||||||
- Anbieter: zuletzt erfolgreich genutzter (`localStorage
|
- Anbieter: zuletzt erfolgreich genutzter (`localStorage
|
||||||
projuebersicht-mail-ki`), sonst der erste mit Schlüssel.
|
projuebersicht-mail-ki`), sonst der erste mit Schlüssel.
|
||||||
- Jeder Vorschlag öffnet das normale Aufgaben-Panel vorausgefüllt
|
- Umschalter „Aufgabe | Meilenstein“: eigene Stile `.segmented.mail-auswahl-art`
|
||||||
(`openTodoModal(null, prefill)`, neu: `title`, `aufwand`, `notiz`,
|
und Klasse `mail-art-knopf` (sonst greift unter Glas
|
||||||
`mailHinweis`, `nachSchliessen`); bei mehreren erst eine Auswahl
|
`html.glas button:not([class])` mit Rand und Glanzkante, und der gewählte
|
||||||
(`#mailAuswahlModal`), dann nacheinander. Ohne Speichern entsteht nichts.
|
Knopf war von `var(--surface)` kaum zu unterscheiden).
|
||||||
- **Notiz je Aufgabe** (`t.notiz`, `plan_todos.notiz`, Migration
|
- Jeder gewählte Vorschlag öffnet nacheinander das normale Panel
|
||||||
`migrateAddPlanTodoNotiz()`, in `IMPORT_OPTIONAL_COLS`): Feld „Notiz“ im
|
vorausgefüllt: `openTodoModal(null, prefill)` (`title`, `start`/`end`,
|
||||||
Aufgaben-Panel für jede Aufgabe; aus einer Mail mit Absender, Datum,
|
`assignees`, `aufwand`, `notiz`, `mailHinweis`, `nachSchliessen`) bzw.
|
||||||
|
`openMilestoneModal(null, prefill)` (`name`, `date` = Ende des Vorschlags,
|
||||||
|
`notiz`, `mailHinweis`, `nachSchliessen`). Ohne Speichern entsteht nichts.
|
||||||
|
- **Notiz je Aufgabe und Meilenstein** (`t.notiz`/`m.notiz`,
|
||||||
|
`plan_todos.notiz`/`plan_milestones.notiz`, Migration
|
||||||
|
`migrateAddPlanTodoNotiz()`, in `IMPORT_OPTIONAL_COLS`): Feld „Notiz“ in
|
||||||
|
beiden Panels; aus einer Mail mit Absender, Datum,
|
||||||
Betreff, Bezugssatz und Mailtext. In der Aufgabenliste ein Symbol mit
|
Betreff, Bezugssatz und Mailtext. In der Aufgabenliste ein Symbol mit
|
||||||
Tooltip. Stile: Block „Termin aus Mail“ am Ende von `plan-styles.css`.
|
Tooltip. Stile: Block „Termin aus Mail“ am Ende von `plan-styles.css`.
|
||||||
|
|
||||||
@@ -3458,8 +3522,8 @@ nur, wenn sich der Kommentar DES GEWÄHLTEN GEWERKS gegenüber der Sitzung davor
|
|||||||
geändert hat; Sitzungen dazwischen als grauer Vermerk („KW 40 bis KW 43 ohne
|
geändert hat; Sitzungen dazwischen als grauer Vermerk („KW 40 bis KW 43 ohne
|
||||||
neuen Kommentar“); geänderte Leistungsphasen seit dem vorigen Eintrag als
|
neuen Kommentar“); geänderte Leistungsphasen seit dem vorigen Eintrag als
|
||||||
Chips („LPH 5: 60 → 75 %“); Verfasser und Datum; „übernehmen“
|
Chips („LPH 5: 60 → 75 %“); Verfasser und Datum; „übernehmen“
|
||||||
(`berichtVerlaufUebernehmen()` → `takeCommentFromHistory()`); drei
|
(`berichtVerlaufUebernehmen()` → `takeCommentFromHistory()`); gezeigt wird,
|
||||||
sichtbar, Rest über „Alle N anzeigen“ (`berichtVerlaufUmschalten()` zeichnet
|
was geladen ist, „Weitere laden“ holt ältere (`berichtVerlaufNeu()` zeichnet
|
||||||
nur den Abschnitt neu, ein angefangener Kommentar bleibt). Die dichte Karte
|
nur den Abschnitt neu, ein angefangener Kommentar bleibt). Die dichte Karte
|
||||||
bekommt dafür `opts.ohneVerlauf` (`renderBoardDrawer()`), damit der
|
bekommt dafür `opts.ohneVerlauf` (`renderBoardDrawer()`), damit der
|
||||||
Kachel-Streifen dort nicht zusätzlich erscheint. Projektliste und
|
Kachel-Streifen dort nicht zusätzlich erscheint. Projektliste und
|
||||||
@@ -4168,6 +4232,103 @@ across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr."
|
|||||||
no special-casing needed. A redirect `GET /Test.html`-style old link isn't
|
no special-casing needed. A redirect `GET /Test.html`-style old link isn't
|
||||||
needed here since nothing ever pointed at this tab before it existed.
|
needed here since nothing ever pointed at this tab before it existed.
|
||||||
|
|
||||||
|
## Kalender-Abo für Outlook (iCalendar)
|
||||||
|
|
||||||
|
Eriks Wunsch: die eigenen Termine in Outlook sehen, nur zum Lesen.
|
||||||
|
Einstellungen → Mein Konto → Karte „Kalender in Outlook“
|
||||||
|
(`renderSettingsKalender()`, `js/settings/settings.js`): Adresse erzeugen,
|
||||||
|
kopieren, „In Outlook öffnen“ (`webcal:`-Link), Anleitung, „Neue Adresse“
|
||||||
|
(alte wird ungültig), „Abschalten“.
|
||||||
|
- **Server:** `users.kalender_token` (Migration in `migrateUsersDepartment()`,
|
||||||
|
leer = aus). `GET`/`POST /mein-kalender` (requireAuth, nur eigenes Konto;
|
||||||
|
POST `{aus:true}` schaltet ab, sonst neue Adresse). **`GET
|
||||||
|
/kalender/<token>.ics` ist ohne Sitzung erreichbar** — der Token (24 Byte
|
||||||
|
zufällig) IST die Anmeldung, weil Outlook sich nicht einloggen kann.
|
||||||
|
Unbekannt, deaktiviertes Konto oder Rolle ohne „Anwendung nutzen“ → 404.
|
||||||
|
- **Inhalt** (`kalenderEreignisse()`, rein und testbar): aus der jüngsten
|
||||||
|
echten Sitzung die eigenen offenen Aufgaben mit Termin (ohne erledigte und
|
||||||
|
ohne Phasenbalken), Meilensteine der Projekte, denen man zugeordnet ist
|
||||||
|
oder in denen man eine eigene Aufgabe hat, dazu die eigenen
|
||||||
|
Abwesenheiten. Alles ganztägig (`VALUE=DATE`, DTEND = Tag danach), als
|
||||||
|
„frei“ (Abwesenheit „abwesend“). Notizen gehen in die Beschreibung.
|
||||||
|
`kalenderText()` maskiert nach RFC 5545 und faltet Zeilen auf 75 Byte.
|
||||||
|
- **Adresse** (`kalenderUrl()`): der Host, über den der Browser die App
|
||||||
|
aufruft — wer über `localhost` öffnet, bekommt eine `localhost`-Adresse
|
||||||
|
(eine automatische Ersetzung durch die Geräte-IP hat Erik verworfen, weil
|
||||||
|
sie im Docker-Container nicht verlässlich ist).
|
||||||
|
- **Grenze:** Neues Outlook / Outlook im Browser holen das Abo über
|
||||||
|
Microsoft-Server aus dem Internet ab — dafür muss der Server von außen
|
||||||
|
erreichbar sein. Das klassische Outlook am PC holt es im Firmennetz direkt.
|
||||||
|
|
||||||
|
## Teams: persönliche Nachricht bei neuen Aufgaben
|
||||||
|
|
||||||
|
Eriks Wunsch („nur persönlich“): Wird jemandem eine Aufgabe zugewiesen,
|
||||||
|
bekommt GENAU DIESE Person in Teams eine Nachricht im Chat mit dem Flow-Bot,
|
||||||
|
mit Link direkt auf die Aufgabe. Kein Kanal.
|
||||||
|
- **Einrichten in Teams** (Anleitung aufklappbar unter **Admin → Teams**):
|
||||||
|
leerer Workflow, Auslöser „Bei Empfang einer Teams-Webhookanforderung“
|
||||||
|
(Jeder), Aktion „Karte in einem Chat oder Kanal veröffentlichen“ als
|
||||||
|
Flow-Bot im Chat mit Flow-Bot, Empfänger `triggerBody()?['empfaenger']`,
|
||||||
|
Karte `string(triggerBody()?['karte'])`. Die Auslöser-Adresse trägt ein
|
||||||
|
Admin in der App ein (`loadAdminTeams()`, Recht **`a.teams`**). Der
|
||||||
|
Workflow läuft unter dem Konto, das ihn anlegt (besser Sammelkonto/IT).
|
||||||
|
- **Was die App schickt:** je neu zuständiger Person `{empfaenger, karte}` —
|
||||||
|
`empfaenger` = `users.email` (muss dem Teams-Anmeldenamen entsprechen),
|
||||||
|
`karte` = Adaptive Card (`teamsKarte()`: „Neue Aufgabe für Sie: …“,
|
||||||
|
Projekt, Fällig/Zeitraum, Aufwand, Zugewiesen von, bei mehreren auch
|
||||||
|
Zuständig, Knopf „Aufgabe öffnen“). Ohne Konto/E-Mail keine Nachricht
|
||||||
|
(nur ein Log-Eintrag). Testnachricht geht an die E-Mail des Admins.
|
||||||
|
- **Admin-Karte** (`loadAdminTeams()`): Statuszeile (eingerichtet + Host
|
||||||
|
bzw. „noch nicht eingerichtet“), breites mehrzeiliges Adressfeld
|
||||||
|
(`teamsFeldPruefen()`: Speichern erst bei https ohne Leerzeichen; bei
|
||||||
|
hinterlegter Adresse erst nach „Adresse ändern“ sichtbar), Schalter für
|
||||||
|
die Benachrichtigungen (gesperrt ohne Adresse), „Testnachricht an mich
|
||||||
|
senden“, „Anbindung entfernen“. Stile: Block „Admin → Teams“ am Ende von
|
||||||
|
`styles.css`.
|
||||||
|
- **Speicher:** Tabelle `integrationen` (`teams_webhook`,
|
||||||
|
`teams_neue_aufgaben`), bewusst NICHT `app_settings` (das darf jedes
|
||||||
|
Konto lesen) und nicht in `IMPORT_TABLES`; die Adresse geht nie an den
|
||||||
|
Browser zurück. Routen `GET`/`PUT /admin/teams`, `POST /admin/teams/test`.
|
||||||
|
- **Auslöser:** `neueZuweisungenMelden()` (aus `/save-week` und
|
||||||
|
`/save-projects`, mit `req`) sammelt je Aufgabe die NEU Zuständigen —
|
||||||
|
nicht wer sich selbst zuweist, keine Phasenbalken, nie die Sandbox —,
|
||||||
|
`teamsNeueAufgaben()` sendet im Hintergrund; Fehler nur im Log.
|
||||||
|
- **Link** (`aufgabenLink()`): `/Projektplan/<Name>?weekLabel=…&projNr=…
|
||||||
|
&aufgabe=<todoId>#aufgaben`, Host aus der speichernden Anfrage (nur im
|
||||||
|
Büronetz/VPN erreichbar). `plan-state.js` (`startAufgabe`) öffnet die
|
||||||
|
jüngste Sitzung, falls der Link eine ältere nennt, und dort das
|
||||||
|
Aufgaben-Panel; die Adresszeile wird nachgezogen.
|
||||||
|
- Braucht Internetzugang nach außen.
|
||||||
|
|
||||||
|
## Sitzungen nach Bedarf laden (`js/store.js`)
|
||||||
|
|
||||||
|
Eriks Wunsch: `/load-all` wuchs jede Woche (alle Sitzungen, ~17 KB je
|
||||||
|
Woche). Jetzt lädt der Browser beim Start nur die jüngsten
|
||||||
|
`SITZUNGEN_START` (4: aktuelle + drei davor) über `GET /load-all?wochen=4`;
|
||||||
|
dieselbe Antwort trägt `wochenListe` = ALLE echten Sitzungen schlank
|
||||||
|
(`{id, label, date, locked, updatedAt}`, `echteSitzungenSortiert()`), im
|
||||||
|
Browser `db.wochenListe`. **`db.weeks` hält damit nur die GELADENEN
|
||||||
|
Sitzungen** — wer über `db.weeks` iteriert, sieht nicht alle.
|
||||||
|
- **Verlauf** (Wochenbericht „Verlauf der Vorwochen“, Kommentar-Verlauf im
|
||||||
|
Projekt-Dashboard, Verlauf-Kacheln der Projektliste): zeigt, was geladen
|
||||||
|
ist; darunter `weitereLadenKnopf()` → `weitereSitzungenLaden()` →
|
||||||
|
`GET /weeks-aelter?vor=<älteste geladene>&anzahl=5` holt je
|
||||||
|
`SITZUNGEN_NACHLADEN` (5) ältere. Die früheren Knöpfe „Alle N anzeigen“
|
||||||
|
sind entfallen. Der Wochenbericht zeichnet danach nur den Verlauf neu
|
||||||
|
(`berichtVerlaufNeu()`), damit ein angefangener Kommentar bleibt; der
|
||||||
|
Kachel-Verlauf merkt sich, ob er aufgeklappt war (`verlaufOffen`).
|
||||||
|
- **Nicht geladene Sitzungen bei Bedarf**: `sitzungSicherLaden(label)` (über
|
||||||
|
`GET /week`). Genutzt von Admin → Sitzungen (Liste aus `adminSitzungen()`
|
||||||
|
= alle; Entsperren/Öffnen/Löschen/Sperren nehmen jetzt das Label) und von
|
||||||
|
`openPlanView()` bei einem Link auf ein Projekt einer älteren Sitzung.
|
||||||
|
- Live: Ein Ereignis zu einer nicht geladenen Sitzung zieht nur deren Eintrag
|
||||||
|
in `db.wochenListe` nach, statt alles neu zu laden. `deleteWeekById()`
|
||||||
|
zählt für „mindestens eine Sitzung“ die `wochenListe`.
|
||||||
|
- `/load-all` ohne `wochen` liefert weiterhin alles (ältere Browser-Stände).
|
||||||
|
`loadWeeks()` nimmt dafür auch eine Liste von Labels (`IN (…)`).
|
||||||
|
- Gemessen: 98 KB statt 158 KB bei 9 Sitzungen; bleibt künftig etwa gleich,
|
||||||
|
statt mit jeder Woche zu wachsen.
|
||||||
|
|
||||||
## Gleichzeitiges Arbeiten (Zusammenführen statt Verwerfen, `js/store.js`)
|
## Gleichzeitiges Arbeiten (Zusammenführen statt Verwerfen, `js/store.js`)
|
||||||
|
|
||||||
Eine Sitzung wird weiterhin als Ganzes über `POST /save-week` mit
|
Eine Sitzung wird weiterhin als Ganzes über `POST /save-week` mit
|
||||||
@@ -4324,6 +4485,17 @@ Rechner.
|
|||||||
kein `backdrop-filter` — eigenes Design, Kiosk-Rechner) und
|
kein `backdrop-filter` — eigenes Design, Kiosk-Rechner) und
|
||||||
`prefers-reduced-transparency` (deckende Flächen).
|
`prefers-reduced-transparency` (deckende Flächen).
|
||||||
|
|
||||||
|
## Datums-Kalender mit KW (`js/date-picker.js`)
|
||||||
|
|
||||||
|
Eriks Wunsch: Jeder Kalender beim Anlegen/Bearbeiten zeigt die
|
||||||
|
Kalenderwoche. `renderRangePicker()` hat eine erste Spalte „KW“
|
||||||
|
(`getWeekNumber()`, `.range-picker-kw`, Popup 316px breit). Native
|
||||||
|
`<input type="date">` (Feiertage, Abwesenheiten in der Benutzerverwaltung,
|
||||||
|
„Aufgabe aus Kommentar“, Assistent, TODOs aus Protokoll) öffnen über einen
|
||||||
|
globalen Klick-Abfänger denselben Kalender statt des Browser-Kalenders; der
|
||||||
|
Wert wird ins Feld geschrieben und `input`/`change` ausgelöst. Ausnahme per
|
||||||
|
Attribut `data-nativ`.
|
||||||
|
|
||||||
## Dunkelmodus nachgeschärft (Kalender, Sitzungskommentar)
|
## Dunkelmodus nachgeschärft (Kalender, Sitzungskommentar)
|
||||||
|
|
||||||
Eriks Wahl „A“ aus einem Kontrast-Vergleich; Block „DUNKELMODUS
|
Eriks Wahl „A“ aus einem Kontrast-Vergleich; Block „DUNKELMODUS
|
||||||
@@ -4460,8 +4632,8 @@ Speicherüberlauf. Vier Ebenen, jede fängt ab, was die darüber verpasst:
|
|||||||
(`wlan-energiesparen-aus.service`), `wlan-waechter.timer` jede Minute —
|
(`wlan-energiesparen-aus.service`), `wlan-waechter.timer` jede Minute —
|
||||||
ohne Gateway ab 2 min neu verbinden, nach 10 min `systemctl reboot`.
|
ohne Gateway ab 2 min neu verbinden, nach 10 min `systemctl reboot`.
|
||||||
- **Prüfen:** `Docker/pi-pruefen.sh` (Speicher, Neustarts, OOM, Strom).
|
- **Prüfen:** `Docker/pi-pruefen.sh` (Speicher, Neustarts, OOM, Strom).
|
||||||
- Offen (Schritt 4 des Plans): `/load-all` liefert weiter ALLE Sitzungen —
|
- **Sitzungen nach Bedarf** (Schritt 4, umgesetzt 30.10.2026): siehe
|
||||||
wächst wöchentlich auf Server und Browser.
|
„Sitzungen nach Bedarf laden“ unten.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
+105
-37
@@ -38,8 +38,8 @@
|
|||||||
<link rel="icon" type="image/png" href="/logo.png">
|
<link rel="icon" type="image/png" href="/logo.png">
|
||||||
<link rel="alternate icon" href="/logo.ico">
|
<link rel="alternate icon" href="/logo.ico">
|
||||||
<link rel="apple-touch-icon" href="/logo.png">
|
<link rel="apple-touch-icon" href="/logo.png">
|
||||||
<link rel="stylesheet" href="/styles.css?v=106">
|
<link rel="stylesheet" href="/styles.css?v=111">
|
||||||
<link rel="stylesheet" href="/plan-styles.css?v=43">
|
<link rel="stylesheet" href="/plan-styles.css?v=51">
|
||||||
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
|
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
|
||||||
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
|
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
|
||||||
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
|
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
|
||||||
@@ -234,6 +234,7 @@
|
|||||||
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb & Sicherheit</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb & Sicherheit</button>
|
||||||
|
<button class="menu-item sidebar-subitem" data-admin-section="teams" onclick="showAdminSection('teams')">Teams</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank & Sitzungen</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank & Sitzungen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -455,6 +456,16 @@
|
|||||||
mit Fehlertext, der bis zum nächsten Testergebnis stehen bleibt
|
mit Fehlertext, der bis zum nächsten Testergebnis stehen bleibt
|
||||||
(Design-Entwürfe mit Erik abgestimmt: Artifacts d7ab2945,
|
(Design-Entwürfe mit Erik abgestimmt: Artifacts d7ab2945,
|
||||||
01fe1346). -->
|
01fe1346). -->
|
||||||
|
<!-- Kalender-Abo für Outlook (renderSettingsKalender(), js/settings/settings.js;
|
||||||
|
Server: /mein-kalender, /kalender/<token>.ics) -->
|
||||||
|
<section class="settings-card" id="settingsKalenderCard">
|
||||||
|
<h3 class="settings-card-title">Kalender in Outlook</h3>
|
||||||
|
<p class="hint">Ihre offenen Aufgaben, die Meilensteine Ihrer Projekte und Ihre
|
||||||
|
Abwesenheiten als Kalender in Outlook — nur zum Ansehen, geändert wird weiter hier.
|
||||||
|
Outlook aktualisiert ihn selbst (je nach Outlook stündlich bis alle paar Stunden).</p>
|
||||||
|
<div id="settingsKalender"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-card" id="settingsAiCard">
|
<section class="settings-card" id="settingsAiCard">
|
||||||
<h3 class="settings-card-title">KI-Zugangsdaten</h3>
|
<h3 class="settings-card-title">KI-Zugangsdaten</h3>
|
||||||
<p class="hint">Für die automatische Punkt-Erkennung beim Protokoll-Import
|
<p class="hint">Für die automatische Punkt-Erkennung beim Protokoll-Import
|
||||||
@@ -665,6 +676,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Teams-Anbindung (loadAdminTeams(), js/admin/admin.js; Server: /admin/teams*) -->
|
||||||
|
<section class="settings-card" id="adminTeamsCard" data-admin-section="teams" style="display:none">
|
||||||
|
<h3 class="settings-card-title">Teams <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
|
||||||
|
<p class="hint">Bekommt jemand eine Aufgabe zugewiesen, erhält genau diese Person in Teams
|
||||||
|
eine persönliche Nachricht (Chat mit dem Flow-Bot) mit Link direkt auf die Aufgabe.
|
||||||
|
Gefunden wird sie über die E-Mail-Adresse ihres Kontos — die muss ihrem Teams-Anmeldenamen
|
||||||
|
entsprechen. Wer kein Konto oder keine E-Mail hat, bekommt nichts.</p>
|
||||||
|
<div id="adminTeamsInhalt"></div>
|
||||||
|
<details class="kalender-anleitung">
|
||||||
|
<summary>So richten Sie den Workflow in Teams ein</summary>
|
||||||
|
<ol>
|
||||||
|
<li>In Teams die App <i>Workflows</i> öffnen (oder im Browser make.powerautomate.com) und
|
||||||
|
einen <b>leeren</b> Workflow anlegen.</li>
|
||||||
|
<li><b>Auslöser:</b> „Bei Empfang einer Teams-Webhookanforderung“, bei
|
||||||
|
<i>Wer kann den Flow auslösen</i> „Jeder“ wählen.</li>
|
||||||
|
<li><b>Aktion:</b> „Karte in einem Chat oder Kanal veröffentlichen“ —
|
||||||
|
<i>Posten als</i>: Flow-Bot, <i>Posten in</i>: Chat mit Flow-Bot,
|
||||||
|
<i>Empfänger</i>: als Ausdruck <code>triggerBody()?['empfaenger']</code>,
|
||||||
|
<i>Adaptive Karte</i>: als Ausdruck <code>string(triggerBody()?['karte'])</code>.</li>
|
||||||
|
<li>Speichern. Beim Auslöser steht jetzt eine lange Adresse (https://…) — kopieren,
|
||||||
|
hier einfügen und speichern.</li>
|
||||||
|
<li>„Testnachricht senden“ — die Nachricht kommt an Sie selbst.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="hint">Der Workflow läuft unter dem Konto, das ihn anlegt. Am besten legt ihn ein
|
||||||
|
Sammelkonto oder die IT an — sonst hört er auf, wenn dieses Konto gesperrt wird.
|
||||||
|
Der Server braucht Internetzugang nach außen; der Link in der Nachricht funktioniert
|
||||||
|
nur im Büronetz (bzw. per VPN). Die Bezeichnungen in Teams können je nach Version
|
||||||
|
leicht abweichen.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
<section class="settings-card" id="adminBetriebCard" data-admin-section="betrieb" style="display:none">
|
<section class="settings-card" id="adminBetriebCard" data-admin-section="betrieb" style="display:none">
|
||||||
<h3 class="settings-card-title">Betrieb & Sicherheit <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
|
<h3 class="settings-card-title">Betrieb & Sicherheit <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
|
||||||
<div class="settings-field-grid">
|
<div class="settings-field-grid">
|
||||||
@@ -851,7 +892,7 @@
|
|||||||
<nav class="plan-nav" id="planNav">
|
<nav class="plan-nav" id="planNav">
|
||||||
<button class="plan-nav-item" data-section="dashboard" onclick="showPlanSection('dashboard')">Dashboard</button>
|
<button class="plan-nav-item" data-section="dashboard" onclick="showPlanSection('dashboard')">Dashboard</button>
|
||||||
<button class="plan-nav-item" data-section="terminplan" onclick="showPlanSection('terminplan')">Terminplan</button>
|
<button class="plan-nav-item" data-section="terminplan" onclick="showPlanSection('terminplan')">Terminplan</button>
|
||||||
<button class="plan-nav-item" data-section="phasen" onclick="showPlanSection('phasen')">Leistungsphasen</button>
|
<button class="plan-nav-item" data-section="notizen" onclick="showPlanSection('notizen')">Notizen</button>
|
||||||
<button class="plan-nav-item" data-section="aufgaben" onclick="showPlanSection('aufgaben')">Aufgaben</button>
|
<button class="plan-nav-item" data-section="aufgaben" onclick="showPlanSection('aufgaben')">Aufgaben</button>
|
||||||
<button class="plan-nav-item" data-section="leistung" onclick="showPlanSection('leistung')">Leistung</button>
|
<button class="plan-nav-item" data-section="leistung" onclick="showPlanSection('leistung')">Leistung</button>
|
||||||
<button class="plan-nav-item" data-section="dateien" onclick="showPlanSection('dateien')">Dateien</button>
|
<button class="plan-nav-item" data-section="dateien" onclick="showPlanSection('dateien')">Dateien</button>
|
||||||
@@ -1000,17 +1041,12 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ====================== Bereich: Aufgaben ===================== -->
|
<!-- ====================== Bereich: Aufgaben ===================== -->
|
||||||
<!-- ================== Bereich: Leistungsphasen ================== -->
|
<!-- ======================= Bereich: Notizen ======================= -->
|
||||||
<!-- Eriks Wahl "B": die Phasenbalken des Terminplans als eigener
|
<!-- Eriks Wahl „A – Liste + Editor“: gemeinsame Notizen je Projekt
|
||||||
Bereich — Ablaufbalken über alle Phasen, darunter eine Kachel je
|
(renderPlanNotizen(), js/plan/plan-notizen.js; Server:
|
||||||
Phase (renderPlanPhasen(), js/plan/plan-phasen.js). In der
|
/projekt-notizen*). Ersetzt den früheren Reiter „Leistungsphasen“. -->
|
||||||
Aufgabenliste stehen sie dafür nicht mehr. -->
|
<section class="plan-section" id="planSection-notizen" data-section="notizen">
|
||||||
<section class="plan-section" id="planSection-phasen" data-section="phasen">
|
<div id="planNotizenWrap"></div>
|
||||||
<div class="plan-main-header">
|
|
||||||
<button onclick="addPlanPhase()" id="addPhaseBtnPhasen">+ Leistungsphase</button>
|
|
||||||
<button class="secondary" onclick="openHonorarModal()" id="honorarBtnPhasen" title="Terminplan aus Honorar und Leistungsphasen-Anteilen errechnen">Terminplan aus Honorar</button>
|
|
||||||
</div>
|
|
||||||
<div id="planPhasenWrap"></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="plan-section" id="planSection-aufgaben" data-section="aufgaben">
|
<section class="plan-section" id="planSection-aufgaben" data-section="aufgaben">
|
||||||
@@ -1522,6 +1558,11 @@
|
|||||||
<h3>Hängt ab von</h3>
|
<h3>Hängt ab von</h3>
|
||||||
<div id="milestoneEditDependsOn" class="todo-depends-list pd-liste"></div>
|
<div id="milestoneEditDependsOn" class="todo-depends-list pd-liste"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="pd-abs">
|
||||||
|
<h3>Notiz</h3>
|
||||||
|
<p class="hint pd-hint pd-mail-hinweis" id="milestoneEditMailHinweis" hidden></p>
|
||||||
|
<textarea id="milestoneEditNotiz" class="pd-notiz" rows="4" placeholder="Optional, z. B. Hintergrund oder die Mail, aus der der Meilenstein stammt" aria-label="Notiz"></textarea>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="pd-fuss">
|
<div class="pd-fuss">
|
||||||
<button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button>
|
<button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button>
|
||||||
@@ -1564,12 +1605,38 @@
|
|||||||
<section class="pd-abs" id="todoEditWerAbs">
|
<section class="pd-abs" id="todoEditWerAbs">
|
||||||
<h3>Wer</h3>
|
<h3>Wer</h3>
|
||||||
<div id="todoEditAssignees" class="person-chips"></div>
|
<div id="todoEditAssignees" class="person-chips"></div>
|
||||||
<label class="pd-aufwand" for="todoEditAufwand">
|
<!-- Externe Personen: Kollegen mit Konto, die dem Projekt NICHT
|
||||||
<span>Aufwand</span>
|
zugeordnet sind (Eriks Wunsch). todoExternHinzufuegen()/
|
||||||
<input type="number" id="todoEditAufwand" min="0" step="0.5" placeholder="0" inputmode="decimal">
|
todoExternEntfernen(), js/plan/plan-modals.js -->
|
||||||
<span>Stunden, optional</span>
|
<div class="pd-externe">
|
||||||
</label>
|
<div class="pd-externe-kopf">Externe Personen <span class="hint">nicht im Projekt</span></div>
|
||||||
<p class="hint pd-hint">Zählt zur Auslastung der Zuständigen, verteilt auf den Zeitraum der Aufgabe.</p>
|
<div id="todoEditExterne" class="person-chips"></div>
|
||||||
|
<select id="todoEditExterneWahl" aria-label="Externe Person hinzufügen" onchange="todoExternHinzufuegen(this.value)"></select>
|
||||||
|
</div>
|
||||||
|
<!-- Aufwand: −/+, Schnellwahl, Umrechnung in Arbeitstage
|
||||||
|
(todoAufwandAendern()/todoAufwandSetzen()/todoAufwandInfo(), plan-modals.js) -->
|
||||||
|
<div class="pd-aufwand">
|
||||||
|
<div class="pd-aufwand-kopf">
|
||||||
|
<label for="todoEditAufwand">Aufwand</label>
|
||||||
|
<span>optional · zählt zur Auslastung der Zuständigen</span>
|
||||||
|
</div>
|
||||||
|
<div class="pd-aufwand-zeile">
|
||||||
|
<div class="pd-stepper">
|
||||||
|
<button type="button" class="pd-stepper-knopf" onclick="todoAufwandAendern(-1)" aria-label="Eine Stunde weniger">−</button>
|
||||||
|
<input type="number" id="todoEditAufwand" min="0" step="0.5" placeholder="0" inputmode="decimal" oninput="todoAufwandInfo()">
|
||||||
|
<span class="pd-einheit">Std.</span>
|
||||||
|
<button type="button" class="pd-stepper-knopf" onclick="todoAufwandAendern(1)" aria-label="Eine Stunde mehr">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="pd-aufwand-schnell" id="todoAufwandSchnell" role="group" aria-label="Schnellwahl">
|
||||||
|
<button type="button" data-h="2" onclick="todoAufwandSetzen(2)">2 h</button>
|
||||||
|
<button type="button" data-h="4" onclick="todoAufwandSetzen(4)">½ Tag</button>
|
||||||
|
<button type="button" data-tage="1" onclick="todoAufwandSetzen('tag', 1)">1 Tag</button>
|
||||||
|
<button type="button" data-tage="2" onclick="todoAufwandSetzen('tag', 2)">2 Tage</button>
|
||||||
|
<button type="button" data-tage="5" onclick="todoAufwandSetzen('tag', 5)">1 Woche</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="pd-aufwand-info" id="todoAufwandInfoText"></span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="pd-abs">
|
<section class="pd-abs">
|
||||||
<h3>Stand</h3>
|
<h3>Stand</h3>
|
||||||
@@ -1912,21 +1979,21 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/globals.js?v=8"></script>
|
<script src="/js/globals.js?v=9"></script>
|
||||||
<script src="/js/utils.js?v=30"></script>
|
<script src="/js/utils.js?v=31"></script>
|
||||||
<script src="/js/icons.js?v=2"></script>
|
<script src="/js/icons.js?v=2"></script>
|
||||||
<script src="/js/ui.js?v=9"></script>
|
<script src="/js/ui.js?v=9"></script>
|
||||||
<script src="/js/date-picker.js?v=5"></script>
|
<script src="/js/date-picker.js?v=6"></script>
|
||||||
<script src="/js/server.js?v=7"></script>
|
<script src="/js/server.js?v=7"></script>
|
||||||
<script src="/js/auth.js?v=10"></script>
|
<script src="/js/auth.js?v=11"></script>
|
||||||
<script src="/js/store.js?v=14"></script>
|
<script src="/js/store.js?v=16"></script>
|
||||||
<script src="/js/weeks.js?v=11"></script>
|
<script src="/js/weeks.js?v=12"></script>
|
||||||
<script src="/js/board/board.js?v=41"></script>
|
<script src="/js/board/board.js?v=41"></script>
|
||||||
<script src="/js/wochenbericht/wochenbericht.js?v=28"></script>
|
<script src="/js/wochenbericht/wochenbericht.js?v=29"></script>
|
||||||
<script src="/js/people/people.js?v=8"></script>
|
<script src="/js/people/people.js?v=8"></script>
|
||||||
<script src="/js/planung/planung.js?v=3"></script>
|
<script src="/js/planung/planung.js?v=3"></script>
|
||||||
<script src="/js/planchecker/planchecker.js?v=1"></script>
|
<script src="/js/planchecker/planchecker.js?v=1"></script>
|
||||||
<script src="/js/abwesenheit/abwesenheit.js?v=9"></script>
|
<script src="/js/abwesenheit/abwesenheit.js?v=10"></script>
|
||||||
<script src="/js/projects/projects.js?v=10"></script>
|
<script src="/js/projects/projects.js?v=10"></script>
|
||||||
<script src="/js/load/load.js?v=23"></script>
|
<script src="/js/load/load.js?v=23"></script>
|
||||||
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
|
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
|
||||||
@@ -1944,8 +2011,8 @@
|
|||||||
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
|
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
|
||||||
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
|
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
|
||||||
<script src="/js/wand/wand.js?v=21"></script>
|
<script src="/js/wand/wand.js?v=21"></script>
|
||||||
<script src="/js/settings/settings.js?v=12"></script>
|
<script src="/js/settings/settings.js?v=14"></script>
|
||||||
<script src="/js/admin/admin.js?v=19"></script>
|
<script src="/js/admin/admin.js?v=23"></script>
|
||||||
<script src="/js/admin-users.js?v=10"></script>
|
<script src="/js/admin-users.js?v=10"></script>
|
||||||
<script src="/js/admin/rollen.js?v=1"></script>
|
<script src="/js/admin/rollen.js?v=1"></script>
|
||||||
<script src="/js/einfuehrung.js?v=2"></script>
|
<script src="/js/einfuehrung.js?v=2"></script>
|
||||||
@@ -1957,21 +2024,22 @@
|
|||||||
(das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach
|
(das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach
|
||||||
js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js
|
js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js
|
||||||
umhuellt. -->
|
umhuellt. -->
|
||||||
<script src="/js/plan/plan-state.js?v=21"></script>
|
<script src="/js/plan/plan-state.js?v=25"></script>
|
||||||
<script src="/js/plan/plan-sections.js?v=10"></script>
|
<script src="/js/plan/plan-sections.js?v=12"></script>
|
||||||
<script src="/js/plan/plan-info.js?v=14"></script>
|
<script src="/js/plan/plan-info.js?v=15"></script>
|
||||||
<script src="/js/plan/plan-history.js?v=4"></script>
|
<script src="/js/plan/plan-history.js?v=4"></script>
|
||||||
<script src="/js/plan/plan-build-gantt.js?v=11"></script>
|
<script src="/js/plan/plan-build-gantt.js?v=11"></script>
|
||||||
<script src="/js/plan/plan-gantt.js?v=21"></script>
|
<script src="/js/plan/plan-gantt.js?v=22"></script>
|
||||||
<script src="/js/plan/plan-drag.js?v=8"></script>
|
<script src="/js/plan/plan-drag.js?v=8"></script>
|
||||||
<script src="/js/plan/plan-modals.js?v=14"></script>
|
<script src="/js/plan/plan-modals.js?v=18"></script>
|
||||||
<script src="/js/plan/plan-tasks.js?v=4"></script>
|
<script src="/js/plan/plan-tasks.js?v=4"></script>
|
||||||
<script src="/js/plan/plan-phasen.js?v=2"></script>
|
<script src="/js/plan/plan-phasen.js?v=3"></script>
|
||||||
<script src="/js/plan/plan-files.js?v=4"></script>
|
<script src="/js/plan/plan-files.js?v=4"></script>
|
||||||
<script src="/js/plan/plan-protokoll.js?v=20"></script>
|
<script src="/js/plan/plan-protokoll.js?v=20"></script>
|
||||||
<script src="/js/plan/plan-templates.js?v=11"></script>
|
<script src="/js/plan/plan-templates.js?v=11"></script>
|
||||||
<script src="/js/plan/plan-protokoll-todos.js?v=2"></script>
|
<script src="/js/plan/plan-protokoll-todos.js?v=2"></script>
|
||||||
<script src="/js/plan/plan-mail-eintrag.js?v=1"></script>
|
<script src="/js/plan/plan-mail-eintrag.js?v=4"></script>
|
||||||
|
<script src="/js/plan/plan-notizen.js?v=1"></script>
|
||||||
<script src="/js/plan/plan-honorar.js?v=9"></script>
|
<script src="/js/plan/plan-honorar.js?v=9"></script>
|
||||||
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
|
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
|
||||||
invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen
|
invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen
|
||||||
|
|||||||
@@ -265,9 +265,9 @@ function abwTeamHtml(){
|
|||||||
<div class="section-title" style="margin:0">Team im ${esc(abwMonat.toLocaleDateString('de-DE', { month: 'long' }))}</div>
|
<div class="section-title" style="margin:0">Team im ${esc(abwMonat.toLocaleDateString('de-DE', { month: 'long' }))}</div>
|
||||||
<span class="abw-team-steuer">
|
<span class="abw-team-steuer">
|
||||||
<span class="abw-monat">
|
<span class="abw-monat">
|
||||||
<button type="button" class="abw-rund" onclick="abwMonatWechseln(-1)" aria-label="Voriger Monat">‹</button>
|
<button type="button" class="abw-rund" onclick="abwMonatWechseln(-1)" aria-label="Voriger Monat" title="Voriger Monat">${icon('chevronLeft')}</button>
|
||||||
<strong>${esc(monatName)}</strong>
|
<strong>${esc(monatName)}</strong>
|
||||||
<button type="button" class="abw-rund" onclick="abwMonatWechseln(1)" aria-label="Nächster Monat">›</button>
|
<button type="button" class="abw-rund" onclick="abwMonatWechseln(1)" aria-label="Nächster Monat" title="Nächster Monat">${icon('chevronRight')}</button>
|
||||||
<button type="button" class="secondary small" onclick="abwMonatWechseln(0)">Heute</button>
|
<button type="button" class="secondary small" onclick="abwMonatWechseln(0)">Heute</button>
|
||||||
</span>
|
</span>
|
||||||
<span class="abw-reiter" role="group" aria-label="Gewerk">${['Alle'].concat(gewerke).map(g => `<button type="button" class="${g === abwFilter ? 'ist-an' : ''}" onclick="abwFilterSetzen('${g}')">${g}</button>`).join('')}</span>
|
<span class="abw-reiter" role="group" aria-label="Gewerk">${['Alle'].concat(gewerke).map(g => `<button type="button" class="${g === abwFilter ? 'ist-an' : ''}" onclick="abwFilterSetzen('${g}')">${g}</button>`).join('')}</span>
|
||||||
|
|||||||
+138
-14
@@ -64,6 +64,7 @@ const ADMIN_SECTIONS = [
|
|||||||
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' },
|
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' },
|
||||||
{ key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' },
|
{ key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' },
|
||||||
{ key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' },
|
{ key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' },
|
||||||
|
{ key: 'teams', title: 'Teams', recht: 'a.teams' },
|
||||||
{ key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' },
|
{ key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' },
|
||||||
{ key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] },
|
{ key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] },
|
||||||
];
|
];
|
||||||
@@ -159,6 +160,7 @@ function showAdminSection(key){
|
|||||||
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
|
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
|
||||||
// frisch holen.
|
// frisch holen.
|
||||||
if(key === 'papierkorb') loadAdminPapierkorb();
|
if(key === 'papierkorb') loadAdminPapierkorb();
|
||||||
|
if(key === 'teams') loadAdminTeams();
|
||||||
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
|
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -287,6 +289,14 @@ function juengsteWoche(){
|
|||||||
return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop();
|
return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminSitzungen: ALLE Sitzungen für die Liste — geladene als volles Objekt
|
||||||
|
// (aktueller Sperrstand), nicht geladene aus db.wochenListe (schlank). Seit
|
||||||
|
// beim Start nur die jüngsten Sitzungen geladen werden (js/store.js).
|
||||||
|
function adminSitzungen(){
|
||||||
|
const liste = (db.wochenListe && db.wochenListe.length) ? db.wochenListe : db.weeks;
|
||||||
|
return liste.map(k => db.weeks.find(w => w.label === k.label) || k);
|
||||||
|
}
|
||||||
|
|
||||||
// renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks),
|
// renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks),
|
||||||
// neueste zuerst, mit Datum, Zustand und den passenden Aktionen.
|
// neueste zuerst, mit Datum, Zustand und den passenden Aktionen.
|
||||||
//
|
//
|
||||||
@@ -321,7 +331,7 @@ function renderAdminSitzungen(){
|
|||||||
if(!el || !Array.isArray(db.weeks)) return;
|
if(!el || !Array.isArray(db.weeks)) return;
|
||||||
|
|
||||||
const juengste = juengsteWoche();
|
const juengste = juengsteWoche();
|
||||||
const sortiert = [...db.weeks].sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
|
const sortiert = adminSitzungen().sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
|
||||||
el.innerHTML = sortiert.map(w => {
|
el.innerHTML = sortiert.map(w => {
|
||||||
const aktuell = w.id === db.currentWeek;
|
const aktuell = w.id === db.currentWeek;
|
||||||
const istJuengste = juengste && w.id === juengste.id;
|
const istJuengste = juengste && w.id === juengste.id;
|
||||||
@@ -337,20 +347,20 @@ function renderAdminSitzungen(){
|
|||||||
// jede Aktion und hätte keinen Weg zurück außer über eine andere,
|
// jede Aktion und hätte keinen Weg zurück außer über eine andere,
|
||||||
// zufällige applyAutoLock()-auslösende Handlung.
|
// zufällige applyAutoLock()-auslösende Handlung.
|
||||||
chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>';
|
chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>';
|
||||||
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`;
|
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.label)}')">Fertig – wieder sperren</button>`;
|
||||||
} else if(w.locked){
|
} else if(w.locked){
|
||||||
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
|
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
|
||||||
aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>` : '';
|
aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.label)}')">Entsperren</button>` : '';
|
||||||
} else {
|
} else {
|
||||||
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
|
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
|
||||||
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`;
|
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.label)}')">Öffnen</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<div class="template-admin-row">
|
return `<div class="template-admin-row">
|
||||||
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
|
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
|
||||||
${chip}
|
${chip}
|
||||||
${aktionen}
|
${aktionen}
|
||||||
${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>` : ''}
|
${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.label)}')" title="Sitzung löschen">×</button>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -363,15 +373,22 @@ function renderAdminSitzungen(){
|
|||||||
// nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert
|
// nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert
|
||||||
// hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund
|
// hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund
|
||||||
// zur aktuellen zu machen.
|
// zur aktuellen zu machen.
|
||||||
function adminUnlockWeek(weekId){
|
// Alle vier nehmen das Label: Die Sitzung ist evtl. nicht geladen und wird
|
||||||
unlockWeek(db.weeks.find(w => w.id === weekId), renderAdminSitzungen);
|
// dafür erst nachgeholt (sitzungSicherLaden(), js/store.js).
|
||||||
|
function adminMitSitzung(label, fn){
|
||||||
|
sitzungSicherLaden(label).then(w => {
|
||||||
|
if(!w){ alert('Die Sitzung ' + label + ' wurde am Server nicht gefunden.'); renderAdminSitzungen(); return; }
|
||||||
|
fn(w);
|
||||||
|
}).catch(() => alert('Die Sitzung ' + label + ' konnte nicht geladen werden.'));
|
||||||
}
|
}
|
||||||
function adminDeleteWeek(weekId){
|
function adminUnlockWeek(label){
|
||||||
deleteWeekById(weekId, renderAdminSitzungen);
|
adminMitSitzung(label, w => unlockWeek(w, () => { sitzungKurzAbgleichen(w); renderAdminSitzungen(); }));
|
||||||
}
|
}
|
||||||
function adminOpenWeek(weekId){
|
function adminDeleteWeek(label){
|
||||||
switchWeek(weekId);
|
adminMitSitzung(label, w => deleteWeekById(w.id, renderAdminSitzungen));
|
||||||
showTab('board');
|
}
|
||||||
|
function adminOpenWeek(label){
|
||||||
|
adminMitSitzung(label, w => { switchWeek(w.id); showTab('board'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene
|
// adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene
|
||||||
@@ -383,10 +400,11 @@ function adminOpenWeek(weekId){
|
|||||||
// markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT
|
// markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT
|
||||||
// die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in
|
// die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in
|
||||||
// js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe.
|
// js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe.
|
||||||
function adminRelockWeek(weekId){
|
function adminRelockWeek(label){
|
||||||
const w = db.weeks.find(x => x.id === weekId);
|
const w = db.weeks.find(x => x.label === label);
|
||||||
if(!w || w.locked) return;
|
if(!w || w.locked) return;
|
||||||
w.locked = true;
|
w.locked = true;
|
||||||
|
sitzungKurzAbgleichen(w);
|
||||||
markWeekDirty(w.label);
|
markWeekDirty(w.label);
|
||||||
save({ week: false });
|
save({ week: false });
|
||||||
const juengste = juengsteWoche();
|
const juengste = juengsteWoche();
|
||||||
@@ -1083,3 +1101,109 @@ async function adminProjektEndgueltigLoeschen(id){
|
|||||||
alert('Löschen fehlgeschlagen: ' + err.message);
|
alert('Löschen fehlgeschlagen: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------- Teams (Admin → Teams, Server: /admin/teams*) ----------
|
||||||
|
// Die Webhook-Adresse ist ein Geheimnis und kommt nie zurück in den Browser;
|
||||||
|
// angezeigt wird nur, ob eine eingetragen ist (und an welchen Host).
|
||||||
|
async function loadAdminTeams(){
|
||||||
|
const el = document.getElementById('adminTeamsInhalt');
|
||||||
|
if(!el) return;
|
||||||
|
let d;
|
||||||
|
try { d = await fetch(baseUrl() + '/admin/teams').then(r => r.json()); }
|
||||||
|
catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
|
||||||
|
if(!d || !d.ok){ el.innerHTML = '<span class="settings-warn">Keine Berechtigung.</span>'; return; }
|
||||||
|
// Ist schon eine Adresse hinterlegt, bleibt das Eingabefeld zu, bis man
|
||||||
|
// „Ändern“ wählt — die Adresse selbst kommt ohnehin nie zurück.
|
||||||
|
const feldOffen = !d.konfiguriert;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="teams-status${d.konfiguriert ? ' ist-an' : ''}">
|
||||||
|
<span class="teams-punkt" aria-hidden="true"></span>
|
||||||
|
<span class="teams-status-text">${d.konfiguriert
|
||||||
|
? '<b>Eingerichtet</b><span>Workflow bei ' + esc(d.host) + '</span>'
|
||||||
|
: '<b>Noch nicht eingerichtet</b><span>Adresse aus dem Teams-Workflow unten einfügen</span>'}</span>
|
||||||
|
${d.konfiguriert ? '<button type="button" class="teams-text-knopf" onclick="teamsFeldZeigen()">Adresse ändern</button>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="teams-feld" id="teamsFeld"${feldOffen ? '' : ' hidden'}>
|
||||||
|
<label for="teamsWebhook">${d.konfiguriert ? 'Neue Workflow-Adresse' : 'Workflow-Adresse'}</label>
|
||||||
|
<textarea id="teamsWebhook" rows="3" spellcheck="false" autocomplete="off"
|
||||||
|
placeholder="https://… (beim Auslöser des Teams-Workflows kopieren und hier einfügen)"
|
||||||
|
oninput="teamsFeldPruefen()"></textarea>
|
||||||
|
<div class="teams-feld-fuss">
|
||||||
|
<span class="teams-feld-hinweis" id="teamsFeldHinweis">Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.</span>
|
||||||
|
${d.konfiguriert ? '<button type="button" class="secondary small" onclick="loadAdminTeams()">Abbrechen</button>' : ''}
|
||||||
|
<button type="button" class="small" id="teamsSpeichernKnopf" disabled onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="teams-schalter">
|
||||||
|
<input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''} ${d.konfiguriert ? '' : 'disabled'}
|
||||||
|
onchange="teamsSpeichern({ neueAufgaben: this.checked })">
|
||||||
|
<span class="teams-schalter-bahn" aria-hidden="true"></span>
|
||||||
|
<span class="teams-schalter-text"><b>Zuständige über neue Aufgaben benachrichtigen</b>
|
||||||
|
<span>Persönliche Nachricht mit Link zur Aufgabe</span></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="teams-aktionen">
|
||||||
|
<button type="button" class="secondary small" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht an mich senden</button>
|
||||||
|
${d.konfiguriert ? '<button type="button" class="teams-text-knopf ist-gefahr" onclick="teamsEntfernen()">Anbindung entfernen</button>' : ''}
|
||||||
|
</div>
|
||||||
|
<p class="field-error" id="teamsFehler" hidden></p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// teamsFeldZeigen: blendet das Eingabefeld für eine neue Adresse ein.
|
||||||
|
function teamsFeldZeigen(){
|
||||||
|
const f = document.getElementById('teamsFeld');
|
||||||
|
if(f){ f.hidden = false; document.getElementById('teamsWebhook').focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// teamsFeldPruefen: Speichern erst bei einer https-Adresse; Hinweis passt sich an.
|
||||||
|
function teamsFeldPruefen(){
|
||||||
|
const wert = (document.getElementById('teamsWebhook').value || '').trim();
|
||||||
|
const ok = /^https:\/\/\S+$/.test(wert);
|
||||||
|
const knopf = document.getElementById('teamsSpeichernKnopf');
|
||||||
|
const hinweis = document.getElementById('teamsFeldHinweis');
|
||||||
|
if(knopf) knopf.disabled = !ok;
|
||||||
|
if(hinweis){
|
||||||
|
hinweis.classList.toggle('ist-fehler', !!wert && !ok);
|
||||||
|
hinweis.textContent = !wert ? 'Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.'
|
||||||
|
: ok ? 'Sieht gut aus — jetzt speichern und danach eine Testnachricht senden.'
|
||||||
|
: 'Die Adresse muss mit https:// beginnen und darf keine Leerzeichen enthalten.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamsSpeichern(patch){
|
||||||
|
if('webhook' in patch && !String(patch.webhook || '').trim()){ alert('Bitte die Adresse aus dem Teams-Workflow einfügen.'); return; }
|
||||||
|
const r = await fetch(baseUrl() + '/admin/teams', {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(patch)
|
||||||
|
});
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
if(!r.ok || !d.ok){ alert(d.error || 'Speichern fehlgeschlagen.'); return; }
|
||||||
|
// Nach dem ersten Eintragen der Adresse die Meldungen gleich einschalten.
|
||||||
|
if('webhook' in patch && !document.getElementById('teamsNeueAufgaben').checked){
|
||||||
|
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ neueAufgaben: true }) });
|
||||||
|
}
|
||||||
|
loadAdminTeams();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamsEntfernen(){
|
||||||
|
if(!confirm('Teams-Anbindung entfernen? Es werden keine Nachrichten mehr gesendet.')) return;
|
||||||
|
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ webhook: '', neueAufgaben: false }) });
|
||||||
|
loadAdminTeams();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamsTesten(){
|
||||||
|
const knopf = document.getElementById('teamsTestKnopf');
|
||||||
|
const fehler = document.getElementById('teamsFehler');
|
||||||
|
if(knopf){ knopf.disabled = true; knopf.textContent = 'Wird gesendet…'; }
|
||||||
|
try {
|
||||||
|
const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json());
|
||||||
|
if(fehler){ fehler.hidden = d.ok; fehler.textContent = d.ok ? '' : 'Nicht gesendet: ' + (d.error || 'unbekannter Fehler'); }
|
||||||
|
if(d.ok) alert('Testnachricht gesendet an ' + (d.an || 'Sie') + ' — bitte im Teams-Chat mit dem Flow-Bot nachsehen.');
|
||||||
|
} catch(e){
|
||||||
|
if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; }
|
||||||
|
} finally {
|
||||||
|
if(knopf){ knopf.disabled = false; knopf.textContent = 'Testnachricht senden'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,6 +155,8 @@ function afterAuthenticated(onAuthenticated){
|
|||||||
hideLoginOverlay();
|
hideLoginOverlay();
|
||||||
renderUserBar();
|
renderUserBar();
|
||||||
onAuthenticated();
|
onAuthenticated();
|
||||||
|
// "Termin aus Mail" (js/plan/plan-mail-eintrag.js) nur mit eingerichteter KI.
|
||||||
|
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
|
||||||
if(currentUser && currentUser.mustChangePassword) openPasswordModal(true);
|
if(currentUser && currentUser.mustChangePassword) openPasswordModal(true);
|
||||||
// Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind
|
// Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind
|
||||||
// und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst
|
// und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function openDatePicker(opts){
|
|||||||
document.body.appendChild(pop);
|
document.body.appendChild(pop);
|
||||||
|
|
||||||
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
|
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
|
||||||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px';
|
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 332)) + 'px';
|
||||||
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
||||||
|
|
||||||
renderRangePicker();
|
renderRangePicker();
|
||||||
@@ -80,7 +80,7 @@ function openRangePicker(opts){
|
|||||||
|
|
||||||
// Position: unterhalb des Ankers, innerhalb des Viewports halten
|
// Position: unterhalb des Ankers, innerhalb des Viewports halten
|
||||||
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
|
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
|
||||||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px';
|
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 332)) + 'px';
|
||||||
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
||||||
|
|
||||||
renderRangePicker();
|
renderRangePicker();
|
||||||
@@ -160,6 +160,14 @@ function applyRangePicker(){
|
|||||||
const selected = start || end;
|
const selected = start || end;
|
||||||
if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : '');
|
if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : '');
|
||||||
if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : '');
|
if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : '');
|
||||||
|
// Für umgeleitete native Datumsfelder (siehe unten): deren onchange/
|
||||||
|
// oninput-Handler sollen wie bei einer Eingabe von Hand laufen.
|
||||||
|
[startInput, endInput].forEach(el => {
|
||||||
|
if(el && el.type === 'date'){
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
if(opts.displayId){
|
if(opts.displayId){
|
||||||
const disp = document.getElementById(opts.displayId);
|
const disp = document.getElementById(opts.displayId);
|
||||||
if(disp){
|
if(disp){
|
||||||
@@ -199,10 +207,15 @@ function renderRangePicker(){
|
|||||||
</div>
|
</div>
|
||||||
<div class="range-picker-hint">${hint}</div>
|
<div class="range-picker-hint">${hint}</div>
|
||||||
<div class="range-picker-grid">`;
|
<div class="range-picker-grid">`;
|
||||||
|
// Erste Spalte: Kalenderwoche (Eriks Wunsch — im Büro wird in KW gedacht).
|
||||||
|
html += '<div class="range-picker-weekday range-picker-kw-kopf">KW</div>';
|
||||||
['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`);
|
['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`);
|
||||||
|
const kwZelle = d => `<div class="range-picker-kw">${getWeekNumber(d)}</div>`;
|
||||||
|
if(firstWeekday > 0) html += kwZelle(first);
|
||||||
for(let i = 0; i < firstWeekday; i++) html += '<div></div>';
|
for(let i = 0; i < firstWeekday; i++) html += '<div></div>';
|
||||||
for(let day = 1; day <= daysInMonth; day++){
|
for(let day = 1; day <= daysInMonth; day++){
|
||||||
const d = new Date(st.viewYear, st.viewMonth, day);
|
const d = new Date(st.viewYear, st.viewMonth, day);
|
||||||
|
if(d.getDay() === 1) html += kwZelle(d);
|
||||||
const iso = formatDateISO(d);
|
const iso = formatDateISO(d);
|
||||||
const classes = ['range-picker-day'];
|
const classes = ['range-picker-day'];
|
||||||
if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend');
|
if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend');
|
||||||
@@ -219,3 +232,14 @@ function renderRangePicker(){
|
|||||||
</div>`;
|
</div>`;
|
||||||
pop.innerHTML = html;
|
pop.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
// Native Datumsfelder (<input type="date">) öffnen statt des Browser-
|
||||||
|
// Kalenders (kennt keine KW) denselben Kalender wie der Rest der App. Das
|
||||||
|
// Feld selbst bleibt — Tippen funktioniert weiter, der Picker schreibt den
|
||||||
|
// ISO-Wert hinein und löst input/change aus. Ausnahme: data-nativ.
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const input = e.target && e.target.closest ? e.target.closest('input[type="date"]') : null;
|
||||||
|
if(!input || input.disabled || input.readOnly || input.hasAttribute('data-nativ')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if(!input.id) input.id = 'datum_' + Math.random().toString(36).slice(2, 9);
|
||||||
|
openDatePicker({ anchor: input, inputId: input.id });
|
||||||
|
}, true);
|
||||||
@@ -108,6 +108,9 @@ let saveSyncTimer = null;
|
|||||||
// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung.
|
// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung.
|
||||||
// globalPeople ist ein flaches Array (keine Abteilungstrennung mehr).
|
// globalPeople ist ein flaches Array (keine Abteilungstrennung mehr).
|
||||||
let db = { weeks: [], currentWeek: null, globalPeople: [],
|
let db = { weeks: [], currentWeek: null, globalPeople: [],
|
||||||
|
// wochenListe: ALLE Sitzungen schlank (id, label, date, locked, updatedAt) —
|
||||||
|
// db.weeks hält nur die geladenen (siehe SITZUNGEN_START, js/store.js).
|
||||||
|
wochenListe: [],
|
||||||
// sandboxWeek: Platzhalter bis zum ersten erfolgreichen load() (js/store.js)
|
// sandboxWeek: Platzhalter bis zum ersten erfolgreichen load() (js/store.js)
|
||||||
// befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht
|
// befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht
|
||||||
// crasht, sondern nur eine leere Planung-Liste zeigt.
|
// crasht, sondern nur eine leere Planung-Liste zeigt.
|
||||||
|
|||||||
@@ -68,7 +68,6 @@
|
|||||||
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
|
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
|
||||||
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
|
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
|
||||||
if(typeof renderPlanTasks === 'function') renderPlanTasks();
|
if(typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||||
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
|
|
||||||
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
|
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -132,18 +132,17 @@
|
|||||||
return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2];
|
return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLAN_VERLAUF_KURZ = 3;
|
|
||||||
let planVerlaufAlle = false;
|
|
||||||
let planVerlaufProjekt = null;
|
|
||||||
|
|
||||||
function renderPlanKommentarVerlauf(verlauf){
|
function renderPlanKommentarVerlauf(verlauf){
|
||||||
const el = document.getElementById('planStatusVerlauf');
|
const el = document.getElementById('planStatusVerlauf');
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
const uid = app.project && (app.project.projectUid || app.project.id);
|
// Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
|
||||||
if(uid !== planVerlaufProjekt){ planVerlaufProjekt = uid; planVerlaufAlle = false; }
|
// aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
|
||||||
if(!verlauf.length){ el.hidden = true; el.innerHTML = ''; return; }
|
// js/store.js). Die Sandbox hat keine früheren Fassungen.
|
||||||
|
const weitere = app.isSandboxPlan() ? '' : weitereLadenKnopf();
|
||||||
|
if(!verlauf.length && !weitere){ el.hidden = true; el.innerHTML = ''; return; }
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
const zeigen = planVerlaufAlle ? verlauf : verlauf.slice(0, PLAN_VERLAUF_KURZ);
|
const zeigen = verlauf;
|
||||||
const zeilen = zeigen.map((e, i) => `
|
const zeilen = zeigen.map((e, i) => `
|
||||||
<li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}">
|
<li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}">
|
||||||
<span class="plan-verlauf-kw">${esc(kurzKw(e.week.label))}</span>
|
<span class="plan-verlauf-kw">${esc(kurzKw(e.week.label))}</span>
|
||||||
@@ -153,21 +152,15 @@
|
|||||||
<div class="plan-verlauf-meta">${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}</div>
|
<div class="plan-verlauf-meta">${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</li>`).join('');
|
</li>`).join('');
|
||||||
const mehr = verlauf.length > PLAN_VERLAUF_KURZ
|
const mehr = weitere ? `<div class="plan-verlauf-mehr-zeile">${weitere}</div>` : '';
|
||||||
? `<button type="button" class="plan-verlauf-mehr" onclick="planVerlaufUmschalten()">${planVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + verlauf.length + ' anzeigen'}</button>`
|
|
||||||
: '';
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="plan-verlauf-kopf">
|
<div class="plan-verlauf-kopf">
|
||||||
<span class="plan-verlauf-titel">Verlauf</span>
|
<span class="plan-verlauf-titel">Verlauf</span>
|
||||||
<span class="plan-verlauf-anzahl">${verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span>
|
<span class="plan-verlauf-anzahl">${verlauf.length === 0 ? 'in den geladenen Sitzungen kein früherer Kommentar' : verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span>
|
||||||
</div>
|
</div>
|
||||||
<ol class="plan-verlauf-liste">${zeilen}</ol>${mehr}`;
|
${zeilen ? `<ol class="plan-verlauf-liste">${zeilen}</ol>` : ''}${mehr}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.planVerlaufUmschalten = function(){
|
|
||||||
planVerlaufAlle = !planVerlaufAlle;
|
|
||||||
window.renderPlanInfo();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
window.renderPlanInfo = function(){
|
window.renderPlanInfo = function(){
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
// plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per
|
// plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per
|
||||||
// Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf
|
// Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf
|
||||||
// den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus
|
// den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus
|
||||||
// Aufgaben. Der Terminplan ist nur die grafische Darstellung der
|
// Einträge.
|
||||||
// Aufgabenliste — deshalb entstehen hier ausschließlich Aufgaben
|
|
||||||
// (plan.todos), keine Meilensteine.
|
|
||||||
//
|
//
|
||||||
// Nichts wird ohne Prüfung angelegt: Jeder Vorschlag öffnet das gewohnte
|
// Nichts wird ohne Prüfung angelegt: Die Vorschläge stehen erst in einer
|
||||||
// Aufgaben-Panel vorausgefüllt (openTodoModal(null, prefill)), die Mail
|
// Liste — je Zeile abhaken und „Aufgabe | Meilenstein“ wählen (Eriks
|
||||||
// steht als Notiz darin (t.notiz). Mehrere Vorschläge erst als Liste zum
|
// Wunsch; die KI schlägt die Art vor) —, dann öffnet sich je Eintrag das
|
||||||
// Abhaken, dann nacheinander im Panel. Server: POST /plan-eintrag-aus-mail
|
// gewohnte Panel vorausgefüllt (openTodoModal/openMilestoneModal mit
|
||||||
// (speichert nichts).
|
// prefill), die Mail steht als Notiz darin (t.notiz bzw. m.notiz).
|
||||||
|
// Server: POST /plan-eintrag-aus-mail (speichert nichts).
|
||||||
(function(){
|
(function(){
|
||||||
const app = window.PlanApp;
|
const app = window.PlanApp;
|
||||||
const ANBIETER_KEY = 'projuebersicht-mail-ki';
|
const ANBIETER_KEY = 'projuebersicht-mail-ki';
|
||||||
const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben'];
|
const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben'];
|
||||||
let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit)
|
let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit)
|
||||||
let laeuft = false;
|
let laeuft = false;
|
||||||
|
// kiAnbieter: die Anbieter mit hinterlegtem Schlüssel. Ohne eingerichtete
|
||||||
|
// KI ist die Funktion ganz aus (Eriks Wunsch) — keine Ablagefläche, kein
|
||||||
|
// Abfangen von Strg+V. Geladen nach der Anmeldung und nach jeder Änderung
|
||||||
|
// unter Einstellungen → KI-Zugangsdaten (mailKiStatusLaden()).
|
||||||
|
let kiAnbieter = [];
|
||||||
|
|
||||||
|
window.mailKiStatusLaden = function(){
|
||||||
|
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
|
||||||
|
kiAnbieter = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
|
||||||
|
}).catch(() => { kiAnbieter = []; });
|
||||||
|
};
|
||||||
|
|
||||||
function aktiverAbschnitt(){
|
function aktiverAbschnitt(){
|
||||||
if(typeof currentTab === 'undefined' || currentTab !== 'plan') return '';
|
if(typeof currentTab === 'undefined' || currentTab !== 'plan') return '';
|
||||||
@@ -24,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ablageErlaubt(){
|
function ablageErlaubt(){
|
||||||
return ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked();
|
return kiAnbieter.length > 0 && ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bringtMail(dt){
|
function bringtMail(dt){
|
||||||
@@ -44,7 +54,7 @@
|
|||||||
el.hidden = true;
|
el.hidden = true;
|
||||||
el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')}
|
el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')}
|
||||||
<b id="mailAblageTitel">Mail hier ablegen</b>
|
<b id="mailAblageTitel">Mail hier ablegen</b>
|
||||||
<span id="mailAblageText">Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.</span></div>`;
|
<span id="mailAblageText"></span></div>`;
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
@@ -139,12 +149,9 @@
|
|||||||
// ---------- Auswerten ----------
|
// ---------- Auswerten ----------
|
||||||
|
|
||||||
function anbieterWaehlen(){
|
function anbieterWaehlen(){
|
||||||
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
|
|
||||||
const bereit = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
|
|
||||||
let zuletzt = '';
|
let zuletzt = '';
|
||||||
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
|
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
|
||||||
return bereit.includes(zuletzt) ? zuletzt : (bereit[0] || '');
|
return Promise.resolve(kiAnbieter.includes(zuletzt) ? zuletzt : (kiAnbieter[0] || ''));
|
||||||
}).catch(() => '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function projektPersonen(){
|
function projektPersonen(){
|
||||||
@@ -229,7 +236,8 @@
|
|||||||
app.showToast('In der Mail steht nichts für den Terminplan.');
|
app.showToast('In der Mail steht nichts für den Terminplan.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(eintraege.length === 1){ nacheinanderOeffnen(d, eintraege); return; }
|
// Auch bei nur einem Vorschlag erst die Liste — dort wird gewählt, ob es
|
||||||
|
// eine Aufgabe oder ein Meilenstein wird.
|
||||||
auswahlZeigen(d, eintraege);
|
auswahlZeigen(d, eintraege);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +248,16 @@
|
|||||||
const e = rest.shift();
|
const e = rest.shift();
|
||||||
if(!e){ renderPlanGantt(); return; }
|
if(!e){ renderPlanGantt(); return; }
|
||||||
const nr = gesamt - rest.length;
|
const nr = gesamt - rest.length;
|
||||||
|
if(e.art === 'meilenstein'){
|
||||||
|
openMilestoneModal(null, {
|
||||||
|
name: e.titel,
|
||||||
|
date: e.ende || e.start,
|
||||||
|
notiz: notizText(d.mail || {}, e),
|
||||||
|
mailHinweis: (gesamt > 1 ? `Vorschlag ${nr} von ${gesamt}. ` : '') + hinweisText(d),
|
||||||
|
nachSchliessen: naechster
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
openTodoModal(null, {
|
openTodoModal(null, {
|
||||||
title: e.titel,
|
title: e.titel,
|
||||||
start: e.start,
|
start: e.start,
|
||||||
@@ -271,20 +289,35 @@
|
|||||||
const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende));
|
const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende));
|
||||||
return a === b ? a : a + ' – ' + b;
|
return a === b ? a : a + ' – ' + b;
|
||||||
};
|
};
|
||||||
|
const arten = eintraege.map(e => e.art === 'meilenstein' ? 'meilenstein' : 'aufgabe');
|
||||||
bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail">
|
bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail">
|
||||||
<h2>${eintraege.length} Einträge in der Mail</h2>
|
<h2>${eintraege.length === 1 ? '1 Eintrag' : eintraege.length + ' Einträge'} in der Mail</h2>
|
||||||
<p class="hint">${esc(hinweisText(d))} Die ausgewählten öffnen sich nacheinander im Aufgaben-Panel.</p>
|
<p class="hint">${esc(hinweisText(d))} Wähle je Eintrag, ob er eine Aufgabe oder ein Meilenstein wird. Die ausgewählten öffnen sich danach nacheinander zum Prüfen.</p>
|
||||||
<div class="mail-auswahl-liste">${eintraege.map((e, i) => `
|
<div class="mail-auswahl-liste">${eintraege.map((e, i) => `
|
||||||
<label class="mail-auswahl-zeile"><input type="checkbox" checked data-i="${i}">
|
<div class="mail-auswahl-zeile">
|
||||||
<span class="mail-auswahl-titel">${esc(e.titel)}</span>
|
<input type="checkbox" checked data-i="${i}" aria-label="${esc(e.titel)} übernehmen">
|
||||||
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></label>`).join('')}</div>
|
<span class="mail-auswahl-text"><span class="mail-auswahl-titel">${esc(e.titel)}</span>
|
||||||
|
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></span>
|
||||||
|
<span class="segmented mail-auswahl-art" role="group" aria-label="Art" data-i="${i}">
|
||||||
|
<button type="button" class="mail-art-knopf" data-art="aufgabe" aria-pressed="${arten[i] === 'aufgabe'}">Aufgabe</button>
|
||||||
|
<button type="button" class="mail-art-knopf" data-art="meilenstein" aria-pressed="${arten[i] === 'meilenstein'}">Meilenstein</button>
|
||||||
|
</span>
|
||||||
|
</div>`).join('')}</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button>
|
<button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button>
|
||||||
<button type="button" data-aktion="weiter">Prüfen und anlegen</button>
|
<button type="button" data-aktion="weiter">Prüfen und anlegen</button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
|
bg.querySelectorAll('.mail-auswahl-art button').forEach(b => {
|
||||||
|
b.onclick = () => {
|
||||||
|
const i = Number(b.parentElement.dataset.i);
|
||||||
|
arten[i] = b.dataset.art;
|
||||||
|
b.parentElement.querySelectorAll('button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
|
||||||
|
};
|
||||||
|
});
|
||||||
bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; };
|
bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; };
|
||||||
bg.querySelector('[data-aktion="weiter"]').onclick = () => {
|
bg.querySelector('[data-aktion="weiter"]').onclick = () => {
|
||||||
const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked')).map(cb => eintraege[Number(cb.dataset.i)]);
|
const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked'))
|
||||||
|
.map(cb => { const i = Number(cb.dataset.i); return Object.assign({}, eintraege[i], { art: arten[i] }); });
|
||||||
bg.style.display = 'none';
|
bg.style.display = 'none';
|
||||||
if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt);
|
if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt);
|
||||||
};
|
};
|
||||||
|
|||||||
+136
-34
@@ -33,8 +33,15 @@
|
|||||||
// (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum,
|
// (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum,
|
||||||
// Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und
|
// Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und
|
||||||
// TODOs) aus dem Plan.
|
// TODOs) aus dem Plan.
|
||||||
window.openMilestoneModal = function(index){
|
// msNachSchliessen: wie todoNachSchliessen — "Termin aus Mail" öffnet
|
||||||
|
// damit nach dem Schließen den nächsten Vorschlag.
|
||||||
|
let msNachSchliessen = null;
|
||||||
|
|
||||||
|
// openMilestoneModal: Parameter index (null = neu), prefill (optional, nur
|
||||||
|
// bei neu: name, date, notiz, mailHinweis, nachSchliessen).
|
||||||
|
window.openMilestoneModal = function(index, prefill){
|
||||||
if(app.isLocked()) return;
|
if(app.isLocked()) return;
|
||||||
|
const pre = prefill || {};
|
||||||
const plan = app.ensurePlanLocal();
|
const plan = app.ensurePlanLocal();
|
||||||
if(!Array.isArray(plan.milestones)) plan.milestones = [];
|
if(!Array.isArray(plan.milestones)) plan.milestones = [];
|
||||||
// Migration alter Meilensteine
|
// Migration alter Meilensteine
|
||||||
@@ -43,10 +50,18 @@
|
|||||||
|
|
||||||
const isNew = index === null || plan.milestones[index] === undefined;
|
const isNew = index === null || plan.milestones[index] === undefined;
|
||||||
const currentMs = isNew ? null : plan.milestones[index];
|
const currentMs = isNew ? null : plan.milestones[index];
|
||||||
document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || '');
|
document.getElementById('milestoneEditName').value = isNew ? (pre.name || '') : (currentMs.name || '');
|
||||||
|
const msNotiz = document.getElementById('milestoneEditNotiz');
|
||||||
|
if(msNotiz) msNotiz.value = isNew ? (pre.notiz || '') : (currentMs.notiz || '');
|
||||||
|
const msMailHinweis = document.getElementById('milestoneEditMailHinweis');
|
||||||
|
if(msMailHinweis){
|
||||||
|
msMailHinweis.hidden = !(isNew && pre.mailHinweis);
|
||||||
|
msMailHinweis.textContent = (isNew && pre.mailHinweis) || '';
|
||||||
|
}
|
||||||
|
msNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null;
|
||||||
// Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) —
|
// Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) —
|
||||||
// dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite.
|
// dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite.
|
||||||
const msDateIso = isNew ? '' : (currentMs.date || '');
|
const msDateIso = isNew ? (pre.date || '') : (currentMs.date || '');
|
||||||
document.getElementById('milestoneEditDate').value = msDateIso;
|
document.getElementById('milestoneEditDate').value = msDateIso;
|
||||||
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
|
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
|
||||||
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
|
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
|
||||||
@@ -152,6 +167,9 @@
|
|||||||
window.closeMilestoneModal = function(){
|
window.closeMilestoneModal = function(){
|
||||||
document.getElementById('milestoneModal').style.display = 'none';
|
document.getElementById('milestoneModal').style.display = 'none';
|
||||||
app.currentMilestoneIndex = null;
|
app.currentMilestoneIndex = null;
|
||||||
|
const danach = msNachSchliessen;
|
||||||
|
msNachSchliessen = null;
|
||||||
|
if(danach) setTimeout(danach, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
|
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
|
||||||
@@ -169,18 +187,21 @@
|
|||||||
|
|
||||||
const positionSelect = document.getElementById('milestoneEditPosition');
|
const positionSelect = document.getElementById('milestoneEditPosition');
|
||||||
const position = positionSelect ? positionSelect.value : '';
|
const position = positionSelect ? positionSelect.value : '';
|
||||||
|
const msNotizEl = document.getElementById('milestoneEditNotiz');
|
||||||
|
const notiz = msNotizEl ? msNotizEl.value.trim() : '';
|
||||||
|
|
||||||
const plan = app.ensurePlanLocal();
|
const plan = app.ensurePlanLocal();
|
||||||
if(!Array.isArray(plan.milestones)) plan.milestones = [];
|
if(!Array.isArray(plan.milestones)) plan.milestones = [];
|
||||||
|
|
||||||
if(app.currentMilestoneIndex === null){
|
if(app.currentMilestoneIndex === null){
|
||||||
plan.milestones.push({ id: uid(), name, date, dependsOn, position });
|
plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz });
|
||||||
} else if(plan.milestones[app.currentMilestoneIndex]){
|
} else if(plan.milestones[app.currentMilestoneIndex]){
|
||||||
const m = plan.milestones[app.currentMilestoneIndex];
|
const m = plan.milestones[app.currentMilestoneIndex];
|
||||||
m.name = name;
|
m.name = name;
|
||||||
m.date = date;
|
m.date = date;
|
||||||
m.dependsOn = dependsOn;
|
m.dependsOn = dependsOn;
|
||||||
m.position = position;
|
m.position = position;
|
||||||
|
m.notiz = notiz;
|
||||||
}
|
}
|
||||||
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
|
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
|
||||||
app.savePlan();
|
app.savePlan();
|
||||||
@@ -467,6 +488,104 @@
|
|||||||
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
|
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
|
||||||
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
|
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
|
||||||
// Anlegen.
|
// Anlegen.
|
||||||
|
// ---------- Aufwand: −/+, Schnellwahl, Umrechnung ----------
|
||||||
|
// Ein Arbeitstag = APP_SETTINGS.arbeitsstundenProTag (Admin → Termine).
|
||||||
|
function stundenProTag(){ return Number(APP_SETTINGS && APP_SETTINGS.arbeitsstundenProTag) || 8; }
|
||||||
|
function aufwandLesen(){
|
||||||
|
const el = document.getElementById('todoEditAufwand');
|
||||||
|
return el ? Math.max(0, parseFloat(String(el.value).replace(',', '.')) || 0) : 0;
|
||||||
|
}
|
||||||
|
function aufwandSchreiben(h){
|
||||||
|
const el = document.getElementById('todoEditAufwand');
|
||||||
|
if(!el) return;
|
||||||
|
const w = Math.max(0, Math.round(h * 2) / 2);
|
||||||
|
el.value = w > 0 ? String(w) : '';
|
||||||
|
todoAufwandInfo();
|
||||||
|
}
|
||||||
|
window.todoAufwandAendern = function(delta){ aufwandSchreiben(aufwandLesen() + delta); };
|
||||||
|
// todoAufwandSetzen(h) oder todoAufwandSetzen('tag', n): fester Wert; ein
|
||||||
|
// zweiter Klick auf den gewählten Wert leert das Feld.
|
||||||
|
window.todoAufwandSetzen = function(a, n){
|
||||||
|
const h = a === 'tag' ? n * stundenProTag() : Number(a);
|
||||||
|
aufwandSchreiben(aufwandLesen() === h ? 0 : h);
|
||||||
|
};
|
||||||
|
// todoAufwandInfo: Umrechnung in Arbeitstage und Markierung der Schnellwahl.
|
||||||
|
window.todoAufwandInfo = function(){
|
||||||
|
const h = aufwandLesen();
|
||||||
|
const tag = stundenProTag();
|
||||||
|
const info = document.getElementById('todoAufwandInfoText');
|
||||||
|
if(info){
|
||||||
|
const tage = h / tag;
|
||||||
|
const zahl = x => (Math.round(x * 10) / 10).toLocaleString('de-DE');
|
||||||
|
info.textContent = h > 0 ? '≈ ' + zahl(tage) + (Math.abs(tage - 1) < 0.05 ? ' Arbeitstag' : ' Arbeitstage') + ' (' + zahl(tag) + ' h je Tag)' : 'Ohne Aufwand zählt die Aufgabe nicht zur Auslastung.';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#todoAufwandSchnell button').forEach(b => {
|
||||||
|
const wert = b.dataset.tage ? Number(b.dataset.tage) * tag : Number(b.dataset.h);
|
||||||
|
b.setAttribute('aria-pressed', String(h > 0 && Math.abs(wert - h) < 0.01));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Zuständige: Projekt und „Externe Personen“ ----------
|
||||||
|
// todoProjektPersonen: die dem Projekt zugeordneten Personen (je einmal).
|
||||||
|
function todoProjektPersonen(){
|
||||||
|
const gesehen = new Set();
|
||||||
|
const liste = [];
|
||||||
|
projectAssignees(app.project, app.week).forEach(a => {
|
||||||
|
if(gesehen.has(a.person.id)) return;
|
||||||
|
gesehen.add(a.person.id);
|
||||||
|
liste.push(a.person);
|
||||||
|
});
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
function todoPersonById(id){
|
||||||
|
return ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
|
||||||
|
}
|
||||||
|
function todoPersonChip(person, gedrueckt, extern){
|
||||||
|
const short = person.name || initialsForName(person.fullName || '') || '?';
|
||||||
|
const full = person.fullName || person.name || '';
|
||||||
|
const klick = extern ? `todoExternEntfernen('${esc(person.id)}')` : 'toggleTodoAssignee(this)';
|
||||||
|
const titel = extern ? ' title="Nicht im Projekt — Klick entfernt die Person"' : '';
|
||||||
|
return `<button type="button" class="person-chip${extern ? ' ist-fremd' : ''}"${titel} data-person-id="${esc(person.id)}" aria-pressed="${gedrueckt ? 'true' : 'false'}" onclick="${klick}">`
|
||||||
|
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}${extern ? ' <span class="person-chip-x" aria-hidden="true">×</span>' : ''}</button>`;
|
||||||
|
}
|
||||||
|
// todoExterneSetzen: zeichnet die gewählten externen Personen und die
|
||||||
|
// Auswahl der übrigen Kollegen (Teilnehmerkreis der Sitzung ohne Projekt
|
||||||
|
// und ohne bereits gewählte), nach Gewerk gruppiert.
|
||||||
|
function todoExterneSetzen(ids){
|
||||||
|
const box = document.getElementById('todoEditExterne');
|
||||||
|
const wahl = document.getElementById('todoEditExterneWahl');
|
||||||
|
if(!box || !wahl) return;
|
||||||
|
box.innerHTML = ids.map(id => todoPersonChip(todoPersonById(id), true, true)).join('');
|
||||||
|
const projektIds = new Set(todoProjektPersonen().map(p => p.id));
|
||||||
|
const frei = ((app.week && app.week.people) || [])
|
||||||
|
.filter(p => p && p.id && !projektIds.has(p.id) && !ids.includes(p.id))
|
||||||
|
.sort((a, b) => String(a.fullName || a.name).localeCompare(String(b.fullName || b.name), 'de'));
|
||||||
|
const gruppen = new Map();
|
||||||
|
frei.forEach(p => {
|
||||||
|
const g = (typeof personGewerk === 'function' && personGewerk(p)) || 'Ohne Gewerk';
|
||||||
|
if(!gruppen.has(g)) gruppen.set(g, []);
|
||||||
|
gruppen.get(g).push(p);
|
||||||
|
});
|
||||||
|
const reihenfolge = [...(typeof DEPARTMENTS !== 'undefined' ? Object.keys(DEPARTMENTS) : []), 'Ohne Gewerk'];
|
||||||
|
const schluessel = [...gruppen.keys()].sort((a, b) => reihenfolge.indexOf(a) - reihenfolge.indexOf(b));
|
||||||
|
wahl.innerHTML = `<option value="">${frei.length ? '+ Person hinzufügen…' : 'Keine weiteren Kollegen'}</option>`
|
||||||
|
+ schluessel.map(g => `<optgroup label="${esc(g)}">${gruppen.get(g).map(p =>
|
||||||
|
`<option value="${esc(p.id)}">${esc(p.fullName || p.name)}${p.name && p.fullName ? ' (' + esc(p.name) + ')' : ''}</option>`).join('')}</optgroup>`).join('');
|
||||||
|
wahl.disabled = !frei.length;
|
||||||
|
}
|
||||||
|
function todoExterneIds(){
|
||||||
|
return Array.from(document.querySelectorAll('#todoEditExterne .person-chip')).map(b => b.dataset.personId);
|
||||||
|
}
|
||||||
|
window.todoExternHinzufuegen = function(id){
|
||||||
|
if(!id) return;
|
||||||
|
const ids = todoExterneIds();
|
||||||
|
if(!ids.includes(id)) ids.push(id);
|
||||||
|
todoExterneSetzen(ids);
|
||||||
|
};
|
||||||
|
window.todoExternEntfernen = function(id){
|
||||||
|
todoExterneSetzen(todoExterneIds().filter(x => x !== id));
|
||||||
|
};
|
||||||
|
|
||||||
window.openTodoModal = function(index, prefill){
|
window.openTodoModal = function(index, prefill){
|
||||||
if(app.isLocked()) return;
|
if(app.isLocked()) return;
|
||||||
const plan = app.ensurePlanLocal();
|
const plan = app.ensurePlanLocal();
|
||||||
@@ -523,6 +642,7 @@
|
|||||||
const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand);
|
const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand);
|
||||||
aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : '';
|
aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : '';
|
||||||
}
|
}
|
||||||
|
todoAufwandInfo();
|
||||||
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
|
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
|
||||||
|
|
||||||
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
|
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
|
||||||
@@ -573,39 +693,20 @@
|
|||||||
|
|
||||||
const assigneesDiv = document.getElementById('todoEditAssignees');
|
const assigneesDiv = document.getElementById('todoEditAssignees');
|
||||||
if(assigneesDiv){
|
if(assigneesDiv){
|
||||||
const assignees = projectAssignees(app.project, app.week);
|
|
||||||
const currentAssignees = currentTodo
|
const currentAssignees = currentTodo
|
||||||
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
|
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
|
||||||
: (Array.isArray(pre.assignees) ? pre.assignees : []);
|
: (Array.isArray(pre.assignees) ? pre.assignees : []);
|
||||||
const seenPeople = new Set();
|
const projektLeute = todoProjektPersonen();
|
||||||
const uniquePeople = [];
|
const projektIds = new Set(projektLeute.map(p => p.id));
|
||||||
assignees.forEach(a => {
|
|
||||||
if(!seenPeople.has(a.person.id)){
|
|
||||||
seenPeople.add(a.person.id);
|
|
||||||
uniquePeople.push(a.person);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Bereits Zuständige, die (nicht mehr) dem Projekt zugeordnet sind —
|
|
||||||
// etwa über "Aufgabe aus Kommentar" aus dem ganzen Teilnehmerkreis
|
|
||||||
// gewählt — ebenfalls als Chip zeigen. Ohne sie gingen sie beim
|
|
||||||
// Speichern stillschweigend verloren, weil saveTodoEdit() nur die
|
|
||||||
// gedrückten Chips liest.
|
|
||||||
currentAssignees.forEach(id => {
|
|
||||||
if(seenPeople.has(id)) return;
|
|
||||||
seenPeople.add(id);
|
|
||||||
const p = ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
|
|
||||||
uniquePeople.push(Object.assign({}, p, { nichtImProjekt: true }));
|
|
||||||
});
|
|
||||||
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
|
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
|
||||||
// Personenspalten im Projektboard.
|
// Personenspalten im Projektboard.
|
||||||
assigneesDiv.innerHTML = uniquePeople.map(person => {
|
assigneesDiv.innerHTML = projektLeute.map(person =>
|
||||||
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
|
todoPersonChip(person, currentAssignees.includes(person.id), false)
|
||||||
const short = person.name || initialsForName(person.fullName || '') || '?';
|
).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
|
||||||
const full = person.fullName || person.name || '';
|
// Zuständige außerhalb des Projekts stehen unter „Externe Personen“ —
|
||||||
const fremd = person.nichtImProjekt ? ' title="Nicht (mehr) dem Projekt zugeordnet"' : '';
|
// auch solche, die das Projekt inzwischen verlassen haben (sonst gingen
|
||||||
return `<button type="button" class="person-chip${person.nichtImProjekt ? ' ist-fremd' : ''}"${fremd} data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
|
// sie beim Speichern stillschweigend verloren).
|
||||||
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
|
todoExterneSetzen(currentAssignees.filter(id => !projektIds.has(id)));
|
||||||
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteBtn = document.getElementById('todoEditDelete');
|
const deleteBtn = document.getElementById('todoEditDelete');
|
||||||
@@ -643,7 +744,7 @@
|
|||||||
function todoFormSnapshot(){
|
function todoFormSnapshot(){
|
||||||
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
|
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
|
||||||
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
|
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
|
||||||
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId);
|
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"], #todoEditExterne .person-chip')).map(b => b.dataset.personId);
|
||||||
const gantt = document.getElementById('todoEditShowInGantt');
|
const gantt = document.getElementById('todoEditShowInGantt');
|
||||||
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
|
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
|
||||||
currentTodoEditStatus(), read('todoEditProgress'), deps, people,
|
currentTodoEditStatus(), read('todoEditProgress'), deps, people,
|
||||||
@@ -706,7 +807,8 @@
|
|||||||
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
|
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
|
||||||
|
|
||||||
const assigneesDiv = document.getElementById('todoEditAssignees');
|
const assigneesDiv = document.getElementById('todoEditAssignees');
|
||||||
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [];
|
const assignees = (assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [])
|
||||||
|
.concat(todoExterneIds());
|
||||||
|
|
||||||
const showInGanttEl = document.getElementById('todoEditShowInGantt');
|
const showInGanttEl = document.getElementById('todoEditShowInGantt');
|
||||||
const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
|
const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
// -----------------------------
|
||||||
|
// Projektbereich — Notizen (Eriks Wahl „A – Liste + Editor“)
|
||||||
|
// -----------------------------
|
||||||
|
// Gemeinsame Notizen je Projekt: links die Liste (angeheftete oben, danach
|
||||||
|
// zuletzt geänderte zuerst, Suche über Titel und Text), rechts die gewählte
|
||||||
|
// Notiz direkt bearbeitbar — Titel, dieselbe Werkzeugleiste wie beim
|
||||||
|
// Sitzungskommentar (richtextToolbarHtml(), js/utils.js) und der Text.
|
||||||
|
// Gespeichert wird automatisch kurz nach dem Tippen und beim Verlassen des
|
||||||
|
// Feldes; „Gespeichert ✓“ zeigt den Stand. Mail per Drag & Drop (Text oder
|
||||||
|
// .eml) bzw. Strg+V außerhalb von Eingabefeldern legt eine neue Notiz an,
|
||||||
|
// ohne KI. Live: andere Browser erfahren Änderungen über das Ereignis
|
||||||
|
// {typ:'notizen'} (notizenLiveAenderung(), aus js/store.js).
|
||||||
|
//
|
||||||
|
// Daten am Server: Tabelle projekt_notizen, Routen /projekt-notizen*
|
||||||
|
// (server.js). Die Notizen hängen am Projekt (projectUid), nicht an der
|
||||||
|
// Sitzung. Schreiben: Recht proj.plan; Löschen: Verfasser oder Admin.
|
||||||
|
(function(){
|
||||||
|
const app = window.PlanApp;
|
||||||
|
const SPEICHER_PAUSE_MS = 1200;
|
||||||
|
|
||||||
|
let liste = []; // Notizen des geladenen Projekts
|
||||||
|
let uidGeladen = null; // projectUid, zu dem `liste` gehört
|
||||||
|
let aktivId = null; // gewählte Notiz
|
||||||
|
let suche = '';
|
||||||
|
let basis = ''; // geaendertAm, auf dem die Bearbeitung beruht
|
||||||
|
let ungespeichert = false;
|
||||||
|
let speicherTimer = null;
|
||||||
|
let speichertGerade = false;
|
||||||
|
let beobachter = null; // MutationObserver auf dem Textfeld
|
||||||
|
let loeschenFragt = null; // Timer der zweistufigen Löschen-Abfrage
|
||||||
|
let ladeFehler = '';
|
||||||
|
|
||||||
|
const kannSchreiben = () => typeof hatRecht === 'function' && hatRecht('proj.plan');
|
||||||
|
const projektUid = () => app.project ? String(app.project.projectUid || app.project.id || '') : '';
|
||||||
|
const wrap = () => document.getElementById('planNotizenWrap');
|
||||||
|
const aktiv = () => liste.find(n => n.id === aktivId) || null;
|
||||||
|
|
||||||
|
function zeit(iso){
|
||||||
|
if(!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if(isNaN(d)) return '';
|
||||||
|
const heute = new Date();
|
||||||
|
const gleicherTag = d.toDateString() === heute.toDateString();
|
||||||
|
const gestern = new Date(heute); gestern.setDate(heute.getDate() - 1);
|
||||||
|
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if(gleicherTag) return 'heute ' + uhr;
|
||||||
|
if(d.toDateString() === gestern.toDateString()) return 'gestern ' + uhr;
|
||||||
|
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: d.getFullYear() === heute.getFullYear() ? undefined : 'numeric' });
|
||||||
|
}
|
||||||
|
function kurzname(name){
|
||||||
|
const teile = String(name || '').trim().split(/\s+/);
|
||||||
|
return teile.length > 1 ? teile[0][0] + '. ' + teile.slice(1).join(' ') : (name || '');
|
||||||
|
}
|
||||||
|
function klartext(html){
|
||||||
|
return typeof statusPlainText === 'function' ? statusPlainText(html) : String(html || '').replace(/<[^>]+>/g, ' ');
|
||||||
|
}
|
||||||
|
function sortieren(){
|
||||||
|
liste.sort((a, b) => (b.angeheftet - a.angeheftet) || String(b.geaendertAm).localeCompare(String(a.geaendertAm)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Laden ----------
|
||||||
|
function laden(uid){
|
||||||
|
uidGeladen = uid;
|
||||||
|
ladeFehler = '';
|
||||||
|
return fetch(baseUrl() + '/projekt-notizen?uid=' + encodeURIComponent(uid))
|
||||||
|
.then(r => r.json().then(d => ({ ok: r.ok, d })))
|
||||||
|
.then(({ ok, d }) => {
|
||||||
|
if(uid !== uidGeladen) return;
|
||||||
|
if(!ok || !d.ok) throw new Error(d.error || 'Laden fehlgeschlagen');
|
||||||
|
liste = d.notizen || [];
|
||||||
|
sortieren();
|
||||||
|
})
|
||||||
|
.catch(e => { ladeFehler = e.message || 'Die Notizen konnten nicht geladen werden.'; liste = []; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Darstellung ----------
|
||||||
|
window.renderPlanNotizen = function(){
|
||||||
|
const el = wrap();
|
||||||
|
if(!el || !app.project) return;
|
||||||
|
const uid = projektUid();
|
||||||
|
if(uid !== uidGeladen){
|
||||||
|
jetztSpeichern();
|
||||||
|
aktivId = null; liste = []; suche = '';
|
||||||
|
el.innerHTML = '<div class="notizen-laedt">Notizen werden geladen…</div>';
|
||||||
|
laden(uid).then(() => { if(projektUid() === uid) aufbauen(); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
aufbauen();
|
||||||
|
};
|
||||||
|
|
||||||
|
function aufbauen(){
|
||||||
|
const el = wrap();
|
||||||
|
if(!el) return;
|
||||||
|
if(!aktiv() && liste.length) aktivId = liste[0].id;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="notizen" id="notizenBereich">
|
||||||
|
<aside class="notizen-liste">
|
||||||
|
<div class="notizen-suchzeile">
|
||||||
|
<input type="search" id="notizenSuche" class="notizen-suche" placeholder="Notizen durchsuchen…"
|
||||||
|
aria-label="Notizen durchsuchen" value="${esc(suche)}" oninput="notizenSuchen(this.value)">
|
||||||
|
${kannSchreiben() ? '<button type="button" class="notizen-neu" onclick="notizNeu()">+ Neu</button>' : ''}
|
||||||
|
</div>
|
||||||
|
<div id="notizenEintraege" class="notizen-eintraege"></div>
|
||||||
|
${kannSchreiben() ? '<div class="notizen-ablage">Mail hierher ziehen = neue Notiz</div>' : ''}
|
||||||
|
</aside>
|
||||||
|
<div class="notizen-editor" id="notizenEditor"></div>
|
||||||
|
</div>`;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listeZeichnen(){
|
||||||
|
const box = document.getElementById('notizenEintraege');
|
||||||
|
if(!box) return;
|
||||||
|
if(ladeFehler){ box.innerHTML = '<div class="notizen-leer ist-fehler">' + esc(ladeFehler) + '</div>'; return; }
|
||||||
|
const s = suche.trim().toLowerCase();
|
||||||
|
const treffer = s ? liste.filter(n => (n.titel + ' ' + klartext(n.html)).toLowerCase().includes(s)) : liste;
|
||||||
|
if(!liste.length){ box.innerHTML = '<div class="notizen-leer">Noch keine Notizen.</div>'; return; }
|
||||||
|
if(!treffer.length){ box.innerHTML = '<div class="notizen-leer">Keine Notiz enthält „' + esc(suche) + '“.</div>'; return; }
|
||||||
|
const zeile = n => {
|
||||||
|
const vorschau = klartext(n.html).replace(/\s+/g, ' ').trim().slice(0, 90);
|
||||||
|
return `<button type="button" class="notizen-eintrag${n.id === aktivId ? ' ist-aktiv' : ''}" onclick="notizWaehlen('${esc(n.id)}')">
|
||||||
|
<span class="notizen-eintrag-titel">${esc(n.titel || 'Ohne Titel')}${n.quelle === 'mail' ? ' <span class="notizen-chip">Mail</span>' : ''}</span>
|
||||||
|
<span class="notizen-eintrag-text">${esc(vorschau || 'Kein Text')}</span>
|
||||||
|
<span class="notizen-eintrag-meta">${esc(kurzname(n.geaendertVon))} · ${esc(zeit(n.geaendertAm))}</span>
|
||||||
|
</button>`;
|
||||||
|
};
|
||||||
|
const oben = treffer.filter(n => n.angeheftet), rest = treffer.filter(n => !n.angeheftet);
|
||||||
|
box.innerHTML = (oben.length ? '<div class="notizen-gruppe"><span class="notizen-pin-punkt"></span>Angeheftet</div>' + oben.map(zeile).join('') : '')
|
||||||
|
+ (rest.length ? (oben.length ? '<div class="notizen-gruppe">Weitere</div>' : '') + rest.map(zeile).join('') : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorZeichnen(){
|
||||||
|
const el = document.getElementById('notizenEditor');
|
||||||
|
if(!el) return;
|
||||||
|
if(beobachter){ beobachter.disconnect(); beobachter = null; }
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n){
|
||||||
|
el.innerHTML = `<div class="notizen-editor-leer">
|
||||||
|
<b>${liste.length ? 'Notiz links auswählen' : 'Noch keine Notizen in diesem Projekt'}</b>
|
||||||
|
<span>${kannSchreiben() ? 'Mit „+ Neu“ anlegen oder eine Mail auf diesen Bereich ziehen.' : 'Zum Anlegen fehlt das Recht, den Terminplan zu bearbeiten.'}</span>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const schreiben = kannSchreiben();
|
||||||
|
basis = n.geaendertAm;
|
||||||
|
ungespeichert = false;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="notizen-editor-kopf">
|
||||||
|
<input type="text" id="notizTitel" class="notizen-titel" value="${esc(n.titel)}" placeholder="Titel der Notiz"
|
||||||
|
aria-label="Titel der Notiz" ${schreiben ? '' : 'readonly'} oninput="notizGeaendert()" onblur="notizJetztSpeichern()">
|
||||||
|
${schreiben ? `<button type="button" class="notizen-symbol${n.angeheftet ? ' ist-an' : ''}" onclick="notizAnheften()"
|
||||||
|
title="${n.angeheftet ? 'Nicht mehr anheften' : 'Oben anheften'}" aria-pressed="${n.angeheftet}">${n.angeheftet ? 'Angeheftet' : 'Anheften'}</button>
|
||||||
|
<button type="button" class="notizen-symbol ist-gefahr" id="notizLoeschen" onclick="notizLoeschen()">Löschen</button>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="richtext-wrap notizen-richtext">
|
||||||
|
${schreiben ? richtextToolbarHtml('.notizen-text') : ''}
|
||||||
|
${richtextFeldHtml('notizen-text', sanitizeStatusHtml(n.html || ''), 'Notiz schreiben…', 'id="notizText" onblur="notizJetztSpeichern()"', schreiben)}
|
||||||
|
</div>
|
||||||
|
<div class="notizen-fuss">
|
||||||
|
<span>Angelegt von ${esc(n.erstelltVon)} ${esc(zeit(n.erstelltAm))}${n.geaendertAm !== n.erstelltAm ? ' · zuletzt geändert von ' + esc(n.geaendertVon) + ' ' + esc(zeit(n.geaendertAm)) : ''}</span>
|
||||||
|
<span id="notizStatus" class="notizen-status"></span>
|
||||||
|
</div>`;
|
||||||
|
const text = document.getElementById('notizText');
|
||||||
|
// Auch Fett/Farbe aus der Werkzeugleiste ändern den Inhalt, ohne ein
|
||||||
|
// input-Ereignis auszulösen — daher ein MutationObserver.
|
||||||
|
if(text && schreiben){
|
||||||
|
beobachter = new MutationObserver(() => notizGeaendert());
|
||||||
|
beobachter.observe(text, { childList: true, subtree: true, characterData: true, attributes: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(text, art){
|
||||||
|
const el = document.getElementById('notizStatus');
|
||||||
|
if(!el) return;
|
||||||
|
el.className = 'notizen-status' + (art ? ' ist-' + art : '');
|
||||||
|
el.innerHTML = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bearbeiten und Speichern ----------
|
||||||
|
window.notizenSuchen = function(wert){ suche = wert || ''; listeZeichnen(); };
|
||||||
|
|
||||||
|
window.notizWaehlen = function(id){
|
||||||
|
if(id === aktivId) return;
|
||||||
|
jetztSpeichern();
|
||||||
|
aktivId = id;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.notizGeaendert = function(){
|
||||||
|
if(!kannSchreiben() || !aktiv()) return;
|
||||||
|
ungespeichert = true;
|
||||||
|
status('Ungespeichert');
|
||||||
|
clearTimeout(speicherTimer);
|
||||||
|
speicherTimer = setTimeout(jetztSpeichern, SPEICHER_PAUSE_MS);
|
||||||
|
};
|
||||||
|
window.notizJetztSpeichern = function(){ jetztSpeichern(); };
|
||||||
|
|
||||||
|
// jetztSpeichern: schickt die gewählte Notiz, falls geändert. ohneBasis =
|
||||||
|
// bewusst überschreiben (nach einem Konflikt „Meine Fassung behalten“).
|
||||||
|
function jetztSpeichern(ohneBasis){
|
||||||
|
clearTimeout(speicherTimer);
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n || !ungespeichert || !kannSchreiben()) return Promise.resolve();
|
||||||
|
if(speichertGerade){ speicherTimer = setTimeout(jetztSpeichern, 400); return Promise.resolve(); }
|
||||||
|
const titelEl = document.getElementById('notizTitel');
|
||||||
|
const textEl = document.getElementById('notizText');
|
||||||
|
const titel = titelEl ? titelEl.value : n.titel;
|
||||||
|
const html = textEl ? textEl.innerHTML : n.html;
|
||||||
|
ungespeichert = false;
|
||||||
|
speichertGerade = true;
|
||||||
|
status('Speichert…');
|
||||||
|
return fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify({ titel, html, basis: ohneBasis ? '' : basis })
|
||||||
|
}).then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
|
||||||
|
if(r.status === 409 && d.konflikt){
|
||||||
|
ungespeichert = true;
|
||||||
|
status(esc(d.error) + ' <button type="button" class="notizen-link" onclick="notizKonflikt(true)">Meine Fassung speichern</button>'
|
||||||
|
+ ' <button type="button" class="notizen-link" onclick="notizKonflikt(false)">Neuen Stand laden</button>', 'warnung');
|
||||||
|
n._fremd = d.notiz;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!r.ok || !d.ok) throw new Error(d.error || 'Speichern fehlgeschlagen');
|
||||||
|
Object.assign(n, d.notiz, { titel, html });
|
||||||
|
if(n.id === aktivId) basis = d.notiz.geaendertAm;
|
||||||
|
sortieren();
|
||||||
|
listeZeichnen();
|
||||||
|
if(!ungespeichert) status('Gespeichert ✓', 'ok');
|
||||||
|
}).catch(e => {
|
||||||
|
ungespeichert = true;
|
||||||
|
status('Nicht gespeichert: ' + esc(e.message) + ' — wird erneut versucht.', 'fehler');
|
||||||
|
speicherTimer = setTimeout(jetztSpeichern, 5000);
|
||||||
|
}).finally(() => { speichertGerade = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.notizKonflikt = function(meine){
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n) return;
|
||||||
|
if(meine){ ungespeichert = true; jetztSpeichern(true); return; }
|
||||||
|
if(n._fremd) Object.assign(n, n._fremd);
|
||||||
|
delete n._fremd;
|
||||||
|
ungespeichert = false;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.notizNeu = function(){
|
||||||
|
const uid = projektUid();
|
||||||
|
if(!uid || !kannSchreiben()) return;
|
||||||
|
jetztSpeichern();
|
||||||
|
fetch(baseUrl() + '/projekt-notizen', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify({ uid, titel: '', html: '' })
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if(!d.ok) throw new Error(d.error || 'Anlegen fehlgeschlagen');
|
||||||
|
neueNotizZeigen(d.notiz);
|
||||||
|
const t = document.getElementById('notizTitel');
|
||||||
|
if(t) t.focus();
|
||||||
|
}).catch(e => app.showToast('Notiz konnte nicht angelegt werden: ' + e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
function neueNotizZeigen(notiz){
|
||||||
|
if(!notiz || notiz.uid !== uidGeladen) return;
|
||||||
|
liste = liste.filter(x => x.id !== notiz.id);
|
||||||
|
liste.push(notiz);
|
||||||
|
sortieren();
|
||||||
|
aktivId = notiz.id;
|
||||||
|
suche = '';
|
||||||
|
const s = document.getElementById('notizenSuche');
|
||||||
|
if(s) s.value = '';
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.notizAnheften = function(){
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n) return;
|
||||||
|
jetztSpeichern().then(() => fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify({ angeheftet: !n.angeheftet })
|
||||||
|
})).then(r => r.json()).then(d => {
|
||||||
|
if(!d.ok) throw new Error(d.error || 'Fehler');
|
||||||
|
n.angeheftet = d.notiz.angeheftet;
|
||||||
|
sortieren();
|
||||||
|
listeZeichnen();
|
||||||
|
const knopf = document.querySelector('.notizen-editor-kopf .notizen-symbol:not(.ist-gefahr)');
|
||||||
|
if(knopf){
|
||||||
|
knopf.classList.toggle('ist-an', n.angeheftet);
|
||||||
|
knopf.textContent = n.angeheftet ? 'Angeheftet' : 'Anheften';
|
||||||
|
knopf.title = n.angeheftet ? 'Nicht mehr anheften' : 'Oben anheften';
|
||||||
|
knopf.setAttribute('aria-pressed', String(n.angeheftet));
|
||||||
|
}
|
||||||
|
}).catch(e => app.showToast('Anheften fehlgeschlagen: ' + e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
// notizLoeschen: zweistufig im Knopf selbst (kein confirm()).
|
||||||
|
window.notizLoeschen = function(){
|
||||||
|
const n = aktiv();
|
||||||
|
const knopf = document.getElementById('notizLoeschen');
|
||||||
|
if(!n || !knopf) return;
|
||||||
|
if(!loeschenFragt){
|
||||||
|
knopf.textContent = 'Wirklich löschen?';
|
||||||
|
knopf.classList.add('ist-fragend');
|
||||||
|
loeschenFragt = setTimeout(() => {
|
||||||
|
loeschenFragt = null;
|
||||||
|
const k = document.getElementById('notizLoeschen');
|
||||||
|
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
|
||||||
|
}, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(loeschenFragt); loeschenFragt = null;
|
||||||
|
clearTimeout(speicherTimer); ungespeichert = false;
|
||||||
|
fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), { method: 'DELETE' })
|
||||||
|
.then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
|
||||||
|
if(!r.ok || !d.ok) throw new Error(d.error || 'Löschen fehlgeschlagen');
|
||||||
|
liste = liste.filter(x => x.id !== n.id);
|
||||||
|
aktivId = liste.length ? liste[0].id : null;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
}).catch(e => { status(esc(e.message), 'fehler'); const k = document.getElementById('notizLoeschen'); if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Live: Änderungen aus anderen Browsern ----------
|
||||||
|
// Liste neu holen; die gewählte Notiz nur ersetzen, wenn hier gerade
|
||||||
|
// nichts ungespeichert ist und niemand in ihr tippt.
|
||||||
|
window.notizenLiveAenderung = function(uid){
|
||||||
|
if(!uid || uid !== uidGeladen) return;
|
||||||
|
const n = aktiv();
|
||||||
|
laden(uid).then(() => {
|
||||||
|
if(!wrap()) return;
|
||||||
|
if(aktivId && !liste.some(x => x.id === aktivId)){ aktivId = liste.length ? liste[0].id : null; editorZeichnen(); }
|
||||||
|
listeZeichnen();
|
||||||
|
const frisch = aktiv();
|
||||||
|
const tippt = document.activeElement && document.activeElement.closest && document.activeElement.closest('#notizenEditor');
|
||||||
|
if(frisch && n && frisch.id === n.id && !ungespeichert && !tippt && frisch.geaendertAm !== basis) editorZeichnen();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Mail ablegen (Drag & Drop, Strg+V) ----------
|
||||||
|
function imNotizenBereich(){
|
||||||
|
const s = document.querySelector('.plan-section.is-active');
|
||||||
|
return typeof currentTab !== 'undefined' && currentTab === 'plan' && s && s.dataset.section === 'notizen' && !!app.project;
|
||||||
|
}
|
||||||
|
function ausMail(nutzlast){
|
||||||
|
const uid = projektUid();
|
||||||
|
if(!uid) return;
|
||||||
|
jetztSpeichern();
|
||||||
|
fetch(baseUrl() + '/projekt-notizen/aus-mail', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify(Object.assign({ uid }, nutzlast))
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if(!d.ok) throw new Error(d.error || 'Fehlgeschlagen');
|
||||||
|
neueNotizZeigen(d.notiz);
|
||||||
|
app.showToast('Notiz aus der Mail angelegt.');
|
||||||
|
}).catch(e => app.showToast('Mail konnte nicht übernommen werden: ' + e.message));
|
||||||
|
}
|
||||||
|
function ausDaten(dt){
|
||||||
|
const dateien = Array.from(dt.files || []);
|
||||||
|
const eml = dateien.find(f => /\.eml$/i.test(f.name || '') || f.type === 'message/rfc822');
|
||||||
|
if(eml){
|
||||||
|
if(eml.size > 5 * 1024 * 1024){ app.showToast('Die Mail ist zu groß (mehr als 5 MB).'); return; }
|
||||||
|
eml.text().then(t => ausMail({ eml: t }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(dateien.some(f => /\.msg$/i.test(f.name || ''))){
|
||||||
|
app.showToast('Outlook-Dateien (.msg) lassen sich nicht lesen. Bitte den Mailtext markieren und hineinziehen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text = (dt.getData('text/plain') || '').trim();
|
||||||
|
if(!text){
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = (dt.getData('text/html') || '').replace(/<(script|style)[\s\S]*?<\/\1>/gi, '');
|
||||||
|
text = (div.innerText || div.textContent || '').trim();
|
||||||
|
}
|
||||||
|
if(text) ausMail({ text });
|
||||||
|
}
|
||||||
|
const bereich = () => document.getElementById('planSection-notizen');
|
||||||
|
document.addEventListener('dragover', e => {
|
||||||
|
if(!imNotizenBereich() || !kannSchreiben() || !bereich().contains(e.target)) return;
|
||||||
|
if(e.target.closest && e.target.closest('#notizenEditor [contenteditable="true"], #notizTitel')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
bereich().classList.add('notizen-zieht');
|
||||||
|
});
|
||||||
|
document.addEventListener('dragleave', e => {
|
||||||
|
const b = bereich();
|
||||||
|
if(b && (!e.relatedTarget || !b.contains(e.relatedTarget))) b.classList.remove('notizen-zieht');
|
||||||
|
});
|
||||||
|
document.addEventListener('drop', e => {
|
||||||
|
const b = bereich();
|
||||||
|
if(b) b.classList.remove('notizen-zieht');
|
||||||
|
if(!imNotizenBereich() || !kannSchreiben() || !b.contains(e.target)) return;
|
||||||
|
// In das Textfeld gezogen: normal einfügen lassen, keine neue Notiz.
|
||||||
|
if(e.target.closest && e.target.closest('#notizenEditor [contenteditable="true"], #notizTitel')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
ausDaten(e.dataTransfer);
|
||||||
|
});
|
||||||
|
document.addEventListener('paste', e => {
|
||||||
|
if(!imNotizenBereich() || !kannSchreiben()) return;
|
||||||
|
const ziel = e.target;
|
||||||
|
if(ziel && ziel.closest && ziel.closest('input, textarea, select, [contenteditable="true"], .modal-bg')) return;
|
||||||
|
if(!e.clipboardData) return;
|
||||||
|
e.preventDefault();
|
||||||
|
ausDaten(e.clipboardData);
|
||||||
|
});
|
||||||
|
// Beim Verlassen der Seite noch speichern, was offen ist.
|
||||||
|
window.addEventListener('pagehide', () => { if(ungespeichert) jetztSpeichern(); });
|
||||||
|
})();
|
||||||
@@ -1,21 +1,10 @@
|
|||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Projektbereich — Leistungsphasen
|
// Projektbereich — Zuordnung Aufgabe → Leistungsphase
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Bereich "Leistungsphasen" (Eriks Wahl "B"): die Phasenbalken des
|
// Der frühere Reiter „Leistungsphasen“ (Ablaufbalken + Kachel je Phase) ist
|
||||||
// Terminplans (Aufgaben mit t.lph, balkenPhase() in load.js) als eigener
|
// auf Eriks Wunsch entfallen; an seiner Stelle steht „Notizen“. Geblieben ist
|
||||||
// Bereich statt als Zeilen zwischen den Aufgaben. Oben ein Ablaufbalken über
|
// nur aufgabenPhase(), die die Aufgabenliste (plan-tasks.js) und das
|
||||||
// alle Phasen mit Heute-Linie, darunter eine Kachel je Phase: Fortschritt
|
// Terminplan-PDF (pdf.js) nutzen.
|
||||||
// (aus dem Wochenbericht, p.lph), Zeitraum (aus dem Balken), Aufwand
|
|
||||||
// (Summe der Stunden ihrer Aufgaben) und die Aufgaben, die in ihrem
|
|
||||||
// Zeitraum beginnen — dieselbe Zuordnung wie die Gliederung im Terminplan
|
|
||||||
// (plan-build-gantt.js).
|
|
||||||
//
|
|
||||||
// Keine eigene Datenhaltung: Bearbeiten öffnet dasselbe Panel wie im
|
|
||||||
// Terminplan (openTodoModal() im Phasen-Modus), Fortschritt bleibt im
|
|
||||||
// Wochenbericht ("Ein Ort je Angabe").
|
|
||||||
//
|
|
||||||
// renderPlanPhasen() wird von renderPlanGantt() (plan-gantt.js) und beim
|
|
||||||
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) aufgerufen.
|
|
||||||
|
|
||||||
// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört —
|
// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört —
|
||||||
// der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die
|
// der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die
|
||||||
@@ -31,150 +20,3 @@ function aufgabenPhase(t, todos){
|
|||||||
.find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end));
|
.find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end));
|
||||||
return k ? balkenPhase(k) : '';
|
return k ? balkenPhase(k) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
(function(){
|
|
||||||
const app = window.PlanApp;
|
|
||||||
|
|
||||||
const LPH_NAMEN = {
|
|
||||||
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
|
|
||||||
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
|
|
||||||
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung',
|
|
||||||
'W+M': 'Wartung und Monitoring'
|
|
||||||
};
|
|
||||||
|
|
||||||
const kurzDatum = d => d ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) : '';
|
|
||||||
const langDatum = d => d ? formatDateDE(d) : '';
|
|
||||||
|
|
||||||
// phasenDaten: je Phase alles, was Ablaufbalken und Kachel brauchen.
|
|
||||||
// Rückgabe: Array, sortiert läuft → geplant → fertig, darin nach Beginn.
|
|
||||||
function phasenDaten(){
|
|
||||||
const p = app.project;
|
|
||||||
const plan = app.ensurePlanLocal();
|
|
||||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
|
||||||
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
|
|
||||||
|
|
||||||
// Phasen: beauftragte plus alle, die einen Balken haben.
|
|
||||||
const namen = LPHS.filter(l => beauftragt.includes(l) || todos.some(t => balkenPhase(t) === l));
|
|
||||||
const amtierend = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || null;
|
|
||||||
const laufend = typeof arbeitsPhase === 'function' ? arbeitsPhase(p, amtierend) : amtierend;
|
|
||||||
|
|
||||||
return namen.map(l => {
|
|
||||||
const idx = todos.findIndex(t => balkenPhase(t) === l);
|
|
||||||
const balken = idx >= 0 ? todos[idx] : null;
|
|
||||||
const start = balken ? parseDateISO(balken.start) : null;
|
|
||||||
const ende = balken ? parseDateISO(balken.end) : null;
|
|
||||||
const prozent = typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0;
|
|
||||||
const aufgaben = todos.filter(t => aufgabenPhase(t, todos) === l);
|
|
||||||
// Aufwand = Summe der Stunden ihrer Aufgaben (dieselbe Quelle wie die
|
|
||||||
// Auslastung, js/load/load.js), nicht mehr aus dem Honorar.
|
|
||||||
const stunden = Math.round(aufgaben.reduce((s, t) => s + (Number(t.aufwand) || 0), 0) * 10) / 10;
|
|
||||||
const status = prozent >= 100 ? 'fertig' : (l === laufend ? 'laeuft' : 'geplant');
|
|
||||||
return { l, idx, balken, start, ende, prozent, stunden, aufgaben, status,
|
|
||||||
beauftragt: !beauftragt.length || beauftragt.includes(l) };
|
|
||||||
}).sort((a, b) => {
|
|
||||||
const rang = { laeuft: 0, geplant: 1, fertig: 2 };
|
|
||||||
if(rang[a.status] !== rang[b.status]) return rang[a.status] - rang[b.status];
|
|
||||||
return LPHS.indexOf(a.l) - LPHS.indexOf(b.l);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ablaufHtml: der schmale Balken über alle Phasen mit Zeitraum. Phasen
|
|
||||||
// ohne Zeitraum fehlen hier (sie stehen als Kachel mit Hinweis darunter).
|
|
||||||
function ablaufHtml(daten){
|
|
||||||
const mit = daten.filter(d => d.start && d.ende && d.ende >= d.start)
|
|
||||||
.sort((a, b) => a.start - b.start);
|
|
||||||
if(!mit.length) return '';
|
|
||||||
const von = mit[0].start.getTime();
|
|
||||||
const bis = Math.max(...mit.map(d => d.ende.getTime()));
|
|
||||||
const spanne = Math.max(1, bis - von);
|
|
||||||
const pos = t => Math.min(100, Math.max(0, (t - von) / spanne * 100));
|
|
||||||
const heute = new Date(); heute.setHours(0, 0, 0, 0);
|
|
||||||
const heutePos = heute.getTime() >= von && heute.getTime() <= bis ? pos(heute.getTime()) : null;
|
|
||||||
const segmente = mit.map(d => {
|
|
||||||
const links = pos(d.start.getTime());
|
|
||||||
const breite = Math.max(1.5, pos(d.ende.getTime() + 86400000) - links);
|
|
||||||
const titel = `${d.l}: ${langDatum(d.start)} – ${langDatum(d.ende)}, ${d.prozent} %`;
|
|
||||||
return `<button type="button" class="phasen-ablauf-seg is-${d.status}" style="left:${links}%;width:${breite}%;--fort:${d.prozent}%"
|
|
||||||
title="${esc(titel)}" onclick="phasenKachelZeigen('${esc(d.l)}')"><span>${esc(breite > 6 ? d.l : d.l.replace('LPH ', ''))}</span></button>`;
|
|
||||||
}).join('');
|
|
||||||
const heuteHtml = heutePos === null ? '' : `<span class="phasen-ablauf-heute" style="left:${heutePos}%" title="heute"></span>`;
|
|
||||||
const monat = d => d.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' });
|
|
||||||
return `<div class="plan-panel phasen-ablauf">
|
|
||||||
<div class="phasen-kopf">Ablauf</div>
|
|
||||||
<div class="phasen-ablauf-spur">${segmente}${heuteHtml}</div>
|
|
||||||
<div class="phasen-ablauf-achse"><span>${esc(monat(mit[0].start))}</span>${heutePos === null ? '' : `<span class="phasen-ablauf-heute-text" style="left:${heutePos}%">heute, ${esc(kurzDatum(heute))}</span>`}<span>${esc(monat(new Date(bis)))}</span></div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// kachelHtml: eine Kachel je Phase.
|
|
||||||
function kachelHtml(d, locked){
|
|
||||||
const statusText = { laeuft: 'läuft', geplant: 'geplant', fertig: 'fertig' }[d.status];
|
|
||||||
const erledigt = d.aufgaben.filter(t => t.done).length;
|
|
||||||
const aufgabenText = d.aufgaben.length ? `${erledigt} von ${d.aufgaben.length} erledigt` : 'keine';
|
|
||||||
const zeitraum = d.start && d.ende
|
|
||||||
? `${d.start.getFullYear() === d.ende.getFullYear() ? kurzDatum(d.start) : langDatum(d.start)} – ${langDatum(d.ende)}`
|
|
||||||
: '<span class="phasen-leer">kein Zeitraum</span>';
|
|
||||||
const bearbeiten = locked ? ''
|
|
||||||
: (d.balken
|
|
||||||
? `<button class="secondary small" onclick="openTodoModal(${d.idx})">Bearbeiten</button>`
|
|
||||||
: `<button class="secondary small" onclick="addPlanPhase('${esc(d.l)}')">Zeitraum festlegen</button>`);
|
|
||||||
const ansehen = d.aufgaben.length
|
|
||||||
? `<button class="secondary small" onclick="phasenAufgabenZeigen('${esc(d.l)}')">Aufgaben ansehen</button>` : '';
|
|
||||||
return `<div class="phasen-kachel is-${d.status}" data-phase="${esc(d.l)}">
|
|
||||||
<div class="phasen-kachel-kopf">
|
|
||||||
<div class="phasen-ring" style="--fort:${d.prozent}%"><span>${d.prozent} %</span></div>
|
|
||||||
<div class="phasen-kachel-name">
|
|
||||||
<b>${esc(d.l)}</b>
|
|
||||||
<span>${esc(LPH_NAMEN[d.l] || '')}</span>
|
|
||||||
</div>
|
|
||||||
<span class="phasen-chip is-${d.status}">${statusText}</span>
|
|
||||||
</div>
|
|
||||||
${d.beauftragt ? '' : '<div class="phasen-hinweis">nicht beauftragt</div>'}
|
|
||||||
<div class="phasen-werte">
|
|
||||||
<span><small>Zeitraum</small><b>${zeitraum}</b></span>
|
|
||||||
<span><small>Aufwand</small><b>${d.stunden ? d.stunden.toLocaleString('de-DE') + ' h' : '<span class="phasen-leer">keine Stunden</span>'}</b></span>
|
|
||||||
<span><small>Aufgaben</small><b>${aufgabenText}</b></span>
|
|
||||||
</div>
|
|
||||||
${bearbeiten || ansehen ? `<div class="phasen-knoepfe">${bearbeiten}${ansehen}</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderPlanPhasen: baut den Bereich neu auf. Keine Parameter.
|
|
||||||
window.renderPlanPhasen = function(){
|
|
||||||
const el = document.getElementById('planPhasenWrap');
|
|
||||||
if(!el || !app.project) return;
|
|
||||||
const locked = app.isLocked();
|
|
||||||
const daten = phasenDaten();
|
|
||||||
if(!daten.length){
|
|
||||||
el.innerHTML = `<div class="plan-panel"><div class="plan-task-empty">${locked
|
|
||||||
? 'Für dieses Projekt sind keine Leistungsphasen hinterlegt.'
|
|
||||||
: 'Noch keine Leistungsphase. Unter „Einstellungen“ beauftragen oder über „+ Leistungsphase“ bzw. „Terminplan aus Honorar“ anlegen.'}</div></div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const summeH = daten.reduce((s, d) => s + d.stunden, 0);
|
|
||||||
const enden = daten.filter(d => d.ende).map(d => d.ende.getTime());
|
|
||||||
const laufend = daten.find(d => d.status === 'laeuft');
|
|
||||||
const teile = [daten.filter(d => d.beauftragt).length + ' beauftragt'];
|
|
||||||
if(laufend) teile.push('läuft: ' + laufend.l);
|
|
||||||
if(summeH) teile.push(summeH.toLocaleString('de-DE') + ' h in Aufgaben');
|
|
||||||
if(enden.length) teile.push('Ende voraussichtlich ' + langDatum(new Date(Math.max(...enden))));
|
|
||||||
el.innerHTML = `<div class="phasen-zusammenfassung">${esc(teile.join(' · '))}<span>Fortschritt aus dem Wochenbericht</span></div>
|
|
||||||
${ablaufHtml(daten)}
|
|
||||||
<div class="phasen-raster">${daten.map(d => kachelHtml(d, locked)).join('')}</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// phasenKachelZeigen: Klick auf ein Segment im Ablauf — Kachel ins Bild
|
|
||||||
// holen und kurz hervorheben.
|
|
||||||
window.phasenKachelZeigen = function(l){
|
|
||||||
const k = document.querySelector(`#planPhasenWrap .phasen-kachel[data-phase="${CSS.escape(l)}"]`);
|
|
||||||
if(!k) return;
|
|
||||||
k.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
k.classList.remove('ist-markiert'); void k.offsetWidth; k.classList.add('ist-markiert');
|
|
||||||
};
|
|
||||||
|
|
||||||
// phasenAufgabenZeigen: in den Bereich Aufgaben, gefiltert auf diese Phase.
|
|
||||||
window.phasenAufgabenZeigen = function(l){
|
|
||||||
if(typeof setzeAufgabenFilter === 'function') setzeAufgabenFilter(l);
|
|
||||||
showPlanSection('aufgaben');
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
|
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
|
||||||
// Navigationsleiste. Muss zu den data-section-Attributen in
|
// Navigationsleiste. Muss zu den data-section-Attributen in
|
||||||
// App.html passen.
|
// App.html passen.
|
||||||
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'phasen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'notizen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
||||||
|
|
||||||
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
|
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
|
||||||
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
|
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
|
||||||
@@ -63,8 +63,8 @@
|
|||||||
|
|
||||||
if(!app.project) return;
|
if(!app.project) return;
|
||||||
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
|
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
|
||||||
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
|
|
||||||
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
|
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||||
|
if(key === 'notizen' && typeof renderPlanNotizen === 'function') renderPlanNotizen();
|
||||||
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
|
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
|
||||||
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
|
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
|
||||||
if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings();
|
if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings();
|
||||||
|
|||||||
@@ -99,8 +99,21 @@ window.PlanApp = {
|
|||||||
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
|
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
|
||||||
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
|
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
|
||||||
// gefunden und gerendert wurde.
|
// gefunden und gerendert wurde.
|
||||||
|
// startAufgabe: Link mit ?aufgabe=<id> (z. B. aus einer Teams-Nachricht):
|
||||||
|
// diese Aufgabe nach dem Öffnen des Projekts gleich im Aufgaben-Panel
|
||||||
|
// zeigen — einmalig, beim ersten passenden Öffnen.
|
||||||
|
let startAufgabe = '';
|
||||||
|
try { startAufgabe = new URLSearchParams(window.location.search).get('aufgabe') || ''; } catch(e){ /* egal */ }
|
||||||
|
|
||||||
window.openPlanView = function(){
|
window.openPlanView = function(){
|
||||||
app.showPlanNav();
|
app.showPlanNav();
|
||||||
|
// Ein Aufgaben-Link stammt aus der Sitzung, in der zugewiesen wurde. Ist
|
||||||
|
// inzwischen eine neuere da, dort öffnen — Aufgaben behalten über den
|
||||||
|
// Sitzungswechsel ihre id (ensureCurrentWeekRollover()).
|
||||||
|
if(startAufgabe && app.weekLabel !== SANDBOX_WEEK_LABEL && Array.isArray(db.wochenListe) && db.wochenListe.length){
|
||||||
|
const juengste = db.wochenListe[db.wochenListe.length - 1].label;
|
||||||
|
if(juengste && juengste !== app.weekLabel) app.weekLabel = juengste;
|
||||||
|
}
|
||||||
|
|
||||||
// Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
|
// Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
|
||||||
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
|
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
|
||||||
@@ -143,6 +156,19 @@ window.PlanApp = {
|
|||||||
}
|
}
|
||||||
app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
|
app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
|
||||||
if(!app.week){
|
if(!app.week){
|
||||||
|
// Eine ältere Sitzung, die beim Start nicht mitgeladen wurde (nur die
|
||||||
|
// jüngsten, js/store.js): einzeln nachholen und dann neu öffnen.
|
||||||
|
const bekannt = (db.wochenListe || []).some(w => w.label === app.weekLabel);
|
||||||
|
if(bekannt && typeof sitzungSicherLaden === 'function'){
|
||||||
|
const t = document.getElementById('planSubtitle');
|
||||||
|
if(t) t.textContent = 'Sitzung ' + app.weekLabel + ' wird geladen…';
|
||||||
|
const label = app.weekLabel;
|
||||||
|
sitzungSicherLaden(label).then(w => {
|
||||||
|
if(w && app.weekLabel === label && currentTab === 'plan') showTab('plan');
|
||||||
|
else if(!w) planFail('Nicht gefunden', 'Sitzung "' + label + '" konnte nicht geladen werden.');
|
||||||
|
}).catch(() => planFail('Nicht gefunden', 'Sitzung "' + label + '" konnte nicht geladen werden.'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
|
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -258,12 +284,10 @@ window.PlanApp = {
|
|||||||
|
|
||||||
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
|
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
|
||||||
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
|
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
|
||||||
// Terminplan (…Btn), Aufgaben (…BtnList) und Leistungsphasen (…BtnPhasen),
|
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html.
|
||||||
// siehe App.html.
|
|
||||||
const locked = isLocked();
|
const locked = isLocked();
|
||||||
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
|
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
|
||||||
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList',
|
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList']
|
||||||
'honorarBtnPhasen', 'addPhaseBtnPhasen']
|
|
||||||
.forEach(id => {
|
.forEach(id => {
|
||||||
const btn = document.getElementById(id);
|
const btn = document.getElementById(id);
|
||||||
if(btn) btn.style.display = locked ? 'none' : '';
|
if(btn) btn.style.display = locked ? 'none' : '';
|
||||||
@@ -286,6 +310,22 @@ window.PlanApp = {
|
|||||||
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
|
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
|
||||||
// Bereiche befüllt sind, wird einer davon eingeblendet.
|
// Bereiche befüllt sind, wird einer davon eingeblendet.
|
||||||
app.initPlanSections();
|
app.initPlanSections();
|
||||||
|
|
||||||
|
if(startAufgabe){
|
||||||
|
const id = startAufgabe;
|
||||||
|
startAufgabe = '';
|
||||||
|
// Adresszeile auf die tatsächlich geöffnete Sitzung bringen (der Link
|
||||||
|
// nannte evtl. eine ältere, siehe openPlanView()).
|
||||||
|
if(typeof updateRouteUrl === 'function') updateRouteUrl('plan');
|
||||||
|
const idx = (plan.todos || []).findIndex(t => String(t.id) === id);
|
||||||
|
if(idx < 0){ showToast('Die Aufgabe aus dem Link gibt es in diesem Projekt nicht mehr.'); return; }
|
||||||
|
if(location.hash !== '#aufgaben') showPlanSection('aufgaben');
|
||||||
|
// Panel erst nach dem Zeichnen öffnen (das Gantt misst seine Breite).
|
||||||
|
setTimeout(() => {
|
||||||
|
if(isLocked()){ showToast('Aufgabe: ' + (plan.todos[idx].title || '') + ' — diese Sitzung ist gesperrt.'); return; }
|
||||||
|
openTodoModal(idx);
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
|
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
|
||||||
|
|||||||
@@ -24,9 +24,82 @@
|
|||||||
// gerufen. Keine Parameter, keine Rueckgabe.
|
// gerufen. Keine Parameter, keine Rueckgabe.
|
||||||
function renderSettings(){
|
function renderSettings(){
|
||||||
renderSettingsAccount();
|
renderSettingsAccount();
|
||||||
|
renderSettingsKalender();
|
||||||
renderSettingsAiKeys();
|
renderSettingsAiKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Kalender in Outlook ----------
|
||||||
|
// Geheime Abo-Adresse je Konto (GET/POST /mein-kalender). Ohne Adresse ein
|
||||||
|
// Knopf zum Einrichten; mit Adresse: Kopieren, „In Outlook öffnen“ (webcal:),
|
||||||
|
// Anleitung, neue Adresse (alte wird ungültig) und Abschalten.
|
||||||
|
let kalenderUrlAktuell = null;
|
||||||
|
async function renderSettingsKalender(){
|
||||||
|
const el = document.getElementById('settingsKalender');
|
||||||
|
if(!el) return;
|
||||||
|
if(kalenderUrlAktuell === null){
|
||||||
|
try {
|
||||||
|
const d = await fetch(baseUrl() + '/mein-kalender').then(r => r.json());
|
||||||
|
kalenderUrlAktuell = (d && d.url) || '';
|
||||||
|
} catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
|
||||||
|
}
|
||||||
|
const url = kalenderUrlAktuell;
|
||||||
|
if(!currentUser || !currentUser.personId){
|
||||||
|
el.innerHTML = '<p class="settings-warn">Ohne Personen-Zuordnung enthält der Kalender nur Ihre Abwesenheiten.</p>';
|
||||||
|
} else el.innerHTML = '';
|
||||||
|
if(!url){
|
||||||
|
el.innerHTML += '<button type="button" onclick="kalenderAdresse(false)">Kalender-Adresse erzeugen</button>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const webcal = url.replace(/^https?:/, 'webcal:');
|
||||||
|
el.innerHTML += `
|
||||||
|
<div class="kalender-adresse">
|
||||||
|
<input type="text" id="kalenderUrlFeld" readonly value="${esc(url)}" onclick="this.select()" aria-label="Kalender-Adresse">
|
||||||
|
<button type="button" class="secondary" onclick="kalenderKopieren()">Kopieren</button>
|
||||||
|
<a class="button-link" href="${esc(webcal)}">In Outlook öffnen</a>
|
||||||
|
</div>
|
||||||
|
<details class="kalender-anleitung">
|
||||||
|
<summary>So abonnieren Sie den Kalender</summary>
|
||||||
|
<ol>
|
||||||
|
<li><b>Outlook am PC (klassisch):</b> „In Outlook öffnen“ klicken und bestätigen — oder
|
||||||
|
Kalender → <i>Kalender hinzufügen</i> → <i>Aus dem Internet</i> → Adresse einfügen.</li>
|
||||||
|
<li><b>Neues Outlook / Outlook im Browser:</b> Kalender → <i>Kalender hinzufügen</i> →
|
||||||
|
<i>Aus dem Internet abonnieren</i> → Adresse einfügen. Das funktioniert nur, wenn der
|
||||||
|
Server aus dem Internet erreichbar ist — Microsoft holt den Kalender von außen ab.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="hint">Die Adresse ist Ihr persönlicher Schlüssel: Wer sie kennt, sieht Ihre Termine.
|
||||||
|
Nicht weitergeben. Bei Verdacht „Neue Adresse“ wählen — die alte gilt dann nicht mehr.</p>
|
||||||
|
</details>
|
||||||
|
<div class="kalender-aktionen">
|
||||||
|
<button type="button" class="secondary small" onclick="kalenderAdresse(false, true)">Neue Adresse</button>
|
||||||
|
<button type="button" class="secondary small" onclick="kalenderAdresse(true)">Abschalten</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kalenderAdresse(aus, nachfragen){
|
||||||
|
if(aus && !confirm('Kalender-Abo abschalten? Outlook zeigt danach keine neuen Termine mehr.')) return;
|
||||||
|
if(nachfragen && !confirm('Neue Adresse erzeugen? Die bisherige gilt dann nicht mehr — in Outlook muss der Kalender neu abonniert werden.')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(baseUrl() + '/mein-kalender', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify({ aus: !!aus })
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if(!r.ok || !d.ok) throw new Error(d.error || 'Fehler');
|
||||||
|
kalenderUrlAktuell = d.url || '';
|
||||||
|
renderSettingsKalender();
|
||||||
|
showSettingsNote(aus ? 'Kalender-Abo abgeschaltet.' : 'Kalender-Adresse erzeugt.');
|
||||||
|
} catch(e){ alert('Das hat nicht geklappt: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function kalenderKopieren(){
|
||||||
|
const feld = document.getElementById('kalenderUrlFeld');
|
||||||
|
if(!feld) return;
|
||||||
|
const fertig = () => showSettingsNote('Adresse kopiert.');
|
||||||
|
if(navigator.clipboard && window.isSecureContext){
|
||||||
|
navigator.clipboard.writeText(feld.value).then(fertig).catch(() => { feld.select(); document.execCommand('copy'); fertig(); });
|
||||||
|
} else { feld.select(); document.execCommand('copy'); fertig(); }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Mein Konto ----------
|
// ---------- Mein Konto ----------
|
||||||
|
|
||||||
// renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an.
|
// renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an.
|
||||||
@@ -234,6 +307,8 @@ function putMyAiKey(provider, patch){
|
|||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
}).then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
|
}).then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
|
||||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
|
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
|
||||||
|
// "Termin aus Mail" ist nur mit eingerichteter KI aktiv — Stand neu holen.
|
||||||
|
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
|
||||||
return json;
|
return json;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-9
@@ -541,6 +541,7 @@ function liveEreignis(d){
|
|||||||
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
|
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
|
||||||
if(d.typ === 'neu') return liveAllesNeu();
|
if(d.typ === 'neu') return liveAllesNeu();
|
||||||
if(d.typ === 'personen') return personenNachladen();
|
if(d.typ === 'personen') return personenNachladen();
|
||||||
|
if(d.typ === 'notizen' && typeof notizenLiveAenderung === 'function') return notizenLiveAenderung(d.uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
|
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
|
||||||
@@ -596,7 +597,13 @@ function liveWocheNachladen(label, geloescht){
|
|||||||
// Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach.
|
// Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach.
|
||||||
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
|
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
|
||||||
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label);
|
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label);
|
||||||
if(!w) return liveAllesNeu();
|
if(!w){
|
||||||
|
// Eine ältere, hier nicht geladene Sitzung (z. B. entsperrt): nur ihren
|
||||||
|
// Eintrag in der Sitzungsliste nachziehen, nicht alles neu laden.
|
||||||
|
const kurz = (db.wochenListe || []).find(x => x.label === label);
|
||||||
|
if(kurz) return wocheVomServer(label).then(f => { if(f) Object.assign(kurz, sitzungKurz(f)); ansichtNachBearbeitung(); }).catch(() => {});
|
||||||
|
return liveAllesNeu();
|
||||||
|
}
|
||||||
wocheVomServer(label).then(frisch => {
|
wocheVomServer(label).then(frisch => {
|
||||||
if(!frisch) return liveAllesNeu();
|
if(!frisch) return liveAllesNeu();
|
||||||
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
|
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
|
||||||
@@ -648,6 +655,102 @@ function handleWeekConflict(week, err){
|
|||||||
else load();
|
else load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sitzungen nach Bedarf (Eriks Wunsch: beim Start nur die aktuelle und drei
|
||||||
|
// davor; im Verlauf „Weitere laden“ holt je fünf ältere nach)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// db.weeks hält nur die GELADENEN Sitzungen, db.wochenListe ALLE schlank
|
||||||
|
// ({id, label, date, locked, updatedAt}, älteste zuerst). Wer eine nicht
|
||||||
|
// geladene Sitzung braucht (Admin → Sitzungen, Deep-Link auf ein Projekt
|
||||||
|
// einer älteren Sitzung), holt sie über sitzungSicherLaden().
|
||||||
|
const SITZUNGEN_START = 4;
|
||||||
|
const SITZUNGEN_NACHLADEN = 5;
|
||||||
|
|
||||||
|
function wocheAbsichern(w){
|
||||||
|
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
|
||||||
|
if(!Array.isArray(w.people)) w.people = [];
|
||||||
|
if(!Array.isArray(w.projects)) w.projects = [];
|
||||||
|
if(!w.attendance) w.attendance = {};
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
function sitzungKurz(w){
|
||||||
|
return { id: w.id, label: w.label, date: w.date || '', locked: !!w.locked, updatedAt: w.updatedAt || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// sitzungKurzAbgleichen: den schlanken Eintrag einer Sitzung in
|
||||||
|
// db.wochenListe auf den Stand des vollen Objekts bringen (Sperre, Revision).
|
||||||
|
function sitzungKurzAbgleichen(w){
|
||||||
|
const k = (db.wochenListe || []).find(x => x.label === w.label);
|
||||||
|
if(k) Object.assign(k, sitzungKurz(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
// wochenEinfuegen: geladene Sitzungen in db.weeks aufnehmen (ohne Doppel),
|
||||||
|
// sortieren und als Basis fürs Zusammenführen merken.
|
||||||
|
function wochenEinfuegen(liste){
|
||||||
|
(liste || []).forEach(w => {
|
||||||
|
if(!w || typeof w.label !== 'string' || db.weeks.some(x => x.label === w.label)) return;
|
||||||
|
db.weeks.push(wocheAbsichern(w));
|
||||||
|
basisMerken(w);
|
||||||
|
});
|
||||||
|
db.weeks.sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// aeltereSitzungenAnzahl: wie viele Sitzungen älter als die älteste geladene
|
||||||
|
// noch nicht geladen sind.
|
||||||
|
function aeltereSitzungenAnzahl(){
|
||||||
|
const liste = db.wochenListe || [];
|
||||||
|
if(!db.weeks.length) return 0;
|
||||||
|
const aelteste = db.weeks[0].label;
|
||||||
|
const idx = liste.findIndex(w => w.label === aelteste);
|
||||||
|
return idx < 0 ? 0 : idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// weitereSitzungenLaden: holt die nächsten älteren Sitzungen und zeichnet
|
||||||
|
// danach neu — die ganze Ansicht, oder nur, was nachher (Name einer globalen
|
||||||
|
// Funktion) zeichnet. Der Wochenbericht nutzt das, damit ein angefangener
|
||||||
|
// Kommentar nicht durch ein Neuzeichnen verloren geht. Rückgabe: Promise.
|
||||||
|
let weitereLaufen = null;
|
||||||
|
function weitereSitzungenLaden(anzahl, nachher){
|
||||||
|
if(weitereLaufen) return weitereLaufen;
|
||||||
|
if(!db.weeks.length || aeltereSitzungenAnzahl() === 0) return Promise.resolve();
|
||||||
|
const vor = db.weeks[0].label;
|
||||||
|
weitereLaufen = fetch(baseUrl() + '/weeks-aelter?vor=' + encodeURIComponent(vor) + '&anzahl=' + (anzahl || SITZUNGEN_NACHLADEN))
|
||||||
|
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||||
|
.then(d => {
|
||||||
|
wochenEinfuegen(d.weeks);
|
||||||
|
if(nachher && typeof window[nachher] === 'function'){ window[nachher](); return; }
|
||||||
|
ansichtAuffrischen();
|
||||||
|
// Kommentar-Verlauf im Projekt-Dashboard hängt an renderPlanInfo().
|
||||||
|
if(typeof currentTab !== 'undefined' && currentTab === 'plan' && typeof renderPlanInfo === 'function') renderPlanInfo();
|
||||||
|
})
|
||||||
|
.catch(err => { console.error('Ältere Sitzungen laden fehlgeschlagen:', err); alert('Ältere Sitzungen konnten nicht geladen werden.'); })
|
||||||
|
.finally(() => { weitereLaufen = null; });
|
||||||
|
return weitereLaufen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// weitereLadenKnopf: Knopf „Weitere laden“ für einen Verlauf — nur, solange
|
||||||
|
// es ältere, nicht geladene Sitzungen gibt. Parameter: nachher (optional,
|
||||||
|
// siehe weitereSitzungenLaden()). Rückgabe: HTML-String ('' sonst).
|
||||||
|
function weitereLadenKnopf(nachher){
|
||||||
|
const n = aeltereSitzungenAnzahl();
|
||||||
|
if(!n) return '';
|
||||||
|
const k = Math.min(n, SITZUNGEN_NACHLADEN);
|
||||||
|
return `<button type="button" class="secondary small verlauf-weitere" onmousedown="event.preventDefault()" onclick="event.stopPropagation(); weitereSitzungenLaden(0${nachher ? ", '" + nachher + "'" : ''})"
|
||||||
|
title="Noch ${n} ältere Sitzung${n === 1 ? '' : 'en'} am Server">Weitere laden (${k} ${k === 1 ? 'Sitzung' : 'Sitzungen'})</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sitzungSicherLaden: liefert eine Sitzung, lädt sie bei Bedarf einzeln
|
||||||
|
// nach. Rückgabe: Promise<Woche|null>.
|
||||||
|
function sitzungSicherLaden(label){
|
||||||
|
const da = db.weeks.find(w => w.label === label);
|
||||||
|
if(da) return Promise.resolve(da);
|
||||||
|
return wocheVomServer(label).then(w => {
|
||||||
|
if(!w) return null;
|
||||||
|
wochenEinfuegen([w]);
|
||||||
|
return db.weeks.find(x => x.label === label) || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die
|
// reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die
|
||||||
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
|
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
|
||||||
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
|
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
|
||||||
@@ -745,7 +848,9 @@ function load(silent){
|
|||||||
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
|
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const url = baseUrl() + '/load-all';
|
// Nur die jüngsten SITZUNGEN_START Sitzungen (aktuelle + drei davor);
|
||||||
|
// ältere holt weitereSitzungenLaden() bei Bedarf (Eriks Wunsch).
|
||||||
|
const url = baseUrl() + '/load-all?wochen=' + SITZUNGEN_START;
|
||||||
console.log('Loading from', url);
|
console.log('Loading from', url);
|
||||||
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
|
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
|
||||||
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
|
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
|
||||||
@@ -775,13 +880,9 @@ function load(silent){
|
|||||||
|
|
||||||
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
|
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||||
.filter(w => w && typeof w.label === 'string')
|
.filter(w => w && typeof w.label === 'string')
|
||||||
.map(w => {
|
.map(wocheAbsichern);
|
||||||
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
|
// Alle Sitzungen schlank (Name, Datum, Sperre) — auch die nicht geladenen.
|
||||||
if(!Array.isArray(w.people)) w.people = [];
|
db.wochenListe = Array.isArray(data.wochenListe) ? data.wochenListe : db.weeks.map(sitzungKurz);
|
||||||
if(!Array.isArray(w.projects)) w.projects = [];
|
|
||||||
if(!w.attendance) w.attendance = {};
|
|
||||||
return w;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Server-Stand als Basis für späteres Zusammenführen merken.
|
// Server-Stand als Basis für späteres Zusammenführen merken.
|
||||||
wochenBasis.clear();
|
wochenBasis.clear();
|
||||||
|
|||||||
+17
-3
@@ -1187,8 +1187,19 @@ function verlaufBeteiligte(version){
|
|||||||
// die eine geänderte Zahl sonst nicht.
|
// die eine geänderte Zahl sonst nicht.
|
||||||
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
|
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
|
||||||
// gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML.
|
// gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML.
|
||||||
|
// verlaufOffen: project_uids, deren Verlauf aufgeklappt ist — übersteht das
|
||||||
|
// Neuzeichnen nach „Weitere laden“ (sonst klappte er dabei wieder zu).
|
||||||
|
const verlaufOffen = new Set();
|
||||||
|
function verlaufUmschalten(uid, offen){ if(offen) verlaufOffen.add(uid); else verlaufOffen.delete(uid); }
|
||||||
|
|
||||||
function renderProjektVerlauf(p, weekLabel, locked){
|
function renderProjektVerlauf(p, weekLabel, locked){
|
||||||
const versionen = projectVersionsForUid(p.projectUid);
|
const versionen = projectVersionsForUid(p.projectUid);
|
||||||
|
// Gezeigt wird, was geladen ist (anfangs die drei Sitzungen vor der
|
||||||
|
// aktuellen); „Weitere laden“ holt je fünf ältere (js/store.js).
|
||||||
|
const weitere = typeof weitereLadenKnopf === 'function' ? weitereLadenKnopf() : '';
|
||||||
|
const uidAttr = esc(String(p.projectUid || '')).replace(/'/g, ''');
|
||||||
|
const offen = verlaufOffen.has(p.projectUid) ? ' open' : '';
|
||||||
|
const umschalten = ` ontoggle="verlaufUmschalten('${uidAttr}', this.open)"`;
|
||||||
// Neueste zuerst, damit links die jüngste Vergangenheit steht.
|
// Neueste zuerst, damit links die jüngste Vergangenheit steht.
|
||||||
//
|
//
|
||||||
// Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im
|
// Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im
|
||||||
@@ -1199,13 +1210,15 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
|||||||
const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse();
|
const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse();
|
||||||
|
|
||||||
if(frueher.length === 0){
|
if(frueher.length === 0){
|
||||||
return `<details class="timeline-block">
|
return `<details class="timeline-block"${offen}${umschalten}>
|
||||||
<summary onmousedown="event.preventDefault()">
|
<summary onmousedown="event.preventDefault()">
|
||||||
<span class="comment-history-caret">▶</span>
|
<span class="comment-history-caret">▶</span>
|
||||||
<span>Verlauf</span>
|
<span>Verlauf</span>
|
||||||
<span class="comment-history-count zero">0</span>
|
<span class="comment-history-count zero">0</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="timeline-body"><span class="hint" style="margin:0">Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.</span></div>
|
<div class="timeline-body"><span class="hint" style="margin:0">${weitere
|
||||||
|
? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
|
||||||
|
: 'Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.'}</span>${weitere}</div>
|
||||||
</details>`;
|
</details>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1276,7 +1289,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
|||||||
</article>`;
|
</article>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `<details class="timeline-block">
|
return `<details class="timeline-block"${offen}${umschalten}>
|
||||||
<summary onmousedown="event.preventDefault()">
|
<summary onmousedown="event.preventDefault()">
|
||||||
<span class="comment-history-caret">▶</span>
|
<span class="comment-history-caret">▶</span>
|
||||||
<span>Verlauf</span>
|
<span>Verlauf</span>
|
||||||
@@ -1284,6 +1297,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
|||||||
</summary>
|
</summary>
|
||||||
<div class="timeline-body">
|
<div class="timeline-body">
|
||||||
<div class="timeline-rail">${kacheln}</div>
|
<div class="timeline-rail">${kacheln}</div>
|
||||||
|
${weitere ? `<div class="verlauf-weitere-zeile">${weitere}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</details>`;
|
</details>`;
|
||||||
}
|
}
|
||||||
+4
-1
@@ -138,7 +138,9 @@ function unlockCurrentWeek(){ unlockWeek(currentWeek()); }
|
|||||||
// üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich
|
// üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich
|
||||||
// darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe.
|
// darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe.
|
||||||
function deleteWeekById(weekId, nachDelete){
|
function deleteWeekById(weekId, nachDelete){
|
||||||
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
|
// Gezählt werden ALLE Sitzungen, nicht nur die geladenen (db.wochenListe).
|
||||||
|
const gesamt = (db.wochenListe && db.wochenListe.length) || db.weeks.length;
|
||||||
|
if(gesamt<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
|
||||||
const w = db.weeks.find(x => x.id === weekId);
|
const w = db.weeks.find(x => x.id === weekId);
|
||||||
if(!w) return;
|
if(!w) return;
|
||||||
if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return;
|
if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return;
|
||||||
@@ -151,6 +153,7 @@ function deleteWeekById(weekId, nachDelete){
|
|||||||
console.log('Woche gelöscht:', w.label);
|
console.log('Woche gelöscht:', w.label);
|
||||||
const warAktuell = (weekId === db.currentWeek);
|
const warAktuell = (weekId === db.currentWeek);
|
||||||
db.weeks = db.weeks.filter(x=>x.id!==weekId);
|
db.weeks = db.weeks.filter(x=>x.id!==weekId);
|
||||||
|
if(Array.isArray(db.wochenListe)) db.wochenListe = db.wochenListe.filter(x => x.label !== w.label);
|
||||||
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||||
// Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus
|
// Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus
|
||||||
// irgendeinem Grund gar keine mehr existierende), auf die jüngste
|
// irgendeinem Grund gar keine mehr existierende), auf die jüngste
|
||||||
|
|||||||
@@ -361,7 +361,6 @@ function aktuellesBerichtProjekt(w){
|
|||||||
// Parameter: uid (project_uid). Keine Rückgabe.
|
// Parameter: uid (project_uid). Keine Rückgabe.
|
||||||
function waehleBerichtProjekt(uid){
|
function waehleBerichtProjekt(uid){
|
||||||
berichtAuswahl = uid;
|
berichtAuswahl = uid;
|
||||||
berichtVerlaufAlle = false; // neues Projekt zeigt wieder nur die jüngsten Einträge
|
|
||||||
renderWochenbericht();
|
renderWochenbericht();
|
||||||
const detail = document.getElementById('berichtDetail');
|
const detail = document.getElementById('berichtDetail');
|
||||||
if(detail) detail.scrollIntoView({ block: 'nearest' });
|
if(detail) detail.scrollIntoView({ block: 'nearest' });
|
||||||
@@ -742,10 +741,9 @@ function renderBerichtDetail(p, w, prevWeek, locked){
|
|||||||
// jedem Eintrag alle Werte zu wiederholen. Keine neue Datenhaltung — jede
|
// jedem Eintrag alle Werte zu wiederholen. Keine neue Datenhaltung — jede
|
||||||
// Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()).
|
// Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()).
|
||||||
|
|
||||||
const BERICHT_VERLAUF_KURZ = 3;
|
// Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
|
||||||
// berichtVerlaufAlle: "Alle N anzeigen" aufgeklappt; waehleBerichtProjekt()
|
// aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
|
||||||
// setzt es beim Projektwechsel zurück.
|
// js/store.js) und zeichnet nur diesen Abschnitt neu (berichtVerlaufNeu()).
|
||||||
let berichtVerlaufAlle = false;
|
|
||||||
|
|
||||||
// berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende)
|
// berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende)
|
||||||
function berichtKurzKw(label){
|
function berichtKurzKw(label){
|
||||||
@@ -828,11 +826,12 @@ function renderBerichtVerlauf(p, w, locked){
|
|||||||
const gText = g || 'alle Gewerke';
|
const gText = g || 'alle Gewerke';
|
||||||
let inhalt;
|
let inhalt;
|
||||||
if(eintraege.length === 0){
|
if(eintraege.length === 0){
|
||||||
inhalt = `<div class="hint" style="margin:0">${sitzungen === 0
|
inhalt = `<div class="hint" style="margin:0">${sitzungen === 0 && !aeltereSitzungenAnzahl()
|
||||||
? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.'
|
? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.'
|
||||||
|
: sitzungen === 0 ? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
|
||||||
: 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}</div>`;
|
: 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}</div>`;
|
||||||
} else {
|
} else {
|
||||||
const zeigen = berichtVerlaufAlle ? eintraege : eintraege.slice(0, BERICHT_VERLAUF_KURZ);
|
const zeigen = eintraege;
|
||||||
inhalt = `<ol class="bv-liste">${zeigen.map((e, i) => {
|
inhalt = `<ol class="bv-liste">${zeigen.map((e, i) => {
|
||||||
const datum = berichtVerlaufDatum(e.at);
|
const datum = berichtVerlaufDatum(e.at);
|
||||||
const wer = [e.autor, datum].filter(Boolean).join(', ');
|
const wer = [e.autor, datum].filter(Boolean).join(', ');
|
||||||
@@ -854,9 +853,7 @@ function renderBerichtVerlauf(p, w, locked){
|
|||||||
</li>`;
|
</li>`;
|
||||||
}).join('')}</ol>`;
|
}).join('')}</ol>`;
|
||||||
}
|
}
|
||||||
const mehr = eintraege.length > BERICHT_VERLAUF_KURZ
|
const mehr = weitereLadenKnopf('berichtVerlaufNeu');
|
||||||
? `<button type="button" class="bv-mehr" onclick="berichtVerlaufUmschalten()">${berichtVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + eintraege.length + ' anzeigen'}</button>`
|
|
||||||
: '';
|
|
||||||
const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare';
|
const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare';
|
||||||
return `<section class="bericht-verlauf" id="berichtVerlauf" aria-label="Verlauf der Vorwochen">
|
return `<section class="bericht-verlauf" id="berichtVerlauf" aria-label="Verlauf der Vorwochen">
|
||||||
<div class="bv-kopf">
|
<div class="bv-kopf">
|
||||||
@@ -869,10 +866,9 @@ function renderBerichtVerlauf(p, w, locked){
|
|||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// berichtVerlaufUmschalten: "Alle N anzeigen" / "Weniger anzeigen". Zeichnet
|
// berichtVerlaufNeu: zeichnet nur den Verlauf neu (nach „Weitere laden“),
|
||||||
// nur den Verlauf neu, damit ein angefangener Kommentar unberührt bleibt.
|
// damit ein angefangener Kommentar unberührt bleibt.
|
||||||
function berichtVerlaufUmschalten(){
|
function berichtVerlaufNeu(){
|
||||||
berichtVerlaufAlle = !berichtVerlaufAlle;
|
|
||||||
const w = currentWeek();
|
const w = currentWeek();
|
||||||
const p = w && aktuellesBerichtProjekt(w);
|
const p = w && aktuellesBerichtProjekt(w);
|
||||||
const el = document.getElementById('berichtVerlauf');
|
const el = document.getElementById('berichtVerlauf');
|
||||||
|
|||||||
+146
-75
@@ -1522,11 +1522,51 @@ html[data-theme="dark"] .modal-bg.als-panel { background: rgba(0,0,0,.32); }
|
|||||||
.pd-loeschen:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
|
.pd-loeschen:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
|
||||||
.pd-loeschen[hidden] { display: none; }
|
.pd-loeschen[hidden] { display: none; }
|
||||||
/* Aufwand einer Aufgabe in Stunden (zählt zur Auslastung). */
|
/* Aufwand einer Aufgabe in Stunden (zählt zur Auslastung). */
|
||||||
.pd-aufwand { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 400; margin-top: 4px; }
|
/* Aufwand im Aufgaben-Panel: Kopf, Stepper (−, Zahl, „Std.“, +),
|
||||||
.pd-aufwand > span:first-child { font-weight: 600; color: var(--text-muted); font-size: 12px; }
|
Schnellwahl als kleine Kapseln, Umrechnung in Arbeitstage darunter.
|
||||||
.pd-aufwand > span:last-child { color: var(--text-muted); font-size: 12px; }
|
Knöpfe tragen eigene Klassen bzw. stehen in eigenen Containern, damit die
|
||||||
.pd-aufwand input { width: 96px; padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--r-md); font: inherit; font-size: 13px; color: var(--text); background: var(--surface); text-align: right; }
|
allgemeine (Glas-)Knopfregel sie nicht akzentblau färbt. */
|
||||||
.pd-aufwand input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,113,227,.18); }
|
.pd-aufwand { display: grid; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
|
||||||
|
.pd-aufwand-kopf { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.pd-aufwand-kopf label { font-size: 12px; font-weight: 600; color: var(--text-muted); }
|
||||||
|
.pd-aufwand-kopf span { font-size: 11.5px; color: var(--text-subtle); }
|
||||||
|
.pd-aufwand-zeile { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.pd-stepper {
|
||||||
|
display: inline-flex; align-items: center; height: 34px; border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-pill); background: var(--surface-sunk); overflow: hidden;
|
||||||
|
transition: border-color var(--dur, 150ms), box-shadow var(--dur, 150ms);
|
||||||
|
}
|
||||||
|
.pd-stepper:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||||
|
.pd-stepper .pd-stepper-knopf {
|
||||||
|
all: unset; box-sizing: border-box; width: 32px; height: 100%; display: grid; place-items: center;
|
||||||
|
font-size: 16px; color: var(--text-muted); cursor: pointer;
|
||||||
|
}
|
||||||
|
.pd-stepper .pd-stepper-knopf:hover { background: var(--seg-hover); color: var(--text); }
|
||||||
|
.pd-stepper .pd-stepper-knopf:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
.pd-stepper input {
|
||||||
|
width: 52px; border: 0; background: transparent; padding: 0; text-align: right;
|
||||||
|
font: inherit; font-size: 15px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums;
|
||||||
|
-moz-appearance: textfield; appearance: textfield;
|
||||||
|
}
|
||||||
|
.pd-stepper input::-webkit-outer-spin-button, .pd-stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
.pd-stepper input:focus { outline: none; }
|
||||||
|
/* Glas-/Dialog-Regeln für Eingabefelder geben dem Feld sonst eine eigene
|
||||||
|
Fläche mitten im Stepper — hier bewusst durchsichtig, der Rahmen ist die Kapsel. */
|
||||||
|
.pd-stepper input#todoEditAufwand { background: transparent !important; border: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
|
||||||
|
.pd-einheit { font-size: 12px; color: var(--text-muted); padding: 0 6px 0 4px; }
|
||||||
|
.pd-aufwand-schnell { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.pd-aufwand-schnell button {
|
||||||
|
all: unset; box-sizing: border-box; padding: 5px 11px; border-radius: var(--r-pill); cursor: pointer;
|
||||||
|
font-size: 12px; color: var(--text-muted); border: 1px solid var(--line); background: transparent;
|
||||||
|
transition: background var(--dur, 150ms), color var(--dur, 150ms), border-color var(--dur, 150ms);
|
||||||
|
}
|
||||||
|
.pd-aufwand-schnell button:hover { background: var(--seg-hover); color: var(--text); }
|
||||||
|
.pd-aufwand-schnell button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.pd-aufwand-schnell button[aria-pressed="true"] {
|
||||||
|
background: var(--accent-soft); color: var(--accent); font-weight: 600;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
.pd-aufwand-info { font-size: 12px; color: var(--text-subtle); }
|
||||||
|
|
||||||
/* Terminplan aufgeräumt (Eriks Wahl "A", plan-gantt.js / plan-build-gantt.js):
|
/* Terminplan aufgeräumt (Eriks Wahl "A", plan-gantt.js / plan-build-gantt.js):
|
||||||
eine Leiste statt zwei, Menüs "Mehr" und "+ Neu", Gliederung nach
|
eine Leiste statt zwei, Menüs "Mehr" und "+ Neu", Gliederung nach
|
||||||
@@ -1629,74 +1669,6 @@ html.glas[data-theme="light"] #planSection-terminplan .tp-menue-liste { backgrou
|
|||||||
.tp-sym-phase { width: 14px; height: 6px; border-radius: 999px; background: var(--accent); flex: none; opacity: .75; }
|
.tp-sym-phase { width: 14px; height: 6px; border-radius: 999px; background: var(--accent); flex: none; opacity: .75; }
|
||||||
#todoEditPhaseAbs select { width: 100%; }
|
#todoEditPhaseAbs select { width: 100%; }
|
||||||
|
|
||||||
/* ===================== Bereich Leistungsphasen =====================
|
|
||||||
Eriks Wahl "B": Ablaufbalken über alle Phasen, darunter eine Kachel je
|
|
||||||
Phase (renderPlanPhasen(), js/plan/plan-phasen.js). Text auf Akzent-
|
|
||||||
flächen in festem Weiß — unter Glas ist var(--surface) durchscheinend. */
|
|
||||||
.phasen-zusammenfassung {
|
|
||||||
display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px;
|
|
||||||
font-size: 13px; color: var(--text-muted); margin: 0 2px 12px;
|
|
||||||
}
|
|
||||||
.phasen-zusammenfassung span { font-size: 12px; color: var(--text-subtle); }
|
|
||||||
.phasen-kopf { font-size: 11px; font-weight: 650; letter-spacing: .02em; color: var(--text-muted); text-transform: uppercase; margin-bottom: 8px; }
|
|
||||||
.phasen-ablauf { margin-bottom: 16px; }
|
|
||||||
.phasen-ablauf-spur { position: relative; height: 30px; }
|
|
||||||
.phasen-ablauf-seg {
|
|
||||||
position: absolute; top: 0; bottom: 0; min-width: 0;
|
|
||||||
display: flex; align-items: center; padding: 0 8px; overflow: hidden;
|
|
||||||
border: 0; border-radius: var(--r-md); cursor: pointer;
|
|
||||||
font: inherit; font-size: 12px; font-weight: 600; white-space: nowrap;
|
|
||||||
color: var(--text); background: var(--surface-sunk);
|
|
||||||
}
|
|
||||||
.phasen-ablauf-seg + .phasen-ablauf-seg { box-shadow: -2px 0 0 var(--surface); }
|
|
||||||
.phasen-ablauf-seg.is-fertig { background: var(--green); color: #fff; }
|
|
||||||
.phasen-ablauf-seg.is-laeuft {
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(90deg, var(--accent) var(--fort), color-mix(in srgb, var(--accent) 45%, transparent) var(--fort));
|
|
||||||
}
|
|
||||||
.phasen-ablauf-seg span { overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.phasen-ablauf-heute { position: absolute; top: -4px; bottom: -4px; border-left: 2px solid var(--red); pointer-events: none; }
|
|
||||||
.phasen-ablauf-achse { position: relative; display: flex; justify-content: space-between; margin-top: 6px; font-size: 11.5px; color: var(--text-muted); }
|
|
||||||
.phasen-ablauf-heute-text { position: absolute; top: 0; transform: translateX(-50%); color: var(--red); font-weight: 600; white-space: nowrap; }
|
|
||||||
|
|
||||||
.phasen-raster { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
|
|
||||||
.phasen-kachel {
|
|
||||||
display: flex; flex-direction: column; gap: 12px;
|
|
||||||
background: var(--surface); border: 1px solid var(--line-soft);
|
|
||||||
border-radius: var(--r-lg); padding: 14px 16px;
|
|
||||||
}
|
|
||||||
.phasen-kachel.is-laeuft { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
||||||
.phasen-kachel.is-fertig { opacity: .8; }
|
|
||||||
.phasen-kachel.ist-markiert { animation: phasen-markiert 1.2s var(--ease-out, ease-out); }
|
|
||||||
@keyframes phasen-markiert { 0%, 40% { box-shadow: 0 0 0 3px var(--accent); } 100% { box-shadow: none; } }
|
|
||||||
.phasen-kachel-kopf { display: flex; align-items: flex-start; gap: 12px; }
|
|
||||||
.phasen-ring {
|
|
||||||
--ring: var(--text-subtle);
|
|
||||||
width: 52px; height: 52px; border-radius: 50%; flex: none;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
background: conic-gradient(var(--ring) var(--fort), var(--surface-sunk) 0);
|
|
||||||
}
|
|
||||||
.phasen-kachel.is-laeuft .phasen-ring { --ring: var(--accent); }
|
|
||||||
.phasen-kachel.is-fertig .phasen-ring { --ring: var(--green); }
|
|
||||||
.phasen-ring span {
|
|
||||||
width: 42px; height: 42px; border-radius: 50%; background: var(--surface-alt);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.phasen-kachel-name { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.phasen-kachel-name b { font-size: 15px; }
|
|
||||||
.phasen-kachel-name span { font-size: 12.5px; color: var(--text-muted); }
|
|
||||||
.phasen-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 999px; background: var(--surface-sunk); color: var(--text-muted); white-space: nowrap; }
|
|
||||||
.phasen-chip.is-laeuft { background: var(--accent); color: #fff; }
|
|
||||||
.phasen-chip.is-fertig { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
|
|
||||||
.phasen-hinweis { font-size: 12px; color: var(--amber); margin-top: -4px; }
|
|
||||||
.phasen-werte { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; font-size: 12.5px; }
|
|
||||||
.phasen-werte > span { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
||||||
.phasen-werte small { font-size: 11.5px; color: var(--text-muted); }
|
|
||||||
.phasen-werte b { font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
||||||
.phasen-leer { color: var(--text-subtle); font-weight: 400; font-style: italic; }
|
|
||||||
.phasen-knoepfe { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
||||||
|
|
||||||
/* Aufgabenliste: Phasen-Kennzeichen je Aufgabe und Filter */
|
/* Aufgabenliste: Phasen-Kennzeichen je Aufgabe und Filter */
|
||||||
.plan-task-phase {
|
.plan-task-phase {
|
||||||
flex-shrink: 0; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px;
|
flex-shrink: 0; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px;
|
||||||
@@ -1710,7 +1682,6 @@ html.glas[data-theme="light"] #planSection-terminplan .tp-menue-liste { backgrou
|
|||||||
border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer;
|
border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer;
|
||||||
}
|
}
|
||||||
.plan-task-filter-btn.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
.plan-task-filter-btn.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||||
@media (max-width: 600px) { .phasen-werte { grid-template-columns: 1fr 1fr; } }
|
|
||||||
|
|
||||||
/* Leistungsphasen farblich von Aufgaben getrennt (Eriks Wunsch): Phasen-
|
/* Leistungsphasen farblich von Aufgaben getrennt (Eriks Wunsch): Phasen-
|
||||||
balken in Blauviolett (bewusst bläulicher als das Lila der Meilensteine), Aufgaben bleiben im Akzentblau, erledigte grün,
|
balken in Blauviolett (bewusst bläulicher als das Lila der Meilensteine), Aufgaben bleiben im Akzentblau, erledigte grün,
|
||||||
@@ -1786,6 +1757,106 @@ html[data-theme="dark"] { --gantt-phase: #a59ef2; }
|
|||||||
.mail-auswahl-liste { display: flex; flex-direction: column; gap: 4px; max-height: 50vh; overflow-y: auto; margin: 12px 0; }
|
.mail-auswahl-liste { display: flex; flex-direction: column; gap: 4px; max-height: 50vh; overflow-y: auto; margin: 12px 0; }
|
||||||
.mail-auswahl-zeile {
|
.mail-auswahl-zeile {
|
||||||
display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
|
display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
|
||||||
padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer;
|
padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
.mail-auswahl-text { display: grid; gap: 2px; min-width: 0; }
|
||||||
|
.mail-auswahl-titel { overflow-wrap: anywhere; }
|
||||||
.mail-auswahl-datum { color: var(--text-subtle); font-size: .88em; white-space: nowrap; }
|
.mail-auswahl-datum { color: var(--text-subtle); font-size: .88em; white-space: nowrap; }
|
||||||
|
.mail-auswahl-art { flex: none; }
|
||||||
|
/* Eigene Flächen statt der .segmented-Vorgabe: deren gewählter Knopf ist
|
||||||
|
var(--surface) — unter Glas fast durchsichtig, gewählt und nicht gewählt
|
||||||
|
sahen gleich aus; dazu trug jeder Knopf einen eigenen Rand. */
|
||||||
|
.segmented.mail-auswahl-art button {
|
||||||
|
padding: 4px 12px; font-size: 12px;
|
||||||
|
border: 0; box-shadow: none;
|
||||||
|
background: transparent; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.segmented.mail-auswahl-art button:hover { background: var(--seg-hover); color: var(--text); }
|
||||||
|
.segmented.mail-auswahl-art button[aria-pressed="true"] {
|
||||||
|
background: var(--accent-soft); color: var(--accent); font-weight: 600;
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.mail-auswahl-zeile { grid-template-columns: auto 1fr; }
|
||||||
|
.mail-auswahl-art { grid-column: 2; justify-self: start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Aufgaben-Panel: „Externe Personen“ (Kollegen außerhalb des
|
||||||
|
Projekts, todoExterneSetzen() in plan-modals.js) ---------- */
|
||||||
|
.pd-externe { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--line); display: grid; gap: 8px; }
|
||||||
|
.pd-externe-kopf { font-size: 12px; font-weight: 600; color: var(--text-muted); }
|
||||||
|
.pd-externe-kopf .hint { font-weight: 400; margin: 0 0 0 4px; }
|
||||||
|
#todoEditExterne:empty { display: none; }
|
||||||
|
#todoEditExterneWahl { max-width: 100%; }
|
||||||
|
.person-chip-x { margin-left: 4px; opacity: .6; }
|
||||||
|
.person-chip:hover .person-chip-x { opacity: 1; }
|
||||||
|
|
||||||
|
/* ---------- Notizen (Eriks Wahl „A – Liste + Editor“, js/plan/plan-notizen.js) ----------
|
||||||
|
Links die Liste, rechts der Editor. Knöpfe tragen eigene Klassen bzw.
|
||||||
|
all: unset, damit die allgemeine (Glas-)Knopfregel sie nicht färbt. */
|
||||||
|
.notizen {
|
||||||
|
display: grid; grid-template-columns: minmax(240px, 300px) 1fr; min-height: 480px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--r-lg, 14px); background: var(--surface); overflow: hidden;
|
||||||
|
}
|
||||||
|
#planSection-notizen.notizen-zieht .notizen { outline: 2px dashed var(--accent); outline-offset: 4px; }
|
||||||
|
.notizen-liste { display: flex; flex-direction: column; gap: 8px; padding: 14px; background: var(--surface-alt); border-right: 1px solid var(--line-soft); min-width: 0; }
|
||||||
|
.notizen-suchzeile { display: flex; gap: 8px; }
|
||||||
|
.notizen-suche {
|
||||||
|
flex: 1; min-width: 0; padding: 7px 12px; border: 1px solid var(--line) !important; border-radius: 999px !important;
|
||||||
|
background: var(--surface) !important; color: var(--text); font: inherit; font-size: 12.5px; box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.notizen-suche:focus { outline: none; border-color: var(--accent) !important; }
|
||||||
|
button.notizen-neu { white-space: nowrap; padding: 6px 14px; font-size: 12.5px; }
|
||||||
|
.notizen-eintraege { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; max-height: 62vh; }
|
||||||
|
.notizen-gruppe { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 650; letter-spacing: .05em; text-transform: uppercase; color: var(--text-subtle); margin: 8px 4px 4px; }
|
||||||
|
.notizen-pin-punkt { width: 7px; height: 7px; border-radius: 50%; background: var(--amber); }
|
||||||
|
button.notizen-eintrag, button.notizen-eintrag:hover, button.notizen-eintrag:active {
|
||||||
|
all: unset; box-sizing: border-box; display: grid; gap: 2px; padding: 9px 11px; border-radius: 10px; cursor: pointer; width: 100%;
|
||||||
|
transition: background var(--dur, 150ms);
|
||||||
|
}
|
||||||
|
button.notizen-eintrag:hover { background: var(--surface-sunk); }
|
||||||
|
button.notizen-eintrag:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
button.notizen-eintrag.ist-aktiv { background: var(--surface); box-shadow: inset 3px 0 0 var(--accent), var(--sh-1, 0 1px 2px rgba(0,0,0,.06)); }
|
||||||
|
.notizen-eintrag-titel { font-weight: 600; font-size: 13px; color: var(--text); display: flex; gap: 6px; align-items: center; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.notizen-eintrag-text { font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.notizen-eintrag-meta { font-size: 11px; color: var(--text-subtle); }
|
||||||
|
.notizen-chip { flex: none; font-size: 10.5px; font-weight: 600; color: var(--accent); background: var(--accent-soft); border-radius: 999px; padding: 0 7px; }
|
||||||
|
.notizen-leer { font-size: 12.5px; color: var(--text-subtle); padding: 10px 4px; }
|
||||||
|
.notizen-leer.ist-fehler { color: var(--red); }
|
||||||
|
.notizen-ablage { margin-top: auto; border: 1.5px dashed var(--line); border-radius: 10px; padding: 9px; text-align: center; font-size: 12px; color: var(--text-subtle); }
|
||||||
|
.notizen-laedt { padding: 24px; color: var(--text-subtle); }
|
||||||
|
|
||||||
|
.notizen-editor { display: flex; flex-direction: column; gap: 12px; padding: 18px 22px; min-width: 0; }
|
||||||
|
.notizen-editor-leer { margin: auto; display: grid; gap: 6px; text-align: center; color: var(--text-muted); max-width: 320px; }
|
||||||
|
.notizen-editor-kopf { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.notizen-titel {
|
||||||
|
flex: 1; min-width: 0; border: 0 !important; background: transparent !important; box-shadow: none !important; padding: 2px 0 !important;
|
||||||
|
font: 600 20px/1.3 var(--font-display, inherit); color: var(--text);
|
||||||
|
}
|
||||||
|
.notizen-titel:focus { outline: none; }
|
||||||
|
.notizen-titel::placeholder { color: var(--text-subtle); }
|
||||||
|
button.notizen-symbol, button.notizen-symbol:hover, button.notizen-symbol:active {
|
||||||
|
all: unset; box-sizing: border-box; padding: 5px 11px; border-radius: 999px; border: 1px solid var(--line);
|
||||||
|
font-size: 12px; color: var(--text-muted); cursor: pointer; white-space: nowrap; transition: background var(--dur, 150ms);
|
||||||
|
}
|
||||||
|
button.notizen-symbol:hover { background: var(--surface-sunk); color: var(--text); }
|
||||||
|
button.notizen-symbol:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
button.notizen-symbol.ist-an { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 50%, var(--line)); font-weight: 600; }
|
||||||
|
button.notizen-symbol.ist-gefahr:hover { color: var(--red); }
|
||||||
|
button.notizen-symbol.ist-fragend { background: var(--red); color: #fff; border-color: var(--red); }
|
||||||
|
.notizen-richtext { display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||||
|
.notizen-text {
|
||||||
|
flex: 1; min-height: 260px; outline: none; line-height: 1.55; font-size: 14px; color: var(--text);
|
||||||
|
padding: 4px 2px; overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.notizen-text:empty::before { content: attr(data-placeholder); color: var(--text-subtle); }
|
||||||
|
.notizen-fuss { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; border-top: 1px solid var(--line-soft); padding-top: 10px; font-size: 12px; color: var(--text-subtle); }
|
||||||
|
.notizen-status.ist-ok { color: var(--green); }
|
||||||
|
.notizen-status.ist-warnung { color: var(--amber); }
|
||||||
|
.notizen-status.ist-fehler { color: var(--red); }
|
||||||
|
button.notizen-link, button.notizen-link:hover { all: unset; cursor: pointer; color: var(--accent); text-decoration: underline; margin-left: 6px; }
|
||||||
|
@media (max-width: 760px){
|
||||||
|
.notizen { grid-template-columns: 1fr; }
|
||||||
|
.notizen-liste { border-right: 0; border-bottom: 1px solid var(--line-soft); }
|
||||||
|
.notizen-eintraege { max-height: 240px; }
|
||||||
|
}
|
||||||
+93
-3
@@ -3313,7 +3313,7 @@ html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,2
|
|||||||
.range-picker {
|
.range-picker {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
width: 288px;
|
width: 316px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r-lg);
|
border-radius: var(--r-lg);
|
||||||
@@ -3343,9 +3343,17 @@ html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,2
|
|||||||
|
|
||||||
.range-picker-grid {
|
.range-picker-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: 26px repeat(7, 1fr);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
/* KW-Spalte: klein und gedämpft, damit die Tage die Hauptsache bleiben. */
|
||||||
|
.range-picker-kw {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
font-size: 10px; font-weight: 600; color: var(--text-subtle);
|
||||||
|
border-right: 1px solid var(--line-soft);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.range-picker-kw-kopf { border-right: 1px solid var(--line-soft); }
|
||||||
|
|
||||||
.range-picker-weekday {
|
.range-picker-weekday {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -6128,7 +6136,14 @@ html.glas .tour-blase { background: var(--glas-dicht, var(--surface)); -webkit-b
|
|||||||
.abw-team-steuer { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
.abw-team-steuer { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
.abw-monat { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
|
.abw-monat { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||||
.abw-monat strong { min-width: 120px; text-align: center; font-weight: 600; }
|
.abw-monat strong { min-width: 120px; text-align: center; font-weight: 600; }
|
||||||
.abw-rund { width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer; display: grid; place-items: center; padding: 0; }
|
.abw-rund { width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer; display: grid; place-items: center; padding: 0; flex: none; }
|
||||||
|
/* Eigener Hover: sonst färbt die allgemeine button:hover-Regel den Kreis
|
||||||
|
akzentblau. Pfeile als Symbol (icon()) statt der Schriftzeichen ‹ ›, die im
|
||||||
|
Kreis nicht mittig saßen. */
|
||||||
|
.abw-rund:hover { background: var(--surface-sunk); border-color: var(--text-subtle); }
|
||||||
|
.abw-rund:active { background: var(--surface-sunk); }
|
||||||
|
.abw-rund .icon { display: grid; place-items: center; }
|
||||||
|
.abw-rund svg { width: 16px; height: 16px; display: block; }
|
||||||
.abw-gewerk-kopf { display: flex; align-items: baseline; gap: 10px; }
|
.abw-gewerk-kopf { display: flex; align-items: baseline; gap: 10px; }
|
||||||
.abw-gewerk-kopf small { color: var(--text-muted); font-size: 12.5px; }
|
.abw-gewerk-kopf small { color: var(--text-muted); font-size: 12.5px; }
|
||||||
#abwesenheit .abw-gewerk-kopf + .abw-tl-karte { margin-top: 8px; }
|
#abwesenheit .abw-gewerk-kopf + .abw-tl-karte { margin-top: 8px; }
|
||||||
@@ -6199,3 +6214,78 @@ html.glas .abw-tl-wer { background: var(--glas-deckend, var(--surface)); }
|
|||||||
color: #fff; border-color: transparent;
|
color: #fff; border-color: transparent;
|
||||||
text-shadow: 0 0 2px rgba(0,0,0,.55), 0 0 1px rgba(0,0,0,.6);
|
text-shadow: 0 0 2px rgba(0,0,0,.55), 0 0 1px rgba(0,0,0,.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* „Weitere laden“ im Verlauf (weitereLadenKnopf(), js/store.js): holt je
|
||||||
|
fünf ältere Sitzungen nach — beim Start sind nur die jüngsten geladen. */
|
||||||
|
.verlauf-weitere-zeile, .plan-verlauf-mehr-zeile { display: flex; justify-content: center; margin-top: 10px; }
|
||||||
|
button.verlauf-weitere { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---------- Einstellungen: Kalender in Outlook ---------- */
|
||||||
|
.kalender-adresse { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 6px; }
|
||||||
|
.kalender-adresse input { flex: 1; min-width: 220px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||||
|
.kalender-adresse .button-link {
|
||||||
|
display: inline-flex; align-items: center; padding: 7px 16px; border-radius: var(--r-pill);
|
||||||
|
background: var(--accent); color: #fff; font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.kalender-adresse .button-link:hover { background: var(--accent-hover); }
|
||||||
|
.kalender-anleitung { margin: 6px 0 10px; font-size: 13px; }
|
||||||
|
.kalender-anleitung summary { cursor: pointer; color: var(--accent); }
|
||||||
|
.kalender-anleitung ol { margin: 8px 0; padding-left: 20px; display: grid; gap: 6px; }
|
||||||
|
.kalender-aktionen { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* ---------- Admin → Teams (loadAdminTeams(), js/admin/admin.js) ----------
|
||||||
|
Eigene Klassen statt .settings-field: das Adressfeld ist lang (≈ 300
|
||||||
|
Zeichen) und braucht eine breite, mehrzeilige Fläche. */
|
||||||
|
#adminTeamsInhalt { display: grid; gap: 14px; margin: 12px 0 6px; }
|
||||||
|
.teams-status {
|
||||||
|
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--r-lg); background: var(--surface-sunk);
|
||||||
|
}
|
||||||
|
.teams-punkt { width: 10px; height: 10px; border-radius: 50%; background: var(--text-subtle); flex: none; }
|
||||||
|
.teams-status.ist-an .teams-punkt { background: var(--green); box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 20%, transparent); }
|
||||||
|
.teams-status-text { display: grid; gap: 2px; flex: 1; min-width: 0; }
|
||||||
|
.teams-status-text b { font-size: 13.5px; }
|
||||||
|
.teams-status-text span { font-size: 12px; color: var(--text-muted); overflow-wrap: anywhere; }
|
||||||
|
button.teams-text-knopf, button.teams-text-knopf:hover, button.teams-text-knopf:active {
|
||||||
|
all: unset; cursor: pointer; font-size: 12.5px; color: var(--accent); white-space: nowrap; padding: 4px 2px;
|
||||||
|
}
|
||||||
|
button.teams-text-knopf:hover { text-decoration: underline; }
|
||||||
|
button.teams-text-knopf:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||||
|
button.teams-text-knopf.ist-gefahr { color: var(--red); }
|
||||||
|
|
||||||
|
.teams-feld[hidden] { display: none; }
|
||||||
|
.teams-feld { display: grid; gap: 6px; }
|
||||||
|
.teams-feld label { font-size: 12px; font-weight: 600; color: var(--text-muted); }
|
||||||
|
.teams-feld textarea {
|
||||||
|
width: 100%; box-sizing: border-box; resize: vertical; min-height: 64px;
|
||||||
|
padding: 10px 12px; border: 1px solid var(--line) !important; border-radius: var(--r-md) !important;
|
||||||
|
background: var(--surface) !important; color: var(--text); box-shadow: none !important;
|
||||||
|
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all;
|
||||||
|
}
|
||||||
|
.teams-feld textarea:focus { outline: none; border-color: var(--accent) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent) !important; }
|
||||||
|
.teams-feld-fuss { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.teams-feld-hinweis { flex: 1; min-width: 200px; font-size: 12px; color: var(--text-subtle); }
|
||||||
|
.teams-feld-hinweis.ist-fehler { color: var(--red); }
|
||||||
|
|
||||||
|
/* Schalter statt nackter Checkbox */
|
||||||
|
.teams-schalter { display: flex; align-items: center; gap: 12px; cursor: pointer; }
|
||||||
|
.teams-schalter input { position: absolute; opacity: 0; width: 1px; height: 1px; }
|
||||||
|
.teams-schalter-bahn {
|
||||||
|
flex: none; width: 38px; height: 22px; border-radius: 11px; background: var(--line); position: relative;
|
||||||
|
transition: background var(--dur, 150ms);
|
||||||
|
}
|
||||||
|
.teams-schalter-bahn::after {
|
||||||
|
content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform var(--dur, 150ms) var(--ease-out, ease);
|
||||||
|
}
|
||||||
|
.teams-schalter input:checked + .teams-schalter-bahn { background: var(--accent); }
|
||||||
|
.teams-schalter input:checked + .teams-schalter-bahn::after { transform: translateX(16px); }
|
||||||
|
.teams-schalter input:focus-visible + .teams-schalter-bahn { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.teams-schalter input:disabled + .teams-schalter-bahn { opacity: .45; }
|
||||||
|
.teams-schalter:has(input:disabled) { cursor: default; }
|
||||||
|
.teams-schalter-text { display: grid; gap: 2px; }
|
||||||
|
.teams-schalter-text b { font-size: 13px; font-weight: 600; }
|
||||||
|
.teams-schalter-text span { font-size: 12px; color: var(--text-muted); }
|
||||||
|
.teams-schalter:has(input:disabled) .teams-schalter-text { opacity: .55; }
|
||||||
|
|
||||||
|
.teams-aktionen { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||||
@@ -514,6 +514,7 @@ db.exec(`CREATE TABLE IF NOT EXISTS plan_milestones (
|
|||||||
date TEXT NOT NULL DEFAULT '',
|
date TEXT NOT NULL DEFAULT '',
|
||||||
position TEXT NOT NULL DEFAULT '',
|
position TEXT NOT NULL DEFAULT '',
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
notiz TEXT NOT NULL DEFAULT '',
|
||||||
PRIMARY KEY (week_label, project_id, milestone_id)
|
PRIMARY KEY (week_label, project_id, milestone_id)
|
||||||
)`);
|
)`);
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
|
db.exec(`CREATE TABLE IF NOT EXISTS plan_milestone_deps (
|
||||||
@@ -779,7 +780,8 @@ const RECHTE = [
|
|||||||
['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''],
|
['a.feiertage', 'Feiertage', ''], ['a.vorlagen', 'Aufgaben-Vorlagen', ''],
|
||||||
['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''],
|
['a.checker', 'Plan-Checker-Merkmale', ''], ['a.betrieb', 'Betrieb & Sicherheit', ''],
|
||||||
['a.papierkorb', 'Papierkorb', ''], ['a.backup', 'Datenbank-Backup', ''],
|
['a.papierkorb', 'Papierkorb', ''], ['a.backup', 'Datenbank-Backup', ''],
|
||||||
['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''] ]},
|
['a.import', 'Datenbank-Import', ''], ['a.rollen', 'Rollen & Rechte', ''],
|
||||||
|
['a.teams', 'Teams-Anbindung', ''] ]},
|
||||||
];
|
];
|
||||||
const RECHT_LABEL = new Map(RECHTE.flatMap(g => g.rechte.map(r => [r[0], r[1]])));
|
const RECHT_LABEL = new Map(RECHTE.flatMap(g => g.rechte.map(r => [r[0], r[1]])));
|
||||||
const RECHT_KEYS = new Set(RECHT_LABEL.keys());
|
const RECHT_KEYS = new Set(RECHT_LABEL.keys());
|
||||||
@@ -1224,8 +1226,8 @@ function projektStatements(){
|
|||||||
VALUES (?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?)`);
|
||||||
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
|
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
|
||||||
VALUES (?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?)`);
|
||||||
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order)
|
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||||
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
|
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
|
||||||
VALUES (?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?)`);
|
||||||
|
|
||||||
@@ -1310,7 +1312,7 @@ function serializeProject(weekLabel, p, idx, nowIso){
|
|||||||
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
|
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
|
||||||
});
|
});
|
||||||
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => {
|
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => {
|
||||||
insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx);
|
insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx, String(m.notiz || '').slice(0, 20000));
|
||||||
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
|
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1409,11 +1411,15 @@ function loadGlobalPeople(fuerUser){
|
|||||||
function loadWeeks(nurLabel){
|
function loadWeeks(nurLabel){
|
||||||
// nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die
|
// nurLabel (optional): nur diese eine Sitzung laden — für GET /week und die
|
||||||
// Live-Ereignisse, die sonst jedes Mal alle Sitzungen zusammensetzen müssten.
|
// Live-Ereignisse, die sonst jedes Mal alle Sitzungen zusammensetzen müssten.
|
||||||
|
// Auch eine Liste von Labels (für /load-all und /weeks-aelter, die nur
|
||||||
|
// einige Sitzungen liefern).
|
||||||
|
const labels = nurLabel === undefined ? null : (Array.isArray(nurLabel) ? nurLabel : [nurLabel]).map(String);
|
||||||
|
if(labels && labels.length === 0) return [];
|
||||||
const alle = (tabelle, reihenfolge) => {
|
const alle = (tabelle, reihenfolge) => {
|
||||||
const spalte = tabelle === 'weeks' ? 'label' : 'week_label';
|
const spalte = tabelle === 'weeks' ? 'label' : 'week_label';
|
||||||
const wo = nurLabel === undefined ? '' : ` WHERE ${spalte} = ?`;
|
const wo = labels ? ` WHERE ${spalte} IN (${labels.map(() => '?').join(',')})` : '';
|
||||||
const st = db.prepare(`SELECT * FROM ${tabelle}${wo}${reihenfolge ? ' ' + reihenfolge : ''}`);
|
const st = db.prepare(`SELECT * FROM ${tabelle}${wo}${reihenfolge ? ' ' + reihenfolge : ''}`);
|
||||||
return nurLabel === undefined ? st.all() : st.all(String(nurLabel));
|
return labels ? st.all(...labels) : st.all();
|
||||||
};
|
};
|
||||||
const weeks = alle('weeks', 'ORDER BY label');
|
const weeks = alle('weeks', 'ORDER BY label');
|
||||||
const weekPeople = alle('week_people', 'ORDER BY array_order');
|
const weekPeople = alle('week_people', 'ORDER BY array_order');
|
||||||
@@ -1534,7 +1540,7 @@ function loadWeeks(nurLabel){
|
|||||||
const msMap = {};
|
const msMap = {};
|
||||||
milestones.forEach(r => {
|
milestones.forEach(r => {
|
||||||
const k = msKey(r.week_label, r.project_id, r.milestone_id);
|
const k = msKey(r.week_label, r.project_id, r.milestone_id);
|
||||||
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [] };
|
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '' };
|
||||||
});
|
});
|
||||||
milestoneDeps.forEach(r => {
|
milestoneDeps.forEach(r => {
|
||||||
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
|
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
|
||||||
@@ -1664,11 +1670,11 @@ const IMPORT_TABLES = ['weeks','week_people','week_people_roles','attendance','p
|
|||||||
'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps',
|
'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps',
|
||||||
'plan_milestones','plan_milestone_deps',
|
'plan_milestones','plan_milestone_deps',
|
||||||
'project_protokolle','protokoll_teilnehmer','protokoll_punkte',
|
'project_protokolle','protokoll_teilnehmer','protokoll_punkte',
|
||||||
'project_kommentare'];
|
'project_kommentare', 'projekt_notizen'];
|
||||||
// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen.
|
// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen.
|
||||||
// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk()
|
// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk()
|
||||||
// verteilt die alten Kommentare danach aus projects.status.
|
// verteilt die alten Kommentare danach aus projects.status.
|
||||||
const IMPORT_OPTIONAL_TABLES = ['project_kommentare'];
|
const IMPORT_OPTIONAL_TABLES = ['project_kommentare', 'projekt_notizen'];
|
||||||
|
|
||||||
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
|
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
|
||||||
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
|
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
|
||||||
@@ -2671,6 +2677,11 @@ function migrateAddPlanTodoNotiz(){
|
|||||||
db.exec("ALTER TABLE plan_todos ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
|
db.exec("ALTER TABLE plan_todos ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
|
||||||
console.log('plan_todos um notiz ergänzt.');
|
console.log('plan_todos um notiz ergänzt.');
|
||||||
}
|
}
|
||||||
|
const msCols = db.prepare("SELECT name FROM pragma_table_info('plan_milestones')").all().map(r => r.name);
|
||||||
|
if(!msCols.includes('notiz')){
|
||||||
|
db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
|
||||||
|
console.log('plan_milestones um notiz ergänzt.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
|
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
|
||||||
@@ -3038,6 +3049,12 @@ function migrateUsersDepartment(){
|
|||||||
// dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine
|
// dashboard_reihenfolge: eigene Reihenfolge der Projekte auf "Meine
|
||||||
// Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer =
|
// Aufgaben" (JSON-Liste von Projekt-Schlüsseln, meist project_uid); leer =
|
||||||
// wie in der Projektliste. Je Konto, damit sie auf jedem Gerät gilt.
|
// wie in der Projektliste. Je Konto, damit sie auf jedem Gerät gilt.
|
||||||
|
// kalender_token: geheime Adresse des Kalender-Abos (/kalender/<token>.ics),
|
||||||
|
// leer = abgeschaltet. Siehe "Kalender-Abo für Outlook".
|
||||||
|
if(!cols.includes('kalender_token')){
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN kalender_token TEXT NOT NULL DEFAULT ''");
|
||||||
|
console.log('Benutzer-Tabelle um kalender_token ergänzt.');
|
||||||
|
}
|
||||||
if(!cols.includes('dashboard_reihenfolge')){
|
if(!cols.includes('dashboard_reihenfolge')){
|
||||||
db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''");
|
db.exec("ALTER TABLE users ADD COLUMN dashboard_reihenfolge TEXT NOT NULL DEFAULT ''");
|
||||||
console.log('Benutzer-Tabelle um dashboard_reihenfolge ergänzt.');
|
console.log('Benutzer-Tabelle um dashboard_reihenfolge ergänzt.');
|
||||||
@@ -4652,7 +4669,7 @@ app.post('/save-week', requireAuth, (req, res) => {
|
|||||||
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
|
// aktualisieren kann (verhindert falsche Eigen-Konflikte beim nächsten Save).
|
||||||
const updatedAt = saveWeekRelational(week);
|
const updatedAt = saveWeekRelational(week);
|
||||||
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
|
if(entfernt.length) legeInPapierkorb(entfernt, week.label, req.user);
|
||||||
if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user);
|
if(wocheGabEs) neueZuweisungenMelden(week, alteZuweisungen, req.user, req);
|
||||||
console.log('Saved week:', week.label);
|
console.log('Saved week:', week.label);
|
||||||
ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req);
|
ereignisSenden({ typ: 'woche', label: week.label, updatedAt }, req);
|
||||||
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
|
return res.json({ ok: true, updatedAt: updatedAt || new Date().toISOString() });
|
||||||
@@ -4662,6 +4679,161 @@ app.post('/save-week', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- Teams: persönliche Nachricht bei neuen Aufgaben ----------
|
||||||
|
//
|
||||||
|
// Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, bekommt GENAU DIESE
|
||||||
|
// Person in Teams eine Nachricht (Einzelchat mit dem Flow-Bot) mit Link
|
||||||
|
// direkt auf die Aufgabe im Projekt — nicht der ganze Kanal (Eriks Wahl
|
||||||
|
// „nur persönlich“). Dafür legt man in Teams einen Workflow an: Auslöser
|
||||||
|
// „Bei Empfang einer Teams-Webhookanforderung“, Aktion „Karte in einem Chat
|
||||||
|
// oder Kanal veröffentlichen“ als Flow-Bot im Chat, Empfänger
|
||||||
|
// triggerBody()?['empfaenger'], Karte string(triggerBody()?['karte']). Die
|
||||||
|
// Adresse des Auslösers trägt ein Admin unter Admin → Teams ein (Recht
|
||||||
|
// a.teams). Die App schickt je Person {empfaenger: E-Mail, karte: Adaptive
|
||||||
|
// Card}; Empfänger ist users.email (muss dem Teams-Anmeldenamen
|
||||||
|
// entsprechen). Personen ohne Konto oder E-Mail bekommen nichts. Die Adresse ist ein
|
||||||
|
// Geheimnis (wer sie kennt, kann in den Kanal schreiben) — sie steht in
|
||||||
|
// `integrationen`, nicht in app_settings (das jedes Konto lesen darf), und
|
||||||
|
// geht nie an den Browser zurück. Nicht in IMPORT_TABLES.
|
||||||
|
//
|
||||||
|
// Senden braucht Internetzugang nach außen; ein Fehler wird nur geloggt,
|
||||||
|
// das Speichern der Sitzung scheitert nie daran.
|
||||||
|
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS integrationen (
|
||||||
|
schluessel TEXT PRIMARY KEY,
|
||||||
|
wert TEXT NOT NULL DEFAULT ''
|
||||||
|
)`);
|
||||||
|
|
||||||
|
function integrationLesen(k){
|
||||||
|
const r = db.prepare('SELECT wert FROM integrationen WHERE schluessel = ?').get(k);
|
||||||
|
return r ? r.wert : '';
|
||||||
|
}
|
||||||
|
function integrationSchreiben(k, v){
|
||||||
|
db.prepare('INSERT INTO integrationen (schluessel, wert) VALUES (?, ?) ON CONFLICT(schluessel) DO UPDATE SET wert = excluded.wert')
|
||||||
|
.run(k, String(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// teamsKarte: die Adaptive Card selbst (der Workflow legt sie in den Chat).
|
||||||
|
function teamsKarte(titel, zeilen, fakten, link, linkText){
|
||||||
|
const body = [{ type: 'TextBlock', text: titel, weight: 'Bolder', size: 'Medium', wrap: true }];
|
||||||
|
zeilen.forEach(z => body.push({ type: 'TextBlock', text: z, wrap: true, spacing: 'Small' }));
|
||||||
|
if(fakten.length) body.push({ type: 'FactSet', facts: fakten.map(([title, value]) => ({ title, value })) });
|
||||||
|
return {
|
||||||
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||||
|
type: 'AdaptiveCard', version: '1.4', body,
|
||||||
|
actions: link ? [{ type: 'Action.OpenUrl', title: linkText || 'Öffnen', url: link }] : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// teamsSenden: an die eingetragene Adresse schicken. Rückgabe: Promise —
|
||||||
|
// wirft bei Fehler (für den Test-Knopf); Aufrufer im Hintergrund fangen ab.
|
||||||
|
async function teamsSenden(nachricht, url){
|
||||||
|
const ziel = url || integrationLesen('teams_webhook');
|
||||||
|
if(!ziel) throw new Error('Keine Teams-Adresse eingetragen.');
|
||||||
|
const res = await fetchMitTimeout(ziel, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(nachricht)
|
||||||
|
});
|
||||||
|
if(!res.ok){
|
||||||
|
const t = await res.text().catch(() => '');
|
||||||
|
throw new Error('Teams antwortete mit HTTP ' + res.status + (t ? ': ' + t.slice(0, 200) : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aufgabenLink: Adresse der Aufgabe im Projekt (der Browser öffnet sie direkt
|
||||||
|
// im Aufgaben-Panel, Parameter aufgabe=). Host aus der speichernden Anfrage —
|
||||||
|
// also die Adresse, unter der die App im Büro aufgerufen wird.
|
||||||
|
function aufgabenLink(req, weekLabel, p, todoId){
|
||||||
|
if(!req) return '';
|
||||||
|
const u = new URL('/Projektplan/' + encodeURIComponent(p.name || ''), req.protocol + '://' + req.get('host'));
|
||||||
|
u.searchParams.set('weekLabel', weekLabel);
|
||||||
|
u.searchParams.set('projNr', p.nr || '');
|
||||||
|
u.searchParams.set('aufgabe', String(todoId));
|
||||||
|
u.hash = 'aufgaben';
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamsDatum(iso){
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''));
|
||||||
|
return m ? m[3] + '.' + m[2] + '.' + m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// teamsNeueAufgaben: je Aufgabe mit neu Zuständigen EINE Nachricht. neu =
|
||||||
|
// Map todoId -> { p, t, personen:[ids] }. Läuft im Hintergrund.
|
||||||
|
// teamsNeueAufgaben: je neu zuständiger PERSON eine Nachricht an sie selbst.
|
||||||
|
// neu = Map todoId -> { p, t, personen:[person_ids] }. Läuft im Hintergrund.
|
||||||
|
function teamsNeueAufgaben(weekLabel, neu, user, req){
|
||||||
|
if(!neu.size || integrationLesen('teams_neue_aufgaben') !== '1' || !integrationLesen('teams_webhook')) return;
|
||||||
|
const namen = new Map(db.prepare('SELECT person_id, name, full_name FROM week_people WHERE week_label = ?').all(weekLabel)
|
||||||
|
.map(r => [r.person_id, r.full_name || r.name || r.person_id]));
|
||||||
|
const konto = db.prepare(`SELECT email FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND email != '' AND ${sqlOhneWandtafel()}`);
|
||||||
|
const von = (user && (user.displayName || user.username)) || '';
|
||||||
|
neu.forEach(({ p, t, personen }) => {
|
||||||
|
const wer = personen.map(id => namen.get(id) || id).join(', ');
|
||||||
|
const zeitraum = t.start && t.end && t.start !== t.end ? teamsDatum(t.start) + ' – ' + teamsDatum(t.end) : '';
|
||||||
|
const fakten = [['Projekt', [p.nr, p.name].filter(Boolean).join(' ')]];
|
||||||
|
if(personen.length > 1) fakten.push(['Zuständig', wer]);
|
||||||
|
if(t.end) fakten.push(['Fällig', teamsDatum(t.end)]);
|
||||||
|
if(zeitraum) fakten.push(['Zeitraum', zeitraum]);
|
||||||
|
if(Number(t.aufwand) > 0) fakten.push(['Aufwand', String(t.aufwand).replace('.', ',') + ' h']);
|
||||||
|
if(von) fakten.push(['Zugewiesen von', von]);
|
||||||
|
const karte = teamsKarte('Neue Aufgabe für Sie: ' + (t.title || 'Aufgabe'), [], fakten,
|
||||||
|
aufgabenLink(req, weekLabel, p, t.id), 'Aufgabe öffnen');
|
||||||
|
personen.forEach(pid => {
|
||||||
|
const k = konto.get(pid);
|
||||||
|
if(!k){ console.log('Teams: keine E-Mail für', namen.get(pid) || pid, '— keine Nachricht'); return; }
|
||||||
|
teamsSenden({ empfaenger: k.email, karte })
|
||||||
|
.catch(err => console.warn('Teams-Nachricht an', k.email, 'nicht gesendet:', err.message));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/admin/teams', requireRecht('a.teams'), (req, res) => {
|
||||||
|
const url = integrationLesen('teams_webhook');
|
||||||
|
let host = '';
|
||||||
|
try { host = url ? new URL(url).host : ''; } catch(e){ /* egal */ }
|
||||||
|
res.json({ ok: true, konfiguriert: !!url, host, neueAufgaben: integrationLesen('teams_neue_aufgaben') === '1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT {webhook?, neueAufgaben?} — webhook '' entfernt die Adresse.
|
||||||
|
app.put('/admin/teams', requireRecht('a.teams'), (req, res) => {
|
||||||
|
try {
|
||||||
|
const b = req.body || {};
|
||||||
|
if(typeof b.webhook === 'string'){
|
||||||
|
const w = b.webhook.trim();
|
||||||
|
if(w){
|
||||||
|
let u;
|
||||||
|
try { u = new URL(w); } catch(e){ return res.status(400).json({ ok: false, error: 'Das ist keine gültige Adresse.' }); }
|
||||||
|
if(u.protocol !== 'https:') return res.status(400).json({ ok: false, error: 'Die Teams-Adresse muss mit https:// beginnen.' });
|
||||||
|
}
|
||||||
|
integrationSchreiben('teams_webhook', w);
|
||||||
|
}
|
||||||
|
if(typeof b.neueAufgaben === 'boolean') integrationSchreiben('teams_neue_aufgaben', b.neueAufgaben ? '1' : '0');
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Teams-Einstellung speichern fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /admin/teams/test — Testnachricht; Fehler als {ok:false, error} mit
|
||||||
|
// HTTP 200 (ein fehlgeschlagener Test ist ein erwartetes Ergebnis).
|
||||||
|
// Die Testnachricht geht an den Admin selbst (seine E-Mail aus dem Konto).
|
||||||
|
app.post('/admin/teams/test', requireRecht('a.teams'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const email = (db.prepare('SELECT email FROM users WHERE id = ?').get(req.user.id) || {}).email || '';
|
||||||
|
if(!email) return res.json({ ok: false, error: 'Ihr Konto hat keine E-Mail-Adresse — die braucht Teams, um Sie zu finden (Einstellungen → Mein Konto).' });
|
||||||
|
const link = req.protocol + '://' + req.get('host') + '/';
|
||||||
|
await teamsSenden({ empfaenger: email, karte: teamsKarte('Projektübersicht ist mit Teams verbunden',
|
||||||
|
['Ab jetzt bekommt jede Person hier eine Nachricht, wenn ihr eine Aufgabe zugewiesen wird — mit Link direkt zur Aufgabe.'],
|
||||||
|
[['Eingerichtet von', req.user.displayName || req.user.username]], link, 'Projektübersicht öffnen') });
|
||||||
|
res.json({ ok: true, an: email });
|
||||||
|
} catch(err){
|
||||||
|
res.json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// neueZuweisungenMelden: legt für jede Person, die einer offenen Aufgabe neu
|
// neueZuweisungenMelden: legt für jede Person, die einer offenen Aufgabe neu
|
||||||
// als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der
|
// als zuständig zugewiesen wurde, eine Benachrichtigung an — sofern zu der
|
||||||
// Person ein aktives Konto gehört (users.person_id; Kiosk-Konten nicht) und
|
// Person ein aktives Konto gehört (users.person_id; Kiosk-Konten nicht) und
|
||||||
@@ -4669,7 +4841,7 @@ app.post('/save-week', requireAuth, (req, res) => {
|
|||||||
// Speichern und darf es nie scheitern lassen (Fehler werden nur geloggt).
|
// Speichern und darf es nie scheitern lassen (Fehler werden nur geloggt).
|
||||||
// Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId"
|
// Parameter: week (gespeichertes Wochenobjekt), alt (Set "todoId|personId"
|
||||||
// vor dem Speichern), user (req.user). Keine Rückgabe.
|
// vor dem Speichern), user (req.user). Keine Rückgabe.
|
||||||
function neueZuweisungenMelden(week, alt, user){
|
function neueZuweisungenMelden(week, alt, user, req){
|
||||||
try {
|
try {
|
||||||
const konto = db.prepare(`SELECT id FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND ${sqlOhneWandtafel()}`);
|
const konto = db.prepare(`SELECT id FROM users WHERE person_id = ? AND person_id != '' AND active = 1 AND ${sqlOhneWandtafel()}`);
|
||||||
const schonDa = db.prepare('SELECT 1 FROM benachrichtigungen WHERE user_id = ? AND todo_id = ? AND gelesen = 0');
|
const schonDa = db.prepare('SELECT 1 FROM benachrichtigungen WHERE user_id = ? AND todo_id = ? AND gelesen = 0');
|
||||||
@@ -4679,12 +4851,21 @@ function neueZuweisungenMelden(week, alt, user){
|
|||||||
const jetzt = new Date().toISOString();
|
const jetzt = new Date().toISOString();
|
||||||
const von = (user && (user.displayName || user.username)) || '';
|
const von = (user && (user.displayName || user.username)) || '';
|
||||||
let anzahl = 0;
|
let anzahl = 0;
|
||||||
|
// Für Teams: je Aufgabe die neu Zuständigen — auch Personen ohne Konto,
|
||||||
|
// aber nicht, wer sich selbst zuweist; nie aus der Planung-Sandbox.
|
||||||
|
const fuerTeams = new Map();
|
||||||
|
const sandbox = week.label === SANDBOX_WEEK_LABEL;
|
||||||
|
const ichPerson = user && user.personId ? String(user.personId) : '';
|
||||||
(Array.isArray(week.projects) ? week.projects : []).forEach(p => {
|
(Array.isArray(week.projects) ? week.projects : []).forEach(p => {
|
||||||
const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : [];
|
const todos = (p && p.plan && Array.isArray(p.plan.todos)) ? p.plan.todos : [];
|
||||||
todos.forEach(t => {
|
todos.forEach(t => {
|
||||||
if(!t || t.done) return;
|
if(!t || t.done) return;
|
||||||
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => {
|
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => {
|
||||||
if(alt.has(String(t.id) + '|' + String(pid))) return;
|
if(alt.has(String(t.id) + '|' + String(pid))) return;
|
||||||
|
if(!sandbox && !t.lph && String(pid) !== ichPerson){
|
||||||
|
if(!fuerTeams.has(String(t.id))) fuerTeams.set(String(t.id), { p, t, personen: [] });
|
||||||
|
fuerTeams.get(String(t.id)).personen.push(String(pid));
|
||||||
|
}
|
||||||
const k = konto.get(String(pid));
|
const k = konto.get(String(pid));
|
||||||
if(!k || (user && k.id === user.id)) return;
|
if(!k || (user && k.id === user.id)) return;
|
||||||
if(schonDa.get(k.id, String(t.id))) return;
|
if(schonDa.get(k.id, String(t.id))) return;
|
||||||
@@ -4695,6 +4876,7 @@ function neueZuweisungenMelden(week, alt, user){
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
if(anzahl) console.log('Benachrichtigungen angelegt:', anzahl, '(neue Aufgaben-Zuweisungen)');
|
if(anzahl) console.log('Benachrichtigungen angelegt:', anzahl, '(neue Aufgaben-Zuweisungen)');
|
||||||
|
teamsNeueAufgaben(String(week.label || ''), fuerTeams, user, req);
|
||||||
} catch(err){
|
} catch(err){
|
||||||
console.error('Benachrichtigungen konnten nicht angelegt werden:', err.message);
|
console.error('Benachrichtigungen konnten nicht angelegt werden:', err.message);
|
||||||
}
|
}
|
||||||
@@ -4897,7 +5079,7 @@ app.post('/save-projects', requireAuth, (req, res) => {
|
|||||||
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, label);
|
db.prepare('UPDATE weeks SET updated_at = ? WHERE label = ?').run(jetzt, label);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch(err){ db.exec('ROLLBACK'); throw err; }
|
} catch(err){ db.exec('ROLLBACK'); throw err; }
|
||||||
neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user);
|
neueZuweisungenMelden({ label, projects: liste.map(e => e.project) }, alteZuweisungen, req.user, req);
|
||||||
const ids = new Set(liste.map(e => String(e.project.id)));
|
const ids = new Set(liste.map(e => String(e.project.id)));
|
||||||
const frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id)));
|
const frisch = (loadWeeks(label)[0] || { projects: [] }).projects.filter(p => ids.has(String(p.id)));
|
||||||
ereignisSenden({ typ: 'projekte', label, updatedAt: jetzt, projekte: frisch }, req);
|
ereignisSenden({ typ: 'projekte', label, updatedAt: jetzt, projekte: frisch }, req);
|
||||||
@@ -4915,6 +5097,25 @@ app.post('/save-projects', requireAuth, (req, res) => {
|
|||||||
// winzig, deshalb fragt der Browser ihn alle 20 Sekunden ab und holt nur bei
|
// winzig, deshalb fragt der Browser ihn alle 20 Sekunden ab und holt nur bei
|
||||||
// einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim
|
// einer Abweichung die geänderte Sitzung nach (Live-Abgleich beim
|
||||||
// gleichzeitigen Arbeiten, js/store.js). Für die Wandtafel ebenfalls lesbar.
|
// gleichzeitigen Arbeiten, js/store.js). Für die Wandtafel ebenfalls lesbar.
|
||||||
|
// GET /weeks-aelter?vor=<label>&anzahl=5: die nächsten älteren Sitzungen vor
|
||||||
|
// <label> vollständig ("Weitere laden" im Verlauf), dazu wie viele danach
|
||||||
|
// noch übrig sind.
|
||||||
|
app.get('/weeks-aelter', requireAuthOderKiosk, (req, res) => {
|
||||||
|
try {
|
||||||
|
const liste = echteSitzungenSortiert();
|
||||||
|
const vor = String(req.query.vor || '');
|
||||||
|
const anzahl = Math.min(50, Math.max(1, Math.floor(Number(req.query.anzahl)) || 5));
|
||||||
|
const idx = liste.findIndex(w => w.label === vor);
|
||||||
|
const bis = idx < 0 ? liste.length : idx;
|
||||||
|
const von = Math.max(0, bis - anzahl);
|
||||||
|
const weeks = loadWeeks(liste.slice(von, bis).map(w => w.label));
|
||||||
|
res.json({ ok: true, weeks, uebrig: von });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Ältere Sitzungen laden fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/week-revisionen', requireAuthOderKiosk, (req, res) => {
|
app.get('/week-revisionen', requireAuthOderKiosk, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const revisionen = {};
|
const revisionen = {};
|
||||||
@@ -5017,7 +5218,8 @@ function projektlisteAenderungen(week){
|
|||||||
const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean));
|
const uids = new Set(eingehend.map(p => String(p.projectUid || '')).filter(Boolean));
|
||||||
const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid)));
|
const fehlend = gespeichert.filter(r => !ids.has(r.project_id) && !(r.project_uid && uids.has(r.project_uid)));
|
||||||
if(!fehlend.length) return { neu, entfernt: [] };
|
if(!fehlend.length) return { neu, entfernt: [] };
|
||||||
const w = loadWeeks().find(x => x.label === label);
|
// Nur diese eine Sitzung laden, nicht alle (loadWeeks(label)).
|
||||||
|
const w = loadWeeks(label)[0];
|
||||||
const fehlendIds = new Set(fehlend.map(r => r.project_id));
|
const fehlendIds = new Set(fehlend.map(r => r.project_id));
|
||||||
return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] };
|
return { neu, entfernt: w ? (w.projects || []).filter(p => fehlendIds.has(String(p.id))) : [] };
|
||||||
}
|
}
|
||||||
@@ -5176,9 +5378,25 @@ app.post('/unlock-week', requireRecht('s.entsperren'), (req, res) => {
|
|||||||
// Route: Lädt alle Wochen (jetzt eine einzige globale Reihe) plus die
|
// Route: Lädt alle Wochen (jetzt eine einzige globale Reihe) plus die
|
||||||
// firmenweite Personenliste. Liefert strukturierte JSON-Objekte (aus den
|
// firmenweite Personenliste. Liefert strukturierte JSON-Objekte (aus den
|
||||||
// relationalen Tabellen zusammengesetzt); nach Label sortiert.
|
// relationalen Tabellen zusammengesetzt); nach Label sortiert.
|
||||||
|
// echteSitzungenSortiert: alle echten Sitzungen (ohne Sandbox) als schlanke
|
||||||
|
// Liste, älteste zuerst — {id, label, date, locked, updatedAt}.
|
||||||
|
function echteSitzungenSortiert(){
|
||||||
|
return db.prepare('SELECT week_id, label, date, locked, updated_at FROM weeks WHERE label != ?').all(SANDBOX_WEEK_LABEL)
|
||||||
|
.map(r => ({ id: r.week_id, label: r.label, date: r.date || '', locked: !!r.locked, updatedAt: r.updated_at || '' }))
|
||||||
|
.sort((a, b) => weekSortKeyServer(a.label).localeCompare(weekSortKeyServer(b.label)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /load-all?wochen=N: Mit wochen nur die jüngsten N Sitzungen vollständig
|
||||||
|
// (Eriks Wunsch: aktuelle + drei davor), dazu wochenListe = ALLE Sitzungen
|
||||||
|
// schlank (Name, Datum, Sperre) für Admin → Sitzungen und das Nachladen.
|
||||||
|
// Ohne wochen wie früher alle (z. B. ältere Browser-Stände).
|
||||||
app.get('/load-all', requireAuthOderKiosk, (req, res) => {
|
app.get('/load-all', requireAuthOderKiosk, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const allWeeks = loadWeeks();
|
const anzahl = Math.floor(Number(req.query.wochen));
|
||||||
|
const wochenListe = echteSitzungenSortiert();
|
||||||
|
const allWeeks = anzahl > 0
|
||||||
|
? loadWeeks(wochenListe.slice(-anzahl).map(w => w.label).concat([SANDBOX_WEEK_LABEL]))
|
||||||
|
: loadWeeks();
|
||||||
// Die reservierte Sandbox-"Sitzung" (Planung, siehe SANDBOX_WEEK_LABEL)
|
// Die reservierte Sandbox-"Sitzung" (Planung, siehe SANDBOX_WEEK_LABEL)
|
||||||
// ist keine echte Sitzung — sie wird separat als sandboxPlans
|
// ist keine echte Sitzung — sie wird separat als sandboxPlans
|
||||||
// ausgeliefert, statt in weeks[] zu landen. So kommt sie clientseitig
|
// ausgeliefert, statt in weeks[] zu landen. So kommt sie clientseitig
|
||||||
@@ -5189,7 +5407,7 @@ app.get('/load-all', requireAuthOderKiosk, (req, res) => {
|
|||||||
const sandboxPlans = sandbox ? sandbox.projects : [];
|
const sandboxPlans = sandbox ? sandbox.projects : [];
|
||||||
const sandboxUpdatedAt = sandbox ? sandbox.updatedAt : '';
|
const sandboxUpdatedAt = sandbox ? sandbox.updatedAt : '';
|
||||||
const people = loadGlobalPeople(req.user);
|
const people = loadGlobalPeople(req.user);
|
||||||
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt });
|
res.json({ ok: true, weeks, people, sandboxPlans, sandboxUpdatedAt, wochenListe });
|
||||||
} catch(err){
|
} catch(err){
|
||||||
console.error('Load error', err);
|
console.error('Load error', err);
|
||||||
res.status(500).json({ ok: false, error: String(err) });
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
@@ -6866,12 +7084,17 @@ function mailTeilLesen(roh, tiefe){
|
|||||||
if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' };
|
if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' };
|
||||||
if(!typ.startsWith('text/')) return { plain: '', html: '' };
|
if(!typ.startsWith('text/')) return { plain: '', html: '' };
|
||||||
const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase();
|
const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase();
|
||||||
let buf;
|
let text;
|
||||||
if(enc === 'base64') buf = Buffer.from(rumpf.replace(/\s+/g, ''), 'base64');
|
if(enc === 'base64' || enc === 'quoted-printable'){
|
||||||
else if(enc === 'quoted-printable'){
|
const buf = enc === 'base64'
|
||||||
buf = Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
|
? Buffer.from(rumpf.replace(/\s+/g, ''), 'base64')
|
||||||
} else buf = Buffer.from(rumpf, 'latin1');
|
: Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
|
||||||
const text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
|
text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
|
||||||
|
} else {
|
||||||
|
// 7bit/8bit: Der Browser hat die Datei bereits als Text gelesen (UTF-8) —
|
||||||
|
// ein Umweg über Bytes zerstörte hier die Umlaute.
|
||||||
|
text = rumpf;
|
||||||
|
}
|
||||||
return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' };
|
return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6927,6 +7150,7 @@ const KI_MAIL_SCHEMA = {
|
|||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
art: { type: 'string', enum: ['aufgabe', 'meilenstein'] },
|
||||||
titel: { type: 'string' },
|
titel: { type: 'string' },
|
||||||
start: { type: 'string' },
|
start: { type: 'string' },
|
||||||
ende: { type: 'string' },
|
ende: { type: 'string' },
|
||||||
@@ -6934,7 +7158,7 @@ const KI_MAIL_SCHEMA = {
|
|||||||
aufwandStunden: { type: 'number' },
|
aufwandStunden: { type: 'number' },
|
||||||
zitat: { type: 'string' }
|
zitat: { type: 'string' }
|
||||||
},
|
},
|
||||||
required: ['titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'],
|
required: ['art', 'titel', 'start', 'ende', 'zustaendig', 'aufwandStunden', 'zitat'],
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6947,7 +7171,8 @@ const KI_MAIL_SCHEMA = {
|
|||||||
// Mails bei temperature:0 einzeln prüfen (siehe CLAUDE.md "### Protokolle").
|
// Mails bei temperature:0 einzeln prüfen (siehe CLAUDE.md "### Protokolle").
|
||||||
const KI_MAIL_SYSTEM_PROMPT = 'Du bekommst eine E-Mail aus einem Ingenieurbüro und Angaben zum Projekt. Mache daraus '
|
const KI_MAIL_SYSTEM_PROMPT = 'Du bekommst eine E-Mail aus einem Ingenieurbüro und Angaben zum Projekt. Mache daraus '
|
||||||
+ 'Einträge für den Terminplan des Projekts: je Termin, Frist oder zu erledigender Arbeit einen Eintrag. '
|
+ 'Einträge für den Terminplan des Projekts: je Termin, Frist oder zu erledigender Arbeit einen Eintrag. '
|
||||||
+ 'Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" '
|
+ 'art: "meilenstein" für einen festen Stichtag ohne eigene Arbeit (Abgabe, Termin, Freigabe, Besprechung), '
|
||||||
|
+ '"aufgabe" für zu erledigende Arbeit. Titel kurz, Tätigkeit zuerst. Datumsangaben als JJJJ-MM-TT; relative Angaben wie "nächsten Freitag" '
|
||||||
+ 'rechnest du vom Datum der Mail aus. Ein einzelner Termin oder eine Frist: start und ende gleich. '
|
+ 'rechnest du vom Datum der Mail aus. Ein einzelner Termin oder eine Frist: start und ende gleich. '
|
||||||
+ 'Ohne Datum in der Mail bleiben start und ende leer. zustaendig: nur Namen aus der Liste der Beteiligten, '
|
+ 'Ohne Datum in der Mail bleiben start und ende leer. zustaendig: nur Namen aus der Liste der Beteiligten, '
|
||||||
+ 'die in der Mail als verantwortlich genannt sind, sonst leer. aufwandStunden nur wenn genannt, sonst 0. zitat: der Satz aus der Mail, '
|
+ 'die in der Mail als verantwortlich genannt sind, sonst leer. aufwandStunden nur wenn genannt, sonst 0. zitat: der Satz aus der Mail, '
|
||||||
@@ -6976,6 +7201,7 @@ function normalisiereMailEintrag(e){
|
|||||||
if(ende && !start) start = ende;
|
if(ende && !start) start = ende;
|
||||||
if(start && ende && ende < start){ const t = start; start = ende; ende = t; }
|
if(start && ende && ende < start){ const t = start; start = ende; ende = t; }
|
||||||
return {
|
return {
|
||||||
|
art: (e && e.art) === 'meilenstein' ? 'meilenstein' : 'aufgabe',
|
||||||
titel: String((e && e.titel) || '').trim().slice(0, 200),
|
titel: String((e && e.titel) || '').trim().slice(0, 200),
|
||||||
start, ende,
|
start, ende,
|
||||||
zustaendig: Array.isArray(e && e.zustaendig) ? e.zustaendig.map(String).filter(Boolean) : [],
|
zustaendig: Array.isArray(e && e.zustaendig) ? e.zustaendig.map(String).filter(Boolean) : [],
|
||||||
@@ -7084,6 +7310,155 @@ app.post('/plan-eintrag-aus-mail', requireAuth, requireRecht('proj.plan'), async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- Projekt-Notizen (Reiter „Notizen“, js/plan/plan-notizen.js) ----------
|
||||||
|
//
|
||||||
|
// Eriks Wunsch (Entwurf A „Liste + Editor“): gemeinsame Notizen je Projekt,
|
||||||
|
// formatiert wie der Sitzungskommentar, anheftbar, Mail per Drag & Drop.
|
||||||
|
// Die Notizen hängen am PROJEKT (project_uid), nicht an einer Sitzung —
|
||||||
|
// sie überstehen den Wochenwechsel, ohne kopiert zu werden, und gelten auch
|
||||||
|
// für Planung-Terminpläne (Sandbox). Gespeichert wird je Notiz, nicht über
|
||||||
|
// /save-week. Lesen: jedes Konto mit Grundzugang; Schreiben: Recht
|
||||||
|
// proj.plan; Löschen: Verfasser oder Administrator.
|
||||||
|
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS projekt_notizen (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_uid TEXT NOT NULL,
|
||||||
|
titel TEXT NOT NULL DEFAULT '',
|
||||||
|
html TEXT NOT NULL DEFAULT '',
|
||||||
|
angeheftet INTEGER NOT NULL DEFAULT 0,
|
||||||
|
quelle TEXT NOT NULL DEFAULT '',
|
||||||
|
erstellt_von INTEGER NOT NULL DEFAULT 0,
|
||||||
|
erstellt_am TEXT NOT NULL DEFAULT '',
|
||||||
|
geaendert_von INTEGER NOT NULL DEFAULT 0,
|
||||||
|
geaendert_am TEXT NOT NULL DEFAULT ''
|
||||||
|
)`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_projekt_notizen_uid ON projekt_notizen(project_uid)');
|
||||||
|
|
||||||
|
const NOTIZ_MAX_HTML = 200000;
|
||||||
|
|
||||||
|
function notizZeile(r){
|
||||||
|
return {
|
||||||
|
id: r.id, uid: r.project_uid, titel: r.titel, html: r.html, angeheftet: !!r.angeheftet, quelle: r.quelle,
|
||||||
|
erstelltVon: r.erstellt_name || '', erstelltVonId: r.erstellt_von, erstelltAm: r.erstellt_am,
|
||||||
|
geaendertVon: r.geaendert_name || '', geaendertAm: r.geaendert_am
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function notizLesen(id){
|
||||||
|
const r = db.prepare(`SELECT n.*, COALESCE(u1.display_name, 'Gelöschter Benutzer') AS erstellt_name,
|
||||||
|
COALESCE(u2.display_name, 'Gelöschter Benutzer') AS geaendert_name
|
||||||
|
FROM projekt_notizen n LEFT JOIN users u1 ON u1.id = n.erstellt_von LEFT JOIN users u2 ON u2.id = n.geaendert_von
|
||||||
|
WHERE n.id = ?`).get(String(id));
|
||||||
|
return r ? notizZeile(r) : null;
|
||||||
|
}
|
||||||
|
function istAdministrator(user){ return !!(user && (rollenCache.get(user.role) || {}).fest); }
|
||||||
|
|
||||||
|
function notizAnlegen(req, uid, titel, html, quelle){
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const jetzt = new Date().toISOString();
|
||||||
|
db.prepare(`INSERT INTO projekt_notizen (id, project_uid, titel, html, angeheftet, quelle, erstellt_von, erstellt_am, geaendert_von, geaendert_am)
|
||||||
|
VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
|
||||||
|
.run(id, uid, String(titel || '').slice(0, 300), String(html || '').slice(0, NOTIZ_MAX_HTML), quelle || '', req.user.id, jetzt, req.user.id, jetzt);
|
||||||
|
ereignisSenden({ typ: 'notizen', uid }, req);
|
||||||
|
return notizLesen(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/projekt-notizen', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const uid = String(req.query.uid || '');
|
||||||
|
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' });
|
||||||
|
const rows = db.prepare(`SELECT n.*, COALESCE(u1.display_name, 'Gelöschter Benutzer') AS erstellt_name,
|
||||||
|
COALESCE(u2.display_name, 'Gelöschter Benutzer') AS geaendert_name
|
||||||
|
FROM projekt_notizen n LEFT JOIN users u1 ON u1.id = n.erstellt_von LEFT JOIN users u2 ON u2.id = n.geaendert_von
|
||||||
|
WHERE n.project_uid = ? ORDER BY n.angeheftet DESC, n.geaendert_am DESC`).all(uid);
|
||||||
|
res.json({ ok: true, notizen: rows.map(notizZeile) });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notizen laden fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/projekt-notizen', requireAuth, requireRecht('proj.plan'), (req, res) => {
|
||||||
|
try {
|
||||||
|
const b = req.body || {};
|
||||||
|
const uid = String(b.uid || '');
|
||||||
|
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' });
|
||||||
|
res.json({ ok: true, notiz: notizAnlegen(req, uid, b.titel, b.html, '') });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notiz anlegen fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT {titel?, html?, angeheftet?, basis?} — basis = geaendertAm des Stands,
|
||||||
|
// auf dem die Änderung beruht; weicht der gespeicherte ab, 409 mit dem
|
||||||
|
// aktuellen Stand (jemand anderes hat inzwischen gespeichert).
|
||||||
|
app.put('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, res) => {
|
||||||
|
try {
|
||||||
|
const alt = notizLesen(req.params.id);
|
||||||
|
if(!alt) return res.status(404).json({ ok: false, error: 'Die Notiz gibt es nicht mehr.' });
|
||||||
|
const b = req.body || {};
|
||||||
|
if(typeof b.basis === 'string' && b.basis && b.basis !== alt.geaendertAm){
|
||||||
|
return res.status(409).json({ ok: false, konflikt: true, notiz: alt,
|
||||||
|
error: alt.geaendertVon + ' hat diese Notiz inzwischen geändert.' });
|
||||||
|
}
|
||||||
|
const titel = typeof b.titel === 'string' ? b.titel.slice(0, 300) : alt.titel;
|
||||||
|
const html = typeof b.html === 'string' ? b.html.slice(0, NOTIZ_MAX_HTML) : alt.html;
|
||||||
|
const angeheftet = typeof b.angeheftet === 'boolean' ? (b.angeheftet ? 1 : 0) : (alt.angeheftet ? 1 : 0);
|
||||||
|
// Anheften allein ist keine inhaltliche Änderung: Zeitstempel und
|
||||||
|
// Bearbeiter bleiben, damit die Sortierung „zuletzt geändert“ stimmt.
|
||||||
|
const nurPin = typeof b.titel !== 'string' && typeof b.html !== 'string';
|
||||||
|
const jetzt = nurPin ? alt.geaendertAm : new Date().toISOString();
|
||||||
|
db.prepare(`UPDATE projekt_notizen SET titel = ?, html = ?, angeheftet = ?, geaendert_am = ?
|
||||||
|
${nurPin ? '' : ', geaendert_von = ' + Number(req.user.id)} WHERE id = ?`)
|
||||||
|
.run(titel, html, angeheftet, jetzt, alt.id);
|
||||||
|
ereignisSenden({ typ: 'notizen', uid: alt.uid }, req);
|
||||||
|
res.json({ ok: true, notiz: notizLesen(alt.id) });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notiz speichern fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, res) => {
|
||||||
|
try {
|
||||||
|
const alt = notizLesen(req.params.id);
|
||||||
|
if(!alt) return res.json({ ok: true });
|
||||||
|
if(alt.erstelltVonId !== req.user.id && !istAdministrator(req.user)){
|
||||||
|
return res.status(403).json({ ok: false, error: 'Löschen darf nur, wer die Notiz angelegt hat, oder ein Administrator.' });
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM projekt_notizen WHERE id = ?').run(alt.id);
|
||||||
|
ereignisSenden({ typ: 'notizen', uid: alt.uid }, req);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notiz löschen fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /projekt-notizen/aus-mail {uid, text|eml} — Mail als neue Notiz, OHNE
|
||||||
|
// KI: Titel = Betreff (sonst erste Zeile), oben Absender und Datum, darunter
|
||||||
|
// der Text (zitierte Vorgänger-Mails abgeschnitten, wie bei „Termin aus Mail“).
|
||||||
|
app.post('/projekt-notizen/aus-mail', requireAuth, requireRecht('proj.plan'), (req, res) => {
|
||||||
|
try {
|
||||||
|
const b = req.body || {};
|
||||||
|
const uid = String(b.uid || '');
|
||||||
|
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' });
|
||||||
|
const mail = (typeof b.eml === 'string' && b.eml.trim()) ? leseEml(b.eml)
|
||||||
|
: { betreff: '', absender: '', datum: '', text: String(b.text || '') };
|
||||||
|
const text = mailZitateAbschneiden(mail.text).slice(0, 50000);
|
||||||
|
if(!text && !mail.betreff) return res.status(400).json({ ok: false, error: 'In der Mail wurde kein Text gefunden.' });
|
||||||
|
const ersteZeile = (text.split('\n').map(z => z.trim()).find(Boolean) || 'Mail').slice(0, 120);
|
||||||
|
const escHtml = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const kopf = [mail.absender ? '<b>Von:</b> ' + escHtml(mail.absender) : '',
|
||||||
|
mail.datum ? '<b>Datum:</b> ' + escHtml(teamsDatum(mail.datum)) : ''].filter(Boolean).join('<br>');
|
||||||
|
const html = (kopf ? kopf + '<br><br>' : '') + escHtml(text).replace(/\n/g, '<br>');
|
||||||
|
res.json({ ok: true, notiz: notizAnlegen(req, uid, mail.betreff || ersteZeile, html, 'mail') });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notiz aus Mail fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Plan Checker (plan_checker_merkmale) ----------
|
// ---------- Plan Checker (plan_checker_merkmale) ----------
|
||||||
//
|
//
|
||||||
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt
|
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt
|
||||||
@@ -8265,6 +8640,169 @@ app.get('/app-settings', requireAuthOderKiosk, (req, res) => {
|
|||||||
// "### Protokolle". GET liefert je Anbieter, OB ein Schlüssel gesetzt ist
|
// "### Protokolle". GET liefert je Anbieter, OB ein Schlüssel gesetzt ist
|
||||||
// (nie der Schlüssel selbst, wie ein Passwort) sowie die — nicht geheime —
|
// (nie der Schlüssel selbst, wie ein Passwort) sowie die — nicht geheime —
|
||||||
// apiUrl zum Weiterbearbeiten.
|
// apiUrl zum Weiterbearbeiten.
|
||||||
|
// ---------- Kalender-Abo für Outlook (iCalendar) ----------
|
||||||
|
//
|
||||||
|
// Eriks Wunsch: die eigenen Termine in Outlook sehen. Jedes Konto bekommt
|
||||||
|
// unter Einstellungen → Mein Konto eine geheime Adresse
|
||||||
|
// /kalender/<token>.ics; Outlook abonniert sie („Aus dem Internet“) und holt
|
||||||
|
// sie selbst regelmäßig. NUR LESEN — geändert wird weiter in der App.
|
||||||
|
// Inhalt aus der jüngsten echten Sitzung: eigene offene Aufgaben mit Termin,
|
||||||
|
// Meilensteine der eigenen Projekte (zugeordnet oder mit eigener Aufgabe)
|
||||||
|
// und die eigenen Abwesenheiten. Ganztägige Einträge (VALUE=DATE), damit
|
||||||
|
// keine Zeitzone im Spiel ist.
|
||||||
|
//
|
||||||
|
// Der Token IST die Anmeldung (Outlook kann sich nicht einloggen) — deshalb
|
||||||
|
// lang und zufällig, jederzeit neu erzeugbar (alte Adresse ungültig) oder
|
||||||
|
// abschaltbar. Deaktivierte Konten und reine Wandtafel-Konten bekommen 404.
|
||||||
|
|
||||||
|
function kalenderUrl(req, token){
|
||||||
|
return req.protocol + '://' + req.get('host') + '/kalender/' + token + '.ics';
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/mein-kalender', requireAuth, (req, res) => {
|
||||||
|
const row = db.prepare('SELECT kalender_token FROM users WHERE id = ?').get(req.user.id);
|
||||||
|
const token = row && row.kalender_token;
|
||||||
|
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST {aus: true} schaltet ab, sonst wird eine NEUE Adresse erzeugt (die
|
||||||
|
// bisherige wird damit ungültig).
|
||||||
|
app.post('/mein-kalender', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const aus = !!(req.body && req.body.aus);
|
||||||
|
const token = aus ? '' : crypto.randomBytes(24).toString('base64url');
|
||||||
|
db.prepare('UPDATE users SET kalender_token = ? WHERE id = ?').run(token, req.user.id);
|
||||||
|
res.json({ ok: true, url: token ? kalenderUrl(req, token) : '' });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Kalender-Adresse speichern fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// icsText: Text nach RFC 5545 maskieren.
|
||||||
|
function icsText(s){
|
||||||
|
return String(s || '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
||||||
|
}
|
||||||
|
// icsZeile: Zeilen über 75 Byte falten (Folgezeilen beginnen mit Leerzeichen).
|
||||||
|
function icsZeile(z){
|
||||||
|
if(Buffer.byteLength(z, 'utf8') <= 75) return z;
|
||||||
|
const teile = [];
|
||||||
|
let rest = z;
|
||||||
|
// Erste Zeile höchstens 75 Byte, Folgezeilen 74 (plus führendes Leerzeichen).
|
||||||
|
const grenze = () => teile.length ? 74 : 75;
|
||||||
|
while(Buffer.byteLength(rest, 'utf8') > grenze()){
|
||||||
|
let n = Math.min(rest.length, grenze());
|
||||||
|
while(Buffer.byteLength(rest.slice(0, n), 'utf8') > grenze()) n--;
|
||||||
|
// Kein Ersatzpaar (Emoji o. ä.) zerschneiden.
|
||||||
|
const c = rest.charCodeAt(n - 1);
|
||||||
|
if(c >= 0xD800 && c <= 0xDBFF) n--;
|
||||||
|
teile.push(rest.slice(0, n));
|
||||||
|
rest = rest.slice(n);
|
||||||
|
}
|
||||||
|
teile.push(rest);
|
||||||
|
return teile.join('\r\n ');
|
||||||
|
}
|
||||||
|
function icsDatum(iso){ return String(iso || '').replace(/-/g, ''); }
|
||||||
|
// icsTagDanach: DTEND ganztägiger Einträge ist der Tag NACH dem letzten Tag.
|
||||||
|
function icsTagDanach(iso){
|
||||||
|
const d = new Date(iso + 'T12:00:00');
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
const ICS_DATUM = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
// kalenderEreignisse: die Einträge einer Person aus einer Sitzung plus ihren
|
||||||
|
// Abwesenheiten. Rein (ohne Datenbank), damit testbar. Parameter: ich
|
||||||
|
// (person_id), w (Sitzung oder null), abwesenheiten (Zeilen aus
|
||||||
|
// user_absences). Rückgabe: [{uid, start, ende, titel, projekt, text, frei}].
|
||||||
|
function kalenderEreignisse(ich, w, abwesenheiten){
|
||||||
|
const ereignisse = [];
|
||||||
|
if(w && ich){
|
||||||
|
(w.projects || []).forEach(p => {
|
||||||
|
const projekt = [p.nr, p.name].filter(Boolean).join(' ');
|
||||||
|
const zugeordnet = Object.keys(p.assign || {}).some(k => k.split('|')[0] === ich && Number(p.assign[k]) > 0);
|
||||||
|
const plan = p.plan || {};
|
||||||
|
let eigeneAufgabe = false;
|
||||||
|
(plan.todos || []).forEach(t => {
|
||||||
|
if(!Array.isArray(t.assignees) || !t.assignees.includes(ich)) return;
|
||||||
|
eigeneAufgabe = true;
|
||||||
|
if(t.done || t.lph) return;
|
||||||
|
const start = ICS_DATUM.test(t.start || '') ? t.start : (ICS_DATUM.test(t.end || '') ? t.end : '');
|
||||||
|
const ende = ICS_DATUM.test(t.end || '') ? t.end : start;
|
||||||
|
if(!start) return;
|
||||||
|
ereignisse.push({
|
||||||
|
uid: 'aufgabe-' + t.id, start, ende,
|
||||||
|
titel: t.title || 'Aufgabe', projekt,
|
||||||
|
text: 'Aufgabe in ' + projekt + (Number(t.progress) > 0 ? ' · ' + Number(t.progress) + ' % erledigt' : '')
|
||||||
|
+ (t.notiz ? '\n\n' + String(t.notiz).slice(0, 1500) : '')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if(!zugeordnet && !eigeneAufgabe) return;
|
||||||
|
(plan.milestones || []).forEach(m => {
|
||||||
|
if(!ICS_DATUM.test(m.date || '')) return;
|
||||||
|
ereignisse.push({
|
||||||
|
uid: 'meilenstein-' + (p.projectUid || p.id) + '-' + m.id, start: m.date, ende: m.date,
|
||||||
|
titel: '◆ ' + (m.name || 'Meilenstein'), projekt,
|
||||||
|
text: 'Meilenstein in ' + projekt + (m.notiz ? '\n\n' + String(m.notiz).slice(0, 1500) : '')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(abwesenheiten || []).forEach(a => {
|
||||||
|
if(!ICS_DATUM.test(a.start || '')) return;
|
||||||
|
ereignisse.push({
|
||||||
|
uid: 'abwesenheit-' + a.id, start: a.start, ende: ICS_DATUM.test(a.end || '') ? a.end : a.start,
|
||||||
|
titel: a.type || 'Abwesend', projekt: '', text: a.note || '', frei: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return ereignisse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// kalenderText: die iCalendar-Datei (CRLF, gefaltete Zeilen).
|
||||||
|
function kalenderText(name, ereignisse){
|
||||||
|
const jetzt = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
||||||
|
const zeilen = [
|
||||||
|
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Projektuebersicht//Kalender//DE', 'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:PUBLISH', 'X-WR-CALNAME:' + icsText('Projektübersicht – ' + (name || '')),
|
||||||
|
'X-WR-TIMEZONE:Europe/Berlin', 'REFRESH-INTERVAL;VALUE=DURATION:PT1H', 'X-PUBLISHED-TTL:PT1H'
|
||||||
|
];
|
||||||
|
ereignisse.forEach(e => {
|
||||||
|
zeilen.push('BEGIN:VEVENT',
|
||||||
|
'UID:' + icsText(e.uid) + '@projektuebersicht',
|
||||||
|
'DTSTAMP:' + jetzt,
|
||||||
|
'DTSTART;VALUE=DATE:' + icsDatum(e.start),
|
||||||
|
'DTEND;VALUE=DATE:' + icsTagDanach(e.ende < e.start ? e.start : e.ende),
|
||||||
|
'SUMMARY:' + icsText(e.titel + (e.projekt ? ' (' + e.projekt + ')' : '')),
|
||||||
|
'DESCRIPTION:' + icsText(e.text),
|
||||||
|
// Als „frei“ eintragen: Ganztägige Aufgaben sollen die Verfügbarkeit
|
||||||
|
// in Outlook nicht blockieren; Abwesenheiten zeigen „abwesend“.
|
||||||
|
'TRANSP:TRANSPARENT',
|
||||||
|
e.frei ? 'X-MICROSOFT-CDO-BUSYSTATUS:OOF' : 'X-MICROSOFT-CDO-BUSYSTATUS:FREE',
|
||||||
|
'END:VEVENT');
|
||||||
|
});
|
||||||
|
zeilen.push('END:VCALENDAR');
|
||||||
|
return zeilen.map(icsZeile).join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/kalender/:datei', (req, res) => {
|
||||||
|
try {
|
||||||
|
const token = String(req.params.datei || '').replace(/\.ics$/i, '');
|
||||||
|
if(token.length < 20) return res.status(404).end();
|
||||||
|
const u = db.prepare('SELECT id, display_name, person_id, role, active FROM users WHERE kalender_token = ?').get(token);
|
||||||
|
if(!u || !u.active || !hatRecht({ id: u.id, role: u.role }, 'app')) return res.status(404).end();
|
||||||
|
const label = echteSitzungenSortiert().map(w => w.label).pop();
|
||||||
|
const w = label ? loadWeeks(label)[0] : null;
|
||||||
|
const abw = db.prepare('SELECT id, start, end, type, note FROM user_absences WHERE user_id = ?').all(u.id);
|
||||||
|
res.set('Content-Type', 'text/calendar; charset=utf-8');
|
||||||
|
res.set('Cache-Control', 'no-cache');
|
||||||
|
res.set('Content-Disposition', 'inline; filename="projektuebersicht.ics"');
|
||||||
|
res.send(kalenderText(u.display_name, kalenderEreignisse(u.person_id || '', w, abw)));
|
||||||
|
} catch(err){
|
||||||
|
console.error('Kalender ausliefern fehlgeschlagen:', err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/my-ai-settings', requireAuth, (req, res) => {
|
app.get('/my-ai-settings', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const chatgpt = readUserAiKey(req.user.id, 'chatgpt');
|
const chatgpt = readUserAiKey(req.user.id, 'chatgpt');
|
||||||
@@ -8879,6 +9417,10 @@ function betriebAufraeumen(){
|
|||||||
try {
|
try {
|
||||||
const grenze = Date.now() - loginBlockMs();
|
const grenze = Date.now() - loginBlockMs();
|
||||||
loginAttempts.forEach((e, k) => { if(e.first < grenze) loginAttempts.delete(k); });
|
loginAttempts.forEach((e, k) => { if(e.first < grenze) loginAttempts.delete(k); });
|
||||||
|
// Merkliste der last_seen_at-Schreibzeiten (attachUser) nicht endlos
|
||||||
|
// wachsen lassen: Einträge älter als eine Stunde braucht niemand mehr.
|
||||||
|
const stundeHer = Date.now() - 60 * 60 * 1000;
|
||||||
|
sitzungZuletztGeschrieben.forEach((t, k) => { if(t < stundeHer) sitzungZuletztGeschrieben.delete(k); });
|
||||||
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||||
} catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); }
|
} catch(err){ console.error('Aufräumen fehlgeschlagen:', err.message); }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user