diff --git a/CLAUDE.md b/CLAUDE.md index d1fa96a..d58627f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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`, diff --git a/public/App.html b/public/App.html index 797a42a..824e50c 100644 --- a/public/App.html +++ b/public/App.html @@ -323,6 +323,14 @@ +
+ + +

Prüft zusätzlich, ob der Plankopf zu diesem Projekt passt (Nummer/Bezeichnung).

+
+
@@ -879,6 +887,7 @@ + + + +
+ ${kandidaten.length ? kandidaten.map(k => { + const kuerzel = k.person.name || initialsForName(k.person.fullName || '') || '?'; + const voll = k.person.fullName || k.person.name || ''; + return ``; + }).join('') : 'Niemand diesem Projekt zugeordnet.'} +
+ ${!vorausgewaehlt.length && v.zustaendigVorschlag ? `KI-Vorschlag „${esc(v.zustaendigVorschlag)}“ — kein passendes Teammitglied gefunden.` : ''} + ${quelle ? `aus: ${esc(quelle)}` : ''} +
+ + `; + }).join(''); + + return ` +
+ + + · + + +
+
${zeilen}
`; + } + + // 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.'); + } +})(); diff --git a/public/js/plan/plan-settings.js b/public/js/plan/plan-settings.js index 398b459..a10922e 100644 --- a/public/js/plan/plan-settings.js +++ b/public/js/plan/plan-settings.js @@ -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)} `).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 `${esc(entry.person.name)}`; + }).join('') : `Niemand hinterlegt`; + return `
${esc(r)}
${chips}
`; + }).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. diff --git a/public/js/plan/plan-state.js b/public/js/plan/plan-state.js index a9e4c09..a2473a6 100644 --- a/public/js/plan/plan-state.js +++ b/public/js/plan/plan-state.js @@ -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' : ''; diff --git a/public/js/planchecker/planchecker.js b/public/js/planchecker/planchecker.js index e392960..f4e9fc4 100644 --- a/public/js/planchecker/planchecker.js +++ b/public/js/planchecker/planchecker.js @@ -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 = '' + gruppenNamen.map(g => { + const opts = projekte.filter(p => (p.group || 'Ohne Gruppe') === g) + .slice().sort((a, b) => a.name.localeCompare(b.name)) + .map(p => ``).join(''); + return opts ? `${opts}` : ''; + }).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); diff --git a/public/js/projekt-wizard.js b/public/js/projekt-wizard.js index 42c6096..e3ed187 100644 --- a/public/js/projekt-wizard.js +++ b/public/js/projekt-wizard.js @@ -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; }); }); diff --git a/public/js/wochenbericht/wochenbericht.js b/public/js/wochenbericht/wochenbericht.js index e126b2f..2c149de 100644 --- a/public/js/wochenbericht/wochenbericht.js +++ b/public/js/wochenbericht/wochenbericht.js @@ -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){ `; } -// 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 `
${esc(gruppe)}
` : ''; + gruppeVorher = gruppe; + const name = (p.nr ? p.nr + ' · ' : '') + p.name; + return `${gruppenkopf}
+ aria-current="${istAktiv ? 'true' : 'false'}"> ${String(position).padStart(2, '0')} ${info.bucket === 'unchanged' ? '' : ``} - ${esc(tooltip)} + ${esc(name)}
`; }).join(''); @@ -653,7 +670,7 @@ function renderBerichtDetail(p, w, prevWeek, locked){

${esc(p.name)}

${subText}
${fehlen.length ? `Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.` : ''} - ${renderVorwochenzeile(p, prevWeek)} + ${renderVorwocheRad(p, w)} ${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)} ${locked ? '' : `
`}`; } -// 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 `
- Vorwoche · ${esc(prevWeek.label)} - ${esc(text)} +// 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) => ``).join(''); + return `
+ +
+ ${esc(eintrag.week.label)} + ${esc(eintrag.text)} +
+ +
${punkte}
`; } +// 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); } diff --git a/public/plan-styles.css b/public/plan-styles.css index 7948b36..a857980 100644 --- a/public/plan-styles.css +++ b/public/plan-styles.css @@ -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); } diff --git a/public/styles.css b/public/styles.css index e95de8a..5bd1dd2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; + } +} diff --git a/server.js b/server.js index 144ee99..e65c3bf 100644 --- a/server.js +++ b/server.js @@ -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){