Beteiligte in die Projekteinstellungen, TODOs aus Protokoll, Bewegung

Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und
nur noch in den Projekteinstellungen editierbar (planCycleAssign()).
Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis
dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen.

Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF
wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben
abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst
unabhaengig von den gespeicherten Protokollen - nichts davon wird
abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die
Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter
Import leicht 20 Vorschlaege liefert.

Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt
(Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog.
Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan
herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe.
plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit
Zeilenzahl und Umsortierung nicht auseinanderlaufen.

KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt
(Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der
echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen
Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem
alten Limit und brach bei jeder Schwankung ab.

Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root,
angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die
Akzentleiste ist ein inset-Schatten statt eines positionierten
Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion
war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens.

Plan Checker: optionaler Projektabgleich des Plankopfs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5 committed 2026-10-17 15:14:26 +02:00
1 parent 6ba93b2a92
commit 93505e0e48
17 files changed
+1883 -334

No files matched your search

+233 -38
View File
@@ -172,14 +172,20 @@ user-facing text.
│ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts │ │ ├── plan-state.js # PlanApp-Zustand, Datenladepath, savePlan, Toasts
│ │ ├── plan-sections.js # Bereichs-Navigation (showPlanSection, URL-Fragment) │ │ ├── plan-sections.js # Bereichs-Navigation (showPlanSection, URL-Fragment)
│ │ │ # + Dashboard-Karten (renderPlanDashboard) │ │ │ # + Dashboard-Karten (renderPlanDashboard)
│ │ ├── plan-info.js # Status/Kommentar, Gruppe, amtierende LPH, LPH-%, │ │ ├── plan-info.js # Status/Kommentar, Gruppe, amtierende LPH, LPH-%
│ │ │ # Beteiligten-Matrix (verteilt auf Dashboard+Einstellungen) │ │ │ # (Dashboard-Bereich; die Beteiligten-Matrix ist
│ │ │ # entfallen, siehe plan-settings.js)
│ │ ├── plan-settings.js # Bereich "Einstellungen": Projektangaben, Honorar, │ │ ├── plan-settings.js # Bereich "Einstellungen": Projektangaben, Honorar,
│ │ │ # beauftragte Leistungsphasen (renderPlanSettings) │ │ │ # beauftragte Leistungsphasen, Beteiligten-Matrix
│ │ │ # (renderPlanSettings, planCycleAssign)
│ │ ├── plan-tasks.js # Bereich "Aufgaben": Aufgaben-/Meilensteinliste │ │ ├── plan-tasks.js # Bereich "Aufgaben": Aufgaben-/Meilensteinliste
│ │ │ # (renderPlanTasks; öffnet dieselben Dialoge wie das GANTT) │ │ │ # (renderPlanTasks; öffnet dieselben Dialoge wie das GANTT)
│ │ ├── plan-history.js # Kommentar-Verlauf über alle Sitzungen (+ Vorwochen-Helfer) │ │ ├── plan-history.js # Kommentar-Verlauf über alle Sitzungen (+ Vorwochen-Helfer)
│ │ ├── plan-templates.js # Aufgaben aus LPH-Vorlagen (lph_templates) │ │ ├── plan-templates.js # Aufgaben aus LPH-Vorlagen (lph_templates)
│ │ ├── plan-protokoll-todos.js # "+ TODOs aus Protokoll": externes PDF
│ │ │ # per KI einlesen + Aufgaben daraus ableiten —
│ │ │ # unabhängig von plan-protokoll.js, nichts
│ │ │ # davon wird gespeichert
│ │ ├── plan-honorar.js # Terminplan aus Honorar + beauftragten LPH errechnen │ │ ├── plan-honorar.js # Terminplan aus Honorar + beauftragten LPH errechnen
│ │ ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen) │ │ ├── plan-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen)
│ │ ├── plan-gantt.js # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load │ │ ├── plan-gantt.js # renderPlanGantt, Abhängigkeitslinien, Zoom, Ressourcen-Load
@@ -809,8 +815,8 @@ on the JSON endpoints.
The `js/plan/*` modules of the Projektbereich sit right after `gantt.js` The `js/plan/*` modules of the Projektbereich sit right after `gantt.js`
(`plan-state → plan-sections → plan-info → plan-history → (`plan-state → plan-sections → plan-info → plan-history →
plan-build-gantt → plan-gantt → plan-drag → plan-modals → plan-tasks → plan-build-gantt → plan-gantt → plan-drag → plan-modals → plan-tasks →
plan-files → plan-templates → plan-honorar → plan-settings → plan-files → plan-templates → plan-protokoll-todos → plan-honorar →
plan-schedule`): they must come after `gantt.js`, whose `buildGanttHtml()` plan-settings → plan-schedule`): they must come after `gantt.js`, whose `buildGanttHtml()`
`plan-build-gantt.js` wraps, and `plan-history` must stay before `plan-build-gantt.js` wraps, and `plan-history` must stay before
`plan-build-gantt`, which uses its previous-week helpers. None of them `plan-build-gantt`, which uses its previous-week helpers. None of them
collides with a main-module global — every one runs in an IIFE over collides with a main-module global — every one runs in an IIFE over
@@ -1006,34 +1012,60 @@ authoritative, and two people could change the same field in different places.
The split follows what kind of data it is: The split follows what kind of data it is:
- **Laufende Erfassung → Wochenbericht only.** The session comment - **Laufende Erfassung → Wochenbericht only.** The session comment
(`p.status`), the progress per Leistungsphase (`p.lph`) and the assignment of (`p.status`) and the progress per Leistungsphase (`p.lph`) are produced in
people (`p.assign`) are produced in the weekly meeting. They are editable the weekly meeting. They are editable **only** in the Wochenbericht.
**only** in the Wochenbericht.
- **Stammdaten → Projektbereich only.** Projektnummer, Bezeichnung, - **Stammdaten → Projektbereich only.** Projektnummer, Bezeichnung,
Schlagwörter, Gruppe, amtierende Leistungsphase, Auftragsstatus, Honorar, the Schlagwörter, Gruppe, amtierende Leistungsphase, Auftragsstatus, Honorar, the
beauftragte Leistungsphasen and the Aufwands-Gewichtung live in the beauftragte Leistungsphasen, the Aufwands-Gewichtung, and **the assignment of
Projektbereich (`einstellungen` section, `js/plan/plan-settings.js` plus people (`p.assign`)** live in the Projektbereich (`einstellungen` section,
`js/plan/plan-info.js` for Gruppe/amtierende LPH). `js/plan/plan-settings.js` plus `js/plan/plan-info.js` for Gruppe/amtierende
LPH). `p.assign` moved here on Erik's explicit request ("In den
Einstellungen der Projekte will ich auch die Projektbeteiligten einstellen
können"), fully relocated rather than duplicated — he chose that option
outright over making it editable in both places, to keep this same "Ein Ort
je Angabe" invariant for the new field too. It is edited via
`planCycleAssign(personId, role)` in `plan-settings.js`, filling
`#planAssignMatrix` — deliberately **not** reusing `cycleAssign()`
(removed, see below) or going through `currentWeek()`/`save()` at all: the
Projektbereich can be deep-linked to an older week, or be a Planung-Sandbox
plan with no `currentWeek()` entry at all (same trap `planOpenAufwand()`
already guards against), so `planCycleAssign()` mutates `app.project`
directly and calls `app.savePlan()`, exactly like every other Stammdatum in
this file.
- **Everywhere else: display plus a way there.** `renderBoardDrawer()` takes - **Everywhere else: display plus a way there.** `renderBoardDrawer()` takes
`opts.nurAnsicht` (set by `BOARD_OPTS` in `js/board/board.js`): same card, `opts.nurAnsicht` (set by `BOARD_OPTS` in `js/board/board.js`): same card,
same values, same order — but no input fields, no clickable avatars, and a same values, same order — but no input fields and a `.drawer-verweis`
`.drawer-verweis` underneath with the button "Im Wochenbericht bearbeiten →". underneath with the button "Im Wochenbericht bearbeiten →" for Kommentar/
The Projektbereich does the same via `setzeBerichtVerweis()` Fortschritt. The Beteiligten-Zuordnung (`roleRows`) is now **always**
(`js/plan/plan-info.js`), filling `#planStatusVerweis`, `#planLphVerweis` rendered read-only inside `renderBoardDrawer()` — in the Projektboard AND
and `#planAssignVerweis`. in the Wochenbericht, regardless of `opts.nurAnsicht` — with its own
"In den Projekteinstellungen bearbeiten →" button next to it
(`springZuEinstellungen(pid)`, `js/gantt.js`). The Projektbereich's
Dashboard section does the equivalent for Kommentar/Fortschritt via
`setzeBerichtVerweis()` (`js/plan/plan-info.js`), filling
`#planStatusVerweis`/`#planLphVerweis`; it has no such verweis for
Beteiligte any more, since that matrix is edited right there in
Einstellungen now, not somewhere else.
- **The link is a real jump**, not a hint: `springZumWochenbericht(uid)` - **The link is a real jump**, not a hint: `springZumWochenbericht(uid)`
(`js/plan/plan-info.js`, also used by `renderBerichtVerweis()` in (`js/plan/plan-info.js`, also used by `renderBerichtVerweis()` in
`board.js`) sets `berichtAuswahl` **before** `showTab('wochenbericht')` and `board.js`) sets `berichtAuswahl` **before** `showTab('wochenbericht')` and
therefore lands on that very project. Without it the user would have to find therefore lands on that very project. Without it the user would have to find
it again in a list of thirty. (`berichtAuswahl` is a global `let` binding it again in a list of thirty. (`berichtAuswahl` is a global `let` binding
from `js/wochenbericht/wochenbericht.js` — classic scripts, no modules, so from `js/wochenbericht/wochenbericht.js` — classic scripts, no modules, so
assigning it from another file works.) assigning it from another file works.) `springZuEinstellungen(pid)` is the
same idea in the other direction: it sets `location.hash = 'einstellungen'`
**before** calling `openProjectPlan(pid)`, the same "set the hash first"
trick `wandSpringeZuProjekt()` already used, since `showTab('plan')`'s own
`updateRouteUrl()` preserves an already-set hash.
- **Consequence:** `planStatusBlur()`, `setPlanLph()` and `setPlanAssign()` in - **Consequence:** `planStatusBlur()`, `setPlanLph()` and `setPlanAssign()` in
`js/plan/plan-info.js` are **gone** — they had no caller left. The status `js/plan/plan-info.js` are **gone** — they had no caller left. The status
editor's formatting toolbar (`formatStatus()` buttons) went with them in the editor's formatting toolbar (`formatStatus()` buttons) went with them in the
Projektbereich; the Wochenbericht keeps the full editor. `editLph()`, Projektbereich; the Wochenbericht keeps the full editor. `editLph()` and
`cycleAssign()` and `saveBoardComment()` in `js/board/board.js` are `saveBoardComment()` in `js/board/board.js` are untouched — the
untouched — the Wochenbericht's editing hangs off them. Wochenbericht's editing hangs off them. `cycleAssign()` in `js/board/board.js`
is now **also gone** (the Beteiligten relocation's own consequence,
mirroring the three functions above) — it had exactly one caller
(`roleRows`'s avatar `onclick`), which is now unconditionally read-only.
- **Honorar and beauftragte Leistungsphasen stay editable in TWO places** on - **Honorar and beauftragte Leistungsphasen stay editable in TWO places** on
purpose (Projekteinstellungen and the "Terminplan aus Honorar" dialog, so the purpose (Projekteinstellungen and the "Terminplan aus Honorar" dialog, so the
dialog remains usable on its own); both write through the same functions in dialog remains usable on its own); both write through the same functions in
@@ -1230,28 +1262,129 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
very same `openTodoModal()`/`openMilestoneModal()` as the Gantt, and the very same `openTodoModal()`/`openMilestoneModal()` as the Gantt, and the
indices match because the list keeps `plan.todos`/`plan.milestones` order. indices match because the list keeps `plan.todos`/`plan.milestones` order.
The create buttons (`+ Aufgabe`, `+ Meilenstein`, `+ Vorlage`, The create buttons (`+ Aufgabe`, `+ Meilenstein`, `+ Vorlage`,
`+ Terminplan`) exist twice — in the Gantt toolbar and above the list, the `+ Terminplan`, `+ TODOs aus Protokoll`) exist twice — in the Gantt
latter with an `…BtnList` id suffix; `renderPlanPage()` hides both sets on toolbar and above the list, the latter with an `…BtnList` id suffix;
locked weeks. `renderPlanPage()` hides both sets on locked weeks.
**A todo is only a Gantt row when it has BOTH a start and an end date.** **"+ TODOs aus Protokoll"** (`#protokollTodosBtn`/`#protokollTodosBtnList`,
`js/plan/plan-protokoll-todos.js`) is a second, genuinely independent way
to turn a meeting protocol into tasks — Erik's explicit request: "ein
Feature... das die Protokolle per KI einliest und dann aus den
Protokollpunkten Aufgaben für die Aufgabenseite im Projekt erzeugt". It
is deliberately **unrelated to `project_protokolle`/`protokoll_punkte`
and to the Protokolle section** (`js/plan/plan-protokoll.js`) — Erik's
own words: "Es sollen externe Protokolle im PDF-Format sein, die durch
die KI eingelesen werden", i.e. an ad-hoc upload of a protocol that may
never become (or already exist as) a stored Protokoll of this project,
not a second entry point into the same table. Nothing is persisted on
either end: no `project_protokolle` row, no file under
`sessions/uploads/` — the PDF and every intermediate result live only in
the request/response and the open dialog, the same "ad-hoc tool, no
archive" model as the Plan Checker (`### Protokolle` below covers the
always-persisted Protokoll-Import; this is its lightweight opposite).
Three steps in one dialog (`#protokollTodosModal`), picked by Erik from a
three-way design comparison (Taste-Skill artifact, "A — Schritt-Dialog"
vs. a wide split-panel "Werkbank" vs. an inline card in the task list):
a persistent chrome — title, a 3-segment progress bar
(`#protokollTodosSteps`), and a fixed Zurück/Abbrechen/Weiter footer —
stays on screen across all three steps, with only
`#protokollTodosBody`'s content swapped per step (same "Container-Div
wird je Phase neu befüllt" idea `applyTemplate()`'s dialog already uses).
The right-hand "Weiter" button both advances the step AND fires that
step's network call in one click (`protokollTodosWeiter()`); "Zurück"
(`protokollTodosZurueck()`) only ever re-renders already-held module
state, no re-fetch — except a re-shown file `<input>` can't be
re-populated from script, so going back past step 0 means re-picking the
file. A genuine KI failure (`zeigeFehler()`, red) is kept visually
distinct from a successful-but-degraded fallback
(`zeigeFallbackHinweis()`, the same amber `.protokoll-ki-fallback-banner`
the Protokoll-Import uses) — a fallback is an accepted outcome, not an
error.
(1) upload a PDF + pick a provider → `POST
/plan-todos-aus-protokoll/einlesen` reuses `erkennePunkteMitKI()`/
`erkennePunkteAusText()` **unchanged** (server.js) to extract points,
returned straight to the client, nowhere written to a table; (2) the
extracted points appear as a checklist (every `status:'offen'` point
pre-checked, `erledigt`/`info` pre-unchecked but still selectable — one
surface covers both "whole protocol" and "just this one point"); the
checked point objects (already in the browser, no server round-trip to
refetch) go to `POST /plan-todos-aus-protokoll/aufgaben-vorschlag`,
which runs the **second**, independent KI step, `leiteAufgabenMitKI()` →
`leiteAufgabenMitChatGPT`/`leiteAufgabenMitClaude` (server.js, same
`fetchMitTimeout()`/`LOKAL_DISPATCHER`/`reasoning_effort` plumbing as
`erkennePunkteMitChatGPT`/`Claude`, own `KI_AUFGABEN_SCHEMA`/
`KI_AUFGABEN_SYSTEM_PROMPT`) — this step *judges* the points rather than
copying them: several points about the same action can collapse into
one task, a purely informational point yields none; on any KI failure
(or no provider chosen) the route falls back to a direct 1:1 mapping
(`erkennungsart:'direkt'`), the same "always succeed, possibly degrade"
policy as the Protokoll-Import; (3) the proposed tasks appear as a
compact row list (`renderVorschlaegeHtml()`) — Erik's pick from a second,
narrower Taste-Skill comparison scoped to just this step: a real test run
had already produced 19 proposals from one protocol, and a full card per
row would have been nothing but scrolling. Each row shows title plus a
Fälligkeit/Zuständig badge; the full description, date field, and
Zuständigen-Auswahl only appear once the row is tapped
(`.protokoll-todo-vorschlag.open`, `.pt-row-detail`). A selection bar
above the list (`N von M ausgewählt`, "Alle"/"Keine" —
`protokollTodosAuswahlAlle()`) avoids unchecking rows one by one at that
volume. Zuständig is pre-matched against `projectAssignees(app.project,
app.week)` by a simple case-insensitive name match,
`matchePersonenVonName()` — an external protocol's "Zuständig" is often
an outside firm with no match at all, which is the expected common case,
not a bug. Confirming pushes one `plan_todo` per checked row
straight into `app.ensurePlanLocal().todos` **with both `start` and
`end` always set** (today / the given Fälligkeit or today) — exactly
`applyTemplate()`'s/`saveTodoAusKommentar()`'s push shape — specifically
so every created task is a Gantt row immediately, not a dateless
checklist item (see the GANTT-row rule right above); `app.savePlan()`
then `fitPlanGanttRange()` (again mirroring `applyTemplate()`) so the
visible GANTT window actually includes the new bars instead of only
showing them in the plain Aufgaben list.
**A todo is only a Gantt row when it has BOTH a start and an end date
AND `showInGantt` is not explicitly `false`.**
`plan.todos` is the one shared list either way — every todo always shows `plan.todos` is the one shared list either way — every todo always shows
up here in Aufgaben, with its assignees (`t.assignees`, "Zuständige" up here in Aufgaben, with its assignees (`t.assignees`, "Zuständige"
chips in `openTodoModal()`, unrelated to whether it has a schedule) — but chips in `openTodoModal()`, unrelated to whether it has a schedule) — but
`plan-build-gantt.js` (the `buildGanttHtml()` extension that inserts `plan-build-gantt.js` (the `buildGanttHtml()` extension that inserts
TODO/milestone rows under the project row) only inserts a row for a todo TODO/milestone rows under the project row) only inserts a row for a todo
with `todo.start && todo.end`; a title-only todo (created via `+ Aufgabe` with `todo.start && todo.end && todo.showInGantt !== false`; a
or `+ TODO` with the date range left empty — nothing in `saveTodoEdit()` title-only todo (created via `+ Aufgabe` or `+ TODO` with the date range
requires dates, only the title) stays a pure checklist item that appears left empty — nothing in `saveTodoEdit()` requires dates, only the title)
solely here, never in the Gantt/Terminplan. The Aufgaben row for such a stays a pure checklist item that appears solely here, never in the
todo shows "ohne Termin" in place of a date range Gantt/Terminplan. The Aufgaben row for such a todo shows "ohne Termin" in
(`.plan-task-no-schedule`). Giving it a start/end later (editing the same place of a date range (`.plan-task-no-schedule`). Giving it a start/end
todo through either view) makes a Gantt row for it appear on the next later (editing the same todo through either view) makes a Gantt row for
render — same object, no separate "convert to scheduled" action needed. it appear on the next render — same object, no separate "convert to
scheduled" action needed.
**`showInGantt`** (`plan_todos.show_in_gantt`, `DEFAULT 1`,
`migrateAddPlanTodoShowInGantt()`) is the independent half of that
condition — Erik's explicit request, after "TODOs aus Protokoll"
(`plan-protokoll-todos.js`) showed that a single import can easily create
15-25 dated tasks at once, which — before this field existed — were all
unconditionally forced into the Gantt as bars. A checkbox "Im Terminplan
(GANTT) anzeigen" in the TODO-edit dialog (`#todoEditShowInGantt`,
`App.html`, read/written in `openTodoModal()`/`saveTodoEdit()`,
`js/plan/plan-modals.js`) lets a *dated* task still exist (tracked under
Aufgaben, counted on "Meine Aufgaben", included in dependency-based
Auto-Schedule/critical-path) without drawing a bar. Checked by default;
`!== false` rather than `=== true` everywhere it's read, so a todo from
before this column existed (or any object missing the field) keeps
showing exactly as before — no retroactive behavior change. Carried
through "Speichern & Neu" (`openTodoModal(null, {..., showInGantt})`)
like the other frame fields (Zeitraum/Zuständige), so adding several
hidden-from-Gantt tasks in a row doesn't mean re-unchecking it every
time. **Not** touched by `plan-schedule.js`'s Auto-Schedule/critical-path
(`t.start && t.end`, unchanged) or `plan-gantt.js`'s ghost-date overlay —
a hidden todo is still a real, scheduled task for every purpose except
drawing its own bar.
`reorderPlanRows()` (`js/plan/plan-drag.js`) accounts for this: its `reorderPlanRows()` (`js/plan/plan-drag.js`) accounts for this: its
DOM-row-count safety check and the reordering itself only ever touch DOM-row-count safety check and the reordering itself only ever touch
todos that have `start && end` (`scheduledIds`) — a title-only todo has todos that have `start && end && showInGantt !== false`
no drag handle in the Gantt to begin with and keeps its exact position in (`scheduledIds`) — exactly the same condition as the insertion above, so
`plan.todos` untouched by any Gantt row drag. the count of draggable DOM rows and `scheduledIds.size` never drift
apart. A title-only or Gantt-hidden todo has no drag handle in the Gantt
to begin with and keeps its exact position in `plan.todos` untouched by
any Gantt row drag.
- **Leistung** — Leistungsstand & Abrechnung (`renderPlanLeistung()`, - **Leistung** — Leistungsstand & Abrechnung (`renderPlanLeistung()`,
`js/plan/plan-leistung.js`): the value actually earned, per beauftragte `js/plan/plan-leistung.js`): the value actually earned, per beauftragte
Leistungsphase, as **Honorar × HOAI-Anteil der Phase × Fortschritt der Leistungsphase, as **Honorar × HOAI-Anteil der Phase × Fortschritt der
@@ -1273,9 +1406,12 @@ step into a project explicit. Everything lives in `public/js/auftrag.js`:
"### Protokolle" below for the data model and the "Fortschreiben" "### Protokolle" below for the data model and the "Fortschreiben"
mechanism. mechanism.
- **Einstellungen** — project number/name (display only — the page is - **Einstellungen** — project number/name (display only — the page is
addressed by them), Gruppe, amtierende Leistungsphase, Beteiligten-Matrix addressed by them), Gruppe, amtierende Leistungsphase (both still
(all still rendered by `plan-info.js`), plus Honorar and the **beauftragte rendered by `plan-info.js`), plus Honorar, the **beauftragte
Leistungsphasen** (`renderPlanSettings()`, `js/plan/plan-settings.js`). Leistungsphasen** and the **Beteiligten-Matrix**
(`renderPlanSettings()`/`planCycleAssign()`, `js/plan/plan-settings.js` —
the Matrix moved here from a read-only `plan-info.js` display once it
became genuinely editable, see "Ein Ort je Angabe" above).
- `p.honorar`/`p.beauftragteLP` can now be edited in TWO places: here and in - `p.honorar`/`p.beauftragteLP` can now be edited in TWO places: here and in
the "Terminplan aus Honorar" dialog (which keeps them so it stays usable on the "Terminplan aus Honorar" dialog (which keeps them so it stays usable on
its own). Both write through `setHonorarBetrag()`/`toggleBeauftragteLp()` in its own). Both write through `setHonorarBetrag()`/`toggleBeauftragteLp()` in
@@ -3235,6 +3371,65 @@ across Gewerke or users. Erik's own words: "macht so keinen Sinn mehr."
no special-casing needed. A redirect `GET /Test.html`-style old link isn't no special-casing needed. A redirect `GET /Test.html`-style old link isn't
needed here since nothing ever pointed at this tab before it existed. needed here since nothing ever pointed at this tab before it existed.
## Bewegung (Hover/Klick-Rückmeldung)
The app's interaction feedback is **token-driven and lives in one place**:
five variables in `:root` (`styles.css`), applied by a clearly-marked
"BEWEGUNG" block at the **end** of `styles.css` plus a smaller companion
block at the end of `plan-styles.css` for the Projektbereich-only row types.
Every rule reads through `var()`; changing the feel is a change to those
five values, not to the components.
```
--dur: 150ms kurze Rückmeldung (Fläche, Farbe, Anheben, Druck)
--dur-lg: 200ms grössere Zustandswechsel (Dialog, Aufklappen)
--ease-out cubic-bezier(.2,.8,.2,1), schnell los / weich aus
--row-lift: -1px Hub einer anklickbaren Zeile beim Zeigen
--press: .97 Stauchung beim Drücken
```
Erik picked this level ("Spürbar") from a three-way live comparison
(dezent / spürbar / lebendig) built with the Taste-Skill. **Level 3's
entrance animations were deliberately NOT taken**: with ~30 projects in a
list that re-renders on every save, rows animating in on each render is
unrest, not polish. The rule that follows from that: the surface moves
**only in response to pointing or clicking**, never on its own, and never
in a loop.
- **Row lift + accent rail** on clickable rows (`.board-row-main`,
`.bericht-rail-item`, `.planung-row`, `.dash-rand-zeile`, and in
`plan-styles.css` `.todo-depends-item`, `.pt-row-main`, `.plan-task-row`).
The rail is an **`inset` box-shadow growing 0 → 3px**, deliberately NOT a
`::before` with `position:absolute`: `.board-row` is `draggable`
(`js/projects/projects.js`), and an extra positioned element or a new
stacking context on a dragged row can shift the drag image and hit
targets. An inset shadow touches neither layout nor stacking. For the
same reason the transform sits on the **inner** `.board-row-main`, never
on the draggable `.board-row` itself (verified: the draggable element
keeps `transform: none`).
- **`.protokoll-liste-zeile` is the one exception** and must stay one: it
already carries `border-left: 3px solid transparent`, which its
`.ist-aktiv` state colours. It therefore animates *that existing border*
instead of adding the inset shadow. Using both would paint 6 px of accent
instead of 3.
- **Dialogs** use `animation`, not `transition`: `.modal-bg` is toggled
`display:none` ⇄ `flex` from JS, and a `display` change is not
transitionable, whereas an animation restarts every time the element is
rendered again (`modal-bg-auf` / `modal-auf`).
- **`prefers-reduced-motion` was missing from this codebase entirely** and
is now handled at the end of `styles.css`. Because the level is
token-driven, the block simply flips the tokens (`--row-lift: 0px`,
`--press: 1`, durations to 1ms); a blanket
`transition-duration/animation-duration: 1ms !important` additionally
catches the ~60 older rules that still hard-code their own timings.
- **Keyboard focus** got explicit `:focus-visible` outlines on the same
rows/chips. The lift and press effects answer mouse and touch only;
without this the rework would have left keyboard use worse off than
before.
- **The Wandtafel (`body.wandtafel-aktiv`) is deliberately excluded** — a
kiosk touch monitor has no hover, and that screen carries its own
`--dash-*` design anyway.
## Docker ## Docker
- `docker compose up --build` from the repo root. Compose maps `3003:3003`, - `docker compose up --build` from the repo root. Compose maps `3003:3003`,
+51 -2
View File
@@ -323,6 +323,14 @@
</div> </div>
</div> </div>
<div class="pc-section">
<label class="pc-label">Projekt (optional)</label>
<select id="planCheckerProjekt" class="pc-select" style="width:100%">
<option value="">Kein Projekt wählen</option>
</select>
<p class="hint" style="margin:4px 0 0">Prüft zusätzlich, ob der Plankopf zu diesem Projekt passt (Nummer/Bezeichnung).</p>
</div>
<div class="pc-section"> <div class="pc-section">
<label class="pc-label">KI-Anbieter</label> <label class="pc-label">KI-Anbieter</label>
<select id="planCheckerAnbieter" class="pc-select" style="width:100%"></select> <select id="planCheckerAnbieter" class="pc-select" style="width:100%"></select>
@@ -879,6 +887,7 @@
<button onclick="addTodo()" id="addTodoBtn">+ TODO</button> <button onclick="addTodo()" id="addTodoBtn">+ TODO</button>
<button onclick="openTemplateModal()" id="templateBtn" title="Standard-Aufgaben einer Leistungsphase einfügen">+ Vorlage</button> <button onclick="openTemplateModal()" id="templateBtn" title="Standard-Aufgaben einer Leistungsphase einfügen">+ Vorlage</button>
<button onclick="openHonorarModal()" id="honorarBtn" title="Terminplan aus Honorar und Leistungsphasen-Anteilen errechnen">+ Terminplan</button> <button onclick="openHonorarModal()" id="honorarBtn" title="Terminplan aus Honorar und Leistungsphasen-Anteilen errechnen">+ Terminplan</button>
<button onclick="openProtokollTodosModal()" id="protokollTodosBtn" title="Externes Besprechungsprotokoll (PDF) per KI einlesen und daraus Aufgaben ableiten">+ TODOs aus Protokoll</button>
<button onclick="autoScheduleTodos()" id="autoScheduleBtn" title="Aufgaben nach Abhängigkeiten automatisch anordnen">Auto-Schedule</button> <button onclick="autoScheduleTodos()" id="autoScheduleBtn" title="Aufgaben nach Abhängigkeiten automatisch anordnen">Auto-Schedule</button>
<button onclick="toggleCriticalPath()" id="criticalPathBtn" title="Kritischen Pfad ein-/ausblenden">Kritischer Pfad</button> <button onclick="toggleCriticalPath()" id="criticalPathBtn" title="Kritischen Pfad ein-/ausblenden">Kritischer Pfad</button>
<!-- Nur für Sandbox-Pläne der Planung sichtbar (renderPlanPage(), <!-- Nur für Sandbox-Pläne der Planung sichtbar (renderPlanPage(),
@@ -902,6 +911,7 @@
<button onclick="addPlanMilestone()" id="addMilestoneBtnList">+ Meilenstein</button> <button onclick="addPlanMilestone()" id="addMilestoneBtnList">+ Meilenstein</button>
<button onclick="openTemplateModal()" id="templateBtnList" title="Standard-Aufgaben einer Leistungsphase einfügen">+ Vorlage</button> <button onclick="openTemplateModal()" id="templateBtnList" title="Standard-Aufgaben einer Leistungsphase einfügen">+ Vorlage</button>
<button onclick="openHonorarModal()" id="honorarBtnList" title="Terminplan aus Honorar und Leistungsphasen-Anteilen errechnen">+ Terminplan</button> <button onclick="openHonorarModal()" id="honorarBtnList" title="Terminplan aus Honorar und Leistungsphasen-Anteilen errechnen">+ Terminplan</button>
<button onclick="openProtokollTodosModal()" id="protokollTodosBtnList" title="Externes Besprechungsprotokoll (PDF) per KI einlesen und daraus Aufgaben ableiten">+ TODOs aus Protokoll</button>
<button class="secondary" onclick="autoScheduleTodos()" id="autoScheduleBtnList" title="Aufgaben nach Abhängigkeiten automatisch anordnen">Auto-Schedule</button> <button class="secondary" onclick="autoScheduleTodos()" id="autoScheduleBtnList" title="Aufgaben nach Abhängigkeiten automatisch anordnen">Auto-Schedule</button>
</div> </div>
@@ -1056,9 +1066,8 @@
<div class="plan-panel plan-panel-wide"> <div class="plan-panel plan-panel-wide">
<h3 class="plan-panel-title">Beteiligte</h3> <h3 class="plan-panel-title">Beteiligte</h3>
<p class="hint" style="margin-top:0">Wer diesem Projekt zugeordnet ist — kräftig die Hauptverantwortung, blass die Unterstützung. Geändert wird die Zuordnung im Wochenbericht.</p> <p class="hint" style="margin-top:0">Wer diesem Projekt zugeordnet ist — kräftig die Hauptverantwortung, blass die Unterstützung. Zum Ändern anklicken.</p>
<div class="board-role-mini" id="planAssignMatrix"></div> <div class="board-role-mini" id="planAssignMatrix"></div>
<div class="plan-verweis-row" id="planAssignVerweis"></div>
</div> </div>
</div> </div>
</section> </section>
@@ -1250,6 +1259,41 @@
</div> </div>
</div> </div>
<!-- "TODOs aus Protokoll" (js/plan/plan-protokoll-todos.js): liest ein
EXTERNES Besprechungsprotokoll (PDF) per KI ein und leitet daraus
Aufgaben für dieses Projekt ab. Bewusst unabhängig vom Protokoll-Import
oben: nichts davon wird gespeichert, kein Bezug zu project_protokolle —
ein reines Ad-hoc-Werkzeug wie der Plan Checker. Drei Schritte
(Hochladen → Punkte wählen → Aufgaben bestätigen) in EINEM Dialog mit
sichtbarem Fortschritt (#protokollTodosSteps) und fester Zurück/
Abbrechen/Weiter-Fußzeile — Titel, Fortschritt und der Inhalt von
#protokollTodosBody werden je Schritt neu gerendert, der Rahmen bleibt
stehen. Entwurfsrichtung "A — Schritt-Dialog" aus dem Entwurfsvergleich
(Taste-Skill), von Erik gewählt. -->
<div class="modal-bg" id="protokollTodosModal" style="display:none">
<div class="modal modal-wide">
<h2 id="protokollTodosTitle">Externes Protokoll einlesen</h2>
<p class="hint" id="protokollTodosSubtitle" style="margin-top:-4px"></p>
<div class="protokoll-todos-steps" id="protokollTodosSteps">
<div class="seg" data-i="0"></div><div class="seg" data-i="1"></div><div class="seg" data-i="2"></div>
</div>
<div class="protokoll-ki-fallback-banner" id="protokollTodosFallback" hidden></div>
<div id="protokollTodosBody"></div>
<span class="field-error" id="protokollTodosError" hidden></span>
<div class="protokoll-import-loading" id="protokollTodosLoading" hidden>
<span class="protokoll-import-spinner" aria-hidden="true"></span>
<span id="protokollTodosLoadingText">Wird verarbeitet …</span>
</div>
<div class="toolbar" style="margin-top:14px; justify-content:space-between">
<button type="button" class="secondary" id="protokollTodosBackBtn" onclick="protokollTodosZurueck()" disabled>Zurück</button>
<div style="display:flex; gap:8px">
<button type="button" class="secondary" onclick="closeProtokollTodosModal()">Abbrechen</button>
<button type="button" id="protokollTodosPrimaryBtn" onclick="protokollTodosWeiter()">Einlesen</button>
</div>
</div>
</div>
</div>
<!-- Vorlagen-Dialog: Standard-Aufgaben einer Leistungsphase einfügen. <!-- Vorlagen-Dialog: Standard-Aufgaben einer Leistungsphase einfügen.
Eigenständiger Dialog — der frühere gemeinsame Dialog mit Modus-Umschalter Eigenständiger Dialog — der frühere gemeinsame Dialog mit Modus-Umschalter
("Aus Vorlage" / "Aus Honorar") wurde in zwei getrennte Dialoge aufgeteilt, ("Aus Vorlage" / "Aus Honorar") wurde in zwei getrennte Dialoge aufgeteilt,
@@ -1401,6 +1445,10 @@
<input type="hidden" id="todoEditEnd"> <input type="hidden" id="todoEditEnd">
<span class="field-error" id="todoEditRangeError" hidden></span> <span class="field-error" id="todoEditRangeError" hidden></span>
<p class="hint">Ohne Zeitraum erscheint die Aufgabe nur unter „Aufgaben“, nicht im Terminplan.</p> <p class="hint">Ohne Zeitraum erscheint die Aufgabe nur unter „Aufgaben“, nicht im Terminplan.</p>
<label style="display:flex; align-items:center; gap:6px; margin-top:6px; font-weight:normal">
<input type="checkbox" id="todoEditShowInGantt" checked>
Im Terminplan (GANTT) anzeigen
</label>
</div> </div>
<div class="form-row"> <div class="form-row">
@@ -1766,6 +1814,7 @@
<script src="/js/plan/plan-files.js?v=3"></script> <script src="/js/plan/plan-files.js?v=3"></script>
<script src="/js/plan/plan-protokoll.js?v=17"></script> <script src="/js/plan/plan-protokoll.js?v=17"></script>
<script src="/js/plan/plan-templates.js?v=4"></script> <script src="/js/plan/plan-templates.js?v=4"></script>
<script src="/js/plan/plan-protokoll-todos.js?v=1"></script>
<script src="/js/plan/plan-honorar.js?v=6"></script> <script src="/js/plan/plan-honorar.js?v=6"></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
+77 -74
View File
@@ -9,17 +9,20 @@
// Detailpanel auf (voller Status-Editor, vollständige Rollen-Zuordnung, alle // Detailpanel auf (voller Status-Editor, vollständige Rollen-Zuordnung, alle
// LPH-Balken). Sie wird von `App.html` nach `js/weeks.js` (vor // LPH-Balken). Sie wird von `App.html` nach `js/weeks.js` (vor
// `js/people/people.js`) geladen und stellt globale Funktionen wie // `js/people/people.js`) geladen und stellt globale Funktionen wie
// `renderBoard()`, `toggleBoardRow()`, `cycleAssign()`, // `renderBoard()`, `toggleBoardRow()`,
// und `editLph()` bereit. Sie nutzt `currentWeek()`/ // und `editLph()` bereit. Sie nutzt `currentWeek()`/
// `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`, // `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`,
// `assignKey()`, `statusFocus()`, `sanitizeStatusHtml()` und // `assignKey()`, `statusFocus()`, `sanitizeStatusHtml()` und
// `getProgressColor()` aus `js/utils.js` sowie `openProjectPlan()` aus // `getProgressColor()` aus `js/utils.js` sowie `openProjectPlan()` aus
// `js/gantt.js` — navigiert bewusst im selben Tab (kein window.open() mehr), // `js/gantt.js` — navigiert bewusst im selben Tab (kein window.open() mehr),
// ein Klick auf den Rest der Zeile klappt stattdessen nur das Detailpanel // ein Klick auf den Rest der Zeile klappt stattdessen nur das Detailpanel
// auf/zu (toggleBoardRow()). editLph()/cycleAssign() rufen zusätzlich // auf/zu (toggleBoardRow()). editLph() ruft zusätzlich
// renderWochenbericht() (js/wochenbericht/wochenbericht.js) auf, da dessen // renderWochenbericht() (js/wochenbericht/wochenbericht.js) auf, da dessen
// Karten dieselben renderBoardDrawer()-Bedienelemente nutzen, aber in einem // Karten dieselben renderBoardDrawer()-Bedienelemente nutzen, aber in einem
// eigenen DOM-Container (#wochenberichtContent) liegen. // eigenen DOM-Container (#wochenberichtContent) liegen. Die Zuordnung der
// Beteiligten (p.assign) ist hier und im Wochenbericht nur noch Anzeige;
// editierbar ist sie allein über planCycleAssign() in
// js/plan/plan-settings.js — siehe renderBoardDrawer()s Kopfkommentar.
// boardRowOpen: UI-Zustand, welche Projekt-Zeile ihr Detailpanel aufgeklappt // boardRowOpen: UI-Zustand, welche Projekt-Zeile ihr Detailpanel aufgeklappt
// darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt // darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt
@@ -28,17 +31,20 @@
// persistiert — jede Sitzung startet mit eingeklappten Zeilen. // persistiert — jede Sitzung startet mit eingeklappten Zeilen.
let boardRowOpen = {}; let boardRowOpen = {};
// berichtDisclosureState: merkt sich je Abschnitt (Leistungsphasen, Beteiligte, // berichtDisclosureState: merkt sich, ob die Anwesenheit im Wochenbericht
// Anwesenheit), ob er im Wochenbericht (Fokus-Modus — Eriks Wahl "C" aus dem // (renderAttendance(), js/wochenbericht/wochenbericht.js) gerade auf- oder
// Darstellungs-Vergleich: renderBoardDrawer()s dichte Fassung weiter unten + // zugeklappt ist. Ursprünglich auch für Leistungsphasen/Beteiligte gedacht
// renderAttendance() in js/wochenbericht/wochenbericht.js) gerade auf- oder // (damals beide ebenfalls Aufklapper, Fokus-Modus "C"); seit Eriks
// zugeklappt ist. Ohne dieses Gedächtnis klappte jeder Abschnitt bei jedem // Rückmeldung zum Entwurfsvergleich ("Aktenregal") sind beide wieder
// Neu-Rendern wieder auf seinen Vorgabewert zurück — und JEDE Änderung in der // dauerhaft sichtbar (renderBoardDrawer()s dichte Fassung weiter unten) und
// Schublade (eine Zuordnung, ein LPH-Wert) baut den ganzen Bericht neu auf. // brauchen dieses Gedächtnis nicht mehr — nur die Anwesenheit bleibt ein
// 'toggle' bubbelt nicht, ein Lauscher in der Capture-Phase sieht das // Aufklapper. Ohne dieses Gedächtnis klappte sie bei jedem Neu-Rendern wieder
// Ereignis trotzdem auf dem Weg nach unten (gleiches Muster wie der // auf ihren Vorgabewert zurück — und JEDE Änderung im Bericht (eine
// Zuordnung, ein LPH-Wert, ein Schritt im Vorwoche-Scrollrad) baut den ganzen
// Bericht neu auf. 'toggle' bubbelt nicht, ein Lauscher in der Capture-Phase
// sieht das Ereignis trotzdem auf dem Weg nach unten (gleiches Muster wie der
// scroll-Lauscher der Wandtafel). // scroll-Lauscher der Wandtafel).
const berichtDisclosureState = { lph: true, beteiligte: false, anwesenheit: false }; const berichtDisclosureState = { anwesenheit: false };
document.addEventListener('toggle', function(e){ document.addEventListener('toggle', function(e){
const key = e.target && e.target.dataset && e.target.dataset.berichtAbschnitt; const key = e.target && e.target.dataset && e.target.dataset.berichtAbschnitt;
if(key) berichtDisclosureState[key] = e.target.open; if(key) berichtDisclosureState[key] = e.target.open;
@@ -467,21 +473,37 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
// bei der vollständigen Liste, sonst stünde dort gar nichts. // bei der vollständigen Liste, sonst stünde dort gar nichts.
// opts.nurAnsicht: die Karte zeigt, sie erfasst nicht. // opts.nurAnsicht: die Karte zeigt, sie erfasst nicht.
// //
// Kommentar, Fortschritt je Leistungsphase und die Zuordnung der // Kommentar und Fortschritt je Leistungsphase entstehen in der
// Beteiligten entstehen in der wöchentlichen Besprechung und werden deshalb // wöchentlichen Besprechung und werden deshalb allein im Wochenbericht
// allein im Wochenbericht gepflegt. Zuvor waren dieselben drei Angaben an // gepflegt. Zuvor waren dieselben Angaben an drei Stellen bearbeitbar
// drei Stellen bearbeitbar (Projektboard, Wochenbericht, Projektbereich) — // (Projektboard, Wochenbericht, Projektbereich) — niemand wusste, welche
// niemand wusste, welche die richtige ist, und zwei Personen konnten // die richtige ist, und zwei Personen konnten dasselbe Feld an
// dasselbe Feld an verschiedenen Stellen ändern. Dieselbe Karte, dieselben // verschiedenen Stellen ändern. Dieselbe Karte, dieselben Werte, nur ohne
// Werte, nur ohne Eingabefelder und mit einem Verweis dorthin, wo sie // Eingabefelder und mit einem Verweis dorthin, wo sie gepflegt werden.
// gepflegt werden. Eine gesperrte Sitzung wirkt weiterhin zusätzlich. // Eine gesperrte Sitzung wirkt weiterhin zusätzlich.
//
// Die Zuordnung der Beteiligten (p.assign, roleRows unten) gehörte früher
// zu dieser Gruppe, ist aber auf Eriks Wunsch umgezogen: ein Stammdatum,
// nur noch in den Projekteinstellungen editierbar
// (planCycleAssign(), js/plan/plan-settings.js). Hier — sowohl im
// Projektboard als auch im Wochenbericht — ist sie deshalb IMMER nur
// Anzeige, unabhängig von opts.nurAnsicht; cycleAssign() ist damit ohne
// Aufrufer und entfallen.
const ansicht = !!(opts && opts.nurAnsicht); const ansicht = !!(opts && opts.nurAnsicht);
const starr = ansicht || locked; const starr = ansicht || locked;
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []; const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
const nurBeauftragte = !!(opts && opts.nurBeauftragteLph) && beauftragt.length > 0; const nurBeauftragte = !!(opts && opts.nurBeauftragteLph) && beauftragt.length > 0;
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
const aktLp = lpActiveList(p)[0]; const aktLp = lpActiveList(p)[0];
// Die amtierende Phase (aktLp) IMMER mit anzeigen, auch wenn sie nicht in
// beauftragteLP steht: aktLp kommt unabhängig von beauftragteLP aus
// lpActiveList() (js/load/load.js) - beide Felder können auseinanderlaufen
// (z. B. ein Projekt auf LP 8, aber nur LP 1/2/3/6 als beauftragt
// gepflegt). Ohne diese Ergänzung behauptete die Kopfzeile dieses
// Abschnitts ("LP 8 aktiv"), während LP 8 in der aufgeklappten Liste
// darunter gar nicht auftauchte und ihr Fortschritt dort nicht pflegbar
// war - gefunden beim Live-Test mit echten Projektdaten.
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l) || l === aktLp) : LPHS;
// lphLeiste: die Leistungsphasen nebeneinander statt gestapelt — seit // lphLeiste: die Leistungsphasen nebeneinander statt gestapelt — seit
// Variante A (Eriks Wahl aus dem Layout-Vergleich) die EINE Darstellung für // Variante A (Eriks Wahl aus dem Layout-Vergleich) die EINE Darstellung für
@@ -510,55 +532,48 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
const entries = allPeople.filter(e=>e.role===r); const entries = allPeople.filter(e=>e.role===r);
const chips = entries.length ? entries.map(entry=>{ const chips = entries.length ? entries.map(entry=>{
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0; const state = p.assign[assignKey(entry.person.id, entry.role)] || 0;
const click = starr ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`; return `<span class="board-avatar ${roleClass(entry.role)} state-${state} is-ansicht${locked ? ' locked-cell' : ''}" title="${esc(entry.person.fullName || entry.person.name)}">${esc(entry.person.name)}</span>`;
return `<span class="board-avatar ${roleClass(entry.role)} state-${state}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}" ${click} title="${esc(entry.person.fullName || entry.person.name)}">${esc(entry.person.name)}</span>`;
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`; }).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`;
return `<div class="board-role-row"><span class="board-role-label ${roleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`; return `<div class="board-role-row"><span class="board-role-label ${roleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
}).join(''); }).join('');
// Dichte Fassung (Wochenbericht): erst zwei, dann drei Spalten nebeneinander // Dichte Fassung (Wochenbericht), zweite Fassung (Eriks Rückmeldung zum
// — bei 29 Projekten mit allem gleichzeitig sichtbar kam von Erik die // Entwurfsvergleich "Aktenregal"): Leistungsphasen und Beteiligte stehen
// Rückmeldung "zu überladen". Jetzt (Fokus-Modus, Eriks Wahl "C" aus dem // jetzt dauerhaft sichtbar nebeneinander statt hinter Aufklappern verborgen
// Darstellungs-Vergleich) liegt nur der Kommentar offen, Leistungsphasen // — "die Beteiligten sollen mit angezeigt werden". Die erste Fokus-Modus-
// und Beteiligte stecken in aufklappbaren Abschnitten untereinander. // Fassung hatte beide hinter <details> versteckt (Reaktion auf "zu
// überladen" bei allem gleichzeitig sichtbar); diesmal bleiben beide
// bewusst KOMPAKT, damit sie nebeneinander Platz finden, ohne den Kommentar
// ("im Vergleich zu groß") zu überragen: Leistungsphasen nutzen dieselbe
// Kästchen-Leiste wie das Projektboard (lphLeiste, oben in dieser Funktion
// berechnet — keine zweite, eigene LPH-Darstellung mehr für den
// Wochenbericht), Beteiligte ihre ohnehin schon kompakten Rollenzeilen
// (roleRows, s. u.).
// //
// Die Beteiligten behalten ihre Zeile je Rolle — anders als im Entwurf, wo // Die Beteiligten behalten ihre Zeile je Rolle — anders als im Entwurf, wo
// sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in // sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in
// mehreren Rollen vorkommen, und cycleAssign() braucht die Rolle. Ohne sie // mehreren Rollen vorkommen, und ohne die Zeile wäre nicht erkennbar, in
// wäre beim Klicken nicht erkennbar, welche Zuordnung man durchschaltet. // welcher Rolle genau jemand zugeordnet ist (dieselbe Zeilenform nutzt die
// jetzt editierbare Matrix der Projekteinstellungen, planCycleAssign(),
// js/plan/plan-settings.js).
if(opts && opts.dicht){ if(opts && opts.dicht){
// Fokus-Modus (Eriks Wahl "C" aus dem Darstellungs-Vergleich): nur der
// Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in
// aufklappbaren Abschnitten (<details>, Zustand in berichtDisclosureState)
// statt dauerhaft nebeneinander zu stehen. Die Leistungsphasen stehen hier
// als Balkenzeilen (bericht-lph-row) statt als Kästchen-Leiste
// (board-lph-leiste bleibt die Darstellung des Projektboards, unverändert).
const lphBarRows = sichtbareLphs.map(l=>{
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
const aktiv = l === aktLp;
const inputAttr = starr ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
const pct = v !== "" ? Math.min(100, parseFloat(v)||0) : 0;
return `<div class="bericht-lph-row${aktiv ? ' is-aktiv' : ''}">
<span class="bericht-lph-label">${esc(l.replace('LPH ', 'LP '))}</span>
<span class="bericht-lph-track"><i style="width:${pct}%"></i></span>
<span class="bericht-lph-pct"><input type="text" class="bericht-lph-input${locked ? ' locked-cell' : ''}" value="${esc(v)}" ${inputAttr} placeholder="-">%</span>
</div>`;
}).join('');
const lphInfo = aktLp const lphInfo = aktLp
? esc(aktLp.replace('LPH ', 'LP ')) + ' aktiv' + (p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? ' · ' + esc(p.lph[aktLp]) + '%' : '') ? esc(aktLp.replace('LPH ', 'LP ')) + ' aktiv' + (p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? ' · ' + esc(p.lph[aktLp]) + '%' : '')
: 'Keine aktive Leistungsphase'; : 'keine aktiv';
const zugeordnetAnzahl = allPeople.filter(e => (p.assign[assignKey(e.person.id, e.role)] || 0) > 0).length;
return `<div class="board-drawer board-drawer-dicht"> return `<div class="board-drawer board-drawer-dicht">
${renderCommentEditor(p, weekLabel, locked, true)} ${renderCommentEditor(p, weekLabel, locked, true)}
<details class="bericht-disclosure" data-bericht-abschnitt="lph" ${berichtDisclosureState.lph ? 'open' : ''}> <div class="bericht-meta-grid">
<summary><span>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</span><span class="count">${lphInfo}</span><span class="chev">${icon('chevronRight')}</span></summary> <div>
<div class="bericht-disclosure-body">${lphBarRows}</div> <div class="bericht-meta-label">${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'} · ${lphInfo}</div>
</details> ${lphLeiste ? `<div class="board-lph-leiste">${lphLeiste}</div>` : '<span class="hint" style="margin:0">Keine gültige Leistungsphase hinterlegt — bitte in den Projekteinstellungen prüfen.</span>'}
<details class="bericht-disclosure" data-bericht-abschnitt="beteiligte" ${berichtDisclosureState.beteiligte ? 'open' : ''}> </div>
<summary><span>Beteiligte</span><span class="count">${zugeordnetAnzahl} zugeordnet</span><span class="chev">${icon('chevronRight')}</span></summary> <div>
<div class="bericht-disclosure-body"><div class="board-role-mini">${roleRows}</div></div> <div class="bericht-meta-label">Beteiligte</div>
</details> <div class="board-role-mini">${roleRows}</div>
${!locked ? `<button type="button" class="plan-verweis" style="margin-top:4px" onclick="springZuEinstellungen('${p.id}')">In den Projekteinstellungen bearbeiten →</button>` : ''}
</div>
</div>
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''} ${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
</div>`; </div>`;
} }
@@ -582,6 +597,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
<div class="drawer-col drawer-col-people"> <div class="drawer-col drawer-col-people">
<h4>Beteiligte</h4> <h4>Beteiligte</h4>
<div class="board-role-mini">${roleRows}</div> <div class="board-role-mini">${roleRows}</div>
${!locked ? `<button type="button" class="plan-verweis" style="margin-top:4px" onclick="springZuEinstellungen('${p.id}')">In den Projekteinstellungen bearbeiten →</button>` : ''}
</div> </div>
</div> </div>
${ansicht ? renderBerichtVerweis(p, locked) : ''} ${ansicht ? renderBerichtVerweis(p, locked) : ''}
@@ -605,7 +621,7 @@ function renderBerichtVerweis(p, locked){
if(locked) return '<div class="drawer-verweis"><span class="hint" style="margin:0">Die Sitzung ist gesperrt.</span></div>'; if(locked) return '<div class="drawer-verweis"><span class="hint" style="margin:0">Die Sitzung ist gesperrt.</span></div>';
const uid = esc(p.projectUid || ''); const uid = esc(p.projectUid || '');
return `<div class="drawer-verweis"> return `<div class="drawer-verweis">
<span class="hint" style="margin:0">Kommentar, Fortschritt und Zuordnung werden in der Besprechung erfasst.</span> <span class="hint" style="margin:0">Kommentar und Fortschritt werden in der Besprechung erfasst.</span>
<button type="button" class="plan-verweis" onclick="springZumWochenbericht('${uid}')">Im Wochenbericht bearbeiten →</button> <button type="button" class="plan-verweis" onclick="springZumWochenbericht('${uid}')">Im Wochenbericht bearbeiten →</button>
</div>`; </div>`;
} }
@@ -673,20 +689,6 @@ function renderBoard(){
container.innerHTML = html; container.innerHTML = html;
} }
// cycleAssign: wechselt die Zuweisung einer Person zu einem Projekt zyklisch
// durch die Zustände 0=keine → 1=Haupt → 2=optional → 0. Parameter `pid`
// (Projekt-ID), `personId`, `role`; bei Zustand 0 wird der Eintrag gelöscht.
// Keine Rückgabe.
function cycleAssign(pid, personId, role){
if(guardWeekLocked()) return;
const p = currentWeek().projects.find(x=>x.id===pid);
const key = assignKey(personId, role);
const cur = p.assign[key]||0;
const next = (cur+1)%3;
if(next===0) delete p.assign[key]; else p.assign[key]=next;
save(); renderBoard(); renderWochenbericht();
}
// editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein // editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein
// Projekt. Parameter: `pid` (Projekt-ID), `lph` (Leistungsphasen-Schlüssel), // Projekt. Parameter: `pid` (Projekt-ID), `lph` (Leistungsphasen-Schlüssel),
// `val` (Eingabewert). Leere/`%`-Werte werden bereinigt; keine Rückgabe. // `val` (Eingabewert). Leere/`%`-Werte werden bereinigt; keine Rückgabe.
@@ -696,7 +698,8 @@ function editLph(pid,lph,val){
val = val.trim(); val = val.trim();
if(val==="") delete p.lph[lph]; if(val==="") delete p.lph[lph];
else p.lph[lph]=val.replace('%',''); else p.lph[lph]=val.replace('%','');
save(); renderBoard(); renderWochenbericht(); save(); renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
} }
// HINWEIS: delProj() ist entfallen — seit der neuen Zeilendarstellung des // HINWEIS: delProj() ist entfallen — seit der neuen Zeilendarstellung des
+13
View File
@@ -323,6 +323,19 @@ function openProjectPlan(pid){
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
// springZuEinstellungen: wie openProjectPlan(), landet aber gezielt im
// Bereich "Einstellungen" statt im Dashboard — der Verweis aus Board/
// Wochenbericht zur jetzt dort editierbaren Beteiligten-Matrix
// (planCycleAssign(), js/plan/plan-settings.js). Der Hash muss VOR
// showTab('plan') gesetzt werden: dessen eigener updateRouteUrl() behält
// einen bereits vorhandenen location.hash bei (dasselbe Prinzip wie bei
// wandSpringeZuProjekt(), js/wand/wand.js). Parameter: pid (Projekt-ID der
// aktuellen Sitzung). Kein Rückgabewert.
function springZuEinstellungen(pid){
location.hash = 'einstellungen';
openProjectPlan(pid);
}
// closePlanPage: Gegenstück zu openProjectPlan() für den "Schließen"-Knopf // closePlanPage: Gegenstück zu openProjectPlan() für den "Schließen"-Knopf
// in der Kopfzeile des Projektbereichs. Früher ging es hier in der // in der Kopfzeile des Projektbereichs. Früher ging es hier in der
// Browser-Historie zurück, weil der Projektbereich ein eigenes Dokument war. // Browser-Historie zurück, weil der Projektbereich ein eigenes Dokument war.
+10 -1
View File
@@ -281,7 +281,16 @@
// Terminplan nicht mit leeren Zeilen ohne Balken zustellen. Nicht // Terminplan nicht mit leeren Zeilen ohne Balken zustellen. Nicht
// jede Aufgabe aus "Aufgaben" landet also im GANTT, wohl aber jede // jede Aufgabe aus "Aufgaben" landet also im GANTT, wohl aber jede
// Aufgabe aus dem GANTT in "Aufgaben" (dieselbe Liste, siehe oben). // Aufgabe aus dem GANTT in "Aufgaben" (dieselbe Liste, siehe oben).
if(todo.start && todo.end){ // UND showInGantt darf nicht ausdrücklich false sein — die Checkbox
// "Im Terminplan anzeigen" im Aufgaben-Dialog (plan-modals.js) blendet
// eine terminierte Aufgabe trotz gültigem Zeitraum gezielt aus dem
// GANTT aus (Erik: ein einzelner "TODOs aus Protokoll"-Import,
// plan-protokoll-todos.js, legt leicht 15-25 datierte Aufgaben auf
// einmal an, die sonst alle zwingend als Balken erschienen). Fehlt
// das Feld (ältere Aufgaben vor dieser Spalte), gilt weiterhin
// sichtbar — !== false statt === true, damit kein Bestandsverhalten
// kippt.
if(todo.start && todo.end && todo.showInGantt !== false){
const todoRow = buildTodoRow(todo, tIdx); const todoRow = buildTodoRow(todo, tIdx);
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling); insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
insertAfter = todoRow; insertAfter = todoRow;
+11 -5
View File
@@ -113,11 +113,17 @@
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : []; const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
// Nur terminierte Aufgaben (Start UND Ende gesetzt) haben überhaupt eine // Nur terminierte Aufgaben (Start UND Ende gesetzt) MIT showInGantt !==
// eigene GANTT-Zeile (siehe plan-build-gantt.js) und damit einen // false haben überhaupt eine eigene GANTT-Zeile (exakt dieselbe
// Ziehgriff — Aufgaben ohne Termin bleiben an ihrer Stelle // Bedingung wie in plan-build-gantt.js) und damit einen Ziehgriff —
// in plan.todos stehen, sie können hier gar nicht bewegt worden sein. // Aufgaben ohne Termin oder bewusst aus dem GANTT ausgeblendete bleiben
const scheduledIds = new Set(todos.filter(t => t.start && t.end).map(t => t.id)); // an ihrer Stelle in plan.todos stehen, sie können hier gar nicht bewegt
// worden sein. Ohne diesen zweiten Teil der Bedingung würde scheduledIds
// mehr Einträge zählen als tatsächlich .gantt-todo-Zeilen im DOM stehen,
// und der Sicherheitscheck unten (todoRowIds.length !== scheduledIds.size)
// würde JEDE Umsortierung stillschweigend verwerfen, sobald auch nur eine
// Aufgabe ausgeblendet ist.
const scheduledIds = new Set(todos.filter(t => t.start && t.end && t.showInGantt !== false).map(t => t.id));
// Alle beweglichen Zeilen in aktueller DOM-Reihenfolge // Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)]; const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)];
+27 -33
View File
@@ -4,16 +4,15 @@
// Dieses Modul füllt die Projektangaben des Projektbereichs. Es unterscheidet // Dieses Modul füllt die Projektangaben des Projektbereichs. Es unterscheidet
// dabei zwei Arten von Angaben, und das ist der Kern der Sache: // dabei zwei Arten von Angaben, und das ist der Kern der Sache:
// //
// * NUR ANZEIGE — Kommentar der Sitzung (#planStatusEdit), Fortschritt je // * NUR ANZEIGE — Kommentar der Sitzung (#planStatusEdit) und Fortschritt
// Leistungsphase (#planLphList) und die Zuordnung der Beteiligten // je Leistungsphase (#planLphList). Beide entstehen in der wöchentlichen
// (#planAssignMatrix). Diese drei entstehen in der wöchentlichen
// Besprechung und werden ausschließlich im Wochenbericht erfasst. Neben // Besprechung und werden ausschließlich im Wochenbericht erfasst. Neben
// jeder von ihnen steht ein Verweis dorthin (setzeBerichtVerweis()), der // jeder von ihnen steht ein Verweis dorthin (setzeBerichtVerweis()), der
// hinüberwechselt UND dort dieses Projekt einstellt. // hinüberwechselt UND dort dieses Projekt einstellt.
// * STAMMDATEN, hier pflegbar — Gruppe (#planGroupSelect) und amtierende // * STAMMDATEN, hier pflegbar — Gruppe (#planGroupSelect) und amtierende
// Leistungsphase (#planActiveLpSelect), beide im Bereich "Einstellungen". // Leistungsphase (#planActiveLpSelect), beide im Bereich "Einstellungen".
// //
// Warum: Dieselben drei Angaben waren zuvor an DREI Stellen bearbeitbar — // Warum: Dieselben Angaben waren zuvor an DREI Stellen bearbeitbar —
// Projektboard, Wochenbericht und hier. Niemand wusste, welche davon die // Projektboard, Wochenbericht und hier. Niemand wusste, welche davon die
// richtige ist, und zwei Personen konnten dasselbe Feld an verschiedenen // richtige ist, und zwei Personen konnten dasselbe Feld an verschiedenen
// Stellen ändern. Seitdem hat jede Angabe genau einen Ort, an dem sie // Stellen ändern. Seitdem hat jede Angabe genau einen Ort, an dem sie
@@ -21,11 +20,20 @@
// früheren Schreibfunktionen dieses Moduls (planStatusBlur(), setPlanLph(), // früheren Schreibfunktionen dieses Moduls (planStatusBlur(), setPlanLph(),
// setPlanAssign()) sind damit entfallen. // setPlanAssign()) sind damit entfallen.
// //
// BEWUSST NICHT hier: Projektnummer, Bezeichnung, Schlagwörter, Honorar und // Die Zuordnung der Beteiligten (p.assign) gehörte früher ebenfalls zu
// beauftragte Leistungsphasen. Sie sind ebenfalls Stammdaten, werden aber von // dieser "nur Anzeige"-Gruppe — auf Eriks ausdrücklichen Wunsch ist sie
// js/plan/plan-settings.js gerendert (Honorar und beauftragte Phasen // seitdem umgekehrt: kein reines Anzeigefeld mehr, sondern ein echtes
// geschrieben von setHonorarBetrag()/toggleBeauftragteLp() aus // Stammdatum, und zwar NUR HIER im Projektbereich editierbar (Bereich
// js/plan/plan-honorar.js). // "Einstellungen", gerendert von js/plan/plan-settings.js —
// #planAssignMatrix liegt dort im Markup, nicht im Dashboard). Der
// Wochenbericht zeigt die Zuordnung seitdem nur noch zum Lesen, mit einem
// Verweis hierher.
//
// BEWUSST NICHT hier: Projektnummer, Bezeichnung, Schlagwörter, Honorar,
// beauftragte Leistungsphasen und die Beteiligten-Matrix. Sie sind
// Stammdaten und werden von js/plan/plan-settings.js gerendert (Honorar und
// beauftragte Phasen geschrieben von setHonorarBetrag()/toggleBeauftragteLp()
// aus js/plan/plan-honorar.js, die Beteiligten-Matrix von planCycleAssign()).
// //
// renderPlanInfo() wird von // renderPlanInfo() wird von
// renderPlanPage() (plan-state.js) nach jedem Laden aufgerufen; Änderungen // renderPlanPage() (plan-state.js) nach jedem Laden aufgerufen; Änderungen
@@ -128,25 +136,8 @@
}).join(''); }).join('');
} }
setzeBerichtVerweis('planLphVerweis', locked); setzeBerichtVerweis('planLphVerweis', locked);
// Beteiligte: siehe js/plan/plan-settings.js (renderPlanSettings()) —
// Beteiligte — nur Anzeige. Nicht zugeordnete Personen erscheinen // Stammdatum, nicht mehr hier.
// weiterhin blass, damit erkennbar bleibt, wer überhaupt zur Auswahl
// stünde; anklickbar sind sie hier aber nicht mehr.
const assignMatrix = document.getElementById('planAssignMatrix');
if(assignMatrix){
const grouped = peopleByRole(app.week);
if(!p.assign) p.assign = {};
assignMatrix.innerHTML = ROLES.map(r => {
const entries = grouped[r] || [];
const chips = entries.length ? entries.map(entry => {
const state = p.assign[assignKey(entry.person.id, r)] || 0;
const title = (entry.person.fullName || entry.person.name) + ' · ' + r;
return `<span class="board-avatar ${app.planRoleClass(r)} state-${state} is-ansicht" title="${esc(title)}">${esc(entry.person.name)}</span>`;
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`;
return `<div class="board-role-row"><span class="board-role-label ${app.planRoleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
}).join('');
}
setzeBerichtVerweis('planAssignVerweis', locked);
}; };
// setzeBerichtVerweis: füllt einen Hinweis-Platz mit dem Weg zur // setzeBerichtVerweis: füllt einen Hinweis-Platz mit dem Weg zur
@@ -202,9 +193,12 @@
}; };
// Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind // Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind
// entfallen. Fortschritt, Zuordnung und Kommentar werden allein im // entfallen. Fortschritt und Kommentar werden allein im Wochenbericht
// Wochenbericht erfasst (siehe den Kopf dieser Datei); hier gab es damit // erfasst (siehe den Kopf dieser Datei); hier gab es damit keinen Aufrufer
// keinen Aufrufer mehr. Die gleichnamigen Funktionen des Projektboards // mehr. Die gleichnamigen Funktionen des Projektboards
// (editLph()/cycleAssign()/saveBoardComment() in js/board/board.js) sind // (editLph()/saveBoardComment() in js/board/board.js) sind davon
// davon unberührt — dort hängt die Erfassung des Wochenberichts dran. // unberührt — dort hängt die Erfassung des Wochenberichts dran. Die
// Zuordnung der Beteiligten (ehemals setPlanAssign()) läuft seit ihrer
// Verlagerung in die Projekteinstellungen über planCycleAssign()
// (js/plan/plan-settings.js) — ebenfalls kein Fall für dieses Modul.
})(); })();
+14 -2
View File
@@ -397,6 +397,14 @@
} }
refreshTodoDuration(); refreshTodoDuration();
// Im Terminplan anzeigen: Vorgabe sichtbar (!== false, nicht === true —
// eine Aufgabe ohne das Feld, z. B. aus einem alten Bestand, gilt
// weiterhin als sichtbar, kein Bestandsverhalten kippt rückwirkend).
// Bei "Speichern & Neu" übernimmt pre.showInGantt den zuletzt gesetzten
// Wert (dieselben Rahmendaten wie Zeitraum/Zuständige, s. saveTodoEdit()).
const showInGanttEl = document.getElementById('todoEditShowInGantt');
if(showInGanttEl) showInGanttEl.checked = isNew ? (pre.showInGantt !== undefined ? pre.showInGantt : true) : currentTodo.showInGantt !== false;
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;
applyTodoStatus(todoStatusFrom(currentTodo), storedProgress); applyTodoStatus(todoStatusFrom(currentTodo), storedProgress);
@@ -563,11 +571,14 @@
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) : [];
const showInGanttEl = document.getElementById('todoEditShowInGantt');
const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = []; if(!Array.isArray(plan.todos)) plan.todos = [];
if(app.currentTodoIndex === null){ if(app.currentTodoIndex === null){
plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees }); plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt });
} else if(plan.todos[app.currentTodoIndex]){ } else if(plan.todos[app.currentTodoIndex]){
const t = plan.todos[app.currentTodoIndex]; const t = plan.todos[app.currentTodoIndex];
t.title = title; t.title = title;
@@ -577,6 +588,7 @@
t.progress = progress; t.progress = progress;
t.dependsOn = dependsOn; t.dependsOn = dependsOn;
t.assignees = assignees; t.assignees = assignees;
t.showInGantt = showInGantt;
} }
app.savePlan(); app.savePlan();
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt // Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
@@ -584,7 +596,7 @@
todoModalSnapshot = todoFormSnapshot(); todoModalSnapshot = todoFormSnapshot();
closeTodoModal(); closeTodoModal();
renderPlanGantt(); renderPlanGantt();
if(andNew) openTodoModal(null, { start, end, assignees }); if(andNew) openTodoModal(null, { start, end, assignees, showInGantt });
}; };
// deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe, // deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe,
+464
View File
@@ -0,0 +1,464 @@
// -----------------------------
// Projektplan — "TODOs aus Protokoll" (externes PDF per KI)
// -----------------------------
// Eigenständige Funktion im Bereich "Aufgaben" (+ GANTT-Toolbar, gleichrangig
// neben "+ Vorlage"/"+ Terminplan"): ein EXTERNES Besprechungsprotokoll im
// PDF-Format wird per KI eingelesen, und daraus werden — in einem zweiten,
// eigenen KI-Schritt — Aufgaben für dieses Projekt abgeleitet. Bewusst
// KOMPLETT unabhängig von js/plan/plan-protokoll.js: nichts hier liest aus
// oder schreibt in project_protokolle/protokoll_punkte, nichts wird auf dem
// Server dauerhaft abgelegt (weder das PDF noch die erkannten Punkte) — ein
// reines Ad-hoc-Werkzeug wie der Plan Checker, kein Dokumentenarchiv. Wer das
// externe Protokoll dauerhaft als Projektunterlage ablegen will, nutzt den
// normalen Protokoll-Import (Bereich "Protokolle").
//
// Ein Dialog (#protokollTodosModal), DREI Schritte, feste Fußzeile
// (Zurück/Abbrechen/Weiter) — Entwurfsrichtung "A — Schritt-Dialog" aus dem
// Entwurfsvergleich (Taste-Skill), von Erik gewählt: ein sichtbarer
// Fortschrittsbalken (#protokollTodosSteps) und derselbe Knopf-Platz über
// alle Schritte hinweg, statt je Phase ein eigenes Knopf-Set. Nur der
// Inhalt zwischen Fortschrittsbalken und Fußzeile (#protokollTodosBody)
// wird je Schritt neu gerendert; Titel/Untertitel/Fortschritt/Fußzeile
// bleiben derselbe Rahmen. Der rechte Fußzeilen-Knopf ("Weiter") führt
// zugleich die KI-Aufrufe aus — ein Klick macht Schritt und Netzwerkaufruf
// in einem:
// 0. Hochladen — PDF + KI-Anbieter wählen → "Einlesen" ruft POST
// /plan-todos-aus-protokoll/einlesen auf (server.js, nutzt dieselbe
// Erkennung wie der Protokoll-Import: erkennePunkteMitKI()/
// erkennePunkteAusText()).
// 1. Punkte wählen — die erkannten Punkte als Checkliste (alle offenen
// standardmäßig angehakt) → "Aufgaben ableiten" ruft POST
// /plan-todos-aus-protokoll/aufgaben-vorschlag mit genau diesen
// Punkten im Body (kein erneuter Server-Lookup, nichts ist
// persistiert).
// 2. Aufgaben bestätigen — die Vorschläge als kompakte Zeilenliste
// (Entwurfsrichtung "B — Kompakte Zeilen" aus einem zweiten
// Taste-Skill-Vergleich, nur für diesen Schritt; siehe
// renderVorschlaegeHtml()): Titel/Termin-Abzeichen/Zuständig-Kürzel auf
// einen Blick, Beschreibung/Bearbeitung erst nach Antippen der Zeile —
// ein echter Testlauf lieferte bereits 19 Vorschläge, als ausführliche
// Karten wäre das nur noch Scrollen gewesen. "N Aufgaben einfügen"
// erzeugt normale plan_todos MIT Start- UND Enddatum (sonst keine
// GANTT-Zeile, siehe CLAUDE.md) und ruft danach fitPlanGanttRange() wie
// applyTemplate(), damit die neuen Balken im sichtbaren GANTT-Zeitraum
// liegen.
// "Zurück" springt ohne erneuten Serveraufruf auf den vorherigen Schritt
// zurück (die Daten bleiben im Modul-Zustand); einzige Ausnahme: ein
// Datei-Eingabefeld lässt sich aus Sicherheitsgründen nicht per Skript
// erneut vorbelegen — wer von Schritt 1 zurück zu Schritt 0 geht, muss die
// Datei bei Bedarf erneut auswählen.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// Modul-Zustand — lebt nur, während der Dialog offen ist; ein erneutes
// Öffnen startet immer wieder bei Schritt 0.
let step = 0;
let erkanntePunkte = [];
let punkteAuswahl = {}; // Index in erkanntePunkte -> angehakt (Boolean), überlebt Zurück/Weiter
let gewaehlterAnbieter = '';
let ausgewaehltePunkte = []; // die Punkte, die tatsächlich an die KI gingen (für die Quelle-Referenz in Schritt 2)
let vorschlaege = [];
const STEP_TITEL = ['Externes Protokoll einlesen', 'Punkte wählen', 'Aufgaben bestätigen'];
// openProtokollTodosModal: öffnet den Dialog bei Schritt 0. Bei gesperrter
// Sitzung gar nicht erst geöffnet (wie bei den anderen Bearbeitungs-
// Dialogen, siehe plan-modals.js).
window.openProtokollTodosModal = function(){
if(app.isLocked()) return;
step = 0;
erkanntePunkte = [];
punkteAuswahl = {};
gewaehlterAnbieter = '';
ausgewaehltePunkte = [];
vorschlaege = [];
renderStep();
document.getElementById('protokollTodosModal').style.display = 'flex';
};
window.closeProtokollTodosModal = function(){
document.getElementById('protokollTodosModal').style.display = 'none';
};
function zeigeFehler(msg){
const el = document.getElementById('protokollTodosError');
if(el){ el.textContent = msg; el.hidden = false; }
}
function verbergeFehler(){
const el = document.getElementById('protokollTodosError');
if(el){ el.textContent = ''; el.hidden = true; }
}
// zeigeFallbackHinweis: eigener, bernsteinfarbener Hinweis für einen
// GLÜCKTEN, aber degradierten Durchlauf (KI nicht erreichbar, auf die
// einfache Übernahme zurückgefallen) — bewusst NICHT dieselbe rote
// Fehlermeldung wie zeigeFehler() oben, dieselbe Unterscheidung wie der
// Protokoll-Import (.protokoll-ki-fallback-banner, plan-protokoll.js):
// ein Fallback ist ein akzeptiertes Ergebnis, kein Fehlschlag.
function zeigeFallbackHinweis(msg){
const el = document.getElementById('protokollTodosFallback');
if(el){ el.textContent = '⚠️ ' + msg; el.hidden = false; }
}
function verbergeFallbackHinweis(){
const el = document.getElementById('protokollTodosFallback');
if(el){ el.textContent = ''; el.hidden = true; }
}
// setzeLadezustand: sperrt während eines laufenden KI-Aufrufs den
// gesamten Dialog (Fußzeile inklusive, damit kein zweiter Aufruf
// überlappt) — ohne das gäbe es während eines länger laufenden Aufrufs
// (bis zu KI_TIMEOUT_MS_LOKAL = 30 Minuten bei einem eigenen Server,
// server.js) keinerlei Rückmeldung, derselbe Grund wie bei
// setzeProtokollImportLadezustand() in plan-protokoll.js.
function setzeLadezustand(aktiv, text){
const modal = document.getElementById('protokollTodosModal');
if(modal) modal.querySelectorAll('input, select, button').forEach(el => { el.disabled = aktiv; });
const ladeEl = document.getElementById('protokollTodosLoading');
if(ladeEl) ladeEl.hidden = !aktiv;
const textEl = document.getElementById('protokollTodosLoadingText');
if(textEl && text) textEl.textContent = text;
// setzeLadezustand(false) würde sonst auch "Zurück" auf Schritt 0
// wieder aktivieren — das macht erst renderStep() richtig.
if(!aktiv) renderStep();
}
// anbieterOptionenHtml: baut die Options-Liste des KI-Anbieter-Felds aus
// GET /my-ai-settings — derselbe Aufbau wie #protokollImportErkennung in
// plan-protokoll.js.
function anbieterOptionenHtml(){
return fetch(baseUrl() + '/my-ai-settings')
.then(r => r.json())
.then(d => {
if(!d.ok) throw new Error('nicht ok');
return `<option value="chatgpt">ChatGPT${d.chatgpt && d.chatgpt.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>
<option value="claude">Claude${d.claude && d.claude.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>`;
})
.catch(() => '<option value="chatgpt">ChatGPT</option><option value="claude">Claude</option>');
}
// renderStep: zeichnet Titel, Fortschrittsbalken, Fußzeilen-Beschriftung
// und den schrittspezifischen Inhalt. Macht keinen Netzwerkaufruf — das
// übernimmt protokollTodosWeiter(). Keine Parameter, keine Rückgabe.
function renderStep(){
verbergeFehler();
verbergeFallbackHinweis();
document.getElementById('protokollTodosTitle').textContent = STEP_TITEL[step];
document.querySelectorAll('#protokollTodosSteps .seg').forEach(s => {
s.classList.toggle('done', +s.dataset.i <= step);
});
document.getElementById('protokollTodosBackBtn').disabled = (step === 0);
const primaryBtn = document.getElementById('protokollTodosPrimaryBtn');
const subtitleEl = document.getElementById('protokollTodosSubtitle');
const body = document.getElementById('protokollTodosBody');
if(step === 0){
subtitleEl.textContent = 'PDF hochladen, KI-Anbieter wählen.';
primaryBtn.textContent = 'Einlesen';
body.innerHTML = `
<div class="form-row">
<label for="protokollTodosFile">Externes Protokoll (PDF)</label>
<input type="file" id="protokollTodosFile" accept=".pdf">
</div>
<div class="form-row">
<label for="protokollTodosAnbieter">KI-Anbieter</label>
<select id="protokollTodosAnbieter"><option value="chatgpt">ChatGPT</option><option value="claude">Claude</option></select>
</div>`;
anbieterOptionenHtml().then(html => {
const sel = document.getElementById('protokollTodosAnbieter');
if(sel){ sel.innerHTML = html; if(gewaehlterAnbieter) sel.value = gewaehlterAnbieter; }
});
} else if(step === 1){
subtitleEl.textContent = erkanntePunkte.length + ' Punkte erkannt — offene sind vorausgewählt.';
primaryBtn.textContent = 'Aufgaben ableiten';
body.innerHTML = `
<div class="todo-depends-list" id="protokollTodosPunkteListe" style="max-height:280px">
${erkanntePunkte.map((p, i) => {
const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : (p.status === 'offen');
punkteAuswahl[i] = checked;
return `<label class="todo-depends-item">
<input type="checkbox" value="${i}" ${checked ? 'checked' : ''}>
<span style="flex:1">
<b>${esc(p.thema || 'Ohne Thema')}</b>
${p.text ? `<br><span class="hint" style="margin:0">${esc(String(p.text).slice(0, 160))}</span>` : ''}
</span>
<span class="hint" style="margin:0">${esc(p.zustaendig || '')}${p.termin ? ' · ' + esc(formatDateDE(parseDateISO(p.termin)) || p.termin) : ''}</span>
</label>`;
}).join('')}
</div>`;
document.getElementById('protokollTodosPunkteListe').querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => { punkteAuswahl[cb.value] = cb.checked; });
});
} else {
subtitleEl.textContent = vorschlaege.length + ' Vorschläge aus den gewählten Punkten.';
body.innerHTML = renderVorschlaegeHtml();
const liste = document.getElementById('protokollTodosVorschlaege');
if(liste){
// Abwahl blendet die Zeile ab (statt sie zu entfernen) — dieselbe
// "noch da, aber erkennbar nicht dabei"-Behandlung wie bei den
// Punkten in Schritt 1.
liste.addEventListener('change', e => {
if(!e.target.classList.contains('pt-vorschlag-check')) return;
e.target.closest('.protokoll-todo-vorschlag').classList.toggle('off', !e.target.checked);
aktualisierePrimaerKnopf();
});
// Klick auf die Zeile klappt Beschreibung/Termin/Zuständige auf —
// außer der Klick galt der Checkbox oder dem Titel-Eingabefeld
// selbst, die sollen nicht zugleich die Zeile zu-/aufklappen.
liste.addEventListener('click', e => {
if(e.target.closest('.pt-vorschlag-check, .pt-vorschlag-titel')) return;
const main = e.target.closest('.pt-row-main');
if(main) main.closest('.protokoll-todo-vorschlag').classList.toggle('open');
});
}
aktualisierePrimaerKnopf();
}
}
function aktualisierePrimaerKnopf(){
const checks = document.querySelectorAll('.pt-vorschlag-check');
const n = checks.length ? Array.from(checks).filter(c => c.checked).length : vorschlaege.length;
const btn = document.getElementById('protokollTodosPrimaryBtn');
btn.textContent = n + ' Aufgabe' + (n === 1 ? '' : 'n') + ' einfügen';
btn.disabled = n === 0;
const selCount = document.getElementById('protokollTodosSelCount');
if(selCount) selCount.innerHTML = `<b>${n}</b> von ${vorschlaege.length} ausgewählt`;
}
// protokollTodosAuswahlAlle: markiert alle Vorschlagszeilen auf einen
// Schlag an oder ab ("Alle"/"Keine" neben der Auswahl-Zählung) — bei 19
// Vorschlägen (im echten Testlauf beobachtet) sonst 19 Einzelklicks.
// Parameter: alle (Boolean). Keine Rückgabe.
window.protokollTodosAuswahlAlle = function(alle){
document.querySelectorAll('#protokollTodosVorschlaege .pt-vorschlag-check').forEach(cb => {
cb.checked = alle;
cb.closest('.protokoll-todo-vorschlag').classList.toggle('off', !alle);
});
aktualisierePrimaerKnopf();
};
// matchePersonenVonName: einfacher Namensabgleich zwischen einem
// KI-Textvorschlag ("zustaendig" aus dem externen Protokoll — oft eine
// externe Firma, kein Teammitglied) und den diesem Projekt zugeordneten
// Personen. Case-insensitive Teilstring auf Name/vollem Namen; kein
// Treffer ist der Normalfall und bleibt als reiner Text sichtbar.
function matchePersonenVonName(text, kandidaten){
const t = String(text || '').trim().toLowerCase();
if(!t) return [];
const treffer = new Set();
kandidaten.forEach(k => {
const name = String(k.person.name || '').toLowerCase();
const voll = String(k.person.fullName || '').toLowerCase();
if((name && t.includes(name)) || (voll && t.includes(voll))) treffer.add(k.person.id);
});
return Array.from(treffer);
}
// renderVorschlaegeHtml: die Vorschlagsliste aus Entwurfsrichtung
// "B — Kompakte Zeilen" (Taste-Skill-Vergleich) — eine Zeile je Vorschlag
// (Titel, Termin-Abzeichen, Zuständig-Kürzel), Beschreibung/Termin-Feld/
// Zuständigen-Auswahl erst nach Antippen der Zeile sichtbar. Gewählt, weil
// ein echter Testlauf bereits 19 Vorschläge aus einem Protokoll lieferte —
// als ausführliche Karte wäre das ganze Fenster voll Scrollen gewesen,
// als Zeilenliste passt die komplette Übersicht auf einen Blick, ohne
// Termin/Zuständig ganz zu verstecken (beide stehen als Abzeichen schon
// in der eingeklappten Zeile). Eine Auswahl-Zeile oben
// ("N von M ausgewählt" + Alle/Keine) erspart bei vielen Vorschlägen das
// einzelne Abwählen.
function renderVorschlaegeHtml(){
if(vorschlaege.length === 0){
return '<p class="hint">Keine Aufgaben vorgeschlagen — die ausgewählten Punkte waren rein informativ.</p>';
}
// Kandidaten für die Zuständigen-Auswahl: die diesem Projekt bereits
// zugeordneten Personen (projectAssignees(), js/gantt.js), einmalig
// entdoppelt (eine Person kann in mehreren Rollen zugeordnet sein).
const kandidaten = [];
const gesehen = new Set();
projectAssignees(app.project, app.week).forEach(k => {
if(!gesehen.has(k.person.id)){ gesehen.add(k.person.id); kandidaten.push(k); }
});
const zeilen = vorschlaege.map((v, idx) => {
const quelle = (v.quellRefs || []).map(r => ausgewaehltePunkte[r] && ausgewaehltePunkte[r].thema).filter(Boolean).join(', ');
const vorausgewaehlt = matchePersonenVonName(v.zustaendigVorschlag, kandidaten);
const erstePerson = vorausgewaehlt.length ? kandidaten.find(k => k.person.id === vorausgewaehlt[0]) : null;
return `<div class="protokoll-todo-vorschlag" data-idx="${idx}">
<div class="pt-row-main">
<input type="checkbox" class="pt-vorschlag-check" checked>
<input type="text" class="pt-vorschlag-titel" value="${esc(v.titel)}">
<span class="pt-row-badges">
${v.faelligkeit ? `<span class="pt-badge-date">${esc(formatDateDE(parseDateISO(v.faelligkeit)) || v.faelligkeit)}</span>` : ''}
${erstePerson ? `<span class="pt-badge-person" title="${esc(erstePerson.person.fullName || erstePerson.person.name)}">${esc(erstePerson.person.name || initialsForName(erstePerson.person.fullName || '') || '?')}</span>` : ''}
</span>
<span class="pt-chevron">&#9656;</span>
</div>
<div class="pt-row-detail">
${v.beschreibung ? `<div class="hint protokoll-todo-vorschlag-beschreibung">${esc(v.beschreibung)}</div>` : ''}
<div class="protokoll-todo-vorschlag-felder">
<label class="form-row" style="margin-bottom:6px">
<span>Fällig am</span>
<input type="date" class="pt-vorschlag-termin" value="${esc(v.faelligkeit)}" style="max-width:180px">
</label>
<!-- Dieselben .person-chip/.person-chip-ini-Chips mit
aria-pressed und toggleTodoAssignee() (js/plan/plan-modals.js)
wie im echten TODO-Dialog ("Zuständige") — die erste Fassung
hier nutzte stattdessen eine rohe, unformatierte Checkbox
neben dem Kürzel, was neben den übrigen, bewusst gestalteten
Zeilen schlicht unfertig aussah (Eriks Rückmeldung). -->
<div class="person-chips">
${kandidaten.length ? kandidaten.map(k => {
const kuerzel = k.person.name || initialsForName(k.person.fullName || '') || '?';
const voll = k.person.fullName || k.person.name || '';
return `<button type="button" class="person-chip pt-vorschlag-person" data-person-id="${esc(k.person.id)}"
aria-pressed="${vorausgewaehlt.includes(k.person.id)}" onclick="toggleTodoAssignee(this)">
<span class="person-chip-ini">${esc(kuerzel)}</span>${esc(voll)}
</button>`;
}).join('') : '<span class="hint" style="margin:0">Niemand diesem Projekt zugeordnet.</span>'}
</div>
${!vorausgewaehlt.length && v.zustaendigVorschlag ? `<span class="hint" style="margin:0">KI-Vorschlag „${esc(v.zustaendigVorschlag)}“ — kein passendes Teammitglied gefunden.</span>` : ''}
${quelle ? `<span class="hint" style="margin:0">aus: ${esc(quelle)}</span>` : ''}
</div>
</div>
</div>`;
}).join('');
return `
<div class="pt-sel-bar">
<span class="pt-sel-count" id="protokollTodosSelCount"></span>
<span class="pt-sel-actions">
<button type="button" onclick="protokollTodosAuswahlAlle(true)">Alle</button> ·
<button type="button" onclick="protokollTodosAuswahlAlle(false)">Keine</button>
</span>
</div>
<div class="todo-depends-list" id="protokollTodosVorschlaege" style="max-height:360px">${zeilen}</div>`;
}
// protokollTodosZurueck: ein Schritt zurück, ohne erneuten Serveraufruf —
// alle Daten bleiben im Modul-Zustand. Keine Parameter, keine Rückgabe.
window.protokollTodosZurueck = function(){
if(step === 0) return;
step--;
renderStep();
};
// protokollTodosWeiter: der rechte Fußzeilen-Knopf führt Schritt UND
// Netzwerkaufruf in einem aus — "Einlesen"/"Aufgaben ableiten" lösen den
// jeweils nächsten KI-Schritt aus, der letzte Klick übernimmt die
// Aufgaben. Keine Parameter, keine Rückgabe.
window.protokollTodosWeiter = function(){
if(step === 0) return schrittEinlesen();
if(step === 1) return schrittAbleiten();
return schrittUebernehmen();
};
function schrittEinlesen(){
verbergeFehler();
const fileInput = document.getElementById('protokollTodosFile');
const file = fileInput && fileInput.files && fileInput.files[0];
if(!file) return zeigeFehler('Bitte eine PDF-Datei auswählen.');
gewaehlterAnbieter = (document.getElementById('protokollTodosAnbieter') || {}).value || '';
setzeLadezustand(true, 'Wird eingelesen … bei KI-Erkennung über einen eigenen Server kann dies bis zu 30 Minuten dauern.');
fetch(baseUrl() + '/plan-todos-aus-protokoll/einlesen?anbieter=' + encodeURIComponent(gewaehlterAnbieter), {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/pdf' },
body: file
})
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(!ok || !d.ok) throw new Error((d && d.error) || 'Einlesen fehlgeschlagen.');
erkanntePunkte = Array.isArray(d.punkte) ? d.punkte : [];
punkteAuswahl = {};
if(erkanntePunkte.length === 0){ setzeLadezustand(false); zeigeFehler('Es wurden keine Punkte erkannt.'); return; }
step = 1;
setzeLadezustand(false);
if(d.erkennungsart !== 'chatgpt' && d.erkennungsart !== 'claude' && d.erkennungshinweis){
zeigeFallbackHinweis('KI-Erkennung nicht verfügbar (' + d.erkennungshinweis + ') — Punkte per Textmuster erkannt.');
}
})
.catch(err => {
console.error('TODOs aus Protokoll (Einlesen) fehlgeschlagen:', err);
setzeLadezustand(false);
zeigeFehler(err.message || String(err));
});
}
function schrittAbleiten(){
verbergeFehler();
const listEl = document.getElementById('protokollTodosPunkteListe');
const checkedIdx = listEl
? Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked')).map(cb => parseInt(cb.value, 10))
: [];
if(checkedIdx.length === 0) return zeigeFehler('Bitte mindestens einen Punkt auswählen.');
ausgewaehltePunkte = checkedIdx.map(i => erkanntePunkte[i]);
setzeLadezustand(true, 'Aufgaben werden abgeleitet …');
fetch(baseUrl() + '/plan-todos-aus-protokoll/aufgaben-vorschlag', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ anbieter: gewaehlterAnbieter, punkte: ausgewaehltePunkte })
})
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(!ok || !d.ok) throw new Error((d && d.error) || 'Ableiten fehlgeschlagen.');
vorschlaege = Array.isArray(d.vorschlaege) ? d.vorschlaege : [];
step = 2;
setzeLadezustand(false);
if(d.erkennungsart === 'direkt' && d.erkennungshinweis){
zeigeFallbackHinweis('KI-Ableitung nicht verfügbar (' + d.erkennungshinweis + ') — Punkte direkt übernommen, ohne Zusammenfassung.');
}
})
.catch(err => {
console.error('TODOs aus Protokoll (Ableiten) fehlgeschlagen:', err);
setzeLadezustand(false);
zeigeFehler(err.message || String(err));
});
}
// schrittUebernehmen: legt für jede angehakte Vorschlagszeile einen
// normalen plan_todo-Eintrag an — IMMER mit gesetztem Start UND Ende,
// sonst bleibt die Aufgabe ein reiner Checklisten-Eintrag ohne GANTT-Zeile
// (CLAUDE.md: "Ein TODO ist nur dann eine GANTT-Zeile, wenn es BEIDE Daten
// hat"). Gleiche Form wie applyTemplate() (plan-templates.js) und
// saveTodoAusKommentar() (wochenbericht.js). Kein Netzwerkaufruf hier
// selbst — app.savePlan() übernimmt das Speichern wie überall sonst im
// Projektbereich.
function schrittUebernehmen(){
if(app.isLocked()) return;
const rows = document.querySelectorAll('#protokollTodosVorschlaege .protokoll-todo-vorschlag');
const plan = app.ensurePlanLocal();
const heute = formatDateISO(new Date());
let anzahl = 0;
rows.forEach(row => {
const check = row.querySelector('.pt-vorschlag-check');
if(!check || !check.checked) return;
const titel = ((row.querySelector('.pt-vorschlag-titel') || {}).value || '').trim();
if(!titel) return;
const faelligkeit = (row.querySelector('.pt-vorschlag-termin') || {}).value || '';
const personen = Array.from(row.querySelectorAll('.pt-vorschlag-person[aria-pressed="true"]')).map(el => el.dataset.personId);
plan.todos.push({
id: uid(),
title: titel,
start: heute,
end: faelligkeit || heute,
done: false,
progress: 0,
dependsOn: [],
assignees: personen,
});
anzahl++;
});
closeProtokollTodosModal();
if(anzahl === 0) return;
app.savePlan();
// Wie applyTemplate(): der sichtbare GANTT-Zeitraum muss die neuen
// Balken einschließen, sonst erscheinen sie zwar in der Aufgabenliste,
// aber außerhalb des aktuellen Zooms unsichtbar im Terminplan.
fitPlanGanttRange();
app.showToast(anzahl + ' Aufgabe(n) aus Protokoll angelegt.');
}
})();
+56 -3
View File
@@ -13,7 +13,7 @@
// gespeichert von renderPlanInfo()/setPlanGroup()/setPlanActiveLp() in // gespeichert von renderPlanInfo()/setPlanGroup()/setPlanActiveLp() in
// js/plan/plan-info.js — dieses Modul rührt sie nicht an) // js/plan/plan-info.js — dieses Modul rührt sie nicht an)
// * Honorar (netto) und die BEAUFTRAGTEN LEISTUNGSPHASEN // * Honorar (netto) und die BEAUFTRAGTEN LEISTUNGSPHASEN
// * Beteiligte (Rollen-/Personen-Matrix, ebenfalls aus plan-info.js) // * Beteiligte (Rollen-/Personen-Matrix, #planAssignMatrix)
// //
// Honorar und beauftragte Leistungsphasen standen früher ausschließlich im // Honorar und beauftragte Leistungsphasen standen früher ausschließlich im
// Dialog "Terminplan aus Honorar" (js/plan/plan-honorar.js). Sie sind // Dialog "Terminplan aus Honorar" (js/plan/plan-honorar.js). Sie sind
@@ -24,9 +24,21 @@
// aus plan-honorar.js und rendern einander nach jeder Änderung neu, sodass // aus plan-honorar.js und rendern einander nach jeder Änderung neu, sodass
// sie nicht auseinanderlaufen können. // sie nicht auseinanderlaufen können.
// //
// Die Beteiligten-Matrix war früher ein reines Anzeigefeld, gefüllt von
// plan-info.js, mit einem Verweis in den Wochenbericht — dort entstand die
// Zuordnung. Auf Eriks ausdrücklichen Wunsch ("In den Einstellungen der
// Projekte will ich auch die Projektbeteiligten einstellen können") ist das
// umgekehrt: Zuordnung ist jetzt ein Stammdatum wie Honorar, und zwar NUR
// HIER editierbar — der Wochenbericht zeigt sie seitdem nur noch zum Lesen
// (board.js, renderBoardDrawer()) mit einem Verweis hierher. planCycleAssign()
// unten ersetzt für diesen Zweck cycleAssign() (js/board/board.js): jene
// Funktion sucht das Projekt über currentWeek() und fände es bei einem
// Tiefenlink auf eine andere Sitzung oder einem Planung-Sandbox-Plan dort
// nicht — dieselbe Falle, vor der planOpenAufwand() unten warnt.
//
// Gespeichert wird über den normalen Weg app.savePlan() (die Felder sind Teil // Gespeichert wird über den normalen Weg app.savePlan() (die Felder sind Teil
// des Wochen-JSON, Tabellen projects.honorar bzw. project_beauftragte_lph) — // des Wochen-JSON, Tabellen projects.honorar/project_assign bzw.
// es gibt dafür keinen eigenen Endpunkt. // project_beauftragte_lph) — es gibt dafür keinen eigenen Endpunkt.
// //
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js). // (plan-state.js).
@@ -151,6 +163,47 @@
${esc(l)} ${esc(l)}
</label>`).join(''); </label>`).join('');
} }
// Beteiligte — Stammdatum, hier editierbar (siehe Kopfkommentar). Nicht
// zugeordnete Personen erscheinen blass, damit erkennbar bleibt, wer zur
// Auswahl stünde; ein Klick schaltet den Zustand durch (planCycleAssign()).
const assignMatrix = document.getElementById('planAssignMatrix');
if(assignMatrix){
const grouped = peopleByRole(app.week);
if(!p.assign) p.assign = {};
assignMatrix.innerHTML = ROLES.map(r => {
const entries = grouped[r] || [];
const chips = entries.length ? entries.map(entry => {
const state = p.assign[assignKey(entry.person.id, r)] || 0;
const click = locked ? '' : `onclick="planCycleAssign('${entry.person.id}','${r}')"`;
const title = (entry.person.fullName || entry.person.name) + ' · ' + r;
return `<span class="board-avatar ${app.planRoleClass(r)} state-${state}${locked ? ' locked-cell' : ''}" ${click} title="${esc(title)}">${esc(entry.person.name)}</span>`;
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`;
return `<div class="board-role-row"><span class="board-role-label ${app.planRoleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
}).join('');
}
};
// planCycleAssign: wechselt die Zuordnung einer Person zu DIESEM Projekt
// zyklisch durch 0=keine → 1=Haupt → 2=optional → 0 (dieselbe Zustandsfolge
// wie cycleAssign() in js/board/board.js, hier aber auf app.project statt
// currentWeek() — siehe Kopfkommentar). Parameter: personId, role. Keine
// Rückgabe.
window.planCycleAssign = function(personId, role){
const p = app.project;
if(!p || app.isLocked()) return;
if(!p.assign) p.assign = {};
const key = assignKey(personId, role);
const cur = p.assign[key] || 0;
const next = (cur + 1) % 3;
if(next === 0) delete p.assign[key]; else p.assign[key] = next;
app.savePlan();
renderPlanSettings();
// Dieselbe Zuordnung erscheint im Projektboard und im Wochenbericht
// (dort jetzt nur noch zum Lesen) — beide sollen sofort nachziehen,
// nicht erst beim nächsten vollständigen Neuladen.
if(typeof renderBoard === 'function') renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
}; };
// setPlanProjNr: ändert die Projektnummer. // setPlanProjNr: ändert die Projektnummer.
+2 -2
View File
@@ -249,8 +249,8 @@ window.PlanApp = {
// 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) und Aufgaben (…BtnList), siehe App.html. // Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html.
const locked = isLocked(); const locked = isLocked();
['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'autoScheduleBtn', ['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn', 'autoScheduleBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'autoScheduleBtnList'] 'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList']
.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' : '';
+51 -7
View File
@@ -7,7 +7,11 @@
// admin-gepflegte Liste von Merkmalen prüfen (z. B. "Maßketten vorhanden", // admin-gepflegte Liste von Merkmalen prüfen (z. B. "Maßketten vorhanden",
// siehe Admin → Plan-Checker-Merkmale, js/admin/admin.js). Zusätzlich lässt // siehe Admin → Plan-Checker-Merkmale, js/admin/admin.js). Zusätzlich lässt
// sich je Prüfung EIN weiteres Merkmal ergänzen, das nur für diese eine // sich je Prüfung EIN weiteres Merkmal ergänzen, das nur für diese eine
// Prüfung gilt und nicht gespeichert wird. // Prüfung gilt und nicht gespeichert wird - und optional ein Projekt der
// aktuellen Sitzung wählen: Dann kommt serverseitig automatisch ein
// weiteres, ebenso nicht gespeichertes Merkmal dazu, das den Plankopf gegen
// Projektnummer/Bezeichnung dieses Projekts abgleicht (ladePlanCheckerProjekte()/
// planCheckerPruefen() unten, server.js POST /plan-checker-pruefen).
// //
// Darstellung "Arbeitsfläche" (Eriks Wahl "B" aus dem Design-Vergleich): // Darstellung "Arbeitsfläche" (Eriks Wahl "B" aus dem Design-Vergleich):
// zwei Spalten dauerhaft nebeneinander — links Upload/Konfiguration, rechts // zwei Spalten dauerhaft nebeneinander — links Upload/Konfiguration, rechts
@@ -41,13 +45,42 @@ let planCheckerDatei = null;
let planCheckerMerkmale = []; let planCheckerMerkmale = [];
// renderPlanChecker: Einstiegspunkt, von showTab('planchecker') gerufen. // renderPlanChecker: Einstiegspunkt, von showTab('planchecker') gerufen.
// Lädt die Merkmale-Grundliste und die KI-Anbieter-Auswahl neu — beide // Lädt die Merkmale-Grundliste, die KI-Anbieter-Auswahl und die Projektliste
// können sich seit dem letzten Besuch geändert haben (Admin-Pflege bzw. // neu — alle drei können sich seit dem letzten Besuch geändert haben
// eigener Schlüssel unter Einstellungen → Mein Konto). Keine Parameter, // (Admin-Pflege, eigener Schlüssel unter Einstellungen → Mein Konto, bzw.
// keine Rückgabe. // ein inzwischen angelegtes/umbenanntes Projekt). Keine Parameter, keine
// Rückgabe.
function renderPlanChecker(){ function renderPlanChecker(){
ladePlanCheckerMerkmale(); ladePlanCheckerMerkmale();
ladePlanCheckerAnbieter(); ladePlanCheckerAnbieter();
ladePlanCheckerProjekte();
}
// ladePlanCheckerProjekte: füllt die optionale Projekt-Auswahl aus der
// bereits geladenen aktuellen Sitzung (currentWeek().projects, js/weeks.js)
// — kein eigener Server-Aufruf nötig, die Liste liegt dem Client ohnehin
// schon vor. Gruppiert wie die Projektliste selbst (GROUP_ORDER/sortGroups(),
// js/globals.js bzw. js/utils.js). Der Options-Wert ist die Projekt-ID
// (stabil innerhalb der Sitzung) statt Nummer/Name direkt zu kodieren — bei
// der Prüfung wird das Projekt darüber erneut in currentWeek().projects
// nachgeschlagen (planCheckerPruefen() unten). Keine Parameter, keine
// Rückgabe.
function ladePlanCheckerProjekte(){
const sel = document.getElementById('planCheckerProjekt');
if(!sel) return;
const w = typeof currentWeek === 'function' ? currentWeek() : null;
const projekte = (w && Array.isArray(w.projects)) ? w.projects : [];
const bisher = sel.value;
const gruppenNamen = sortGroups([...new Set(projekte.map(p => p.group || 'Ohne Gruppe'))]);
sel.innerHTML = '<option value="">Kein Projekt wählen</option>' + gruppenNamen.map(g => {
const opts = projekte.filter(p => (p.group || 'Ohne Gruppe') === g)
.slice().sort((a, b) => a.name.localeCompare(b.name))
.map(p => `<option value="${esc(p.id)}">${esc(p.nr ? p.nr + ' · ' : '')}${esc(p.name)}</option>`).join('');
return opts ? `<optgroup label="${esc(g)}">${opts}</optgroup>` : '';
}).join('');
// Auswahl erhalten, falls sie nach dem Neuladen noch existiert (z. B. nach
// erneutem Tab-Wechsel innerhalb derselben Sitzung).
if(bisher && [...sel.options].some(o => o.value === bisher)) sel.value = bisher;
} }
// ladePlanCheckerMerkmale: holt die admin-gepflegte Grundliste (GET // ladePlanCheckerMerkmale: holt die admin-gepflegte Grundliste (GET
@@ -143,7 +176,7 @@ function planCheckerDrop(e){
function planCheckerLadezustand(aktiv){ function planCheckerLadezustand(aktiv){
const btn = document.getElementById('planCheckerPruefenBtn'); const btn = document.getElementById('planCheckerPruefenBtn');
if(btn){ btn.disabled = aktiv; btn.textContent = aktiv ? 'Wird geprüft…' : 'Plan prüfen'; } if(btn){ btn.disabled = aktiv; btn.textContent = aktiv ? 'Wird geprüft…' : 'Plan prüfen'; }
['planCheckerFile', 'planCheckerZusatz', 'planCheckerAnbieter'].forEach(id => { ['planCheckerFile', 'planCheckerZusatz', 'planCheckerAnbieter', 'planCheckerProjekt'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if(el) el.disabled = aktiv; if(el) el.disabled = aktiv;
}); });
@@ -164,9 +197,20 @@ function planCheckerPruefen(){
const zusatzEl = document.getElementById('planCheckerZusatz'); const zusatzEl = document.getElementById('planCheckerZusatz');
const zusatz = zusatzEl ? zusatzEl.value.trim() : ''; const zusatz = zusatzEl ? zusatzEl.value.trim() : '';
// Projekt-Abgleich: die Auswahl trägt nur die Projekt-ID (s.
// ladePlanCheckerProjekte() oben) - Nummer/Bezeichnung werden hier erneut
// aus currentWeek().projects nachgeschlagen, statt sie schon beim Aufbau
// der Auswahl im value zu kodieren.
const projektEl = document.getElementById('planCheckerProjekt');
const projektId = projektEl ? projektEl.value : '';
const w = typeof currentWeek === 'function' ? currentWeek() : null;
const projekt = projektId && w ? (w.projects || []).find(p => p.id === projektId) : null;
const url = baseUrl() + '/plan-checker-pruefen' const url = baseUrl() + '/plan-checker-pruefen'
+ '?provider=' + encodeURIComponent(provider) + '?provider=' + encodeURIComponent(provider)
+ '&zusatzMerkmal=' + encodeURIComponent(zusatz); + '&zusatzMerkmal=' + encodeURIComponent(zusatz)
+ '&projektNr=' + encodeURIComponent(projekt ? (projekt.nr || '') : '')
+ '&projektName=' + encodeURIComponent(projekt ? projekt.name : '');
const datei = planCheckerDatei; const datei = planCheckerDatei;
planCheckerLadezustand(true); planCheckerLadezustand(true);
+1 -1
View File
@@ -726,7 +726,7 @@ function wizardAnlegen(){
}; };
// Zuordnungen: Zustand 1 = Hauptverantwortung (dieselbe Bedeutung wie in // Zuordnungen: Zustand 1 = Hauptverantwortung (dieselbe Bedeutung wie in
// cycleAssign(), js/board/board.js). // planCycleAssign(), js/plan/plan-settings.js).
ROLES.forEach(r => { ROLES.forEach(r => {
wizard.team[r].forEach(id => { pr.assign[assignKey(id, r)] = 1; }); wizard.team[r].forEach(id => { pr.assign[assignKey(id, r)] = 1; });
}); });
+128 -45
View File
@@ -16,12 +16,14 @@
// Der Vergleich zur Vorwoche läuft über project_uid (siehe // Der Vergleich zur Vorwoche läuft über project_uid (siehe
// migrateAddProjectUid() in server.js) — die dauerhafte, wochenunabhängige // migrateAddProjectUid() in server.js) — die dauerhafte, wochenunabhängige
// Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu // Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu
// vergeben) nicht bieten konnte. Die Bearbeitung der Projekte selbst // vergeben) nicht bieten konnte. Die Bearbeitung von Kommentar und LPH läuft
// (Kommentar-Editor, LPH, Beteiligte) läuft vollständig über die bestehenden // vollständig über die bestehenden Funktionen aus js/board/board.js
// Funktionen aus js/board/board.js (editLph/cycleAssign/ // (editLph/renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses
// renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses Modul // Modul ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
// ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die // Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten). Die
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten). // Beteiligten-Zuordnung zeigt dieser Tab seit ihrer Verlagerung in die
// Projekteinstellungen nur noch zum Lesen, mit Verweis dorthin
// (springZuEinstellungen(), js/gantt.js).
// Die Anwesenheit lag früher im Projektboard; sie gehört hierher, weil in // Die Anwesenheit lag früher im Projektboard; sie gehört hierher, weil in
// diesem Tab die Sitzung abgearbeitet wird. // diesem Tab die Sitzung abgearbeitet wird.
// //
@@ -154,7 +156,10 @@ function absenceNoteFor(personId, weekDate){
} }
// renderAttendance: baut den Anwesenheitsblock der aktuellen Sitzung. // renderAttendance: baut den Anwesenheitsblock der aktuellen Sitzung.
// Aufgerufen aus renderBoard(). Keine Parameter, keine Rückgabe. // Aufgerufen innerhalb dieser Datei (toggleAttendance(), setAllAttendance()
// sowie innerhalb/rund um renderWochenbericht()) — NICHT aus renderBoard(),
// die Anwesenheit ist allein eine Wochenbericht-Angelegenheit. Keine
// Parameter, keine Rückgabe.
function renderAttendance(){ function renderAttendance(){
const container = document.getElementById('weekAttendance'); const container = document.getElementById('weekAttendance');
if(!container) return; if(!container) return;
@@ -246,9 +251,9 @@ function setAllAttendance(present){
// renderWochenbericht: baut den kompletten Wochenbericht-Tab für die eine // renderWochenbericht: baut den kompletten Wochenbericht-Tab für die eine
// offene Sitzung auf (currentWeek(), js/weeks.js) — Hinweis auf die Vorwoche, // offene Sitzung auf (currentWeek(), js/weeks.js) — Hinweis auf die Vorwoche,
// dann die drei Gruppen. Wird nach jedem Laden sowie nach jeder Änderung // dann die drei Gruppen. Wird nach jedem Laden sowie nach jeder Änderung
// über editLph()/cycleAssign() (js/board/board.js) neu aufgerufen, damit ein // über editLph() (js/board/board.js) bzw. planCycleAssign()
// Projekt sofort in die passende Gruppe wandert. Keine Parameter, keine // (js/plan/plan-settings.js) neu aufgerufen, damit ein Projekt sofort in die
// Rückgabe. // passende Gruppe wandert. Keine Parameter, keine Rückgabe.
// ---------- Tagesordnung: Zustand ---------- // ---------- Tagesordnung: Zustand ----------
// berichtAuswahl: project_uid des Projekts, das gerade rechts im Detail // berichtAuswahl: project_uid des Projekts, das gerade rechts im Detail
@@ -347,6 +352,8 @@ function aktuellesBerichtProjekt(w){
// Parameter: uid (project_uid). Keine Rückgabe. // Parameter: uid (project_uid). Keine Rückgabe.
function waehleBerichtProjekt(uid){ function waehleBerichtProjekt(uid){
berichtAuswahl = uid; berichtAuswahl = uid;
vorwocheRadIndex = 0; // neues Projekt beginnt wieder bei der jüngsten Vorwoche
vorwocheRadScrollAkku = 0; // kein halb aufgelaufenes Scrollen vom vorherigen Projekt übernehmen
renderWochenbericht(); renderWochenbericht();
const detail = document.getElementById('berichtDetail'); const detail = document.getElementById('berichtDetail');
if(detail) detail.scrollIntoView({ block: 'nearest' }); if(detail) detail.scrollIntoView({ block: 'nearest' });
@@ -389,7 +396,13 @@ function besprochenUndWeiter(pid){
function setzeAlleBesprochen(wert){ function setzeAlleBesprochen(wert){
if(guardWeekLocked()) return; if(guardWeekLocked()) return;
const w = currentWeek(); const w = currentWeek();
(w.projects || []).forEach(p => { p.besprochen = !!wert; }); // berichtProjekte(w) statt w.projects direkt: Der Knopf wirkt auf die
// gerade sichtbare, nach wochenberichtGewerk gefilterte Liste - sonst
// markierte "Alle abhaken" bei gewähltem Gewerk auch die Projekte der
// ANDEREN Gewerke als besprochen, die in dieser Besprechung nie zu sehen
// waren (widerspricht "Wochenbericht — Gewerke-Trennung": Vollständigkeit
// gilt innerhalb des gewählten Gewerks, nicht über alle hinweg).
berichtProjekte(w).forEach(p => { p.besprochen = !!wert; });
save(); save();
renderWochenbericht(); renderWochenbericht();
} }
@@ -576,13 +589,17 @@ function renderBerichtKopf(w, liste, prevWeek, locked){
</div>`; </div>`;
} }
// renderBerichtListe: die Tagesordnung als schmale Nummern-Leiste (Fokus- // renderBerichtListe: die Tagesordnung als Leiste mit Nummer, Gruppe UND
// Modus, Eriks Wahl "C" aus dem Darstellungs-Vergleich) — jede Kachel trägt // Name direkt sichtbar (Entwurf "Aktenregal", Eriks Wahl aus dem zweiten
// nur die Position in der Liste plus ggf. den Änderungs-Punkt, Nummer und // Darstellungs-Vergleich — löst die vorherige schmale Icon-Leiste ab, die
// Name stehen in einem Tooltip. Eigene Funktion, weil das Sprung-Feld sie // Nummer und Namen nur im Hover-Tooltip zeigte: "so sehe ich die ganze
// allein neu zeichnet — das Detail und vor allem ein gerade getippter // Tagesordnung auf einen Blick"). Gruppiert wie die Projektliste
// Kommentar dürfen dabei nicht verlorengehen. Keine Parameter, keine // (GROUP_ORDER, js/globals.js), mit einer Zwischenüberschrift bei jedem
// Rückgabe. // Gruppenwechsel — die Positionsnummer zählt dabei über alle Gruppen hinweg
// weiter, sie ist die Position in der VOLLSTÄNDIGEN Tagesordnung. Eigene
// Funktion, weil das Sprung-Feld sie allein neu zeichnet — das Detail und
// vor allem ein gerade getippter Kommentar dürfen dabei nicht
// verlorengehen. Keine Parameter, keine Rückgabe.
function renderBerichtListe(){ function renderBerichtListe(){
const el = document.getElementById('berichtListe'); const el = document.getElementById('berichtListe');
const w = currentWeek(); const w = currentWeek();
@@ -601,21 +618,21 @@ function renderBerichtListe(){
return; return;
} }
let gruppeVorher = null;
el.innerHTML = liste.map(p => { el.innerHTML = liste.map(p => {
const info = classifyProjectChange(p, prevWeek); const info = classifyProjectChange(p, prevWeek);
const istAktiv = aktiv && aktiv.id === p.id; const istAktiv = aktiv && aktiv.id === p.id;
const position = voll.findIndex(x => x.id === p.id) + 1; const position = voll.findIndex(x => x.id === p.id) + 1;
const tooltip = (p.nr ? p.nr + ' · ' : '') + p.name; const gruppe = p.group || 'Ohne Gruppe';
// Eigene, schwebende Tooltip-Box (wie im freigegebenen Entwurf) statt const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : '';
// des nativen Browser-Tooltips: Seit die Leiste nicht mehr selbst gruppeVorher = gruppe;
// scrollt (kein overflow-y:auto mehr, siehe .bericht-rail in styles.css), const name = (p.nr ? p.nr + ' · ' : '') + p.name;
// gibt es auch keine abschneidende Overflow-Begrenzung mehr dafür. return `${gruppenkopf}<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
return `<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${p.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-label="${esc(tooltip)}"> 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-tooltip">${esc(tooltip)}</span> <span class="bericht-rail-name">${esc(name)}</span>
</div>`; </div>`;
}).join(''); }).join('');
@@ -653,7 +670,7 @@ function renderBerichtDetail(p, w, prevWeek, locked){
<h1 class="bericht-h1">${esc(p.name)}</h1> <h1 class="bericht-h1">${esc(p.name)}</h1>
<div class="bericht-sub">${subText}</div> <div class="bericht-sub">${subText}</div>
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''} ${fehlen.length ? `<span class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''}
${renderVorwochenzeile(p, prevWeek)} ${renderVorwocheRad(p, w)}
${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)} ${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)}
${locked ? '' : `<div class="bericht-detail-fuss"> ${locked ? '' : `<div class="bericht-detail-fuss">
<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')"> <button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
@@ -664,22 +681,79 @@ function renderBerichtDetail(p, w, prevWeek, locked){
</div>`}`; </div>`}`;
} }
// renderVorwochenzeile: der Kommentar der Vorwoche als eine Zeile ÜBER dem // vorwocheRadIndex: Position im Kommentar-Verlauf des GERADE GEÖFFNETEN
// Eingabefeld. Die häufigste Frage im Meeting ist "was hatten wir letzte // Projekts (0 = jüngste Vorwoche, höher = weiter zurück). Ein einzelner
// Woche vereinbart?" — der Verlauf beantwortet sie zwar, aber erst nach dem // modulweiter Stand genügt, da immer nur ein Projekt im Fokus steht;
// Aufklappen. Parameter: p, prevWeek. Rückgabe: HTML (leer ohne Vorwoche). // waehleBerichtProjekt() setzt ihn beim Projektwechsel zurück.
function renderVorwochenzeile(p, prevWeek){ let vorwocheRadIndex = 0;
if(!prevWeek || !p.projectUid) return '';
const vor = (prevWeek.projects || []).find(x => x.projectUid === p.projectUid); // renderVorwocheRad: löst renderVorwochenzeile() ab, die nur die
if(!vor) return ''; // UNMITTELBARE Vorwoche zeigte. Eriks Rückmeldung zum Entwurfsvergleich: mit
const text = statusPlainText(vor.status); // dem Mausrad (oder den beiden Pfeilen) durch MEHRERE vergangene Kommentare
if(!text) return ''; // blättern können, nicht nur eine Vorwoche sehen. Nutzt
return `<div class="bericht-vorwoche"> // projectStatusHistory() (js/utils.js) — denselben, bereits für den
<span class="bericht-vorwoche-label">Vorwoche · ${esc(prevWeek.label)}</span> // Verlaufs-Dialog geprüften Verlauf (nur Sitzungen mit tatsächlich
<span class="bericht-vorwoche-text">${esc(text)}</span> // geändertem Text, neueste zuerst), abzüglich der aktuellen Sitzung selbst
// (die steht ja bereits im Eingabefeld darunter). Parameter: p, w (aktuelle
// Sitzung). Rückgabe: HTML (leer ohne Verlauf).
function renderVorwocheRad(p, w){
if(!p.projectUid) return '';
const verlauf = projectStatusHistory(p.projectUid).filter(e => e.week.label !== w.label);
if(verlauf.length === 0) return '';
const i = Math.max(0, Math.min(verlauf.length - 1, vorwocheRadIndex));
const eintrag = verlauf[i];
const punkte = verlauf.map((_, idx) => `<span class="dot${idx === i ? ' aktiv' : ''}"></span>`).join('');
return `<div class="bericht-vorwoche-rad" title="Mit dem Mausrad durch den Verlauf blättern" onwheel="vorwocheRadScroll(event)">
<button type="button" class="bericht-vorwoche-nav" onclick="vorwocheRadSchritt(1)" ${i >= verlauf.length - 1 ? 'disabled' : ''} title="Weiter zurück">‹</button>
<div class="bericht-vorwoche-inhalt">
<span class="bericht-vorwoche-label">${esc(eintrag.week.label)}</span>
<span class="bericht-vorwoche-text">${esc(eintrag.text)}</span>
</div>
<button type="button" class="bericht-vorwoche-nav" onclick="vorwocheRadSchritt(-1)" ${i <= 0 ? 'disabled' : ''} title="Neuer">›</button>
<div class="bericht-vorwoche-dots">${punkte}</div>
</div>`; </div>`;
} }
// vorwocheRadSchritt: bewegt vorwocheRadIndex um delta (geklemmt auf den
// vorhandenen Verlauf) und zeichnet den Bericht neu — derselbe vollständige
// Neuaufbau, den auch editLph() auslöst; ein offener
// Kommentar-Editor übersteht das über dasselbe Fokus-Wiederherstellungsnetz
// (pendingCommentFocus, js/board/board.js), eine aufgeklappte Anwesenheit
// über berichtDisclosureState. Parameter: delta (1 = weiter zurück, -1 =
// neuer). Keine Rückgabe.
function vorwocheRadSchritt(delta){
vorwocheRadScrollAkku = 0; // ein gezielter Klick verwirft ein halb aufgelaufenes Scrollen
const w = currentWeek();
const p = aktuellesBerichtProjekt(w);
if(!w || !p) return;
const max = projectStatusHistory(p.projectUid).filter(e => e.week.label !== w.label).length - 1;
vorwocheRadIndex = Math.max(0, Math.min(max, vorwocheRadIndex + delta));
renderWochenbericht();
}
// vorwocheRadScrollAkku/-SCHWELLE: jeder einzelne wheel-Tick löste zunächst
// sofort einen Schritt aus — auf einem Trackpad erzeugt schon ein kurzes
// Antippen mehrere Ticks mit kleinem deltaY, wodurch ein einziges Scrollen
// mehrere Wochen auf einmal übersprang (Erik: "Empfindlichkeit beim Scrollen
// etwas verlangsamen"). Jetzt wird deltaY aufsummiert, bis die Schwelle
// überschritten ist — eine einzelne Mausrad-Rastung (meist ~100) überschreitet
// sie sofort und bleibt damit bei einem Schritt je Rastung, ein kurzes
// Trackpad-Antippen dagegen oft nicht. Erster Richtwert, bei Bedarf
// nachjustieren.
let vorwocheRadScrollAkku = 0;
const VORWOCHE_RAD_SCHWELLE = 50;
// vorwocheRadScroll: Mausrad-/Trackpad-Bedienung desselben Scrollrads —
// runter (deltaY positiv, aufsummiert) blättert weiter zurück, hoch blättert
// zu neueren Einträgen. e.preventDefault() verhindert, dass dieselbe Bewegung
// zusätzlich die Seite scrollt. Parameter: e (WheelEvent). Keine Rückgabe.
function vorwocheRadScroll(e){
e.preventDefault();
vorwocheRadScrollAkku += e.deltaY;
if(Math.abs(vorwocheRadScrollAkku) < VORWOCHE_RAD_SCHWELLE) return;
vorwocheRadSchritt(vorwocheRadScrollAkku > 0 ? 1 : -1);
}
// ---------- Aufgabe aus dem Kommentar ---------- // ---------- Aufgabe aus dem Kommentar ----------
// //
// Beschlüsse fallen im Meeting und landen bisher als Fließtext im Kommentar — // Beschlüsse fallen im Meeting und landen bisher als Fließtext im Kommentar —
@@ -872,10 +946,9 @@ function protokollAlsMail(){
// ---------- Tastatur ---------- // ---------- Tastatur ----------
// //
// Wer im Meeting mitschreibt, hat keine Hand für die Maus frei. ↓/↑ wechseln // Wer im Meeting mitschreibt, hat keine Hand für die Maus frei. ↓/↑ wechseln
// das Projekt, Strg+↵ speichert den Kommentar und geht weiter. Die Tasten // das Projekt, Strg+↵ im Kommentarfeld speichert den Kommentar und geht
// greifen nur im Wochenbericht und nicht, während in einem Eingabefeld // weiter. Die Tasten greifen nur im Wochenbericht und nicht, während in
// getippt wird — mit einer Ausnahme: Strg+↵ im Kommentarfeld ist genau der // einem Eingabefeld getippt wird.
// Fall, für den es gedacht ist.
document.addEventListener('keydown', function(e){ document.addEventListener('keydown', function(e){
if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return; if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return;
const w = currentWeek(); const w = currentWeek();
@@ -888,12 +961,22 @@ document.addEventListener('keydown', function(e){
if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){ if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){
const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null; const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null;
// Strg+Enter nur abfangen, wenn der Fokus tatsächlich im Kommentarfeld
// liegt — sonst OHNE feld-Prüfung, z. B. in einem Leistungsphasen-
// Eingabefeld (.bericht-lph-input) oder im Titelfeld des "Aufgabe aus
// Kommentar"-Dialogs getippt, sprang die App sofort zum nächsten Projekt
// und baute die Liste neu auf — ein gerade eingetippter, noch nicht per
// onchange/blur gespeicherter Wert ging dabei kommentarlos verloren, und
// ein offen stehender Dialog bezog sich danach lautlos auf das falsche
// (inzwischen gewechselte) Projekt. Ohne feld bricht diese Weiche jetzt
// einfach ab und überlässt Strg+Enter dem normalen Feldverhalten.
if(!feld) return;
const aktiv = aktuellesBerichtProjekt(w); const aktiv = aktuellesBerichtProjekt(w);
if(!aktiv) return; if(!aktiv) return;
e.preventDefault(); e.preventDefault();
// Der Kommentar speichert sich über den bestehenden Weg selbst; hier // Der Kommentar speichert sich über den bestehenden Weg selbst; hier
// folgt nur der Sprung zum nächsten offenen Punkt. // folgt nur der Sprung zum nächsten offenen Punkt.
if(feld && typeof saveBoardComment === 'function'){ if(typeof saveBoardComment === 'function'){
const knopf = feld.closest('.comment-block').querySelector('.comment-editor-foot button'); const knopf = feld.closest('.comment-block').querySelector('.comment-editor-foot button');
if(knopf) saveBoardComment(knopf); if(knopf) saveBoardComment(knopf);
} }
+106
View File
@@ -754,6 +754,53 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
animation: protokoll-import-spin .8s linear infinite; } animation: protokoll-import-spin .8s linear infinite; }
@keyframes protokoll-import-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes protokoll-import-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* "TODOs aus Protokoll" (js/plan/plan-protokoll-todos.js): die drei Segmente
oben im Dialog zeigen den Fortschritt durch Hochladen → Punkte →
Aufgaben — erledigte/aktuelle Schritte gefüllt, kommende leer. Entwurf
"A — Schritt-Dialog" (Taste-Skill-Vergleich, Eriks Wahl). */
.protokoll-todos-steps { display: flex; gap: 5px; margin: 12px 0 16px; }
.protokoll-todos-steps .seg { flex: 1; height: 4px; border-radius: 2px; background: var(--line-soft); }
.protokoll-todos-steps .seg.done { background: var(--accent); }
/* Schritt 3 ("Aufgaben bestätigen"), Entwurfsrichtung "B — Kompakte Zeilen"
aus einem zweiten Taste-Skill-Vergleich (Eriks Wahl): eine Zeile je
Vorschlag statt einer ausführlichen Karte — ein echter Testlauf lieferte
bereits 19 Vorschläge aus einem Protokoll, als Karten wäre das nur noch
Scrollen gewesen. Termin/Zuständig stehen als Abzeichen schon in der
eingeklappten Zeile; Beschreibung und die volle Bearbeitung (Termin-Feld,
Zuständigen-Auswahl) erscheinen erst nach Antippen der Zeile
(.protokoll-todo-vorschlag.open). */
.pt-sel-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.pt-sel-count { font-size: 12.5px; color: var(--text-muted); }
.pt-sel-count b { color: var(--accent); font-weight: 700; }
.pt-sel-actions button { appearance: none; border: none; background: none; font: inherit; font-size: 11.5px;
font-weight: 650; color: var(--accent); cursor: pointer; padding: 2px 3px; }
.pt-sel-actions button:hover { text-decoration: underline; }
.protokoll-todo-vorschlag { border-bottom: 1px solid var(--line-soft); }
.protokoll-todo-vorschlag:last-child { border-bottom: none; }
.pt-row-main { display: flex; align-items: center; gap: 9px; padding: 8px 6px; cursor: pointer; }
.protokoll-todo-vorschlag.off .pt-row-main { opacity: .45; }
.pt-row-main .pt-vorschlag-check { flex-shrink: 0; accent-color: var(--accent); cursor: pointer; }
.pt-row-main .pt-vorschlag-titel { flex: 1; min-width: 0; border: none; background: none; font: inherit;
font-weight: 650; font-size: 12.8px; color: var(--text); padding: 2px 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pt-row-badges { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.pt-badge-date { font-size: 10.5px; font-weight: 650; padding: 2px 8px; border-radius: 999px;
background: var(--accent-soft); color: var(--accent); white-space: nowrap; }
/* Pille statt perfektem Kreis: ein Kürzel ist 2-4 Zeichen lang (initialsForName(),
js/utils.js), ein starrer 18px-Kreis hätte "THK" o. Ä. abgeschnitten. */
.pt-badge-person { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px;
background: var(--accent); color: var(--surface);
font-size: 9px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.pt-chevron { color: var(--text-subtle); font-size: 10.5px; width: 12px; text-align: center;
transition: transform .15s; flex-shrink: 0; }
.protokoll-todo-vorschlag.open .pt-chevron { transform: rotate(90deg); }
.pt-row-detail { display: none; padding: 0 6px 12px; }
.protokoll-todo-vorschlag.open .pt-row-detail { display: block; }
.protokoll-todo-vorschlag-beschreibung { margin: 2px 0 8px 28px; }
.protokoll-todo-vorschlag-felder { margin-left: 28px; display: flex; flex-direction: column; gap: 6px; }
.protokoll-detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; .protokoll-detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px;
padding-bottom: 16px; border-bottom: 1px solid var(--line-soft); margin-bottom: 16px; } padding-bottom: 16px; border-bottom: 1px solid var(--line-soft); margin-bottom: 16px; }
.protokoll-detail-meta { color: var(--text-muted); font-size: 12.5px; } .protokoll-detail-meta { color: var(--text-muted); font-size: 12.5px; }
@@ -1009,3 +1056,62 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
.legend-swatch { width: 13px; height: 13px; border-radius: 4px; flex-shrink: 0; display: inline-block; } .legend-swatch { width: 13px; height: 13px; border-radius: 4px; flex-shrink: 0; display: inline-block; }
.legend-swatch.neu { background: var(--protokoll-neu-bg); border: 1.5px solid var(--protokoll-neu-border); } .legend-swatch.neu { background: var(--protokoll-neu-bg); border: 1.5px solid var(--protokoll-neu-border); }
.legend-swatch.info { background: var(--status-info-bg); border: 1.5px solid var(--status-info); } .legend-swatch.info { background: var(--status-info-bg); border: 1.5px solid var(--status-info); }
/* ============================================================
BEWEGUNG - Stufe "Spürbar", Teil Projektbereich
============================================================
Ergänzung zum gleichnamigen Block am Ende von styles.css; die
Tokens (--dur, --ease-out, --row-lift, --press) stehen dort in
:root und gelten hier mit. Erfasst die Zeilenarten, die es nur
im Projektbereich gibt.
============================================================ */
/* Anklickbare Zeilen: anheben + einfahrende Akzentleiste, exakt wie
die Projektliste. Auch hier als inset-Schatten statt ::before -
die Aufgabenzeilen des GANTT sind ziehbar (plan-drag.js), ein
Stapelkontext auf der Zeile wäre dort riskant. */
.todo-depends-item,
.pt-row-main,
.plan-task-row {
transition: background var(--dur) var(--ease-out),
box-shadow var(--dur) var(--ease-out),
transform var(--dur) var(--ease-out);
}
.todo-depends-item:hover,
.pt-row-main:hover,
.plan-task-row:hover {
transform: translateY(var(--row-lift));
box-shadow: inset 3px 0 0 0 var(--accent);
}
.todo-depends-item:active,
.pt-row-main:active,
.plan-task-row:active { transform: translateY(0); transition-duration: 60ms; }
/* Die Protokoll-Zeile ist der eine Sonderfall: sie trägt bereits einen
eigenen linken Rand (border-left: 3px solid transparent, oben), den
der aktive Punkt auf die Akzentfarbe setzt. Hier NICHT zusätzlich den
inset-Schatten verwenden - beides zusammen ergäbe 6 px Akzent statt 3.
Stattdessen wird genau dieser vorhandene Rand animiert, das Anheben
bleibt identisch zu allen anderen Zeilen. */
.protokoll-liste-zeile {
transition: background var(--dur) var(--ease-out),
border-left-color var(--dur) var(--ease-out),
transform var(--dur) var(--ease-out);
}
.protokoll-liste-zeile:hover {
transform: translateY(var(--row-lift));
border-left-color: var(--accent);
}
.protokoll-liste-zeile:active { transform: translateY(0); transition-duration: 60ms; }
/* Bereichs-Reiter des Projektbereichs: Druckpunkt wie jeder andere
Schalter. Die Unterstreichung des aktiven Reiters bleibt wie sie ist. */
.plan-nav-item { transition: color var(--dur) var(--ease-out),
border-color var(--dur) var(--ease-out),
transform var(--dur) var(--ease-out); }
.plan-nav-item:active { transform: scale(var(--press)); }
/* Aufklapp-Pfeil der Vorschlagszeile ("TODOs aus Protokoll"): dreht
sich jetzt mit derselben Dauer wie der Pfeil der Projektliste
(.board-chev), statt mit einem eigenen Wert. */
.pt-chevron { transition: transform var(--dur) var(--ease-out); }
+238 -81
View File
@@ -64,6 +64,31 @@
--sh-3: 0 8px 28px rgba(0,0,0,.12), 0 2px 8px rgba(0,0,0,.06); --sh-3: 0 8px 28px rgba(0,0,0,.12), 0 2px 8px rgba(0,0,0,.06);
--ease: cubic-bezier(.4, 0, .2, 1); --ease: cubic-bezier(.4, 0, .2, 1);
/* === Bewegung (Stufe "Spürbar", Eriks Wahl aus dem Interaktions-
Vergleich) ============================================================
Die gesamte Rückmeldung der Oberfläche hängt an diesen fünf Werten.
Eine andere Stufe wäre ein Wechsel HIER, nicht in den Komponenten:
jede Regel weiter unten liest ausschliesslich über var().
--dur kurze Rückmeldung (Fläche, Farbe, Anheben, Druck)
--dur-lg grössere Zustandswechsel (Aufklappen, Dialog, Band)
--ease-out Ausschwingen: schnell los, weich aus. Bewusst anders als
das ältere --ease (symmetrisch), das für Bestandsregeln
unverändert bleibt.
--row-lift wie weit sich eine anklickbare Zeile beim Zeigen hebt
--press Stauchung beim Drücken (Knöpfe, Chips, Zeilen)
Grundregeln, die überall gelten: bewegt wird nur transform und
opacity (nie Höhe/Breite, das ruckelt), nichts läuft in Dauerschleife,
und jede Regung quittiert eine echte Handlung. Wer im Betriebssystem
"Bewegung reduzieren" gesetzt hat, bekommt am Dateiende alles auf
praktisch null gesetzt - dort genügt es, diese Variablen zu kippen. */
--dur: 150ms;
--dur-lg: 200ms;
--ease-out: cubic-bezier(.2, .8, .2, 1);
--row-lift: -1px;
--press: .97;
} }
/* === Basis & Reset === */ /* === Basis & Reset === */
@@ -1449,16 +1474,24 @@ tr:nth-child(even) td { background: var(--surface-alt); }
} }
/* === Dichte Fassung der Karte (.board-drawer-dicht, Wochenbericht) === /* === Dichte Fassung der Karte (.board-drawer-dicht, Wochenbericht) ===
Fokus-Modus (Eriks Wahl "C" aus dem Darstellungs-Vergleich): nur der Fokus-Modus, zweite Fassung (Eriks Rückmeldung zum Entwurfsvergleich "A -
Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in Aktenregal"): der Kommentar bleibt das größte, offene Element; Leistungs-
aufklappbaren Abschnitten (.bericht-disclosure, siehe renderBoardDrawer()s phasen und Beteiligte stehen darunter dauerhaft sichtbar nebeneinander
dichte Fassung in js/board/board.js) statt dauerhaft nebeneinander zu (.bericht-meta-grid, siehe renderBoardDrawer()s dichte Fassung in
stehen — bei 29 Projekten wurde der Bericht mit allem gleichzeitig js/board/board.js) statt in aufklappbaren Abschnitten versteckt zu sein -
sichtbar als "zu überladen" zurückgemeldet. Kein eigenes Raster mehr (die "die Beteiligten sollen mit angezeigt werden". Die erste Fassung hatte
drei Blöcke stehen schlicht untereinander), deshalb sitzt die dichte beide hinter Aufklappern verborgen (gegen die Rückmeldung "zu überladen"
Fassung flächig in ihrem Container statt als eigene Schublade mit bei allem gleichzeitig sichtbar); jetzt sind beide bewusst KOMPAKT
zusätzlichem Rahmen/Hintergrund darin. */ gehalten (Kästchen-Leiste bzw. schmale Rollenzeilen statt breiter
Balkenzeilen), damit sie nebeneinander Platz finden, ohne den Kommentar zu
überragen. Nur die Anwesenheit (renderAttendance(),
js/wochenbericht/wochenbericht.js) bleibt ein Aufklapper
(.bericht-disclosure, weiter unten) — sie ist eine Sitzungs-, keine
Projekt-Angabe und sitzt ohnehin außerhalb dieser Karte am Seitenfuß. */
.board-drawer-dicht { padding: 0; background: transparent; border-top: none; } .board-drawer-dicht { padding: 0; background: transparent; border-top: none; }
.bericht-meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line-soft); }
@media (max-width: 860px) { .bericht-meta-grid { grid-template-columns: 1fr; gap: 16px; } }
.bericht-meta-label { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--text-subtle); margin-bottom: 8px; }
.bericht-disclosure { .bericht-disclosure {
border: 1px solid var(--line-soft); border: 1px solid var(--line-soft);
@@ -1496,33 +1529,6 @@ tr:nth-child(even) td { background: var(--surface-alt); }
.bericht-disclosure[open] summary { border-bottom: 1px solid var(--line-soft); } .bericht-disclosure[open] summary { border-bottom: 1px solid var(--line-soft); }
.bericht-disclosure-body { padding: 10px 14px 12px; } .bericht-disclosure-body { padding: 10px 14px 12px; }
/* Leistungsphasen als Balkenzeilen statt der Kästchen-Leiste des
Projektboards (.board-lph-leiste/-cell bleiben dort unverändert) — in der
schmalen Fokus-Spalte brauchen zehn Kästchen nebeneinander zu wenig Platz
je Phase, eine Zeile je Phase dagegen hat Raum für Label, Balken UND
Eingabefeld. */
.bericht-lph-row { display: flex; align-items: center; gap: 10px; padding: 5px 0; font-size: 12.5px; }
.bericht-lph-label { width: 48px; flex-shrink: 0; color: var(--text-muted); }
.bericht-lph-track { flex: 1; height: 4px; background: var(--line-soft); border-radius: 2px; overflow: hidden; }
.bericht-lph-track i { display: block; height: 100%; background: var(--text-subtle); }
.bericht-lph-row.is-aktiv .bericht-lph-track i { background: var(--accent); }
.bericht-lph-row.is-aktiv .bericht-lph-label { color: var(--accent); font-weight: 650; }
.bericht-lph-pct {
display: flex; align-items: center; gap: 1px; flex-shrink: 0;
color: var(--text-subtle); font-variant-numeric: tabular-nums; font-size: 11.5px;
}
/* .bericht-lph-row (zwei Klassen) statt der bloßen Element-Klasse: nötig, um
den globalen input[type=text]-Grundstil (Polsterung/Rahmen/Hintergrund,
weiter oben) zu schlagen — sonst gewinnt dessen höhere Spezifität und die
Polsterung von 7px 10px ließ "100" im 42px schmalen Feld auf "10"
abschneiden. */
.bericht-lph-row .bericht-lph-input {
width: 28px; flex-shrink: 0; border: none; border-radius: 0; background: transparent;
text-align: right; font-size: 11.5px; font-variant-numeric: tabular-nums; color: var(--text);
font-family: inherit; padding: 1px 0;
}
.bericht-lph-row .bericht-lph-input:focus { outline: 1.5px solid var(--accent); outline-offset: -1px; border-radius: 3px; box-shadow: none; }
.bericht-lph-row .bericht-lph-input.locked-cell { cursor: default; }
/* Der Kommentar bleibt das größte Element der dichten Fassung — etwas /* Der Kommentar bleibt das größte Element der dichten Fassung — etwas
größere Schrift als im Projektboard, wo er eine Spalte unter mehreren größere Schrift als im Projektboard, wo er eine Spalte unter mehreren
@@ -2368,11 +2374,14 @@ a.secondary:hover { background: var(--surface-sunk); border-color: var(--text-su
/* === Anwesenheit der Sitzung ============================================ /* === Anwesenheit der Sitzung ============================================
Fokus-Modus (Eriks Wahl "C"): ein aufklappbarer Abschnitt wie Leistungs- Fokus-Modus: ein aufklappbarer Abschnitt (.bericht-disclosure, siehe
phasen/Beteiligte im Wochenbericht (.bericht-disclosure, siehe weiter weiter oben) am Seitenfuß des Wochenberichts, UNABHÄNGIG vom gewählten
oben), kein dauerhaft sichtbarer Block mehr. Zustände bleiben binär Projekt (eine Sitzungs-, keine Projekt-Angabe) - anders als Leistungs-
(anwesend / nicht anwesend, Tabelle attendance); der Zähler in der phasen/Beteiligte, die seit Eriks Rückmeldung zum Entwurfsvergleich wieder
Kopfzeile macht sichtbar, wenn noch niemand geklickt hat. */ dauerhaft sichtbar sind (.bericht-meta-grid, siehe weiter oben), bleibt
dies der einzige Aufklapper. Zustände bleiben binär (anwesend / nicht
anwesend, Tabelle attendance); der Zähler in der Kopfzeile macht sichtbar,
wenn noch niemand geklickt hat. */
#weekAttendance .bericht-disclosure { margin-top: 16px; } #weekAttendance .bericht-disclosure { margin-top: 16px; }
.attendance-actions { display: flex; gap: 8px; margin-bottom: 10px; } .attendance-actions { display: flex; gap: 8px; margin-bottom: 10px; }
@@ -4174,12 +4183,15 @@ td.actions-cell button { margin: 2px; }
color: var(--text-muted); color: var(--text-muted);
} }
/* === Wochenbericht als Tagesordnung, Fokus-Modus (Eriks Wahl "C" aus dem /* === Wochenbericht als Tagesordnung, Fokus-Modus "Aktenregal" (Eriks Wahl
Darstellungs-Vergleich, js/wochenbericht/wochenbericht.js) === aus dem Entwurfsvergleich, js/wochenbericht/wochenbericht.js) ===
Links eine schmale Nummern-Leiste (jedes Projekt nur eine Position + ggf. Links eine Leiste mit Nummer, Gruppe UND Name direkt sichtbar (keine
ein Änderungs-Punkt, Name im Tooltip), rechts das gewählte Projekt — nur schmale Icon-Spalte mit Namen nur im Hover-Tooltip mehr), rechts das
Kommentar und Kernstatus liegen dort offen, Leistungsphasen/Beteiligte/ gewählte Projekt: Kommentar (inkl. Scrollrad zum Verlauf), darunter
Anwesenheit stecken in aufklappbaren Abschnitten (.bericht-disclosure). Leistungsphasen und Beteiligte dauerhaft sichtbar nebeneinander
(.bericht-meta-grid). Nur die Anwesenheit der Sitzung bleibt ein
aufklappbarer Abschnitt (.bericht-disclosure) - sie ist sitzungs-, nicht
projektbezogen und sitzt am Seitenfuß, unabhängig vom gewählten Projekt.
Ersetzt die vorherige ausgeschriebene Liste|Detail-Darstellung, die mit Ersetzt die vorherige ausgeschriebene Liste|Detail-Darstellung, die mit
Gewerke-Dropdown, Fortschrittsbalken, Suchfeld, mehreren Badges je Zeile Gewerke-Dropdown, Fortschrittsbalken, Suchfeld, mehreren Badges je Zeile
und allem gleichzeitig sichtbar als "zu überladen" zurückgemeldet wurde. und allem gleichzeitig sichtbar als "zu überladen" zurückgemeldet wurde.
@@ -4201,7 +4213,12 @@ td.actions-cell button { margin: 2px; }
} }
.bericht-gewerk-select { .bericht-gewerk-select {
font-size: 12px; font-weight: 600; padding: 5px 10px; font-size: 12px; font-weight: 600; padding: 5px 10px;
border-radius: var(--r-sm); background: var(--surface-alt); /* background-color statt background (Kurzform): die Kurzform setzt das vom
globalen select-Selektor gesetzte Pfeil-Icon (background-image/-repeat/
-position) auf die Vorgabewerte zurück, wodurch das 10x6px-Pfeilchen
über das ganze Feld kachelt statt einmal rechts zu sitzen - derselbe
Fehler wie zuvor bei .pc-select. */
border-radius: var(--r-sm); background-color: var(--surface-alt);
border: 1px solid var(--line); color: var(--text); cursor: pointer; border: 1px solid var(--line); color: var(--text); cursor: pointer;
} }
.bericht-ring { .bericht-ring {
@@ -4347,46 +4364,42 @@ td.actions-cell button { margin: 2px; }
border: 1px solid var(--line-soft); border-radius: var(--r-sm); background: var(--surface-alt); border: 1px solid var(--line-soft); border-radius: var(--r-sm); background: var(--surface-alt);
} }
.pc-result-shot:hover img { border-color: var(--accent); } .pc-result-shot:hover img { border-color: var(--accent); }
.bericht-layout { display: grid; grid-template-columns: 64px minmax(0, 1fr); } /* Entwurf "Aktenregal" (zweite Fassung des Fokus-Modus, Eriks Wahl aus dem
@media (max-width: 760px) { .bericht-layout { grid-template-columns: 48px minmax(0, 1fr); } } Entwurfsvergleich): die Leiste zeigt Nummer, Gruppe UND Name direkt statt
nur eine Nummer mit Name im Hover-Tooltip (die vorherige, schmale 64px-
Fassung) — man sieht die ganze Tagesordnung auf einen Blick, nicht nur
beim Drüberfahren. */
.bericht-layout { display: grid; grid-template-columns: 240px minmax(0, 1fr); }
@media (max-width: 860px) { .bericht-layout { grid-template-columns: 1fr; } }
.bericht-rail { .bericht-rail {
background: var(--surface-alt); background: var(--surface-alt);
border-right: 1px solid var(--line-soft); border-right: 1px solid var(--line-soft);
padding: 10px 0; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 10px 8px; display: flex; flex-direction: column; gap: 2px;
/* Die Leiste bleibt beim Scrollen im Blick — sie IST die Tagesordnung. Kein position: sticky; top: 64px; max-height: calc(100vh - 64px); overflow-y: auto;
eigener Rahmen/Radius/Schatten mehr und keine eigene Höhenbegrenzung:
die Fläche gehört zu .bericht-panel, und eine gedeckelte Höhe zwang den
eigenen (inzwischen entfernten) Tooltip zum Abschneiden — ein Element mit
overflow-y ungleich visible schneidet nach der CSS-Spezifikation auch in
X-Richtung ab. */
position: sticky; top: 64px;
} }
.bericht-rail-gruppe {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
color: var(--text-subtle); padding: 12px 8px 5px;
}
.bericht-rail-gruppe:first-child { padding-top: 2px; }
.bericht-rail-item { .bericht-rail-item {
position: relative; width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; position: relative; display: flex; align-items: center; gap: 8px;
display: flex; align-items: center; justify-content: center; padding: 6px 8px; border-radius: var(--r-sm); cursor: pointer; color: var(--text); font-size: 12.5px;
color: var(--text-subtle); cursor: pointer;
} }
.bericht-rail-item:hover { background: var(--surface-sunk); color: var(--text); } .bericht-rail-item:hover { background: var(--surface-sunk); }
.bericht-rail-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .bericht-rail-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.bericht-rail-item.is-aktiv { background: var(--accent); color: #fff; } .bericht-rail-item.is-aktiv { background: var(--accent-soft); }
.bericht-rail-item.is-aktiv .bericht-rail-name { color: var(--accent); font-weight: 650; }
/* Abgehakt: zurückgenommen, aber erkennbar — man muss im Meeting noch sehen /* Abgehakt: zurückgenommen, aber erkennbar — man muss im Meeting noch sehen
können, was man schon besprochen hat. */ können, was man schon besprochen hat. */
.bericht-rail-item.is-ab { opacity: .55; } .bericht-rail-item.is-ab { opacity: .55; }
.bericht-rail-item.is-aktiv.is-ab { opacity: 1; } .bericht-rail-item.is-aktiv.is-ab { opacity: 1; }
.bericht-rail-num { font-size: 10.5px; font-weight: 700; font-variant-numeric: tabular-nums; } .bericht-rail-num { font-size: 11px; color: var(--text-subtle); font-variant-numeric: tabular-nums; flex-shrink: 0; width: 16px; }
.bericht-rail-dot { position: absolute; top: 3px; right: 3px; width: 6px; height: 6px; border-radius: 50%; } .bericht-rail-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.bericht-rail-dot.is-new, .bericht-rail-dot.is-status { background: var(--band-neu); } .bericht-rail-dot.is-new, .bericht-rail-dot.is-status { background: var(--band-neu); }
.bericht-rail-dot.is-progress { background: var(--band-fortschritt); } .bericht-rail-dot.is-progress { background: var(--band-fortschritt); }
.bericht-rail-item.is-aktiv .bericht-rail-dot { outline: 1.5px solid var(--accent-soft); outline-offset: -1px; } .bericht-rail-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bericht-rail-tooltip {
position: absolute; left: 48px; top: 50%; transform: translateY(-50%);
background: var(--text); color: var(--surface); font-size: 11px; padding: 4px 8px;
border-radius: var(--r-sm); white-space: nowrap; opacity: 0; pointer-events: none;
transition: opacity .12s var(--ease); z-index: 5;
}
.bericht-rail-item:hover .bericht-rail-tooltip,
.bericht-rail-item:focus-visible .bericht-rail-tooltip { opacity: 1; }
/* max-width: die Hauptspalte bleibt eine schmale Lesespalte statt bis zum /* max-width: die Hauptspalte bleibt eine schmale Lesespalte statt bis zum
rechten Rand zu laufen — Teil der Fokus-Idee, nicht nur die Nebeninfos rechten Rand zu laufen — Teil der Fokus-Idee, nicht nur die Nebeninfos
@@ -4412,17 +4425,36 @@ td.actions-cell button { margin: 2px; }
color: var(--amber); font-size: 12px; margin-bottom: 16px; color: var(--amber); font-size: 12px; margin-bottom: 16px;
} }
/* Der Kommentar der Vorwoche steht ÜBER dem Eingabefeld: "Was hatten wir /* Scrollrad über dem Kommentar-Eingabefeld: "Was hatten wir letzte Woche
letzte Woche vereinbart?" ist die häufigste Frage im Meeting. */ vereinbart?" ist die häufigste Frage im Meeting - stand bis hierhin als
.bericht-vorwoche { starre Einzeile mit nur der unmittelbaren Vorwoche da. Erik wollte mit dem
background: var(--surface-sunk); border-radius: var(--r-md); Mausrad durch MEHRERE vergangene Kommentare blättern können
padding: 9px 12px; margin-bottom: 14px; font-size: 12px; color: var(--text-muted); (renderVorwocheRad(), js/wochenbericht/wochenbericht.js), nicht nur die
eine Vorwoche sehen - die Quelle ist derselbe, bereits geprüfte Verlauf wie
im Verlaufs-Dialog (projectStatusHistory(), js/utils.js). */
.bericht-vorwoche-rad {
display: flex; align-items: center; gap: 8px; background: var(--surface-sunk);
border-radius: var(--r-md); padding: 7px 9px; margin-bottom: 14px; cursor: ns-resize;
} }
.bericht-vorwoche-nav {
width: 22px; height: 22px; border: none; background: none; border-radius: 50%;
color: var(--text-muted); cursor: pointer; font-size: 13px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-family: inherit;
}
.bericht-vorwoche-nav:hover:not(:disabled) { background: var(--surface); color: var(--text); }
.bericht-vorwoche-nav:disabled { opacity: .3; cursor: default; }
.bericht-vorwoche-inhalt { flex: 1; min-width: 0; }
.bericht-vorwoche-label { .bericht-vorwoche-label {
display: block; font-weight: 600; color: var(--text-subtle); display: block; font-weight: 600; color: var(--text-subtle);
text-transform: uppercase; font-size: 10.5px; letter-spacing: .05em; margin-bottom: 3px; text-transform: uppercase; font-size: 10.5px; letter-spacing: .05em; margin-bottom: 3px;
} }
.bericht-vorwoche-text { color: var(--text-muted); } .bericht-vorwoche-text {
color: var(--text-muted); font-size: 12px; display: block;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bericht-vorwoche-dots { display: flex; flex-direction: column; gap: 3px; flex-shrink: 0; }
.bericht-vorwoche-dots .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--line); }
.bericht-vorwoche-dots .dot.aktiv { background: var(--accent); }
.bericht-detail-fuss { display: flex; align-items: center; gap: 14px; margin-top: 20px; } .bericht-detail-fuss { display: flex; align-items: center; gap: 14px; margin-top: 20px; }
.bericht-fuss-spacer { flex: 1; } .bericht-fuss-spacer { flex: 1; }
@@ -5095,3 +5127,128 @@ td.actions-cell button { margin: 2px; }
.wiz-plan-zeile span:nth-child(2), .wiz-plan-zeile span:nth-child(2),
.wiz-plan-zeile span:nth-child(4) { display: none; } .wiz-plan-zeile span:nth-child(4) { display: none; }
} }
/* ============================================================
BEWEGUNG - Stufe "Spürbar"
============================================================
Von Erik aus einem Dreier-Vergleich gewählt (dezent / spürbar /
lebendig). Die Werte stehen als Tokens oben in :root; hier wird
nur angewendet.
Bewusst NICHT enthalten: Eintritts-Animationen für Listen und
aufziehende Balken (das war Stufe 3). Die Oberfläche bewegt sich
ausschliesslich als Antwort auf Zeigen oder Klicken, nie von
selbst - bei einer Liste mit dreissig Projekten, die mehrmals
täglich neu gerendert wird, wäre alles andere Unruhe.
Die Wandtafel (body.wandtafel-aktiv) bleibt bewusst aussen vor:
ein Touch-Monitor kennt kein Hover, und die Tafel trägt ohnehin
ihre eigene Gestaltung (--dash-*).
============================================================ */
/* --- 1. Anklickbare Zeilen: anheben + Akzentleiste ---------------
Die Leiste entsteht als inset-Schatten, NICHT als ::before mit
position:absolute. Grund: .board-row ist draggable (Drag-and-Drop
der Projektliste, js/projects/projects.js). Ein zusätzliches
positioniertes Element bzw. ein Stapelkontext auf der Zeile kann
das Ziehbild und die Trefferflächen verschieben; ein inset-Schatten
kann das nicht, weil er weder Layout noch Stapelkontext anfasst.
Der Schatten wächst von 0 auf 3px, wirkt also wie eine von links
einfahrende Leiste. */
.board-row-main,
.bericht-rail-item,
.planung-row,
.dash-rand-zeile {
transition: background var(--dur) var(--ease-out),
box-shadow var(--dur) var(--ease-out),
transform var(--dur) var(--ease-out);
}
.board-row-main:hover,
.bericht-rail-item:hover,
.planung-row:hover,
.dash-rand-zeile:hover {
transform: translateY(var(--row-lift));
box-shadow: inset 3px 0 0 0 var(--accent);
}
/* Beim Drücken wieder absetzen: die Zeile geht unter den Finger
zurück, statt angehoben zu bleiben. */
.board-row-main:active,
.bericht-rail-item:active,
.planung-row:active,
.dash-rand-zeile:active {
transform: translateY(0);
transition-duration: 60ms;
}
/* Eine bereits ausgewählte Zeile trägt die Leiste dauerhaft - sonst
verschwände beim Wegfahren der Maus die einzige Markierung. */
.bericht-rail-item.is-aktiv { box-shadow: inset 3px 0 0 0 var(--accent); }
/* --- 2. Druckpunkt für alles Klickbare -------------------------
button:active trug schon scale(.97); hier kommen die übrigen
Bedienelemente auf denselben Wert, damit sich nicht das eine
Element gedrückt anfühlt und das Nachbarelement nicht. */
.board-avatar,
.person-chip,
.nav-item,
.attendance-tile,
.plan-verweis,
.tag-chip .tag-remove,
.richtext-btn,
.pc-btn {
transition: background var(--dur) var(--ease-out),
border-color var(--dur) var(--ease-out),
color var(--dur) var(--ease-out),
transform var(--dur) var(--ease-out);
}
.board-avatar:not(.is-ansicht):active,
.person-chip:active,
.nav-item:active,
.attendance-tile:active,
.plan-verweis:active,
.richtext-btn:active,
.pc-btn:active { transform: scale(var(--press)); }
/* --- 3. Dialoge: auftauchen statt aufblitzen -------------------
.modal-bg wird per JS von display:none auf flex geschaltet. Ein
display-Wechsel ist nicht übergangsfähig, eine ANIMATION dagegen
startet neu, sobald das Element wieder gerendert wird - deshalb
hier animation statt transition. Die Fläche blendet auf, der
Dialog kommt dabei minimal aus der Tiefe. */
.modal-bg { animation: modal-bg-auf var(--dur-lg) var(--ease-out); }
.modal-bg > .modal { animation: modal-auf var(--dur-lg) var(--ease-out); }
@keyframes modal-bg-auf { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-auf {
from { opacity: 0; transform: translateY(6px) scale(.985); }
to { opacity: 1; transform: none; }
}
/* --- 4. Tastatur-Fokus sichtbar halten ------------------------
Die Hebe-/Druck-Effekte oben reagieren auf Maus und Finger. Wer
mit der Tastatur arbeitet, braucht eine eigene, deutliche Marke;
ohne sie wäre die Oberfläche nach diesem Umbau für Tastatur-
Bedienung schlechter lesbar als vorher. */
.board-row-main:focus-visible,
.bericht-rail-item:focus-visible,
.planung-row:focus-visible,
.person-chip:focus-visible,
.board-avatar:focus-visible,
.nav-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* --- 5. Bewegung abbestellt ------------------------------------
Pflichtteil, in dieser Datei bisher gar nicht vorhanden. Weil die
Stufe an Variablen hängt, genügt es, sie zu kippen: ohne Hub und
ohne Stauchung bleibt reine Farb-Rückmeldung übrig. Die
transition-/animation-Zeilen fangen zusätzlich die rund sechzig
älteren Regeln ab, die ihre Dauer noch fest eingetragen haben. */
@media (prefers-reduced-motion: reduce) {
:root { --row-lift: 0px; --press: 1; --dur: 1ms; --dur-lg: 1ms; }
*, *::before, *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
}
+401 -40
View File
@@ -343,7 +343,7 @@ app.use(require('cors')());
// express.raw() ihn bekommt ("Keine Datei empfangen"). 8 MB statt 2 MB, weil // express.raw() ihn bekommt ("Keine Datei empfangen"). 8 MB statt 2 MB, weil
// eine Sitzung mit vielen Projekten und langen Kommentaren sonst irgendwann // eine Sitzung mit vielen Projekten und langen Kommentaren sonst irgendwann
// still am 413 scheitert. // still am 413 scheitert.
const RAW_BODY_ROUTES = new Set(['/import', '/project-files', '/project-protokolle/import', '/plan-checker-pruefen']); const RAW_BODY_ROUTES = new Set(['/import', '/project-files', '/project-protokolle/import', '/plan-checker-pruefen', '/plan-todos-aus-protokoll/einlesen']);
const jsonBodyParser = express.json({ limit: '8mb' }); const jsonBodyParser = express.json({ limit: '8mb' });
app.use((req, res, next) => { app.use((req, res, next) => {
if(req.method === 'POST' && RAW_BODY_ROUTES.has(req.path)) return next(); if(req.method === 'POST' && RAW_BODY_ROUTES.has(req.path)) return next();
@@ -556,6 +556,7 @@ db.exec(`CREATE TABLE IF NOT EXISTS plan_todos (
done INTEGER NOT NULL DEFAULT 0, done INTEGER NOT NULL DEFAULT 0,
progress INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
show_in_gantt INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (week_label, project_id, todo_id) PRIMARY KEY (week_label, project_id, todo_id)
)`); )`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees ( db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees (
@@ -1050,8 +1051,8 @@ function serializeWeek(week){
VALUES (?, ?, ?, ?, ?)`); VALUES (?, ?, ?, ?, ?)`);
const insPlanPerson = db.prepare(`INSERT OR IGNORE INTO plan_people (week_label, project_id, person_id, array_order) const insPlanPerson = db.prepare(`INSERT OR IGNORE INTO plan_people (week_label, project_id, person_id, array_order)
VALUES (?, ?, ?, ?)`); VALUES (?, ?, ?, ?)`);
const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order) const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order, show_in_gantt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const insTodoAssignee = db.prepare(`INSERT OR IGNORE INTO plan_todo_assignees (week_label, project_id, todo_id, person_id) const insTodoAssignee = db.prepare(`INSERT OR IGNORE INTO plan_todo_assignees (week_label, project_id, todo_id, person_id)
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)
@@ -1112,7 +1113,8 @@ function serializeWeek(week){
(Array.isArray(plan.people) ? plan.people : []).forEach((pid, pidx) => insPlanPerson.run(weekLabel, projId, String(pid || ''), pidx)); (Array.isArray(plan.people) ? plan.people : []).forEach((pid, pidx) => insPlanPerson.run(weekLabel, projId, String(pid || ''), pidx));
(Array.isArray(plan.todos) ? plan.todos : []).forEach((t, tidx) => { (Array.isArray(plan.todos) ? plan.todos : []).forEach((t, tidx) => {
insTodo.run(weekLabel, projId, String(t.id || ''), String(t.title || ''), insTodo.run(weekLabel, projId, String(t.id || ''), String(t.title || ''),
String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx); String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx,
t.showInGantt === false ? 0 : 1);
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => insTodoAssignee.run(weekLabel, projId, String(t.id || ''), String(pid || ''))); (Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => insTodoAssignee.run(weekLabel, projId, String(t.id || ''), String(pid || '')));
(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 || '')));
}); });
@@ -1297,7 +1299,7 @@ function loadWeeks(){
const todoMap = {}; const todoMap = {};
todos.forEach(r => { todos.forEach(r => {
const k = todoKey(r.week_label, r.project_id, r.todo_id); const k = todoKey(r.week_label, r.project_id, r.todo_id);
todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress }; todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress, showInGantt: !!r.show_in_gantt };
}); });
todoAssignees.forEach(r => { todoAssignees.forEach(r => {
const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)]; const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)];
@@ -2238,6 +2240,24 @@ function migrateAddProjectGroesse(){
} }
} }
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
// (idempotent, gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()).
// DEFAULT 1, damit jede bestehende Aufgabe ihr bisheriges Verhalten behält
// (sichtbar im GANTT, sobald Start UND Ende gesetzt sind) — die neue
// Checkbox im Aufgaben-Dialog ("Im Terminplan anzeigen", plan-modals.js)
// blendet gezielt EINZELNE Aufgaben aus, ändert an diesem Normalfall nichts.
// Erik auf eigenen Wunsch, nachdem "TODOs aus Protokoll" (plan-protokoll-
// todos.js) gezeigt hatte, dass ein einzelner Import leicht 15-25 Aufgaben
// auf einmal anlegt — alle MIT Datum (siehe dortiger Kopfkommentar), also
// bisher alle zwingend auch als GANTT-Balken.
function migrateAddPlanTodoShowInGantt(){
const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name);
if(!cols.includes('show_in_gantt')){
db.exec('ALTER TABLE plan_todos ADD COLUMN show_in_gantt INTEGER NOT NULL DEFAULT 1');
console.log('plan_todos um show_in_gantt ergänzt.');
}
}
// migrateAddProjectUpdatedAt: rüstet die Spalte `updated_at` nach (idempotent, // migrateAddProjectUpdatedAt: rüstet die Spalte `updated_at` nach (idempotent,
// gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()). Gebraucht von // gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()). Gebraucht von
// der Planung-Sandbox, siehe Spaltenkommentar am CREATE TABLE oben. // der Planung-Sandbox, siehe Spaltenkommentar am CREATE TABLE oben.
@@ -2665,6 +2685,7 @@ migrateAddProjectStatusAuthor();
migrateAddProjectAuftragsstatus(); migrateAddProjectAuftragsstatus();
migrateAddProjectGroesse(); migrateAddProjectGroesse();
migrateAddProjectUpdatedAt(); migrateAddProjectUpdatedAt();
migrateAddPlanTodoShowInGantt();
migrateAddUserAiKeyModel(); migrateAddUserAiKeyModel();
migrateAddProtokollQuelle(); migrateAddProtokollQuelle();
migrateAddProtokollPunktErstelltAm(); migrateAddProtokollPunktErstelltAm();
@@ -4264,6 +4285,29 @@ const KI_TIMEOUT_MS_CLOUD = 45000;
// sichtbar, kein scheinbares Hängenbleiben. // sichtbar, kein scheinbares Hängenbleiben.
const KI_TIMEOUT_MS_LOKAL = 1800000; const KI_TIMEOUT_MS_LOKAL = 1800000;
// KI_TIMEOUT_MS_CLOUD_FREMD: ein abweichender, aber weiterhin REMOTE
// liegender Endpunkt (z. B. Mammouth.ai, OpenRouter, ein Firmen-Proxy) ist
// weder "die echte, bekanntermaßen schnelle OpenAI/Anthropic-API" (dort
// gilt zurecht der scharfe KI_TIMEOUT_MS_CLOUD) noch "lokale CPU-Inferenz,
// die Minuten dauern darf" (KI_TIMEOUT_MS_LOKAL) — ein eigener,
// mittlerer Wert. Gefunden bei einem echten Fehlschlag: "TODOs aus
// Protokoll" brach bei Erik zuverlässig mit "This operation was aborted"
// ab, obwohl der Protokoll-Import grundsätzlich funktionierte — derselbe
// Konto-Schlüssel zeigte auf https://api.mammouth.ai/v1 (Modell
// "gpt-6-luna"), kein Loopback, also griff bislang der scharfe
// KI_TIMEOUT_MS_CLOUD (45s). Direkt gegen den echten Schlüssel nachgestellt
// (derselbe Aufruf, derselbe Prompt, dasselbe Dokument wie im Fehlerfall):
// ein einzelnes triviales Prompt beantwortete der Anbieter in 1,3s, die
// tatsächliche Punkte-Erkennung eines realen Protokolls (29.594 Zeichen)
// brauchte dagegen 39,2s — schon im Erfolgsfall nur 6s unter dem alten
// Limit, jede normale Schwankung (Tageszeit, Dokumentlänge, Anbieterlast)
// reichte seither zuverlässig zum Abbruch. Ein Drittanbieter-Proxy ist
// grundsätzlich langsamer und weniger vorhersagbar als die Original-API,
// aber immer noch ein verwalteter Cloud-Dienst, keine CPU-Inferenz auf
// fremder Hardware — 2 Minuten geben das Dreifache der gemessenen Dauer
// als Spielraum, ohne in die Größenordnung von KI_TIMEOUT_MS_LOKAL zu gehen.
const KI_TIMEOUT_MS_CLOUD_FREMD = 120000;
// LOKAL_DISPATCHER: Node's globales fetch() läuft intern über undici, dessen // LOKAL_DISPATCHER: Node's globales fetch() läuft intern über undici, dessen
// eigener Standard-Agent Verbindungen nach headersTimeout/bodyTimeout = 5 // eigener Standard-Agent Verbindungen nach headersTimeout/bodyTimeout = 5
// Minuten von sich aus kappt — UNABHÄNGIG vom eigenen AbortController unten. // Minuten von sich aus kappt — UNABHÄNGIG vom eigenen AbortController unten.
@@ -4354,6 +4398,17 @@ function writeUserAiKey(userId, provider, patch){
.run(userId, provider, apiKey, apiUrl, model); .run(userId, provider, apiKey, apiUrl, model);
} }
// KI_DEFAULT_URL: vor istLoopbackHost()/fetchMitTimeout() platziert (stand
// ursprünglich weiter unten bei normalisiereAiUrl()), weil fetchMitTimeout()
// unten die beiden Adressen jetzt selbst braucht, um einen ECHTEN
// Anbieter-Endpunkt von einem abweichenden (lokal ODER ein fremder
// Cloud-Proxy) zu unterscheiden — siehe istStandardCloudHost() und den
// Fund bei KI_TIMEOUT_MS_CLOUD_FREMD weiter unten.
const KI_DEFAULT_URL = {
chatgpt: 'https://api.openai.com/v1/chat/completions',
claude: 'https://api.anthropic.com/v1/messages'
};
// istLoopbackHost: gemeinsame Grundlage für istGueltigeAiUrl() (URL-Prüfung // istLoopbackHost: gemeinsame Grundlage für istGueltigeAiUrl() (URL-Prüfung
// beim Speichern) und fetchMitTimeout() (Timeout-Wahl beim Aufruf) — beide // beim Speichern) und fetchMitTimeout() (Timeout-Wahl beim Aufruf) — beide
// müssen dieselbe Vorstellung von "läuft auf der eigenen Maschine" teilen. // müssen dieselbe Vorstellung von "läuft auf der eigenen Maschine" teilen.
@@ -4361,6 +4416,15 @@ function istLoopbackHost(hostname){
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]'; return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
} }
// istStandardCloudHost: true nur für die beiden ECHTEN Anbieter-Hosts aus
// KI_DEFAULT_URL (api.openai.com/api.anthropic.com) — die einzigen, bei
// denen "eine Antwort dauert ungewöhnlich lange" zuverlässig "hier ist
// etwas kaputt" bedeutet. Grundlage für fetchMitTimeout()s dritte Stufe
// (KI_TIMEOUT_MS_CLOUD_FREMD) direkt darunter.
function istStandardCloudHost(hostname){
return hostname === new URL(KI_DEFAULT_URL.chatgpt).hostname || hostname === new URL(KI_DEFAULT_URL.claude).hostname;
}
// istGueltigeAiUrl: https wird grundsätzlich verlangt (der Schlüssel reist // istGueltigeAiUrl: https wird grundsätzlich verlangt (der Schlüssel reist
// sonst im Klartext über ein Netz, das nicht die eigene Maschine ist) — mit // sonst im Klartext über ein Netz, das nicht die eigene Maschine ist) — mit
// EINER Ausnahme: http auf dem Loopback. Ein lokal laufender Server wie // EINER Ausnahme: http auf dem Loopback. Ein lokal laufender Server wie
@@ -4378,10 +4442,20 @@ function istGueltigeAiUrl(url){
} }
async function fetchMitTimeout(url, options){ async function fetchMitTimeout(url, options){
// Drei Stufen statt zwei: echter Anbieter-Host (knapp, 45s) < abweichender
// Remote-Host wie ein Drittanbieter-Proxy (mittel, 2min, s.
// KI_TIMEOUT_MS_CLOUD_FREMD) < Loopback (großzügig, 30min) — siehe dortigen
// Kommentar für den konkreten Fehlschlag, der die mittlere Stufe nötig machte.
let istLokal = false; let istLokal = false;
try { istLokal = istLoopbackHost(new URL(url).hostname); } catch(e){ /* ungültige URL: Vorgabe bleibt Cloud-Timeout */ } let istStandard = true;
try {
const hostname = new URL(url).hostname;
istLokal = istLoopbackHost(hostname);
istStandard = istStandardCloudHost(hostname);
} catch(e){ /* ungültige URL: Vorgabe bleibt der knappe Standard-Timeout */ }
const timeoutMs = istLokal ? KI_TIMEOUT_MS_LOKAL : (istStandard ? KI_TIMEOUT_MS_CLOUD : KI_TIMEOUT_MS_CLOUD_FREMD);
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), istLokal ? KI_TIMEOUT_MS_LOKAL : KI_TIMEOUT_MS_CLOUD); const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const fetchOptions = Object.assign({}, options, { signal: controller.signal }); const fetchOptions = Object.assign({}, options, { signal: controller.signal });
if(istLokal) fetchOptions.dispatcher = LOKAL_DISPATCHER; if(istLokal) fetchOptions.dispatcher = LOKAL_DISPATCHER;
@@ -4458,10 +4532,8 @@ const KI_PUNKT_SCHEMA = {
additionalProperties: false additionalProperties: false
}; };
const KI_DEFAULT_URL = { // KI_DEFAULT_URL ist jetzt weiter oben definiert, direkt vor
chatgpt: 'https://api.openai.com/v1/chat/completions', // istLoopbackHost()/fetchMitTimeout() — siehe Kommentar dort.
claude: 'https://api.anthropic.com/v1/messages'
};
// normalisiereAiUrl: wer eine eigene API-URL hinterlegt, tippt bei einem // normalisiereAiUrl: wer eine eigene API-URL hinterlegt, tippt bei einem
// lokalen Server wie LM Studio/Ollama erfahrungsgemäß nur die Basisadresse // lokalen Server wie LM Studio/Ollama erfahrungsgemäß nur die Basisadresse
@@ -4623,6 +4695,232 @@ async function erkennePunkteMitKI(text, userId, provider){
throw new Error('Unbekannter Anbieter: ' + provider); throw new Error('Unbekannter Anbieter: ' + provider);
} }
// ---------- "TODOs aus Protokoll" (public/js/plan/plan-protokoll-todos.js) ----------
//
// Eigenständige, zweite KI-Stufe: nimmt bereits erkannte Besprechungspunkte
// (egal ob frisch aus einem Ad-hoc-PDF-Upload oder sonstwoher) und leitet
// daraus Aufgaben-VORSCHLÄGE ab (Titel, Beschreibung, Fälligkeit, Zuständig-
// Vorschlag) — anders als erkennePunkteMitKI() oben, das nur Punkte aus
// Fließtext EXTRAHIERT, beurteilt dieser Schritt, was davon tatsächlich zu
// tun ist: mehrere Punkte können zu einer Aufgabe zusammengehören, rein
// informative Punkte liefern keine. Komplett unabhängig von
// project_protokolle/protokoll_punkte — die Punkte kommen direkt im
// Request-Body, nichts wird hier gelesen oder geschrieben.
// normalisiereAufgabeAusKi: Analoges Gegenstück zu normalisierePunktAusKi()
// oben, für eine KI-Antwortzeile dieser zweiten Stufe.
function normalisiereAufgabeAusKi(a){
return {
titel: String((a && a.titel) || ''),
beschreibung: String((a && a.beschreibung) || ''),
faelligkeit: String((a && a.faelligkeit) || ''),
zustaendigVorschlag: String((a && a.zustaendigVorschlag) || ''),
quellRefs: Array.isArray(a && a.quellRefs) ? a.quellRefs.filter(r => Number.isInteger(r)) : []
};
}
const KI_AUFGABEN_SCHEMA = {
type: 'object',
properties: {
aufgaben: {
type: 'array',
items: {
type: 'object',
properties: {
titel: { type: 'string' },
beschreibung: { type: 'string' },
faelligkeit: { type: 'string' },
zustaendigVorschlag: { type: 'string' },
quellRefs: { type: 'array', items: { type: 'integer' } }
},
required: ['titel', 'beschreibung', 'faelligkeit', 'zustaendigVorschlag', 'quellRefs'],
additionalProperties: false
}
}
},
required: ['aufgaben'],
additionalProperties: false
};
// KI_AUFGABEN_SYSTEM_PROMPT: eigener, von KI_SYSTEM_PROMPT bewusst getrennter
// Prompt — andere Aufgabe (Aufgaben aus bereits strukturierten Punkten
// ABLEITEN, nicht Punkte aus Fließtext EXTRAHIEREN). Wie bei KI_SYSTEM_PROMPT/
// PLAN_CHECK_SYSTEM_PROMPT gilt: der Wortlaut ist ein Startpunkt, kein
// Festbetrag — vor dem produktiven Einsatz mit einem echten externen
// Protokoll bei temperature:0 testen, Änderungen einzeln bewerten (siehe
// CLAUDE.md "### Protokolle").
const KI_AUFGABEN_SYSTEM_PROMPT = 'Du bekommst Besprechungspunkte eines Projektprotokolls (je Punkt: ref, thema, '
+ 'text, zustaendig, termin). Leite daraus konkrete, umsetzbare Aufgaben ab. Mehrere Punkte, die dieselbe Handlung '
+ 'betreffen, können in einer Aufgabe zusammengefasst werden; rein informative Punkte ohne notwendige Handlung '
+ 'liefern KEINE Aufgabe. Je Aufgabe: ein kurzer Titel (Tätigkeit zuerst), eine kurze Beschreibung, ein '
+ 'Fälligkeitsdatum NUR wenn im Text ein konkretes Datum genannt ist (sonst leer lassen, nichts erfinden), ein '
+ 'Vorschlag für die zuständige Person (wörtlich aus dem Feld "zustaendig" übernommen, nichts erfinden), und die '
+ 'refs der Punkte, aus denen sie entstand.';
// aufgabenTextFuerKi: baut den Nutzer-Content für beide Anbieter aus der
// Punkteliste — jeder Punkt trägt seine Array-Position als "ref", damit die
// Antwort referenzierbar ist, ohne dass ein id-Konzept nötig wäre (nichts
// hier ist persistiert).
function aufgabenTextFuerKi(punkte){
return punkte.map((p, i) => `ref ${i}\nThema: ${p.thema || ''}\nText: ${p.text || ''}\n`
+ `Zuständig: ${p.zustaendig || ''}\nTermin: ${p.termin || ''}`).join('\n\n');
}
async function leiteAufgabenMitChatGPT(punkte, apiKey, apiUrl, model){
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
let istLokal = false;
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* ungültige URL: bleibt false */ }
const body = {
model: model || 'gpt-4o-mini',
messages: [
{ role: 'system', content: KI_AUFGABEN_SYSTEM_PROMPT },
{ role: 'user', content: aufgabenTextFuerKi(punkte) }
],
response_format: { type: 'json_schema', json_schema: { name: 'protokoll_aufgaben', strict: true, schema: KI_AUFGABEN_SCHEMA } },
// Kleiner als bei der Punkte-Erkennung (erkennePunkteMitChatGPT oben):
// die Ausgabe ist nur eine Handvoll kurzer Aufgaben, kein ganzes Protokoll.
max_tokens: 4096,
temperature: 0
};
if(istLokal) body.reasoning_effort = 'none';
const res = await fetchMitTimeout(zielUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
body: JSON.stringify(body)
});
if(!res.ok){
const errBody = await res.text().catch(() => '');
throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
}
const data = await res.json();
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
if(!content) throw new Error('ChatGPT lieferte keine Antwort.');
let parsed;
try { parsed = JSON.parse(content); } catch(e){ throw new Error('ChatGPT-Antwort war kein gültiges JSON.'); }
if(!Array.isArray(parsed.aufgaben)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
return parsed.aufgaben.map(normalisiereAufgabeAusKi);
}
async function leiteAufgabenMitClaude(punkte, apiKey, apiUrl, model){
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
model: model || 'claude-haiku-4-5-20251001',
max_tokens: 4096,
temperature: 0,
system: KI_AUFGABEN_SYSTEM_PROMPT,
messages: [{ role: 'user', content: aufgabenTextFuerKi(punkte) }],
tools: [{
name: 'protokoll_aufgaben',
description: 'Die aus den Besprechungspunkten abgeleiteten Aufgaben.',
input_schema: KI_AUFGABEN_SCHEMA
}],
tool_choice: { type: 'tool', name: 'protokoll_aufgaben' }
})
});
if(!res.ok){
const errBody = await res.text().catch(() => '');
throw new Error('Claude antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
}
const data = await res.json();
const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null;
if(!toolUse || !toolUse.input || !Array.isArray(toolUse.input.aufgaben)){
throw new Error('Claude-Antwort hatte nicht die erwartete Form.');
}
return toolUse.input.aufgaben.map(normalisiereAufgabeAusKi);
}
async function leiteAufgabenMitKI(punkte, userId, provider){
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
if(provider === 'chatgpt') return leiteAufgabenMitChatGPT(punkte, apiKey, apiUrl, model);
if(provider === 'claude') return leiteAufgabenMitClaude(punkte, apiKey, apiUrl, model);
throw new Error('Unbekannter Anbieter: ' + provider);
}
// Route 1/2: liest ein hochgeladenes PDF (roh im Body, wie
// /project-protokolle/import) und erkennt daraus Besprechungspunkte — nutzt
// DIESELBE Erkennung wie der echte Protokoll-Import (erkennePunkteMitKI()/
// erkennePunkteAusText()), legt aber NICHTS an: kein project_protokolle-
// Eintrag, keine Datei auf der Festplatte (anders als der Import, der beides
// immer persistiert — diese Funktion ist bewusst ein Ad-hoc-Werkzeug ohne
// Archiv, siehe Kopfkommentar oben). Die erkannten Punkte gehen direkt an
// den Client zurück und werden von der zweiten Route unten (ohne erneuten
// Server-Lookup) weiterverarbeitet.
app.post('/plan-todos-aus-protokoll/einlesen', requireAuth, express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => {
try {
const buf = req.body;
if(!Buffer.isBuffer(buf) || buf.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen.' });
}
const text = await extrahierePdfText(buf);
let punkte, erkennungsart = 'textmuster', erkennungshinweis = '';
const anbieter = req.query.anbieter;
if(anbieter === 'chatgpt' || anbieter === 'claude'){
try {
const ergebnis = await erkennePunkteMitKI(text, req.user.id, anbieter);
punkte = ergebnis.punkte;
erkennungsart = anbieter;
} catch(err){
console.warn('KI-Erkennung (TODOs aus Protokoll) fehlgeschlagen, Rückfall auf Textmuster:', err.message);
erkennungshinweis = err.message;
punkte = erkennePunkteAusText(text);
}
} else {
punkte = erkennePunkteAusText(text);
}
res.json({ ok: true, punkte, erkennungsart, erkennungshinweis });
} catch(err){
console.error('TODOs aus Protokoll (Einlesen) fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route 2/2: leitet aus den (vom Client ausgewählten) Punkten Aufgaben-
// Vorschläge ab. Die Punkte kommen direkt im Body — kein Bezug zu
// project_protokolle, siehe Kopfkommentar oben.
app.post('/plan-todos-aus-protokoll/aufgaben-vorschlag', requireAuth, async (req, res) => {
try {
const { anbieter, punkte } = req.body || {};
if(!Array.isArray(punkte) || punkte.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Punkte ausgewählt.' });
}
let vorschlaege, erkennungsart = 'direkt', erkennungshinweis = '';
if(anbieter === 'chatgpt' || anbieter === 'claude'){
try {
vorschlaege = await leiteAufgabenMitKI(punkte, req.user.id, anbieter);
erkennungsart = anbieter;
} catch(err){
console.warn('KI-Ableitung (TODOs aus Protokoll) fehlgeschlagen, Rückfall auf 1:1-Übernahme:', err.message);
erkennungshinweis = err.message;
vorschlaege = null;
}
}
if(!vorschlaege){
// Direkter Rückfall: je ausgewähltem Punkt ein Vorschlag, derselbe
// Rückfall-Geist wie erkennePunkteAusText() beim Import.
vorschlaege = punkte.map((p, i) => ({
titel: String(p.thema || '').trim() || String(p.text || '').slice(0, 80),
beschreibung: String(p.text || ''),
faelligkeit: String(p.termin || ''),
zustaendigVorschlag: String(p.zustaendig || ''),
quellRefs: [i]
}));
}
res.json({ ok: true, vorschlaege, erkennungsart, erkennungshinweis });
} catch(err){
console.error('TODOs aus Protokoll (Aufgaben-Vorschlag) 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
@@ -4679,18 +4977,25 @@ async function erkennePunkteMitKI(text, userId, provider){
const PLAN_CHECK_SYSTEM_PROMPT = 'Du prüfst eine technische Planzeichnung (Architektur/TGA) auf das Vorhandensein ' const PLAN_CHECK_SYSTEM_PROMPT = 'Du prüfst eine technische Planzeichnung (Architektur/TGA) auf das Vorhandensein '
+ 'bestimmter Merkmale. Du bekommst eine nummerierte Liste von Merkmalen mit Prüfanweisung. Gib für JEDES Merkmal aus der ' + 'bestimmter Merkmale. Du bekommst eine nummerierte Liste von Merkmalen mit Prüfanweisung. Gib für JEDES Merkmal aus der '
+ 'Liste genau einen Eintrag zurück, mit dem Titel exakt wie angegeben. "erfuellt" ist nur dann true, wenn das Merkmal auf ' + 'Liste genau einen Eintrag zurück, mit dem Titel exakt wie angegeben. "erfuellt" ist nur dann true, wenn das Merkmal auf '
+ 'der Zeichnung eindeutig zu erkennen ist - im Zweifel false. "begruendung" ist KURZ - ein bis zwei Sätze, NIE eine ' + 'der Zeichnung eindeutig zu erkennen ist - im Zweifel false. "begruendung" ist HÖCHSTENS 150 ZEICHEN LANG - eine feste '
+ 'Aufzählung einzelner Werte (z. B. nicht jede einzelne Maßzahl auflisten) - beschreibe stattdessen zusammenfassend, was ' + 'Obergrenze, kein Stilwunsch. Zähle NIEMALS einzelne Werte auf (keine Maßzahlen, keine Höhenangaben, keine '
+ 'zu sehen ist. Erfinde nichts.'; + 'Koordinaten) - auch nicht als Beispiel ("z. B. ...") und auch nicht, wenn auf der Zeichnung sehr viele davon zu sehen '
+ 'sind: beschreibe stattdessen in einem knappen Satz, DASS und WO solche Angaben vorkommen. Erfinde nichts.';
// Wie bei KI_SYSTEM_PROMPT oben gilt: Formulierungsänderungen können das // Wie bei KI_SYSTEM_PROMPT oben gilt: Formulierungsänderungen können das
// Ergebnis stark verschieben (siehe dortige Projekthistorie). Der Zusatz // Ergebnis stark verschieben (siehe dortige Projekthistorie). Der erste
// "NIE eine Aufzählung einzelner Werte" kam nach einem echten Fehlschlag // Zusatz ("NIE eine Aufzählung einzelner Werte", ohne Zeichengrenze) kam nach
// dazu: Ein lokal über LM Studio angebundenes Modell zählte für "Maßketten // einem echten Fehlschlag dazu: Ein lokal über LM Studio angebundenes Modell
// vorhanden" jede einzelne gefundene Maßzahl einzeln auf ("2,50 m, 3,12 m, // zählte für "Maßketten vorhanden" jede einzelne gefundene Maßzahl einzeln
// 1,20 m, 2,10 m, ..."), wodurch die Antwort noch vor Abschluss des JSON an // auf ("2,50 m, 3,12 m, 1,20 m, 2,10 m, ..."), wodurch die Antwort noch vor
// max_tokens (s. u.) abgeschnitten wurde und als Ganzes nicht mehr parsbar // Abschluss des JSON an max_tokens (s. u.) abgeschnitten wurde und als Ganzes
// war ("ChatGPT-Antwort war kein gültiges JSON"). Das ursprüngliche "ein bis // nicht mehr parsbar war ("ChatGPT-Antwort war kein gültiges JSON"). Reichte
// zwei Sätze" allein reichte diesem Modell als Grenze nicht aus. // demselben Modell beim nächsten Fehlschlag aber IMMER NOCH nicht: Es zählte
// diesmal sogar "1,00, 2,00, 3,00 ... 22,00" durch und sprengte damit auch
// das inzwischen angehobene max_tokens erneut. Eine semantische Anweisung
// ("halt dich kurz") lässt sich offenbar umgehen, eine harte Zahl
// ("höchstens 150 Zeichen") ist für ein Modell mechanisch leichter zu prüfen
// - ebenfalls noch nicht an diesem konkreten Fehlschlag gegengetestet, siehe
// Hinweis bei max_tokens.
// PLAN_CHECK_BILD_ANZAHL: Gesamtzahl der an die KI gesendeten Bilder (1 // PLAN_CHECK_BILD_ANZAHL: Gesamtzahl der an die KI gesendeten Bilder (1
// Übersicht + die Kacheln, s. renderPdfAlsBildsatz() oben) - bestimmt den // Übersicht + die Kacheln, s. renderPdfAlsBildsatz() oben) - bestimmt den
@@ -4798,14 +5103,15 @@ async function pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, mode
] } ] }
], ],
response_format: { type: 'json_schema', json_schema: { name: 'plan_pruefung', strict: true, schema: PLAN_CHECK_SCHEMA } }, response_format: { type: 'json_schema', json_schema: { name: 'plan_pruefung', strict: true, schema: PLAN_CHECK_SCHEMA } },
// 4096 war zu knapp bemessen: ein lokal angebundenes Modell hielt sich // 4096 war zu knapp, dann 8192 - ein lokal angebundenes Modell hielt sich
// trotz "ein bis zwei Sätze" nicht an die Kürze (s. PLAN_CHECK_SYSTEM_PROMPT // trotz zunehmend schärferer Anweisung (s. PLAN_CHECK_SYSTEM_PROMPT oben,
// oben) und die Antwort wurde mitten im JSON abgeschnitten. 8192 als // Projekthistorie dort) wiederholt nicht an die Kürze, und die Antwort
// Sicherheitsnetz zusätzlich zur geschärften Prompt-Anweisung - beide // wurde mitten im JSON abgeschnitten. 16000 angeglichen an das Limit, das
// Maßnahmen zusammen, da sich (wie schon bei KI_SYSTEM_PROMPT dokumentiert) // sich für genau dasselbe Problem beim Protokoll-Import bereits bewährt
// nicht verlässlich vorhersagen lässt, ob ein Modell eine Kürze-Anweisung // hat (erkennePunkteMitChatGPT() oben) - weiterhin nur ein Sicherheitsnetz
// tatsächlich befolgt. // zusätzlich zur Prompt-Anweisung, keine Garantie: Ein Modell, das
max_tokens: 8192, // entschlossen genug aufzählt, kann auch dieses Limit erreichen.
max_tokens: 16000,
temperature: 0 temperature: 0
}; };
// Siehe erkennePunkteMitChatGPT() oben: manche lokale Server laden // Siehe erkennePunkteMitChatGPT() oben: manche lokale Server laden
@@ -4832,12 +5138,21 @@ async function pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, mode
const bereinigt = content.trim().replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, ''); const bereinigt = content.trim().replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, '');
let parsed; let parsed;
try { parsed = JSON.parse(bereinigt); } catch(e){ try { parsed = JSON.parse(bereinigt); } catch(e){
// Rohtext der Antwort im Fehler, wie bei den HTTP-Fehlern oben // finish_reason:'length' heißt konkret "max_tokens erreicht, bevor das
// (errBody.slice(0, 200)) - ohne das war ein lokal/drittanbieter- // Modell fertig war" - der häufigste real beobachtete Grund für
// bedingtes Scheitern (z. B. ein Modell, das nur EIN Bild pro Anfrage // ungültiges JSON hier (ein Modell, das trotz PLAN_CHECK_SYSTEM_PROMPT
// verarbeiten kann und bei mehreren Bildern mit Fließtext statt JSON // einzelne Werte aufzählt, s. dortige Projekthistorie). Eine konkrete
// antwortet) nur über die Server-Konsole nachvollziehbar. // Fehlermeldung dafür erspart das erneute Einschicken des rohen
throw new Error('ChatGPT-Antwort war kein gültiges JSON: ' + content.slice(0, 300)); // Antworttexts zur Diagnose. Rohtext zusätzlich im Fehler, wie bei den
// HTTP-Fehlern oben (errBody.slice(0, 200)) - andere Scheitern-Gründe
// (z. B. ein Modell, das nur EIN Bild pro Anfrage verarbeiten kann und
// bei mehreren Bildern mit Fließtext statt JSON antwortet) bleiben so
// trotzdem sichtbar.
const abgeschnitten = data.choices[0].finish_reason === 'length';
const hinweis = abgeschnitten
? 'Die Antwort wurde abgeschnitten (Modell hat das Token-Limit erreicht, bevor es fertig war - vermutlich zu ausführliche Begründungen). '
: '';
throw new Error('ChatGPT-Antwort war kein gültiges JSON: ' + hinweis + content.slice(0, 300));
} }
if(!Array.isArray(parsed.ergebnisse)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.'); if(!Array.isArray(parsed.ergebnisse)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
return parsed.ergebnisse; return parsed.ergebnisse;
@@ -4875,10 +5190,11 @@ async function pruefePlanMitClaude(bilderBase64, merkmale, apiKey, apiUrl, model
// bleibt trotzdem die richtige Vorgabe für diese anspruchsvolle // bleibt trotzdem die richtige Vorgabe für diese anspruchsvolle
// Bildaufgabe. Konto-Einstellung überschreibt auch diese Vorgabe. // Bildaufgabe. Konto-Einstellung überschreibt auch diese Vorgabe.
model: model || 'claude-sonnet-5', model: model || 'claude-sonnet-5',
// 8192 statt 4096 - gleiches Sicherheitsnetz wie bei pruefePlanMitChatGPT() // 16000 - dasselbe Sicherheitsnetz, auf denselben Wert angehoben wie bei
// oben, selbes Risiko gilt grundsätzlich auch hier (eine zu ausführliche // pruefePlanMitChatGPT() oben (Projekthistorie dort), selbes Risiko gilt
// Begründung, die vor Abschluss des JSON abgeschnitten wird). // grundsätzlich auch hier (eine zu ausführliche Begründung, die vor
max_tokens: 8192, // Abschluss des JSON abgeschnitten wird).
max_tokens: 16000,
temperature: 0, temperature: 0,
system: PLAN_CHECK_SYSTEM_PROMPT, system: PLAN_CHECK_SYSTEM_PROMPT,
messages: [{ role: 'user', content: [ messages: [{ role: 'user', content: [
@@ -5436,6 +5752,51 @@ app.post('/plan-checker-pruefen', requireAuth, express.raw({ type: '*/*', limit:
} }
const merkmale = db.prepare('SELECT titel, prompt FROM plan_checker_merkmale ORDER BY sort_order, titel').all(); const merkmale = db.prepare('SELECT titel, prompt FROM plan_checker_merkmale ORDER BY sort_order, titel').all();
// Projekt-Abgleich (Eriks Wunsch): optional, genau wie das Zusatz-Merkmal
// weiter unten nur für DIESE eine Prüfung und nicht gespeichert - kein
// neues Feld in plan_checker_merkmale, sondern derselbe Mechanismus, nur
// mit einem serverseitig gebauten statt frei getipptem Text. Das Projekt
// selbst kommt aus dem bereits beim Client geladenen db.weeks (die
// Auswahl im Frontend liest currentWeek().projects, kein eigener
// Endpunkt) - der Server bekommt nur Nummer/Bezeichnung als Query-Param,
// prüft sie aber NICHT gegen die Datenbank (reine Texteingabe für den
// Prompt, wie das Zusatz-Merkmal auch). Bewusst nur Projektnummer +
// Bezeichnung: Das sind die einzigen Projekt-Stammdaten, die es im
// Datenmodell überhaupt gibt - ein Plankopf kennt zusätzlich
// Planersteller, Maßstab, Plannummer, Datum, aber dafür gibt es keine
// gespeicherte Vorgabe, gegen die man abgleichen könnte.
const projektNr = String(req.query.projektNr || '').trim();
const projektName = String(req.query.projektName || '').trim();
if(projektNr || projektName){
const soll = [
projektNr ? `Projektnummer "${projektNr}"` : null,
projektName ? `Projektbezeichnung "${projektName}"` : null
].filter(Boolean).join(' und ');
// Erste Fassung fragte direkt "stimmt der Plankopf mit X überein?" -
// ein echter Fehlschlag zeigte, dass das zu leicht als "bestätige X"
// statt "lies ab und vergleiche dann" gelesen wird: Die KI gab exakt
// die vorgegebenen Werte als angeblich abgelesene Werte zurück und
// erklärte sie für übereinstimmend, obwohl im Plankopf tatsächlich eine
// andere Nummer stand (Erik hat das am echten Ergebnis nachgewiesen).
// Jetzt zweistufig erzwungen: ERST ablesen UND DAS GELESENE in der
// Begründung wörtlich nennen, DANN erst vergleichen - macht die
// Vorgabewerte nicht Teil der "Lese"-Aufgabe und macht einen
// Bestätigungsfehler wie diesen für einen Menschen beim Lesen der
// Begründung sofort erkennbar (genau wie Erik ihn gefunden hat).
merkmale.push({
titel: 'Plankopf stimmt mit Projekt überein',
prompt: 'Lies zuerst die im Plankopf (Schriftfeld) TATSÄCHLICH abgedruckte Projektnummer und/oder '
+ 'Projektbezeichnung ab - unabhängig davon, was du gleich vergleichen sollst. Nenne diese tatsächlich '
+ 'abgelesenen Werte als ERSTES in der Begründung, wörtlich wie abgedruckt. Vergleiche ERST DANACH, ob '
+ `diese abgelesenen Werte zu ${soll} passen. Sinngemäße Übereinstimmung genügt (Abkürzungen, leicht `
+ 'abweichende Schreibweise, zusätzliche Wörter), eine erkennbar ANDERE Nummer oder ein erkennbar anderes '
+ 'Projekt dagegen nicht. "erfuellt" ist nur dann true, wenn die TATSÄCHLICH abgelesenen Werte passen - '
+ 'nicht schon deshalb, weil das erwartete Projekt plausibel klingt. Ist die Angabe im Plankopf nicht '
+ 'lesbar, sage das, statt die erwarteten Werte zu wiederholen.'
});
}
const zusatz = String(req.query.zusatzMerkmal || '').trim(); const zusatz = String(req.query.zusatzMerkmal || '').trim();
if(zusatz) merkmale.push({ titel: zusatz, prompt: zusatz }); if(zusatz) merkmale.push({ titel: zusatz, prompt: zusatz });
if(merkmale.length === 0){ if(merkmale.length === 0){