Compare commits
7
Commits
v1.1
...
e12936444d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e12936444d | ||
|
|
4ac3d8d53d | ||
|
|
e291ca8438 | ||
|
|
e697057b86 | ||
|
|
7d65ff813f | ||
|
|
51dfbe8cd8 | ||
|
|
e7243f6f26 |
No files matched your search
@@ -1521,6 +1521,43 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
|
|||||||
system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this
|
system entirely — `loadAmpelColor()` returns `var(--red)` etc., so this
|
||||||
view now also follows the app-wide dark-mode palette like everywhere
|
view now also follows the app-wide dark-mode palette like everywhere
|
||||||
else.
|
else.
|
||||||
|
- **Aufwand im Aufgaben-Panel** (Eriks Wunsch „schöner darstellen“):
|
||||||
|
Kapsel mit −/+ (±1 h) und Einheit „Std.“, Schnellwahl 2 h / ½ Tag /
|
||||||
|
1 Tag / 2 Tage / 1 Woche (Tage über `APP_SETTINGS.arbeitsstundenProTag`;
|
||||||
|
erneuter Klick leert), darunter die Umrechnung in Arbeitstage
|
||||||
|
(`todoAufwandAendern()`/`todoAufwandSetzen()`/`todoAufwandInfo()`,
|
||||||
|
`plan-modals.js`). Feld-id `todoEditAufwand` und Speichern unverändert.
|
||||||
|
Das Eingabefeld ist per `!important` durchsichtig, weil die Glas-Regeln
|
||||||
|
für Eingabefelder ihm sonst eine eigene Fläche geben.
|
||||||
|
- **„Externe Personen“ im Aufgaben-Panel** (Eriks Wunsch): unter „Wer“
|
||||||
|
stehen oben die Projektbeteiligten als Chips, darunter der Abschnitt
|
||||||
|
„Externe Personen“ — Kollegen mit Konto, die dem Projekt NICHT
|
||||||
|
zugeordnet sind, über eine Auswahl nach Gewerk (`todoExterneSetzen()`,
|
||||||
|
`todoExternHinzufuegen()`/`todoExternEntfernen()` in `plan-modals.js`;
|
||||||
|
Quelle: Teilnehmerkreis der Sitzung ohne die Projektbeteiligten). Sie
|
||||||
|
landen ganz normal in `t.assignees` — damit zählen sie zu ihrer
|
||||||
|
Auslastung, erscheinen unter „Meine Aufgaben“, bekommen Glocke und
|
||||||
|
Teams-Nachricht. Wer einer Aufgabe zugewiesen ist, aber (nicht mehr) im
|
||||||
|
Projekt, erscheint beim Öffnen automatisch dort. Keine Datenänderung
|
||||||
|
am Projekt: die Person wird NICHT unter „Beteiligte“ eingetragen.
|
||||||
|
- **Panels als „Planblatt“** (Eriks Wahl, unslop, 11/2026; löst den
|
||||||
|
Aufbau mit `.pd-abs`-Abschnitten im folgenden Absatz ab): Kopf mit Art,
|
||||||
|
Projekt (`#todoModalWo`/`#milestoneModalWo`, `panelWoSetzen()`), Titel
|
||||||
|
ohne Kasten und dem **Stand direkt darunter** (Aufgabe: Status-Segment +
|
||||||
|
Fortschritt; Meilenstein: „Offen | Erreicht“ über `msStandSetzen()`, das
|
||||||
|
versteckte Feld `#milestoneEditErreicht` bleibt die Quelle für
|
||||||
|
`saveMilestoneEdit()`, dazu „seit N Tagen überfällig“). Darunter die
|
||||||
|
**Lage im Terminplan** (`lageBildHtml()`, `todoLageNeu()`,
|
||||||
|
`msLageBildNeu()` in `plan-modals.js`): sechs Kalenderwochen ab einer
|
||||||
|
Woche vor dem Eintrag (früher, wenn ein Vorgänger davor endet), oben
|
||||||
|
Vorgänger, Mitte der Eintrag mit Fortschritt, unten Nachfolger,
|
||||||
|
Heute-Linie; nur Anzeige, neu gezeichnet bei Zeitraum, Stand und
|
||||||
|
Vorgängern. Dann die Angaben als Zeilen (`.pd-zeile`, Beschriftung
|
||||||
|
links): Zeitraum, Zuständig, Aufwand (`#todoEditAufwandAbs`, im
|
||||||
|
Phasen-Modus mit ausgeblendet), Hängt ab von (gewählte Vorgänger offen
|
||||||
|
in `#todoEditDependsSummary`, Auswahl eingeklappt unter „Vorgänger
|
||||||
|
ändern“), Notiz. Alle Feld-ids und Schreibwege unverändert. Stile:
|
||||||
|
Block „Planblatt“ am Ende von `plan-styles.css`.
|
||||||
- **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“):
|
- **Aufgabe und Meilenstein als Seitenpanel** (Eriks Wahl „B“):
|
||||||
`#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am
|
`#todoModal` und `#milestoneModal` sind `.modal-bg.als-panel` — rechts am
|
||||||
Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der
|
Rand, volle Höhe, Hintergrund nur leicht abgedunkelt, damit der
|
||||||
@@ -1533,6 +1570,37 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
|
|||||||
`plan-modals.js`) über dem bisherigen Feld `#milestoneEditPosition`,
|
`plan-modals.js`) über dem bisherigen Feld `#milestoneEditPosition`,
|
||||||
das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert.
|
das allein gespeichert wird. Alle Feld-ids und Schreibwege unverändert.
|
||||||
Stile: Block „Seitenpanel“ am Ende von `plan-styles.css`.
|
Stile: Block „Seitenpanel“ am Ende von `plan-styles.css`.
|
||||||
|
- **Notizen** (Reiter zwischen Terminplan und Aufgaben, Eriks Wahl „A –
|
||||||
|
Liste + Editor“ aus drei Entwürfen, `js/plan/plan-notizen.js`):
|
||||||
|
gemeinsame Notizen je Projekt. Links Liste (angeheftete oben, sonst
|
||||||
|
zuletzt geändert zuerst, Suche über Titel und Text), rechts Editor mit
|
||||||
|
Titel, Werkzeugleiste des Sitzungskommentars (`richtextToolbarHtml()`)
|
||||||
|
und Text; **automatisches Speichern** 1,2 s nach der letzten Änderung und
|
||||||
|
beim Verlassen des Feldes (MutationObserver, weil die Werkzeugleiste kein
|
||||||
|
input-Ereignis auslöst), Anzeige „Gespeichert ✓“. Anheften, Löschen
|
||||||
|
zweistufig im Knopf. **Mail** (Text oder `.eml`) per Drag & Drop auf den
|
||||||
|
Bereich oder Strg+V außerhalb der Felder → neue Notiz OHNE KI (Titel =
|
||||||
|
Betreff, Von/Datum oben, Zitate abgeschnitten).
|
||||||
|
Server: Tabelle `projekt_notizen` (an `project_uid`, nicht an der
|
||||||
|
Sitzung; in `IMPORT_TABLES` + `IMPORT_OPTIONAL_TABLES`), Routen `GET
|
||||||
|
/projekt-notizen?uid=`, `POST /projekt-notizen`, `PUT
|
||||||
|
/projekt-notizen/:id` (mit `basis` → 409 bei fremder Änderung; Client
|
||||||
|
bietet „Meine Fassung speichern“/„Neuen Stand laden“), `DELETE` (nur
|
||||||
|
Verfasser oder Administrator), `POST /projekt-notizen/aus-mail`. Lesen:
|
||||||
|
Grundzugang; Schreiben: `proj.plan`. Live-Ereignis `{typ:'notizen',
|
||||||
|
uid}` → `notizenLiveAenderung()`. Stile: Block „Notizen“ am Ende von
|
||||||
|
`plan-styles.css`.
|
||||||
|
**Gefunden dabei:** `leseEml()` zerstörte Umlaute in `.eml`-Dateien ohne
|
||||||
|
Transfer-Kodierung (7bit/8bit, der Normalfall) — der Text kam schon als
|
||||||
|
UTF-8-Zeichenkette vom Browser und wurde fälschlich als latin1-Bytes neu
|
||||||
|
dekodiert. Betraf auch „Termin aus Mail“; behoben.
|
||||||
|
- **Reiter „Leistungsphasen“ entfernt** (Eriks Wunsch, 30.10.2026): Nav-Knopf,
|
||||||
|
Abschnitt `#planSection-phasen`, `renderPlanPhasen()` und der CSS-Block
|
||||||
|
sind weg; ein alter Link auf `#phasen` landet auf dem Dashboard.
|
||||||
|
`js/plan/plan-phasen.js` enthält nur noch `aufgabenPhase()` (genutzt von
|
||||||
|
der Aufgabenliste und dem Terminplan-PDF). „+ Leistungsphase“ und
|
||||||
|
„Terminplan aus Honorar“ bleiben im Terminplan unter „+ Neu“. Der
|
||||||
|
folgende Absatz beschreibt den entfernten Stand.
|
||||||
- **Leistungsphasen** (eigener Reiter zwischen Terminplan und Aufgaben,
|
- **Leistungsphasen** (eigener Reiter zwischen Terminplan und Aufgaben,
|
||||||
Eriks Wahl „B“, `js/plan/plan-phasen.js`): oben ein Ablaufbalken aller
|
Eriks Wahl „B“, `js/plan/plan-phasen.js`): oben ein Ablaufbalken aller
|
||||||
Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter
|
Phasen mit Zeitraum (Heute-Linie, Klick hebt die Kachel hervor), darunter
|
||||||
@@ -1787,6 +1855,58 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
|
|||||||
`plan-gantt.js` both bail out unless `currentTab === 'plan'` — they now run
|
`plan-gantt.js` both bail out unless `currentTab === 'plan'` — they now run
|
||||||
while completely different tabs are on screen.
|
while completely different tabs are on screen.
|
||||||
|
|
||||||
|
### Notizen in der Seitenleiste (Tab `notizen`, `js/notizen/notizen.js`)
|
||||||
|
|
||||||
|
Eriks Wunsch: ein eigener Bereich „Notizen“ (Seitenleiste, eigene Gruppe
|
||||||
|
„Notizen“ unter „Team“, `/Notizen`), in dem jedes Konto die Notizen ALLER seiner
|
||||||
|
Projekte sieht und **projektunabhängige Notizen** anlegt, die sich später
|
||||||
|
einem Projekt zuordnen lassen. Gleicher Aufbau wie der Projekt-Reiter
|
||||||
|
(Liste + Editor, Werkzeugleiste, automatisches Speichern, Anheften, Löschen
|
||||||
|
zweistufig), dazu oben ein **Projektfilter** (Alle / Ohne Projekt / je
|
||||||
|
Projekt mit Anzahl) und im Editor die Auswahl **„Projekt“** (Ohne Projekt,
|
||||||
|
„Meine Projekte“, „Weitere Projekte“ der aktuellen Sitzung) plus „Im Projekt
|
||||||
|
öffnen“. „+ Neu“ legt im gefilterten Projekt an, sonst ohne Projekt. Eigene
|
||||||
|
Element-ids (`mn…`) und Funktionen (`mnNeu()`, `mnZuordnen()` …), weil der
|
||||||
|
Projekt-Reiter gleichzeitig im DOM liegt; Stile aus `.notizen-*`
|
||||||
|
(plan-styles.css) plus Block „NOTIZEN (Seitenleiste)“ am Ende von
|
||||||
|
`styles.css`.
|
||||||
|
- **Server:** Eine projektunabhängige Notiz ist eine Zeile in
|
||||||
|
`projekt_notizen` mit `project_uid = ''`; sie sieht und ändert NUR ihr
|
||||||
|
Verfasser (`notizDarf()`). `GET /meine-notizen` liefert die Notizen der
|
||||||
|
eigenen Projekte (`meineProjektUids()`: jüngste echte Sitzung, zugeordnet
|
||||||
|
oder mit eigener Aufgabe — Regel wie `dashDaten()`) plus die eigenen
|
||||||
|
projektunabhängigen, dazu `meineProjekte`. `PUT /projekt-notizen/:id` nimmt
|
||||||
|
`uid` zum Zuordnen (Ziel-Projekt braucht `proj.plan`, Lösen nur der
|
||||||
|
Verfasser). `POST`/`PUT`/`DELETE`/`aus-mail` prüfen jetzt selbst
|
||||||
|
(`notizDarf()`) statt pauschal `requireRecht('proj.plan')`: ohne Projekt
|
||||||
|
genügt die Anmeldung. `GET /projekt-notizen/anzahl` zählt nur
|
||||||
|
Projekt-Notizen.
|
||||||
|
- **Live:** `{typ:'notizen'}` geht in `store.js` an `meineNotizenLive()` und
|
||||||
|
`notizenLiveAenderung()`; das Echo der eigenen Speicherung (kommt auch im
|
||||||
|
selben Tab an) wird 3 s lang ausgelassen, sonst verschwände die Anzeige
|
||||||
|
„Gespeichert ✓“.
|
||||||
|
|
||||||
|
### Seitenleiste — Gliederung nach Arbeitsbereich (11/2026)
|
||||||
|
|
||||||
|
Eriks Wahl „A“ (unslop + Taste-Skill): Die Einträge stehen in Gruppen
|
||||||
|
(`.sidebar-gruppe` in `App.html`): **Dashboard** · **Projekte**
|
||||||
|
(Projekte, darunter eingezogen das geöffnete Projekt `#planNavItem` mit
|
||||||
|
Projektnummer in Mono, Wochenbericht, Terminplan) · **Team** (Auslastung,
|
||||||
|
Abwesenheiten) · **Notizen** (eigene Gruppe, Eriks Wunsch) · **Studenten** (Plan Checker, ganze Gruppe `nur-recht
|
||||||
|
wz.checker`) · unten `.sidebar-unten` mit **Einstellungen** und **Admin**.
|
||||||
|
Die Überschriften „Navigation“ und „Planung“ sind entfallen; der Menüpunkt
|
||||||
|
heißt weiter „Dashboard“. Admin-Unterpunkte in drei Zwischengruppen
|
||||||
|
(`.sidebar-subgruppe`: Personen, Vorgaben, System); `adminNavAbgleichen()`
|
||||||
|
blendet eine Zwischengruppe aus, wenn keiner ihrer Punkte erlaubt ist.
|
||||||
|
Maße: Einträge 32 px, Icon 18 px + 8 px, Rubrik 16 px über der Gruppe und
|
||||||
|
4 px über ihren Einträgen. Stile: Block „SEITENLEISTE“ am Ende von
|
||||||
|
`styles.css`.
|
||||||
|
|
||||||
|
**Falle in CSS-Kommentaren:** `*/` beendet einen Kommentar auch mitten im
|
||||||
|
Text (z. B. „--dash-*/--wt-*“). Der Rest wird dann als Regel gelesen und
|
||||||
|
verschluckt die nächste echte Regel. So fehlten zeitweise `--font-mono`
|
||||||
|
(alle Mono-Ziffern) und `--accent` im automatischen Dunkelmodus.
|
||||||
|
|
||||||
### Termin aus Mail (`js/plan/plan-mail-eintrag.js`)
|
### Termin aus Mail (`js/plan/plan-mail-eintrag.js`)
|
||||||
|
|
||||||
Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze
|
Eriks Wunsch: Mailtext per Drag & Drop (markierter Text oder ganze
|
||||||
@@ -3349,6 +3469,26 @@ the system font offline). `z-index: 400`, above `.modal-bg` (300).
|
|||||||
one project is ever open, so height is free while width is scarce — beside
|
one project is ever open, so height is free while width is scarce — beside
|
||||||
the list, three columns left the people 137 px.
|
the list, three columns left the people 137 px.
|
||||||
|
|
||||||
|
### Wochenbericht — Aufgaben des Projekts (Eriks Wahl „B“, 11/2026)
|
||||||
|
|
||||||
|
Unter der Projektkarte, vor „Besprochen & weiter“, der Abschnitt „Aufgaben“
|
||||||
|
(`renderBerichtAufgaben()`/`berichtAufgabenDaten()` in
|
||||||
|
`js/wochenbericht/wochenbericht.js`) mit drei Spalten: **Erledigt seit KW …**
|
||||||
|
(jetzt `done`, in der vorigen GELADENEN Sitzung offen oder nicht vorhanden —
|
||||||
|
Abgleich über die Aufgaben-id, ohne Vorwoche leer), **Überfällig** (offen,
|
||||||
|
Ende vor heute), **Nächste zwei Wochen** (offene Aufgaben und Meilensteine
|
||||||
|
bis heute + 14). Phasenbalken (`t.lph`) zählen nicht. Aufgaben mit
|
||||||
|
Zuständigen aus dem gewählten Gewerk stehen zuerst, gezeigt werden aber alle.
|
||||||
|
Nur Anzeige: ein Klick öffnet das normale Aufgaben-/Meilenstein-Panel ÜBER
|
||||||
|
dem Wochenbericht (`berichtEintragOeffnen()` setzt dafür
|
||||||
|
`PlanApp.week/project`; `showTab('plan')` setzt beides ohnehin neu);
|
||||||
|
`closeTodoModal()`/`closeMilestoneModal()` zeichnen danach nur den Abschnitt
|
||||||
|
und die Tagesordnung neu (`berichtAufgabenNeu()`), ein angefangener Kommentar
|
||||||
|
bleibt. Die Tagesordnung zeigt je Projekt die Zahl überfälliger Aufgaben rot
|
||||||
|
(`.bericht-rail-ueber`). Das PDF hat in jeder Akte die Tabelle „Aufgaben“
|
||||||
|
(`pdfWbAufgaben()`, höchstens fünf je Gruppe). Stile: Block „AUFGABEN IM
|
||||||
|
WOCHENBERICHT“ am Ende von `styles.css`.
|
||||||
|
|
||||||
### Wochenbericht — Gewerke-Trennung
|
### Wochenbericht — Gewerke-Trennung
|
||||||
|
|
||||||
Since departments stopped partitioning the data (see "Departments were
|
Since departments stopped partitioning the data (see "Departments were
|
||||||
@@ -3554,6 +3694,26 @@ Stile: Block „Verlauf der Vorwochen im Wochenbericht“ (`.bv-*`,
|
|||||||
|
|
||||||
### Dashboard ("Meine Aufgaben", now the default landing tab)
|
### Dashboard ("Meine Aufgaben", now the default landing tab)
|
||||||
|
|
||||||
|
**Stand 11/2026 — Entwurf „Bauplan“ (Eriks Wahl, Vorlage im Artifact
|
||||||
|
„Projektübersicht neu gestaltet“):** Kopf ohne Begrüßung und Uhr: Zeile
|
||||||
|
„KW 45, Mo 2. bis Fr 6. November 2026“ (Mono) über dem Titel „Meine
|
||||||
|
Aufgaben“, rechts die Sitzung (`renderDashboardKopf()`, `dashWochenMontag()`;
|
||||||
|
`greetingForHour()`/`startDashboardClock()` sind entfallen). Band mit Satz und
|
||||||
|
vier Zahlen: offene Aufgaben, überfällig (rot), diese Woche fällig (aus
|
||||||
|
`dashDaten()`, heute bis Sonntag), Freigaben. Neu darunter **„Diese Woche“**
|
||||||
|
(`renderDashWoche()`): Mo–Fr als Spalten mit den Terminen der eigenen
|
||||||
|
Projekte, getönt nach eigene Aufgabe / Aufgabe im Projekt / Meilenstein /
|
||||||
|
überfällig, je Tag höchstens `DASH_WOCHE_JE_TAG` (4). Jeder Projektblock hat
|
||||||
|
die Zeile **„Meine Rolle | Team | Notizen“** (`renderDashProjektInfo()`, Eriks
|
||||||
|
Wunsch): Team = übrige Beteiligte als Kürzel-Kreise in Rollenfarben
|
||||||
|
(`beteiligteJePerson()`/`beteiligtenAvatarHtml()`, höchstens 5, Namen
|
||||||
|
gekürzt), Notizen nur wenn vorhanden, mit Sprung in den Reiter „Notizen“.
|
||||||
|
Die Anzahl liefert **`GET /projekt-notizen/anzahl`** (`{uid: {anzahl,
|
||||||
|
zuletzt}}`, requireAuth), parallel zu `/my-dashboard` geladen
|
||||||
|
(`dashNotizen`). Die Rollen-Chips im Projektkopf sind dafür entfallen. Der
|
||||||
|
**Monatskalender bleibt unverändert** (Eriks Vorgabe). Stile: Block „MEINE
|
||||||
|
AUFGABEN — Entwurf Bauplan“ am Ende von `styles.css`.
|
||||||
|
|
||||||
**Aktueller Aufbau (Entwurf „B — Monatsblatt“, Eriks Wahl aus drei Varianten):**
|
**Aktueller Aufbau (Entwurf „B — Monatsblatt“, Eriks Wahl aus drei Varianten):**
|
||||||
Kopf und Band wie unten beschrieben; darunter **links „Meine Projekte“**, je
|
Kopf und Band wie unten beschrieben; darunter **links „Meine Projekte“**, je
|
||||||
Projekt ein Block (`renderDashProjekte()`): eigene Rollen als Chips (gestrichelt
|
Projekt ein Block (`renderDashProjekte()`): eigene Rollen als Chips (gestrichelt
|
||||||
@@ -3641,9 +3801,14 @@ Terminplänen zu beantworten war. Details:
|
|||||||
Altersgrenze stehen — eine Frist verfällt nicht dadurch, dass sie lange
|
Altersgrenze stehen — eine Frist verfällt nicht dadurch, dass sie lange
|
||||||
gerissen ist. Ohne die Grenze nach vorn stünden Termine aus einem Jahr in
|
gerissen ist. Ohne die Grenze nach vorn stünden Termine aus einem Jahr in
|
||||||
der Liste und die dringenden gingen darin unter.
|
der Liste und die dringenden gingen darin unter.
|
||||||
- **Meilensteine haben kein Erledigt-Kennzeichen** (`plan_milestones` hat
|
- **Meilensteine haben ein Kennzeichen „Erreicht“** (`m.erreicht`,
|
||||||
keine `done`-Spalte), deshalb zählt bei ihnen allein das Datum; bei Aufgaben
|
`plan_milestones.erreicht`, Migration in `migrateAddPlanTodoNotiz()`, in
|
||||||
gilt zusätzlich `done = 0` und ein gesetztes Enddatum.
|
`IMPORT_OPTIONAL_COLS`; Häkchen im Meilenstein-Panel unter „Wann“). Ein
|
||||||
|
vergangener, nicht erreichter Meilenstein gilt überall als überfällig
|
||||||
|
(Band und Projektblöcke auf „Meine Aufgaben“, Projekt-Dashboard,
|
||||||
|
Wochenbericht); erreichte fallen aus Terminlisten, „Nächster Meilenstein“
|
||||||
|
und Wandtafel heraus, im Kalender bleiben sie stehen. Bei Aufgaben gilt
|
||||||
|
`done = 0` und ein gesetztes Enddatum.
|
||||||
- **Die Namen der Zuständigen kommen aus `week_people`**, nicht aus
|
- **Die Namen der Zuständigen kommen aus `week_people`**, nicht aus
|
||||||
`loadGlobalPeople()`: Die globale Liste enthält nur Personen mit
|
`loadGlobalPeople()`: Die globale Liste enthält nur Personen mit
|
||||||
Benutzerkonto, die Teilnehmerliste einer Sitzung dagegen jede Person, die
|
Benutzerkonto, die Teilnehmerliste einer Sitzung dagegen jede Person, die
|
||||||
@@ -4210,36 +4375,45 @@ kopieren, „In Outlook öffnen“ (`webcal:`-Link), Anleitung, „Neue Adresse
|
|||||||
Microsoft-Server aus dem Internet ab — dafür muss der Server von außen
|
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.
|
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
|
Eriks Wunsch („nur persönlich“): Wird jemandem eine Aufgabe zugewiesen,
|
||||||
Teams-Kanal eine Karte mit Link direkt auf die Aufgabe.
|
bekommt GENAU DIESE Person in Teams eine Nachricht im Chat mit dem Flow-Bot,
|
||||||
- **Einrichten:** In Teams im Kanal *Workflows* → „Beim Empfang einer
|
mit Link direkt auf die Aufgabe. Kein Kanal.
|
||||||
Webhook-Anfrage in einem Kanal posten“; die erzeugte https-Adresse unter
|
- **Einrichten in Teams** (Anleitung aufklappbar unter **Admin → Teams**):
|
||||||
**Admin → Teams** eintragen (`loadAdminTeams()`, `js/admin/admin.js`,
|
leerer Workflow, Auslöser „Bei Empfang einer Teams-Webhookanforderung“
|
||||||
Recht **`a.teams`**, neu im Katalog „Admin-Bereich“), „Testnachricht
|
(Jeder), Aktion „Karte in einem Chat oder Kanal veröffentlichen“ als
|
||||||
senden“. Anleitung steht aufklappbar in der Karte.
|
Flow-Bot im Chat mit Flow-Bot, Empfänger `triggerBody()?['empfaenger']`,
|
||||||
- **Speicher:** Tabelle `integrationen` (schluessel/wert; `teams_webhook`,
|
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
|
`teams_neue_aufgaben`), bewusst NICHT `app_settings` (das darf jedes
|
||||||
Konto lesen) und nicht in `IMPORT_TABLES`. Die Adresse geht nie an den
|
Konto lesen) und nicht in `IMPORT_TABLES`; die Adresse geht nie an den
|
||||||
Browser zurück — `GET /admin/teams` liefert nur `konfiguriert`/`host`.
|
Browser zurück. Routen `GET`/`PUT /admin/teams`, `POST /admin/teams/test`.
|
||||||
`PUT /admin/teams {webhook?, neueAufgaben?}` (nur https), `POST
|
|
||||||
/admin/teams/test` (Fehler als `{ok:false}` mit HTTP 200).
|
|
||||||
- **Auslöser:** `neueZuweisungenMelden()` (aus `/save-week` und
|
- **Auslöser:** `neueZuweisungenMelden()` (aus `/save-week` und
|
||||||
`/save-projects`, jetzt mit `req`) sammelt je Aufgabe die NEU Zuständigen
|
`/save-projects`, mit `req`) sammelt je Aufgabe die NEU Zuständigen —
|
||||||
— auch Personen ohne Konto, nicht wer sich selbst zuweist, keine
|
nicht wer sich selbst zuweist, keine Phasenbalken, nie die Sandbox —,
|
||||||
Phasenbalken, nie die Sandbox — und `teamsNeueAufgaben()` schickt je
|
`teamsNeueAufgaben()` sendet im Hintergrund; Fehler nur im Log.
|
||||||
Aufgabe EINE Adaptive Card (`teamsKarte()`: Titel, Projekt, Zuständig,
|
|
||||||
Fällig/Zeitraum, Aufwand, Zugewiesen von, Knopf „Aufgabe öffnen“). Im
|
|
||||||
Hintergrund; Fehler nur im Log, das Speichern scheitert nie daran.
|
|
||||||
- **Link** (`aufgabenLink()`): `/Projektplan/<Name>?weekLabel=…&projNr=…
|
- **Link** (`aufgabenLink()`): `/Projektplan/<Name>?weekLabel=…&projNr=…
|
||||||
&aufgabe=<todoId>#aufgaben`, Host aus der speichernden Anfrage (= Adresse
|
&aufgabe=<todoId>#aufgaben`, Host aus der speichernden Anfrage (nur im
|
||||||
der App im Büro; funktioniert nur im Büronetz/VPN). Im Browser liest
|
Büronetz/VPN erreichbar). `plan-state.js` (`startAufgabe`) öffnet die
|
||||||
`plan-state.js` `aufgabe` beim Laden (`startAufgabe`): öffnet die
|
jüngste Sitzung, falls der Link eine ältere nennt, und dort das
|
||||||
JÜNGSTE Sitzung, falls der Link eine ältere nennt (Aufgaben-ids bleiben
|
Aufgaben-Panel; die Adresszeile wird nachgezogen.
|
||||||
beim Sitzungswechsel gleich), springt nach „Aufgaben“ und öffnet das
|
- Braucht Internetzugang nach außen.
|
||||||
Aufgaben-Panel; Adresszeile wird nachgezogen.
|
|
||||||
- Braucht Internetzugang nach außen (Microsoft-Server).
|
|
||||||
|
|
||||||
## Sitzungen nach Bedarf laden (`js/store.js`)
|
## Sitzungen nach Bedarf laden (`js/store.js`)
|
||||||
|
|
||||||
@@ -4374,6 +4548,39 @@ minütlich. Ein Klick markiert als gelesen und springt in die Aufgaben des
|
|||||||
Projekts — gesucht in der AKTUELLEN Sitzung über die Projektnummer (die
|
Projekts — gesucht in der AKTUELLEN Sitzung über die Projektnummer (die
|
||||||
Projekt-ID wechselt je Sitzung), Planung-Terminpläne über ihre ID.
|
Projekt-ID wechselt je Sitzung), Planung-Terminpläne über ihre ID.
|
||||||
|
|
||||||
|
## Gestaltung „Bauplan“ (unslop-Überarbeitung, 11/2026)
|
||||||
|
|
||||||
|
Eriks Wunsch: das gesamte Design mit dem Skill „unslop“ überarbeiten, Wahl
|
||||||
|
„volle Überarbeitung“, Glas „beruhigen“. Inhalte, Aufbau und Funktionen sind
|
||||||
|
unverändert; geändert sind nur Stylesheets, ein paar Texte und Inline-Größen.
|
||||||
|
- **Richtung:** Planköpfe und Raster eines Bauplans — Flächen fast neutral
|
||||||
|
(Graphit, leicht kühl), ein Akzent (Logo-Blau `#0f5a85`, dunkel
|
||||||
|
`#86bfdf`), Farbe nur in Inhalten (Terminplan, Auslastung, Status).
|
||||||
|
Löst die frühere Dunkel-Palette in Wandtafel-Blau ab.
|
||||||
|
- **Regeln, die neue Stile einhalten sollten:** Schriftgrößen 11/12/13/15/19/24
|
||||||
|
(keine halben Pixel), Gewichte 400/500, 600 nur selten; keine gesperrten
|
||||||
|
Großbuchstaben-Labels; keine negative Laufweite unter 40px; Abstände im
|
||||||
|
4px-Raster (2 und 6 nur in kleinen Bedienelementen); Rundungen 4–6
|
||||||
|
(Schildchen/Chips), 8 (Knöpfe, Eingaben, Umschalter), 12–16 (Karten);
|
||||||
|
Kapselform nur für dünne Balken, Schalter, Zählpunkte und Avatare; keine
|
||||||
|
farbigen Schatten/Leuchten, keine Emoji, keine Ausrufezeichen in Texten.
|
||||||
|
- **Kennzeichen:** Kalenderwochen, Projektnummern und Kennzahlen in
|
||||||
|
Monospace-Ziffern (`--font-mono`, tabellarisch) — Block „BAUPLAN“ am Ende
|
||||||
|
von `styles.css` (auch: ruhige aktive Navigation, Knopf-Übergänge).
|
||||||
|
- **Dunkel:** Hauptknöpfe in `--knopf` (`#276f9c`, weiße Schrift 5,6:1), weil
|
||||||
|
weiße Schrift auf dem hellen Akzent nur ~2:1 erreicht.
|
||||||
|
- **Glas beruhigt** (`glas.css`): Flächen fast deckend, nur noch zwei
|
||||||
|
schwache Lichtflächen in den Logo-Blautönen (Mint entfallen), Rundungen
|
||||||
|
8/14/16, neutrale Schatten, kein `saturate()`, aktive Einträge mit Haarlinie
|
||||||
|
statt farbigem Schatten.
|
||||||
|
- **Ausgenommen:** die Wandtafel (eigenes Design, `--dash-*`/`--wt-*`) und
|
||||||
|
die Login-Seite bei den Schriftgrößen.
|
||||||
|
- Das Umbauskript arbeitete je CSS-Regel; geprüft: gleiche Regelzahl wie
|
||||||
|
vorher (kein Parse-Verlust), keine Überläufe/Großbuchstaben auf Dashboard,
|
||||||
|
Projekte, Wochenbericht, Auslastung, Abwesenheiten, Einstellungen und in
|
||||||
|
den Projektbereichen. Methode nach „unslop“ von Damir Sokolovsky
|
||||||
|
(@heydamir), CC BY 4.0.
|
||||||
|
|
||||||
## Glas-Darstellung (`public/glas.css`)
|
## Glas-Darstellung (`public/glas.css`)
|
||||||
|
|
||||||
Eriks Wahl aus dem Gestaltungsvorschlag „Projektübersicht in Glas“ (Annäherung
|
Eriks Wahl aus dem Gestaltungsvorschlag „Projektübersicht in Glas“ (Annäherung
|
||||||
@@ -4692,6 +4899,46 @@ Abweichungen:
|
|||||||
Rendern des Wochenberichts mit. `esc()` wandelt jetzt über `String()` um
|
Rendern des Wochenberichts mit. `esc()` wandelt jetzt über `String()` um
|
||||||
(nur `null`/`undefined` werden zu `''`).
|
(nur `null`/`undefined` werden zu `''`).
|
||||||
|
|
||||||
|
## Behobener Fehler: wachsende „&amp;…“ in Kommentaren (11/2026)
|
||||||
|
|
||||||
|
Eriks Meldung nach dem Sitzungswechsel KW 45: In Kommentaren stand
|
||||||
|
„&amp;amp;amp;nbsp;“. Ursache: `sanitizeFormatHtml()` (server.js)
|
||||||
|
maskierte den Text zwischen den Tags mit `htmlEscape()` — auch ein `&`, das
|
||||||
|
schon eine fertige Entity begann. Weil `serializeProject()` die Kommentare
|
||||||
|
bei JEDEM Speichern eines Projekts erneut bereinigt (auch wenn nur eine
|
||||||
|
Aufgabe geändert wurde), kam pro Speichern eine Stufe dazu ( →
|
||||||
|
&nbsp; → &amp;nbsp; …). Jetzt maskiert `textMaskieren()` ein `&` nur,
|
||||||
|
wenn es keine Entity beginnt — beliebig oft wiederholbar (geprüft: alle
|
||||||
|
Kommentare nach dreimaligem Bereinigen unverändert). `entitiesReparieren()`
|
||||||
|
(beim Start und nach `/import`, idempotent) führt gestapelte Stufen in
|
||||||
|
`project_kommentare.status` und `protokoll_punkte.text` aller Sitzungen über
|
||||||
|
`entitiesEntstapeln()` zurück.
|
||||||
|
|
||||||
|
## Behobener Fehler: Kommentare abgewählter Gewerke (11/2026)
|
||||||
|
|
||||||
|
Eriks Meldung zu 21-4030: Im Projekt-Dashboard stand ein HLS-Kommentar,
|
||||||
|
obwohl das Projekt seit KW 31 nur noch ELT hat. Ursache: Die einmalige
|
||||||
|
Umstellung `kommentareInAktuelleSitzungUebernehmen()` hatte je Gewerk den
|
||||||
|
jüngsten früheren Kommentar übernommen, ohne die aktuellen Gewerke des
|
||||||
|
Projekts zu prüfen; der Sitzungswechsel trug ihn weiter. Jetzt:
|
||||||
|
`kommentareDesProjekts()` (js/utils.js) — die Gesamtanzeige
|
||||||
|
(`kommentarVon(p, null)`, `kommentarAnzeigeHtml()`) und der Verlauf im
|
||||||
|
Projekt-Dashboard (`planKommentarDaten()`, je Sitzung mit DEREN Gewerken)
|
||||||
|
zeigen nur Gewerke des Projekts; `ensureCurrentWeekRollover()` übernimmt nur
|
||||||
|
Kommentare dieser Gewerke. Gelöscht wird nichts — die verirrte Kopie fällt
|
||||||
|
beim nächsten Sitzungswechsel aus der aktuellen Sitzung heraus.
|
||||||
|
|
||||||
|
**Darstellung mehrerer Gewerke „B – Überschrift mit Linie“** (Eriks Wahl
|
||||||
|
aus dem Artifact „Kommentare je Gewerk“): `kommentarAnzeigeHtml(p, null,
|
||||||
|
mitMeta)` zeichnet je Gewerk eine Kopfzeile (Gewerk in seiner Farbe
|
||||||
|
`--gewerk-elt/-hls/-arch`, feine Linie, mit `mitMeta` Verfasser und Zeit)
|
||||||
|
und darunter den Text; bei nur einem Gewerk wie bisher nur den Text.
|
||||||
|
Genutzt von der Kachel „Kommentar der Sitzung“ im Projekt-Dashboard (dort
|
||||||
|
entfällt dann die gemeinsame Verfasserzeile, `kommentarGewerkeMitText()`),
|
||||||
|
der Kommentaransicht der Projektliste/Wandtafel (`renderKommentarAnsicht()`)
|
||||||
|
und den Verlauf-Kacheln (ohne Verfasser). Stile `.kommentar-gewerke`,
|
||||||
|
`.kommentar-kopf` in `styles.css`.
|
||||||
|
|
||||||
## Behobene Fehler (Testlauf 10/2026, fünf Test-Agents)
|
## Behobene Fehler (Testlauf 10/2026, fünf Test-Agents)
|
||||||
|
|
||||||
- **Gesperrte Sitzungen sind für Nicht-Admins auch inhaltlich unveränderlich**:
|
- **Gesperrte Sitzungen sind für Nicht-Admins auch inhaltlich unveränderlich**:
|
||||||
|
|||||||
+244
-202
@@ -38,13 +38,13 @@
|
|||||||
<link rel="icon" type="image/png" href="/logo.png">
|
<link rel="icon" type="image/png" href="/logo.png">
|
||||||
<link rel="alternate icon" href="/logo.ico">
|
<link rel="alternate icon" href="/logo.ico">
|
||||||
<link rel="apple-touch-icon" href="/logo.png">
|
<link rel="apple-touch-icon" href="/logo.png">
|
||||||
<link rel="stylesheet" href="/styles.css?v=110">
|
<link rel="stylesheet" href="/styles.css?v=124">
|
||||||
<link rel="stylesheet" href="/plan-styles.css?v=45">
|
<link rel="stylesheet" href="/plan-styles.css?v=57">
|
||||||
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
|
<!-- Glas-Darstellung (public/glas.css, als letztes Stylesheet): greift nur mit
|
||||||
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
|
der Klasse html.glas. Gesetzt wird sie hier, vor dem ersten Zeichnen,
|
||||||
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
|
damit die Seite nicht erst ohne Glas aufblitzt; abschaltbar je Browser
|
||||||
unter Einstellungen → Mein Konto (localStorage projuebersicht-glas). -->
|
unter Einstellungen → Mein Konto (localStorage projuebersicht-glas). -->
|
||||||
<link rel="stylesheet" href="/glas.css?v=14">
|
<link rel="stylesheet" href="/glas.css?v=18">
|
||||||
<script>
|
<script>
|
||||||
try { if(localStorage.getItem('projuebersicht-glas') !== 'aus') document.documentElement.classList.add('glas'); }
|
try { if(localStorage.getItem('projuebersicht-glas') !== 'aus') document.documentElement.classList.add('glas'); }
|
||||||
catch(e){ document.documentElement.classList.add('glas'); }
|
catch(e){ document.documentElement.classList.add('glas'); }
|
||||||
@@ -129,116 +129,87 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav" aria-label="Hauptnavigation">
|
||||||
|
<!-- Gliederung nach Arbeitsbereich (Eriks Wahl „A“, unslop-Überarbeitung
|
||||||
|
11/2026): oben das Dashboard, dann Projekte, Team, Notizen und Studenten;
|
||||||
|
Einstellungen und Admin sitzen unten (.sidebar-unten, margin-top:
|
||||||
|
auto) und rutschen nur bei aufgeklapptem Admin in den Bildlauf. -->
|
||||||
|
<div class="sidebar-gruppe">
|
||||||
<button class="nav-item tab active" data-tab="dashboard" onclick="showTab('dashboard')" title="Dashboard">
|
<button class="nav-item tab active" data-tab="dashboard" onclick="showTab('dashboard')" title="Dashboard">
|
||||||
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="m4.5 7.5 1.3 1.3L8.5 6"/><line x1="13" y1="6" x2="21" y2="6"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="m4.5 17.5 1.3 1.3 2.7-2.8"/><line x1="13" y1="16" x2="21" y2="16"/></svg></span></span><span class="nav-label">Dashboard</span>
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><path d="m4.5 7.5 1.3 1.3L8.5 6"/><line x1="13" y1="6" x2="21" y2="6"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="m4.5 17.5 1.3 1.3 2.7-2.8"/><line x1="13" y1="16" x2="21" y2="16"/></svg></span></span><span class="nav-label">Dashboard</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr class="sidebar-nav-divider">
|
<div class="sidebar-gruppe">
|
||||||
|
<div class="sidebar-section-label">Projekte</div>
|
||||||
<div class="sidebar-section-label">Navigation</div>
|
|
||||||
<!-- Die eine Projektliste. Der frühere zweite Eintrag "Projekte" —
|
|
||||||
dieselben Projekte, dieselben Angaben, eine zweite Bearbeitungs-
|
|
||||||
maske — ist hier aufgegangen: Reihenfolge, Gruppenwechsel und
|
|
||||||
Löschen stehen in dieser Liste, die Projektangaben im
|
|
||||||
Projektbereich, die wöchentliche Erfassung im Wochenbericht. -->
|
|
||||||
<button class="nav-item tab" data-tab="board" onclick="showTab('board')" title="Projekte">
|
<button class="nav-item tab" data-tab="board" onclick="showTab('board')" title="Projekte">
|
||||||
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2.5"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg></span></span><span class="nav-label">Projekte</span>
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2.5"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg></span></span><span class="nav-label">Projekte</span>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Geöffnetes Projekt: eingezogen unter „Projekte“, nur sichtbar,
|
||||||
|
solange ein Projekt offen ist (syncPlanNavItem(), js/tabs-auth.js). -->
|
||||||
|
<button class="nav-item tab nav-unterpunkt" data-tab="plan" id="planNavItem" style="display:none" onclick="showTab('plan')" title="Projektbereich">
|
||||||
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="14" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="16" x2="16" y2="16"/></svg></span></span><span class="nav-label" id="planNavItemLabel">Projektbereich</span>
|
||||||
|
</button>
|
||||||
<button class="nav-item tab" data-tab="wochenbericht" onclick="showTab('wochenbericht')" title="Wochenbericht">
|
<button class="nav-item tab" data-tab="wochenbericht" onclick="showTab('wochenbericht')" title="Wochenbericht">
|
||||||
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5h8a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1-1.5 1.5H8A1.5 1.5 0 0 1 6.5 19V5A1.5 1.5 0 0 1 8 3.5Z"/><path d="M9.5 3.5h5v2.2a.8.8 0 0 1-.8.8h-3.4a.8.8 0 0 1-.8-.8V3.5Z"/><path d="m9.3 12.2 1.5 1.5 3.4-3.4"/><line x1="9.3" y1="16.3" x2="14.7" y2="16.3"/></svg></span></span><span class="nav-label">Wochenbericht</span>
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5h8a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1-1.5 1.5H8A1.5 1.5 0 0 1 6.5 19V5A1.5 1.5 0 0 1 8 3.5Z"/><path d="M9.5 3.5h5v2.2a.8.8 0 0 1-.8.8h-3.4a.8.8 0 0 1-.8-.8V3.5Z"/><path d="m9.3 12.2 1.5 1.5 3.4-3.4"/><line x1="9.3" y1="16.3" x2="14.7" y2="16.3"/></svg></span></span><span class="nav-label">Wochenbericht</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item tab" data-tab="load" onclick="showTab('load')" title="Auslastung">
|
|
||||||
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="21" x2="5" y2="10"/><line x1="12" y1="21" x2="12" y2="5"/><line x1="19" y1="21" x2="19" y2="14"/><line x1="3" y1="21" x2="21" y2="21"/></svg></span></span><span class="nav-label">Auslastung</span>
|
|
||||||
</button>
|
|
||||||
<!-- Abwesenheiten (js/abwesenheit/abwesenheit.js): sichtbar mit einem der
|
|
||||||
vier Abwesenheits-Rechte (data-recht mit "|" = eines davon genügt). -->
|
|
||||||
<button class="nav-item tab nur-recht" data-recht="team.abw|team.abwProjekte|team.abwGewerk|team.abwAlle" data-tab="abwesenheit" onclick="showTab('abwesenheit')" title="Abwesenheiten">
|
|
||||||
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="3" x2="8" y2="6.5"/><line x1="16" y1="3" x2="16" y2="6.5"/><line x1="8.5" y1="13" x2="15.5" y2="17.5"/><line x1="15.5" y1="13" x2="8.5" y2="17.5"/></svg></span></span><span class="nav-label">Abwesenheiten</span>
|
|
||||||
</button>
|
|
||||||
<!-- "Projektbeteiligte" stand hier bis zur Entfernung der Abteilungs-
|
|
||||||
trennung (migrateRemoveDepartmentPartition(), CLAUDE.md
|
|
||||||
"Departments were retired"): eine rein lesende Liste, deren
|
|
||||||
eigentlicher Zweck — Beteiligte gezielt aus einer ANDEREN Abteilung
|
|
||||||
in die eigene Sitzung zu holen — mit der Zusammenlegung zu EINEM
|
|
||||||
gemeinsamen Personenkreis entfiel. Übrig blieb eine Dopplung der
|
|
||||||
Rollen-/Avatar-Chips, die in Wochenbericht/Board/Auslastung ohnehin
|
|
||||||
schon sichtbar sind. Auf Eriks Wunsch aus der Seitenleiste entfernt;
|
|
||||||
der Tab selbst (`#people`, `js/people/people.js`) und sein Slug
|
|
||||||
`Projektbeteiligte` in VALID_TAB_SLUGS bestehen unverändert fort —
|
|
||||||
ein alter Link/Deep-Link funktioniert also weiterhin, nur der
|
|
||||||
direkte Weg über die Navigation fehlt. -->
|
|
||||||
|
|
||||||
<!-- Projektbereich: der einzige Navigationspunkt, der nicht immer da
|
|
||||||
ist. Er erscheint, sobald ein Projekt geöffnet wurde, und trägt
|
|
||||||
dessen Bezeichnung (syncPlanNavItem() in js/tabs-auth.js). Ohne
|
|
||||||
ihn wäre im Projektbereich gar kein Punkt hervorgehoben und die
|
|
||||||
Seitenleiste zeigte nicht mehr, wo man gerade ist. -->
|
|
||||||
<button class="nav-item tab" data-tab="plan" id="planNavItem" style="display:none" onclick="showTab('plan')" title="Projektbereich">
|
|
||||||
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="14" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="16" x2="16" y2="16"/></svg></span></span><span class="nav-label" id="planNavItemLabel">Projektbereich</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Planung: eigener, zweiter Abschnitt (nicht Teil von "Navigation"),
|
|
||||||
wie gewünscht — eine Sandbox für Terminpläne unabhängig von einem
|
|
||||||
echten Projekt (aus Honorar errechnet oder frei angelegt), siehe
|
|
||||||
CLAUDE.md "Planung — projektunabhängige Terminplan-Sandbox". -->
|
|
||||||
<div class="sidebar-section-label nur-recht" data-recht="wz.planung">Planung</div>
|
|
||||||
<button class="nav-item tab nur-recht" data-recht="wz.planung" data-tab="planung" onclick="showTab('planung')" title="Terminplan">
|
<button class="nav-item tab nur-recht" data-recht="wz.planung" data-tab="planung" onclick="showTab('planung')" title="Terminplan">
|
||||||
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="17" rx="2.5"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="2.5" x2="8" y2="6.5"/><line x1="16" y1="2.5" x2="16" y2="6.5"/><line x1="7" y1="14" x2="12" y2="14"/><line x1="7" y1="17.5" x2="15" y2="17.5"/></svg></span></span><span class="nav-label">Terminplan</span>
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="17" rx="2.5"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="2.5" x2="8" y2="6.5"/><line x1="16" y1="2.5" x2="16" y2="6.5"/><line x1="7" y1="14" x2="12" y2="14"/><line x1="7" y1="17.5" x2="15" y2="17.5"/></svg></span></span><span class="nav-label">Terminplan</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Studenten: eigener, dritter Abschnitt (wie "Planung" nicht Teil
|
<div class="sidebar-gruppe">
|
||||||
von "Navigation") — bislang nur ein Werkzeug, der Plan Checker:
|
<div class="sidebar-section-label">Team</div>
|
||||||
lädt einen Bauplan als PDF hoch und lässt ihn von einer KI gegen
|
<button class="nav-item tab" data-tab="load" onclick="showTab('load')" title="Auslastung">
|
||||||
eine Liste von Merkmalen prüfen (Maßketten, Plankopf, ...),
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="21" x2="5" y2="10"/><line x1="12" y1="21" x2="12" y2="5"/><line x1="19" y1="21" x2="19" y2="14"/><line x1="3" y1="21" x2="21" y2="21"/></svg></span></span><span class="nav-label">Auslastung</span>
|
||||||
siehe js/planchecker/planchecker.js. -->
|
</button>
|
||||||
<div class="sidebar-section-label nur-recht" data-recht="wz.checker">Studenten</div>
|
<button class="nav-item tab nur-recht" data-recht="team.abw|team.abwProjekte|team.abwGewerk|team.abwAlle" data-tab="abwesenheit" onclick="showTab('abwesenheit')" title="Abwesenheiten">
|
||||||
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="3" x2="8" y2="6.5"/><line x1="16" y1="3" x2="16" y2="6.5"/><line x1="8.5" y1="13" x2="15.5" y2="17.5"/><line x1="15.5" y1="13" x2="8.5" y2="17.5"/></svg></span></span><span class="nav-label">Abwesenheiten</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-gruppe">
|
||||||
|
<div class="sidebar-section-label">Notizen</div>
|
||||||
|
<button class="nav-item tab" data-tab="notizen" onclick="showTab('notizen')" title="Notizen">
|
||||||
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v12a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1Z"/><path d="M13.5 3.5V7a1 1 0 0 0 1 1h4"/><line x1="8.5" y1="12.5" x2="15.5" y2="12.5"/><line x1="8.5" y1="16" x2="13.5" y2="16"/></svg></span></span><span class="nav-label">Notizen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-gruppe nur-recht" data-recht="wz.checker">
|
||||||
|
<div class="sidebar-section-label">Studenten</div>
|
||||||
<button class="nav-item tab nur-recht" data-recht="wz.checker" data-tab="planchecker" onclick="showTab('planchecker')" title="Plan Checker">
|
<button class="nav-item tab nur-recht" data-recht="wz.checker" data-tab="planchecker" onclick="showTab('planchecker')" title="Plan Checker">
|
||||||
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg></span></span><span class="nav-label">Plan Checker</span>
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg></span></span><span class="nav-label">Plan Checker</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr class="sidebar-nav-divider">
|
<div class="sidebar-gruppe sidebar-unten">
|
||||||
|
|
||||||
<!-- Einstellungen: seit die firmenweiten Masken in den Admin-Bereich
|
|
||||||
gewandert sind, steht hier nur noch das eigene Konto — ein
|
|
||||||
Aufklapp-Menue fuer einen einzigen Punkt waere Ballast. -->
|
|
||||||
<button class="nav-item tab" data-tab="settings" onclick="showTab('settings')" title="Einstellungen">
|
<button class="nav-item tab" data-tab="settings" onclick="showTab('settings')" title="Einstellungen">
|
||||||
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M12 3.5v2.4M12 18.1v2.4M20.5 12h-2.4M5.9 12H3.5M17.7 6.3l-1.7 1.7M8 15.7l-1.7 1.7M17.7 17.7 16 16M8 8 6.3 6.3"/></svg></span></span><span class="nav-label">Einstellungen</span>
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3.2"/><path d="M12 3.5v2.4M12 18.1v2.4M20.5 12h-2.4M5.9 12H3.5M17.7 6.3l-1.7 1.7M8 15.7l-1.7 1.7M17.7 17.7 16 16M8 8 6.3 6.3"/></svg></span></span><span class="nav-label">Einstellungen</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Der Bereich „Werkzeuge“ ist entfallen (Eriks Wunsch): „+ Neues
|
|
||||||
Projekt“ steht in der Projektliste („+ Projekt“), die Wandtafel
|
|
||||||
braucht nur das Wandtafel-Konto (landet dort automatisch, sonst
|
|
||||||
über /Wandtafel erreichbar). -->
|
|
||||||
|
|
||||||
<!-- Admin: aufklappbare Gruppe ueber die Abschnitte von #admin. Wie
|
|
||||||
bei "Einstellungen" zuvor ist das KEINE Aktionsliste, sondern
|
|
||||||
Navigation innerhalb eines Bereichs — deshalb fuehrt schon der
|
|
||||||
Kopf-Eintrag dorthin (toggleAdminNav()) und die Unterpunkte
|
|
||||||
schalten nur den sichtbaren Abschnitt um (showAdminSection() in
|
|
||||||
js/admin/admin.js). Die gesamte Gruppe blendet renderUserBar()
|
|
||||||
(js/auth.js) fuer Nicht-Administratoren aus; die eigentliche
|
|
||||||
Sperre sitzt serverseitig auf den Routen. -->
|
|
||||||
<div id="navAdminSection" style="display:none">
|
<div id="navAdminSection" style="display:none">
|
||||||
<button class="nav-item nav-group-toggle" id="adminAccordionToggle" onclick="toggleAdminNav()" title="Admin" aria-expanded="false" aria-controls="adminSubnav">
|
<button class="nav-item nav-group-toggle" id="adminAccordionToggle" onclick="toggleAdminNav()" title="Admin" aria-expanded="false" aria-controls="adminSubnav">
|
||||||
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 5.5 6v5.3c0 4.4 2.9 7.4 6.5 8.7 3.6-1.3 6.5-4.3 6.5-8.7V6Z"/><path d="m9 12 2.1 2.1L15.5 9.5"/></svg></span></span><span class="nav-label">Admin</span>
|
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 5.5 6v5.3c0 4.4 2.9 7.4 6.5 8.7 3.6-1.3 6.5-4.3 6.5-8.7V6Z"/><path d="m9 12 2.1 2.1L15.5 9.5"/></svg></span></span><span class="nav-label">Admin</span>
|
||||||
<span class="nav-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
<span class="nav-chevron"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="sidebar-subnav" id="adminSubnav">
|
<div class="sidebar-subnav" id="adminSubnav">
|
||||||
|
<div class="sidebar-subgruppe">Personen</div>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="benutzer" onclick="showAdminSection('benutzer')">Benutzerverwaltung</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="benutzer" onclick="showAdminSection('benutzer')">Benutzerverwaltung</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="rollen" onclick="showAdminSection('rollen')">Rollen & Rechte</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="rollen" onclick="showAdminSection('rollen')">Rollen & Rechte</button>
|
||||||
|
<div class="sidebar-subgruppe">Vorgaben</div>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="honorar" onclick="showAdminSection('honorar')">Honorar</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="honorar" onclick="showAdminSection('honorar')">Honorar</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="auslastung" onclick="showAdminSection('auslastung')">Auslastung</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="auslastung" onclick="showAdminSection('auslastung')">Auslastung</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="termine" onclick="showAdminSection('termine')">Termine & Sitzungen</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="termine" onclick="showAdminSection('termine')">Termine & Sitzungen</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="feiertage" onclick="showAdminSection('feiertage')">Feiertage</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="feiertage" onclick="showAdminSection('feiertage')">Feiertage</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
|
||||||
|
<div class="sidebar-subgruppe">System</div>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb & Sicherheit</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb & Sicherheit</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="teams" onclick="showAdminSection('teams')">Teams</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="teams" onclick="showAdminSection('teams')">Teams</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="papierkorb" onclick="showAdminSection('papierkorb')">Papierkorb</button>
|
||||||
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank & Sitzungen</button>
|
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank & Sitzungen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button class="sidebar-collapse-bar" id="sidebarCollapseToggle" onclick="toggleSidebarCollapse()" title="Seitenleiste ein-/ausklappen" aria-pressed="false">
|
<button class="sidebar-collapse-bar" id="sidebarCollapseToggle" onclick="toggleSidebarCollapse()" title="Seitenleiste ein-/ausklappen" aria-pressed="false">
|
||||||
@@ -299,6 +270,9 @@
|
|||||||
<!-- LOAD -->
|
<!-- LOAD -->
|
||||||
<!-- ABWESENHEITEN (js/abwesenheit/abwesenheit.js) -->
|
<!-- ABWESENHEITEN (js/abwesenheit/abwesenheit.js) -->
|
||||||
<div id="abwesenheit" style="display:none"></div>
|
<div id="abwesenheit" style="display:none"></div>
|
||||||
|
<!-- NOTIZEN (js/notizen/notizen.js): Notizen aller eigenen Projekte und
|
||||||
|
projektunabhängige Notizen -->
|
||||||
|
<div id="notizen" style="display:none"></div>
|
||||||
|
|
||||||
<div id="load" style="display:none">
|
<div id="load" style="display:none">
|
||||||
<div class="section-title">Auslastung der Projektbeteiligten</div>
|
<div class="section-title">Auslastung der Projektbeteiligten</div>
|
||||||
@@ -679,23 +653,31 @@
|
|||||||
<!-- Teams-Anbindung (loadAdminTeams(), js/admin/admin.js; Server: /admin/teams*) -->
|
<!-- Teams-Anbindung (loadAdminTeams(), js/admin/admin.js; Server: /admin/teams*) -->
|
||||||
<section class="settings-card" id="adminTeamsCard" data-admin-section="teams" style="display:none">
|
<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>
|
<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
|
<p class="hint">Bekommt jemand eine Aufgabe zugewiesen, erhält genau diese Person in Teams
|
||||||
Nachricht mit Link direkt auf die Aufgabe im Projekt.</p>
|
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>
|
<div id="adminTeamsInhalt"></div>
|
||||||
<details class="kalender-anleitung">
|
<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>
|
<ol>
|
||||||
<li>In Teams den gewünschten Kanal öffnen, oben bei den drei Punkten
|
<li>In Teams die App <i>Workflows</i> öffnen (oder im Browser make.powerautomate.com) und
|
||||||
<i>Workflows</i> wählen.</li>
|
einen <b>leeren</b> Workflow anlegen.</li>
|
||||||
<li>Die Vorlage <i>„Beim Empfang einer Webhook-Anfrage in einem Kanal posten“</i>
|
<li><b>Auslöser:</b> „Bei Empfang einer Teams-Webhookanforderung“, bei
|
||||||
auswählen, einen Namen vergeben (z. B. „Projektübersicht“) und bestätigen.</li>
|
<i>Wer kann den Flow auslösen</i> „Jeder“ wählen.</li>
|
||||||
<li>Teams zeigt am Ende eine lange Adresse (beginnt mit https://). Diese kopieren,
|
<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>
|
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>
|
</ol>
|
||||||
<p class="hint">Der Server braucht dafür Internetzugang nach außen. Der Link in der
|
<p class="hint">Der Workflow läuft unter dem Konto, das ihn anlegt. Am besten legt ihn ein
|
||||||
Nachricht führt auf die Adresse, unter der die App im Büro aufgerufen wird — er
|
Sammelkonto oder die IT an — sonst hört er auf, wenn dieses Konto gesperrt wird.
|
||||||
funktioniert also nur im Büronetz (bzw. per VPN).</p>
|
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>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
<section class="settings-card" id="adminBetriebCard" data-admin-section="betrieb" style="display:none">
|
<section class="settings-card" id="adminBetriebCard" data-admin-section="betrieb" style="display:none">
|
||||||
@@ -884,7 +866,7 @@
|
|||||||
<nav class="plan-nav" id="planNav">
|
<nav class="plan-nav" id="planNav">
|
||||||
<button class="plan-nav-item" data-section="dashboard" onclick="showPlanSection('dashboard')">Dashboard</button>
|
<button class="plan-nav-item" data-section="dashboard" onclick="showPlanSection('dashboard')">Dashboard</button>
|
||||||
<button class="plan-nav-item" data-section="terminplan" onclick="showPlanSection('terminplan')">Terminplan</button>
|
<button class="plan-nav-item" data-section="terminplan" onclick="showPlanSection('terminplan')">Terminplan</button>
|
||||||
<button class="plan-nav-item" data-section="phasen" onclick="showPlanSection('phasen')">Leistungsphasen</button>
|
<button class="plan-nav-item" data-section="notizen" onclick="showPlanSection('notizen')">Notizen</button>
|
||||||
<button class="plan-nav-item" data-section="aufgaben" onclick="showPlanSection('aufgaben')">Aufgaben</button>
|
<button class="plan-nav-item" data-section="aufgaben" onclick="showPlanSection('aufgaben')">Aufgaben</button>
|
||||||
<button class="plan-nav-item" data-section="leistung" onclick="showPlanSection('leistung')">Leistung</button>
|
<button class="plan-nav-item" data-section="leistung" onclick="showPlanSection('leistung')">Leistung</button>
|
||||||
<button class="plan-nav-item" data-section="dateien" onclick="showPlanSection('dateien')">Dateien</button>
|
<button class="plan-nav-item" data-section="dateien" onclick="showPlanSection('dateien')">Dateien</button>
|
||||||
@@ -1033,17 +1015,12 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ====================== Bereich: Aufgaben ===================== -->
|
<!-- ====================== Bereich: Aufgaben ===================== -->
|
||||||
<!-- ================== Bereich: Leistungsphasen ================== -->
|
<!-- ======================= Bereich: Notizen ======================= -->
|
||||||
<!-- Eriks Wahl "B": die Phasenbalken des Terminplans als eigener
|
<!-- Eriks Wahl „A – Liste + Editor“: gemeinsame Notizen je Projekt
|
||||||
Bereich — Ablaufbalken über alle Phasen, darunter eine Kachel je
|
(renderPlanNotizen(), js/plan/plan-notizen.js; Server:
|
||||||
Phase (renderPlanPhasen(), js/plan/plan-phasen.js). In der
|
/projekt-notizen*). Ersetzt den früheren Reiter „Leistungsphasen“. -->
|
||||||
Aufgabenliste stehen sie dafür nicht mehr. -->
|
<section class="plan-section" id="planSection-notizen" data-section="notizen">
|
||||||
<section class="plan-section" id="planSection-phasen" data-section="phasen">
|
<div id="planNotizenWrap"></div>
|
||||||
<div class="plan-main-header">
|
|
||||||
<button onclick="addPlanPhase()" id="addPhaseBtnPhasen">+ Leistungsphase</button>
|
|
||||||
<button class="secondary" onclick="openHonorarModal()" id="honorarBtnPhasen" title="Terminplan aus Honorar und Leistungsphasen-Anteilen errechnen">Terminplan aus Honorar</button>
|
|
||||||
</div>
|
|
||||||
<div id="planPhasenWrap"></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="plan-section" id="planSection-aufgaben" data-section="aufgaben">
|
<section class="plan-section" id="planSection-aufgaben" data-section="aufgaben">
|
||||||
@@ -1525,41 +1502,67 @@
|
|||||||
Art und Titel, Abschnitte Wann / Wo bzw. Wer / Stand / Hängt ab von,
|
Art und Titel, Abschnitte Wann / Wo bzw. Wer / Stand / Hängt ab von,
|
||||||
feste Fußleiste. Alle Feld-ids wie zuvor; befüllt und ausgewertet von
|
feste Fußleiste. Alle Feld-ids wie zuvor; befüllt und ausgewertet von
|
||||||
js/plan/plan-modals.js. Stile: Block "Seitenpanel" in plan-styles.css. -->
|
js/plan/plan-modals.js. Stile: Block "Seitenpanel" in plan-styles.css. -->
|
||||||
|
<!-- Seitenpanels Aufgabe und Meilenstein als "Planblatt" (Eriks Wahl,
|
||||||
|
unslop): Kopf mit Art, Projekt und Stand, darunter die Lage im
|
||||||
|
Terminplan (todoLageNeu()/msLageBildNeu(), js/plan/plan-modals.js), dann
|
||||||
|
die Angaben als Zeilen mit Beschriftung. Alle Feld-ids wie zuvor.
|
||||||
|
Stile: Block "Planblatt" am Ende von plan-styles.css. -->
|
||||||
<div class="modal-bg als-panel" id="milestoneModal" style="display:none">
|
<div class="modal-bg als-panel" id="milestoneModal" style="display:none">
|
||||||
<div class="modal pd">
|
<div class="modal pd">
|
||||||
<div class="pd-kopf">
|
<div class="pd-kopf">
|
||||||
<div class="pd-art"><span class="pd-raute" aria-hidden="true"></span><span id="milestoneModalTitle">Meilenstein</span>
|
<div class="pd-art"><span class="pd-raute" aria-hidden="true"></span><span id="milestoneModalTitle">Meilenstein</span>
|
||||||
<span class="modal-kbd">Esc schließt</span>
|
<span class="pd-wo" id="milestoneModalWo"></span>
|
||||||
<button type="button" class="pd-zu" onclick="closeMilestoneModal()" aria-label="Schließen">×</button></div>
|
<span class="modal-kbd"><kbd>Esc</kbd></span>
|
||||||
|
<button type="button" class="pd-zu" onclick="closeMilestoneModal()" aria-label="Schließen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button></div>
|
||||||
<input type="text" id="milestoneEditName" class="pd-titel" placeholder="z. B. Abgabe Genehmigungsplanung" aria-label="Name des Meilensteins">
|
<input type="text" id="milestoneEditName" class="pd-titel" placeholder="z. B. Abgabe Genehmigungsplanung" aria-label="Name des Meilensteins">
|
||||||
|
<div class="pd-stand">
|
||||||
|
<div class="segmented pd-seg" id="milestoneEditStand" role="group" aria-label="Stand">
|
||||||
|
<button type="button" data-stand="offen" aria-pressed="true" onclick="msStandSetzen(false)"><span class="segmented-dot"></span>Offen</button>
|
||||||
|
<button type="button" data-stand="erreicht" aria-pressed="false" onclick="msStandSetzen(true)"><span class="segmented-dot"></span>Erreicht</button>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" id="milestoneEditErreicht" hidden>
|
||||||
|
<span class="pd-warnung" id="milestoneEditWarnung" hidden></span>
|
||||||
|
</div>
|
||||||
|
<div class="pd-lage" id="msLageBild" aria-label="Lage im Terminplan"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pd-inhalt">
|
<div class="pd-inhalt">
|
||||||
<section class="pd-abs">
|
<div class="pd-zeile">
|
||||||
<h3>Wann</h3>
|
<span class="pd-lab">Datum</span>
|
||||||
<!-- sichtbares Anzeigefeld (deutsches Format) + verstecktes ISO-Feld -->
|
<div class="pd-wert">
|
||||||
<input type="text" id="milestoneEditDateDisplay" class="range-picker-display" placeholder="Datum wählen…" readonly aria-label="Datum"
|
<!-- sichtbares Anzeigefeld (deutsches Format) + verstecktes ISO-Feld -->
|
||||||
onclick="openDatePicker({ anchor: this, inputId: 'milestoneEditDate', displayId: 'milestoneEditDateDisplay' })">
|
<div class="field-inline">
|
||||||
<input type="hidden" id="milestoneEditDate">
|
<input type="text" id="milestoneEditDateDisplay" class="range-picker-display" placeholder="Datum wählen…" readonly aria-label="Datum"
|
||||||
</section>
|
onclick="openDatePicker({ anchor: this, inputId: 'milestoneEditDate', displayId: 'milestoneEditDateDisplay', onApply: msLageBildNeu })">
|
||||||
<section class="pd-abs">
|
<span class="field-badge pd-kw" id="milestoneEditKw" hidden></span>
|
||||||
<h3>Wo im Terminplan</h3>
|
</div>
|
||||||
<div class="segmented pd-seg" id="milestoneEditLage">
|
<input type="hidden" id="milestoneEditDate">
|
||||||
<button type="button" data-lage="zeile" aria-pressed="false" onclick="msLageSetzen('zeile')">Oben, eigene Zeile</button>
|
|
||||||
<button type="button" data-lage="aufgabe" aria-pressed="false" onclick="msLageSetzen('aufgabe')">Bei einer Aufgabe</button>
|
|
||||||
</div>
|
</div>
|
||||||
<select id="milestoneEditPosition" aria-label="Position bei einer Aufgabe" onchange="msLageSync()">
|
</div>
|
||||||
<option value="">Oben in der Projektzeile</option>
|
<div class="pd-zeile">
|
||||||
</select>
|
<span class="pd-lab">Im Terminplan</span>
|
||||||
</section>
|
<div class="pd-wert">
|
||||||
<section class="pd-abs">
|
<div class="segmented pd-seg" id="milestoneEditLage">
|
||||||
<h3>Hängt ab von</h3>
|
<button type="button" data-lage="zeile" aria-pressed="false" onclick="msLageSetzen('zeile')">Eigene Zeile oben</button>
|
||||||
<div id="milestoneEditDependsOn" class="todo-depends-list pd-liste"></div>
|
<button type="button" data-lage="aufgabe" aria-pressed="false" onclick="msLageSetzen('aufgabe')">Bei einer Aufgabe</button>
|
||||||
</section>
|
</div>
|
||||||
<section class="pd-abs">
|
<select id="milestoneEditPosition" aria-label="Position bei einer Aufgabe" onchange="msLageSync()">
|
||||||
<h3>Notiz</h3>
|
<option value="">Oben in der Projektzeile</option>
|
||||||
<p class="hint pd-hint pd-mail-hinweis" id="milestoneEditMailHinweis" hidden></p>
|
</select>
|
||||||
<textarea id="milestoneEditNotiz" class="pd-notiz" rows="4" placeholder="Optional, z. B. Hintergrund oder die Mail, aus der der Meilenstein stammt" aria-label="Notiz"></textarea>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
<div class="pd-zeile">
|
||||||
|
<span class="pd-lab">Hängt ab von</span>
|
||||||
|
<div class="pd-wert">
|
||||||
|
<div id="milestoneEditDependsOn" class="todo-depends-list pd-liste" onchange="msLageBildNeu()"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pd-zeile">
|
||||||
|
<span class="pd-lab"><label for="milestoneEditNotiz">Notiz</label></span>
|
||||||
|
<div class="pd-wert">
|
||||||
|
<p class="hint pd-hint pd-mail-hinweis" id="milestoneEditMailHinweis" hidden></p>
|
||||||
|
<textarea id="milestoneEditNotiz" class="pd-notiz" rows="3" placeholder="Hintergrund oder die Mail, aus der der Meilenstein stammt"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pd-fuss">
|
<div class="pd-fuss">
|
||||||
<button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button>
|
<button type="button" class="pd-loeschen" id="milestoneEditDelete" onclick="deleteMilestoneEdit()">Löschen</button>
|
||||||
@@ -1572,45 +1575,12 @@
|
|||||||
<div class="modal pd">
|
<div class="modal pd">
|
||||||
<div class="pd-kopf">
|
<div class="pd-kopf">
|
||||||
<div class="pd-art"><span class="pd-kasten" aria-hidden="true"></span><span id="todoModalTitle">Aufgabe bearbeiten</span>
|
<div class="pd-art"><span class="pd-kasten" aria-hidden="true"></span><span id="todoModalTitle">Aufgabe bearbeiten</span>
|
||||||
<span class="modal-kbd" id="todoModalKbd">Esc · Strg+↵</span>
|
<span class="pd-wo" id="todoModalWo"></span>
|
||||||
<button type="button" class="pd-zu" onclick="closeTodoModal()" aria-label="Schließen">×</button></div>
|
<span class="modal-kbd" id="todoModalKbd"><kbd>Esc</kbd> <kbd>Strg</kbd><kbd>↵</kbd></span>
|
||||||
|
<button type="button" class="pd-zu" onclick="closeTodoModal()" aria-label="Schließen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button></div>
|
||||||
<input type="text" id="todoEditTitle" class="pd-titel" placeholder="Titel der Aufgabe" aria-label="Titel">
|
<input type="text" id="todoEditTitle" class="pd-titel" placeholder="Titel der Aufgabe" aria-label="Titel">
|
||||||
<span class="field-error" id="todoEditTitleError" hidden></span>
|
<span class="field-error" id="todoEditTitleError" hidden></span>
|
||||||
</div>
|
<div class="pd-stand">
|
||||||
<div class="pd-inhalt">
|
|
||||||
<!-- Nur im Phasen-Modus ("+ Neu" → "Leistungsphase" oder Bearbeiten
|
|
||||||
eines Phasenbalkens): Welche Leistungsphase dieser Balken ist. Nur
|
|
||||||
darüber wird ein Balken zum Phasenkopf, nicht mehr über den Titel. -->
|
|
||||||
<section class="pd-abs" id="todoEditPhaseAbs" hidden>
|
|
||||||
<h3>Leistungsphase</h3>
|
|
||||||
<select id="todoEditLph" aria-label="Leistungsphase" onchange="todoPhaseGewechselt()"></select>
|
|
||||||
<p class="hint pd-hint">Aufgaben, die im Zeitraum dieser Phase beginnen, stehen im Terminplan darunter.</p>
|
|
||||||
</section>
|
|
||||||
<section class="pd-abs">
|
|
||||||
<h3>Wann</h3>
|
|
||||||
<div class="field-inline">
|
|
||||||
<input type="text" id="todoEditRange" class="range-picker-display" placeholder="Start – Ende wählen…" readonly aria-label="Zeitraum"
|
|
||||||
onclick="openRangePicker({ anchor: this, startInputId: 'todoEditStart', endInputId: 'todoEditEnd', displayId: 'todoEditRange', onApply: refreshTodoDuration })">
|
|
||||||
<span class="field-badge" id="todoEditDuration" hidden></span>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="todoEditStart">
|
|
||||||
<input type="hidden" id="todoEditEnd">
|
|
||||||
<span class="field-error" id="todoEditRangeError" hidden></span>
|
|
||||||
<label class="pd-haken"><input type="checkbox" id="todoEditShowInGantt" checked> Im Terminplan als Balken zeigen</label>
|
|
||||||
<p class="hint pd-hint">Ohne Zeitraum erscheint die Aufgabe nur unter „Aufgaben“, nicht im Terminplan.</p>
|
|
||||||
</section>
|
|
||||||
<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>
|
|
||||||
</section>
|
|
||||||
<section class="pd-abs">
|
|
||||||
<h3>Stand</h3>
|
|
||||||
<div class="segmented pd-seg" id="todoEditStatus">
|
<div class="segmented pd-seg" id="todoEditStatus">
|
||||||
<button type="button" data-status="offen" aria-pressed="false" onclick="setTodoEditStatus('offen')"><span class="segmented-dot"></span>Offen</button>
|
<button type="button" data-status="offen" aria-pressed="false" onclick="setTodoEditStatus('offen')"><span class="segmented-dot"></span>Offen</button>
|
||||||
<button type="button" data-status="arbeit" aria-pressed="false" onclick="setTodoEditStatus('arbeit')"><span class="segmented-dot"></span>In Arbeit</button>
|
<button type="button" data-status="arbeit" aria-pressed="false" onclick="setTodoEditStatus('arbeit')"><span class="segmented-dot"></span>In Arbeit</button>
|
||||||
@@ -1621,28 +1591,98 @@
|
|||||||
oninput="onTodoProgressInput(this)">
|
oninput="onTodoProgressInput(this)">
|
||||||
<output id="todoEditProgressVal">0 %</output>
|
<output id="todoEditProgressVal">0 %</output>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
<section class="pd-abs">
|
<div class="pd-lage" id="todoLageBild" aria-label="Lage im Terminplan"></div>
|
||||||
<h3>Notiz</h3>
|
</div>
|
||||||
<p class="hint pd-hint pd-mail-hinweis" id="todoEditMailHinweis" hidden></p>
|
<div class="pd-inhalt">
|
||||||
<textarea id="todoEditNotiz" class="pd-notiz" rows="4" placeholder="Optional, z. B. Hintergrund oder die Mail, aus der die Aufgabe stammt" aria-label="Notiz"></textarea>
|
<!-- Nur im Phasen-Modus ("+ Neu" → "Leistungsphase" oder Bearbeiten
|
||||||
</section>
|
eines Phasenbalkens): Welche Leistungsphase dieser Balken ist. Nur
|
||||||
<section class="pd-abs">
|
darüber wird ein Balken zum Phasenkopf, nicht mehr über den Titel. -->
|
||||||
<details class="fold" id="todoEditDependsFold">
|
<div class="pd-zeile" id="todoEditPhaseAbs" hidden>
|
||||||
<summary>
|
<span class="pd-lab"><label for="todoEditLph">Leistungsphase</label></span>
|
||||||
<span class="fold-caret">▶</span>
|
<div class="pd-wert">
|
||||||
<span>Hängt ab von</span>
|
<select id="todoEditLph" onchange="todoPhaseGewechselt()"></select>
|
||||||
<span class="fold-count zero" id="todoEditDependsCount">0</span>
|
<p class="hint pd-hint">Aufgaben, die im Zeitraum dieser Phase beginnen, stehen im Terminplan darunter.</p>
|
||||||
</summary>
|
</div>
|
||||||
<div class="fold-body">
|
</div>
|
||||||
<div class="fold-search">
|
<div class="pd-zeile">
|
||||||
<span class="fold-search-icon">⌕</span>
|
<span class="pd-lab">Zeitraum</span>
|
||||||
<input type="text" id="todoEditDependsSearch" placeholder="Aufgabe oder Meilenstein suchen…" oninput="filterTodoDepends()">
|
<div class="pd-wert">
|
||||||
</div>
|
<div class="field-inline">
|
||||||
<div id="todoEditDependsOn" class="todo-depends-list flat" onchange="refreshTodoDependsCount()"></div>
|
<input type="text" id="todoEditRange" class="range-picker-display" placeholder="Start – Ende wählen…" readonly aria-label="Zeitraum"
|
||||||
|
onclick="openRangePicker({ anchor: this, startInputId: 'todoEditStart', endInputId: 'todoEditEnd', displayId: 'todoEditRange', onApply: refreshTodoDuration })">
|
||||||
|
<span class="field-badge" id="todoEditDuration" hidden></span>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
<input type="hidden" id="todoEditStart">
|
||||||
</section>
|
<input type="hidden" id="todoEditEnd">
|
||||||
|
<span class="field-error" id="todoEditRangeError" hidden></span>
|
||||||
|
<label class="pd-haken"><input type="checkbox" id="todoEditShowInGantt" checked> Als Balken im Terminplan</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pd-zeile" id="todoEditWerAbs">
|
||||||
|
<span class="pd-lab">Zuständig</span>
|
||||||
|
<div class="pd-wert">
|
||||||
|
<div id="todoEditAssignees" class="person-chips"></div>
|
||||||
|
<!-- 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Aufwand: −/+, Schnellwahl, Umrechnung in Arbeitstage
|
||||||
|
(todoAufwandAendern()/todoAufwandSetzen()/todoAufwandInfo(), plan-modals.js) -->
|
||||||
|
<div class="pd-zeile" id="todoEditAufwandAbs">
|
||||||
|
<span class="pd-lab"><label for="todoEditAufwand">Aufwand</label></span>
|
||||||
|
<div class="pd-wert pd-aufwand">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="pd-zeile">
|
||||||
|
<span class="pd-lab">Hängt ab von</span>
|
||||||
|
<div class="pd-wert">
|
||||||
|
<div class="pd-vorg" id="todoEditDependsSummary"></div>
|
||||||
|
<details class="fold" id="todoEditDependsFold">
|
||||||
|
<summary>
|
||||||
|
<span class="fold-caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg></span>
|
||||||
|
<span>Vorgänger ändern</span>
|
||||||
|
<span class="fold-count zero" id="todoEditDependsCount">0</span>
|
||||||
|
</summary>
|
||||||
|
<div class="fold-body">
|
||||||
|
<div class="fold-search">
|
||||||
|
<span class="fold-search-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="6"/><line x1="15.5" y1="15.5" x2="20" y2="20"/></svg></span>
|
||||||
|
<input type="text" id="todoEditDependsSearch" placeholder="Aufgabe oder Meilenstein suchen" oninput="filterTodoDepends()">
|
||||||
|
</div>
|
||||||
|
<div id="todoEditDependsOn" class="todo-depends-list flat" onchange="refreshTodoDependsCount()"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pd-zeile">
|
||||||
|
<span class="pd-lab"><label for="todoEditNotiz">Notiz</label></span>
|
||||||
|
<div class="pd-wert">
|
||||||
|
<p class="hint pd-hint pd-mail-hinweis" id="todoEditMailHinweis" hidden></p>
|
||||||
|
<textarea id="todoEditNotiz" class="pd-notiz" rows="3" placeholder="Hintergrund oder die Mail, aus der die Aufgabe stammt"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pd-fuss">
|
<div class="pd-fuss">
|
||||||
<button type="button" class="pd-loeschen" id="todoEditDelete" onclick="deleteTodoEdit()">Löschen</button>
|
<button type="button" class="pd-loeschen" id="todoEditDelete" onclick="deleteTodoEdit()">Löschen</button>
|
||||||
@@ -1950,21 +1990,22 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/globals.js?v=9"></script>
|
<script src="/js/globals.js?v=10"></script>
|
||||||
<script src="/js/utils.js?v=31"></script>
|
<script src="/js/utils.js?v=33"></script>
|
||||||
<script src="/js/icons.js?v=2"></script>
|
<script src="/js/icons.js?v=2"></script>
|
||||||
<script src="/js/ui.js?v=9"></script>
|
<script src="/js/ui.js?v=9"></script>
|
||||||
<script src="/js/date-picker.js?v=6"></script>
|
<script src="/js/date-picker.js?v=6"></script>
|
||||||
<script src="/js/server.js?v=7"></script>
|
<script src="/js/server.js?v=7"></script>
|
||||||
<script src="/js/auth.js?v=11"></script>
|
<script src="/js/auth.js?v=11"></script>
|
||||||
<script src="/js/store.js?v=15"></script>
|
<script src="/js/store.js?v=17"></script>
|
||||||
<script src="/js/weeks.js?v=12"></script>
|
<script src="/js/weeks.js?v=12"></script>
|
||||||
<script src="/js/board/board.js?v=41"></script>
|
<script src="/js/board/board.js?v=42"></script>
|
||||||
<script src="/js/wochenbericht/wochenbericht.js?v=29"></script>
|
<script src="/js/wochenbericht/wochenbericht.js?v=31"></script>
|
||||||
<script src="/js/people/people.js?v=8"></script>
|
<script src="/js/people/people.js?v=8"></script>
|
||||||
<script src="/js/planung/planung.js?v=3"></script>
|
<script src="/js/planung/planung.js?v=3"></script>
|
||||||
<script src="/js/planchecker/planchecker.js?v=1"></script>
|
<script src="/js/planchecker/planchecker.js?v=1"></script>
|
||||||
<script src="/js/abwesenheit/abwesenheit.js?v=10"></script>
|
<script src="/js/abwesenheit/abwesenheit.js?v=10"></script>
|
||||||
|
<script src="/js/notizen/notizen.js?v=3"></script>
|
||||||
<script src="/js/projects/projects.js?v=10"></script>
|
<script src="/js/projects/projects.js?v=10"></script>
|
||||||
<script src="/js/load/load.js?v=23"></script>
|
<script src="/js/load/load.js?v=23"></script>
|
||||||
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
|
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
|
||||||
@@ -1973,7 +2014,7 @@
|
|||||||
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
|
<!-- Assistent "Neues Projekt": braucht auftrag.js (Auftragsstatus) und
|
||||||
load.js (faktorFormat), steht deshalb dahinter. -->
|
load.js (faktorFormat), steht deshalb dahinter. -->
|
||||||
<script src="/js/projekt-wizard.js?v=6"></script>
|
<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=14"></script>
|
||||||
<script src="/js/benachrichtigungen.js?v=5"></script>
|
<script src="/js/benachrichtigungen.js?v=5"></script>
|
||||||
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
|
<!-- wand.js braucht berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor()/
|
||||||
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
|
stundenFormat() (load.js), classifyProjectChange()/findPreviousWeek()
|
||||||
@@ -1981,12 +2022,12 @@
|
|||||||
relativerTag()/tageBis()/initialsFor() (dashboard.js) — alle laden
|
relativerTag()/tageBis()/initialsFor() (dashboard.js) — alle laden
|
||||||
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
|
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
|
||||||
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
|
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
|
||||||
<script src="/js/wand/wand.js?v=21"></script>
|
<script src="/js/wand/wand.js?v=22"></script>
|
||||||
<script src="/js/settings/settings.js?v=14"></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=24"></script>
|
||||||
<script src="/js/admin-users.js?v=10"></script>
|
<script src="/js/admin-users.js?v=10"></script>
|
||||||
<script src="/js/admin/rollen.js?v=1"></script>
|
<script src="/js/admin/rollen.js?v=1"></script>
|
||||||
<script src="/js/einfuehrung.js?v=2"></script>
|
<script src="/js/einfuehrung.js?v=3"></script>
|
||||||
<script src="/js/gantt.js?v=10"></script>
|
<script src="/js/gantt.js?v=10"></script>
|
||||||
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
|
<!-- Module des Projektbereichs (#plan). Sie liefen frueher nur in
|
||||||
project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist,
|
project-plan.html; seit der Projektbereich ein Bereich dieser Seite ist,
|
||||||
@@ -1995,30 +2036,31 @@
|
|||||||
(das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach
|
(das seine Vorwochen-Helfer nutzt), und alle zusammen muessen nach
|
||||||
js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js
|
js/gantt.js kommen, dessen buildGanttHtml() plan-build-gantt.js
|
||||||
umhuellt. -->
|
umhuellt. -->
|
||||||
<script src="/js/plan/plan-state.js?v=24"></script>
|
<script src="/js/plan/plan-state.js?v=25"></script>
|
||||||
<script src="/js/plan/plan-sections.js?v=10"></script>
|
<script src="/js/plan/plan-sections.js?v=13"></script>
|
||||||
<script src="/js/plan/plan-info.js?v=15"></script>
|
<script src="/js/plan/plan-info.js?v=17"></script>
|
||||||
<script src="/js/plan/plan-history.js?v=4"></script>
|
<script src="/js/plan/plan-history.js?v=4"></script>
|
||||||
<script src="/js/plan/plan-build-gantt.js?v=11"></script>
|
<script src="/js/plan/plan-build-gantt.js?v=11"></script>
|
||||||
<script src="/js/plan/plan-gantt.js?v=21"></script>
|
<script src="/js/plan/plan-gantt.js?v=24"></script>
|
||||||
<script src="/js/plan/plan-drag.js?v=8"></script>
|
<script src="/js/plan/plan-drag.js?v=8"></script>
|
||||||
<script src="/js/plan/plan-modals.js?v=15"></script>
|
<script src="/js/plan/plan-modals.js?v=21"></script>
|
||||||
<script src="/js/plan/plan-tasks.js?v=4"></script>
|
<script src="/js/plan/plan-tasks.js?v=5"></script>
|
||||||
<script src="/js/plan/plan-phasen.js?v=2"></script>
|
<script src="/js/plan/plan-phasen.js?v=3"></script>
|
||||||
<script src="/js/plan/plan-files.js?v=4"></script>
|
<script src="/js/plan/plan-files.js?v=4"></script>
|
||||||
<script src="/js/plan/plan-protokoll.js?v=20"></script>
|
<script src="/js/plan/plan-protokoll.js?v=21"></script>
|
||||||
<script src="/js/plan/plan-templates.js?v=11"></script>
|
<script src="/js/plan/plan-templates.js?v=11"></script>
|
||||||
<script src="/js/plan/plan-protokoll-todos.js?v=2"></script>
|
<script src="/js/plan/plan-protokoll-todos.js?v=3"></script>
|
||||||
<script src="/js/plan/plan-mail-eintrag.js?v=4"></script>
|
<script src="/js/plan/plan-mail-eintrag.js?v=4"></script>
|
||||||
|
<script src="/js/plan/plan-notizen.js?v=2"></script>
|
||||||
<script src="/js/plan/plan-honorar.js?v=9"></script>
|
<script src="/js/plan/plan-honorar.js?v=9"></script>
|
||||||
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
|
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
|
||||||
invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen
|
invalidateHonorarConfig(), damit eine Aenderung der Anteile auch seinen
|
||||||
Zwischenspeicher verwirft. -->
|
Zwischenspeicher verwirft. -->
|
||||||
<script src="/js/plan/plan-leistung.js?v=1"></script>
|
<script src="/js/plan/plan-leistung.js?v=1"></script>
|
||||||
<script src="/js/plan/plan-settings.js?v=14"></script>
|
<script src="/js/plan/plan-settings.js?v=14"></script>
|
||||||
<script src="/js/plan/plan-schedule.js?v=6"></script>
|
<script src="/js/plan/plan-schedule.js?v=7"></script>
|
||||||
<script src="/js/tabs-auth.js?v=17"></script>
|
<script src="/js/tabs-auth.js?v=19"></script>
|
||||||
<script src="/js/pdf.js?v=31"></script>
|
<script src="/js/pdf.js?v=32"></script>
|
||||||
<script src="/js/backup.js?v=2"></script>
|
<script src="/js/backup.js?v=2"></script>
|
||||||
<script src="/js/import.js?v=1"></script>
|
<script src="/js/import.js?v=1"></script>
|
||||||
<script src="/js/init.js?v=14"></script>
|
<script src="/js/init.js?v=14"></script>
|
||||||
|
|||||||
+96
-89
@@ -1,5 +1,8 @@
|
|||||||
/* ===========================================================================
|
/* ===========================================================================
|
||||||
GLAS-DARSTELLUNG (Eriks Wahl: Gestaltungsvorschlag "Projektübersicht in
|
GLAS-DARSTELLUNG — beruhigt in der unslop-Überarbeitung „Bauplan“ (Eriks
|
||||||
|
Wahl „Beruhigen“): Flächen fast deckend, Lichtflächen schwach und nur noch
|
||||||
|
in den zwei Logo-Blautönen, kleinere Rundungen und neutrale Schatten.
|
||||||
|
Ursprünglich: (Eriks Wahl: Gestaltungsvorschlag "Projektübersicht in
|
||||||
Glas", Annäherung an Apple Liquid Glass)
|
Glas", Annäherung an Apple Liquid Glass)
|
||||||
---------------------------------------------------------------------------
|
---------------------------------------------------------------------------
|
||||||
Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES
|
Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES
|
||||||
@@ -27,107 +30,105 @@
|
|||||||
|
|
||||||
/* ---------- Tokens: hell ---------- */
|
/* ---------- Tokens: hell ---------- */
|
||||||
html.glas {
|
html.glas {
|
||||||
--bg: #e8eef3;
|
--bg: #eef1f4;
|
||||||
--surface: rgba(255,255,255,.58);
|
--surface: rgba(255,255,255,.82);
|
||||||
--surface-alt: rgba(255,255,255,.40);
|
--surface-alt: rgba(255,255,255,.66);
|
||||||
--surface-sunk: rgba(15,90,133,.07);
|
--surface-sunk: rgba(16,24,32,.05);
|
||||||
--line: rgba(20,45,65,.16);
|
--line: rgba(16,24,32,.13);
|
||||||
--line-soft: rgba(20,45,65,.09);
|
--line-soft: rgba(16,24,32,.07);
|
||||||
|
|
||||||
--accent: #0f5a85;
|
--accent: #0f5a85;
|
||||||
--accent-hover: #0d5079;
|
--accent-hover: #0d5079;
|
||||||
--accent-active: #0a4565;
|
--accent-active: #0a4565;
|
||||||
--accent-soft: rgba(15,90,133,.11);
|
--accent-soft: rgba(15,90,133,.09);
|
||||||
--band-neu: #0f5a85;
|
--band-neu: #0f5a85;
|
||||||
|
|
||||||
--text: #14212b;
|
--text: #16191d;
|
||||||
--text-muted: #4b5d6b;
|
--text-muted: #4d545c;
|
||||||
--text-subtle: #6f8291;
|
--text-subtle: #69707a;
|
||||||
|
|
||||||
--r-md: 10px;
|
--r-md: 8px;
|
||||||
--r-lg: 18px;
|
--r-lg: 14px;
|
||||||
--r-xl: 22px;
|
--r-xl: 16px;
|
||||||
|
|
||||||
--sh-1: 0 8px 28px rgba(21,58,86,.10), 0 1px 4px rgba(21,58,86,.06);
|
--sh-1: 0 1px 2px rgba(16,24,32,.05);
|
||||||
--sh-2: 0 12px 34px rgba(21,58,86,.13), 0 2px 6px rgba(21,58,86,.07);
|
--sh-2: 0 4px 16px rgba(16,24,32,.07), 0 1px 2px rgba(16,24,32,.04);
|
||||||
--sh-3: 0 20px 60px rgba(21,58,86,.20), 0 4px 14px rgba(21,58,86,.10);
|
--sh-3: 0 12px 32px rgba(16,24,32,.12), 0 2px 6px rgba(16,24,32,.06);
|
||||||
|
|
||||||
--glas-rahmen: rgba(255,255,255,.62);
|
--glas-rahmen: rgba(255,255,255,.84);
|
||||||
--glas-dicht: rgba(255,255,255,.86);
|
--glas-dicht: rgba(255,255,255,.94);
|
||||||
--glas-deckend: #f7fafc;
|
--glas-deckend: #f7fafc;
|
||||||
--glas-kante: rgba(255,255,255,.80);
|
--glas-kante: rgba(255,255,255,.70);
|
||||||
--glas-kante-2: rgba(255,255,255,.35);
|
--glas-kante-2: rgba(255,255,255,.25);
|
||||||
--glas-blur: 24px;
|
--glas-blur: 16px;
|
||||||
--glas-hell: #94b0cc;
|
--glas-hell: #94b0cc;
|
||||||
--glas-tief: #116896;
|
--glas-tief: #116896;
|
||||||
--glas-logo: .16;
|
--glas-logo: .07;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Tokens: dunkel (System und manuell) ---------- */
|
/* ---------- Tokens: dunkel (System und manuell) ---------- */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
html.glas:not([data-theme="light"]) {
|
html.glas:not([data-theme="light"]) {
|
||||||
--bg: #0b141b;
|
--bg: #111315;
|
||||||
--surface: rgba(30,46,60,.50);
|
--surface: rgba(26,29,33,.80);
|
||||||
--surface-alt: rgba(30,46,60,.34);
|
--surface-alt: rgba(26,29,33,.62);
|
||||||
--surface-sunk: rgba(255,255,255,.06);
|
--surface-sunk: rgba(255,255,255,.05);
|
||||||
--line: rgba(255,255,255,.14);
|
--line: rgba(236,238,240,.13);
|
||||||
--line-soft: rgba(255,255,255,.08);
|
--line-soft: rgba(236,238,240,.07);
|
||||||
--accent: #8cc9e6;
|
--accent: #86bfdf;
|
||||||
--accent-hover: #a3d4ec;
|
--accent-hover: #9dcbe5;
|
||||||
--accent-active: #6fb3d3;
|
--accent-active: #6aa9cc;
|
||||||
--accent-soft: rgba(140,201,230,.15);
|
--accent-soft: rgba(134,191,223,.13);
|
||||||
--band-neu: #116896;
|
--band-neu: #116896;
|
||||||
--text: #e8f0f5;
|
--text: #eceef0;
|
||||||
--text-muted: #a9bccb;
|
--text-muted: #a5abb2;
|
||||||
--text-subtle: #7790a2;
|
--text-subtle: #878d95;
|
||||||
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30);
|
--sh-1: 0 1px 2px rgba(0,0,0,.4);
|
||||||
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30);
|
--sh-2: 0 4px 16px rgba(0,0,0,.45);
|
||||||
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35);
|
--sh-3: 0 16px 40px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
|
||||||
--glas-rahmen: rgba(28,42,54,.58);
|
--glas-rahmen: rgba(24,27,30,.84);
|
||||||
--glas-dicht: rgba(22,34,44,.90);
|
--glas-dicht: rgba(22,24,27,.95);
|
||||||
--glas-deckend: #15212b;
|
--glas-deckend: #181a1d;
|
||||||
--glas-kante: rgba(255,255,255,.18);
|
--glas-kante: rgba(255,255,255,.12);
|
||||||
--glas-kante-2: rgba(255,255,255,.07);
|
--glas-kante-2: rgba(255,255,255,.05);
|
||||||
--glas-hell: #2c5675;
|
--glas-hell: #2c5675;
|
||||||
--glas-tief: #1d6f99;
|
--glas-tief: #1d6f99;
|
||||||
--glas-logo: .10;
|
--glas-logo: .06;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html.glas[data-theme="dark"] {
|
html.glas[data-theme="dark"] {
|
||||||
--bg: #0b141b;
|
--bg: #111315;
|
||||||
--surface: rgba(30,46,60,.50);
|
--surface: rgba(26,29,33,.80);
|
||||||
--surface-alt: rgba(30,46,60,.34);
|
--surface-alt: rgba(26,29,33,.62);
|
||||||
--surface-sunk: rgba(255,255,255,.06);
|
--surface-sunk: rgba(255,255,255,.05);
|
||||||
--line: rgba(255,255,255,.14);
|
--line: rgba(236,238,240,.13);
|
||||||
--line-soft: rgba(255,255,255,.08);
|
--line-soft: rgba(236,238,240,.07);
|
||||||
--accent: #8cc9e6;
|
--accent: #86bfdf;
|
||||||
--accent-hover: #a3d4ec;
|
--accent-hover: #9dcbe5;
|
||||||
--accent-active: #6fb3d3;
|
--accent-active: #6aa9cc;
|
||||||
--accent-soft: rgba(140,201,230,.15);
|
--accent-soft: rgba(134,191,223,.13);
|
||||||
--band-neu: #116896;
|
--band-neu: #116896;
|
||||||
--text: #e8f0f5;
|
--text: #eceef0;
|
||||||
--text-muted: #a9bccb;
|
--text-muted: #a5abb2;
|
||||||
--text-subtle: #7790a2;
|
--text-subtle: #878d95;
|
||||||
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30);
|
--sh-1: 0 1px 2px rgba(0,0,0,.4);
|
||||||
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30);
|
--sh-2: 0 4px 16px rgba(0,0,0,.45);
|
||||||
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35);
|
--sh-3: 0 16px 40px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35);
|
||||||
--glas-rahmen: rgba(28,42,54,.58);
|
--glas-rahmen: rgba(24,27,30,.84);
|
||||||
--glas-dicht: rgba(22,34,44,.90);
|
--glas-dicht: rgba(22,24,27,.95);
|
||||||
--glas-deckend: #15212b;
|
--glas-deckend: #181a1d;
|
||||||
--glas-kante: rgba(255,255,255,.18);
|
--glas-kante: rgba(255,255,255,.12);
|
||||||
--glas-kante-2: rgba(255,255,255,.07);
|
--glas-kante-2: rgba(255,255,255,.05);
|
||||||
--glas-hell: #2c5675;
|
--glas-hell: #2c5675;
|
||||||
--glas-tief: #1d6f99;
|
--glas-tief: #1d6f99;
|
||||||
--glas-logo: .10;
|
--glas-logo: .06;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Hintergrund: Lichtflächen und Logo ---------- */
|
/* ---------- Hintergrund: Lichtflächen und Logo ---------- */
|
||||||
html.glas body {
|
html.glas body {
|
||||||
background:
|
background:
|
||||||
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%),
|
radial-gradient(60% 55% at 8% 4%, color-mix(in srgb, var(--glas-hell) 26%, transparent), transparent 70%),
|
||||||
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%),
|
radial-gradient(50% 50% at 92% 12%, color-mix(in srgb, var(--glas-tief) 9%, transparent), transparent 70%),
|
||||||
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
|
|
||||||
radial-gradient(40% 40% at 28% 82%, color-mix(in srgb, var(--glas-hell) 42%, transparent), transparent 70%),
|
|
||||||
var(--bg);
|
var(--bg);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
@@ -157,6 +158,7 @@ html.glas .abw-team-kopf,
|
|||||||
html.glas .report-card,
|
html.glas .report-card,
|
||||||
html.glas .load-card,
|
html.glas .load-card,
|
||||||
html.glas .settings-card,
|
html.glas .settings-card,
|
||||||
|
html.glas .mn-notizen,
|
||||||
html.glas .dash-karte,
|
html.glas .dash-karte,
|
||||||
html.glas .dash-band,
|
html.glas .dash-band,
|
||||||
html.glas .bericht-panel,
|
html.glas .bericht-panel,
|
||||||
@@ -181,8 +183,8 @@ html.glas .plan-toast,
|
|||||||
html.glas .glocke-menue,
|
html.glas .glocke-menue,
|
||||||
html.glas .wiz {
|
html.glas .wiz {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%);
|
-webkit-backdrop-filter: blur(var(--glas-blur));
|
||||||
backdrop-filter: blur(var(--glas-blur)) saturate(170%);
|
backdrop-filter: blur(var(--glas-blur));
|
||||||
border: 1px solid var(--glas-kante-2);
|
border: 1px solid var(--glas-kante-2);
|
||||||
box-shadow: var(--sh-1), inset 0 1px 0 var(--glas-kante), inset 0 -1px 0 var(--glas-kante-2);
|
box-shadow: var(--sh-1), inset 0 1px 0 var(--glas-kante), inset 0 -1px 0 var(--glas-kante-2);
|
||||||
}
|
}
|
||||||
@@ -191,7 +193,7 @@ html.glas .wiz {
|
|||||||
html.glas .app-topbar {
|
html.glas .app-topbar {
|
||||||
background: var(--glas-rahmen);
|
background: var(--glas-rahmen);
|
||||||
border-width: 0 0 1px 0;
|
border-width: 0 0 1px 0;
|
||||||
box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(21,58,86,.06);
|
box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(16,24,32,.06);
|
||||||
}
|
}
|
||||||
html.glas .topbar-logo { height: 26px; }
|
html.glas .topbar-logo { height: 26px; }
|
||||||
|
|
||||||
@@ -248,11 +250,11 @@ html.glas .modal-bg {
|
|||||||
margin: 12px 12px 0;
|
margin: 12px 12px 0;
|
||||||
border-radius: var(--r-xl);
|
border-radius: var(--r-xl);
|
||||||
background: var(--glas-rahmen);
|
background: var(--glas-rahmen);
|
||||||
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%);
|
-webkit-backdrop-filter: blur(var(--glas-blur));
|
||||||
backdrop-filter: blur(var(--glas-blur)) saturate(170%);
|
backdrop-filter: blur(var(--glas-blur));
|
||||||
border: 1px solid var(--glas-kante-2);
|
border: 1px solid var(--glas-kante-2);
|
||||||
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
|
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
|
||||||
padding: 4px 12px 10px;
|
padding: 4px 12px 12px;
|
||||||
}
|
}
|
||||||
/* Kopf und Reiter sind innerhalb der Karte keine eigenen Flächen mehr. */
|
/* Kopf und Reiter sind innerhalb der Karte keine eigenen Flächen mehr. */
|
||||||
html.glas .plan-kopf .plan-header,
|
html.glas .plan-kopf .plan-header,
|
||||||
@@ -265,19 +267,25 @@ html.glas .modal-bg {
|
|||||||
}
|
}
|
||||||
/* Inhalt bündig unter der Kopfkarte und im selben 12px-Abstand. */
|
/* Inhalt bündig unter der Kopfkarte und im selben 12px-Abstand. */
|
||||||
html.glas .plan-page .plan-main-content { padding-left: 12px; padding-right: 12px; padding-top: 12px; }
|
html.glas .plan-page .plan-main-content { padding-left: 12px; padding-right: 12px; padding-top: 12px; }
|
||||||
html.glas .plan-kopf .plan-header { padding: 10px 12px 6px; }
|
html.glas .plan-kopf .plan-header { padding: 12px 12px 8px; }
|
||||||
html.glas .plan-kopf .plan-nav { padding: 0; gap: 2px; }
|
html.glas .plan-kopf .plan-nav { padding: 0; gap: 2px; }
|
||||||
|
/* Reiter wie die Einträge der Seitenleiste (html.glas .nav-item.active):
|
||||||
|
32 px, helle Fläche mit Haarlinie, Schrift dunkel statt Akzentblau. */
|
||||||
html.glas .plan-kopf .plan-nav-item {
|
html.glas .plan-kopf .plan-nav-item {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
border-radius: 999px;
|
border-radius: 8px;
|
||||||
padding: 8px 16px;
|
height: 32px;
|
||||||
font-size: 13.5px;
|
padding: 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
html.glas .plan-kopf .plan-nav-item:hover { background: var(--surface-sunk); }
|
html.glas .plan-kopf .plan-nav-item:hover { background: var(--surface-sunk); }
|
||||||
|
/* Der Kopf ist selbst eine helle Karte — eine weiße Kapsel wie in der
|
||||||
|
(grau durchscheinenden) Seitenleiste verschwände darauf. Deshalb hier
|
||||||
|
die umgekehrte Stufe: etwas dunkler als die Karte, mit Haarlinie. */
|
||||||
html.glas .plan-kopf .plan-nav-item.is-active {
|
html.glas .plan-kopf .plan-nav-item.is-active {
|
||||||
background: var(--glas-dicht);
|
background: rgba(16,24,32,.07);
|
||||||
color: var(--accent);
|
color: var(--text);
|
||||||
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10);
|
box-shadow: inset 0 0 0 1px rgba(16,24,32,.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (min-width: 769px) and (prefers-color-scheme: dark) {
|
@media (min-width: 769px) and (prefers-color-scheme: dark) {
|
||||||
@@ -293,13 +301,13 @@ html.glas .modal-bg {
|
|||||||
Grau, auf allen anderen Seiten den Glas-Hintergrund. */
|
Grau, auf allen anderen Seiten den Glas-Hintergrund. */
|
||||||
html.glas .plan-main { background: transparent; }
|
html.glas .plan-main { background: transparent; }
|
||||||
|
|
||||||
html.glas .nav-item { border-radius: 999px; }
|
html.glas .nav-item { border-radius: 8px; }
|
||||||
html.glas .nav-item:hover,
|
html.glas .nav-item:hover,
|
||||||
html.glas .nav-item:visited:hover { background: var(--surface-sunk); }
|
html.glas .nav-item:visited:hover { background: var(--surface-sunk); }
|
||||||
html.glas .nav-item.active {
|
html.glas .nav-item.active {
|
||||||
background: var(--glas-dicht);
|
background: var(--glas-dicht);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10);
|
box-shadow: 0 0 0 1px var(--line-soft);
|
||||||
}
|
}
|
||||||
html.glas .nav-item.active .nav-icon { color: var(--accent); }
|
html.glas .nav-item.active .nav-icon { color: var(--accent); }
|
||||||
/* Die bestehenden Dunkel-Regeln für die Navigation setzen feste Werte —
|
/* Die bestehenden Dunkel-Regeln für die Navigation setzen feste Werte —
|
||||||
@@ -373,9 +381,8 @@ html.glas .gantt-row.gantt-days .gantt-label { background: var(--gantt-label-gru
|
|||||||
die (noch leere) App-Oberfläche, die nicht durchscheinen darf. */
|
die (noch leere) App-Oberfläche, die nicht durchscheinen darf. */
|
||||||
html.glas .login-seite {
|
html.glas .login-seite {
|
||||||
background:
|
background:
|
||||||
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%),
|
radial-gradient(60% 55% at 8% 4%, color-mix(in srgb, var(--glas-hell) 26%, transparent), transparent 70%),
|
||||||
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%),
|
radial-gradient(50% 50% at 92% 12%, color-mix(in srgb, var(--glas-tief) 9%, transparent), transparent 70%),
|
||||||
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
|
|
||||||
var(--bg);
|
var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+63
-12
@@ -76,6 +76,13 @@ function adminNavAbgleichen(){
|
|||||||
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
|
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
|
||||||
if(item) item.hidden = !adminAbschnittErlaubt(sec);
|
if(item) item.hidden = !adminAbschnittErlaubt(sec);
|
||||||
});
|
});
|
||||||
|
// Zwischenüberschriften (Personen/Vorgaben/System) nur, wenn darunter
|
||||||
|
// mindestens ein erlaubter Punkt steht.
|
||||||
|
document.querySelectorAll('#adminSubnav .sidebar-subgruppe').forEach(kopf => {
|
||||||
|
let el = kopf.nextElementSibling, sichtbar = false;
|
||||||
|
while(el && !el.classList.contains('sidebar-subgruppe')){ if(!el.hidden) sichtbar = true; el = el.nextElementSibling; }
|
||||||
|
kopf.hidden = !sichtbar;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen?
|
// adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen?
|
||||||
@@ -1113,22 +1120,66 @@ async function loadAdminTeams(){
|
|||||||
try { d = await fetch(baseUrl() + '/admin/teams').then(r => r.json()); }
|
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; }
|
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; }
|
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 = `
|
el.innerHTML = `
|
||||||
<div class="settings-kv-row"><span class="settings-kv-key">Status</span>
|
<div class="teams-status${d.konfiguriert ? ' ist-an' : ''}">
|
||||||
<span>${d.konfiguriert ? '<span class="sitzung-chip ist-geoeffnet">Verbunden</span> <span class="hint" style="margin:0">' + esc(d.host) + '</span>'
|
<span class="teams-punkt" aria-hidden="true"></span>
|
||||||
: '<span class="sitzung-chip ist-gesperrt">Nicht eingerichtet</span>'}</span></div>
|
<span class="teams-status-text">${d.konfiguriert
|
||||||
<label class="settings-field"><span>${d.konfiguriert ? 'Neue Adresse (ersetzt die bisherige)' : 'Adresse aus dem Teams-Workflow'}</span>
|
? '<b>Eingerichtet</b><span>Workflow bei ' + esc(d.host) + '</span>'
|
||||||
<input type="password" id="teamsWebhook" placeholder="https://…" autocomplete="off"></label>
|
: '<b>Noch nicht eingerichtet</b><span>Adresse aus dem Teams-Workflow unten einfügen</span>'}</span>
|
||||||
<label class="settings-glas-schalter"><input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''}
|
${d.konfiguriert ? '<button type="button" class="teams-text-knopf" onclick="teamsFeldZeigen()">Adresse ändern</button>' : ''}
|
||||||
onchange="teamsSpeichern({ neueAufgaben: this.checked })"> Neue Aufgaben melden</label>
|
</div>
|
||||||
<div class="kalender-aktionen" style="margin-top:10px">
|
|
||||||
<button type="button" onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Adresse speichern</button>
|
<div class="teams-feld" id="teamsFeld"${feldOffen ? '' : ' hidden'}>
|
||||||
<button type="button" class="secondary" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht senden</button>
|
<label for="teamsWebhook">${d.konfiguriert ? 'Neue Workflow-Adresse' : 'Workflow-Adresse'}</label>
|
||||||
${d.konfiguriert ? '<button type="button" class="secondary" onclick="teamsEntfernen()">Entfernen</button>' : ''}
|
<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>
|
</div>
|
||||||
<p class="field-error" id="teamsFehler" hidden></p>`;
|
<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){
|
async function teamsSpeichern(patch){
|
||||||
if('webhook' in patch && !String(patch.webhook || '').trim()){ alert('Bitte die Adresse aus dem Teams-Workflow einfügen.'); return; }
|
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', {
|
const r = await fetch(baseUrl() + '/admin/teams', {
|
||||||
@@ -1156,7 +1207,7 @@ async function teamsTesten(){
|
|||||||
try {
|
try {
|
||||||
const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json());
|
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(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){
|
} catch(e){
|
||||||
if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; }
|
if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; }
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -748,7 +748,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
|
|||||||
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
|
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
|
||||||
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
|
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
|
||||||
function renderKommentarAnsicht(p, locked){
|
function renderKommentarAnsicht(p, locked){
|
||||||
const text = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || '');
|
const text = kommentarAnzeigeHtml(p, kommentarGewerk(), true);
|
||||||
return `<div class="status-edit is-ansicht locked-cell">${text ||
|
return `<div class="status-edit is-ansicht locked-cell">${text ||
|
||||||
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
|
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ const ABSENCE_TYPE_CLASS = {
|
|||||||
// renderDashboard()-Aufruf neu gestartet (vorheriges Intervall zuerst
|
// renderDashboard()-Aufruf neu gestartet (vorheriges Intervall zuerst
|
||||||
// gestoppt), damit beim wiederholten Tab-Wechsel nicht mehrere Intervalle
|
// gestoppt), damit beim wiederholten Tab-Wechsel nicht mehrere Intervalle
|
||||||
// parallel laufen.
|
// parallel laufen.
|
||||||
let dashboardClockTimer = null;
|
|
||||||
|
|
||||||
// renderDashboard: lädt GET /my-dashboard und rendert Begrüßung/Uhr,
|
// renderDashboard: lädt GET /my-dashboard und rendert Begrüßung/Uhr,
|
||||||
// Kennzahlen sowie die drei Bereiche in #dashboardContent. Wird nur beim
|
// Kennzahlen sowie die drei Bereiche in #dashboardContent. Wird nur beim
|
||||||
// Wechsel auf den Tab aufgerufen (showTab()), nicht beim initialen
|
// Wechsel auf den Tab aufgerufen (showTab()), nicht beim initialen
|
||||||
@@ -66,6 +64,9 @@ async function renderDashboard(){
|
|||||||
if(!container) return;
|
if(!container) return;
|
||||||
container.innerHTML = '<div class="hint">Lade Dashboard…</div>';
|
container.innerHTML = '<div class="hint">Lade Dashboard…</div>';
|
||||||
|
|
||||||
|
// Notizen je Projekt (Anzahl) parallel — fällt sie aus, fehlt nur die Angabe.
|
||||||
|
const notizenAnfrage = fetch(baseUrl() + '/projekt-notizen/anzahl')
|
||||||
|
.then(r => r.ok ? r.json() : null).catch(() => null);
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(baseUrl() + '/my-dashboard');
|
const res = await fetch(baseUrl() + '/my-dashboard');
|
||||||
@@ -76,6 +77,8 @@ async function renderDashboard(){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nz = await notizenAnfrage;
|
||||||
|
dashNotizen = (nz && nz.ok && nz.anzahl) || {};
|
||||||
const weekLabel = data.weekLabel || null;
|
const weekLabel = data.weekLabel || null;
|
||||||
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
||||||
const projectDates = Array.isArray(data.projectDates) ? data.projectDates : [];
|
const projectDates = Array.isArray(data.projectDates) ? data.projectDates : [];
|
||||||
@@ -103,12 +106,15 @@ async function renderDashboard(){
|
|||||||
const heuteFaellig = tasks.filter(t => t.end === heute).length
|
const heuteFaellig = tasks.filter(t => t.end === heute).length
|
||||||
+ projectDates.filter(d => d.heute).length;
|
+ projectDates.filter(d => d.heute).length;
|
||||||
|
|
||||||
|
const daten = dashDaten();
|
||||||
|
const sonntag = formatDateISO(new Date(dashWochenMontag().getTime() + 6 * 86400000));
|
||||||
|
const dieseWoche = daten.eintraege.filter(e => e.datum >= heute && e.datum <= sonntag).length;
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
html += renderDashboardKopf(weekLabel);
|
html += renderDashboardKopf(weekLabel);
|
||||||
html += renderDashboardBand({
|
html += renderDashboardBand({
|
||||||
ueberfaellig, heuteFaellig,
|
ueberfaellig, heuteFaellig, dieseWoche,
|
||||||
aufgaben: tasks.length,
|
aufgaben: tasks.length,
|
||||||
termine: projectDates.length,
|
|
||||||
freigaben: pending.length,
|
freigaben: pending.length,
|
||||||
abwesend: absencesToday,
|
abwesend: absencesToday,
|
||||||
});
|
});
|
||||||
@@ -120,10 +126,10 @@ async function renderDashboard(){
|
|||||||
// Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen,
|
// Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen,
|
||||||
// weil es das ist, was sich seit dem letzten Blick geändert hat.
|
// weil es das ist, was sich seit dem letzten Blick geändert hat.
|
||||||
html += '<div id="dashNeu"></div>';
|
html += '<div id="dashNeu"></div>';
|
||||||
|
html += renderDashWoche(daten);
|
||||||
html += '<div class="dash-zonen">';
|
html += '<div class="dash-zonen">';
|
||||||
|
|
||||||
// Linke Zone: die eigenen Projekte mit ihren Terminen
|
// Linke Zone: die eigenen Projekte mit ihren Terminen
|
||||||
const daten = dashDaten();
|
|
||||||
html += '<div>';
|
html += '<div>';
|
||||||
const zuAlle = dashZuLesen();
|
const zuAlle = dashZuLesen();
|
||||||
const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p)));
|
const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p)));
|
||||||
@@ -153,7 +159,6 @@ async function renderDashboard(){
|
|||||||
if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); }
|
if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); }
|
||||||
dashKalDaten = daten;
|
dashKalDaten = daten;
|
||||||
renderDashKalender();
|
renderDashKalender();
|
||||||
startDashboardClock();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ----------
|
// ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ----------
|
||||||
@@ -164,6 +169,8 @@ async function renderDashboard(){
|
|||||||
let dashKalDaten = null;
|
let dashKalDaten = null;
|
||||||
let dashKalMonat = null; // { jahr, monat } (monat 0–11)
|
let dashKalMonat = null; // { jahr, monat } (monat 0–11)
|
||||||
let dashKalTag = null; // ISO-Datum
|
let dashKalTag = null; // ISO-Datum
|
||||||
|
// dashNotizen: { project_uid: {anzahl, zuletzt} } aus GET /projekt-notizen/anzahl.
|
||||||
|
let dashNotizen = {};
|
||||||
|
|
||||||
// dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte
|
// dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte
|
||||||
// des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu,
|
// des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu,
|
||||||
@@ -205,14 +212,14 @@ function dashDaten(){
|
|||||||
});
|
});
|
||||||
(plan.milestones || []).forEach(m => {
|
(plan.milestones || []).forEach(m => {
|
||||||
if(!m.date) return;
|
if(!m.date) return;
|
||||||
termine.push({ art: 'meilenstein', titel: m.name || 'Meilenstein', datum: m.date, meins: false, wer: [] });
|
termine.push({ art: 'meilenstein', titel: m.name || 'Meilenstein', datum: m.date, meins: false, wer: [], erreicht: !!m.erreicht });
|
||||||
});
|
});
|
||||||
const hatEigene = termine.some(t => t.meins);
|
const hatEigene = termine.some(t => t.meins);
|
||||||
if(!rollen.length && !hatEigene) return;
|
if(!rollen.length && !hatEigene) return;
|
||||||
// Erledigte Vergangenheit interessiert hier nicht: Meilensteine vor heute
|
// Erreichte Meilensteine fallen weg (im Kalender bleiben sie stehen);
|
||||||
// fallen weg (sie haben kein Erledigt-Kennzeichen), offene Aufgaben vor
|
// nicht erreichte vor heute gelten wie offene Aufgaben als überfällig —
|
||||||
// heute bleiben als überfällig stehen.
|
// dieselbe Regel wie die Zahl im Band (GET /my-dashboard).
|
||||||
const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.datum < heute));
|
const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.erreicht));
|
||||||
relevant.sort((a, b) => (a.datum || '9999').localeCompare(b.datum || '9999'));
|
relevant.sort((a, b) => (a.datum || '9999').localeCompare(b.datum || '9999'));
|
||||||
projekte.push({ p, rollen, termine: relevant, alleTermine: termine });
|
projekte.push({ p, rollen, termine: relevant, alleTermine: termine });
|
||||||
});
|
});
|
||||||
@@ -253,6 +260,23 @@ function dashProjektUrl(p, abschnitt){
|
|||||||
return url.toString();
|
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,
|
// DASH_TERMINE_JE_PROJEKT: so viele Termine stehen je Projekt offen da,
|
||||||
// Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst
|
// Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst
|
||||||
// verdrängt ein Projekt mit 25 gerissenen Fristen alle anderen aus dem Blick
|
// verdrängt ein Projekt mit 25 gerissenen Fristen alle anderen aus dem Blick
|
||||||
@@ -423,9 +447,8 @@ function renderDashProjekte(daten){
|
|||||||
const key = dashProjKey(p);
|
const key = dashProjKey(p);
|
||||||
const istZu = zu.has(key);
|
const istZu = zu.has(key);
|
||||||
const rollen = x.rollen.length
|
const rollen = x.rollen.length
|
||||||
? x.rollen.map(r => '<span class="dash-rolle' + (r.state === 2 ? ' ist-neben' : '') + '" title="'
|
? x.rollen.map(r => esc(r.rolle) + (r.state === 2 ? ', Unterstützung' : '')).join(' / ')
|
||||||
+ (r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung') + '">' + esc(r.rolle) + '</span>').join('')
|
: 'nur eigene Aufgabe';
|
||||||
: '<span class="dash-rolle ist-neben" title="Nicht zugeordnet, aber mit eigener Aufgabe">nur Aufgabe</span>';
|
|
||||||
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
|
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
|
||||||
const pct = aktLp && p.lph && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? Math.max(0, Math.min(100, Number(p.lph[aktLp]) || 0)) : null;
|
const pct = aktLp && p.lph && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? Math.max(0, Math.min(100, Number(p.lph[aktLp]) || 0)) : null;
|
||||||
const phase = aktLp
|
const phase = aktLp
|
||||||
@@ -441,12 +464,12 @@ function renderDashProjekte(daten){
|
|||||||
? sichtbar.map(t => renderDashTermin(p, t)).join('')
|
? sichtbar.map(t => renderDashTermin(p, t)).join('')
|
||||||
: '<div class="dash-leer">Keine offenen Termine.</div>';
|
: '<div class="dash-leer">Keine offenen Termine.</div>';
|
||||||
const mehr = rest > 0
|
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
|
// Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele
|
||||||
// Termine überfällig bzw. offen sind.
|
// Termine überfällig bzw. offen sind.
|
||||||
const kurz = (ueber.length ? '<span class="dash-proj-zahl ist-rot">' + ueber.length + ' überfällig</span>' : '')
|
const kurz = (ueber.length ? '<span class="dash-proj-zahl ist-rot">' + ueber.length + ' überfällig</span>' : '')
|
||||||
+ '<span class="dash-proj-zahl">' + kommend.length + ' offen</span>';
|
+ '<span class="dash-proj-zahl' + (kommend.length ? ' ist-offen' : '') + '">' + kommend.length + ' offen</span>';
|
||||||
return '<div class="dash-karte dash-proj' + (istZu ? ' ist-zu' : '') + '" data-key="' + esc(key) + '">'
|
return '<div class="dash-karte dash-proj' + (istZu ? ' ist-zu' : '') + '" data-key="' + esc(key) + '">'
|
||||||
+ '<div class="dash-proj-kopf">'
|
+ '<div class="dash-proj-kopf">'
|
||||||
+ (daten.projekte.length > 1 ? '<span class="dash-proj-griff" tabindex="0" role="button" title="Ziehen, um die Reihenfolge zu ändern (oder Pfeiltasten)" aria-label="Reihenfolge ändern" onmousedown="dashGriffDruck(this)" ontouchstart="dashGriffDruck(this)" onkeydown="dashGriffTaste(event, this)">' + icon('grip') + '</span>' : '')
|
+ (daten.projekte.length > 1 ? '<span class="dash-proj-griff" tabindex="0" role="button" title="Ziehen, um die Reihenfolge zu ändern (oder Pfeiltasten)" aria-label="Reihenfolge ändern" onmousedown="dashGriffDruck(this)" ontouchstart="dashGriffDruck(this)" onkeydown="dashGriffTaste(event, this)">' + icon('grip') + '</span>' : '')
|
||||||
@@ -455,10 +478,10 @@ function renderDashProjekte(daten){
|
|||||||
+ '<span class="dash-proj-nr">' + esc(p.nr || '') + '</span><span class="dash-proj-titel">' + esc(p.name || '') + '</span>'
|
+ '<span class="dash-proj-nr">' + esc(p.nr || '') + '</span><span class="dash-proj-titel">' + esc(p.name || '') + '</span>'
|
||||||
+ '</button>'
|
+ '</button>'
|
||||||
+ '<span class="dash-proj-kurz">' + kurz + '</span>'
|
+ '<span class="dash-proj-kurz">' + kurz + '</span>'
|
||||||
+ '<span class="dash-rollen">' + rollen + '</span>'
|
+ '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" onclick="return dashImTabOeffnen(event, this)" title="Projekt öffnen">Öffnen</a>'
|
||||||
+ '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" target="_blank" rel="noopener" title="Projekt öffnen">Öffnen</a>'
|
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="dash-proj-inhalt">'
|
+ '<div class="dash-proj-inhalt">'
|
||||||
|
+ renderDashProjektInfo(x, rollen)
|
||||||
+ '<div class="dash-proj-sub">' + phase + '</div>'
|
+ '<div class="dash-proj-sub">' + phase + '</div>'
|
||||||
+ '<div class="dash-proj-termine">' + zeilen + mehr + '</div>'
|
+ '<div class="dash-proj-termine">' + zeilen + mehr + '</div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
@@ -466,6 +489,103 @@ function renderDashProjekte(daten){
|
|||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderDashWoche: Band „Diese Woche“ (Entwurf „Bauplan“): Mo bis Fr als
|
||||||
|
// Spalten mit den Terminen der eigenen Projekte an diesem Tag — eigene
|
||||||
|
// Aufgaben, Aufgaben im Projekt, Meilensteine, Überfälliges. Je Tag höchstens
|
||||||
|
// DASH_WOCHE_JE_TAG, der Rest als „+ N weitere“. Parameter: daten (dashDaten()).
|
||||||
|
const DASH_WOCHE_JE_TAG = 4;
|
||||||
|
function renderDashWoche(daten){
|
||||||
|
if(!daten.geladen || !daten.projekte.length) return '';
|
||||||
|
const heute = heuteIso();
|
||||||
|
const mo = dashWochenMontag();
|
||||||
|
const TAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr'];
|
||||||
|
let spalten = '';
|
||||||
|
for(let i = 0; i < 5; i++){
|
||||||
|
const d = new Date(mo); d.setDate(mo.getDate() + i);
|
||||||
|
const iso = formatDateISO(d);
|
||||||
|
const e = daten.eintraege.filter(x => x.datum === iso)
|
||||||
|
.sort((a, b) => (b.meins ? 1 : 0) - (a.meins ? 1 : 0));
|
||||||
|
const sicht = e.slice(0, DASH_WOCHE_JE_TAG);
|
||||||
|
const rest = e.length - sicht.length;
|
||||||
|
spalten += '<div class="dash-woche-tag' + (iso === heute ? ' ist-heute' : '') + '">'
|
||||||
|
+ '<div class="dash-woche-kopf"><span>' + TAGE[i] + '</span><span class="dash-woche-datum">' + d.getDate() + '.' + (d.getMonth() + 1) + '.</span></div>'
|
||||||
|
+ '<div class="dash-woche-liste">'
|
||||||
|
+ (sicht.map(x => {
|
||||||
|
const art = x.art === 'meilenstein' ? 'ist-ms' : (x.datum < heute ? 'ist-ueber' : (x.meins ? 'ist-meins' : 'ist-fremd'));
|
||||||
|
return '<a class="dash-woche-eintrag ' + art + '" href="' + dashProjektUrl(x.projekt, x.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" onclick="return dashImTabOeffnen(event, this)"'
|
||||||
|
+ ' title="' + esc(x.titel + ' (' + [x.projekt.nr, x.projekt.name].filter(Boolean).join(' ') + ')') + '">'
|
||||||
|
+ '<span class="dash-woche-titel">' + esc(x.titel) + '</span>'
|
||||||
|
+ '<span class="dash-woche-projekt">' + esc([x.projekt.nr, x.projekt.name].filter(Boolean).join(' ')) + '</span></a>';
|
||||||
|
}).join('') || '<span class="dash-woche-leer">keine Termine</span>')
|
||||||
|
+ (rest > 0 ? '<span class="dash-woche-mehr">+ ' + rest + ' weitere</span>' : '')
|
||||||
|
+ '</div></div>';
|
||||||
|
}
|
||||||
|
const legende = '<div class="dash-woche-legende">'
|
||||||
|
+ '<span><i class="ist-meins"></i>meine Aufgabe</span><span><i class="ist-fremd"></i>Aufgabe im Projekt</span>'
|
||||||
|
+ '<span><i class="ist-ms"></i>Meilenstein</span><span><i class="ist-ueber"></i>überfällig</span></div>';
|
||||||
|
return dashAbschnitt('Diese Woche', null, 'eigene Aufgaben und Termine meiner Projekte',
|
||||||
|
'<div class="dash-karte dash-woche"><div class="dash-woche-rahmen"><div class="dash-woche-raster">' + spalten + '</div></div>' + legende + '</div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDashProjektInfo: Zeile „Meine Rolle | Team | Notizen“ im Projektblock
|
||||||
|
// (Eriks Wunsch): die eigene Rolle, die übrigen Beteiligten als Kürzel-Kreise
|
||||||
|
// in den Rollenfarben (wie Projektliste, beteiligtenAvatarHtml()) und — nur
|
||||||
|
// wenn vorhanden — die Zahl der Notizen mit Sprung in den Reiter „Notizen“.
|
||||||
|
// Parameter: x (Eintrag aus dashDaten()), rollenText. Rückgabe: HTML-String.
|
||||||
|
const DASH_TEAM_MAX = 5;
|
||||||
|
function renderDashProjektInfo(x, rollenText){
|
||||||
|
const p = x.p;
|
||||||
|
const w = currentWeek();
|
||||||
|
const ich = currentUser && currentUser.personId;
|
||||||
|
const personen = {};
|
||||||
|
((w && w.people) || []).forEach(q => { personen[q.id] = q; });
|
||||||
|
const eintraege = [];
|
||||||
|
Object.keys(p.assign || {}).forEach(key => {
|
||||||
|
const [pid, role] = key.split('|');
|
||||||
|
const state = Number(p.assign[key]) || 0;
|
||||||
|
if(state > 0) eintraege.push({ person: personen[pid] || { id: pid, name: pid, fullName: pid }, role, state });
|
||||||
|
});
|
||||||
|
const alle = beteiligteJePerson(eintraege);
|
||||||
|
const ichB = alle.find(b => b.person.id === ich);
|
||||||
|
const team = alle.filter(b => b.person.id !== ich);
|
||||||
|
const kreis = b => beteiligtenAvatarHtml(b, typeof roleClass === 'function' ? roleClass(b.role) : '');
|
||||||
|
const kurzName = b => {
|
||||||
|
const teile = String(b.person.fullName || b.person.name || '').trim().split(/\s+/);
|
||||||
|
return teile.length > 1 ? teile[0] + ' ' + teile[teile.length - 1][0] + '.' : teile[0];
|
||||||
|
};
|
||||||
|
const namen = team.length > 2
|
||||||
|
? team.slice(0, 2).map(kurzName).join(', ') + ' +' + (team.length - 2)
|
||||||
|
: team.map(kurzName).join(', ');
|
||||||
|
|
||||||
|
let html = '<div class="dash-proj-info">'
|
||||||
|
+ '<div class="dash-proj-feld"><span class="dash-proj-feld-titel">Meine Rolle</span>'
|
||||||
|
+ '<span class="dash-proj-feld-wert">' + (ichB ? kreis(ichB) : '') + '<span>' + rollenText + '</span></span></div>'
|
||||||
|
+ '<div class="dash-proj-feld"><span class="dash-proj-feld-titel">Team</span>'
|
||||||
|
+ '<span class="dash-proj-feld-wert">'
|
||||||
|
+ (team.length
|
||||||
|
? '<span class="dash-proj-team">' + team.slice(0, DASH_TEAM_MAX).map(kreis).join('') + '</span><span class="dash-proj-namen" title="' + esc(team.map(b => b.title).join('\n')) + '">' + esc(namen) + '</span>'
|
||||||
|
: '<span class="dash-proj-namen">niemand weiter</span>')
|
||||||
|
+ '</span></div>';
|
||||||
|
const n = dashNotizen[p.projectUid];
|
||||||
|
if(n && n.anzahl > 0){
|
||||||
|
html += '<a class="dash-proj-feld dash-proj-notizen" href="' + dashProjektUrl(p, 'notizen') + '" onclick="return dashImTabOeffnen(event, this)">'
|
||||||
|
+ '<span class="dash-proj-feld-titel">Notizen</span>'
|
||||||
|
+ '<span class="dash-proj-feld-wert">' + icon('fileText') + '<span><b>' + n.anzahl + '</b> ' + (n.anzahl === 1 ? 'Notiz' : 'Notizen') + '</span>'
|
||||||
|
+ (n.zuletzt ? '<span class="dash-proj-zuletzt">' + esc(dashZuletzt(n.zuletzt)) + '</span>' : '') + '</span></a>';
|
||||||
|
}
|
||||||
|
return html + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// dashZuletzt: „zuletzt heute/gestern“ oder „vom 28.10.“ zu einem Zeitstempel.
|
||||||
|
function dashZuletzt(zeit){
|
||||||
|
const d = new Date(zeit);
|
||||||
|
if(isNaN(d)) return '';
|
||||||
|
const tage = -tageBis(formatDateISO(d));
|
||||||
|
if(tage <= 0) return 'zuletzt heute';
|
||||||
|
if(tage === 1) return 'zuletzt gestern';
|
||||||
|
return 'vom ' + d.getDate() + '.' + (d.getMonth() + 1) + '.';
|
||||||
|
}
|
||||||
|
|
||||||
// renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste
|
// renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste
|
||||||
// des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe,
|
// des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe,
|
||||||
// fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute.
|
// fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute.
|
||||||
@@ -480,7 +600,7 @@ function renderDashTermin(p, t, mitProjekt){
|
|||||||
const unter = mitProjekt
|
const unter = mitProjekt
|
||||||
? (p.nr ? p.nr + ' ' : '') + (p.name || '')
|
? (p.nr ? p.nr + ' ' : '') + (p.name || '')
|
||||||
: (t.art === 'meilenstein' ? 'Meilenstein' : (t.meins ? 'meine Aufgabe' : (t.wer.length ? t.wer.join(', ') : 'niemand zugewiesen')));
|
: (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-art ' + klasse + '">' + zeichen + '</span>'
|
||||||
+ '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(t.titel) + '</span>'
|
+ '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(t.titel) + '</span>'
|
||||||
+ '<span class="dash-termin-meta">' + esc(unter) + '</span></span>'
|
+ '<span class="dash-termin-meta">' + esc(unter) + '</span></span>'
|
||||||
@@ -576,22 +696,30 @@ function dashKalBlaettern(n){
|
|||||||
renderDashKalender();
|
renderDashKalender();
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein
|
// renderDashboardKopf: Titel und Kalenderwoche statt Begrüßung (Entwurf
|
||||||
// rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist
|
// „Bauplan“, Eriks Wahl): „KW 45, Mo 2. bis Fr 6. November 2026“, rechts die
|
||||||
// die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand.
|
// Sitzung. Parameter: weekLabel. Rückgabe: HTML-String.
|
||||||
// Die Inhalte füllt startDashboardClock() live nach.
|
|
||||||
// Parameter: weekLabel. Rückgabe: HTML-String.
|
|
||||||
function renderDashboardKopf(weekLabel){
|
function renderDashboardKopf(weekLabel){
|
||||||
|
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||||
|
const mo = dashWochenMontag();
|
||||||
|
const fr = new Date(mo); fr.setDate(mo.getDate() + 4);
|
||||||
|
const zeile = 'KW ' + getWeekNumber(mo) + ', Mo ' + mo.getDate() + '.'
|
||||||
|
+ (mo.getMonth() !== fr.getMonth() ? ' ' + MONATE[mo.getMonth()] : '')
|
||||||
|
+ ' bis Fr ' + fr.getDate() + '. ' + MONATE[fr.getMonth()] + ' ' + fr.getFullYear();
|
||||||
return '<div class="dash-kopf">'
|
return '<div class="dash-kopf">'
|
||||||
+ '<p class="dash-gruss"><span id="dashboardGreetingText"></span>'
|
+ '<div class="dash-kopf-links"><span class="dash-kopf-kw">' + esc(zeile) + '</span>'
|
||||||
+ '<em id="dashboardGreetingDate"></em></p>'
|
+ '<h2 class="dash-titel">Meine Aufgaben</h2></div>'
|
||||||
+ '<div class="dash-kopf-rechts">'
|
+ (weekLabel ? '<div class="dash-kopf-rechts"><span class="dash-kopf-label">Sitzung</span><span class="dash-sitzung">' + esc(weekLabel) + '</span></div>' : '')
|
||||||
+ (weekLabel ? '<span class="dash-sitzung">' + esc(weekLabel) + '</span>' : '')
|
|
||||||
+ '<span class="dash-uhr" id="dashboardClock"></span>'
|
|
||||||
+ '</div>'
|
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dashWochenMontag: Montag der laufenden Woche (lokale Zeit, 0 Uhr).
|
||||||
|
function dashWochenMontag(){
|
||||||
|
const d = parseDateISO(heuteIso());
|
||||||
|
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
// renderDashboardBand: der Befund des Tages als Satz, daneben die Kennzahlen.
|
// renderDashboardBand: der Befund des Tages als Satz, daneben die Kennzahlen.
|
||||||
//
|
//
|
||||||
// Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine
|
// Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine
|
||||||
@@ -617,17 +745,17 @@ function renderDashboardBand(z){
|
|||||||
satz = '<b class="ist-ruhig">Nichts offen, nichts überfällig.</b>';
|
satz = '<b class="ist-ruhig">Nichts offen, nichts überfällig.</b>';
|
||||||
}
|
}
|
||||||
|
|
||||||
const zahl = (wert, label) =>
|
const zahl = (wert, label, rot) =>
|
||||||
'<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + '"><b>' + wert + '</b><span>' + label + '</span></div>';
|
'<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + (rot ? ' ist-rot' : '') + '"><span>' + label + '</span><b>' + wert + '</b></div>';
|
||||||
|
|
||||||
const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0;
|
const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0;
|
||||||
return '<div class="dash-band' + (ruhig ? ' ist-ruhig' : '') + '">'
|
return '<div class="dash-band' + (ruhig ? ' ist-ruhig' : '') + '">'
|
||||||
+ '<p class="dash-befund">' + satz + '</p>'
|
+ '<p class="dash-befund">' + satz + '</p>'
|
||||||
+ '<div class="dash-band-zahlen">'
|
+ '<div class="dash-band-zahlen">'
|
||||||
+ zahl(z.aufgaben, 'offene Aufgaben')
|
+ zahl(z.aufgaben, 'offene Aufgaben')
|
||||||
+ zahl(z.termine, 'Termine im Blick')
|
+ zahl(z.ueberfaellig, 'überfällig', z.ueberfaellig > 0)
|
||||||
|
+ zahl(z.dieseWoche, 'diese Woche fällig')
|
||||||
+ zahl(z.freigaben, 'Freigaben')
|
+ zahl(z.freigaben, 'Freigaben')
|
||||||
+ zahl(z.abwesend, 'heute abwesend')
|
|
||||||
+ '</div></div>';
|
+ '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,43 +777,6 @@ function dashLeer(text){
|
|||||||
return '<div class="dash-karte"><div class="dash-leer">' + esc(text) + '</div></div>';
|
return '<div class="dash-karte"><div class="dash-leer">' + esc(text) + '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// greetingForHour:// greetingForHour: liefert je Tageszeit eine passende Begrüßung. Parameter:
|
|
||||||
// hour (Number, 0-23). Rückgabe: String.
|
|
||||||
function greetingForHour(hour){
|
|
||||||
if(hour < 5) return 'Gute Nacht';
|
|
||||||
if(hour < 11) return 'Guten Morgen';
|
|
||||||
if(hour < 18) return 'Guten Tag';
|
|
||||||
if(hour < 22) return 'Guten Abend';
|
|
||||||
return 'Gute Nacht';
|
|
||||||
}
|
|
||||||
|
|
||||||
// startDashboardClock: (re-)startet das Intervall, das Begrüßung, Datum und
|
|
||||||
// Uhrzeit im Dashboard-Header aktualisiert. Bricht sich selbst ab, sobald die
|
|
||||||
// Zielelemente nicht mehr im DOM sind (z. B. weil das Dashboard neu gerendert
|
|
||||||
// wurde). Keine Parameter, keine Rückgabe.
|
|
||||||
function startDashboardClock(){
|
|
||||||
if(dashboardClockTimer) clearInterval(dashboardClockTimer);
|
|
||||||
const name = (currentUser && (currentUser.displayName || currentUser.username)) || '';
|
|
||||||
const tick = () => {
|
|
||||||
const textEl = document.getElementById('dashboardGreetingText');
|
|
||||||
const dateEl = document.getElementById('dashboardGreetingDate');
|
|
||||||
const clockEl = document.getElementById('dashboardClock');
|
|
||||||
if(!textEl || !dateEl || !clockEl){
|
|
||||||
clearInterval(dashboardClockTimer);
|
|
||||||
dashboardClockTimer = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = new Date();
|
|
||||||
// Begrüßung und Datum stehen in EINER Zeile (früher untereinander), das
|
|
||||||
// Datum als gedämpfter Zusatz — deshalb der führende Gedankenstrich.
|
|
||||||
textEl.textContent = greetingForHour(now.getHours()) + (name ? ', ' + name : '');
|
|
||||||
dateEl.textContent = ' — ' + now.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
|
|
||||||
clockEl.textContent = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
dashboardClockTimer = setInterval(tick, 30000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem
|
// relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem
|
||||||
// ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht
|
// ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht
|
||||||
// sagt, ob das morgen oder in drei Wochen ist.
|
// sagt, ob das morgen oder in drei Wochen ist.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const EINFUEHRUNG_SCHRITTE = [
|
|||||||
text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen <b>Rundgang jederzeit neu starten</b>.' },
|
text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen <b>Rundgang jederzeit neu starten</b>.' },
|
||||||
{ id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin',
|
{ id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin',
|
||||||
text: () => 'Du darfst Teile der Verwaltung pflegen: ' + esc(typeof ADMIN_SECTIONS !== 'undefined' ? ADMIN_SECTIONS.filter(adminAbschnittErlaubt).map(s => s.title).join(', ') : '') + '.' },
|
text: () => 'Du darfst Teile der Verwaltung pflegen: ' + esc(typeof ADMIN_SECTIONS !== 'undefined' ? ADMIN_SECTIONS.filter(adminAbschnittErlaubt).map(s => s.title).join(', ') : '') + '.' },
|
||||||
{ id: 'ende', tab: 'dashboard', titel: 'Fertig!',
|
{ id: 'ende', tab: 'dashboard', titel: 'Rundgang abgeschlossen',
|
||||||
text: () => {
|
text: () => {
|
||||||
const hinweise = [];
|
const hinweise = [];
|
||||||
if(currentUser && !currentUser.personId){
|
if(currentUser && !currentUser.personId){
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ const TAB_SLUGS = {
|
|||||||
wochenbericht: 'Wochenbericht',
|
wochenbericht: 'Wochenbericht',
|
||||||
load: 'Auslastung',
|
load: 'Auslastung',
|
||||||
abwesenheit: 'Abwesenheiten',
|
abwesenheit: 'Abwesenheiten',
|
||||||
|
notizen: 'Notizen',
|
||||||
people: 'Projektbeteiligte',
|
people: 'Projektbeteiligte',
|
||||||
planung: 'Planung',
|
planung: 'Planung',
|
||||||
planchecker: 'PlanChecker',
|
planchecker: 'PlanChecker',
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
// -----------------------------
|
||||||
|
// Seitenleiste „Notizen“ (Tab `notizen`, /Notizen)
|
||||||
|
// -----------------------------
|
||||||
|
// Eriks Wunsch: Jedes Konto sieht hier die Notizen ALLER seiner Projekte und
|
||||||
|
// legt auch projektunabhängige Notizen an, die sich beim Bearbeiten einem
|
||||||
|
// Projekt zuordnen lassen. Gleicher Aufbau wie der Reiter „Notizen“ im
|
||||||
|
// Projektbereich (js/plan/plan-notizen.js: Liste + Editor, Werkzeugleiste,
|
||||||
|
// automatisches Speichern), dazu ein Projektfilter und im Editor die Auswahl
|
||||||
|
// „Projekt“. Eigene Element-ids (mn…), weil der Projekt-Reiter gleichzeitig
|
||||||
|
// im DOM liegt.
|
||||||
|
//
|
||||||
|
// Daten: GET /meine-notizen (Notizen meiner Projekte + meine
|
||||||
|
// projektunabhängigen, project_uid = ''), sonst dieselben Routen
|
||||||
|
// /projekt-notizen* wie der Projekt-Reiter; PUT nimmt `uid` zum Zuordnen.
|
||||||
|
// Projektunabhängige Notizen sieht und ändert nur, wer sie angelegt hat.
|
||||||
|
(function(){
|
||||||
|
const SPEICHER_PAUSE_MS = 1200;
|
||||||
|
const OHNE = '__ohne__';
|
||||||
|
|
||||||
|
let liste = [];
|
||||||
|
let meineProjekte = new Set();
|
||||||
|
let geladen = false;
|
||||||
|
let ladeFehler = '';
|
||||||
|
let filter = ''; // '' = alle, OHNE = ohne Projekt, sonst uid
|
||||||
|
let suche = '';
|
||||||
|
let aktivId = null;
|
||||||
|
let basis = '';
|
||||||
|
let ungespeichert = false;
|
||||||
|
let speicherTimer = null;
|
||||||
|
let speichertGerade = false;
|
||||||
|
let beobachter = null;
|
||||||
|
let loeschenFragt = null;
|
||||||
|
let eigenesSchreiben = 0; // Zeitpunkt der letzten eigenen Änderung (Live-Echo auslassen)
|
||||||
|
|
||||||
|
const el = () => document.getElementById('notizen');
|
||||||
|
const aktiv = () => liste.find(n => n.id === aktivId) || null;
|
||||||
|
const darf = n => !n || !n.uid ? true : (typeof hatRecht === 'function' && hatRecht('proj.plan'));
|
||||||
|
const darfProjekte = () => typeof hatRecht === 'function' && hatRecht('proj.plan');
|
||||||
|
|
||||||
|
// Projekte der aktuellen Sitzung je projectUid (Name und Nummer).
|
||||||
|
function projekte(){
|
||||||
|
const w = typeof currentWeek === 'function' ? currentWeek() : null;
|
||||||
|
const m = new Map();
|
||||||
|
((w && w.projects) || []).forEach(p => { if(p.projectUid) m.set(String(p.projectUid), p); });
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
function projektName(uid, karte){
|
||||||
|
if(!uid) return 'Ohne Projekt';
|
||||||
|
const p = (karte || projekte()).get(uid);
|
||||||
|
return p ? [p.nr, p.name].filter(Boolean).join(' ') : 'Projekt nicht in der aktuellen Sitzung';
|
||||||
|
}
|
||||||
|
function zeit(iso){
|
||||||
|
if(!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if(isNaN(d)) return '';
|
||||||
|
const heute = new Date();
|
||||||
|
const gestern = new Date(heute); gestern.setDate(heute.getDate() - 1);
|
||||||
|
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if(d.toDateString() === heute.toDateString()) return 'heute ' + uhr;
|
||||||
|
if(d.toDateString() === gestern.toDateString()) return 'gestern ' + uhr;
|
||||||
|
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: d.getFullYear() === heute.getFullYear() ? undefined : 'numeric' });
|
||||||
|
}
|
||||||
|
const klartext = html => typeof statusPlainText === 'function' ? statusPlainText(html) : String(html || '').replace(/<[^>]+>/g, ' ');
|
||||||
|
function sortieren(){
|
||||||
|
liste.sort((a, b) => (b.angeheftet - a.angeheftet) || String(b.geaendertAm).localeCompare(String(a.geaendertAm)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Laden ----------
|
||||||
|
function laden(){
|
||||||
|
ladeFehler = '';
|
||||||
|
return fetch(baseUrl() + '/meine-notizen')
|
||||||
|
.then(r => r.json().then(d => ({ ok: r.ok, d })))
|
||||||
|
.then(({ ok, d }) => {
|
||||||
|
if(!ok || !d.ok) throw new Error(d.error || 'Laden fehlgeschlagen');
|
||||||
|
liste = d.notizen || [];
|
||||||
|
meineProjekte = new Set(d.meineProjekte || []);
|
||||||
|
sortieren();
|
||||||
|
geladen = true;
|
||||||
|
})
|
||||||
|
.catch(e => { ladeFehler = e.message || 'Die Notizen konnten nicht geladen werden.'; liste = []; geladen = true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Darstellung ----------
|
||||||
|
window.renderMeineNotizen = function(){
|
||||||
|
const box = el();
|
||||||
|
if(!box) return;
|
||||||
|
box.innerHTML = '<div class="notizen-laedt">Notizen werden geladen…</div>';
|
||||||
|
jetztSpeichern();
|
||||||
|
laden().then(aufbauen);
|
||||||
|
};
|
||||||
|
|
||||||
|
function sichtbar(){
|
||||||
|
const s = suche.trim().toLowerCase();
|
||||||
|
return liste.filter(n => (!filter || (filter === OHNE ? !n.uid : n.uid === filter))
|
||||||
|
&& (!s || (n.titel + ' ' + klartext(n.html) + ' ' + projektName(n.uid)).toLowerCase().includes(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOptionen(){
|
||||||
|
const karte = projekte();
|
||||||
|
const mitNotizen = new Set(liste.filter(n => n.uid).map(n => n.uid));
|
||||||
|
const uids = Array.from(new Set([...meineProjekte, ...mitNotizen]))
|
||||||
|
.sort((a, b) => projektName(a, karte).localeCompare(projektName(b, karte), 'de'));
|
||||||
|
const zahl = f => liste.filter(n => f === OHNE ? !n.uid : n.uid === f).length;
|
||||||
|
return '<option value="">Alle Projekte (' + liste.length + ')</option>'
|
||||||
|
+ '<option value="' + OHNE + '"' + (filter === OHNE ? ' selected' : '') + '>Ohne Projekt (' + zahl(OHNE) + ')</option>'
|
||||||
|
+ uids.map(u => '<option value="' + esc(u) + '"' + (filter === u ? ' selected' : '') + '>' + esc(projektName(u, karte)) + ' (' + zahl(u) + ')</option>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function aufbauen(){
|
||||||
|
const box = el();
|
||||||
|
if(!box) return;
|
||||||
|
const treffer = sichtbar();
|
||||||
|
if(!treffer.some(n => n.id === aktivId)) aktivId = treffer.length ? treffer[0].id : null;
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="mn-seite">
|
||||||
|
<div class="mn-kopf">
|
||||||
|
<div class="mn-kopf-links">
|
||||||
|
<span class="mn-kopf-unter">Notizen aller meiner Projekte und eigene Notizen ohne Projekt</span>
|
||||||
|
<h2 class="mn-titel">Notizen</h2>
|
||||||
|
</div>
|
||||||
|
<label class="mn-filter">Projekt
|
||||||
|
<select id="mnFilter" onchange="mnFilter(this.value)">${filterOptionen()}</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="notizen mn-notizen">
|
||||||
|
<aside class="notizen-liste">
|
||||||
|
<div class="notizen-suchzeile">
|
||||||
|
<input type="search" id="mnSuche" class="notizen-suche" placeholder="Notizen durchsuchen…"
|
||||||
|
aria-label="Notizen durchsuchen" value="${esc(suche)}" oninput="mnSuchen(this.value)">
|
||||||
|
<button type="button" class="notizen-neu" onclick="mnNeu()">+ Neu</button>
|
||||||
|
</div>
|
||||||
|
<div id="mnEintraege" class="notizen-eintraege"></div>
|
||||||
|
</aside>
|
||||||
|
<div class="notizen-editor" id="mnEditor"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listeZeichnen(){
|
||||||
|
const box = document.getElementById('mnEintraege');
|
||||||
|
if(!box) return;
|
||||||
|
if(ladeFehler){ box.innerHTML = '<div class="notizen-leer ist-fehler">' + esc(ladeFehler) + '</div>'; return; }
|
||||||
|
const treffer = sichtbar();
|
||||||
|
if(!liste.length){ box.innerHTML = '<div class="notizen-leer">Noch keine Notizen. Mit „+ Neu“ eine eigene anlegen.</div>'; return; }
|
||||||
|
if(!treffer.length){ box.innerHTML = '<div class="notizen-leer">' + (suche ? 'Keine Notiz enthält „' + esc(suche) + '“.' : 'Keine Notizen in dieser Auswahl.') + '</div>'; return; }
|
||||||
|
const karte = projekte();
|
||||||
|
const zeile = n => {
|
||||||
|
const vorschau = klartext(n.html).replace(/\s+/g, ' ').trim().slice(0, 90);
|
||||||
|
return `<button type="button" class="notizen-eintrag${n.id === aktivId ? ' ist-aktiv' : ''}" onclick="mnWaehlen('${esc(n.id)}')">
|
||||||
|
<span class="notizen-eintrag-titel">${esc(n.titel || 'Ohne Titel')}${n.quelle === 'mail' ? ' <span class="notizen-chip">Mail</span>' : ''}</span>
|
||||||
|
<span class="notizen-eintrag-text">${esc(vorschau || 'Kein Text')}</span>
|
||||||
|
<span class="notizen-eintrag-meta"><span class="mn-projekt${n.uid ? '' : ' ist-ohne'}">${esc(projektName(n.uid, karte))}</span> · ${esc(zeit(n.geaendertAm))}</span>
|
||||||
|
</button>`;
|
||||||
|
};
|
||||||
|
const oben = treffer.filter(n => n.angeheftet), rest = treffer.filter(n => !n.angeheftet);
|
||||||
|
box.innerHTML = (oben.length ? '<div class="notizen-gruppe"><span class="notizen-pin-punkt"></span>Angeheftet</div>' + oben.map(zeile).join('') : '')
|
||||||
|
+ (rest.length ? (oben.length ? '<div class="notizen-gruppe">Weitere</div>' : '') + rest.map(zeile).join('') : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// projektAuswahl: Auswahl im Editor — „Ohne Projekt“, meine Projekte, dann
|
||||||
|
// die übrigen der Sitzung. Ohne Recht proj.plan nur „Ohne Projekt“ (und das
|
||||||
|
// aktuelle Projekt, damit der Stand lesbar bleibt).
|
||||||
|
function projektAuswahl(n){
|
||||||
|
const karte = projekte();
|
||||||
|
const name = u => projektName(u, karte);
|
||||||
|
const sort = arr => arr.sort((a, b) => name(a).localeCompare(name(b), 'de'));
|
||||||
|
const opt = u => '<option value="' + esc(u) + '"' + (n.uid === u ? ' selected' : '') + '>' + esc(name(u)) + '</option>';
|
||||||
|
const meine = sort(Array.from(meineProjekte).filter(u => karte.has(u)));
|
||||||
|
const weitere = sort(Array.from(karte.keys()).filter(u => !meineProjekte.has(u)));
|
||||||
|
const sperren = !darf(n) || (!darfProjekte() && !n.uid);
|
||||||
|
let html = '<option value=""' + (!n.uid ? ' selected' : '') + '>Ohne Projekt</option>';
|
||||||
|
if(darfProjekte()){
|
||||||
|
if(meine.length) html += '<optgroup label="Meine Projekte">' + meine.map(opt).join('') + '</optgroup>';
|
||||||
|
if(weitere.length) html += '<optgroup label="Weitere Projekte">' + weitere.map(opt).join('') + '</optgroup>';
|
||||||
|
}
|
||||||
|
if(n.uid && !karte.has(n.uid)) html += opt(n.uid);
|
||||||
|
return '<label class="mn-zuordnung">Projekt <select id="mnProjekt" onchange="mnZuordnen(this.value)"'
|
||||||
|
+ (sperren ? ' disabled title="' + (darfProjekte() ? '' : 'Zum Zuordnen fehlt das Recht, den Terminplan zu bearbeiten.') + '"' : '') + '>' + html + '</select></label>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorZeichnen(){
|
||||||
|
const box = document.getElementById('mnEditor');
|
||||||
|
if(!box) return;
|
||||||
|
if(beobachter){ beobachter.disconnect(); beobachter = null; }
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n){
|
||||||
|
box.innerHTML = `<div class="notizen-editor-leer">
|
||||||
|
<b>${liste.length ? 'Notiz links auswählen' : 'Noch keine Notizen'}</b>
|
||||||
|
<span>Mit „+ Neu“ eine Notiz anlegen. Sie gehört zunächst nur Ihnen und lässt sich oben rechts einem Projekt zuordnen.</span>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const schreiben = darf(n);
|
||||||
|
basis = n.geaendertAm;
|
||||||
|
ungespeichert = false;
|
||||||
|
const projekt = n.uid ? projekte().get(n.uid) : null;
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="notizen-editor-kopf">
|
||||||
|
<input type="text" id="mnTitel" class="notizen-titel" value="${esc(n.titel)}" placeholder="Titel der Notiz"
|
||||||
|
aria-label="Titel der Notiz" ${schreiben ? '' : 'readonly'} oninput="mnGeaendert()" onblur="mnJetztSpeichern()">
|
||||||
|
${schreiben ? `<button type="button" class="notizen-symbol${n.angeheftet ? ' ist-an' : ''}" onclick="mnAnheften()"
|
||||||
|
aria-pressed="${n.angeheftet}">${n.angeheftet ? 'Angeheftet' : 'Anheften'}</button>
|
||||||
|
<button type="button" class="notizen-symbol ist-gefahr" id="mnLoeschen" onclick="mnLoeschen()">Löschen</button>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="mn-editor-zeile">
|
||||||
|
${projektAuswahl(n)}
|
||||||
|
${projekt ? `<a class="mn-projekt-link" href="#" onclick="return mnProjektOeffnen('${esc(projekt.id)}')">Im Projekt öffnen</a>` : ''}
|
||||||
|
${!n.uid ? '<span class="mn-hinweis">Nur für Sie sichtbar, bis sie einem Projekt zugeordnet ist</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="richtext-wrap notizen-richtext">
|
||||||
|
${schreiben ? richtextToolbarHtml('#mnText') : ''}
|
||||||
|
${richtextFeldHtml('notizen-text', sanitizeStatusHtml(n.html || ''), 'Notiz schreiben…', 'id="mnText" onblur="mnJetztSpeichern()"', schreiben)}
|
||||||
|
</div>
|
||||||
|
<div class="notizen-fuss">
|
||||||
|
<span>Angelegt von ${esc(n.erstelltVon)} ${esc(zeit(n.erstelltAm))}${n.geaendertAm !== n.erstelltAm ? ' · zuletzt geändert von ' + esc(n.geaendertVon) + ' ' + esc(zeit(n.geaendertAm)) : ''}</span>
|
||||||
|
<span id="mnStatus" class="notizen-status"></span>
|
||||||
|
</div>`;
|
||||||
|
const text = document.getElementById('mnText');
|
||||||
|
if(text && schreiben){
|
||||||
|
beobachter = new MutationObserver(() => mnGeaendert());
|
||||||
|
beobachter.observe(text, { childList: true, subtree: true, characterData: true, attributes: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(text, art){
|
||||||
|
const s = document.getElementById('mnStatus');
|
||||||
|
if(!s) return;
|
||||||
|
s.className = 'notizen-status' + (art ? ' ist-' + art : '');
|
||||||
|
s.innerHTML = text;
|
||||||
|
}
|
||||||
|
function hinweis(text){ if(typeof zeigeHinweis === 'function') zeigeHinweis(text); else status(esc(text), 'fehler'); }
|
||||||
|
|
||||||
|
// ---------- Bedienung ----------
|
||||||
|
window.mnFilter = function(wert){ jetztSpeichern(); filter = wert || ''; aufbauen(); };
|
||||||
|
window.mnSuchen = function(wert){ suche = wert || ''; listeZeichnen(); };
|
||||||
|
window.mnWaehlen = function(id){
|
||||||
|
if(id === aktivId) return;
|
||||||
|
jetztSpeichern();
|
||||||
|
aktivId = id;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
};
|
||||||
|
window.mnGeaendert = function(){
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n || !darf(n)) return;
|
||||||
|
ungespeichert = true;
|
||||||
|
status('Ungespeichert');
|
||||||
|
clearTimeout(speicherTimer);
|
||||||
|
speicherTimer = setTimeout(jetztSpeichern, SPEICHER_PAUSE_MS);
|
||||||
|
};
|
||||||
|
window.mnJetztSpeichern = function(){ jetztSpeichern(); };
|
||||||
|
|
||||||
|
function senden(n, nutzlast){
|
||||||
|
eigenesSchreiben = Date.now();
|
||||||
|
return fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(nutzlast)
|
||||||
|
}).then(r => r.json().then(d => ({ r, d })));
|
||||||
|
}
|
||||||
|
|
||||||
|
function jetztSpeichern(ohneBasis){
|
||||||
|
clearTimeout(speicherTimer);
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n || !ungespeichert || !darf(n)) return Promise.resolve();
|
||||||
|
if(speichertGerade){ speicherTimer = setTimeout(jetztSpeichern, 400); return Promise.resolve(); }
|
||||||
|
const titelEl = document.getElementById('mnTitel');
|
||||||
|
const textEl = document.getElementById('mnText');
|
||||||
|
const titel = titelEl ? titelEl.value : n.titel;
|
||||||
|
const html = textEl ? textEl.innerHTML : n.html;
|
||||||
|
ungespeichert = false;
|
||||||
|
speichertGerade = true;
|
||||||
|
status('Speichert…');
|
||||||
|
return senden(n, { titel, html, basis: ohneBasis ? '' : basis }).then(({ r, d }) => {
|
||||||
|
if(r.status === 409 && d.konflikt){
|
||||||
|
ungespeichert = true;
|
||||||
|
status(esc(d.error) + ' <button type="button" class="notizen-link" onclick="mnKonflikt(true)">Meine Fassung speichern</button>'
|
||||||
|
+ ' <button type="button" class="notizen-link" onclick="mnKonflikt(false)">Neuen Stand laden</button>', 'warnung');
|
||||||
|
n._fremd = d.notiz;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!r.ok || !d.ok) throw new Error(d.error || 'Speichern fehlgeschlagen');
|
||||||
|
Object.assign(n, d.notiz, { titel, html });
|
||||||
|
if(n.id === aktivId) basis = d.notiz.geaendertAm;
|
||||||
|
sortieren();
|
||||||
|
listeZeichnen();
|
||||||
|
if(!ungespeichert) status('Gespeichert ✓', 'ok');
|
||||||
|
}).catch(e => {
|
||||||
|
ungespeichert = true;
|
||||||
|
status('Nicht gespeichert: ' + esc(e.message) + ' — wird erneut versucht.', 'fehler');
|
||||||
|
speicherTimer = setTimeout(jetztSpeichern, 5000);
|
||||||
|
}).finally(() => { speichertGerade = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.mnKonflikt = function(meine){
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n) return;
|
||||||
|
if(meine){ ungespeichert = true; jetztSpeichern(true); return; }
|
||||||
|
if(n._fremd) Object.assign(n, n._fremd);
|
||||||
|
delete n._fremd;
|
||||||
|
ungespeichert = false;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
};
|
||||||
|
|
||||||
|
// mnNeu: neue Notiz — im gefilterten Projekt, sonst ohne Projekt.
|
||||||
|
window.mnNeu = function(){
|
||||||
|
jetztSpeichern();
|
||||||
|
const uid = filter && filter !== OHNE && darfProjekte() ? filter : '';
|
||||||
|
eigenesSchreiben = Date.now();
|
||||||
|
fetch(baseUrl() + '/projekt-notizen', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify({ uid, titel: '', html: '' })
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if(!d.ok) throw new Error(d.error || 'Anlegen fehlgeschlagen');
|
||||||
|
liste = liste.filter(x => x.id !== d.notiz.id).concat([d.notiz]);
|
||||||
|
sortieren();
|
||||||
|
aktivId = d.notiz.id;
|
||||||
|
suche = '';
|
||||||
|
aufbauen();
|
||||||
|
const t = document.getElementById('mnTitel');
|
||||||
|
if(t) t.focus();
|
||||||
|
}).catch(e => hinweis('Notiz konnte nicht angelegt werden: ' + e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
// mnZuordnen: Notiz einem Projekt zuordnen (oder wieder lösen). Offene
|
||||||
|
// Änderungen werden vorher gespeichert.
|
||||||
|
window.mnZuordnen = function(uid){
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n) return;
|
||||||
|
jetztSpeichern().then(() => senden(n, { uid: uid || '' })).then(({ r, d }) => {
|
||||||
|
if(!r.ok || !d.ok) throw new Error(d.error || 'Zuordnen fehlgeschlagen');
|
||||||
|
Object.assign(n, d.notiz);
|
||||||
|
if(n.uid) meineProjekte.add(n.uid);
|
||||||
|
if(filter && filter !== (n.uid || OHNE)) filter = n.uid || OHNE;
|
||||||
|
aufbauen();
|
||||||
|
status(n.uid ? 'Dem Projekt zugeordnet ✓' : 'Vom Projekt gelöst ✓', 'ok');
|
||||||
|
}).catch(e => { hinweis(e.message); editorZeichnen(); });
|
||||||
|
};
|
||||||
|
|
||||||
|
window.mnAnheften = function(){
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n) return;
|
||||||
|
jetztSpeichern().then(() => senden(n, { angeheftet: !n.angeheftet })).then(({ d }) => {
|
||||||
|
if(!d.ok) throw new Error(d.error || 'Fehler');
|
||||||
|
n.angeheftet = d.notiz.angeheftet;
|
||||||
|
sortieren();
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
}).catch(e => hinweis('Anheften fehlgeschlagen: ' + e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.mnLoeschen = function(){
|
||||||
|
const n = aktiv();
|
||||||
|
const knopf = document.getElementById('mnLoeschen');
|
||||||
|
if(!n || !knopf) return;
|
||||||
|
if(!loeschenFragt){
|
||||||
|
knopf.textContent = 'Wirklich löschen?';
|
||||||
|
knopf.classList.add('ist-fragend');
|
||||||
|
loeschenFragt = setTimeout(() => {
|
||||||
|
loeschenFragt = null;
|
||||||
|
const k = document.getElementById('mnLoeschen');
|
||||||
|
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
|
||||||
|
}, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(loeschenFragt); loeschenFragt = null;
|
||||||
|
clearTimeout(speicherTimer); ungespeichert = false;
|
||||||
|
eigenesSchreiben = Date.now();
|
||||||
|
fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), { method: 'DELETE' })
|
||||||
|
.then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
|
||||||
|
if(!r.ok || !d.ok) throw new Error(d.error || 'Löschen fehlgeschlagen');
|
||||||
|
liste = liste.filter(x => x.id !== n.id);
|
||||||
|
aktivId = null;
|
||||||
|
aufbauen();
|
||||||
|
}).catch(e => { status(esc(e.message), 'fehler'); const k = document.getElementById('mnLoeschen'); if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); } });
|
||||||
|
};
|
||||||
|
|
||||||
|
window.mnProjektOeffnen = function(pid){
|
||||||
|
jetztSpeichern();
|
||||||
|
location.hash = 'notizen';
|
||||||
|
if(typeof openProjectPlan === 'function') openProjectPlan(pid);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Live: Änderungen aus anderen Browsern ----------
|
||||||
|
window.meineNotizenLive = function(uid){
|
||||||
|
if(typeof currentTab === 'undefined' || currentTab !== 'notizen' || !geladen) return;
|
||||||
|
if(uid && !meineProjekte.has(uid)) return;
|
||||||
|
const vorher = aktiv();
|
||||||
|
const tippt = document.activeElement && document.activeElement.closest && document.activeElement.closest('#mnEditor');
|
||||||
|
// Das Echo der eigenen Speicherung (kommt auch in diesem Tab an) auslassen.
|
||||||
|
if(ungespeichert || tippt || Date.now() - eigenesSchreiben < 3000) return;
|
||||||
|
laden().then(() => {
|
||||||
|
if(vorher && liste.some(x => x.id === vorher.id)) aktivId = vorher.id;
|
||||||
|
aufbauen();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Klick irgendwo anders: die Rückfrage „Wirklich löschen?“ zurücknehmen.
|
||||||
|
document.addEventListener('pointerdown', e => {
|
||||||
|
if(!loeschenFragt || (e.target.closest && e.target.closest('#mnLoeschen'))) return;
|
||||||
|
clearTimeout(loeschenFragt); loeschenFragt = null;
|
||||||
|
const k = document.getElementById('mnLoeschen');
|
||||||
|
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
|
||||||
|
}, true);
|
||||||
|
window.addEventListener('pagehide', () => { if(ungespeichert) jetztSpeichern(); });
|
||||||
|
})();
|
||||||
+31
-2
@@ -518,13 +518,14 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
|
|||||||
color: '#D70015', margin: [0, 6, 0, 0] });
|
color: '#D70015', margin: [0, 6, 0, 0] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const innerContent = {
|
const aufgaben = pdfWbAufgaben(p, w, prevWeek);
|
||||||
|
const innerContent = { stack: [{
|
||||||
columns: [
|
columns: [
|
||||||
{ width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) },
|
{ width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) },
|
||||||
{ width: 190, stack: rightStack }
|
{ width: 190, stack: rightStack }
|
||||||
],
|
],
|
||||||
columnGap: 12
|
columnGap: 12
|
||||||
};
|
}].concat(aufgaben ? [aufgaben] : []) };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
table: {
|
table: {
|
||||||
@@ -548,6 +549,34 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pdfWbAufgaben: die Aufgaben der Akte wie auf dem Bildschirm
|
||||||
|
// (berichtAufgabenDaten() in wochenbericht.js) — erledigt seit der Vorwoche,
|
||||||
|
// überfällig, nächste zwei Wochen, je höchstens fünf. Rückgabe: pdfmake-Block
|
||||||
|
// oder null, wenn in keiner Gruppe etwas steht.
|
||||||
|
function pdfWbAufgaben(p, w, prevWeek){
|
||||||
|
if(typeof berichtAufgabenDaten !== 'function') return null;
|
||||||
|
const d = berichtAufgabenDaten(p, w, prevWeek);
|
||||||
|
const gruppen = [
|
||||||
|
[prevWeek ? 'Erledigt seit ' + berichtKurzKw(prevWeek.label) : 'Erledigt', d.erledigt, null],
|
||||||
|
['Überfällig', d.ueberfaellig, '#D70015'],
|
||||||
|
['Nächste zwei Wochen', d.bald, null]
|
||||||
|
].filter(g => g[1].length);
|
||||||
|
if(!gruppen.length) return null;
|
||||||
|
const body = [];
|
||||||
|
gruppen.forEach(([titel, liste, farbe]) => {
|
||||||
|
liste.slice(0, 5).forEach((e, i) => body.push([
|
||||||
|
{ text: i === 0 ? titel + ' (' + liste.length + ')' : '', style: 'td', bold: true, color: farbe || undefined },
|
||||||
|
{ text: (e.art === 'meilenstein' ? 'Meilenstein: ' : '') + e.titel + (e.neu ? ' neu' : ''), style: 'td' },
|
||||||
|
{ text: e.kuerzel, style: 'td' },
|
||||||
|
{ text: e.datum ? formatDateDE(parseDateISO(e.datum)) : '', style: 'td', alignment: 'right', color: farbe || undefined }
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
return { stack: [
|
||||||
|
{ text: 'AUFGABEN', fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 10, 0, 3] },
|
||||||
|
{ table: { widths: [100, '*', 70, 50], body }, layout: pdfWbGridLayout(false) }
|
||||||
|
] };
|
||||||
|
}
|
||||||
|
|
||||||
// pdfWbVorigerKommentar: Block mit dem jüngsten früheren Kommentar des
|
// pdfWbVorigerKommentar: Block mit dem jüngsten früheren Kommentar des
|
||||||
// Gewerks. Steht er in der direkten Vorwoche, heißt er "Vorwoche KW …",
|
// Gewerks. Steht er in der direkten Vorwoche, heißt er "Vorwoche KW …",
|
||||||
// sonst "Zuletzt KW …". Parameter: p (Projekt), w (Sitzung). Rückgabe:
|
// sonst "Zuletzt KW …". Parameter: p (Projekt), w (Sitzung). Rückgabe:
|
||||||
|
|||||||
@@ -68,7 +68,6 @@
|
|||||||
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
|
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
|
||||||
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
|
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
|
||||||
if(typeof renderPlanTasks === 'function') renderPlanTasks();
|
if(typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||||
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
|
|
||||||
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
|
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,7 +171,7 @@
|
|||||||
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt');
|
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt');
|
||||||
|
|
||||||
const upcoming = milestones
|
const upcoming = milestones
|
||||||
.filter(m => m.date && parseDateISO(m.date) >= today)
|
.filter(m => m.date && !m.erreicht && parseDateISO(m.date) >= today)
|
||||||
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
|
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
|
||||||
const msZelle = upcoming.length > 0
|
const msZelle = upcoming.length > 0
|
||||||
? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein')
|
? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein')
|
||||||
@@ -541,7 +540,7 @@
|
|||||||
const ampel = loadAmpelColor(l.quote);
|
const ampel = loadAmpelColor(l.quote);
|
||||||
const breite = Math.min(100, l.quote * 100);
|
const breite = Math.min(100, l.quote * 100);
|
||||||
const absCount = app.countPersonAbsences(l.person.id);
|
const absCount = app.countPersonAbsences(l.person.id);
|
||||||
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : '';
|
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:11px;margin-top:4px">${absCount} Abwesenheit(en)</div>` : '';
|
||||||
|
|
||||||
let todoListHtml;
|
let todoListHtml;
|
||||||
if(l.todos.length === 0){
|
if(l.todos.length === 0){
|
||||||
@@ -567,12 +566,12 @@
|
|||||||
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`;
|
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`;
|
||||||
|
|
||||||
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
|
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
|
||||||
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
|
<h3>${esc(l.person.name)} <span style="font-size:11px;color:var(--text-subtle);font-weight:400">(${esc(l.roles.join(' / '))})</span></h3>
|
||||||
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div>
|
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div>
|
||||||
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
|
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
|
||||||
${offenText}
|
${offenText}
|
||||||
${otherBadge}
|
${otherBadge}
|
||||||
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
|
<div class="load-detail" style="font-size:11px;margin-top:4px">${todoListHtml}</div>
|
||||||
${absBadge}
|
${absBadge}
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
for(let i = versionen.length - 1; i >= 0; i--){
|
for(let i = versionen.length - 1; i >= 0; i--){
|
||||||
const k = kommentarVon(versionen[i].project, null);
|
const k = kommentarVon(versionen[i].project, null);
|
||||||
if(statusPlainText(k.status)){
|
if(statusPlainText(k.status)){
|
||||||
letzter = { week: versionen[i].week, k, aktuell: i === versionen.length - 1 };
|
letzter = { week: versionen[i].week, k, project: versionen[i].project, aktuell: i === versionen.length - 1 };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,8 @@
|
|||||||
gewerke.forEach(g => {
|
gewerke.forEach(g => {
|
||||||
let vorher = '';
|
let vorher = '';
|
||||||
versionen.forEach(v => {
|
versionen.forEach(v => {
|
||||||
const k = kommentarVon(v.project, g);
|
// Nur, wenn das Projekt in JENER Sitzung das Gewerk hatte.
|
||||||
|
const k = (g in kommentareDesProjekts(v.project)) ? kommentarVon(v.project, g) : { status: '' };
|
||||||
const text = statusPlainText(k.status);
|
const text = statusPlainText(k.status);
|
||||||
if(text && text !== vorher && !(letzter && v.week === letzter.week)){
|
if(text && text !== vorher && !(letzter && v.week === letzter.week)){
|
||||||
verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' });
|
verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' });
|
||||||
@@ -184,8 +185,9 @@
|
|||||||
if(editEl){
|
if(editEl){
|
||||||
editEl.className = 'status-edit is-ansicht';
|
editEl.className = 'status-edit is-ansicht';
|
||||||
editEl.contentEditable = 'false';
|
editEl.contentEditable = 'false';
|
||||||
// Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js).
|
// Alle Gewerke zusammen; bei mehreren je mit Überschrift, Linie und
|
||||||
const text = kom.letzter ? sanitizeStatusHtml(kom.letzter.k.status) : '';
|
// Verfasser (kommentarAnzeigeHtml(), js/utils.js, Eriks Wahl „B“).
|
||||||
|
const text = kom.letzter ? kommentarAnzeigeHtml(kom.letzter.project, null, true) : '';
|
||||||
editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>';
|
editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>';
|
||||||
}
|
}
|
||||||
const hinweisEl = document.getElementById('planStatusHinweis');
|
const hinweisEl = document.getElementById('planStatusHinweis');
|
||||||
@@ -200,7 +202,9 @@
|
|||||||
const autorEl = document.getElementById('planStatusAutor');
|
const autorEl = document.getElementById('planStatusAutor');
|
||||||
if(autorEl){
|
if(autorEl){
|
||||||
const k = kom.letzter ? kom.letzter.k : {};
|
const k = kom.letzter ? kom.letzter.k : {};
|
||||||
autorEl.innerHTML = k.status && k.statusAuthor
|
// Bei mehreren Gewerken steht der Verfasser schon je Gewerk im Text.
|
||||||
|
const mehrere = kom.letzter && kommentarGewerkeMitText(kom.letzter.project) > 1;
|
||||||
|
autorEl.innerHTML = !mehrere && k.status && k.statusAuthor
|
||||||
? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}`
|
? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}`
|
||||||
+ (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '')
|
+ (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '')
|
||||||
: '';
|
: '';
|
||||||
|
|||||||
+275
-33
@@ -65,6 +65,9 @@
|
|||||||
document.getElementById('milestoneEditDate').value = msDateIso;
|
document.getElementById('milestoneEditDate').value = msDateIso;
|
||||||
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
|
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
|
||||||
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
|
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
|
||||||
|
const msErreicht = document.getElementById('milestoneEditErreicht');
|
||||||
|
if(msErreicht) msErreicht.checked = !isNew && !!currentMs.erreicht;
|
||||||
|
panelWoSetzen('milestoneModalWo', '');
|
||||||
|
|
||||||
const positionSelect = document.getElementById('milestoneEditPosition');
|
const positionSelect = document.getElementById('milestoneEditPosition');
|
||||||
if(positionSelect){
|
if(positionSelect){
|
||||||
@@ -129,6 +132,7 @@
|
|||||||
dependsOnList.innerHTML = listHtml;
|
dependsOnList.innerHTML = listHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
msLageBildNeu();
|
||||||
document.getElementById('milestoneModal').style.display = 'flex';
|
document.getElementById('milestoneModal').style.display = 'flex';
|
||||||
document.getElementById('milestoneEditName').focus();
|
document.getElementById('milestoneEditName').focus();
|
||||||
};
|
};
|
||||||
@@ -167,6 +171,7 @@
|
|||||||
window.closeMilestoneModal = function(){
|
window.closeMilestoneModal = function(){
|
||||||
document.getElementById('milestoneModal').style.display = 'none';
|
document.getElementById('milestoneModal').style.display = 'none';
|
||||||
app.currentMilestoneIndex = null;
|
app.currentMilestoneIndex = null;
|
||||||
|
if(currentTab === 'wochenbericht') berichtAufgabenNeu();
|
||||||
const danach = msNachSchliessen;
|
const danach = msNachSchliessen;
|
||||||
msNachSchliessen = null;
|
msNachSchliessen = null;
|
||||||
if(danach) setTimeout(danach, 0);
|
if(danach) setTimeout(danach, 0);
|
||||||
@@ -189,12 +194,14 @@
|
|||||||
const position = positionSelect ? positionSelect.value : '';
|
const position = positionSelect ? positionSelect.value : '';
|
||||||
const msNotizEl = document.getElementById('milestoneEditNotiz');
|
const msNotizEl = document.getElementById('milestoneEditNotiz');
|
||||||
const notiz = msNotizEl ? msNotizEl.value.trim() : '';
|
const notiz = msNotizEl ? msNotizEl.value.trim() : '';
|
||||||
|
const msErreichtEl = document.getElementById('milestoneEditErreicht');
|
||||||
|
const erreicht = !!(msErreichtEl && msErreichtEl.checked);
|
||||||
|
|
||||||
const plan = app.ensurePlanLocal();
|
const plan = app.ensurePlanLocal();
|
||||||
if(!Array.isArray(plan.milestones)) plan.milestones = [];
|
if(!Array.isArray(plan.milestones)) plan.milestones = [];
|
||||||
|
|
||||||
if(app.currentMilestoneIndex === null){
|
if(app.currentMilestoneIndex === null){
|
||||||
plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz });
|
plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz, erreicht });
|
||||||
} else if(plan.milestones[app.currentMilestoneIndex]){
|
} else if(plan.milestones[app.currentMilestoneIndex]){
|
||||||
const m = plan.milestones[app.currentMilestoneIndex];
|
const m = plan.milestones[app.currentMilestoneIndex];
|
||||||
m.name = name;
|
m.name = name;
|
||||||
@@ -202,6 +209,7 @@
|
|||||||
m.dependsOn = dependsOn;
|
m.dependsOn = dependsOn;
|
||||||
m.position = position;
|
m.position = position;
|
||||||
m.notiz = notiz;
|
m.notiz = notiz;
|
||||||
|
m.erreicht = erreicht;
|
||||||
}
|
}
|
||||||
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
|
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
|
||||||
app.savePlan();
|
app.savePlan();
|
||||||
@@ -266,6 +274,140 @@
|
|||||||
return progress > 0 ? 'arbeit' : 'offen';
|
return progress > 0 ? 'arbeit' : 'offen';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Planblatt: Kopfzeile, Lage im Terminplan, Stand ----------
|
||||||
|
|
||||||
|
// kurzDatum: "2026-11-19" -> "19.11." Parameter: iso. Rückgabe: String.
|
||||||
|
function kurzDatum(iso){ return formatDateDE(parseDateISO(iso)).slice(0, 6); }
|
||||||
|
|
||||||
|
// planKnoten: Aufgabe oder Meilenstein zu einer Knoten-id (Meilensteine mit
|
||||||
|
// Präfix "m_", wie in dependsOn). Rückgabe: {titel, start, end, ms, fertig} oder null.
|
||||||
|
function planKnoten(plan, id){
|
||||||
|
id = String(id || '');
|
||||||
|
if(id.indexOf('m_') === 0){
|
||||||
|
const m = (plan.milestones || []).find(x => 'm_' + x.id === id);
|
||||||
|
return m ? { id, titel: m.name || 'Meilenstein', start: m.date || '', end: m.date || '', ms: true, fertig: !!m.erreicht } : null;
|
||||||
|
}
|
||||||
|
const t = (plan.todos || []).find(x => String(x.id) === id);
|
||||||
|
return t ? { id, titel: t.title || 'Aufgabe', start: t.start || t.end || '', end: t.end || t.start || '', ms: false, fertig: !!t.done } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// panelWoSetzen: Projekt (Nummer, Name) und optional Phase in der Kopfzeile.
|
||||||
|
function panelWoSetzen(elId, phase){
|
||||||
|
const el = document.getElementById(elId);
|
||||||
|
if(!el) return;
|
||||||
|
const p = app.project || {};
|
||||||
|
el.innerHTML = (p.nr ? `<span class="pd-nr">${esc(p.nr)}</span> ` : '') + esc(p.name || '') + (phase ? ' · ' + esc(phase) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// LAGE_TAGE: der Ausschnitt im Panelkopf — sechs Kalenderwochen ab dem
|
||||||
|
// Montag der Woche VOR dem Eintrag.
|
||||||
|
const LAGE_TAGE = 42;
|
||||||
|
|
||||||
|
// lageBildHtml: Ausschnitt des Terminplans um einen Eintrag. Oben die
|
||||||
|
// Vorgänger (grau, wenn erledigt), in der Mitte der Eintrag selbst, unten
|
||||||
|
// die Nachfolger; dazu die Heute-Linie. Nur Anzeige.
|
||||||
|
// e = {id (Knoten-id oder null), start, end, ms, progress, fertig, deps}.
|
||||||
|
// Rückgabe: HTML.
|
||||||
|
function lageBildHtml(e){
|
||||||
|
const plan = app.ensurePlanLocal();
|
||||||
|
const s = parseDateISO(e.start || e.end), en = parseDateISO(e.end || e.start);
|
||||||
|
if(!s || !en) return '<div class="pd-lage-leer">Noch kein Termin gewählt</div>';
|
||||||
|
const vor = (e.deps || []).map(id => planKnoten(plan, id)).filter(k => k && k.start);
|
||||||
|
// Ausschnitt beginnt eine Woche vor dem Eintrag, früher, wenn ein
|
||||||
|
// Vorgänger davor endet (höchstens drei Wochen), damit er sichtbar bleibt.
|
||||||
|
const mo = new Date(s);
|
||||||
|
mo.setDate(mo.getDate() - 7);
|
||||||
|
const grenze = new Date(s); grenze.setDate(grenze.getDate() - 21);
|
||||||
|
vor.forEach(k => { const v = parseDateISO(k.end); if(v && v < mo) mo.setTime(Math.max(v.getTime() - 2 * 864e5, grenze.getTime())); });
|
||||||
|
mo.setDate(mo.getDate() - ((mo.getDay() + 6) % 7));
|
||||||
|
const tag = 100 / LAGE_TAGE;
|
||||||
|
const x = d => daysBetween(mo, d) * tag;
|
||||||
|
const nach = e.id ? (plan.todos || []).filter(t => (t.dependsOn || []).includes(e.id)).map(t => planKnoten(plan, t.id))
|
||||||
|
.concat((plan.milestones || []).filter(m => (m.dependsOn || []).includes(e.id)).map(m => planKnoten(plan, 'm_' + m.id)))
|
||||||
|
.filter(k => k && k.start) : [];
|
||||||
|
const form = (k, y, klasse, progress) => {
|
||||||
|
const a = parseDateISO(k.start), b = parseDateISO(k.end) || a;
|
||||||
|
if(k.ms){
|
||||||
|
const l = x(a) + tag / 2;
|
||||||
|
return l < 0 || l > 100 ? '' : `<span class="pd-lage-raute ${klasse}" style="left:${l}%;top:${y}px" title="${esc(k.titel)}"></span>`;
|
||||||
|
}
|
||||||
|
const l = Math.max(0, x(a)), r = Math.min(100, x(b) + tag);
|
||||||
|
if(r <= l) return '';
|
||||||
|
return `<span class="pd-lage-bar ${klasse}" style="left:${l}%;width:${r - l}%;top:${y}px" title="${esc(k.titel)}">`
|
||||||
|
+ (progress > 0 ? `<b style="width:${Math.min(100, progress)}%"></b>` : '') + '</span>';
|
||||||
|
};
|
||||||
|
const ich = { titel: '', start: e.start || e.end, end: e.end || e.start, ms: e.ms };
|
||||||
|
const ichKlasse = 'is-diese' + (e.fertig ? ' is-fertig' : '') + (e.ueber ? ' is-ueber' : '');
|
||||||
|
let flaeche = vor.slice(0, 3).map(k => form(k, 4, k.fertig ? 'is-fertig' : 'is-vor', 0)).join('')
|
||||||
|
+ form(ich, e.ms ? 18 : 20, ichKlasse, e.ms ? 0 : e.progress)
|
||||||
|
+ nach.slice(0, 3).map(k => form(k, 36, 'is-nach', 0)).join('');
|
||||||
|
const heute = parseDateISO(heuteIso());
|
||||||
|
const hx = x(heute) + tag / 2;
|
||||||
|
if(hx >= 0 && hx <= 100) flaeche += `<span class="pd-lage-heute" style="left:${hx}%"></span>`;
|
||||||
|
const skala = [0, 1, 2, 3, 4, 5].map(i => {
|
||||||
|
const d = new Date(mo); d.setDate(d.getDate() + i * 7);
|
||||||
|
return `<span style="left:${i * 100 / 6}%">KW ${String(getWeekNumber(d)).padStart(2, '0')}</span>`;
|
||||||
|
}).join('');
|
||||||
|
const legende = [];
|
||||||
|
if(vor[0]) legende.push(`<span>${esc(vor[0].titel)}${vor[0].fertig ? ', erledigt' : ''}</span>`);
|
||||||
|
if(hx >= 0 && hx <= 100) legende.push(`<span>heute ${esc(kurzDatum(heuteIso()))}</span>`);
|
||||||
|
if(nach[0]) legende.push(`<span>danach ${esc(nach[0].titel)} ${esc(kurzDatum(nach[0].start))}</span>`);
|
||||||
|
return `<div class="pd-lage-skala">${skala}</div><div class="pd-lage-flaeche">${flaeche}</div>`
|
||||||
|
+ (legende.length ? `<div class="pd-lage-legende">${legende.join('')}</div>` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// todoLageNeu: zeichnet die Lage der Aufgabe aus den aktuellen Eingaben.
|
||||||
|
window.todoLageNeu = function(){
|
||||||
|
const el = document.getElementById('todoLageBild');
|
||||||
|
if(!el || !app.project) return;
|
||||||
|
const plan = app.ensurePlanLocal();
|
||||||
|
const t = app.currentTodoIndex !== null ? plan.todos[app.currentTodoIndex] : null;
|
||||||
|
const status = currentTodoEditStatus();
|
||||||
|
const slider = document.getElementById('todoEditProgress');
|
||||||
|
el.innerHTML = lageBildHtml({
|
||||||
|
id: t ? t.id : null,
|
||||||
|
start: document.getElementById('todoEditStart').value,
|
||||||
|
end: document.getElementById('todoEditEnd').value,
|
||||||
|
ms: false, fertig: status === 'fertig',
|
||||||
|
progress: status === 'fertig' ? 100 : (status === 'arbeit' && slider ? Number(slider.value) : 0),
|
||||||
|
deps: Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// msLageBildNeu: Lage, KW-Marke und Überfällig-Hinweis des Meilensteins.
|
||||||
|
window.msLageBildNeu = function(){
|
||||||
|
const el = document.getElementById('msLageBild');
|
||||||
|
if(!el || !app.project) return;
|
||||||
|
const plan = app.ensurePlanLocal();
|
||||||
|
const m = app.currentMilestoneIndex !== null ? plan.milestones[app.currentMilestoneIndex] : null;
|
||||||
|
const datum = document.getElementById('milestoneEditDate').value;
|
||||||
|
const erreicht = document.getElementById('milestoneEditErreicht').checked;
|
||||||
|
const heute = heuteIso();
|
||||||
|
const ueber = !!datum && !erreicht && datum < heute;
|
||||||
|
el.innerHTML = lageBildHtml({
|
||||||
|
id: m ? 'm_' + m.id : null, start: datum, end: datum, ms: true, fertig: erreicht, ueber,
|
||||||
|
deps: Array.from(document.querySelectorAll('#milestoneEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value)
|
||||||
|
});
|
||||||
|
const kw = document.getElementById('milestoneEditKw');
|
||||||
|
const d = parseDateISO(datum);
|
||||||
|
if(kw){ kw.hidden = !d; kw.textContent = d ? 'KW ' + String(getWeekNumber(d)).padStart(2, '0') : ''; }
|
||||||
|
document.querySelectorAll('#milestoneEditStand button').forEach(b =>
|
||||||
|
b.setAttribute('aria-pressed', String((b.dataset.stand === 'erreicht') === erreicht)));
|
||||||
|
const warn = document.getElementById('milestoneEditWarnung');
|
||||||
|
if(warn){
|
||||||
|
const tage = ueber ? daysBetween(parseDateISO(datum), parseDateISO(heute)) : 0;
|
||||||
|
warn.hidden = !ueber;
|
||||||
|
warn.textContent = ueber ? 'seit ' + (tage === 1 ? '1 Tag' : tage + ' Tagen') + ' überfällig' : '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// msStandSetzen: Umschalter "Offen | Erreicht" — schreibt das (versteckte)
|
||||||
|
// Feld #milestoneEditErreicht, das saveMilestoneEdit() liest.
|
||||||
|
window.msStandSetzen = function(erreicht){
|
||||||
|
document.getElementById('milestoneEditErreicht').checked = !!erreicht;
|
||||||
|
msLageBildNeu();
|
||||||
|
};
|
||||||
|
|
||||||
// applyTodoStatus: schreibt den Status in das Segment und blendet den
|
// applyTodoStatus: schreibt den Status in das Segment und blendet den
|
||||||
// Fortschrittsregler nur im Zustand "In Arbeit" ein (offen = 0 %,
|
// Fortschrittsregler nur im Zustand "In Arbeit" ein (offen = 0 %,
|
||||||
// erledigt = 100 % sind implizit). Parameter: status (String),
|
// erledigt = 100 % sind implizit). Parameter: status (String),
|
||||||
@@ -296,6 +438,7 @@
|
|||||||
slider.value = value;
|
slider.value = value;
|
||||||
if(label) label.textContent = value + ' %';
|
if(label) label.textContent = value + ' %';
|
||||||
}
|
}
|
||||||
|
todoLageNeu();
|
||||||
}
|
}
|
||||||
|
|
||||||
// setTodoEditStatus: Klick-Handler des Status-Segments im TODO-Modal.
|
// setTodoEditStatus: Klick-Handler des Status-Segments im TODO-Modal.
|
||||||
@@ -313,6 +456,7 @@
|
|||||||
todoProgressMemo = value;
|
todoProgressMemo = value;
|
||||||
const label = document.getElementById('todoEditProgressVal');
|
const label = document.getElementById('todoEditProgressVal');
|
||||||
if(label) label.textContent = value + ' %';
|
if(label) label.textContent = value + ' %';
|
||||||
|
todoLageNeu();
|
||||||
};
|
};
|
||||||
|
|
||||||
// currentTodoEditStatus: liest den aktuell gewählten Segment-Status aus dem
|
// currentTodoEditStatus: liest den aktuell gewählten Segment-Status aus dem
|
||||||
@@ -335,6 +479,7 @@
|
|||||||
badge.hidden = days <= 0;
|
badge.hidden = days <= 0;
|
||||||
badge.textContent = days > 0 ? (days + ' AT') : '';
|
badge.textContent = days > 0 ? (days + ' AT') : '';
|
||||||
clearFieldError('todoEditRange');
|
clearFieldError('todoEditRange');
|
||||||
|
todoLageNeu();
|
||||||
};
|
};
|
||||||
|
|
||||||
// setFieldError / clearFieldError: zeigen einen Validierungshinweis direkt
|
// setFieldError / clearFieldError: zeigen einen Validierungshinweis direkt
|
||||||
@@ -401,6 +546,17 @@
|
|||||||
const count = list.querySelectorAll('input[type="checkbox"]:checked').length;
|
const count = list.querySelectorAll('input[type="checkbox"]:checked').length;
|
||||||
badge.textContent = count;
|
badge.textContent = count;
|
||||||
badge.classList.toggle('zero', count === 0);
|
badge.classList.toggle('zero', count === 0);
|
||||||
|
const summary = document.getElementById('todoEditDependsSummary');
|
||||||
|
if(summary){
|
||||||
|
const plan = app.ensurePlanLocal();
|
||||||
|
summary.innerHTML = Array.from(list.querySelectorAll('input[type="checkbox"]:checked'))
|
||||||
|
.map(cb => planKnoten(plan, cb.value)).filter(Boolean)
|
||||||
|
.map(k => `<div class="pd-vorg-zeile"><span class="pd-vorg-stand${k.fertig ? ' is-fertig' : ''}${k.ms ? ' is-ms' : ''}" aria-hidden="true"></span>`
|
||||||
|
+ `<span class="pd-vorg-titel">${esc(k.titel)}</span>`
|
||||||
|
+ `<span class="pd-vorg-datum">${k.end ? 'bis ' + esc(kurzDatum(k.end)) : 'ohne Termin'}</span></div>`).join('');
|
||||||
|
summary.hidden = !summary.innerHTML;
|
||||||
|
}
|
||||||
|
todoLageNeu();
|
||||||
};
|
};
|
||||||
|
|
||||||
// filterTodoDepends: blendet in der Vorgänger-Liste alle Einträge aus, die
|
// filterTodoDepends: blendet in der Vorgänger-Liste alle Einträge aus, die
|
||||||
@@ -459,6 +615,8 @@
|
|||||||
const neu = document.getElementById('todoEditSaveNew');
|
const neu = document.getElementById('todoEditSaveNew');
|
||||||
if(abs) abs.hidden = !an;
|
if(abs) abs.hidden = !an;
|
||||||
if(wer) wer.hidden = an;
|
if(wer) wer.hidden = an;
|
||||||
|
const aufwandAbs = document.getElementById('todoEditAufwandAbs');
|
||||||
|
if(aufwandAbs) aufwandAbs.hidden = an;
|
||||||
if(neu) neu.hidden = an;
|
if(neu) neu.hidden = an;
|
||||||
const titelEl = document.getElementById('todoModalTitle');
|
const titelEl = document.getElementById('todoModalTitle');
|
||||||
if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten');
|
if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten');
|
||||||
@@ -488,6 +646,104 @@
|
|||||||
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
|
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
|
||||||
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
|
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
|
||||||
// Anlegen.
|
// Anlegen.
|
||||||
|
// ---------- Aufwand: −/+, Schnellwahl, Umrechnung ----------
|
||||||
|
// Ein Arbeitstag = APP_SETTINGS.arbeitsstundenProTag (Admin → Termine).
|
||||||
|
function stundenProTag(){ return Number(APP_SETTINGS && APP_SETTINGS.arbeitsstundenProTag) || 8; }
|
||||||
|
function aufwandLesen(){
|
||||||
|
const el = document.getElementById('todoEditAufwand');
|
||||||
|
return el ? Math.max(0, parseFloat(String(el.value).replace(',', '.')) || 0) : 0;
|
||||||
|
}
|
||||||
|
function aufwandSchreiben(h){
|
||||||
|
const el = document.getElementById('todoEditAufwand');
|
||||||
|
if(!el) return;
|
||||||
|
const w = Math.max(0, Math.round(h * 2) / 2);
|
||||||
|
el.value = w > 0 ? String(w) : '';
|
||||||
|
todoAufwandInfo();
|
||||||
|
}
|
||||||
|
window.todoAufwandAendern = function(delta){ aufwandSchreiben(aufwandLesen() + delta); };
|
||||||
|
// todoAufwandSetzen(h) oder todoAufwandSetzen('tag', n): fester Wert; ein
|
||||||
|
// zweiter Klick auf den gewählten Wert leert das Feld.
|
||||||
|
window.todoAufwandSetzen = function(a, n){
|
||||||
|
const h = a === 'tag' ? n * stundenProTag() : Number(a);
|
||||||
|
aufwandSchreiben(aufwandLesen() === h ? 0 : h);
|
||||||
|
};
|
||||||
|
// todoAufwandInfo: Umrechnung in Arbeitstage und Markierung der Schnellwahl.
|
||||||
|
window.todoAufwandInfo = function(){
|
||||||
|
const h = aufwandLesen();
|
||||||
|
const tag = stundenProTag();
|
||||||
|
const info = document.getElementById('todoAufwandInfoText');
|
||||||
|
if(info){
|
||||||
|
const tage = h / tag;
|
||||||
|
const zahl = x => (Math.round(x * 10) / 10).toLocaleString('de-DE');
|
||||||
|
info.textContent = h > 0 ? '≈ ' + zahl(tage) + (Math.abs(tage - 1) < 0.05 ? ' Arbeitstag' : ' Arbeitstage') + ' (' + zahl(tag) + ' h je Tag)' : 'Ohne Aufwand zählt die Aufgabe nicht zur Auslastung.';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#todoAufwandSchnell button').forEach(b => {
|
||||||
|
const wert = b.dataset.tage ? Number(b.dataset.tage) * tag : Number(b.dataset.h);
|
||||||
|
b.setAttribute('aria-pressed', String(h > 0 && Math.abs(wert - h) < 0.01));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Zuständige: Projekt und „Externe Personen“ ----------
|
||||||
|
// todoProjektPersonen: die dem Projekt zugeordneten Personen (je einmal).
|
||||||
|
function todoProjektPersonen(){
|
||||||
|
const gesehen = new Set();
|
||||||
|
const liste = [];
|
||||||
|
projectAssignees(app.project, app.week).forEach(a => {
|
||||||
|
if(gesehen.has(a.person.id)) return;
|
||||||
|
gesehen.add(a.person.id);
|
||||||
|
liste.push(a.person);
|
||||||
|
});
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
function todoPersonById(id){
|
||||||
|
return ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
|
||||||
|
}
|
||||||
|
function todoPersonChip(person, gedrueckt, extern){
|
||||||
|
const short = person.name || initialsForName(person.fullName || '') || '?';
|
||||||
|
const full = person.fullName || person.name || '';
|
||||||
|
const klick = extern ? `todoExternEntfernen('${esc(person.id)}')` : 'toggleTodoAssignee(this)';
|
||||||
|
const titel = extern ? ' title="Nicht im Projekt — Klick entfernt die Person"' : '';
|
||||||
|
return `<button type="button" class="person-chip${extern ? ' ist-fremd' : ''}"${titel} data-person-id="${esc(person.id)}" aria-pressed="${gedrueckt ? 'true' : 'false'}" onclick="${klick}">`
|
||||||
|
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}${extern ? ' <span class="person-chip-x" aria-hidden="true">×</span>' : ''}</button>`;
|
||||||
|
}
|
||||||
|
// todoExterneSetzen: zeichnet die gewählten externen Personen und die
|
||||||
|
// Auswahl der übrigen Kollegen (Teilnehmerkreis der Sitzung ohne Projekt
|
||||||
|
// und ohne bereits gewählte), nach Gewerk gruppiert.
|
||||||
|
function todoExterneSetzen(ids){
|
||||||
|
const box = document.getElementById('todoEditExterne');
|
||||||
|
const wahl = document.getElementById('todoEditExterneWahl');
|
||||||
|
if(!box || !wahl) return;
|
||||||
|
box.innerHTML = ids.map(id => todoPersonChip(todoPersonById(id), true, true)).join('');
|
||||||
|
const projektIds = new Set(todoProjektPersonen().map(p => p.id));
|
||||||
|
const frei = ((app.week && app.week.people) || [])
|
||||||
|
.filter(p => p && p.id && !projektIds.has(p.id) && !ids.includes(p.id))
|
||||||
|
.sort((a, b) => String(a.fullName || a.name).localeCompare(String(b.fullName || b.name), 'de'));
|
||||||
|
const gruppen = new Map();
|
||||||
|
frei.forEach(p => {
|
||||||
|
const g = (typeof personGewerk === 'function' && personGewerk(p)) || 'Ohne Gewerk';
|
||||||
|
if(!gruppen.has(g)) gruppen.set(g, []);
|
||||||
|
gruppen.get(g).push(p);
|
||||||
|
});
|
||||||
|
const reihenfolge = [...(typeof DEPARTMENTS !== 'undefined' ? Object.keys(DEPARTMENTS) : []), 'Ohne Gewerk'];
|
||||||
|
const schluessel = [...gruppen.keys()].sort((a, b) => reihenfolge.indexOf(a) - reihenfolge.indexOf(b));
|
||||||
|
wahl.innerHTML = `<option value="">${frei.length ? '+ Person hinzufügen…' : 'Keine weiteren Kollegen'}</option>`
|
||||||
|
+ schluessel.map(g => `<optgroup label="${esc(g)}">${gruppen.get(g).map(p =>
|
||||||
|
`<option value="${esc(p.id)}">${esc(p.fullName || p.name)}${p.name && p.fullName ? ' (' + esc(p.name) + ')' : ''}</option>`).join('')}</optgroup>`).join('');
|
||||||
|
wahl.disabled = !frei.length;
|
||||||
|
}
|
||||||
|
function todoExterneIds(){
|
||||||
|
return Array.from(document.querySelectorAll('#todoEditExterne .person-chip')).map(b => b.dataset.personId);
|
||||||
|
}
|
||||||
|
window.todoExternHinzufuegen = function(id){
|
||||||
|
if(!id) return;
|
||||||
|
const ids = todoExterneIds();
|
||||||
|
if(!ids.includes(id)) ids.push(id);
|
||||||
|
todoExterneSetzen(ids);
|
||||||
|
};
|
||||||
|
window.todoExternEntfernen = function(id){
|
||||||
|
todoExterneSetzen(todoExterneIds().filter(x => x !== id));
|
||||||
|
};
|
||||||
|
|
||||||
window.openTodoModal = function(index, prefill){
|
window.openTodoModal = function(index, prefill){
|
||||||
if(app.isLocked()) return;
|
if(app.isLocked()) return;
|
||||||
const plan = app.ensurePlanLocal();
|
const plan = app.ensurePlanLocal();
|
||||||
@@ -544,7 +800,9 @@
|
|||||||
const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand);
|
const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand);
|
||||||
aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : '';
|
aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : '';
|
||||||
}
|
}
|
||||||
|
todoAufwandInfo();
|
||||||
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
|
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
|
||||||
|
panelWoSetzen('todoModalWo', currentTodo && typeof aufgabenPhase === 'function' ? aufgabenPhase(currentTodo, plan.todos) : '');
|
||||||
|
|
||||||
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
|
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
|
||||||
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
|
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
|
||||||
@@ -588,45 +846,27 @@
|
|||||||
const searchInput = document.getElementById('todoEditDependsSearch');
|
const searchInput = document.getElementById('todoEditDependsSearch');
|
||||||
if(searchInput) searchInput.value = '';
|
if(searchInput) searchInput.value = '';
|
||||||
refreshTodoDependsCount();
|
refreshTodoDependsCount();
|
||||||
// Bereich nur aufklappen, wenn es dort auch etwas zu sehen gibt
|
// Gewählte Vorgänger stehen offen darüber (#todoEditDependsSummary), die
|
||||||
|
// Auswahlliste bleibt zu, bis man sie ändern will.
|
||||||
const fold = document.getElementById('todoEditDependsFold');
|
const fold = document.getElementById('todoEditDependsFold');
|
||||||
if(fold) fold.open = !!(currentTodo && Array.isArray(currentTodo.dependsOn) && currentTodo.dependsOn.length > 0);
|
if(fold) fold.open = false;
|
||||||
|
|
||||||
const assigneesDiv = document.getElementById('todoEditAssignees');
|
const assigneesDiv = document.getElementById('todoEditAssignees');
|
||||||
if(assigneesDiv){
|
if(assigneesDiv){
|
||||||
const assignees = projectAssignees(app.project, app.week);
|
|
||||||
const currentAssignees = currentTodo
|
const currentAssignees = currentTodo
|
||||||
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
|
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
|
||||||
: (Array.isArray(pre.assignees) ? pre.assignees : []);
|
: (Array.isArray(pre.assignees) ? pre.assignees : []);
|
||||||
const seenPeople = new Set();
|
const projektLeute = todoProjektPersonen();
|
||||||
const uniquePeople = [];
|
const projektIds = new Set(projektLeute.map(p => p.id));
|
||||||
assignees.forEach(a => {
|
|
||||||
if(!seenPeople.has(a.person.id)){
|
|
||||||
seenPeople.add(a.person.id);
|
|
||||||
uniquePeople.push(a.person);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Bereits Zuständige, die (nicht mehr) dem Projekt zugeordnet sind —
|
|
||||||
// etwa über "Aufgabe aus Kommentar" aus dem ganzen Teilnehmerkreis
|
|
||||||
// gewählt — ebenfalls als Chip zeigen. Ohne sie gingen sie beim
|
|
||||||
// Speichern stillschweigend verloren, weil saveTodoEdit() nur die
|
|
||||||
// gedrückten Chips liest.
|
|
||||||
currentAssignees.forEach(id => {
|
|
||||||
if(seenPeople.has(id)) return;
|
|
||||||
seenPeople.add(id);
|
|
||||||
const p = ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
|
|
||||||
uniquePeople.push(Object.assign({}, p, { nichtImProjekt: true }));
|
|
||||||
});
|
|
||||||
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
|
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
|
||||||
// Personenspalten im Projektboard.
|
// Personenspalten im Projektboard.
|
||||||
assigneesDiv.innerHTML = uniquePeople.map(person => {
|
assigneesDiv.innerHTML = projektLeute.map(person =>
|
||||||
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
|
todoPersonChip(person, currentAssignees.includes(person.id), false)
|
||||||
const short = person.name || initialsForName(person.fullName || '') || '?';
|
).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
|
||||||
const full = person.fullName || person.name || '';
|
// Zuständige außerhalb des Projekts stehen unter „Externe Personen“ —
|
||||||
const fremd = person.nichtImProjekt ? ' title="Nicht (mehr) dem Projekt zugeordnet"' : '';
|
// auch solche, die das Projekt inzwischen verlassen haben (sonst gingen
|
||||||
return `<button type="button" class="person-chip${person.nichtImProjekt ? ' ist-fremd' : ''}"${fremd} data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
|
// sie beim Speichern stillschweigend verloren).
|
||||||
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
|
todoExterneSetzen(currentAssignees.filter(id => !projektIds.has(id)));
|
||||||
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteBtn = document.getElementById('todoEditDelete');
|
const deleteBtn = document.getElementById('todoEditDelete');
|
||||||
@@ -664,7 +904,7 @@
|
|||||||
function todoFormSnapshot(){
|
function todoFormSnapshot(){
|
||||||
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
|
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
|
||||||
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
|
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
|
||||||
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId);
|
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"], #todoEditExterne .person-chip')).map(b => b.dataset.personId);
|
||||||
const gantt = document.getElementById('todoEditShowInGantt');
|
const gantt = document.getElementById('todoEditShowInGantt');
|
||||||
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
|
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
|
||||||
currentTodoEditStatus(), read('todoEditProgress'), deps, people,
|
currentTodoEditStatus(), read('todoEditProgress'), deps, people,
|
||||||
@@ -681,6 +921,7 @@
|
|||||||
}
|
}
|
||||||
document.getElementById('todoModal').style.display = 'none';
|
document.getElementById('todoModal').style.display = 'none';
|
||||||
app.currentTodoIndex = null;
|
app.currentTodoIndex = null;
|
||||||
|
if(currentTab === 'wochenbericht') berichtAufgabenNeu();
|
||||||
todoModalSnapshot = '';
|
todoModalSnapshot = '';
|
||||||
const danach = todoNachSchliessen;
|
const danach = todoNachSchliessen;
|
||||||
todoNachSchliessen = null;
|
todoNachSchliessen = null;
|
||||||
@@ -727,7 +968,8 @@
|
|||||||
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
|
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
|
||||||
|
|
||||||
const assigneesDiv = document.getElementById('todoEditAssignees');
|
const assigneesDiv = document.getElementById('todoEditAssignees');
|
||||||
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [];
|
const assignees = (assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [])
|
||||||
|
.concat(todoExterneIds());
|
||||||
|
|
||||||
const showInGanttEl = document.getElementById('todoEditShowInGantt');
|
const showInGanttEl = document.getElementById('todoEditShowInGantt');
|
||||||
const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
|
const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
// -----------------------------
|
||||||
|
// Projektbereich — Notizen (Eriks Wahl „A – Liste + Editor“)
|
||||||
|
// -----------------------------
|
||||||
|
// Gemeinsame Notizen je Projekt: links die Liste (angeheftete oben, danach
|
||||||
|
// zuletzt geänderte zuerst, Suche über Titel und Text), rechts die gewählte
|
||||||
|
// Notiz direkt bearbeitbar — Titel, dieselbe Werkzeugleiste wie beim
|
||||||
|
// Sitzungskommentar (richtextToolbarHtml(), js/utils.js) und der Text.
|
||||||
|
// Gespeichert wird automatisch kurz nach dem Tippen und beim Verlassen des
|
||||||
|
// Feldes; „Gespeichert ✓“ zeigt den Stand. Mail per Drag & Drop (Text oder
|
||||||
|
// .eml) bzw. Strg+V außerhalb von Eingabefeldern legt eine neue Notiz an,
|
||||||
|
// ohne KI. Live: andere Browser erfahren Änderungen über das Ereignis
|
||||||
|
// {typ:'notizen'} (notizenLiveAenderung(), aus js/store.js).
|
||||||
|
//
|
||||||
|
// Daten am Server: Tabelle projekt_notizen, Routen /projekt-notizen*
|
||||||
|
// (server.js). Die Notizen hängen am Projekt (projectUid), nicht an der
|
||||||
|
// Sitzung. Schreiben: Recht proj.plan; Löschen: Verfasser oder Admin.
|
||||||
|
(function(){
|
||||||
|
const app = window.PlanApp;
|
||||||
|
const SPEICHER_PAUSE_MS = 1200;
|
||||||
|
|
||||||
|
let liste = []; // Notizen des geladenen Projekts
|
||||||
|
let uidGeladen = null; // projectUid, zu dem `liste` gehört
|
||||||
|
let aktivId = null; // gewählte Notiz
|
||||||
|
let suche = '';
|
||||||
|
let basis = ''; // geaendertAm, auf dem die Bearbeitung beruht
|
||||||
|
let ungespeichert = false;
|
||||||
|
let speicherTimer = null;
|
||||||
|
let speichertGerade = false;
|
||||||
|
let beobachter = null; // MutationObserver auf dem Textfeld
|
||||||
|
let loeschenFragt = null; // Timer der zweistufigen Löschen-Abfrage
|
||||||
|
let ladeFehler = '';
|
||||||
|
|
||||||
|
const kannSchreiben = () => typeof hatRecht === 'function' && hatRecht('proj.plan');
|
||||||
|
const projektUid = () => app.project ? String(app.project.projectUid || app.project.id || '') : '';
|
||||||
|
const wrap = () => document.getElementById('planNotizenWrap');
|
||||||
|
const aktiv = () => liste.find(n => n.id === aktivId) || null;
|
||||||
|
|
||||||
|
function zeit(iso){
|
||||||
|
if(!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if(isNaN(d)) return '';
|
||||||
|
const heute = new Date();
|
||||||
|
const gleicherTag = d.toDateString() === heute.toDateString();
|
||||||
|
const gestern = new Date(heute); gestern.setDate(heute.getDate() - 1);
|
||||||
|
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if(gleicherTag) return 'heute ' + uhr;
|
||||||
|
if(d.toDateString() === gestern.toDateString()) return 'gestern ' + uhr;
|
||||||
|
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: d.getFullYear() === heute.getFullYear() ? undefined : 'numeric' });
|
||||||
|
}
|
||||||
|
function kurzname(name){
|
||||||
|
const teile = String(name || '').trim().split(/\s+/);
|
||||||
|
return teile.length > 1 ? teile[0][0] + '. ' + teile.slice(1).join(' ') : (name || '');
|
||||||
|
}
|
||||||
|
function klartext(html){
|
||||||
|
return typeof statusPlainText === 'function' ? statusPlainText(html) : String(html || '').replace(/<[^>]+>/g, ' ');
|
||||||
|
}
|
||||||
|
function sortieren(){
|
||||||
|
liste.sort((a, b) => (b.angeheftet - a.angeheftet) || String(b.geaendertAm).localeCompare(String(a.geaendertAm)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Laden ----------
|
||||||
|
function laden(uid){
|
||||||
|
uidGeladen = uid;
|
||||||
|
ladeFehler = '';
|
||||||
|
return fetch(baseUrl() + '/projekt-notizen?uid=' + encodeURIComponent(uid))
|
||||||
|
.then(r => r.json().then(d => ({ ok: r.ok, d })))
|
||||||
|
.then(({ ok, d }) => {
|
||||||
|
if(uid !== uidGeladen) return;
|
||||||
|
if(!ok || !d.ok) throw new Error(d.error || 'Laden fehlgeschlagen');
|
||||||
|
liste = d.notizen || [];
|
||||||
|
sortieren();
|
||||||
|
})
|
||||||
|
.catch(e => { ladeFehler = e.message || 'Die Notizen konnten nicht geladen werden.'; liste = []; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Darstellung ----------
|
||||||
|
window.renderPlanNotizen = function(){
|
||||||
|
const el = wrap();
|
||||||
|
if(!el || !app.project) return;
|
||||||
|
const uid = projektUid();
|
||||||
|
if(uid !== uidGeladen){
|
||||||
|
jetztSpeichern();
|
||||||
|
aktivId = null; liste = []; suche = '';
|
||||||
|
el.innerHTML = '<div class="notizen-laedt">Notizen werden geladen…</div>';
|
||||||
|
laden(uid).then(() => { if(projektUid() === uid) aufbauen(); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
aufbauen();
|
||||||
|
};
|
||||||
|
|
||||||
|
function aufbauen(){
|
||||||
|
const el = wrap();
|
||||||
|
if(!el) return;
|
||||||
|
if(!aktiv() && liste.length) aktivId = liste[0].id;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="notizen" id="notizenBereich">
|
||||||
|
<aside class="notizen-liste">
|
||||||
|
<div class="notizen-suchzeile">
|
||||||
|
<input type="search" id="notizenSuche" class="notizen-suche" placeholder="Notizen durchsuchen…"
|
||||||
|
aria-label="Notizen durchsuchen" value="${esc(suche)}" oninput="notizenSuchen(this.value)">
|
||||||
|
${kannSchreiben() ? '<button type="button" class="notizen-neu" onclick="notizNeu()">+ Neu</button>' : ''}
|
||||||
|
</div>
|
||||||
|
<div id="notizenEintraege" class="notizen-eintraege"></div>
|
||||||
|
${kannSchreiben() ? '<div class="notizen-ablage">Mail hierher ziehen = neue Notiz</div>' : ''}
|
||||||
|
</aside>
|
||||||
|
<div class="notizen-editor" id="notizenEditor"></div>
|
||||||
|
</div>`;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listeZeichnen(){
|
||||||
|
const box = document.getElementById('notizenEintraege');
|
||||||
|
if(!box) return;
|
||||||
|
if(ladeFehler){ box.innerHTML = '<div class="notizen-leer ist-fehler">' + esc(ladeFehler) + '</div>'; return; }
|
||||||
|
const s = suche.trim().toLowerCase();
|
||||||
|
const treffer = s ? liste.filter(n => (n.titel + ' ' + klartext(n.html)).toLowerCase().includes(s)) : liste;
|
||||||
|
if(!liste.length){ box.innerHTML = '<div class="notizen-leer">Noch keine Notizen.</div>'; return; }
|
||||||
|
if(!treffer.length){ box.innerHTML = '<div class="notizen-leer">Keine Notiz enthält „' + esc(suche) + '“.</div>'; return; }
|
||||||
|
const zeile = n => {
|
||||||
|
const vorschau = klartext(n.html).replace(/\s+/g, ' ').trim().slice(0, 90);
|
||||||
|
return `<button type="button" class="notizen-eintrag${n.id === aktivId ? ' ist-aktiv' : ''}" onclick="notizWaehlen('${esc(n.id)}')">
|
||||||
|
<span class="notizen-eintrag-titel">${esc(n.titel || 'Ohne Titel')}${n.quelle === 'mail' ? ' <span class="notizen-chip">Mail</span>' : ''}</span>
|
||||||
|
<span class="notizen-eintrag-text">${esc(vorschau || 'Kein Text')}</span>
|
||||||
|
<span class="notizen-eintrag-meta">${esc(kurzname(n.geaendertVon))} · ${esc(zeit(n.geaendertAm))}</span>
|
||||||
|
</button>`;
|
||||||
|
};
|
||||||
|
const oben = treffer.filter(n => n.angeheftet), rest = treffer.filter(n => !n.angeheftet);
|
||||||
|
box.innerHTML = (oben.length ? '<div class="notizen-gruppe"><span class="notizen-pin-punkt"></span>Angeheftet</div>' + oben.map(zeile).join('') : '')
|
||||||
|
+ (rest.length ? (oben.length ? '<div class="notizen-gruppe">Weitere</div>' : '') + rest.map(zeile).join('') : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorZeichnen(){
|
||||||
|
const el = document.getElementById('notizenEditor');
|
||||||
|
if(!el) return;
|
||||||
|
if(beobachter){ beobachter.disconnect(); beobachter = null; }
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n){
|
||||||
|
el.innerHTML = `<div class="notizen-editor-leer">
|
||||||
|
<b>${liste.length ? 'Notiz links auswählen' : 'Noch keine Notizen in diesem Projekt'}</b>
|
||||||
|
<span>${kannSchreiben() ? 'Mit „+ Neu“ anlegen oder eine Mail auf diesen Bereich ziehen.' : 'Zum Anlegen fehlt das Recht, den Terminplan zu bearbeiten.'}</span>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const schreiben = kannSchreiben();
|
||||||
|
basis = n.geaendertAm;
|
||||||
|
ungespeichert = false;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="notizen-editor-kopf">
|
||||||
|
<input type="text" id="notizTitel" class="notizen-titel" value="${esc(n.titel)}" placeholder="Titel der Notiz"
|
||||||
|
aria-label="Titel der Notiz" ${schreiben ? '' : 'readonly'} oninput="notizGeaendert()" onblur="notizJetztSpeichern()">
|
||||||
|
${schreiben ? `<button type="button" class="notizen-symbol${n.angeheftet ? ' ist-an' : ''}" onclick="notizAnheften()"
|
||||||
|
title="${n.angeheftet ? 'Nicht mehr anheften' : 'Oben anheften'}" aria-pressed="${n.angeheftet}">${n.angeheftet ? 'Angeheftet' : 'Anheften'}</button>
|
||||||
|
<button type="button" class="notizen-symbol ist-gefahr" id="notizLoeschen" onclick="notizLoeschen()">Löschen</button>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="richtext-wrap notizen-richtext">
|
||||||
|
${schreiben ? richtextToolbarHtml('.notizen-text') : ''}
|
||||||
|
${richtextFeldHtml('notizen-text', sanitizeStatusHtml(n.html || ''), 'Notiz schreiben…', 'id="notizText" onblur="notizJetztSpeichern()"', schreiben)}
|
||||||
|
</div>
|
||||||
|
<div class="notizen-fuss">
|
||||||
|
<span>Angelegt von ${esc(n.erstelltVon)} ${esc(zeit(n.erstelltAm))}${n.geaendertAm !== n.erstelltAm ? ' · zuletzt geändert von ' + esc(n.geaendertVon) + ' ' + esc(zeit(n.geaendertAm)) : ''}</span>
|
||||||
|
<span id="notizStatus" class="notizen-status"></span>
|
||||||
|
</div>`;
|
||||||
|
const text = document.getElementById('notizText');
|
||||||
|
// Auch Fett/Farbe aus der Werkzeugleiste ändern den Inhalt, ohne ein
|
||||||
|
// input-Ereignis auszulösen — daher ein MutationObserver.
|
||||||
|
if(text && schreiben){
|
||||||
|
beobachter = new MutationObserver(() => notizGeaendert());
|
||||||
|
beobachter.observe(text, { childList: true, subtree: true, characterData: true, attributes: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(text, art){
|
||||||
|
const el = document.getElementById('notizStatus');
|
||||||
|
if(!el) return;
|
||||||
|
el.className = 'notizen-status' + (art ? ' ist-' + art : '');
|
||||||
|
el.innerHTML = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bearbeiten und Speichern ----------
|
||||||
|
window.notizenSuchen = function(wert){ suche = wert || ''; listeZeichnen(); };
|
||||||
|
|
||||||
|
window.notizWaehlen = function(id){
|
||||||
|
if(id === aktivId) return;
|
||||||
|
jetztSpeichern();
|
||||||
|
aktivId = id;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.notizGeaendert = function(){
|
||||||
|
if(!kannSchreiben() || !aktiv()) return;
|
||||||
|
ungespeichert = true;
|
||||||
|
status('Ungespeichert');
|
||||||
|
clearTimeout(speicherTimer);
|
||||||
|
speicherTimer = setTimeout(jetztSpeichern, SPEICHER_PAUSE_MS);
|
||||||
|
};
|
||||||
|
window.notizJetztSpeichern = function(){ jetztSpeichern(); };
|
||||||
|
|
||||||
|
// jetztSpeichern: schickt die gewählte Notiz, falls geändert. ohneBasis =
|
||||||
|
// bewusst überschreiben (nach einem Konflikt „Meine Fassung behalten“).
|
||||||
|
function jetztSpeichern(ohneBasis){
|
||||||
|
clearTimeout(speicherTimer);
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n || !ungespeichert || !kannSchreiben()) return Promise.resolve();
|
||||||
|
if(speichertGerade){ speicherTimer = setTimeout(jetztSpeichern, 400); return Promise.resolve(); }
|
||||||
|
const titelEl = document.getElementById('notizTitel');
|
||||||
|
const textEl = document.getElementById('notizText');
|
||||||
|
const titel = titelEl ? titelEl.value : n.titel;
|
||||||
|
const html = textEl ? textEl.innerHTML : n.html;
|
||||||
|
ungespeichert = false;
|
||||||
|
speichertGerade = true;
|
||||||
|
status('Speichert…');
|
||||||
|
return fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify({ titel, html, basis: ohneBasis ? '' : basis })
|
||||||
|
}).then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
|
||||||
|
if(r.status === 409 && d.konflikt){
|
||||||
|
ungespeichert = true;
|
||||||
|
status(esc(d.error) + ' <button type="button" class="notizen-link" onclick="notizKonflikt(true)">Meine Fassung speichern</button>'
|
||||||
|
+ ' <button type="button" class="notizen-link" onclick="notizKonflikt(false)">Neuen Stand laden</button>', 'warnung');
|
||||||
|
n._fremd = d.notiz;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!r.ok || !d.ok) throw new Error(d.error || 'Speichern fehlgeschlagen');
|
||||||
|
Object.assign(n, d.notiz, { titel, html });
|
||||||
|
if(n.id === aktivId) basis = d.notiz.geaendertAm;
|
||||||
|
sortieren();
|
||||||
|
listeZeichnen();
|
||||||
|
if(!ungespeichert) status('Gespeichert ✓', 'ok');
|
||||||
|
}).catch(e => {
|
||||||
|
ungespeichert = true;
|
||||||
|
status('Nicht gespeichert: ' + esc(e.message) + ' — wird erneut versucht.', 'fehler');
|
||||||
|
speicherTimer = setTimeout(jetztSpeichern, 5000);
|
||||||
|
}).finally(() => { speichertGerade = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.notizKonflikt = function(meine){
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n) return;
|
||||||
|
if(meine){ ungespeichert = true; jetztSpeichern(true); return; }
|
||||||
|
if(n._fremd) Object.assign(n, n._fremd);
|
||||||
|
delete n._fremd;
|
||||||
|
ungespeichert = false;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.notizNeu = function(){
|
||||||
|
const uid = projektUid();
|
||||||
|
if(!uid || !kannSchreiben()) return;
|
||||||
|
jetztSpeichern();
|
||||||
|
fetch(baseUrl() + '/projekt-notizen', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify({ uid, titel: '', html: '' })
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if(!d.ok) throw new Error(d.error || 'Anlegen fehlgeschlagen');
|
||||||
|
neueNotizZeigen(d.notiz);
|
||||||
|
const t = document.getElementById('notizTitel');
|
||||||
|
if(t) t.focus();
|
||||||
|
}).catch(e => app.showToast('Notiz konnte nicht angelegt werden: ' + e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
function neueNotizZeigen(notiz){
|
||||||
|
if(!notiz || notiz.uid !== uidGeladen) return;
|
||||||
|
liste = liste.filter(x => x.id !== notiz.id);
|
||||||
|
liste.push(notiz);
|
||||||
|
sortieren();
|
||||||
|
aktivId = notiz.id;
|
||||||
|
suche = '';
|
||||||
|
const s = document.getElementById('notizenSuche');
|
||||||
|
if(s) s.value = '';
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.notizAnheften = function(){
|
||||||
|
const n = aktiv();
|
||||||
|
if(!n) return;
|
||||||
|
jetztSpeichern().then(() => fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify({ angeheftet: !n.angeheftet })
|
||||||
|
})).then(r => r.json()).then(d => {
|
||||||
|
if(!d.ok) throw new Error(d.error || 'Fehler');
|
||||||
|
n.angeheftet = d.notiz.angeheftet;
|
||||||
|
sortieren();
|
||||||
|
listeZeichnen();
|
||||||
|
const knopf = document.querySelector('.notizen-editor-kopf .notizen-symbol:not(.ist-gefahr)');
|
||||||
|
if(knopf){
|
||||||
|
knopf.classList.toggle('ist-an', n.angeheftet);
|
||||||
|
knopf.textContent = n.angeheftet ? 'Angeheftet' : 'Anheften';
|
||||||
|
knopf.title = n.angeheftet ? 'Nicht mehr anheften' : 'Oben anheften';
|
||||||
|
knopf.setAttribute('aria-pressed', String(n.angeheftet));
|
||||||
|
}
|
||||||
|
}).catch(e => app.showToast('Anheften fehlgeschlagen: ' + e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
// notizLoeschen: zweistufig im Knopf selbst (kein confirm()).
|
||||||
|
window.notizLoeschen = function(){
|
||||||
|
const n = aktiv();
|
||||||
|
const knopf = document.getElementById('notizLoeschen');
|
||||||
|
if(!n || !knopf) return;
|
||||||
|
if(!loeschenFragt){
|
||||||
|
knopf.textContent = 'Wirklich löschen?';
|
||||||
|
knopf.classList.add('ist-fragend');
|
||||||
|
loeschenFragt = setTimeout(() => {
|
||||||
|
loeschenFragt = null;
|
||||||
|
const k = document.getElementById('notizLoeschen');
|
||||||
|
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
|
||||||
|
}, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(loeschenFragt); loeschenFragt = null;
|
||||||
|
clearTimeout(speicherTimer); ungespeichert = false;
|
||||||
|
fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), { method: 'DELETE' })
|
||||||
|
.then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
|
||||||
|
if(!r.ok || !d.ok) throw new Error(d.error || 'Löschen fehlgeschlagen');
|
||||||
|
liste = liste.filter(x => x.id !== n.id);
|
||||||
|
aktivId = liste.length ? liste[0].id : null;
|
||||||
|
listeZeichnen();
|
||||||
|
editorZeichnen();
|
||||||
|
}).catch(e => { status(esc(e.message), 'fehler'); const k = document.getElementById('notizLoeschen'); if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Live: Änderungen aus anderen Browsern ----------
|
||||||
|
// Liste neu holen; die gewählte Notiz nur ersetzen, wenn hier gerade
|
||||||
|
// nichts ungespeichert ist und niemand in ihr tippt.
|
||||||
|
window.notizenLiveAenderung = function(uid){
|
||||||
|
if(!uid || uid !== uidGeladen) return;
|
||||||
|
const n = aktiv();
|
||||||
|
laden(uid).then(() => {
|
||||||
|
if(!wrap()) return;
|
||||||
|
if(aktivId && !liste.some(x => x.id === aktivId)){ aktivId = liste.length ? liste[0].id : null; editorZeichnen(); }
|
||||||
|
listeZeichnen();
|
||||||
|
const frisch = aktiv();
|
||||||
|
const tippt = document.activeElement && document.activeElement.closest && document.activeElement.closest('#notizenEditor');
|
||||||
|
if(frisch && n && frisch.id === n.id && !ungespeichert && !tippt && frisch.geaendertAm !== basis) editorZeichnen();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Mail ablegen (Drag & Drop, Strg+V) ----------
|
||||||
|
function imNotizenBereich(){
|
||||||
|
const s = document.querySelector('.plan-section.is-active');
|
||||||
|
return typeof currentTab !== 'undefined' && currentTab === 'plan' && s && s.dataset.section === 'notizen' && !!app.project;
|
||||||
|
}
|
||||||
|
function ausMail(nutzlast){
|
||||||
|
const uid = projektUid();
|
||||||
|
if(!uid) return;
|
||||||
|
jetztSpeichern();
|
||||||
|
fetch(baseUrl() + '/projekt-notizen/aus-mail', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||||
|
body: JSON.stringify(Object.assign({ uid }, nutzlast))
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if(!d.ok) throw new Error(d.error || 'Fehlgeschlagen');
|
||||||
|
neueNotizZeigen(d.notiz);
|
||||||
|
app.showToast('Notiz aus der Mail angelegt.');
|
||||||
|
}).catch(e => app.showToast('Mail konnte nicht übernommen werden: ' + e.message));
|
||||||
|
}
|
||||||
|
function ausDaten(dt){
|
||||||
|
const dateien = Array.from(dt.files || []);
|
||||||
|
const eml = dateien.find(f => /\.eml$/i.test(f.name || '') || f.type === 'message/rfc822');
|
||||||
|
if(eml){
|
||||||
|
if(eml.size > 5 * 1024 * 1024){ app.showToast('Die Mail ist zu groß (mehr als 5 MB).'); return; }
|
||||||
|
eml.text().then(t => ausMail({ eml: t }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(dateien.some(f => /\.msg$/i.test(f.name || ''))){
|
||||||
|
app.showToast('Outlook-Dateien (.msg) lassen sich nicht lesen. Bitte den Mailtext markieren und hineinziehen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text = (dt.getData('text/plain') || '').trim();
|
||||||
|
if(!text){
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = (dt.getData('text/html') || '').replace(/<(script|style)[\s\S]*?<\/\1>/gi, '');
|
||||||
|
text = (div.innerText || div.textContent || '').trim();
|
||||||
|
}
|
||||||
|
if(text) ausMail({ text });
|
||||||
|
}
|
||||||
|
const bereich = () => document.getElementById('planSection-notizen');
|
||||||
|
document.addEventListener('dragover', e => {
|
||||||
|
if(!imNotizenBereich() || !kannSchreiben() || !bereich().contains(e.target)) return;
|
||||||
|
if(e.target.closest && e.target.closest('#notizenEditor [contenteditable="true"], #notizTitel')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
bereich().classList.add('notizen-zieht');
|
||||||
|
});
|
||||||
|
document.addEventListener('dragleave', e => {
|
||||||
|
const b = bereich();
|
||||||
|
if(b && (!e.relatedTarget || !b.contains(e.relatedTarget))) b.classList.remove('notizen-zieht');
|
||||||
|
});
|
||||||
|
document.addEventListener('drop', e => {
|
||||||
|
const b = bereich();
|
||||||
|
if(b) b.classList.remove('notizen-zieht');
|
||||||
|
if(!imNotizenBereich() || !kannSchreiben() || !b.contains(e.target)) return;
|
||||||
|
// In das Textfeld gezogen: normal einfügen lassen, keine neue Notiz.
|
||||||
|
if(e.target.closest && e.target.closest('#notizenEditor [contenteditable="true"], #notizTitel')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
ausDaten(e.dataTransfer);
|
||||||
|
});
|
||||||
|
document.addEventListener('paste', e => {
|
||||||
|
if(!imNotizenBereich() || !kannSchreiben()) return;
|
||||||
|
const ziel = e.target;
|
||||||
|
if(ziel && ziel.closest && ziel.closest('input, textarea, select, [contenteditable="true"], .modal-bg')) return;
|
||||||
|
if(!e.clipboardData) return;
|
||||||
|
e.preventDefault();
|
||||||
|
ausDaten(e.clipboardData);
|
||||||
|
});
|
||||||
|
// Beim Verlassen der Seite noch speichern, was offen ist.
|
||||||
|
// Klick irgendwo anders: die Rückfrage „Wirklich löschen?“ zurücknehmen.
|
||||||
|
document.addEventListener('pointerdown', e => {
|
||||||
|
if(!loeschenFragt || (e.target.closest && e.target.closest('#notizLoeschen'))) return;
|
||||||
|
clearTimeout(loeschenFragt); loeschenFragt = null;
|
||||||
|
const k = document.getElementById('notizLoeschen');
|
||||||
|
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
|
||||||
|
}, true);
|
||||||
|
window.addEventListener('pagehide', () => { if(ungespeichert) jetztSpeichern(); });
|
||||||
|
})();
|
||||||
@@ -1,21 +1,10 @@
|
|||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Projektbereich — Leistungsphasen
|
// Projektbereich — Zuordnung Aufgabe → Leistungsphase
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
// Bereich "Leistungsphasen" (Eriks Wahl "B"): die Phasenbalken des
|
// Der frühere Reiter „Leistungsphasen“ (Ablaufbalken + Kachel je Phase) ist
|
||||||
// Terminplans (Aufgaben mit t.lph, balkenPhase() in load.js) als eigener
|
// auf Eriks Wunsch entfallen; an seiner Stelle steht „Notizen“. Geblieben ist
|
||||||
// Bereich statt als Zeilen zwischen den Aufgaben. Oben ein Ablaufbalken über
|
// nur aufgabenPhase(), die die Aufgabenliste (plan-tasks.js) und das
|
||||||
// alle Phasen mit Heute-Linie, darunter eine Kachel je Phase: Fortschritt
|
// Terminplan-PDF (pdf.js) nutzen.
|
||||||
// (aus dem Wochenbericht, p.lph), Zeitraum (aus dem Balken), Aufwand
|
|
||||||
// (Summe der Stunden ihrer Aufgaben) und die Aufgaben, die in ihrem
|
|
||||||
// Zeitraum beginnen — dieselbe Zuordnung wie die Gliederung im Terminplan
|
|
||||||
// (plan-build-gantt.js).
|
|
||||||
//
|
|
||||||
// Keine eigene Datenhaltung: Bearbeiten öffnet dasselbe Panel wie im
|
|
||||||
// Terminplan (openTodoModal() im Phasen-Modus), Fortschritt bleibt im
|
|
||||||
// Wochenbericht ("Ein Ort je Angabe").
|
|
||||||
//
|
|
||||||
// renderPlanPhasen() wird von renderPlanGantt() (plan-gantt.js) und beim
|
|
||||||
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) aufgerufen.
|
|
||||||
|
|
||||||
// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört —
|
// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört —
|
||||||
// der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die
|
// der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die
|
||||||
@@ -31,150 +20,3 @@ function aufgabenPhase(t, todos){
|
|||||||
.find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end));
|
.find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end));
|
||||||
return k ? balkenPhase(k) : '';
|
return k ? balkenPhase(k) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
(function(){
|
|
||||||
const app = window.PlanApp;
|
|
||||||
|
|
||||||
const LPH_NAMEN = {
|
|
||||||
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
|
|
||||||
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
|
|
||||||
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung',
|
|
||||||
'W+M': 'Wartung und Monitoring'
|
|
||||||
};
|
|
||||||
|
|
||||||
const kurzDatum = d => d ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) : '';
|
|
||||||
const langDatum = d => d ? formatDateDE(d) : '';
|
|
||||||
|
|
||||||
// phasenDaten: je Phase alles, was Ablaufbalken und Kachel brauchen.
|
|
||||||
// Rückgabe: Array, sortiert läuft → geplant → fertig, darin nach Beginn.
|
|
||||||
function phasenDaten(){
|
|
||||||
const p = app.project;
|
|
||||||
const plan = app.ensurePlanLocal();
|
|
||||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
|
||||||
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
|
|
||||||
|
|
||||||
// Phasen: beauftragte plus alle, die einen Balken haben.
|
|
||||||
const namen = LPHS.filter(l => beauftragt.includes(l) || todos.some(t => balkenPhase(t) === l));
|
|
||||||
const amtierend = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || null;
|
|
||||||
const laufend = typeof arbeitsPhase === 'function' ? arbeitsPhase(p, amtierend) : amtierend;
|
|
||||||
|
|
||||||
return namen.map(l => {
|
|
||||||
const idx = todos.findIndex(t => balkenPhase(t) === l);
|
|
||||||
const balken = idx >= 0 ? todos[idx] : null;
|
|
||||||
const start = balken ? parseDateISO(balken.start) : null;
|
|
||||||
const ende = balken ? parseDateISO(balken.end) : null;
|
|
||||||
const prozent = typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0;
|
|
||||||
const aufgaben = todos.filter(t => aufgabenPhase(t, todos) === l);
|
|
||||||
// Aufwand = Summe der Stunden ihrer Aufgaben (dieselbe Quelle wie die
|
|
||||||
// Auslastung, js/load/load.js), nicht mehr aus dem Honorar.
|
|
||||||
const stunden = Math.round(aufgaben.reduce((s, t) => s + (Number(t.aufwand) || 0), 0) * 10) / 10;
|
|
||||||
const status = prozent >= 100 ? 'fertig' : (l === laufend ? 'laeuft' : 'geplant');
|
|
||||||
return { l, idx, balken, start, ende, prozent, stunden, aufgaben, status,
|
|
||||||
beauftragt: !beauftragt.length || beauftragt.includes(l) };
|
|
||||||
}).sort((a, b) => {
|
|
||||||
const rang = { laeuft: 0, geplant: 1, fertig: 2 };
|
|
||||||
if(rang[a.status] !== rang[b.status]) return rang[a.status] - rang[b.status];
|
|
||||||
return LPHS.indexOf(a.l) - LPHS.indexOf(b.l);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ablaufHtml: der schmale Balken über alle Phasen mit Zeitraum. Phasen
|
|
||||||
// ohne Zeitraum fehlen hier (sie stehen als Kachel mit Hinweis darunter).
|
|
||||||
function ablaufHtml(daten){
|
|
||||||
const mit = daten.filter(d => d.start && d.ende && d.ende >= d.start)
|
|
||||||
.sort((a, b) => a.start - b.start);
|
|
||||||
if(!mit.length) return '';
|
|
||||||
const von = mit[0].start.getTime();
|
|
||||||
const bis = Math.max(...mit.map(d => d.ende.getTime()));
|
|
||||||
const spanne = Math.max(1, bis - von);
|
|
||||||
const pos = t => Math.min(100, Math.max(0, (t - von) / spanne * 100));
|
|
||||||
const heute = new Date(); heute.setHours(0, 0, 0, 0);
|
|
||||||
const heutePos = heute.getTime() >= von && heute.getTime() <= bis ? pos(heute.getTime()) : null;
|
|
||||||
const segmente = mit.map(d => {
|
|
||||||
const links = pos(d.start.getTime());
|
|
||||||
const breite = Math.max(1.5, pos(d.ende.getTime() + 86400000) - links);
|
|
||||||
const titel = `${d.l}: ${langDatum(d.start)} – ${langDatum(d.ende)}, ${d.prozent} %`;
|
|
||||||
return `<button type="button" class="phasen-ablauf-seg is-${d.status}" style="left:${links}%;width:${breite}%;--fort:${d.prozent}%"
|
|
||||||
title="${esc(titel)}" onclick="phasenKachelZeigen('${esc(d.l)}')"><span>${esc(breite > 6 ? d.l : d.l.replace('LPH ', ''))}</span></button>`;
|
|
||||||
}).join('');
|
|
||||||
const heuteHtml = heutePos === null ? '' : `<span class="phasen-ablauf-heute" style="left:${heutePos}%" title="heute"></span>`;
|
|
||||||
const monat = d => d.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' });
|
|
||||||
return `<div class="plan-panel phasen-ablauf">
|
|
||||||
<div class="phasen-kopf">Ablauf</div>
|
|
||||||
<div class="phasen-ablauf-spur">${segmente}${heuteHtml}</div>
|
|
||||||
<div class="phasen-ablauf-achse"><span>${esc(monat(mit[0].start))}</span>${heutePos === null ? '' : `<span class="phasen-ablauf-heute-text" style="left:${heutePos}%">heute, ${esc(kurzDatum(heute))}</span>`}<span>${esc(monat(new Date(bis)))}</span></div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// kachelHtml: eine Kachel je Phase.
|
|
||||||
function kachelHtml(d, locked){
|
|
||||||
const statusText = { laeuft: 'läuft', geplant: 'geplant', fertig: 'fertig' }[d.status];
|
|
||||||
const erledigt = d.aufgaben.filter(t => t.done).length;
|
|
||||||
const aufgabenText = d.aufgaben.length ? `${erledigt} von ${d.aufgaben.length} erledigt` : 'keine';
|
|
||||||
const zeitraum = d.start && d.ende
|
|
||||||
? `${d.start.getFullYear() === d.ende.getFullYear() ? kurzDatum(d.start) : langDatum(d.start)} – ${langDatum(d.ende)}`
|
|
||||||
: '<span class="phasen-leer">kein Zeitraum</span>';
|
|
||||||
const bearbeiten = locked ? ''
|
|
||||||
: (d.balken
|
|
||||||
? `<button class="secondary small" onclick="openTodoModal(${d.idx})">Bearbeiten</button>`
|
|
||||||
: `<button class="secondary small" onclick="addPlanPhase('${esc(d.l)}')">Zeitraum festlegen</button>`);
|
|
||||||
const ansehen = d.aufgaben.length
|
|
||||||
? `<button class="secondary small" onclick="phasenAufgabenZeigen('${esc(d.l)}')">Aufgaben ansehen</button>` : '';
|
|
||||||
return `<div class="phasen-kachel is-${d.status}" data-phase="${esc(d.l)}">
|
|
||||||
<div class="phasen-kachel-kopf">
|
|
||||||
<div class="phasen-ring" style="--fort:${d.prozent}%"><span>${d.prozent} %</span></div>
|
|
||||||
<div class="phasen-kachel-name">
|
|
||||||
<b>${esc(d.l)}</b>
|
|
||||||
<span>${esc(LPH_NAMEN[d.l] || '')}</span>
|
|
||||||
</div>
|
|
||||||
<span class="phasen-chip is-${d.status}">${statusText}</span>
|
|
||||||
</div>
|
|
||||||
${d.beauftragt ? '' : '<div class="phasen-hinweis">nicht beauftragt</div>'}
|
|
||||||
<div class="phasen-werte">
|
|
||||||
<span><small>Zeitraum</small><b>${zeitraum}</b></span>
|
|
||||||
<span><small>Aufwand</small><b>${d.stunden ? d.stunden.toLocaleString('de-DE') + ' h' : '<span class="phasen-leer">keine Stunden</span>'}</b></span>
|
|
||||||
<span><small>Aufgaben</small><b>${aufgabenText}</b></span>
|
|
||||||
</div>
|
|
||||||
${bearbeiten || ansehen ? `<div class="phasen-knoepfe">${bearbeiten}${ansehen}</div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderPlanPhasen: baut den Bereich neu auf. Keine Parameter.
|
|
||||||
window.renderPlanPhasen = function(){
|
|
||||||
const el = document.getElementById('planPhasenWrap');
|
|
||||||
if(!el || !app.project) return;
|
|
||||||
const locked = app.isLocked();
|
|
||||||
const daten = phasenDaten();
|
|
||||||
if(!daten.length){
|
|
||||||
el.innerHTML = `<div class="plan-panel"><div class="plan-task-empty">${locked
|
|
||||||
? 'Für dieses Projekt sind keine Leistungsphasen hinterlegt.'
|
|
||||||
: 'Noch keine Leistungsphase. Unter „Einstellungen“ beauftragen oder über „+ Leistungsphase“ bzw. „Terminplan aus Honorar“ anlegen.'}</div></div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const summeH = daten.reduce((s, d) => s + d.stunden, 0);
|
|
||||||
const enden = daten.filter(d => d.ende).map(d => d.ende.getTime());
|
|
||||||
const laufend = daten.find(d => d.status === 'laeuft');
|
|
||||||
const teile = [daten.filter(d => d.beauftragt).length + ' beauftragt'];
|
|
||||||
if(laufend) teile.push('läuft: ' + laufend.l);
|
|
||||||
if(summeH) teile.push(summeH.toLocaleString('de-DE') + ' h in Aufgaben');
|
|
||||||
if(enden.length) teile.push('Ende voraussichtlich ' + langDatum(new Date(Math.max(...enden))));
|
|
||||||
el.innerHTML = `<div class="phasen-zusammenfassung">${esc(teile.join(' · '))}<span>Fortschritt aus dem Wochenbericht</span></div>
|
|
||||||
${ablaufHtml(daten)}
|
|
||||||
<div class="phasen-raster">${daten.map(d => kachelHtml(d, locked)).join('')}</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// phasenKachelZeigen: Klick auf ein Segment im Ablauf — Kachel ins Bild
|
|
||||||
// holen und kurz hervorheben.
|
|
||||||
window.phasenKachelZeigen = function(l){
|
|
||||||
const k = document.querySelector(`#planPhasenWrap .phasen-kachel[data-phase="${CSS.escape(l)}"]`);
|
|
||||||
if(!k) return;
|
|
||||||
k.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
k.classList.remove('ist-markiert'); void k.offsetWidth; k.classList.add('ist-markiert');
|
|
||||||
};
|
|
||||||
|
|
||||||
// phasenAufgabenZeigen: in den Bereich Aufgaben, gefiltert auf diese Phase.
|
|
||||||
window.phasenAufgabenZeigen = function(l){
|
|
||||||
if(typeof setzeAufgabenFilter === 'function') setzeAufgabenFilter(l);
|
|
||||||
showPlanSection('aufgaben');
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
// ein Fallback ist ein akzeptiertes Ergebnis, kein Fehlschlag.
|
// ein Fallback ist ein akzeptiertes Ergebnis, kein Fehlschlag.
|
||||||
function zeigeFallbackHinweis(msg){
|
function zeigeFallbackHinweis(msg){
|
||||||
const el = document.getElementById('protokollTodosFallback');
|
const el = document.getElementById('protokollTodosFallback');
|
||||||
if(el){ el.textContent = '⚠️ ' + msg; el.hidden = false; }
|
if(el){ el.textContent = msg; el.hidden = false; }
|
||||||
}
|
}
|
||||||
function verbergeFallbackHinweis(){
|
function verbergeFallbackHinweis(){
|
||||||
const el = document.getElementById('protokollTodosFallback');
|
const el = document.getElementById('protokollTodosFallback');
|
||||||
|
|||||||
@@ -548,7 +548,7 @@
|
|||||||
// ab, verschwindet also nie von selbst, sondern nur wenn man das
|
// ab, verschwindet also nie von selbst, sondern nur wenn man das
|
||||||
// Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar.
|
// Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar.
|
||||||
const importBanner = p.quelleDateiname
|
const importBanner = p.quelleDateiname
|
||||||
? `<div class="protokoll-import-banner">🔎 Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>`
|
? `<div class="protokoll-import-banner">Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>`
|
||||||
: '';
|
: '';
|
||||||
// Rein clientseitiges Merkmal (siehe importiereProtokoll() oben) — nur
|
// Rein clientseitiges Merkmal (siehe importiereProtokoll() oben) — nur
|
||||||
// direkt nach GENAU dem Import gesetzt, der die KI angefordert, aber
|
// direkt nach GENAU dem Import gesetzt, der die KI angefordert, aber
|
||||||
@@ -559,7 +559,7 @@
|
|||||||
// das war Eriks Rückmeldung, nachdem er von einem KI-Fehlschlag nichts
|
// das war Eriks Rückmeldung, nachdem er von einem KI-Fehlschlag nichts
|
||||||
// in der Oberfläche sah.
|
// in der Oberfläche sah.
|
||||||
const kiFallbackBanner = p._kiFallbackHinweis
|
const kiFallbackBanner = p._kiFallbackHinweis
|
||||||
? `<div class="protokoll-ki-fallback-banner">⚠️ Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>`
|
? `<div class="protokoll-ki-fallback-banner">Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>`
|
||||||
: '';
|
: '';
|
||||||
const kopf = `
|
const kopf = `
|
||||||
${kiFallbackBanner}
|
${kiFallbackBanner}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
if(moved.size > 0){
|
if(moved.size > 0){
|
||||||
app.savePlan();
|
app.savePlan();
|
||||||
renderPlanGantt();
|
renderPlanGantt();
|
||||||
app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Achtung: zyklische Abhängigkeit gefunden!' : ''));
|
app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Zyklische Abhängigkeit gefunden — bitte Vorgänger prüfen.' : ''));
|
||||||
} else if(hasCycle){
|
} else if(hasCycle){
|
||||||
app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.');
|
app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
|
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
|
||||||
// Navigationsleiste. Muss zu den data-section-Attributen in
|
// Navigationsleiste. Muss zu den data-section-Attributen in
|
||||||
// App.html passen.
|
// App.html passen.
|
||||||
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'phasen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'notizen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
||||||
|
|
||||||
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
|
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
|
||||||
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
|
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
|
||||||
@@ -63,8 +63,8 @@
|
|||||||
|
|
||||||
if(!app.project) return;
|
if(!app.project) return;
|
||||||
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
|
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
|
||||||
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
|
|
||||||
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
|
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||||
|
if(key === 'notizen' && typeof renderPlanNotizen === 'function') renderPlanNotizen();
|
||||||
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
|
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
|
||||||
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
|
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
|
||||||
if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings();
|
if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings();
|
||||||
@@ -107,9 +107,8 @@
|
|||||||
if(nav) nav.style.display = '';
|
if(nav) nav.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// upcomingEntries: offene Aufgaben mit Endtermin und Meilensteine ab heute
|
// upcomingEntries: offene Aufgaben mit Endtermin und nicht erreichte
|
||||||
// (vergangene Meilensteine haben kein Erledigt-Kennzeichen und gelten als
|
// Meilensteine (vergangene gelten als überfällig), chronologisch. idx verweist in plan.todos/plan.milestones, damit
|
||||||
// gewesen), chronologisch. idx verweist in plan.todos/plan.milestones, damit
|
|
||||||
// ein Klick denselben Dialog öffnet wie im Terminplan.
|
// ein Klick denselben Dialog öffnet wie im Terminplan.
|
||||||
// Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }.
|
// Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }.
|
||||||
function upcomingEntries(){
|
function upcomingEntries(){
|
||||||
@@ -126,7 +125,7 @@
|
|||||||
meins: !!ich && (t.assignees || []).includes(ich) });
|
meins: !!ich && (t.assignees || []).includes(ich) });
|
||||||
});
|
});
|
||||||
(plan.milestones || []).forEach((m, idx) => {
|
(plan.milestones || []).forEach((m, idx) => {
|
||||||
if(!m.date || m.date < heute) return;
|
if(!m.date || m.erreicht) return;
|
||||||
rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false });
|
rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false });
|
||||||
});
|
});
|
||||||
rows.sort((a, b) => a.iso.localeCompare(b.iso));
|
rows.sort((a, b) => a.iso.localeCompare(b.iso));
|
||||||
|
|||||||
@@ -284,12 +284,10 @@ window.PlanApp = {
|
|||||||
|
|
||||||
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
|
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
|
||||||
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
|
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
|
||||||
// Terminplan (…Btn), Aufgaben (…BtnList) und Leistungsphasen (…BtnPhasen),
|
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html.
|
||||||
// siehe App.html.
|
|
||||||
const locked = isLocked();
|
const locked = isLocked();
|
||||||
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
|
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
|
||||||
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList',
|
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList']
|
||||||
'honorarBtnPhasen', 'addPhaseBtnPhasen']
|
|
||||||
.forEach(id => {
|
.forEach(id => {
|
||||||
const btn = document.getElementById(id);
|
const btn = document.getElementById(id);
|
||||||
if(btn) btn.style.display = locked ? 'none' : '';
|
if(btn) btn.style.display = locked ? 'none' : '';
|
||||||
|
|||||||
@@ -166,16 +166,16 @@
|
|||||||
const d = parseDateISO(m.date);
|
const d = parseDateISO(m.date);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const overdue = d && d < today;
|
const overdue = d && d < today && !m.erreicht;
|
||||||
const msAction = locked ? '' : ` onclick="openMilestoneModal(${idx})" title="Zum Bearbeiten klicken"`;
|
const msAction = locked ? '' : ` onclick="openMilestoneModal(${idx})" title="Zum Bearbeiten klicken"`;
|
||||||
return `<div class="plan-task-row${locked ? ' is-locked' : ''}"${msAction}>
|
return `<div class="plan-task-row${locked ? ' is-locked' : ''}"${msAction}>
|
||||||
<span class="plan-task-dot"></span>
|
<span class="plan-task-dot"></span>
|
||||||
<span class="plan-task-title">${esc(m.name || 'Meilenstein')}</span>
|
<span class="plan-task-title">${esc(m.name || 'Meilenstein')}</span>
|
||||||
<span class="plan-task-meta${overdue ? ' overdue' : ''}">${esc(d ? formatDateDE(d) : 'ohne Datum')}</span>
|
<span class="plan-task-meta${overdue ? ' overdue' : ''}">${m.erreicht ? 'erreicht · ' : ''}${esc(d ? formatDateDE(d) : 'ohne Datum')}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
const msCount = document.getElementById('planMilestoneCount');
|
const msCount = document.getElementById('planMilestoneCount');
|
||||||
if(msCount) msCount.textContent = milestones.length === 0 ? '' : `${milestones.length} gesamt`;
|
if(msCount) msCount.textContent = milestones.length === 0 ? '' : `${milestones.filter(m => m.erreicht).length} von ${milestones.length} erreicht`;
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
@@ -541,6 +541,11 @@ function liveEreignis(d){
|
|||||||
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
|
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
|
||||||
if(d.typ === 'neu') return liveAllesNeu();
|
if(d.typ === 'neu') return liveAllesNeu();
|
||||||
if(d.typ === 'personen') return personenNachladen();
|
if(d.typ === 'personen') return personenNachladen();
|
||||||
|
if(d.typ === 'notizen'){
|
||||||
|
if(typeof meineNotizenLive === 'function') meineNotizenLive(d.uid);
|
||||||
|
if(typeof notizenLiveAenderung === 'function') notizenLiveAenderung(d.uid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
|
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
|
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
|
||||||
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
|
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
|
||||||
// Dashboard ins Projektboard.
|
// Dashboard ins Projektboard.
|
||||||
const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','people','planung','planchecker','dashboard','wand','settings','admin','plan'];
|
const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','notizen','people','planung','planchecker','dashboard','wand','settings','admin','plan'];
|
||||||
|
|
||||||
function showTab(t){
|
function showTab(t){
|
||||||
// Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der
|
// Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der
|
||||||
@@ -61,6 +61,7 @@ function showTab(t){
|
|||||||
if(t==='wochenbericht') renderWochenbericht();
|
if(t==='wochenbericht') renderWochenbericht();
|
||||||
if(t==='load') renderLoad();
|
if(t==='load') renderLoad();
|
||||||
if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); }
|
if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); }
|
||||||
|
if(t==='notizen' && typeof renderMeineNotizen === 'function') renderMeineNotizen();
|
||||||
if(t==='people') renderPeople();
|
if(t==='people') renderPeople();
|
||||||
if(t==='planung') renderPlanung();
|
if(t==='planung') renderPlanung();
|
||||||
if(t==='planchecker') renderPlanChecker();
|
if(t==='planchecker') renderPlanChecker();
|
||||||
@@ -108,6 +109,9 @@ function syncPlanNavItem(){
|
|||||||
const name = app && app.projName ? app.projName : '';
|
const name = app && app.projName ? app.projName : '';
|
||||||
item.style.display = name ? '' : 'none';
|
item.style.display = name ? '' : 'none';
|
||||||
const label = document.getElementById('planNavItemLabel');
|
const label = document.getElementById('planNavItemLabel');
|
||||||
if(label) label.textContent = name || 'Projektbereich';
|
// Projektnummer in Mono-Ziffern davor (Kennzeichen „Bauplan“).
|
||||||
|
if(label) label.innerHTML = name
|
||||||
|
? (app.projNr ? '<span class="nav-projnr">' + esc(app.projNr) + '</span> ' : '') + esc(name)
|
||||||
|
: 'Projektbereich';
|
||||||
item.title = name ? ('Projektbereich: ' + name) : 'Projektbereich';
|
item.title = name ? ('Projektbereich: ' + name) : 'Projektbereich';
|
||||||
}
|
}
|
||||||
+39
-8
@@ -385,8 +385,21 @@ function kommentarGewerk(){
|
|||||||
// Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des
|
// Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des
|
||||||
// Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder
|
// Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder
|
||||||
// null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }.
|
// null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }.
|
||||||
function kommentarVon(p, g){
|
// kommentareDesProjekts: die Kommentare nur der Gewerke, die das Projekt hat.
|
||||||
|
// Ein Kommentar eines Gewerks, das dem Projekt nicht (mehr) gehört — etwa aus
|
||||||
|
// einer Zeit, als es noch HLS hatte —, gehört nicht in die Gesamtanzeige
|
||||||
|
// (Eriks Meldung zu 21-4030). Ohne gepflegte Gewerke: alle. Rückgabe: Objekt.
|
||||||
|
function kommentareDesProjekts(p){
|
||||||
const alle = (p && p.kommentare) || {};
|
const alle = (p && p.kommentare) || {};
|
||||||
|
const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : [];
|
||||||
|
if(!gewerke.length) return alle;
|
||||||
|
const aus = {};
|
||||||
|
Object.keys(alle).forEach(k => { if(gewerke.includes(k)) aus[k] = alle[k]; });
|
||||||
|
return aus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kommentarVon(p, g){
|
||||||
|
const alle = g ? ((p && p.kommentare) || {}) : kommentareDesProjekts(p);
|
||||||
if(g){
|
if(g){
|
||||||
const e = alle[g] || {};
|
const e = alle[g] || {};
|
||||||
return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen };
|
return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen };
|
||||||
@@ -408,19 +421,37 @@ function kommentarVon(p, g){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g
|
// kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g
|
||||||
// nur dessen Text; ohne Gewerk die Texte aller Gewerke, bei mehreren je mit
|
// nur dessen Text; ohne Gewerk die Texte aller Gewerke des Projekts. Bei
|
||||||
// dem Gewerk als eigener, dezenter Beschriftung (.kommentar-gewerk) —
|
// mehreren Gewerken Darstellung „B – Überschrift mit Linie“ (Eriks Wahl):
|
||||||
// Systemtext, nicht Teil des Kommentars (anders als die fette "ELT:"-Zeile,
|
// je Gewerk eine Kopfzeile mit dem Gewerk in seiner Farbe, einer feinen Linie
|
||||||
// die kommentarVon() für Klartext-Vorschauen einfügt). Rückgabe: HTML ('' ohne Text).
|
// und (mitMeta) Verfasser und Zeit, darunter der Text — Systemtext, nicht
|
||||||
function kommentarAnzeigeHtml(p, g){
|
// Teil des Kommentars (anders als die fette "ELT:"-Zeile, die kommentarVon()
|
||||||
|
// für Klartext-Vorschauen einfügt). Parameter: p, g, mitMeta.
|
||||||
|
// Rückgabe: HTML ('' ohne Text).
|
||||||
|
function kommentarAnzeigeHtml(p, g, mitMeta){
|
||||||
if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || '');
|
if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || '');
|
||||||
const alle = (p && p.kommentare) || {};
|
const alle = kommentareDesProjekts(p);
|
||||||
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
|
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
|
||||||
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
|
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
|
||||||
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
|
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
|
||||||
if(mitText.length === 0) return '';
|
if(mitText.length === 0) return '';
|
||||||
if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status);
|
if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status);
|
||||||
return mitText.map(k => `<div class="kommentar-gewerk">${esc(k)}</div><div>${sanitizeStatusHtml(alle[k].status)}</div>`).join('');
|
return '<div class="kommentar-gewerke">' + mitText.map(k => {
|
||||||
|
const e = alle[k];
|
||||||
|
const zeit = e.statusAt && typeof formatCommentStamp === 'function' ? formatCommentStamp(e.statusAt) : '';
|
||||||
|
const meta = mitMeta ? [e.statusAuthor, zeit].filter(Boolean).join(' · ') : '';
|
||||||
|
return '<div class="kommentar-block">'
|
||||||
|
+ '<div class="kommentar-kopf"><span class="kommentar-gewerk gewerk-' + esc(k.toLowerCase()) + '">' + esc(k) + '</span>'
|
||||||
|
+ '<span class="kommentar-linie"></span>' + (meta ? '<span class="kommentar-meta">' + esc(meta) + '</span>' : '') + '</div>'
|
||||||
|
+ '<div class="kommentar-text">' + sanitizeStatusHtml(e.status) + '</div></div>';
|
||||||
|
}).join('') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// kommentarGewerkeMitText: wie viele Gewerke des Projekts einen Kommentar
|
||||||
|
// haben (für die Frage, ob eine gemeinsame Verfasserzeile Sinn ergibt).
|
||||||
|
function kommentarGewerkeMitText(p){
|
||||||
|
const alle = kommentareDesProjekts(p);
|
||||||
|
return Object.keys(alle).filter(k => alle[k] && statusPlainText(alle[k].status) !== '').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in
|
// kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in
|
||||||
|
|||||||
@@ -822,7 +822,7 @@ function wtTageDaten(w){
|
|||||||
// (offen/überfällig) sind über die Kennzahlen oben erreichbar, die heute
|
// (offen/überfällig) sind über die Kennzahlen oben erreichbar, die heute
|
||||||
// Abwesenden über die Kopfleiste.
|
// Abwesenden über die Kopfleiste.
|
||||||
(plan.milestones || []).forEach(m => {
|
(plan.milestones || []).forEach(m => {
|
||||||
if(m.date && m.date >= heute && spalteFuer[m.date]) spalteFuer[m.date].eintraege.push({ art: 'ms', titel: m.name || 'Meilenstein', projekt: hinweisFuer(m.date) + projekt, pid: pr.id, abschnitt: 'terminplan', sort: m.date });
|
if(m.date && !m.erreicht && m.date >= heute && spalteFuer[m.date]) spalteFuer[m.date].eintraege.push({ art: 'ms', titel: m.name || 'Meilenstein', projekt: hinweisFuer(m.date) + projekt, pid: pr.id, abschnitt: 'terminplan', sort: m.date });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
tage.forEach(t => t.eintraege.sort((a, b) => String(a.sort).localeCompare(String(b.sort))));
|
tage.forEach(t => t.eintraege.sort((a, b) => String(a.sort).localeCompare(String(b.sort))));
|
||||||
|
|||||||
@@ -666,12 +666,14 @@ function renderBerichtListe(){
|
|||||||
const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : '';
|
const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : '';
|
||||||
gruppeVorher = gruppe;
|
gruppeVorher = gruppe;
|
||||||
const name = (p.nr ? p.nr + ' · ' : '') + p.name;
|
const name = (p.nr ? p.nr + ' · ' : '') + p.name;
|
||||||
|
const ueber = berichtAufgabenDaten(p, w, prevWeek).ueberfaellig.length;
|
||||||
return `${gruppenkopf}<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${kommentarVon(p, wochenberichtGewerk).besprochen ? ' is-ab' : ''}"
|
return `${gruppenkopf}<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${kommentarVon(p, wochenberichtGewerk).besprochen ? ' is-ab' : ''}"
|
||||||
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}"
|
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}"
|
||||||
aria-current="${istAktiv ? 'true' : 'false'}">
|
aria-current="${istAktiv ? 'true' : 'false'}">
|
||||||
<span class="bericht-rail-num">${String(position).padStart(2, '0')}</span>
|
<span class="bericht-rail-num">${String(position).padStart(2, '0')}</span>
|
||||||
${info.bucket === 'unchanged' ? '' : `<span class="bericht-rail-dot is-${esc(info.bucket)}" aria-hidden="true"></span>`}
|
${info.bucket === 'unchanged' ? '' : `<span class="bericht-rail-dot is-${esc(info.bucket)}" aria-hidden="true"></span>`}
|
||||||
<span class="bericht-rail-name">${esc(name)}</span>
|
<span class="bericht-rail-name">${esc(name)}</span>
|
||||||
|
${ueber ? `<span class="bericht-rail-ueber" title="${ueber} überfällige Aufgabe${ueber === 1 ? '' : 'n'}">${ueber}</span>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -713,6 +715,7 @@ function renderBerichtDetail(p, w, prevWeek, locked){
|
|||||||
<div class="bericht-sub">${subText}</div>
|
<div class="bericht-sub">${subText}</div>
|
||||||
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Heute abwesend: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''}
|
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Heute abwesend: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''}
|
||||||
${renderBoardDrawer(p, allPeople, kLocked, w.label, Object.assign({}, BERICHT_OPTS, { ohneVerlauf: true }))}
|
${renderBoardDrawer(p, allPeople, kLocked, w.label, Object.assign({}, BERICHT_OPTS, { ohneVerlauf: true }))}
|
||||||
|
${renderBerichtAufgaben(p, w, prevWeek, locked)}
|
||||||
<div class="bericht-detail-fuss">
|
<div class="bericht-detail-fuss">
|
||||||
${!locked && hatRecht('proj.plan') ? `<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
|
${!locked && hatRecht('proj.plan') ? `<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
|
||||||
Aufgabe aus Kommentar…
|
Aufgabe aus Kommentar…
|
||||||
@@ -876,6 +879,133 @@ function berichtVerlaufNeu(){
|
|||||||
el.outerHTML = renderBerichtVerlauf(p, w, isWeekLocked(w));
|
el.outerHTML = renderBerichtVerlauf(p, w, isWeekLocked(w));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Aufgaben des Projekts (Eriks Wahl „B“) ----------
|
||||||
|
//
|
||||||
|
// Die drei Fragen der Runde: was ist seit der Vorwoche erledigt, was ist
|
||||||
|
// überfällig, was steht in den nächsten zwei Wochen an. Nur Anzeige — ein
|
||||||
|
// Klick öffnet dasselbe Aufgaben-/Meilenstein-Panel wie im Terminplan
|
||||||
|
// ("Ein Ort je Angabe"). Keine neue Datenhaltung: "erledigt seit" ist der
|
||||||
|
// Vergleich mit der Kopie des Projekts in der vorigen geladenen Sitzung.
|
||||||
|
// Phasenbalken (t.lph) zählen nicht, sie stehen als Leistungsphasen in der Karte.
|
||||||
|
|
||||||
|
const BERICHT_AUFGABEN_TAGE = 14;
|
||||||
|
const BERICHT_AUFGABEN_JE_SPALTE = 6;
|
||||||
|
|
||||||
|
// berichtAufgabenDaten: Parameter p, w, prevWeek. Rückgabe:
|
||||||
|
// {erledigt, ueberfaellig, bald, weitere, mitVorwoche} — Listen von
|
||||||
|
// {art, idx, titel, datum, progress, neu, kuerzel}. Aufgaben mit Zuständigen
|
||||||
|
// aus dem gewählten Gewerk stehen in jeder Liste zuerst. Auch von js/pdf.js genutzt.
|
||||||
|
function berichtAufgabenDaten(p, w, prevWeek){
|
||||||
|
const plan = p.plan || {};
|
||||||
|
const heute = heuteIso();
|
||||||
|
const bis = isoPlusTage(BERICHT_AUFGABEN_TAGE);
|
||||||
|
const vorher = prevWeek && p.projectUid ? (prevWeek.projects || []).find(x => x.projectUid === p.projectUid) : null;
|
||||||
|
const vorherTodos = new Map(((vorher && vorher.plan && vorher.plan.todos) || []).map(t => [String(t.id), t]));
|
||||||
|
const leute = new Map((w.people || []).map(person => [person.id, person]));
|
||||||
|
const g = wochenberichtGewerk;
|
||||||
|
|
||||||
|
const erledigt = [], ueberfaellig = [], bald = [];
|
||||||
|
let weitere = 0;
|
||||||
|
(plan.todos || []).forEach((t, idx) => {
|
||||||
|
if(t.lph) return;
|
||||||
|
const wer = (t.assignees || []).map(id => leute.get(id)).filter(Boolean);
|
||||||
|
const alt = vorherTodos.get(String(t.id));
|
||||||
|
const e = {
|
||||||
|
art: 'aufgabe', idx, titel: t.title || 'Aufgabe', datum: t.end || '',
|
||||||
|
progress: parseInt(t.progress, 10) || 0, fertig: !!t.done,
|
||||||
|
neu: !!vorher && !alt,
|
||||||
|
kuerzel: wer.map(person => person.name).join(', '),
|
||||||
|
imGewerk: !!g && wer.some(person => personGewerk(person) === g)
|
||||||
|
};
|
||||||
|
if(t.done){
|
||||||
|
if(vorher && (!alt || !alt.done)) erledigt.push(e);
|
||||||
|
} else if(t.end && t.end < heute) ueberfaellig.push(e);
|
||||||
|
else if(t.end && t.end <= bis) bald.push(e);
|
||||||
|
else weitere++;
|
||||||
|
});
|
||||||
|
(plan.milestones || []).forEach((m, idx) => {
|
||||||
|
if(!m.date || m.erreicht) return;
|
||||||
|
const e = { art: 'meilenstein', idx, titel: m.name || 'Meilenstein', datum: m.date, progress: 0, fertig: false, neu: false, kuerzel: '', imGewerk: false };
|
||||||
|
if(m.date < heute) ueberfaellig.push(e);
|
||||||
|
else if(m.date <= bis) bald.push(e);
|
||||||
|
});
|
||||||
|
const sortieren = l => l.sort((a, b) => (b.imGewerk - a.imGewerk) || String(a.datum).localeCompare(String(b.datum)));
|
||||||
|
return { erledigt: sortieren(erledigt), ueberfaellig: sortieren(ueberfaellig), bald: sortieren(bald), weitere, mitVorwoche: !!vorher };
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderBerichtAufgaben: der Abschnitt unter der Projektkarte.
|
||||||
|
// Parameter: p, w, prevWeek, locked. Rückgabe: HTML.
|
||||||
|
function renderBerichtAufgaben(p, w, prevWeek, locked){
|
||||||
|
const d = berichtAufgabenDaten(p, w, prevWeek);
|
||||||
|
const klickbar = !locked && hatRecht('proj.plan');
|
||||||
|
const vorKw = prevWeek ? berichtKurzKw(prevWeek.label) : '';
|
||||||
|
const zeile = e => {
|
||||||
|
const datum = e.datum ? formatDateDE(parseDateISO(e.datum)).slice(0, 6) : '';
|
||||||
|
const art = e.art === 'meilenstein'
|
||||||
|
? '<span class="ba-raute" aria-label="Meilenstein"></span>'
|
||||||
|
: `<span class="ba-haken${e.fertig ? ' is-an' : ''}" aria-hidden="true"></span>`;
|
||||||
|
const inhalt = `${art}
|
||||||
|
<span class="ba-titel">${esc(e.titel)}${e.neu ? '<span class="ba-neu">neu</span>' : ''}${!e.fertig && e.progress > 0 && e.progress < 100 ? `<span class="ba-fort">${e.progress} %</span>` : ''}</span>
|
||||||
|
<span class="ba-wer">${esc(e.kuerzel)}</span>
|
||||||
|
<span class="ba-datum">${esc(datum)}</span>`;
|
||||||
|
return klickbar
|
||||||
|
? `<button type="button" class="ba-zeile" onclick="berichtEintragOeffnen('${esc(p.id)}','${e.art}',${e.idx})">${inhalt}</button>`
|
||||||
|
: `<div class="ba-zeile">${inhalt}</div>`;
|
||||||
|
};
|
||||||
|
const spalte = (titel, liste, leer, klasse) => `<div class="ba-spalte">
|
||||||
|
<div class="ba-spalte-kopf${klasse || ''}"><span>${esc(titel)}</span><span class="ba-zahl">${liste.length}</span></div>
|
||||||
|
${liste.length ? liste.slice(0, BERICHT_AUFGABEN_JE_SPALTE).map(zeile).join('') : `<div class="ba-leer">${esc(leer)}</div>`}
|
||||||
|
${liste.length > BERICHT_AUFGABEN_JE_SPALTE ? `<div class="ba-leer">${liste.length - BERICHT_AUFGABEN_JE_SPALTE} weitere</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
const hinweis = [];
|
||||||
|
if(d.weitere) hinweis.push(d.weitere === 1 ? '1 weitere offene Aufgabe später oder ohne Termin' : d.weitere + ' weitere offene Aufgaben später oder ohne Termin');
|
||||||
|
if(klickbar) hinweis.push('Klick auf eine Zeile öffnet die Aufgabe');
|
||||||
|
return `<section class="bericht-aufgaben" id="berichtAufgaben" aria-label="Aufgaben">
|
||||||
|
<div class="bv-kopf">
|
||||||
|
<h2 class="bv-titel">Aufgaben</h2>
|
||||||
|
<span class="bv-anzahl">${vorKw ? 'seit ' + esc(vorKw) : 'keine Vorwoche geladen'}${wochenberichtGewerk ? ' · Zuständige aus ' + esc(wochenberichtGewerk) + ' zuerst' : ''}</span>
|
||||||
|
<span class="bericht-fuss-spacer"></span>
|
||||||
|
<button type="button" class="secondary small" onclick="berichtAufgabenImProjekt('${esc(p.id)}')">Alle im Projekt</button>
|
||||||
|
</div>
|
||||||
|
<div class="ba-spalten">
|
||||||
|
${spalte(vorKw ? 'Erledigt seit ' + vorKw : 'Erledigt', d.erledigt, d.mitVorwoche ? 'Nichts erledigt' : 'Kein Vergleich möglich')}
|
||||||
|
${spalte('Überfällig', d.ueberfaellig, 'Nichts überfällig', d.ueberfaellig.length ? ' is-rot' : '')}
|
||||||
|
${spalte('Nächste zwei Wochen', d.bald, 'Nichts fällig')}
|
||||||
|
</div>
|
||||||
|
${hinweis.length ? `<div class="ba-hinweis">${esc(hinweis.join(' · '))}</div>` : ''}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// berichtAufgabenNeu: zeichnet nur diesen Abschnitt und die Tagesordnung neu
|
||||||
|
// (nach dem Schließen des Panels), ein angefangener Kommentar bleibt.
|
||||||
|
function berichtAufgabenNeu(){
|
||||||
|
const w = currentWeek();
|
||||||
|
const p = w && aktuellesBerichtProjekt(w);
|
||||||
|
const el = document.getElementById('berichtAufgaben');
|
||||||
|
if(!p || !el) return;
|
||||||
|
el.outerHTML = renderBerichtAufgaben(p, w, findPreviousWeek(w), isWeekLocked(w));
|
||||||
|
renderBerichtListe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// berichtEintragOeffnen: öffnet das Aufgaben- bzw. Meilenstein-Panel über dem
|
||||||
|
// Wochenbericht. Die Panels arbeiten auf window.PlanApp; showTab('plan')
|
||||||
|
// setzt week/project über openPlanView() ohnehin neu.
|
||||||
|
function berichtEintragOeffnen(pid, art, idx){
|
||||||
|
const w = currentWeek();
|
||||||
|
const p = w && (w.projects || []).find(x => x.id === pid);
|
||||||
|
if(!p) return;
|
||||||
|
window.PlanApp.week = w;
|
||||||
|
window.PlanApp.project = p;
|
||||||
|
if(art === 'meilenstein') openMilestoneModal(idx);
|
||||||
|
else openTodoModal(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// berichtAufgabenImProjekt: springt in den Bereich "Aufgaben" des Projekts.
|
||||||
|
function berichtAufgabenImProjekt(pid){
|
||||||
|
location.hash = 'aufgaben';
|
||||||
|
openProjectPlan(pid);
|
||||||
|
}
|
||||||
|
|
||||||
// berichtVerlaufUebernehmen: schreibt den Kommentar jener Sitzung in das
|
// berichtVerlaufUebernehmen: schreibt den Kommentar jener Sitzung in das
|
||||||
// Kommentarfeld der aktuellen (takeCommentFromHistory(), js/board/board.js).
|
// Kommentarfeld der aktuellen (takeCommentFromHistory(), js/board/board.js).
|
||||||
function berichtVerlaufUebernehmen(weekLabel){
|
function berichtVerlaufUebernehmen(weekLabel){
|
||||||
|
|||||||
+552
-371
File diff suppressed because it is too large.
Load diff
+962
-671
File diff suppressed because it is too large.
Load diff
@@ -1020,6 +1020,30 @@ function htmlEscape(s){
|
|||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// textMaskieren: wie htmlEscape(), aber ein '&', das schon eine fertige
|
||||||
|
// Entity beginnt (& ' …), bleibt stehen. Der Text zwischen den
|
||||||
|
// Tags kommt aus gespeichertem HTML, in dem solche Entities ganz normal
|
||||||
|
// stehen. htmlEscape() machte daraus bei JEDEM Speichern eine Stufe mehr
|
||||||
|
// ( → &nbsp; → &amp;nbsp; …) — Eriks Meldung nach dem
|
||||||
|
// Sitzungswechsel KW 45. So bleibt die Bereinigung beliebig oft wiederholbar.
|
||||||
|
function textMaskieren(s){
|
||||||
|
return String(s)
|
||||||
|
.replace(/&(?!(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&')
|
||||||
|
.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// entitiesEntstapeln: macht gestapelte Maskierung rückgängig
|
||||||
|
// (&amp;nbsp; → , &amp; → &). Ein „&“ vor einer
|
||||||
|
// weiteren Entity ist immer das Ergebnis des Fehlers oben, nie Absicht.
|
||||||
|
function entitiesEntstapeln(html){
|
||||||
|
let alt, neu = String(html || '');
|
||||||
|
do {
|
||||||
|
alt = neu;
|
||||||
|
neu = neu.replace(/&((?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&$1');
|
||||||
|
} while(neu !== alt);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
// sicheresStyleAttribut: lässt aus einem style-Attribut nur color und
|
// sicheresStyleAttribut: lässt aus einem style-Attribut nur color und
|
||||||
// font-weight durch — identisch zur Client-Regel. Rückgabe: bereinigter
|
// font-weight durch — identisch zur Client-Regel. Rückgabe: bereinigter
|
||||||
// Wert oder '' (dann wird das Attribut ganz weggelassen).
|
// Wert oder '' (dann wird das Attribut ganz weggelassen).
|
||||||
@@ -1108,7 +1132,7 @@ function sanitizeFormatHtml(html){
|
|||||||
if(c !== '<'){
|
if(c !== '<'){
|
||||||
const naechstes = quelle.indexOf('<', i);
|
const naechstes = quelle.indexOf('<', i);
|
||||||
const text = quelle.slice(i, naechstes === -1 ? quelle.length : naechstes);
|
const text = quelle.slice(i, naechstes === -1 ? quelle.length : naechstes);
|
||||||
if(!verwerfenBis) aus += htmlEscape(text);
|
if(!verwerfenBis) aus += textMaskieren(text);
|
||||||
i += text.length;
|
i += text.length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1226,8 +1250,8 @@ function projektStatements(){
|
|||||||
VALUES (?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?)`);
|
||||||
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
|
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
|
||||||
VALUES (?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?)`);
|
||||||
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz)
|
const insMilestone = db.prepare(`INSERT INTO plan_milestones (week_label, project_id, milestone_id, name, date, position, sort_order, notiz, erreicht)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||||
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
|
const insMilestoneDep = db.prepare(`INSERT OR IGNORE INTO plan_milestone_deps (week_label, project_id, milestone_id, depends_on_id)
|
||||||
VALUES (?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?)`);
|
||||||
|
|
||||||
@@ -1312,7 +1336,7 @@ function serializeProject(weekLabel, p, idx, nowIso){
|
|||||||
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
|
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
|
||||||
});
|
});
|
||||||
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => {
|
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach((m, midx) => {
|
||||||
insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx, String(m.notiz || '').slice(0, 20000));
|
insMilestone.run(weekLabel, projId, String(m.id || ''), String(m.name || ''), String(m.date || ''), String(m.position || ''), midx, String(m.notiz || '').slice(0, 20000), m.erreicht ? 1 : 0);
|
||||||
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
|
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(did => insMilestoneDep.run(weekLabel, projId, String(m.id || ''), String(did || '')));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1540,7 +1564,7 @@ function loadWeeks(nurLabel){
|
|||||||
const msMap = {};
|
const msMap = {};
|
||||||
milestones.forEach(r => {
|
milestones.forEach(r => {
|
||||||
const k = msKey(r.week_label, r.project_id, r.milestone_id);
|
const k = msKey(r.week_label, r.project_id, r.milestone_id);
|
||||||
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '' };
|
msMap[k] = { id: r.milestone_id, name: r.name, date: r.date, position: r.position, dependsOn: [], notiz: r.notiz || '', erreicht: !!r.erreicht };
|
||||||
});
|
});
|
||||||
milestoneDeps.forEach(r => {
|
milestoneDeps.forEach(r => {
|
||||||
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
|
const m = msMap[msKey(r.week_label, r.project_id, r.milestone_id)];
|
||||||
@@ -1670,11 +1694,11 @@ const IMPORT_TABLES = ['weeks','week_people','week_people_roles','attendance','p
|
|||||||
'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps',
|
'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps',
|
||||||
'plan_milestones','plan_milestone_deps',
|
'plan_milestones','plan_milestone_deps',
|
||||||
'project_protokolle','protokoll_teilnehmer','protokoll_punkte',
|
'project_protokolle','protokoll_teilnehmer','protokoll_punkte',
|
||||||
'project_kommentare'];
|
'project_kommentare', 'projekt_notizen'];
|
||||||
// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen.
|
// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen.
|
||||||
// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk()
|
// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk()
|
||||||
// verteilt die alten Kommentare danach aus projects.status.
|
// verteilt die alten Kommentare danach aus projects.status.
|
||||||
const IMPORT_OPTIONAL_TABLES = ['project_kommentare'];
|
const IMPORT_OPTIONAL_TABLES = ['project_kommentare', 'projekt_notizen'];
|
||||||
|
|
||||||
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
|
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
|
||||||
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
|
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
|
||||||
@@ -1689,7 +1713,7 @@ function dbTableColumns(conn, table){
|
|||||||
// Wiederherstellung geschrieben wird, nach einem Versionssprung nicht mehr
|
// Wiederherstellung geschrieben wird, nach einem Versionssprung nicht mehr
|
||||||
// einspielbar.
|
// einspielbar.
|
||||||
const IMPORT_OPTIONAL_COLS = ['updated_at', 'project_uid', 'honorar', 'status_author', 'status_at',
|
const IMPORT_OPTIONAL_COLS = ['updated_at', 'project_uid', 'honorar', 'status_author', 'status_at',
|
||||||
'auftragsstatus', 'besprochen', 'leistungsphase', 'aufwand_stunden', 'notiz'];
|
'auftragsstatus', 'besprochen', 'leistungsphase', 'aufwand_stunden', 'notiz', 'erreicht'];
|
||||||
|
|
||||||
// restoreDbFromBackup: validiert die Backup-Datei (muss das bekannte Schema
|
// restoreDbFromBackup: validiert die Backup-Datei (muss das bekannte Schema
|
||||||
// mit identischen Spalten wie die Live-DB besitzen) und ersetzt den
|
// mit identischen Spalten wie die Live-DB besitzen) und ersetzt den
|
||||||
@@ -2611,6 +2635,30 @@ function kommentareVorsatzUmstellen(){
|
|||||||
if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.');
|
if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entitiesReparieren: führt gestapelte Maskierung (&amp;nbsp; …) in
|
||||||
|
// allen Sitzungskommentaren und Protokoll-Punkten auf das ursprüngliche
|
||||||
|
// Zeichen zurück — Folge des Fehlers in sanitizeFormatHtml() vor
|
||||||
|
// textMaskieren(). Läuft bei jedem Start und nach /import, idempotent.
|
||||||
|
function entitiesReparieren(){
|
||||||
|
const teile = [
|
||||||
|
['project_kommentare', 'status', 'week_label, project_id, gewerk', 'week_label = ? AND project_id = ? AND gewerk = ?', r => [r.week_label, r.project_id, r.gewerk]],
|
||||||
|
['protokoll_punkte', 'text', 'id', 'id = ?', r => [r.id]]
|
||||||
|
];
|
||||||
|
let n = 0;
|
||||||
|
db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
teile.forEach(([tab, feld, schluessel, wo, werte]) => {
|
||||||
|
const upd = db.prepare('UPDATE ' + tab + ' SET ' + feld + ' = ? WHERE ' + wo);
|
||||||
|
db.prepare('SELECT ' + schluessel + ', ' + feld + " AS wert FROM " + tab + " WHERE " + feld + " LIKE '%&%'").all().forEach(r => {
|
||||||
|
const neu = entitiesEntstapeln(r.wert);
|
||||||
|
if(neu !== r.wert){ upd.run(neu, ...werte(r)); n++; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch(e){ db.exec('ROLLBACK'); throw e; }
|
||||||
|
if(n) console.log('Doppelt maskierte Zeichen in ' + n + ' Kommentaren/Protokoll-Punkten repariert.');
|
||||||
|
}
|
||||||
|
|
||||||
function kommentareSpiegelstricheUmstellen(){
|
function kommentareSpiegelstricheUmstellen(){
|
||||||
const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status != ''").all();
|
const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status != ''").all();
|
||||||
const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?');
|
const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?');
|
||||||
@@ -2682,6 +2730,12 @@ function migrateAddPlanTodoNotiz(){
|
|||||||
db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
|
db.exec("ALTER TABLE plan_milestones ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
|
||||||
console.log('plan_milestones um notiz ergänzt.');
|
console.log('plan_milestones um notiz ergänzt.');
|
||||||
}
|
}
|
||||||
|
// erreicht: Meilenstein als erreicht/abgeschlossen markiert — sonst zählt
|
||||||
|
// ein vergangener Meilenstein auf "Meine Aufgaben" als überfällig.
|
||||||
|
if(!msCols.includes('erreicht')){
|
||||||
|
db.exec("ALTER TABLE plan_milestones ADD COLUMN erreicht INTEGER NOT NULL DEFAULT 0");
|
||||||
|
console.log('plan_milestones um erreicht ergänzt.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
|
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
|
||||||
@@ -2908,8 +2962,12 @@ function ensureCurrentWeekRollover(){
|
|||||||
// zur Sitzung und beginnt leer.
|
// zur Sitzung und beginnt leer.
|
||||||
const vorher = p.kommentare || {};
|
const vorher = p.kommentare || {};
|
||||||
p.kommentare = {};
|
p.kommentare = {};
|
||||||
|
// Nur Gewerke, die das Projekt hat — sonst lebte ein Kommentar eines
|
||||||
|
// längst abgewählten Gewerks Woche für Woche weiter (21-4030).
|
||||||
|
const gewerke = Array.isArray(p.gewerke) ? p.gewerke : [];
|
||||||
Object.keys(vorher).forEach(g => {
|
Object.keys(vorher).forEach(g => {
|
||||||
const k = vorher[g] || {};
|
const k = vorher[g] || {};
|
||||||
|
if(gewerke.length && !gewerke.includes(g)) return;
|
||||||
if(!String(k.status || '').trim()) return;
|
if(!String(k.status || '').trim()) return;
|
||||||
p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '',
|
p.kommentare[g] = { status: k.status, statusAuthor: k.statusAuthor || '',
|
||||||
statusAt: k.statusAt || '', besprochen: false };
|
statusAt: k.statusAt || '', besprochen: false };
|
||||||
@@ -3558,6 +3616,7 @@ einmaligeUmstellung('siemon-anteile-2021', siemon2021Umstellen);
|
|||||||
// Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei
|
// Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei
|
||||||
// jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen.
|
// jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen.
|
||||||
kommentareVorsatzUmstellen();
|
kommentareVorsatzUmstellen();
|
||||||
|
entitiesReparieren();
|
||||||
ensureSandboxWeek();
|
ensureSandboxWeek();
|
||||||
ensureUserPersonIdUniqueIndex();
|
ensureUserPersonIdUniqueIndex();
|
||||||
migrateRetirePeopleTablesIntoUsers();
|
migrateRetirePeopleTablesIntoUsers();
|
||||||
@@ -4679,13 +4738,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
|
// Eriks Wunsch: Wird jemandem eine Aufgabe zugewiesen, bekommt GENAU DIESE
|
||||||
// Teams-Kanal eine Nachricht mit Link direkt auf die Aufgabe im Projekt.
|
// Person in Teams eine Nachricht (Einzelchat mit dem Flow-Bot) mit Link
|
||||||
// Eingerichtet in Teams über „Workflows“ → „Beim Empfang einer
|
// direkt auf die Aufgabe im Projekt — nicht der ganze Kanal (Eriks Wahl
|
||||||
// Webhook-Anfrage in einem Kanal posten“; die dort erzeugte Adresse trägt
|
// „nur persönlich“). Dafür legt man in Teams einen Workflow an: Auslöser
|
||||||
// ein Admin unter Admin → Teams ein (Recht a.teams). Die Adresse ist ein
|
// „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
|
// Geheimnis (wer sie kennt, kann in den Kanal schreiben) — sie steht in
|
||||||
// `integrationen`, nicht in app_settings (das jedes Konto lesen darf), und
|
// `integrationen`, nicht in app_settings (das jedes Konto lesen darf), und
|
||||||
// geht nie an den Browser zurück. Nicht in IMPORT_TABLES.
|
// geht nie an den Browser zurück. Nicht in IMPORT_TABLES.
|
||||||
@@ -4707,17 +4772,16 @@ function integrationSchreiben(k, v){
|
|||||||
.run(k, String(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){
|
function teamsKarte(titel, zeilen, fakten, link, linkText){
|
||||||
const body = [{ type: 'TextBlock', text: titel, weight: 'Bolder', size: 'Medium', wrap: true }];
|
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' }));
|
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 })) });
|
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',
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||||
type: 'AdaptiveCard', version: '1.4', body,
|
type: 'AdaptiveCard', version: '1.4', body,
|
||||||
actions: link ? [{ type: 'Action.OpenUrl', title: linkText || 'Öffnen', url: link }] : []
|
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 —
|
// teamsSenden: an die eingetragene Adresse schicken. Rückgabe: Promise —
|
||||||
@@ -4756,22 +4820,31 @@ function teamsDatum(iso){
|
|||||||
|
|
||||||
// teamsNeueAufgaben: je Aufgabe mit neu Zuständigen EINE Nachricht. neu =
|
// teamsNeueAufgaben: je Aufgabe mit neu Zuständigen EINE Nachricht. neu =
|
||||||
// Map todoId -> { p, t, personen:[ids] }. Läuft im Hintergrund.
|
// 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){
|
function teamsNeueAufgaben(weekLabel, neu, user, req){
|
||||||
if(!neu.size || integrationLesen('teams_neue_aufgaben') !== '1' || !integrationLesen('teams_webhook')) return;
|
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)
|
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]));
|
.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)) || '';
|
const von = (user && (user.displayName || user.username)) || '';
|
||||||
neu.forEach(({ p, t, personen }) => {
|
neu.forEach(({ p, t, personen }) => {
|
||||||
const wer = personen.map(id => namen.get(id) || id).join(', ');
|
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 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(t.end) fakten.push(['Fällig', teamsDatum(t.end)]);
|
||||||
if(zeitraum) fakten.push(['Zeitraum', zeitraum]);
|
if(zeitraum) fakten.push(['Zeitraum', zeitraum]);
|
||||||
if(Number(t.aufwand) > 0) fakten.push(['Aufwand', String(t.aufwand).replace('.', ',') + ' h']);
|
if(Number(t.aufwand) > 0) fakten.push(['Aufwand', String(t.aufwand).replace('.', ',') + ' h']);
|
||||||
if(von) fakten.push(['Zugewiesen von', von]);
|
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');
|
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 +4878,16 @@ app.put('/admin/teams', requireRecht('a.teams'), (req, res) => {
|
|||||||
|
|
||||||
// POST /admin/teams/test — Testnachricht; Fehler als {ok:false, error} mit
|
// POST /admin/teams/test — Testnachricht; Fehler als {ok:false, error} mit
|
||||||
// HTTP 200 (ein fehlgeschlagener Test ist ein erwartetes Ergebnis).
|
// 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) => {
|
app.post('/admin/teams/test', requireRecht('a.teams'), async (req, res) => {
|
||||||
try {
|
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') + '/';
|
const link = req.protocol + '://' + req.get('host') + '/';
|
||||||
await teamsSenden(teamsKarte('Projektübersicht ist mit Teams verbunden',
|
await teamsSenden({ empfaenger: email, karte: teamsKarte('Projektübersicht ist mit Teams verbunden',
|
||||||
['Ab jetzt erscheinen hier neue Aufgaben mit einem Link direkt zur Aufgabe im Projekt.'],
|
['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'));
|
[['Eingerichtet von', req.user.displayName || req.user.username]], link, 'Projektübersicht öffnen') });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true, an: email });
|
||||||
} catch(err){
|
} catch(err){
|
||||||
res.json({ ok: false, error: err.message });
|
res.json({ ok: false, error: err.message });
|
||||||
}
|
}
|
||||||
@@ -5551,14 +5627,13 @@ app.get('/my-dashboard', requireAuth, (req, res) => {
|
|||||||
AND t.project_id IN (${platzhalter})
|
AND t.project_id IN (${platzhalter})
|
||||||
`).all(weekLabel, ...meineProjekte);
|
`).all(weekLabel, ...meineProjekte);
|
||||||
|
|
||||||
// Meilensteine: sie tragen kein Erledigt-Kennzeichen, deshalb zählt
|
// Meilensteine: nur die noch nicht erreichten.
|
||||||
// allein das Datum.
|
|
||||||
const milestones = db.prepare(`
|
const milestones = db.prepare(`
|
||||||
SELECT m.milestone_id AS id, m.name AS titel, m.date AS datum,
|
SELECT m.milestone_id AS id, m.name AS titel, m.date AS datum,
|
||||||
m.project_id AS projectId, p.nr AS projectNr, p.name AS projectName
|
m.project_id AS projectId, p.nr AS projectNr, p.name AS projectName
|
||||||
FROM plan_milestones m
|
FROM plan_milestones m
|
||||||
JOIN projects p ON p.week_label = m.week_label AND p.project_id = m.project_id
|
JOIN projects p ON p.week_label = m.week_label AND p.project_id = m.project_id
|
||||||
WHERE m.week_label = ? AND m.date != ''
|
WHERE m.week_label = ? AND m.date != '' AND m.erreicht = 0
|
||||||
AND m.project_id IN (${platzhalter})
|
AND m.project_id IN (${platzhalter})
|
||||||
`).all(weekLabel, ...meineProjekte);
|
`).all(weekLabel, ...meineProjekte);
|
||||||
|
|
||||||
@@ -7067,12 +7142,17 @@ function mailTeilLesen(roh, tiefe){
|
|||||||
if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' };
|
if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' };
|
||||||
if(!typ.startsWith('text/')) return { plain: '', html: '' };
|
if(!typ.startsWith('text/')) return { plain: '', html: '' };
|
||||||
const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase();
|
const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase();
|
||||||
let buf;
|
let text;
|
||||||
if(enc === 'base64') buf = Buffer.from(rumpf.replace(/\s+/g, ''), 'base64');
|
if(enc === 'base64' || enc === 'quoted-printable'){
|
||||||
else if(enc === 'quoted-printable'){
|
const buf = enc === 'base64'
|
||||||
buf = Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
|
? Buffer.from(rumpf.replace(/\s+/g, ''), 'base64')
|
||||||
} else buf = Buffer.from(rumpf, 'latin1');
|
: Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
|
||||||
const text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
|
text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
|
||||||
|
} else {
|
||||||
|
// 7bit/8bit: Der Browser hat die Datei bereits als Text gelesen (UTF-8) —
|
||||||
|
// ein Umweg über Bytes zerstörte hier die Umlaute.
|
||||||
|
text = rumpf;
|
||||||
|
}
|
||||||
return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' };
|
return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7288,6 +7368,228 @@ app.post('/plan-eintrag-aus-mail', requireAuth, requireRecht('proj.plan'), async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- Projekt-Notizen (Reiter „Notizen“, js/plan/plan-notizen.js) ----------
|
||||||
|
//
|
||||||
|
// Eriks Wunsch (Entwurf A „Liste + Editor“): gemeinsame Notizen je Projekt,
|
||||||
|
// formatiert wie der Sitzungskommentar, anheftbar, Mail per Drag & Drop.
|
||||||
|
// Die Notizen hängen am PROJEKT (project_uid), nicht an einer Sitzung —
|
||||||
|
// sie überstehen den Wochenwechsel, ohne kopiert zu werden, und gelten auch
|
||||||
|
// für Planung-Terminpläne (Sandbox). Gespeichert wird je Notiz, nicht über
|
||||||
|
// /save-week. Lesen: jedes Konto mit Grundzugang; Schreiben: Recht
|
||||||
|
// proj.plan; Löschen: Verfasser oder Administrator.
|
||||||
|
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS projekt_notizen (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_uid TEXT NOT NULL,
|
||||||
|
titel TEXT NOT NULL DEFAULT '',
|
||||||
|
html TEXT NOT NULL DEFAULT '',
|
||||||
|
angeheftet INTEGER NOT NULL DEFAULT 0,
|
||||||
|
quelle TEXT NOT NULL DEFAULT '',
|
||||||
|
erstellt_von INTEGER NOT NULL DEFAULT 0,
|
||||||
|
erstellt_am TEXT NOT NULL DEFAULT '',
|
||||||
|
geaendert_von INTEGER NOT NULL DEFAULT 0,
|
||||||
|
geaendert_am TEXT NOT NULL DEFAULT ''
|
||||||
|
)`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_projekt_notizen_uid ON projekt_notizen(project_uid)');
|
||||||
|
|
||||||
|
const NOTIZ_MAX_HTML = 200000;
|
||||||
|
|
||||||
|
function notizZeile(r){
|
||||||
|
return {
|
||||||
|
id: r.id, uid: r.project_uid, titel: r.titel, html: r.html, angeheftet: !!r.angeheftet, quelle: r.quelle,
|
||||||
|
erstelltVon: r.erstellt_name || '', erstelltVonId: r.erstellt_von, erstelltAm: r.erstellt_am,
|
||||||
|
geaendertVon: r.geaendert_name || '', geaendertAm: r.geaendert_am
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function notizLesen(id){
|
||||||
|
const r = db.prepare(`SELECT n.*, COALESCE(u1.display_name, 'Gelöschter Benutzer') AS erstellt_name,
|
||||||
|
COALESCE(u2.display_name, 'Gelöschter Benutzer') AS geaendert_name
|
||||||
|
FROM projekt_notizen n LEFT JOIN users u1 ON u1.id = n.erstellt_von LEFT JOIN users u2 ON u2.id = n.geaendert_von
|
||||||
|
WHERE n.id = ?`).get(String(id));
|
||||||
|
return r ? notizZeile(r) : null;
|
||||||
|
}
|
||||||
|
function istAdministrator(user){ return !!(user && (rollenCache.get(user.role) || {}).fest); }
|
||||||
|
|
||||||
|
function notizAnlegen(req, uid, titel, html, quelle){
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const jetzt = new Date().toISOString();
|
||||||
|
db.prepare(`INSERT INTO projekt_notizen (id, project_uid, titel, html, angeheftet, quelle, erstellt_von, erstellt_am, geaendert_von, geaendert_am)
|
||||||
|
VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
|
||||||
|
.run(id, uid, String(titel || '').slice(0, 300), String(html || '').slice(0, NOTIZ_MAX_HTML), quelle || '', req.user.id, jetzt, req.user.id, jetzt);
|
||||||
|
ereignisSenden({ typ: 'notizen', uid }, req);
|
||||||
|
return notizLesen(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /projekt-notizen/anzahl — je Projekt (project_uid) Anzahl und zuletzt
|
||||||
|
// geändert, für die Projektblöcke auf „Meine Aufgaben“. Nur Zählung, kein Inhalt.
|
||||||
|
app.get('/projekt-notizen/anzahl', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const anzahl = {};
|
||||||
|
db.prepare("SELECT project_uid AS uid, COUNT(*) AS n, MAX(geaendert_am) AS zuletzt FROM projekt_notizen WHERE project_uid != '' GROUP BY project_uid")
|
||||||
|
.all().forEach(r => { anzahl[r.uid] = { anzahl: r.n, zuletzt: r.zuletzt || '' }; });
|
||||||
|
res.json({ ok: true, anzahl });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notizen zählen fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Seitenleiste „Notizen“: alle Notizen meiner Projekte ----------
|
||||||
|
//
|
||||||
|
// Eriks Wunsch: ein eigener Bereich, in dem jedes Konto die Notizen ALLER
|
||||||
|
// seiner Projekte sieht und auch projektunabhängige Notizen anlegt, die sich
|
||||||
|
// später einem Projekt zuordnen lassen. Eine projektunabhängige Notiz ist eine
|
||||||
|
// Zeile mit project_uid = '' und gehört allein ihrem Verfasser (sonst sieht
|
||||||
|
// und ändert sie niemand). „Meine Projekte“ = in der jüngsten echten Sitzung
|
||||||
|
// zugeordnet (assign > 0) oder mit eigener Aufgabe — dieselbe Regel wie das
|
||||||
|
// Dashboard (dashDaten()).
|
||||||
|
function meineProjektUids(user){
|
||||||
|
const ich = user && user.personId;
|
||||||
|
const uids = new Set();
|
||||||
|
if(!ich) return uids;
|
||||||
|
const label = echteSitzungenSortiert().map(w => w.label).pop();
|
||||||
|
const w = label ? loadWeeks(label)[0] : null;
|
||||||
|
((w && w.projects) || []).forEach(p => {
|
||||||
|
const zugeordnet = Object.keys(p.assign || {}).some(k => k.split('|')[0] === ich && Number(p.assign[k]) > 0);
|
||||||
|
const eigene = ((p.plan && p.plan.todos) || []).some(t => Array.isArray(t.assignees) && t.assignees.includes(ich));
|
||||||
|
if((zugeordnet || eigene) && p.projectUid) uids.add(String(p.projectUid));
|
||||||
|
});
|
||||||
|
return uids;
|
||||||
|
}
|
||||||
|
const NOTIZ_SELECT = `SELECT n.*, COALESCE(u1.display_name, 'Gelöschter Benutzer') AS erstellt_name,
|
||||||
|
COALESCE(u2.display_name, 'Gelöschter Benutzer') AS geaendert_name
|
||||||
|
FROM projekt_notizen n LEFT JOIN users u1 ON u1.id = n.erstellt_von LEFT JOIN users u2 ON u2.id = n.geaendert_von`;
|
||||||
|
|
||||||
|
// GET /meine-notizen — {notizen, meineProjekte:[uid]}: die Notizen meiner
|
||||||
|
// Projekte plus meine projektunabhängigen.
|
||||||
|
app.get('/meine-notizen', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const uids = Array.from(meineProjektUids(req.user));
|
||||||
|
const rows = db.prepare(NOTIZ_SELECT + ` WHERE (n.project_uid = '' AND n.erstellt_von = ?)`
|
||||||
|
+ (uids.length ? ' OR n.project_uid IN (' + uids.map(() => '?').join(',') + ')' : '')
|
||||||
|
+ ' ORDER BY n.angeheftet DESC, n.geaendert_am DESC').all(req.user.id, ...uids);
|
||||||
|
res.json({ ok: true, notizen: rows.map(notizZeile), meineProjekte: uids });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Meine Notizen laden fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// notizDarf: Schreibrecht auf eine Notiz bzw. ein Ziel. Projektunabhängig
|
||||||
|
// (uid '') nur der Verfasser; an einem Projekt das Recht proj.plan.
|
||||||
|
function notizDarf(user, uid, erstelltVonId){
|
||||||
|
if(!uid) return erstelltVonId === undefined || erstelltVonId === user.id;
|
||||||
|
return hatRecht(user, 'proj.plan');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/projekt-notizen', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const uid = String(req.query.uid || '');
|
||||||
|
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' });
|
||||||
|
const rows = db.prepare(`SELECT n.*, COALESCE(u1.display_name, 'Gelöschter Benutzer') AS erstellt_name,
|
||||||
|
COALESCE(u2.display_name, 'Gelöschter Benutzer') AS geaendert_name
|
||||||
|
FROM projekt_notizen n LEFT JOIN users u1 ON u1.id = n.erstellt_von LEFT JOIN users u2 ON u2.id = n.geaendert_von
|
||||||
|
WHERE n.project_uid = ? ORDER BY n.angeheftet DESC, n.geaendert_am DESC`).all(uid);
|
||||||
|
res.json({ ok: true, notizen: rows.map(notizZeile) });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notizen laden fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/projekt-notizen', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const b = req.body || {};
|
||||||
|
const uid = String(b.uid || '');
|
||||||
|
if(!notizDarf(req.user, uid)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen in Projekten anzulegen.' });
|
||||||
|
res.json({ ok: true, notiz: notizAnlegen(req, uid, b.titel, b.html, '') });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notiz anlegen fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT {titel?, html?, angeheftet?, basis?} — basis = geaendertAm des Stands,
|
||||||
|
// auf dem die Änderung beruht; weicht der gespeicherte ab, 409 mit dem
|
||||||
|
// aktuellen Stand (jemand anderes hat inzwischen gespeichert).
|
||||||
|
// uid (optional): einem Projekt zuordnen bzw. mit '' wieder projektunabhängig
|
||||||
|
// machen (das nur der Verfasser) — aus der Seitenleiste „Notizen“.
|
||||||
|
app.put('/projekt-notizen/:id', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const alt = notizLesen(req.params.id);
|
||||||
|
if(!alt) return res.status(404).json({ ok: false, error: 'Die Notiz gibt es nicht mehr.' });
|
||||||
|
if(!alt.uid && alt.erstelltVonId !== req.user.id) return res.status(404).json({ ok: false, error: 'Die Notiz gibt es nicht mehr.' });
|
||||||
|
if(!notizDarf(req.user, alt.uid, alt.erstelltVonId)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen zu ändern.' });
|
||||||
|
const b = req.body || {};
|
||||||
|
const neueUid = typeof b.uid === 'string' ? b.uid : alt.uid;
|
||||||
|
if(neueUid !== alt.uid){
|
||||||
|
if(!neueUid && alt.erstelltVonId !== req.user.id) return res.status(403).json({ ok: false, error: 'Nur wer die Notiz angelegt hat, kann sie vom Projekt lösen.' });
|
||||||
|
if(!notizDarf(req.user, neueUid, alt.erstelltVonId)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen in Projekten anzulegen.' });
|
||||||
|
}
|
||||||
|
if(typeof b.basis === 'string' && b.basis && b.basis !== alt.geaendertAm){
|
||||||
|
return res.status(409).json({ ok: false, konflikt: true, notiz: alt,
|
||||||
|
error: alt.geaendertVon + ' hat diese Notiz inzwischen geändert.' });
|
||||||
|
}
|
||||||
|
const titel = typeof b.titel === 'string' ? b.titel.slice(0, 300) : alt.titel;
|
||||||
|
const html = typeof b.html === 'string' ? b.html.slice(0, NOTIZ_MAX_HTML) : alt.html;
|
||||||
|
const angeheftet = typeof b.angeheftet === 'boolean' ? (b.angeheftet ? 1 : 0) : (alt.angeheftet ? 1 : 0);
|
||||||
|
// Anheften allein ist keine inhaltliche Änderung: Zeitstempel und
|
||||||
|
// Bearbeiter bleiben, damit die Sortierung „zuletzt geändert“ stimmt.
|
||||||
|
const nurPin = typeof b.titel !== 'string' && typeof b.html !== 'string';
|
||||||
|
const jetzt = nurPin ? alt.geaendertAm : new Date().toISOString();
|
||||||
|
db.prepare(`UPDATE projekt_notizen SET titel = ?, html = ?, angeheftet = ?, geaendert_am = ?, project_uid = ?
|
||||||
|
${nurPin ? '' : ', geaendert_von = ' + Number(req.user.id)} WHERE id = ?`)
|
||||||
|
.run(titel, html, angeheftet, jetzt, neueUid, alt.id);
|
||||||
|
ereignisSenden({ typ: 'notizen', uid: alt.uid }, req);
|
||||||
|
if(neueUid !== alt.uid) ereignisSenden({ typ: 'notizen', uid: neueUid }, req);
|
||||||
|
res.json({ ok: true, notiz: notizLesen(alt.id) });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notiz speichern fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/projekt-notizen/:id', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const alt = notizLesen(req.params.id);
|
||||||
|
if(!alt) return res.json({ ok: true });
|
||||||
|
if(!notizDarf(req.user, alt.uid, alt.erstelltVonId)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen zu löschen.' });
|
||||||
|
if(alt.erstelltVonId !== req.user.id && !istAdministrator(req.user)){
|
||||||
|
return res.status(403).json({ ok: false, error: 'Löschen darf nur, wer die Notiz angelegt hat, oder ein Administrator.' });
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM projekt_notizen WHERE id = ?').run(alt.id);
|
||||||
|
ereignisSenden({ typ: 'notizen', uid: alt.uid }, req);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notiz löschen fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /projekt-notizen/aus-mail {uid, text|eml} — Mail als neue Notiz, OHNE
|
||||||
|
// KI: Titel = Betreff (sonst erste Zeile), oben Absender und Datum, darunter
|
||||||
|
// der Text (zitierte Vorgänger-Mails abgeschnitten, wie bei „Termin aus Mail“).
|
||||||
|
app.post('/projekt-notizen/aus-mail', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const b = req.body || {};
|
||||||
|
const uid = String(b.uid || '');
|
||||||
|
if(!notizDarf(req.user, uid)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen in Projekten anzulegen.' });
|
||||||
|
const mail = (typeof b.eml === 'string' && b.eml.trim()) ? leseEml(b.eml)
|
||||||
|
: { betreff: '', absender: '', datum: '', text: String(b.text || '') };
|
||||||
|
const text = mailZitateAbschneiden(mail.text).slice(0, 50000);
|
||||||
|
if(!text && !mail.betreff) return res.status(400).json({ ok: false, error: 'In der Mail wurde kein Text gefunden.' });
|
||||||
|
const ersteZeile = (text.split('\n').map(z => z.trim()).find(Boolean) || 'Mail').slice(0, 120);
|
||||||
|
const escHtml = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const kopf = [mail.absender ? '<b>Von:</b> ' + escHtml(mail.absender) : '',
|
||||||
|
mail.datum ? '<b>Datum:</b> ' + escHtml(teamsDatum(mail.datum)) : ''].filter(Boolean).join('<br>');
|
||||||
|
const html = (kopf ? kopf + '<br><br>' : '') + escHtml(text).replace(/\n/g, '<br>');
|
||||||
|
res.json({ ok: true, notiz: notizAnlegen(req, uid, mail.betreff || ersteZeile, html, 'mail') });
|
||||||
|
} catch(err){
|
||||||
|
console.error('Notiz aus Mail fehlgeschlagen:', err);
|
||||||
|
res.status(500).json({ ok: false, error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Plan Checker (plan_checker_merkmale) ----------
|
// ---------- Plan Checker (plan_checker_merkmale) ----------
|
||||||
//
|
//
|
||||||
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt
|
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt
|
||||||
@@ -9091,6 +9393,7 @@ app.post('/import', requireRecht('a.import'), express.raw({ type: '*/*', limit:
|
|||||||
// Ältere Backups tragen Kommentare noch gewerkübergreifend in projects.
|
// Ältere Backups tragen Kommentare noch gewerkübergreifend in projects.
|
||||||
migrateKommentareJeGewerk();
|
migrateKommentareJeGewerk();
|
||||||
kommentareVorsatzUmstellen();
|
kommentareVorsatzUmstellen();
|
||||||
|
entitiesReparieren();
|
||||||
// Ältere Backups kennen das Feld leistungsphase noch nicht.
|
// Ältere Backups kennen das Feld leistungsphase noch nicht.
|
||||||
phasenbalkenAusTitel();
|
phasenbalkenAusTitel();
|
||||||
console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)');
|
console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)');
|
||||||
@@ -9121,7 +9424,7 @@ app.get('/', (req,res)=>{
|
|||||||
// Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404
|
// Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404
|
||||||
// enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um
|
// enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um
|
||||||
// (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr.
|
// (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr.
|
||||||
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
|
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Notizen','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
|
||||||
// Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen)
|
// Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen)
|
||||||
const PLAN_SLUG = 'Projektplan';
|
const PLAN_SLUG = 'Projektplan';
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user