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-sections.js # Bereichs-Navigation (showPlanSection, URL-Fragment)
│ │ │ # + Dashboard-Karten (renderPlanDashboard)
│ │ ├── plan-info.js # Status/Kommentar, Gruppe, amtierende LPH, LPH-%,
│ │ │ # Beteiligten-Matrix (verteilt auf Dashboard+Einstellungen)
│ │ ├── plan-info.js # Status/Kommentar, Gruppe, amtierende LPH, LPH-%
│ │ │ # (Dashboard-Bereich; die Beteiligten-Matrix ist
│ │ │ # entfallen, siehe plan-settings.js)
│ │ ├── plan-settings.js # Bereich "Einstellungen": Projektangaben, Honorar,
│ │ │ # beauftragte Leistungsphasen (renderPlanSettings)
│ │ │ # beauftragte Leistungsphasen, Beteiligten-Matrix
│ │ │ # (renderPlanSettings, planCycleAssign)
│ │ ├── plan-tasks.js # Bereich "Aufgaben": Aufgaben-/Meilensteinliste
│ │ │ # (renderPlanTasks; öffnet dieselben Dialoge wie das GANTT)
│ │ ├── plan-history.js # Kommentar-Verlauf über alle Sitzungen (+ Vorwochen-Helfer)
│ │ ├── 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-build-gantt.js # buildGanttHtml-Erweiterung (TODO- und Meilensteinzeilen)
│ │ ├── 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`
(`plan-state → plan-sections → plan-info → plan-history →
plan-build-gantt → plan-gantt → plan-drag → plan-modals → plan-tasks →
plan-files → plan-templates → plan-honorar → plan-settings →
plan-schedule`): they must come after `gantt.js`, whose `buildGanttHtml()`
plan-files → plan-templates → plan-protokoll-todos → plan-honorar →
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`, which uses its previous-week helpers. None of them
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:
- **Laufende Erfassung → Wochenbericht only.** The session comment
(`p.status`), the progress per Leistungsphase (`p.lph`) and the assignment of
people (`p.assign`) are produced in the weekly meeting. They are editable
**only** in the Wochenbericht.
(`p.status`) and the progress per Leistungsphase (`p.lph`) are produced in
the weekly meeting. They are editable **only** in the Wochenbericht.
- **Stammdaten → Projektbereich only.** Projektnummer, Bezeichnung,
Schlagwörter, Gruppe, amtierende Leistungsphase, Auftragsstatus, Honorar, the
beauftragte Leistungsphasen and the Aufwands-Gewichtung live in the
Projektbereich (`einstellungen` section, `js/plan/plan-settings.js` plus
`js/plan/plan-info.js` for Gruppe/amtierende LPH).
beauftragte Leistungsphasen, the Aufwands-Gewichtung, and **the assignment of
people (`p.assign`)** live in the Projektbereich (`einstellungen` section,
`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
`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
`.drawer-verweis` underneath with the button "Im Wochenbericht bearbeiten →".
The Projektbereich does the same via `setzeBerichtVerweis()`
(`js/plan/plan-info.js`), filling `#planStatusVerweis`, `#planLphVerweis`
and `#planAssignVerweis`.
same values, same order — but no input fields and a `.drawer-verweis`
underneath with the button "Im Wochenbericht bearbeiten →" for Kommentar/
Fortschritt. The Beteiligten-Zuordnung (`roleRows`) is now **always**
rendered read-only inside `renderBoardDrawer()` — in the Projektboard AND
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)`
(`js/plan/plan-info.js`, also used by `renderBerichtVerweis()` in
`board.js`) sets `berichtAuswahl` **before** `showTab('wochenbericht')` and
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
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
`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
Projektbereich; the Wochenbericht keeps the full editor. `editLph()`,
`cycleAssign()` and `saveBoardComment()` in `js/board/board.js` are
untouched — the Wochenbericht's editing hangs off them.
Projektbereich; the Wochenbericht keeps the full editor. `editLph()` and
`saveBoardComment()` in `js/board/board.js` are untouched — the
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
purpose (Projekteinstellungen and the "Terminplan aus Honorar" dialog, so the
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
indices match because the list keeps `plan.todos`/`plan.milestones` order.
The create buttons (`+ Aufgabe`, `+ Meilenstein`, `+ Vorlage`,
`+ Terminplan`) exist twice — in the Gantt toolbar and above the list, the
latter with an `…BtnList` id suffix; `renderPlanPage()` hides both sets on
locked weeks.
**A todo is only a Gantt row when it has BOTH a start and an end date.**
`+ Terminplan`, `+ TODOs aus Protokoll`) exist twice — in the Gantt
toolbar and above the list, the latter with an `…BtnList` id suffix;
`renderPlanPage()` hides both sets on locked weeks.
**"+ 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
up here in Aufgaben, with its assignees (`t.assignees`, "Zuständige"
chips in `openTodoModal()`, unrelated to whether it has a schedule) — but
`plan-build-gantt.js` (the `buildGanttHtml()` extension that inserts
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`
or `+ TODO` with the date range left empty — nothing in `saveTodoEdit()`
requires dates, only the title) stays a pure checklist item that appears
solely here, never in the Gantt/Terminplan. The Aufgaben row for such a
todo shows "ohne Termin" in place of a date range
(`.plan-task-no-schedule`). Giving it a start/end later (editing the same
todo through either view) makes a Gantt row for it appear on the next
render — same object, no separate "convert to scheduled" action needed.
with `todo.start && todo.end && todo.showInGantt !== false`; a
title-only todo (created via `+ Aufgabe` or `+ TODO` with the date range
left empty — nothing in `saveTodoEdit()` requires dates, only the title)
stays a pure checklist item that appears solely here, never in the
Gantt/Terminplan. The Aufgaben row for such a todo shows "ohne Termin" in
place of a date range (`.plan-task-no-schedule`). Giving it a start/end
later (editing the same todo through either view) makes a Gantt row for
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
DOM-row-count safety check and the reordering itself only ever touch
todos that have `start && end` (`scheduledIds`) — a title-only 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.
todos that have `start && end && showInGantt !== false`
(`scheduledIds`) — exactly the same condition as the insertion above, so
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()`,
`js/plan/plan-leistung.js`): the value actually earned, per beauftragte
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"
mechanism.
- **Einstellungen** — project number/name (display only — the page is
addressed by them), Gruppe, amtierende Leistungsphase, Beteiligten-Matrix
(all still rendered by `plan-info.js`), plus Honorar and the **beauftragte
Leistungsphasen** (`renderPlanSettings()`, `js/plan/plan-settings.js`).
addressed by them), Gruppe, amtierende Leistungsphase (both still
rendered by `plan-info.js`), plus Honorar, the **beauftragte
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
the "Terminplan aus Honorar" dialog (which keeps them so it stays usable on
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
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 compose up --build` from the repo root. Compose maps `3003:3003`,
+51 -2
View File
@@ -323,6 +323,14 @@
</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">
<label class="pc-label">KI-Anbieter</label>
<select id="planCheckerAnbieter" class="pc-select" style="width:100%"></select>
@@ -879,6 +887,7 @@
<button onclick="addTodo()" id="addTodoBtn">+ TODO</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="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="toggleCriticalPath()" id="criticalPathBtn" title="Kritischen Pfad ein-/ausblenden">Kritischer Pfad</button>
<!-- Nur für Sandbox-Pläne der Planung sichtbar (renderPlanPage(),
@@ -902,6 +911,7 @@
<button onclick="addPlanMilestone()" id="addMilestoneBtnList">+ Meilenstein</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="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>
</div>
@@ -1056,9 +1066,8 @@
<div class="plan-panel plan-panel-wide">
<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="plan-verweis-row" id="planAssignVerweis"></div>
</div>
</div>
</section>
@@ -1250,6 +1259,41 @@
</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.
Eigenständiger Dialog — der frühere gemeinsame Dialog mit Modus-Umschalter
("Aus Vorlage" / "Aus Honorar") wurde in zwei getrennte Dialoge aufgeteilt,
@@ -1401,6 +1445,10 @@
<input type="hidden" id="todoEditEnd">
<span class="field-error" id="todoEditRangeError" hidden></span>
<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 class="form-row">
@@ -1766,6 +1814,7 @@
<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-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>
<!-- plan-leistung.js muss NACH plan-honorar.js stehen: es umhuellt dessen
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
// LPH-Balken). Sie wird von `App.html` nach `js/weeks.js` (vor
// `js/people/people.js`) geladen und stellt globale Funktionen wie
// `renderBoard()`, `toggleBoardRow()`, `cycleAssign()`,
// `renderBoard()`, `toggleBoardRow()`,
// und `editLph()` bereit. Sie nutzt `currentWeek()`/
// `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`,
// `assignKey()`, `statusFocus()`, `sanitizeStatusHtml()` und
// `getProgressColor()` aus `js/utils.js` sowie `openProjectPlan()` aus
// `js/gantt.js` — navigiert bewusst im selben Tab (kein window.open() mehr),
// 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
// 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
// darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt
@@ -28,17 +31,20 @@
// persistiert — jede Sitzung startet mit eingeklappten Zeilen.
let boardRowOpen = {};
// berichtDisclosureState: merkt sich je Abschnitt (Leistungsphasen, Beteiligte,
// Anwesenheit), ob er im Wochenbericht (Fokus-Modus — Eriks Wahl "C" aus dem
// Darstellungs-Vergleich: renderBoardDrawer()s dichte Fassung weiter unten +
// renderAttendance() in js/wochenbericht/wochenbericht.js) gerade auf- oder
// zugeklappt ist. Ohne dieses Gedächtnis klappte jeder Abschnitt bei jedem
// Neu-Rendern wieder auf seinen Vorgabewert zurück — und JEDE Änderung in der
// Schublade (eine Zuordnung, ein LPH-Wert) 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
// berichtDisclosureState: merkt sich, ob die Anwesenheit im Wochenbericht
// (renderAttendance(), js/wochenbericht/wochenbericht.js) gerade auf- oder
// zugeklappt ist. Ursprünglich auch für Leistungsphasen/Beteiligte gedacht
// (damals beide ebenfalls Aufklapper, Fokus-Modus "C"); seit Eriks
// Rückmeldung zum Entwurfsvergleich ("Aktenregal") sind beide wieder
// dauerhaft sichtbar (renderBoardDrawer()s dichte Fassung weiter unten) und
// brauchen dieses Gedächtnis nicht mehr — nur die Anwesenheit bleibt ein
// Aufklapper. Ohne dieses Gedächtnis klappte sie bei jedem Neu-Rendern wieder
// 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).
const berichtDisclosureState = { lph: true, beteiligte: false, anwesenheit: false };
const berichtDisclosureState = { anwesenheit: false };
document.addEventListener('toggle', function(e){
const key = e.target && e.target.dataset && e.target.dataset.berichtAbschnitt;
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.
// opts.nurAnsicht: die Karte zeigt, sie erfasst nicht.
//
// Kommentar, Fortschritt je Leistungsphase und die Zuordnung der
// Beteiligten entstehen in der wöchentlichen Besprechung und werden deshalb
// allein im Wochenbericht gepflegt. Zuvor waren dieselben drei Angaben an
// drei Stellen bearbeitbar (Projektboard, Wochenbericht, Projektbereich) —
// niemand wusste, welche die richtige ist, und zwei Personen konnten
// dasselbe Feld an verschiedenen Stellen ändern. Dieselbe Karte, dieselben
// Werte, nur ohne Eingabefelder und mit einem Verweis dorthin, wo sie
// gepflegt werden. Eine gesperrte Sitzung wirkt weiterhin zusätzlich.
// Kommentar und Fortschritt je Leistungsphase entstehen in der
// wöchentlichen Besprechung und werden deshalb allein im Wochenbericht
// gepflegt. Zuvor waren dieselben Angaben an drei Stellen bearbeitbar
// (Projektboard, Wochenbericht, Projektbereich) — niemand wusste, welche
// die richtige ist, und zwei Personen konnten dasselbe Feld an
// verschiedenen Stellen ändern. Dieselbe Karte, dieselben Werte, nur ohne
// Eingabefelder und mit einem Verweis dorthin, wo sie gepflegt werden.
// 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 starr = ansicht || locked;
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
const nurBeauftragte = !!(opts && opts.nurBeauftragteLph) && beauftragt.length > 0;
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
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
// 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 chips = entries.length ? entries.map(entry=>{
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}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}" ${click} title="${esc(entry.person.fullName || entry.person.name)}">${esc(entry.person.name)}</span>`;
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>`;
}).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>`;
}).join('');
// Dichte Fassung (Wochenbericht): erst zwei, dann drei Spalten nebeneinander
// — bei 29 Projekten mit allem gleichzeitig sichtbar kam von Erik die
// Rückmeldung "zu überladen". Jetzt (Fokus-Modus, Eriks Wahl "C" aus dem
// Darstellungs-Vergleich) liegt nur der Kommentar offen, Leistungsphasen
// und Beteiligte stecken in aufklappbaren Abschnitten untereinander.
// Dichte Fassung (Wochenbericht), zweite Fassung (Eriks Rückmeldung zum
// Entwurfsvergleich "Aktenregal"): Leistungsphasen und Beteiligte stehen
// jetzt dauerhaft sichtbar nebeneinander statt hinter Aufklappern verborgen
// — "die Beteiligten sollen mit angezeigt werden". Die erste Fokus-Modus-
// 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
// sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in
// mehreren Rollen vorkommen, und cycleAssign() braucht die Rolle. Ohne sie
// wäre beim Klicken nicht erkennbar, welche Zuordnung man durchschaltet.
// mehreren Rollen vorkommen, und ohne die Zeile wäre nicht erkennbar, in
// 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){
// 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
? esc(aktLp.replace('LPH ', 'LP ')) + ' aktiv' + (p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? ' · ' + esc(p.lph[aktLp]) + '%' : '')
: 'Keine aktive Leistungsphase';
const zugeordnetAnzahl = allPeople.filter(e => (p.assign[assignKey(e.person.id, e.role)] || 0) > 0).length;
: 'keine aktiv';
return `<div class="board-drawer board-drawer-dicht">
${renderCommentEditor(p, weekLabel, locked, true)}
<details class="bericht-disclosure" data-bericht-abschnitt="lph" ${berichtDisclosureState.lph ? 'open' : ''}>
<summary><span>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</span><span class="count">${lphInfo}</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body">${lphBarRows}</div>
</details>
<details class="bericht-disclosure" data-bericht-abschnitt="beteiligte" ${berichtDisclosureState.beteiligte ? 'open' : ''}>
<summary><span>Beteiligte</span><span class="count">${zugeordnetAnzahl} zugeordnet</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body"><div class="board-role-mini">${roleRows}</div></div>
</details>
<div class="bericht-meta-grid">
<div>
<div class="bericht-meta-label">${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'} · ${lphInfo}</div>
${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>'}
</div>
<div>
<div class="bericht-meta-label">Beteiligte</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>
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
</div>`;
}
@@ -582,6 +597,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
<div class="drawer-col drawer-col-people">
<h4>Beteiligte</h4>
<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>
${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>';
const uid = esc(p.projectUid || '');
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>
</div>`;
}
@@ -673,20 +689,6 @@ function renderBoard(){
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
// Projekt. Parameter: `pid` (Projekt-ID), `lph` (Leistungsphasen-Schlüssel),
// `val` (Eingabewert). Leere/`%`-Werte werden bereinigt; keine Rückgabe.
@@ -696,7 +698,8 @@ function editLph(pid,lph,val){
val = val.trim();
if(val==="") delete p.lph[lph];
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
+13
View File
@@ -323,6 +323,19 @@ function openProjectPlan(pid){
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
// in der Kopfzeile des Projektbereichs. Früher ging es hier in der
// 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
// jede Aufgabe aus "Aufgaben" landet also im GANTT, wohl aber jede
// 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);
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
insertAfter = todoRow;
+11 -5
View File
@@ -113,11 +113,17 @@
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
// Nur terminierte Aufgaben (Start UND Ende gesetzt) haben überhaupt eine
// eigene GANTT-Zeile (siehe plan-build-gantt.js) und damit einen
// Ziehgriff — Aufgaben ohne Termin bleiben an ihrer Stelle
// in plan.todos stehen, sie können hier gar nicht bewegt worden sein.
const scheduledIds = new Set(todos.filter(t => t.start && t.end).map(t => t.id));
// Nur terminierte Aufgaben (Start UND Ende gesetzt) MIT showInGantt !==
// false haben überhaupt eine eigene GANTT-Zeile (exakt dieselbe
// Bedingung wie in plan-build-gantt.js) und damit einen Ziehgriff —
// Aufgaben ohne Termin oder bewusst aus dem GANTT ausgeblendete bleiben
// 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
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
// dabei zwei Arten von Angaben, und das ist der Kern der Sache:
//
// * NUR ANZEIGE — Kommentar der Sitzung (#planStatusEdit), Fortschritt je
// Leistungsphase (#planLphList) und die Zuordnung der Beteiligten
// (#planAssignMatrix). Diese drei entstehen in der wöchentlichen
// * NUR ANZEIGE — Kommentar der Sitzung (#planStatusEdit) und Fortschritt
// je Leistungsphase (#planLphList). Beide entstehen in der wöchentlichen
// Besprechung und werden ausschließlich im Wochenbericht erfasst. Neben
// jeder von ihnen steht ein Verweis dorthin (setzeBerichtVerweis()), der
// hinüberwechselt UND dort dieses Projekt einstellt.
// * STAMMDATEN, hier pflegbar — Gruppe (#planGroupSelect) und amtierende
// 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
// richtige ist, und zwei Personen konnten dasselbe Feld an verschiedenen
// Stellen ändern. Seitdem hat jede Angabe genau einen Ort, an dem sie
@@ -21,11 +20,20 @@
// früheren Schreibfunktionen dieses Moduls (planStatusBlur(), setPlanLph(),
// setPlanAssign()) sind damit entfallen.
//
// BEWUSST NICHT hier: Projektnummer, Bezeichnung, Schlagwörter, Honorar und
// beauftragte Leistungsphasen. Sie sind ebenfalls Stammdaten, werden aber von
// js/plan/plan-settings.js gerendert (Honorar und beauftragte Phasen
// geschrieben von setHonorarBetrag()/toggleBeauftragteLp() aus
// js/plan/plan-honorar.js).
// Die Zuordnung der Beteiligten (p.assign) gehörte früher ebenfalls zu
// dieser "nur Anzeige"-Gruppe — auf Eriks ausdrücklichen Wunsch ist sie
// seitdem umgekehrt: kein reines Anzeigefeld mehr, sondern ein echtes
// Stammdatum, und zwar NUR HIER im Projektbereich editierbar (Bereich
// "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
// renderPlanPage() (plan-state.js) nach jedem Laden aufgerufen; Änderungen
@@ -128,25 +136,8 @@
}).join('');
}
setzeBerichtVerweis('planLphVerweis', locked);
// Beteiligte — nur Anzeige. Nicht zugeordnete Personen erscheinen
// 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);
// Beteiligte: siehe js/plan/plan-settings.js (renderPlanSettings()) —
// Stammdatum, nicht mehr hier.
};
// setzeBerichtVerweis: füllt einen Hinweis-Platz mit dem Weg zur
@@ -202,9 +193,12 @@
};
// Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind
// entfallen. Fortschritt, Zuordnung und Kommentar werden allein im
// Wochenbericht erfasst (siehe den Kopf dieser Datei); hier gab es damit
// keinen Aufrufer mehr. Die gleichnamigen Funktionen des Projektboards
// (editLph()/cycleAssign()/saveBoardComment() in js/board/board.js) sind
// davon unberührt — dort hängt die Erfassung des Wochenberichts dran.
// entfallen. Fortschritt und Kommentar werden allein im Wochenbericht
// erfasst (siehe den Kopf dieser Datei); hier gab es damit keinen Aufrufer
// mehr. Die gleichnamigen Funktionen des Projektboards
// (editLph()/saveBoardComment() in js/board/board.js) sind davon
// 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();
// 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);
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
applyTodoStatus(todoStatusFrom(currentTodo), storedProgress);
@@ -563,11 +571,14 @@
const assigneesDiv = document.getElementById('todoEditAssignees');
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();
if(!Array.isArray(plan.todos)) plan.todos = [];
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]){
const t = plan.todos[app.currentTodoIndex];
t.title = title;
@@ -577,6 +588,7 @@
t.progress = progress;
t.dependsOn = dependsOn;
t.assignees = assignees;
t.showInGantt = showInGantt;
}
app.savePlan();
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
@@ -584,7 +596,7 @@
todoModalSnapshot = todoFormSnapshot();
closeTodoModal();
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,
+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
// js/plan/plan-info.js — dieses Modul rührt sie nicht an)
// * 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
// 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
// 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
// des Wochen-JSON, Tabellen projects.honorar bzw. project_beauftragte_lph) —
// es gibt dafür keinen eigenen Endpunkt.
// des Wochen-JSON, Tabellen projects.honorar/project_assign bzw.
// project_beauftragte_lph) — es gibt dafür keinen eigenen Endpunkt.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
@@ -151,6 +163,47 @@
${esc(l)}
</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.
+2 -2
View File
@@ -249,8 +249,8 @@ window.PlanApp = {
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html.
const locked = isLocked();
['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'autoScheduleBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'autoScheduleBtnList']
['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn', 'autoScheduleBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList']
.forEach(id => {
const btn = document.getElementById(id);
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",
// 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
// 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):
// zwei Spalten dauerhaft nebeneinander — links Upload/Konfiguration, rechts
@@ -41,13 +45,42 @@ let planCheckerDatei = null;
let planCheckerMerkmale = [];
// renderPlanChecker: Einstiegspunkt, von showTab('planchecker') gerufen.
// Lädt die Merkmale-Grundliste und die KI-Anbieter-Auswahl neu — beide
// können sich seit dem letzten Besuch geändert haben (Admin-Pflege bzw.
// eigener Schlüssel unter Einstellungen → Mein Konto). Keine Parameter,
// keine Rückgabe.
// Lädt die Merkmale-Grundliste, die KI-Anbieter-Auswahl und die Projektliste
// neu — alle drei können sich seit dem letzten Besuch geändert haben
// (Admin-Pflege, eigener Schlüssel unter Einstellungen → Mein Konto, bzw.
// ein inzwischen angelegtes/umbenanntes Projekt). Keine Parameter, keine
// Rückgabe.
function renderPlanChecker(){
ladePlanCheckerMerkmale();
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
@@ -143,7 +176,7 @@ function planCheckerDrop(e){
function planCheckerLadezustand(aktiv){
const btn = document.getElementById('planCheckerPruefenBtn');
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);
if(el) el.disabled = aktiv;
});
@@ -164,9 +197,20 @@ function planCheckerPruefen(){
const zusatzEl = document.getElementById('planCheckerZusatz');
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'
+ '?provider=' + encodeURIComponent(provider)
+ '&zusatzMerkmal=' + encodeURIComponent(zusatz);
+ '&zusatzMerkmal=' + encodeURIComponent(zusatz)
+ '&projektNr=' + encodeURIComponent(projekt ? (projekt.nr || '') : '')
+ '&projektName=' + encodeURIComponent(projekt ? projekt.name : '');
const datei = planCheckerDatei;
planCheckerLadezustand(true);
+1 -1
View File
@@ -726,7 +726,7 @@ function wizardAnlegen(){
};
// Zuordnungen: Zustand 1 = Hauptverantwortung (dieselbe Bedeutung wie in
// cycleAssign(), js/board/board.js).
// planCycleAssign(), js/plan/plan-settings.js).
ROLES.forEach(r => {
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
// migrateAddProjectUid() in server.js) — die dauerhafte, wochenunabhängige
// Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu
// vergeben) nicht bieten konnte. Die Bearbeitung der Projekte selbst
// (Kommentar-Editor, LPH, Beteiligte) läuft vollständig über die bestehenden
// Funktionen aus js/board/board.js (editLph/cycleAssign/
// renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses Modul
// ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten).
// vergeben) nicht bieten konnte. Die Bearbeitung von Kommentar und LPH läuft
// vollständig über die bestehenden Funktionen aus js/board/board.js
// (editLph/renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses
// Modul ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten). Die
// 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
// diesem Tab die Sitzung abgearbeitet wird.
//
@@ -154,7 +156,10 @@ function absenceNoteFor(personId, weekDate){
}
// 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(){
const container = document.getElementById('weekAttendance');
if(!container) return;
@@ -246,9 +251,9 @@ function setAllAttendance(present){
// renderWochenbericht: baut den kompletten Wochenbericht-Tab für die eine
// offene Sitzung auf (currentWeek(), js/weeks.js) — Hinweis auf die Vorwoche,
// dann die drei Gruppen. Wird nach jedem Laden sowie nach jeder Änderung
// über editLph()/cycleAssign() (js/board/board.js) neu aufgerufen, damit ein
// Projekt sofort in die passende Gruppe wandert. Keine Parameter, keine
// Rückgabe.
// über editLph() (js/board/board.js) bzw. planCycleAssign()
// (js/plan/plan-settings.js) neu aufgerufen, damit ein Projekt sofort in die
// passende Gruppe wandert. Keine Parameter, keine Rückgabe.
// ---------- Tagesordnung: Zustand ----------
// berichtAuswahl: project_uid des Projekts, das gerade rechts im Detail
@@ -347,6 +352,8 @@ function aktuellesBerichtProjekt(w){
// Parameter: uid (project_uid). Keine Rückgabe.
function waehleBerichtProjekt(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();
const detail = document.getElementById('berichtDetail');
if(detail) detail.scrollIntoView({ block: 'nearest' });
@@ -389,7 +396,13 @@ function besprochenUndWeiter(pid){
function setzeAlleBesprochen(wert){
if(guardWeekLocked()) return;
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();
renderWochenbericht();
}
@@ -576,13 +589,17 @@ function renderBerichtKopf(w, liste, prevWeek, locked){
</div>`;
}
// renderBerichtListe: die Tagesordnung als schmale Nummern-Leiste (Fokus-
// Modus, Eriks Wahl "C" aus dem Darstellungs-Vergleich) — jede Kachel trägt
// nur die Position in der Liste plus ggf. den Änderungs-Punkt, Nummer und
// Name stehen in einem Tooltip. 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.
// renderBerichtListe: die Tagesordnung als Leiste mit Nummer, Gruppe UND
// Name direkt sichtbar (Entwurf "Aktenregal", Eriks Wahl aus dem zweiten
// Darstellungs-Vergleich — löst die vorherige schmale Icon-Leiste ab, die
// Nummer und Namen nur im Hover-Tooltip zeigte: "so sehe ich die ganze
// Tagesordnung auf einen Blick"). Gruppiert wie die Projektliste
// (GROUP_ORDER, js/globals.js), mit einer Zwischenüberschrift bei jedem
// 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(){
const el = document.getElementById('berichtListe');
const w = currentWeek();
@@ -601,21 +618,21 @@ function renderBerichtListe(){
return;
}
let gruppeVorher = null;
el.innerHTML = liste.map(p => {
const info = classifyProjectChange(p, prevWeek);
const istAktiv = aktiv && aktiv.id === p.id;
const position = voll.findIndex(x => x.id === p.id) + 1;
const tooltip = (p.nr ? p.nr + ' · ' : '') + p.name;
// Eigene, schwebende Tooltip-Box (wie im freigegebenen Entwurf) statt
// des nativen Browser-Tooltips: Seit die Leiste nicht mehr selbst
// scrollt (kein overflow-y:auto mehr, siehe .bericht-rail in styles.css),
// gibt es auch keine abschneidende Overflow-Begrenzung mehr dafür.
return `<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
const gruppe = p.group || 'Ohne Gruppe';
const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : '';
gruppeVorher = gruppe;
const name = (p.nr ? p.nr + ' · ' : '') + p.name;
return `${gruppenkopf}<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
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>
${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>`;
}).join('');
@@ -653,7 +670,7 @@ function renderBerichtDetail(p, w, prevWeek, locked){
<h1 class="bericht-h1">${esc(p.name)}</h1>
<div class="bericht-sub">${subText}</div>
${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)}
${locked ? '' : `<div class="bericht-detail-fuss">
<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
@@ -664,22 +681,79 @@ function renderBerichtDetail(p, w, prevWeek, locked){
</div>`}`;
}
// renderVorwochenzeile: der Kommentar der Vorwoche als eine Zeile ÜBER dem
// Eingabefeld. Die häufigste Frage im Meeting ist "was hatten wir letzte
// Woche vereinbart?" — der Verlauf beantwortet sie zwar, aber erst nach dem
// Aufklappen. Parameter: p, prevWeek. Rückgabe: HTML (leer ohne Vorwoche).
function renderVorwochenzeile(p, prevWeek){
if(!prevWeek || !p.projectUid) return '';
const vor = (prevWeek.projects || []).find(x => x.projectUid === p.projectUid);
if(!vor) return '';
const text = statusPlainText(vor.status);
if(!text) return '';
return `<div class="bericht-vorwoche">
<span class="bericht-vorwoche-label">Vorwoche · ${esc(prevWeek.label)}</span>
<span class="bericht-vorwoche-text">${esc(text)}</span>
// vorwocheRadIndex: Position im Kommentar-Verlauf des GERADE GEÖFFNETEN
// Projekts (0 = jüngste Vorwoche, höher = weiter zurück). Ein einzelner
// modulweiter Stand genügt, da immer nur ein Projekt im Fokus steht;
// waehleBerichtProjekt() setzt ihn beim Projektwechsel zurück.
let vorwocheRadIndex = 0;
// renderVorwocheRad: löst renderVorwochenzeile() ab, die nur die
// UNMITTELBARE Vorwoche zeigte. Eriks Rückmeldung zum Entwurfsvergleich: mit
// dem Mausrad (oder den beiden Pfeilen) durch MEHRERE vergangene Kommentare
// blättern können, nicht nur eine Vorwoche sehen. Nutzt
// projectStatusHistory() (js/utils.js) — denselben, bereits für den
// Verlaufs-Dialog geprüften Verlauf (nur Sitzungen mit tatsächlich
// 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>`;
}
// 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 ----------
//
// Beschlüsse fallen im Meeting und landen bisher als Fließtext im Kommentar —
@@ -872,10 +946,9 @@ function protokollAlsMail(){
// ---------- Tastatur ----------
//
// Wer im Meeting mitschreibt, hat keine Hand für die Maus frei. ↓/↑ wechseln
// das Projekt, Strg+↵ speichert den Kommentar und geht weiter. Die Tasten
// greifen nur im Wochenbericht und nicht, während in einem Eingabefeld
// getippt wird — mit einer Ausnahme: Strg+↵ im Kommentarfeld ist genau der
// Fall, für den es gedacht ist.
// das Projekt, Strg+↵ im Kommentarfeld speichert den Kommentar und geht
// weiter. Die Tasten greifen nur im Wochenbericht und nicht, während in
// einem Eingabefeld getippt wird.
document.addEventListener('keydown', function(e){
if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return;
const w = currentWeek();
@@ -888,12 +961,22 @@ document.addEventListener('keydown', function(e){
if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){
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);
if(!aktiv) return;
e.preventDefault();
// Der Kommentar speichert sich über den bestehenden Weg selbst; hier
// 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');
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; }
@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;
padding-bottom: 16px; border-bottom: 1px solid var(--line-soft); margin-bottom: 16px; }
.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.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); }
/* ============================================================
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);
--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 === */
@@ -1449,16 +1474,24 @@ tr:nth-child(even) td { background: var(--surface-alt); }
}
/* === Dichte Fassung der Karte (.board-drawer-dicht, Wochenbericht) ===
Fokus-Modus (Eriks Wahl "C" aus dem Darstellungs-Vergleich): nur der
Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in
aufklappbaren Abschnitten (.bericht-disclosure, siehe renderBoardDrawer()s
dichte Fassung in js/board/board.js) statt dauerhaft nebeneinander zu
stehen — bei 29 Projekten wurde der Bericht mit allem gleichzeitig
sichtbar als "zu überladen" zurückgemeldet. Kein eigenes Raster mehr (die
drei Blöcke stehen schlicht untereinander), deshalb sitzt die dichte
Fassung flächig in ihrem Container statt als eigene Schublade mit
zusätzlichem Rahmen/Hintergrund darin. */
Fokus-Modus, zweite Fassung (Eriks Rückmeldung zum Entwurfsvergleich "A -
Aktenregal"): der Kommentar bleibt das größte, offene Element; Leistungs-
phasen und Beteiligte stehen darunter dauerhaft sichtbar nebeneinander
(.bericht-meta-grid, siehe renderBoardDrawer()s dichte Fassung in
js/board/board.js) statt in aufklappbaren Abschnitten versteckt zu sein -
"die Beteiligten sollen mit angezeigt werden". Die erste Fassung hatte
beide hinter Aufklappern verborgen (gegen die Rückmeldung "zu überladen"
bei allem gleichzeitig sichtbar); jetzt sind beide bewusst KOMPAKT
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; }
.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 {
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-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
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 ============================================
Fokus-Modus (Eriks Wahl "C"): ein aufklappbarer Abschnitt wie Leistungs-
phasen/Beteiligte im Wochenbericht (.bericht-disclosure, siehe weiter
oben), kein dauerhaft sichtbarer Block mehr. Zustände bleiben binär
(anwesend / nicht anwesend, Tabelle attendance); der Zähler in der
Kopfzeile macht sichtbar, wenn noch niemand geklickt hat. */
Fokus-Modus: ein aufklappbarer Abschnitt (.bericht-disclosure, siehe
weiter oben) am Seitenfuß des Wochenberichts, UNABHÄNGIG vom gewählten
Projekt (eine Sitzungs-, keine Projekt-Angabe) - anders als Leistungs-
phasen/Beteiligte, die seit Eriks Rückmeldung zum Entwurfsvergleich wieder
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; }
.attendance-actions { display: flex; gap: 8px; margin-bottom: 10px; }
@@ -4174,12 +4183,15 @@ td.actions-cell button { margin: 2px; }
color: var(--text-muted);
}
/* === Wochenbericht als Tagesordnung, Fokus-Modus (Eriks Wahl "C" aus dem
Darstellungs-Vergleich, js/wochenbericht/wochenbericht.js) ===
Links eine schmale Nummern-Leiste (jedes Projekt nur eine Position + ggf.
ein Änderungs-Punkt, Name im Tooltip), rechts das gewählte Projekt — nur
Kommentar und Kernstatus liegen dort offen, Leistungsphasen/Beteiligte/
Anwesenheit stecken in aufklappbaren Abschnitten (.bericht-disclosure).
/* === Wochenbericht als Tagesordnung, Fokus-Modus "Aktenregal" (Eriks Wahl
aus dem Entwurfsvergleich, js/wochenbericht/wochenbericht.js) ===
Links eine Leiste mit Nummer, Gruppe UND Name direkt sichtbar (keine
schmale Icon-Spalte mit Namen nur im Hover-Tooltip mehr), rechts das
gewählte Projekt: Kommentar (inkl. Scrollrad zum Verlauf), darunter
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
Gewerke-Dropdown, Fortschrittsbalken, Suchfeld, mehreren Badges je Zeile
und allem gleichzeitig sichtbar als "zu überladen" zurückgemeldet wurde.
@@ -4201,7 +4213,12 @@ td.actions-cell button { margin: 2px; }
}
.bericht-gewerk-select {
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;
}
.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);
}
.pc-result-shot:hover img { border-color: var(--accent); }
.bericht-layout { display: grid; grid-template-columns: 64px minmax(0, 1fr); }
@media (max-width: 760px) { .bericht-layout { grid-template-columns: 48px minmax(0, 1fr); } }
/* Entwurf "Aktenregal" (zweite Fassung des Fokus-Modus, Eriks Wahl aus dem
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 {
background: var(--surface-alt);
border-right: 1px solid var(--line-soft);
padding: 10px 0; display: flex; flex-direction: column; align-items: center; gap: 3px;
/* Die Leiste bleibt beim Scrollen im Blick — sie IST die Tagesordnung. Kein
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;
padding: 10px 8px; display: flex; flex-direction: column; gap: 2px;
position: sticky; top: 64px; max-height: calc(100vh - 64px); overflow-y: auto;
}
.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 {
position: relative; width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: var(--text-subtle); cursor: pointer;
position: relative; display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-radius: var(--r-sm); cursor: pointer; color: var(--text); font-size: 12.5px;
}
.bericht-rail-item:hover { background: var(--surface-sunk); color: var(--text); }
.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:hover { background: var(--surface-sunk); }
.bericht-rail-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.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
können, was man schon besprochen hat. */
.bericht-rail-item.is-ab { opacity: .55; }
.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-dot { position: absolute; top: 3px; right: 3px; width: 6px; height: 6px; border-radius: 50%; }
.bericht-rail-num { font-size: 11px; color: var(--text-subtle); font-variant-numeric: tabular-nums; flex-shrink: 0; width: 16px; }
.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-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-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; }
.bericht-rail-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* max-width: die Hauptspalte bleibt eine schmale Lesespalte statt bis zum
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;
}
/* Der Kommentar der Vorwoche steht ÜBER dem Eingabefeld: "Was hatten wir
letzte Woche vereinbart?" ist die häufigste Frage im Meeting. */
.bericht-vorwoche {
background: var(--surface-sunk); border-radius: var(--r-md);
padding: 9px 12px; margin-bottom: 14px; font-size: 12px; color: var(--text-muted);
/* Scrollrad über dem Kommentar-Eingabefeld: "Was hatten wir letzte Woche
vereinbart?" ist die häufigste Frage im Meeting - stand bis hierhin als
starre Einzeile mit nur der unmittelbaren Vorwoche da. Erik wollte mit dem
Mausrad durch MEHRERE vergangene Kommentare blättern können
(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 {
display: block; font-weight: 600; color: var(--text-subtle);
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-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(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
// eine Sitzung mit vielen Projekten und langen Kommentaren sonst irgendwann
// 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' });
app.use((req, res, 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,
progress 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)
)`);
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees (
@@ -1050,8 +1051,8 @@ function serializeWeek(week){
VALUES (?, ?, ?, ?, ?)`);
const insPlanPerson = db.prepare(`INSERT OR IGNORE INTO plan_people (week_label, project_id, person_id, array_order)
VALUES (?, ?, ?, ?)`);
const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const insTodoAssignee = db.prepare(`INSERT OR IGNORE INTO plan_todo_assignees (week_label, project_id, todo_id, person_id)
VALUES (?, ?, ?, ?)`);
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.todos) ? plan.todos : []).forEach((t, tidx) => {
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.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
});
@@ -1297,7 +1299,7 @@ function loadWeeks(){
const todoMap = {};
todos.forEach(r => {
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 => {
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,
// gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()). Gebraucht von
// der Planung-Sandbox, siehe Spaltenkommentar am CREATE TABLE oben.
@@ -2665,6 +2685,7 @@ migrateAddProjectStatusAuthor();
migrateAddProjectAuftragsstatus();
migrateAddProjectGroesse();
migrateAddProjectUpdatedAt();
migrateAddPlanTodoShowInGantt();
migrateAddUserAiKeyModel();
migrateAddProtokollQuelle();
migrateAddProtokollPunktErstelltAm();
@@ -4264,6 +4285,29 @@ const KI_TIMEOUT_MS_CLOUD = 45000;
// sichtbar, kein scheinbares Hängenbleiben.
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
// eigener Standard-Agent Verbindungen nach headersTimeout/bodyTimeout = 5
// 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);
}
// 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
// beim Speichern) und fetchMitTimeout() (Timeout-Wahl beim Aufruf) — beide
// 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]';
}
// 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
// sonst im Klartext über ein Netz, das nicht die eigene Maschine ist) — mit
// EINER Ausnahme: http auf dem Loopback. Ein lokal laufender Server wie
@@ -4378,10 +4442,20 @@ function istGueltigeAiUrl(url){
}
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;
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 timer = setTimeout(() => controller.abort(), istLokal ? KI_TIMEOUT_MS_LOKAL : KI_TIMEOUT_MS_CLOUD);
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const fetchOptions = Object.assign({}, options, { signal: controller.signal });
if(istLokal) fetchOptions.dispatcher = LOKAL_DISPATCHER;
@@ -4458,10 +4532,8 @@ const KI_PUNKT_SCHEMA = {
additionalProperties: false
};
const KI_DEFAULT_URL = {
chatgpt: 'https://api.openai.com/v1/chat/completions',
claude: 'https://api.anthropic.com/v1/messages'
};
// KI_DEFAULT_URL ist jetzt weiter oben definiert, direkt vor
// istLoopbackHost()/fetchMitTimeout() — siehe Kommentar dort.
// normalisiereAiUrl: wer eine eigene API-URL hinterlegt, tippt bei einem
// 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);
}
// ---------- "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) ----------
//
// 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 '
+ '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 '
+ 'der Zeichnung eindeutig zu erkennen ist - im Zweifel false. "begruendung" ist KURZ - ein bis zwei Sätze, NIE eine '
+ 'Aufzählung einzelner Werte (z. B. nicht jede einzelne Maßzahl auflisten) - beschreibe stattdessen zusammenfassend, was '
+ 'zu sehen ist. Erfinde nichts.';
+ 'der Zeichnung eindeutig zu erkennen ist - im Zweifel false. "begruendung" ist HÖCHSTENS 150 ZEICHEN LANG - eine feste '
+ 'Obergrenze, kein Stilwunsch. Zähle NIEMALS einzelne Werte auf (keine Maßzahlen, keine Höhenangaben, keine '
+ '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
// Ergebnis stark verschieben (siehe dortige Projekthistorie). Der Zusatz
// "NIE eine Aufzählung einzelner Werte" kam nach einem echten Fehlschlag
// dazu: Ein lokal über LM Studio angebundenes Modell zählte für "Maßketten
// vorhanden" jede einzelne gefundene Maßzahl einzeln auf ("2,50 m, 3,12 m,
// 1,20 m, 2,10 m, ..."), wodurch die Antwort noch vor Abschluss des JSON an
// max_tokens (s. u.) abgeschnitten wurde und als Ganzes nicht mehr parsbar
// war ("ChatGPT-Antwort war kein gültiges JSON"). Das ursprüngliche "ein bis
// zwei Sätze" allein reichte diesem Modell als Grenze nicht aus.
// Ergebnis stark verschieben (siehe dortige Projekthistorie). Der erste
// Zusatz ("NIE eine Aufzählung einzelner Werte", ohne Zeichengrenze) kam nach
// einem echten Fehlschlag dazu: Ein lokal über LM Studio angebundenes Modell
// zählte für "Maßketten vorhanden" jede einzelne gefundene Maßzahl einzeln
// auf ("2,50 m, 3,12 m, 1,20 m, 2,10 m, ..."), wodurch die Antwort noch vor
// Abschluss des JSON an max_tokens (s. u.) abgeschnitten wurde und als Ganzes
// nicht mehr parsbar war ("ChatGPT-Antwort war kein gültiges JSON"). Reichte
// 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
// Ü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 } },
// 4096 war zu knapp bemessen: ein lokal angebundenes Modell hielt sich
// trotz "ein bis zwei Sätze" nicht an die Kürze (s. PLAN_CHECK_SYSTEM_PROMPT
// oben) und die Antwort wurde mitten im JSON abgeschnitten. 8192 als
// Sicherheitsnetz zusätzlich zur geschärften Prompt-Anweisung - beide
// Maßnahmen zusammen, da sich (wie schon bei KI_SYSTEM_PROMPT dokumentiert)
// nicht verlässlich vorhersagen lässt, ob ein Modell eine Kürze-Anweisung
// tatsächlich befolgt.
max_tokens: 8192,
// 4096 war zu knapp, dann 8192 - ein lokal angebundenes Modell hielt sich
// trotz zunehmend schärferer Anweisung (s. PLAN_CHECK_SYSTEM_PROMPT oben,
// Projekthistorie dort) wiederholt nicht an die Kürze, und die Antwort
// wurde mitten im JSON abgeschnitten. 16000 angeglichen an das Limit, das
// sich für genau dasselbe Problem beim Protokoll-Import bereits bewährt
// hat (erkennePunkteMitChatGPT() oben) - weiterhin nur ein Sicherheitsnetz
// zusätzlich zur Prompt-Anweisung, keine Garantie: Ein Modell, das
// entschlossen genug aufzählt, kann auch dieses Limit erreichen.
max_tokens: 16000,
temperature: 0
};
// 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*$/, '');
let parsed;
try { parsed = JSON.parse(bereinigt); } catch(e){
// Rohtext der Antwort im Fehler, wie bei den HTTP-Fehlern oben
// (errBody.slice(0, 200)) - ohne das war ein lokal/drittanbieter-
// bedingtes Scheitern (z. B. ein Modell, das nur EIN Bild pro Anfrage
// verarbeiten kann und bei mehreren Bildern mit Fließtext statt JSON
// antwortet) nur über die Server-Konsole nachvollziehbar.
throw new Error('ChatGPT-Antwort war kein gültiges JSON: ' + content.slice(0, 300));
// finish_reason:'length' heißt konkret "max_tokens erreicht, bevor das
// Modell fertig war" - der häufigste real beobachtete Grund für
// ungültiges JSON hier (ein Modell, das trotz PLAN_CHECK_SYSTEM_PROMPT
// einzelne Werte aufzählt, s. dortige Projekthistorie). Eine konkrete
// Fehlermeldung dafür erspart das erneute Einschicken des rohen
// 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.');
return parsed.ergebnisse;
@@ -4875,10 +5190,11 @@ async function pruefePlanMitClaude(bilderBase64, merkmale, apiKey, apiUrl, model
// bleibt trotzdem die richtige Vorgabe für diese anspruchsvolle
// Bildaufgabe. Konto-Einstellung überschreibt auch diese Vorgabe.
model: model || 'claude-sonnet-5',
// 8192 statt 4096 - gleiches Sicherheitsnetz wie bei pruefePlanMitChatGPT()
// oben, selbes Risiko gilt grundsätzlich auch hier (eine zu ausführliche
// Begründung, die vor Abschluss des JSON abgeschnitten wird).
max_tokens: 8192,
// 16000 - dasselbe Sicherheitsnetz, auf denselben Wert angehoben wie bei
// pruefePlanMitChatGPT() oben (Projekthistorie dort), selbes Risiko gilt
// grundsätzlich auch hier (eine zu ausführliche Begründung, die vor
// Abschluss des JSON abgeschnitten wird).
max_tokens: 16000,
temperature: 0,
system: PLAN_CHECK_SYSTEM_PROMPT,
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();
// 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();
if(zusatz) merkmale.push({ titel: zusatz, prompt: zusatz });
if(merkmale.length === 0){