4 Commits
Author SHA1 Message Date
erikandClaude Opus 5.5 e697057b86 Projekte aus „Meine Aufgaben“ im selben Tab öffnen
- „Öffnen“, Termine und „weitere Termine im Projekt“ im Dashboard öffnen
  den Projektbereich jetzt im selben Tab statt in einem neuen, ohne
  Neuladen der Seite (Strg/Cmd-Klick und Mittelklick öffnen weiter einen
  neuen Tab)

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:36:06 +01:00
14 changed files with 1139 additions and 379 deletions

No files matched your search

+85 -26
View File
@@ -1521,6 +1521,25 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this
view now also follows the app-wide dark-mode palette like everywhere
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“):
`#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am
Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der
@@ -1533,6 +1552,37 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
`plan-modals.js`) über dem bisherigen Feld `#milestoneEditPosition`,
das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert.
Stile: Block „Seitenpanel“ am Ende von `plan-styles.css`.
- **Notizen** (Reiter zwischen Terminplan und Aufgaben, Eriks Wahl „A –
Liste + Editor“ aus drei Entwürfen, `js/plan/plan-notizen.js`):
gemeinsame Notizen je Projekt. Links Liste (angeheftete oben, sonst
zuletzt geändert zuerst, Suche über Titel und Text), rechts Editor mit
Titel, Werkzeugleiste des Sitzungskommentars (`richtextToolbarHtml()`)
und Text; **automatisches Speichern** 1,2 s nach der letzten Änderung und
beim Verlassen des Feldes (MutationObserver, weil die Werkzeugleiste kein
input-Ereignis auslöst), Anzeige „Gespeichert ✓“. Anheften, Löschen
zweistufig im Knopf. **Mail** (Text oder `.eml`) per Drag & Drop auf den
Bereich oder Strg+V außerhalb der Felder → neue Notiz OHNE KI (Titel =
Betreff, Von/Datum oben, Zitate abgeschnitten).
Server: Tabelle `projekt_notizen` (an `project_uid`, nicht an der
Sitzung; in `IMPORT_TABLES` + `IMPORT_OPTIONAL_TABLES`), Routen `GET
/projekt-notizen?uid=`, `POST /projekt-notizen`, `PUT
/projekt-notizen/:id` (mit `basis` → 409 bei fremder Änderung; Client
bietet „Meine Fassung speichern“/„Neuen Stand laden“), `DELETE` (nur
Verfasser oder Administrator), `POST /projekt-notizen/aus-mail`. Lesen:
Grundzugang; Schreiben: `proj.plan`. Live-Ereignis `{typ:'notizen',
uid}` → `notizenLiveAenderung()`. Stile: Block „Notizen“ am Ende von
`plan-styles.css`.
**Gefunden dabei:** `leseEml()` zerstörte Umlaute in `.eml`-Dateien ohne
Transfer-Kodierung (7bit/8bit, der Normalfall) — der Text kam schon als
UTF-8-Zeichenkette vom Browser und wurde fälschlich als latin1-Bytes neu
dekodiert. Betraf auch „Termin aus Mail“; behoben.
- **Reiter „Leistungsphasen“ entfernt** (Eriks Wunsch, 30.10.2026): Nav-Knopf,
Abschnitt `#planSection-phasen`, `renderPlanPhasen()` und der CSS-Block
sind weg; ein alter Link auf `#phasen` landet auf dem Dashboard.
`js/plan/plan-phasen.js` enthält nur noch `aufgabenPhase()` (genutzt von
der Aufgabenliste und dem Terminplan-PDF). „+ Leistungsphase“ und
„Terminplan aus Honorar“ bleiben im Terminplan unter „+ Neu“. Der
folgende Absatz beschreibt den entfernten Stand.
- **Leistungsphasen** (eigener Reiter zwischen Terminplan und Aufgaben,
Eriks Wahl „B“, `js/plan/plan-phasen.js`): oben ein Ablaufbalken aller
Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter
@@ -4210,36 +4260,45 @@ kopieren, „In Outlook öffnen“ (`webcal:`-Link), Anleitung, „Neue Adresse
Microsoft-Server aus dem Internet ab — dafür muss der Server von außen
erreichbar sein. Das klassische Outlook am PC holt es im Firmennetz direkt.
## Teams: Nachricht bei neuen Aufgaben
## Teams: persönliche Nachricht bei neuen Aufgaben
Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, erscheint in einem
Teams-Kanal eine Karte mit Link direkt auf die Aufgabe.
- **Einrichten:** In Teams im Kanal *Workflows* → „Beim Empfang einer
Webhook-Anfrage in einem Kanal posten“; die erzeugte https-Adresse unter
**Admin → Teams** eintragen (`loadAdminTeams()`, `js/admin/admin.js`,
Recht **`a.teams`**, neu im Katalog „Admin-Bereich“), „Testnachricht
senden“. Anleitung steht aufklappbar in der Karte.
- **Speicher:** Tabelle `integrationen` (schluessel/wert; `teams_webhook`,
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 — `GET /admin/teams` liefert nur `konfiguriert`/`host`.
`PUT /admin/teams {webhook?, neueAufgaben?}` (nur https), `POST
/admin/teams/test` (Fehler als `{ok:false}` mit HTTP 200).
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`, jetzt mit `req`) sammelt je Aufgabe die NEU Zuständigen
— auch Personen ohne Konto, nicht wer sich selbst zuweist, keine
Phasenbalken, nie die Sandbox — und `teamsNeueAufgaben()` schickt je
Aufgabe EINE Adaptive Card (`teamsKarte()`: Titel, Projekt, Zuständig,
Fällig/Zeitraum, Aufwand, Zugewiesen von, Knopf „Aufgabe öffnen“). Im
Hintergrund; Fehler nur im Log, das Speichern scheitert nie daran.
`/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 (= Adresse
der App im Büro; funktioniert nur im Büronetz/VPN). Im Browser liest
`plan-state.js` `aufgabe` beim Laden (`startAufgabe`): öffnet die
JÜNGSTE Sitzung, falls der Link eine ältere nennt (Aufgaben-ids bleiben
beim Sitzungswechsel gleich), springt nach „Aufgaben“ und öffnet das
Aufgaben-Panel; Adresszeile wird nachgezogen.
- Braucht Internetzugang nach außen (Microsoft-Server).
&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`)
+70 -40
View File
@@ -38,8 +38,8 @@
<link rel="icon" type="image/png" href="/logo.png">
<link rel="alternate icon" href="/logo.ico">
<link rel="apple-touch-icon" href="/logo.png">
<link rel="stylesheet" href="/styles.css?v=110">
<link rel="stylesheet" href="/plan-styles.css?v=45">
<link rel="stylesheet" href="/styles.css?v=111">
<link rel="stylesheet" href="/plan-styles.css?v=51">
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
@@ -679,23 +679,31 @@
<!-- Teams-Anbindung (loadAdminTeams(), js/admin/admin.js; Server: /admin/teams*) -->
<section class="settings-card" id="adminTeamsCard" data-admin-section="teams" style="display:none">
<h3 class="settings-card-title">Teams <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
<p class="hint">Bekommt jemand eine Aufgabe zugewiesen, erscheint in einem Teams-Kanal eine
Nachricht mit Link direkt auf die Aufgabe im Projekt.</p>
<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 die Adresse in Teams ein</summary>
<summary>So richten Sie den Workflow in Teams ein</summary>
<ol>
<li>In Teams den gewünschten Kanal öffnen, oben bei den drei Punkten
<i>Workflows</i> wählen.</li>
<li>Die Vorlage <i>„Beim Empfang einer Webhook-Anfrage in einem Kanal posten“</i>
auswählen, einen Namen vergeben (z. B. „Projektübersicht“) und bestätigen.</li>
<li>Teams zeigt am Ende eine lange Adresse (beginnt mit https://). Diese kopieren,
<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“ — im Kanal sollte eine Karte erscheinen.</li>
<li>„Testnachricht senden“ — die Nachricht kommt an Sie selbst.</li>
</ol>
<p class="hint">Der Server braucht dafür Internetzugang nach außen. Der Link in der
Nachricht führt auf die Adresse, unter der die App im Büro aufgerufen wird — er
funktioniert also nur im Büronetz (bzw. per VPN).</p>
<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">
@@ -884,7 +892,7 @@
<nav class="plan-nav" id="planNav">
<button class="plan-nav-item" data-section="dashboard" onclick="showPlanSection('dashboard')">Dashboard</button>
<button class="plan-nav-item" data-section="terminplan" onclick="showPlanSection('terminplan')">Terminplan</button>
<button class="plan-nav-item" data-section="phasen" onclick="showPlanSection('phasen')">Leistungsphasen</button>
<button class="plan-nav-item" data-section="notizen" onclick="showPlanSection('notizen')">Notizen</button>
<button class="plan-nav-item" data-section="aufgaben" onclick="showPlanSection('aufgaben')">Aufgaben</button>
<button class="plan-nav-item" data-section="leistung" onclick="showPlanSection('leistung')">Leistung</button>
<button class="plan-nav-item" data-section="dateien" onclick="showPlanSection('dateien')">Dateien</button>
@@ -1033,17 +1041,12 @@
</section>
<!-- ====================== Bereich: Aufgaben ===================== -->
<!-- ================== Bereich: Leistungsphasen ================== -->
<!-- Eriks Wahl "B": die Phasenbalken des Terminplans als eigener
Bereich — Ablaufbalken über alle Phasen, darunter eine Kachel je
Phase (renderPlanPhasen(), js/plan/plan-phasen.js). In der
Aufgabenliste stehen sie dafür nicht mehr. -->
<section class="plan-section" id="planSection-phasen" data-section="phasen">
<div class="plan-main-header">
<button onclick="addPlanPhase()" id="addPhaseBtnPhasen">+ Leistungsphase</button>
<button class="secondary" onclick="openHonorarModal()" id="honorarBtnPhasen" title="Terminplan aus Honorar und Leistungsphasen-Anteilen errechnen">Terminplan aus Honorar</button>
</div>
<div id="planPhasenWrap"></div>
<!-- ======================= Bereich: Notizen ======================= -->
<!-- Eriks Wahl „A – Liste + Editor“: gemeinsame Notizen je Projekt
(renderPlanNotizen(), js/plan/plan-notizen.js; Server:
/projekt-notizen*). Ersetzt den früheren Reiter „Leistungsphasen“. -->
<section class="plan-section" id="planSection-notizen" data-section="notizen">
<div id="planNotizenWrap"></div>
</section>
<section class="plan-section" id="planSection-aufgaben" data-section="aufgaben">
@@ -1602,12 +1605,38 @@
<section class="pd-abs" id="todoEditWerAbs">
<h3>Wer</h3>
<div id="todoEditAssignees" class="person-chips"></div>
<label class="pd-aufwand" for="todoEditAufwand">
<span>Aufwand</span>
<input type="number" id="todoEditAufwand" min="0" step="0.5" placeholder="0" inputmode="decimal">
<span>Stunden, optional</span>
</label>
<p class="hint pd-hint">Zählt zur Auslastung der Zuständigen, verteilt auf den Zeitraum der Aufgabe.</p>
<!-- Externe Personen: Kollegen mit Konto, die dem Projekt NICHT
zugeordnet sind (Eriks Wunsch). todoExternHinzufuegen()/
todoExternEntfernen(), js/plan/plan-modals.js -->
<div class="pd-externe">
<div class="pd-externe-kopf">Externe Personen <span class="hint">nicht im Projekt</span></div>
<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 class="pd-abs">
<h3>Stand</h3>
@@ -1957,7 +1986,7 @@
<script src="/js/date-picker.js?v=6"></script>
<script src="/js/server.js?v=7"></script>
<script src="/js/auth.js?v=11"></script>
<script src="/js/store.js?v=15"></script>
<script src="/js/store.js?v=16"></script>
<script src="/js/weeks.js?v=12"></script>
<script src="/js/board/board.js?v=41"></script>
<script src="/js/wochenbericht/wochenbericht.js?v=29"></script>
@@ -1973,7 +2002,7 @@
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
load.js (faktorFormat), steht deshalb dahinter. -->
<script src="/js/projekt-wizard.js?v=6"></script>
<script src="/js/dashboard/dashboard.js?v=10"></script>
<script src="/js/dashboard/dashboard.js?v=11"></script>
<script src="/js/benachrichtigungen.js?v=5"></script>
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
@@ -1983,7 +2012,7 @@
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
<script src="/js/wand/wand.js?v=21"></script>
<script src="/js/settings/settings.js?v=14"></script>
<script src="/js/admin/admin.js?v=21"></script>
<script src="/js/admin/admin.js?v=23"></script>
<script src="/js/admin-users.js?v=10"></script>
<script src="/js/admin/rollen.js?v=1"></script>
<script src="/js/einfuehrung.js?v=2"></script>
@@ -1995,21 +2024,22 @@
(das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach
js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js
umhuellt. -->
<script src="/js/plan/plan-state.js?v=24"></script>
<script src="/js/plan/plan-sections.js?v=10"></script>
<script src="/js/plan/plan-state.js?v=25"></script>
<script src="/js/plan/plan-sections.js?v=12"></script>
<script src="/js/plan/plan-info.js?v=15"></script>
<script src="/js/plan/plan-history.js?v=4"></script>
<script src="/js/plan/plan-build-gantt.js?v=11"></script>
<script src="/js/plan/plan-gantt.js?v=21"></script>
<script src="/js/plan/plan-gantt.js?v=22"></script>
<script src="/js/plan/plan-drag.js?v=8"></script>
<script src="/js/plan/plan-modals.js?v=15"></script>
<script src="/js/plan/plan-modals.js?v=18"></script>
<script src="/js/plan/plan-tasks.js?v=4"></script>
<script src="/js/plan/plan-phasen.js?v=2"></script>
<script src="/js/plan/plan-phasen.js?v=3"></script>
<script src="/js/plan/plan-files.js?v=4"></script>
<script src="/js/plan/plan-protokoll.js?v=20"></script>
<script src="/js/plan/plan-templates.js?v=11"></script>
<script src="/js/plan/plan-protokoll-todos.js?v=2"></script>
<script src="/js/plan/plan-mail-eintrag.js?v=4"></script>
<script src="/js/plan/plan-notizen.js?v=1"></script>
<script src="/js/plan/plan-honorar.js?v=9"></script>
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen
+56 -12
View File
@@ -1113,22 +1113,66 @@ async function loadAdminTeams(){
try { d = await fetch(baseUrl() + '/admin/teams').then(r => r.json()); }
catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
if(!d || !d.ok){ el.innerHTML = '<span class="settings-warn">Keine Berechtigung.</span>'; return; }
// Ist schon eine Adresse hinterlegt, bleibt das Eingabefeld zu, bis man
// „Ändern“ wählt — die Adresse selbst kommt ohnehin nie zurück.
const feldOffen = !d.konfiguriert;
el.innerHTML = `
<div class="settings-kv-row"><span class="settings-kv-key">Status</span>
<span>${d.konfiguriert ? '<span class="sitzung-chip ist-geoeffnet">Verbunden</span> <span class="hint" style="margin:0">' + esc(d.host) + '</span>'
: '<span class="sitzung-chip ist-gesperrt">Nicht eingerichtet</span>'}</span></div>
<label class="settings-field"><span>${d.konfiguriert ? 'Neue Adresse (ersetzt die bisherige)' : 'Adresse aus dem Teams-Workflow'}</span>
<input type="password" id="teamsWebhook" placeholder="https://…" autocomplete="off"></label>
<label class="settings-glas-schalter"><input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''}
onchange="teamsSpeichern({ neueAufgaben: this.checked })"> Neue Aufgaben melden</label>
<div class="kalender-aktionen" style="margin-top:10px">
<button type="button" onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Adresse speichern</button>
<button type="button" class="secondary" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht senden</button>
${d.konfiguriert ? '<button type="button" class="secondary" onclick="teamsEntfernen()">Entfernen</button>' : ''}
<div 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', {
@@ -1156,7 +1200,7 @@ async function teamsTesten(){
try {
const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json());
if(fehler){ fehler.hidden = d.ok; fehler.textContent = d.ok ? '' : 'Nicht gesendet: ' + (d.error || 'unbekannter Fehler'); }
if(d.ok) alert('Testnachricht gesendet — bitte im Teams-Kanal nachsehen.');
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 {
+20 -3
View File
@@ -253,6 +253,23 @@ function dashProjektUrl(p, abschnitt){
return url.toString();
}
// dashImTabOeffnen: öffnet den Projektbereich im SELBEN Tab (Eriks Wunsch),
// ohne die Seite neu zu laden. Strg/Cmd/Umschalt-Klick und Mittelklick
// behalten das Browser-Verhalten (neuer Tab), dafür bleibt das href stehen.
// Der Abschnitt (#aufgaben …) wird wie in springZuEinstellungen() VOR
// showTab('plan') gesetzt, dessen updateRouteUrl() ihn übernimmt.
function dashImTabOeffnen(ev, a){
if(ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return true;
ev.preventDefault();
const url = new URL(a.href);
const name = decodeURIComponent(url.pathname.split('/').pop() || '');
setPlanTarget(url.searchParams.get('weekLabel') || '', url.searchParams.get('projNr') || '', name);
location.hash = url.hash.slice(1) || 'dashboard';
showTab('plan');
window.scrollTo(0, 0);
return false;
}
// DASH_TERMINE_JE_PROJEKT: so viele Termine stehen je Projekt offen da,
// Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst
// verdrängt ein Projekt mit 25 gerissenen Fristen alle anderen aus dem Blick
@@ -441,7 +458,7 @@ function renderDashProjekte(daten){
? sichtbar.map(t => renderDashTermin(p, t)).join('')
: '<div class="dash-leer">Keine offenen Termine.</div>';
const mehr = rest > 0
? '<a class="dash-proj-mehr" href="' + dashProjektUrl(p, 'aufgaben') + '" target="_blank" rel="noopener">' + rest + ' weitere Termine im Projekt</a>'
? '<a class="dash-proj-mehr" href="' + dashProjektUrl(p, 'aufgaben') + '" onclick="return dashImTabOeffnen(event, this)">' + rest + ' weitere Termine im Projekt</a>'
: '';
// Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele
// Termine überfällig bzw. offen sind.
@@ -456,7 +473,7 @@ function renderDashProjekte(daten){
+ '</button>'
+ '<span class="dash-proj-kurz">' + kurz + '</span>'
+ '<span class="dash-rollen">' + rollen + '</span>'
+ '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" target="_blank" rel="noopener" title="Projekt öffnen">Öffnen</a>'
+ '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" onclick="return dashImTabOeffnen(event, this)" title="Projekt öffnen">Öffnen</a>'
+ '</div>'
+ '<div class="dash-proj-inhalt">'
+ '<div class="dash-proj-sub">' + phase + '</div>'
@@ -480,7 +497,7 @@ function renderDashTermin(p, t, mitProjekt){
const unter = mitProjekt
? (p.nr ? p.nr + ' ' : '') + (p.name || '')
: (t.art === 'meilenstein' ? 'Meilenstein' : (t.meins ? 'meine Aufgabe' : (t.wer.length ? t.wer.join(', ') : 'niemand zugewiesen')));
return '<a class="dash-termin" href="' + dashProjektUrl(p, t.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" target="_blank" rel="noopener">'
return '<a class="dash-termin" href="' + dashProjektUrl(p, t.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" onclick="return dashImTabOeffnen(event, this)">'
+ '<span class="dash-termin-art ' + klasse + '">' + zeichen + '</span>'
+ '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(t.titel) + '</span>'
+ '<span class="dash-termin-meta">' + esc(unter) + '</span></span>'
-1
View File
@@ -68,7 +68,6 @@
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
if(typeof renderPlanTasks === 'function') renderPlanTasks();
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
};
+111 -30
View File
@@ -488,6 +488,104 @@
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
// 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){
if(app.isLocked()) return;
const plan = app.ensurePlanLocal();
@@ -544,6 +642,7 @@
const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand);
aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : '';
}
todoAufwandInfo();
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
@@ -594,39 +693,20 @@
const assigneesDiv = document.getElementById('todoEditAssignees');
if(assigneesDiv){
const assignees = projectAssignees(app.project, app.week);
const currentAssignees = currentTodo
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
: (Array.isArray(pre.assignees) ? pre.assignees : []);
const seenPeople = new Set();
const uniquePeople = [];
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 }));
});
const projektLeute = todoProjektPersonen();
const projektIds = new Set(projektLeute.map(p => p.id));
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
// Personenspalten im Projektboard.
assigneesDiv.innerHTML = uniquePeople.map(person => {
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
const short = person.name || initialsForName(person.fullName || '') || '?';
const full = person.fullName || person.name || '';
const fremd = person.nichtImProjekt ? ' title="Nicht (mehr) dem Projekt zugeordnet"' : '';
return `<button type="button" class="person-chip${person.nichtImProjekt ? ' ist-fremd' : ''}"${fremd} data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
assigneesDiv.innerHTML = projektLeute.map(person =>
todoPersonChip(person, currentAssignees.includes(person.id), false)
).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
// Zuständige außerhalb des Projekts stehen unter „Externe Personen“ —
// auch solche, die das Projekt inzwischen verlassen haben (sonst gingen
// sie beim Speichern stillschweigend verloren).
todoExterneSetzen(currentAssignees.filter(id => !projektIds.has(id)));
}
const deleteBtn = document.getElementById('todoEditDelete');
@@ -664,7 +744,7 @@
function todoFormSnapshot(){
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 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');
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
currentTodoEditStatus(), read('todoEditProgress'), deps, people,
@@ -727,7 +807,8 @@
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
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 showInGantt = showInGanttEl ? showInGanttEl.checked : true;
+410
View File
@@ -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(); });
})();
+5 -163
View File
@@ -1,21 +1,10 @@
// -----------------------------
// Projektbereich — Leistungsphasen
// Projektbereich — Zuordnung Aufgabe → Leistungsphase
// -----------------------------
// Bereich "Leistungsphasen" (Eriks Wahl "B"): die Phasenbalken des
// Terminplans (Aufgaben mit t.lph, balkenPhase() in load.js) als eigener
// Bereich statt als Zeilen zwischen den Aufgaben. Oben ein Ablaufbalken über
// alle Phasen mit Heute-Linie, darunter eine Kachel je Phase: Fortschritt
// (aus dem Wochenbericht, p.lph), Zeitraum (aus dem Balken), Aufwand
// (Summe der Stunden ihrer Aufgaben) und die Aufgaben, die in ihrem
// Zeitraum beginnen — dieselbe Zuordnung wie die Gliederung im Terminplan
// (plan-build-gantt.js).
//
// Keine eigene Datenhaltung: Bearbeiten öffnet dasselbe Panel wie im
// Terminplan (openTodoModal() im Phasen-Modus), Fortschritt bleibt im
// Wochenbericht ("Ein Ort je Angabe").
//
// renderPlanPhasen() wird von renderPlanGantt() (plan-gantt.js) und beim
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) aufgerufen.
// Der frühere Reiter „Leistungsphasen“ (Ablaufbalken + Kachel je Phase) ist
// auf Eriks Wunsch entfallen; an seiner Stelle steht „Notizen“. Geblieben ist
// nur aufgabenPhase(), die die Aufgabenliste (plan-tasks.js) und das
// Terminplan-PDF (pdf.js) nutzen.
// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört —
// der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die
@@ -31,150 +20,3 @@ function aufgabenPhase(t, todos){
.find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end));
return k ? balkenPhase(k) : '';
}
(function(){
const app = window.PlanApp;
const LPH_NAMEN = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung',
'W+M': 'Wartung und Monitoring'
};
const kurzDatum = d => d ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) : '';
const langDatum = d => d ? formatDateDE(d) : '';
// phasenDaten: je Phase alles, was Ablaufbalken und Kachel brauchen.
// Rückgabe: Array, sortiert läuft → geplant → fertig, darin nach Beginn.
function phasenDaten(){
const p = app.project;
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
// Phasen: beauftragte plus alle, die einen Balken haben.
const namen = LPHS.filter(l => beauftragt.includes(l) || todos.some(t => balkenPhase(t) === l));
const amtierend = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || null;
const laufend = typeof arbeitsPhase === 'function' ? arbeitsPhase(p, amtierend) : amtierend;
return namen.map(l => {
const idx = todos.findIndex(t => balkenPhase(t) === l);
const balken = idx >= 0 ? todos[idx] : null;
const start = balken ? parseDateISO(balken.start) : null;
const ende = balken ? parseDateISO(balken.end) : null;
const prozent = typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0;
const aufgaben = todos.filter(t => aufgabenPhase(t, todos) === l);
// Aufwand = Summe der Stunden ihrer Aufgaben (dieselbe Quelle wie die
// Auslastung, js/load/load.js), nicht mehr aus dem Honorar.
const stunden = Math.round(aufgaben.reduce((s, t) => s + (Number(t.aufwand) || 0), 0) * 10) / 10;
const status = prozent >= 100 ? 'fertig' : (l === laufend ? 'laeuft' : 'geplant');
return { l, idx, balken, start, ende, prozent, stunden, aufgaben, status,
beauftragt: !beauftragt.length || beauftragt.includes(l) };
}).sort((a, b) => {
const rang = { laeuft: 0, geplant: 1, fertig: 2 };
if(rang[a.status] !== rang[b.status]) return rang[a.status] - rang[b.status];
return LPHS.indexOf(a.l) - LPHS.indexOf(b.l);
});
}
// ablaufHtml: der schmale Balken über alle Phasen mit Zeitraum. Phasen
// ohne Zeitraum fehlen hier (sie stehen als Kachel mit Hinweis darunter).
function ablaufHtml(daten){
const mit = daten.filter(d => d.start && d.ende && d.ende >= d.start)
.sort((a, b) => a.start - b.start);
if(!mit.length) return '';
const von = mit[0].start.getTime();
const bis = Math.max(...mit.map(d => d.ende.getTime()));
const spanne = Math.max(1, bis - von);
const pos = t => Math.min(100, Math.max(0, (t - von) / spanne * 100));
const heute = new Date(); heute.setHours(0, 0, 0, 0);
const heutePos = heute.getTime() >= von && heute.getTime() <= bis ? pos(heute.getTime()) : null;
const segmente = mit.map(d => {
const links = pos(d.start.getTime());
const breite = Math.max(1.5, pos(d.ende.getTime() + 86400000) - links);
const titel = `${d.l}: ${langDatum(d.start)} – ${langDatum(d.ende)}, ${d.prozent} %`;
return `<button type="button" class="phasen-ablauf-seg is-${d.status}" style="left:${links}%;width:${breite}%;--fort:${d.prozent}%"
title="${esc(titel)}" onclick="phasenKachelZeigen('${esc(d.l)}')"><span>${esc(breite > 6 ? d.l : d.l.replace('LPH ', ''))}</span></button>`;
}).join('');
const heuteHtml = heutePos === null ? '' : `<span class="phasen-ablauf-heute" style="left:${heutePos}%" title="heute"></span>`;
const monat = d => d.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' });
return `<div class="plan-panel phasen-ablauf">
<div class="phasen-kopf">Ablauf</div>
<div class="phasen-ablauf-spur">${segmente}${heuteHtml}</div>
<div class="phasen-ablauf-achse"><span>${esc(monat(mit[0].start))}</span>${heutePos === null ? '' : `<span class="phasen-ablauf-heute-text" style="left:${heutePos}%">heute, ${esc(kurzDatum(heute))}</span>`}<span>${esc(monat(new Date(bis)))}</span></div>
</div>`;
}
// kachelHtml: eine Kachel je Phase.
function kachelHtml(d, locked){
const statusText = { laeuft: 'läuft', geplant: 'geplant', fertig: 'fertig' }[d.status];
const erledigt = d.aufgaben.filter(t => t.done).length;
const aufgabenText = d.aufgaben.length ? `${erledigt} von ${d.aufgaben.length} erledigt` : 'keine';
const zeitraum = d.start && d.ende
? `${d.start.getFullYear() === d.ende.getFullYear() ? kurzDatum(d.start) : langDatum(d.start)} – ${langDatum(d.ende)}`
: '<span class="phasen-leer">kein Zeitraum</span>';
const bearbeiten = locked ? ''
: (d.balken
? `<button class="secondary small" onclick="openTodoModal(${d.idx})">Bearbeiten</button>`
: `<button class="secondary small" onclick="addPlanPhase('${esc(d.l)}')">Zeitraum festlegen</button>`);
const ansehen = d.aufgaben.length
? `<button class="secondary small" onclick="phasenAufgabenZeigen('${esc(d.l)}')">Aufgaben ansehen</button>` : '';
return `<div class="phasen-kachel is-${d.status}" data-phase="${esc(d.l)}">
<div class="phasen-kachel-kopf">
<div class="phasen-ring" style="--fort:${d.prozent}%"><span>${d.prozent} %</span></div>
<div class="phasen-kachel-name">
<b>${esc(d.l)}</b>
<span>${esc(LPH_NAMEN[d.l] || '')}</span>
</div>
<span class="phasen-chip is-${d.status}">${statusText}</span>
</div>
${d.beauftragt ? '' : '<div class="phasen-hinweis">nicht beauftragt</div>'}
<div class="phasen-werte">
<span><small>Zeitraum</small><b>${zeitraum}</b></span>
<span><small>Aufwand</small><b>${d.stunden ? d.stunden.toLocaleString('de-DE') + ' h' : '<span class="phasen-leer">keine Stunden</span>'}</b></span>
<span><small>Aufgaben</small><b>${aufgabenText}</b></span>
</div>
${bearbeiten || ansehen ? `<div class="phasen-knoepfe">${bearbeiten}${ansehen}</div>` : ''}
</div>`;
}
// renderPlanPhasen: baut den Bereich neu auf. Keine Parameter.
window.renderPlanPhasen = function(){
const el = document.getElementById('planPhasenWrap');
if(!el || !app.project) return;
const locked = app.isLocked();
const daten = phasenDaten();
if(!daten.length){
el.innerHTML = `<div class="plan-panel"><div class="plan-task-empty">${locked
? 'Für dieses Projekt sind keine Leistungsphasen hinterlegt.'
: 'Noch keine Leistungsphase. Unter „Einstellungen“ beauftragen oder über „+ Leistungsphase“ bzw. „Terminplan aus Honorar“ anlegen.'}</div></div>`;
return;
}
const summeH = daten.reduce((s, d) => s + d.stunden, 0);
const enden = daten.filter(d => d.ende).map(d => d.ende.getTime());
const laufend = daten.find(d => d.status === 'laeuft');
const teile = [daten.filter(d => d.beauftragt).length + ' beauftragt'];
if(laufend) teile.push('läuft: ' + laufend.l);
if(summeH) teile.push(summeH.toLocaleString('de-DE') + ' h in Aufgaben');
if(enden.length) teile.push('Ende voraussichtlich ' + langDatum(new Date(Math.max(...enden))));
el.innerHTML = `<div class="phasen-zusammenfassung">${esc(teile.join(' · '))}<span>Fortschritt aus dem Wochenbericht</span></div>
${ablaufHtml(daten)}
<div class="phasen-raster">${daten.map(d => kachelHtml(d, locked)).join('')}</div>`;
};
// phasenKachelZeigen: Klick auf ein Segment im Ablauf — Kachel ins Bild
// holen und kurz hervorheben.
window.phasenKachelZeigen = function(l){
const k = document.querySelector(`#planPhasenWrap .phasen-kachel[data-phase="${CSS.escape(l)}"]`);
if(!k) return;
k.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
k.classList.remove('ist-markiert'); void k.offsetWidth; k.classList.add('ist-markiert');
};
// phasenAufgabenZeigen: in den Bereich Aufgaben, gefiltert auf diese Phase.
window.phasenAufgabenZeigen = function(l){
if(typeof setzeAufgabenFilter === 'function') setzeAufgabenFilter(l);
showPlanSection('aufgaben');
};
})();
+2 -2
View File
@@ -36,7 +36,7 @@
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
// Navigationsleiste. Muss zu den data-section-Attributen in
// App.html passen.
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'phasen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'notizen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
@@ -63,8 +63,8 @@
if(!app.project) return;
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
if(key === 'notizen' && typeof renderPlanNotizen === 'function') renderPlanNotizen();
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings();
+2 -4
View File
@@ -284,12 +284,10 @@ window.PlanApp = {
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
// Terminplan (…Btn), Aufgaben (…BtnList) und Leistungsphasen (…BtnPhasen),
// siehe App.html.
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html.
const locked = isLocked();
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList',
'honorarBtnPhasen', 'addPhaseBtnPhasen']
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList']
.forEach(id => {
const btn = document.getElementById(id);
if(btn) btn.style.display = locked ? 'none' : '';
+1
View File
@@ -541,6 +541,7 @@ function liveEreignis(d){
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
if(d.typ === 'neu') return liveAllesNeu();
if(d.typ === 'personen') return personenNachladen();
if(d.typ === 'notizen' && typeof notizenLiveAenderung === 'function') return notizenLiveAenderung(d.uid);
}
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
+125 -74
View File
@@ -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[hidden] { display: none; }
/* 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; }
.pd-aufwand > span:first-child { font-weight: 600; color: var(--text-muted); font-size: 12px; }
.pd-aufwand > span:last-child { color: var(--text-muted); font-size: 12px; }
.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; }
.pd-aufwand input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,113,227,.18); }
/* Aufwand im Aufgaben-Panel: Kopf, Stepper (−, Zahl, „Std.“, +),
Schnellwahl als kleine Kapseln, Umrechnung in Arbeitstage darunter.
Knöpfe tragen eigene Klassen bzw. stehen in eigenen Containern, damit die
allgemeine (Glas-)Knopfregel sie nicht akzentblau färbt. */
.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):
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; }
#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 */
.plan-task-phase {
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;
}
.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-
balken in Blauviolett (bewusst bläulicher als das Lila der Meilensteine), Aufgaben bleiben im Akzentblau, erledigte grün,
@@ -1809,3 +1780,83 @@ html[data-theme="dark"] { --gantt-phase: #a59ef2; }
.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; }
}
+57
View File
@@ -6232,3 +6232,60 @@ button.verlauf-weitere { white-space: nowrap; }
.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; }
+195 -24
View File
@@ -1670,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',
'plan_milestones','plan_milestone_deps',
'project_protokolle','protokoll_teilnehmer','protokoll_punkte',
'project_kommentare'];
'project_kommentare', 'projekt_notizen'];
// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen.
// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk()
// verteilt die alten Kommentare danach aus projects.status.
const IMPORT_OPTIONAL_TABLES = ['project_kommentare'];
const IMPORT_OPTIONAL_TABLES = ['project_kommentare', 'projekt_notizen'];
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
@@ -4679,13 +4679,19 @@ app.post('/save-week', requireAuth, (req, res) => {
}
});
// ---------- Teams: Nachricht bei neuen Aufgaben ----------
// ---------- Teams: persönliche Nachricht bei neuen Aufgaben ----------
//
// Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, erscheint in einem
// Teams-Kanal eine Nachricht mit Link direkt auf die Aufgabe im Projekt.
// Eingerichtet in Teams über „Workflows“ → „Beim Empfang einer
// Webhook-Anfrage in einem Kanal posten“; die dort erzeugte Adresse trägt
// ein Admin unter Admin → Teams ein (Recht a.teams). Die Adresse ist ein
// 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.
@@ -4707,17 +4713,16 @@ function integrationSchreiben(k, v){
.run(k, String(v));
}
// teamsKarte: Adaptive Card im Umschlag, den der Teams-Workflow erwartet.
// 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 })) });
const content = {
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 }] : []
};
return { type: 'message', attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', contentUrl: null, content }] };
}
// teamsSenden: an die eingetragene Adresse schicken. Rückgabe: Promise —
@@ -4756,22 +4761,31 @@ function teamsDatum(iso){
// 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(' ')], ['Zuständig', wer]];
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: ' + (t.title || 'Aufgabe'), [], fakten,
const karte = teamsKarte('Neue Aufgabe für Sie: ' + (t.title || 'Aufgabe'), [], fakten,
aufgabenLink(req, weekLabel, p, t.id), 'Aufgabe öffnen');
teamsSenden(karte).catch(err => console.warn('Teams-Nachricht nicht gesendet:', err.message));
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));
});
});
}
@@ -4805,13 +4819,16 @@ app.put('/admin/teams', requireRecht('a.teams'), (req, res) => {
// 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(teamsKarte('Projektübersicht ist mit Teams verbunden',
['Ab jetzt erscheinen hier neue Aufgaben mit einem Link direkt zur Aufgabe im Projekt.'],
[['Eingerichtet von', req.user.displayName || req.user.username]], link, 'Projektübersicht öffnen'));
res.json({ ok: true });
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 });
}
@@ -7067,12 +7084,17 @@ function mailTeilLesen(roh, tiefe){
if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' };
if(!typ.startsWith('text/')) return { plain: '', html: '' };
const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase();
let buf;
if(enc === 'base64') buf = Buffer.from(rumpf.replace(/\s+/g, ''), 'base64');
else if(enc === 'quoted-printable'){
buf = Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
} else buf = Buffer.from(rumpf, 'latin1');
const text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
let text;
if(enc === 'base64' || enc === 'quoted-printable'){
const buf = enc === 'base64'
? Buffer.from(rumpf.replace(/\s+/g, ''), 'base64')
: Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
} else {
// 7bit/8bit: Der Browser hat die Datei bereits als Text gelesen (UTF-8) —
// ein Umweg über Bytes zerstörte hier die Umlaute.
text = rumpf;
}
return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' };
}
@@ -7288,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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kopf = [mail.absender ? '<b>Von:</b> ' + escHtml(mail.absender) : '',
mail.datum ? '<b>Datum:</b> ' + escHtml(teamsDatum(mail.datum)) : ''].filter(Boolean).join('<br>');
const html = (kopf ? kopf + '<br><br>' : '') + escHtml(text).replace(/\n/g, '<br>');
res.json({ ok: true, notiz: notizAnlegen(req, uid, mail.betreff || ersteZeile, html, 'mail') });
} catch(err){
console.error('Notiz aus Mail fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Plan Checker (plan_checker_merkmale) ----------
//
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt