Compare commits

..

10 Commits

Author SHA1 Message Date
Lars
234f87d408 V2-Dev-Stand: Skala 1-10, Benefits/Concerns, DEV-Isolation und Gitea-Doku.
Bündelt die Neuentwicklung in AssigmentMonitorV2 (eigene Ports/DB), die Umstellung auf numerische Bewertungen, Meeting-Checklisten, KI-Tagging und die geplante Feedback-Kaskade — als Basis für Versionsverwaltung in Gitea.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:35:05 +02:00
Lars
782678a1cf Verbesserung. Gesamtview, papierkorb Archiv 2026-07-13 12:45:35 +02:00
Lars
019c686254 Ki Prompts aktualisiert 2026-07-08 09:35:08 +02:00
Lars
1a1f3bb33f GenAI Anbindung 2026-07-07 11:14:00 +02:00
Lars
9c68c414d8 Update 2026-07-06 19:08:24 +02:00
Lars
911dcace72 fix: N/A-Aggregationsbug behoben, FeedbackDraftPage migriert, toten Code entfernt
- buildAiPrompt (AssignmentFeedbackPage.tsx): 'na' wird jetzt korrekt aus dem
  Score-Mittelwert ausgeschlossen (war bislang als 0 mitgerechnet)
- FeedbackDraftPage.tsx auf feedbackCriterionItems/feedbackCategories migriert,
  letzter Konsument der Legacy-Tabellen außer den Seeds. Notizen hängen jetzt
  korrekt pro Kriterium über lineTags, statt ungefiltert an jedes Kriterium.
  Score-Anzeige nutzt RATING_OPTIONS-Labels statt rohem /5-Wert.
- FeedbackStructureConfig.tsx: verwaisten AssignmentType/PhaseTemplate-CRUD-Code
  entfernt (Funktionalität war bereits nach AssignmentTypeConfig.tsx verschoben)
- Doku aktualisiert: CLAUDE.md, docs/HANDOVER.md, docs/STATUS_UND_OFFENE_PUNKTE.md,
  docs/TECHNISCHE_ARCHITEKTUR.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 13:54:19 +02:00
Lars
f47353dbe1 20260703_tagging von Zeilen und Verbindung 2026-07-03 11:56:22 +02:00
Lars
f57db9d161 fix: Evaluation.tsx nach Assignment gruppiert, Papierkorb/Status gefiltert
- Oberste Gliederungsebene ist jetzt das Assignment, Institutees darunter
- Meeting-Filter: nur status='done', keine Papierkorb-Meetings (deletedAt)
- Migriert auf feedbackCriterionItems statt Legacy categories/criteria
- zustand aus package.json entfernt (ungenutzt)
2026-07-02 11:16:41 +02:00
Lars
63ca6583fa refactor: Evaluation.tsx auf feedbackCriterionItems migriert, zustand entfernt
- Liest jetzt feedbackDimensions/feedbackCategories/feedbackCriterionItems statt
  alter categories/criteria-Tabellen
- Korrekte 1-4 Rating-Skala mit RATING_OPTIONS-Labels
- Aggregation via RATING_NUM_MAP aus constants.ts
- zustand aus package.json entfernt (war nicht genutzt)
2026-07-02 10:58:44 +02:00
Lars
594a643068 refactor: MeetingView in 3 Dateien aufgeteilt (meeting/ Unterordner) 2026-07-02 10:30:46 +02:00
102 changed files with 13079 additions and 1701 deletions

12
.claude/launch.json Normal file
View File

@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,
"autoPort": true
}
]
}

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
# Assignment Monitor V2 — lokale Entwicklungs-Isolation
# Kopie nach `.env` anlegen. Diese Werte dürfen NICHT mit dem Produktivsystem
# (c:\dev\AssigmentMonitor, Ports 5173/4000) kollidieren.
# Vite-Frontend (Produktiv: 5173)
PORT=5174
# Express-RPC-API (Produktiv: 4000)
API_PORT=4001
# Vite-Proxy-Ziel → muss zu API_PORT passen
API_URL=http://localhost:4001
# SQLite-Dateiname unter server/data/ (Produktiv: assignment-monitor.sqlite3)
# Nur Dateiname, kein Pfad — der Server erzwingt Ablage innerhalb dieses V2-Projekts.
SQLITE_FILENAME=assignment-monitor-v2-dev.sqlite3

3
.gitignore vendored
View File

@ -11,6 +11,9 @@ dist-ssr/
# TypeScript build info
*.tsbuildinfo
# Lokale SQLite-Datenbank (enthält personenbezogene Daten, siehe CLAUDE.md → Datenschutz)
server/data/
# Local environment / secrets
.env
.env.local

5
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"syncfusioninc.document-viewer-vscode-extensions"
]
}

188
CLAUDE.md
View File

@ -4,23 +4,81 @@ Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) wäh
**Vollständige Dokumentation:** `docs/` — bei neuer Session zuerst `docs/HANDOVER.md` lesen.
**DEV-Isolation:** Dieses Repo (`AssigmentMonitorV2`) ist von Produktiv (`c:\dev\AssigmentMonitor`) getrennt — Ports **5174/4001**, DB `assignment-monitor-v2-dev.sqlite3`. Regeln: `docs/DEV_ISOLATION.md`. Produktiv-Code und -Daten niemals anfassen.
---
## Stack
- React 18 + TypeScript + Vite (PWA)
- Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js`
- Dexie.js v5 — IndexedDB-Wrapper, kein Backend
- Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js`; Brandfarbe als `@theme { --color-brand }` in `src/index.css`
- **Lokaler Node/Express-Server + SQLite** (seit 2026-07-04) — löst Dexie/IndexedDB als primären Datenspeicher ab, siehe eigener Abschnitt unten
- React Router v6
- Capgemini-Brandfarbe: `#0070AD`
- Capgemini-Brandfarbe: `#0070AD` (Tailwind-Klasse `brand`, JS-Konstante `BRAND_COLOR` in `src/config/constants.ts`)
## Projektstruktur (Stand 2026-07-04)
```
src/
config/constants.ts ← RATING_OPTIONS, INST_COLORS, AI_CONFIG, RATING_NUM_MAP, BRAND_COLOR, DEFAULT_RATING_TREND_WEIGHT_STEP
db/
index.ts ← Re-Export (types + queries), rpcClient.ts eingebunden
types.ts ← alle Interfaces, FeedbackRating, AppSettings — von Client UND Server importiert
rpcClient.ts ← rpc(module, fn, ...args) — POST /api/rpc, einziger Netzwerk-Kontaktpunkt des Clients
queries/ ← Client-DB-Zugriffsschicht (siehe eigener Abschnitt unten)
schema.ts, seeds/ ← Dexie/IndexedDB-Legacy-Code, wird nicht mehr gelesen (siehe Datenbank-Abschnitt)
pages/
meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx
RatingWeightConfig.tsx ← Settings-Tab für Zeitgewichtungs-Faktor (in Configuration.tsx eingebunden)
AssignmentTrash.tsx ← Papierkorb-Ansicht für Assignments (`/assignments/trash`), seit 2026-07-13
AssignmentArchive.tsx ← Archiv-Ansicht für Assignments (`/assignments/archive`), seit 2026-07-13
MeetingHistory.tsx ← Rein lesendes chronologisches Gesamtprotokoll aller Meetings eines Assignments (`/assignment/:id/history`), seit 2026-07-13
AiPromptConfig.tsx ← Settings-Tab „KI-Prompts" — ein editierbares Prompt-Template je Dimension, Default-Vorbelegung + Reset (in Configuration.tsx eingebunden, seit 2026-07-07)
...übrige Pages unverändert am alten Ort
utils/
ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung + weightedAverageScore/weightedAverageRating (genutzt von Evaluation.tsx, AssignmentFeedbackPage.tsx, AssessmentTab.tsx, AssignmentDetail.tsx)
aiPrompt.ts ← Prompt-Engine für die KI-Feedback-Generierung (seit 2026-07-07): Platzhalter-Kontext pro Dimension, Rendering, Antwort-Parsing — siehe eigener Abschnitt „KI-Feedback-Generierung" unten
pdfExport.tsx ← PDF-Export (seit 2026-07-14, `@react-pdf/renderer`): Assignment-Report (Gesamtprotokoll + Feedback aller Institutees) und Einzel-Feedback-PDF pro Institutee
server/
index.ts ← Express-App, ein Endpunkt POST /api/rpc
database.ts ← node:sqlite-Verbindung (server/data/assignment-monitor.sqlite3), Schema aus tableRegistry.ts
rpcModules.ts ← Dispatch-Registry { appSettings, consultants, ..., backup } → server/db/queries/*.ts
db/
tableRegistry.ts ← EINE Quelle der Wahrheit: alle 21 Tabellen + Spalten + JSON-/Bool-Codecs (treibt Schema UND Backup)
crud.ts ← generische SQL-CRUD-Bausteine über die Registry
queries/ ← Server-Pendant zu src/db/queries/*.ts, gleiche Funktionsnamen, echtes SQL statt Dexie
scripts/verifyBackupRoundtrip.ts ← automatisierter Vollständigkeits-Beweis für Backup-Export/-Import
```
### Datenbank: lokaler Server statt IndexedDB (seit 2026-07-04)
Grund: Mehrere Browser (Chrome, Edge, ...) auf demselben Laptop sollen dieselben Daten sehen, unabhängig vom Browser-Cache — als Testschritt vor einem späteren Umzug auf einen Docker-Container auf einem Linux-Server. Browser können keine SQLite-Datei direkt öffnen, daher läuft ein kleiner Node/Express-Server (`server/`, Port 4000) mit **`node:sqlite`** (Node-eingebautes SQLite-Modul, **kein** `better-sqlite3`!). Alle Browser sprechen über `http://localhost:5173` (Vite-Dev-Proxy `/api``http://localhost:4000`) denselben Server an.
**Abweichung vom ursprünglich gewählten Stack:** `better-sqlite3` scheiterte lokal an einer fehlenden/kaputten Python-Installation (node-gyp-Kompilierung). `node:sqlite` bietet dieselbe synchrone API (`DatabaseSync`, `.prepare().run()/.get()/.all()`), braucht keine native Kompilierung und ist ab dieser Node-Version (25.x) ohne Flag nutzbar (Node meldet es noch als "experimental" — rein informativ, funktioniert stabil). Falls auf einem anderen Rechner/Server `node:sqlite` fehlt (ältere Node-Version), wäre `better-sqlite3` dort nachzuholen, sofern eine funktionierende Build-Toolchain (Python + C++-Compiler) vorhanden ist.
**Spalten-Migration bei bestehenden Tabellen (seit 2026-07-07):** `database.ts` legt fehlende Tabellen per `CREATE TABLE IF NOT EXISTS` an — das migriert aber **nie** eine bereits existierende Tabelle. Wird `tableRegistry.ts` um eine neue Spalte einer bereits vorhandenen Tabelle ergänzt (z.B. `suggestedAchievements` auf `feedbackDimensionTexts`), gleicht `database.ts` das zusätzlich per `PRAGMA table_info` ab und zieht fehlende Spalten per `ALTER TABLE ... ADD COLUMN` automatisch nach. Ohne das bleibt die alte Spaltenmenge bestehen und jeder Zugriff auf die neue Spalte scheitert erst zur Laufzeit mit „no column named ..." — genau das ist beim Einführen des KI-Prompt-Systems passiert und wurde dabei gefixt.
**RPC statt REST:** Ein einziger Endpunkt `POST /api/rpc` mit Body `{ module, fn, args }` statt ~80 einzelnen Routen — Client (`src/db/rpcClient.ts`) und Server (`server/rpcModules.ts`) sind darüber 1:1 gekoppelt: jede Funktion aus `src/db/queries/*.ts` hat ein gleichnamiges Pendant in `server/db/queries/*.ts`.
**Dexie/IndexedDB ist toter Code, aber noch nicht entfernt:** `src/db/schema.ts` und `src/db/seeds/*.ts` bleiben unverändert im Repo liegen, werden aber von nichts mehr gelesen (Seed-Aufrufe in `main.tsx` wurden entfernt). Vollständiges Entfernen ist eine spätere, bewusste Entscheidung.
**Backup ist jetzt server-seitig:** `src/utils/dbBackup.ts` ruft `rpc('backup', 'exportAll'/'importAll', ...)` statt direkt gegen Dexie zu laufen. `server/db/queries/backup.ts` iteriert exakt `tableRegistry.ts` — dieselbe Liste, die auch das SQL-Schema erzeugt, damit eine neue Tabelle nicht mehr unbemerkt aus dem Backup fallen kann (das ist genau einmal mit `appSettings` passiert, siehe Erledigt-Historie). `server/scripts/verifyBackupRoundtrip.ts` (`npm run verify-backup -- <pfad>`) beweist automatisiert Zeilenzahl- und Inhaltsgleichheit pro Tabelle nach einem Import→Export-Zyklus.
### DB-Zugriffsschicht (`src/db/queries/`, seit 2026-07-04, intern auf RPC umgestellt)
Alle Komponenten greifen auf Daten ausschließlich über benannte Funktionen aus `src/db/queries/*.ts` zu (re-exportiert über `'../db'`) — nach außen unverändert seit der ursprünglichen Extraktion, nur die Innenseite ruft jetzt `rpc(...)` statt `db.<table>.*` auf. Ein Modul pro Domäne: `appSettings.ts`, `consultants.ts` (Groups+Consultants), `assignmentTypes.ts` (AssignmentTypes+PhaseTemplates), `assignments.ts`, `feedbackStructure.ts` (Dimensions/Categories/CriterionItems — `filterVisibleFeedbackStructure()` bleibt reine Client-Funktion ohne RPC), `meetings.ts` (MeetingInstances inkl. kanonischem `!deletedAt && status==='done'`-Filter als `listDoneMeetingsChronological()`, Cascade-Deletes, Export-Datenaggregation `getMeetingExportData()`), `conversationEntries.ts`, `assessments.ts`, `assignmentFeedback.ts`.
## Entwicklung
```bash
npm run dev # Dev-Server starten
npx tsc --noEmit # TypeScript-Check — muss vor jeder Abgabe fehlerfrei sein
npm run dev:all # Frontend (Vite, :5173) + lokaler Server (Express, :4000) zusammen starten
npm run dev # Nur Frontend (Server muss separat laufen, sonst laufen alle DB-Zugriffe ins Leere)
npm run server # Nur den lokalen Server
npx tsc -b # TypeScript-Check über alle 3 Projekte (App/Node/Server) — muss vor jeder Abgabe fehlerfrei sein
```
**Wichtig:** `npx tsc --noEmit` am Root prüft **nichts** (0 Dateien, `tsc --noEmit --listFiles` bestätigt das) — der Root-`tsconfig.json` hat `"files": []` und nur `references`. Ohne `-b` (Build-Modus) werden referenzierte Projekte nicht ausgewertet. Der bisher in diesem Dokument dokumentierte Workflow `npx tsc --noEmit` war dadurch vermutlich seit Einführung der Project References ein stiller No-Op — **immer `npx tsc -b` verwenden.**
---
## Kritische Eigenheiten
@ -28,21 +86,16 @@ npx tsc --noEmit # TypeScript-Check — muss vor jeder Abgabe fehlerfrei sei
### Doppelte `criteriaId`-Semantik
```
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung pro Protokoll-Eintrag)
ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Kriterium-Zuordnung einer einzelnen Zeile innerhalb eines ConversationEntry.note-Blocks, seit 2026-07-02 — bewusst anders benannt als "criteriaId" um obige Verwechslungsgefahr nicht fortzuschreiben)
```
### AssignmentType.criteriaIds
Referenziert `FeedbackCriterionItem.id[]`. Leer = alle Kriterien anzeigen.
Filterlogik (immer so anwenden):
```typescript
const itemFilter = aType?.criteriaIds?.length ? new Set(aType.criteriaIds) : null
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId))
const filteredCats = itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats
```
Filterlogik: `filterVisibleFeedbackStructure(allCategories, allItems, criteriaIds)` in `src/db/queries/feedbackStructure.ts` — seit dem `db/queries/`-Refactoring (2026-07-04) die einzige Implementierung, vorher identisch dupliziert in `MeetingView.tsx` und `AssignmentDetail.tsx`. Nicht erneut inline schreiben.
### Rating-Skala
@ -54,6 +107,10 @@ type FeedbackRating = 'na' | 'not_client_ready' | 'partially_client_ready' | 'ne
Aggregation: gewichteter Durchschnitt → `avg < 1.5` Not · `< 2.5` Partially · `< 3.5` Nearly · `≥ 3.5` Fully
**Bug-Klasse, auf die immer prüfen:** Filter der Form `a.score !== null` schließen N/A NICHT aus (N/A ist der String `'na'`, nicht `null`!). Richtig ist immer `a.score !== null && a.score !== 'na'`. Gefunden und gefixt in `Evaluation.tsx` (2026-07-02); dieselbe Prüfung nötig überall, wo `Assessment.score` oder `ConversationSkillScore.score` gemittelt wird.
**Zweite Bug-Klasse, auf die immer prüfen (Kriterium-Gewichtung):** `FeedbackCriterionItem.weight` geht seit 2026-07-03 von `0,0` bis `2,0` (Config → Feedback, vorher grobe `×1``×5`-Stufen). Jede gewichtete Mittelung MUSS normieren — `Σ(score×weight) / Σ(weight)`, niemals `score×weight` direkt als Endwert interpretieren (würde die Bewertungsstufe verzerren, z.B. Score 3 „Nearly" × Gewicht 0,5 = 1,5, was fälschlich zwischen Not/Partially läge). Zusätzlich: wenn `Σweight === 0` (z.B. alle beteiligten Kriterien einer Kategorie auf Gewicht 0 gesetzt), **nicht** dividieren — `NaN < 1.5/2.5/3.5` ist überall `false` und fällt sonst still auf den letzten Bucket „Fully" durch. Immer `if (wSum === 0) return null` (bzw. `continue`) vor der Division. Seit dem `db/queries/`-Refactoring (2026-07-04) **eine** Implementierung: `weightedAverageScore()`/`weightedAverageRating()` in `src/utils/ratingTrend.ts`, genutzt von `buildGroupMeetingScores()`, `catMode()` (`AssessmentTab.tsx`) und der Meeting-Chip-Anzeige in `AssignmentDetail.tsx` (dort fehlte der `Σweight===0`-Guard vorher — beim Konsolidieren mitgefixt). Bei jedem neuen gewichteten Durchschnitt diese Funktion wiederverwenden, nicht neu implementieren.
### Routing
```
@ -66,7 +123,16 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
- `MeetingInstance.generalNotes` kann `undefined` sein → immer `?? ''` als Fallback
- `PhaseKey` ist `string`, kein Union-Typ
- Tailwind v4: arbitrary values `bg-[#0070AD]` direkt im className — kein Config nötig
- Tailwind v4: Brandfarbe ist `brand` (Tailwind-Klasse, via `@theme` in `index.css`) — **kein** `bg-[#0070AD]` mehr neu schreiben
- Auswertungslogik greift auf **zwei komplett unabhängige Bewertungs-Strukturen** zu, die nicht automatisch synchron sind:
- `Assessment` (Tabelle `assessments`) — pro Meeting erfasste Kriterium-Bewertungen, Basis für `/evaluation`
- `FeedbackCategoryRating` — ausschließlich manuell auf `AssignmentFeedbackPage` gesetzt, unabhängig von Meeting-Daten, Basis für das finale Abschluss-Feedback
- Seit 2026-07-02: `src/utils/ratingTrend.ts` berechnet aus den `Assessment`-Daten einen **zeitgewichteten Vorschlag** pro Kategorie (spätere Meetings zählen stärker, Gewichtungsfaktor konfigurierbar unter Config → Gewichtung) inkl. Trend-Erkennung (Verbesserung/Verschlechterung). Der Vorschlag wird auf `AssignmentFeedbackPage` als Badge angezeigt und bleibt frei überschreibbar — die zwei Strukturen bleiben bewusst getrennt.
- Meeting-Filter bei Aggregationen über mehrere Meetings: immer `!m.deletedAt && m.status === 'done'` — sonst fließen Papierkorb-/unfertige Meetings mit ein
- **Weichmacher/Füllwörter in Formulierungen** (z.B. "ein bisschen") werden bewusst **nicht** über eine eigene Text-Erkennungslogik behandelt — stattdessen legt man dafür ein eigenes, niedrig gewichtetes Kriterium an (z.B. "Sprachliche Präzision", Gewicht < 1) und taggt/notiert entsprechende Zeilen ganz normal (meist mit `(!)`). Die bestehende Tagging- + Notation-Vorschlag-Mechanik deckt das automatisch ab.
- Der `äh`-Zähler (`ConversationEntry.fillerCount`) bleibt bewusst **rein informativ** — keine automatische Schwellenwert-Bewertung. Würde eine eigene Design-Entscheidung brauchen (ab wann gilt die Zahl als "zu hoch"), aktuell reicht die reine Anzeige als Grundlage für die manuelle Bewertung.
- **Cross-Boundary-Imports von `src/` nach `server/`**: Dateien, die (wie `src/db/types.ts`) sowohl vom Client als auch vom Server importiert werden, müssen frei von Laufzeit-Abhängigkeiten auf Browser-Only-Code sein — auch `import type` reicht nicht als Schutz, wenn die importierte Datei selbst wieder aus dem `'../db'`-Barrel importiert (der zieht Dexie/rpcClient/Seeds mit, siehe `src/config/constants.ts`-Fix vom 2026-07-07: Import auf `'../db/types'` direkt umgestellt). Jede neue Datei dieser Art muss zusätzlich explizit in `tsconfig.server.json`s `include` aufgenommen werden (sonst „File is not listed within the file list of project" bzw. sie wird stillschweigend nicht mitkompiliert) — Muster: `src/db/types.ts`, `src/config/constants.ts`.
- `ConversationEntry` entspricht weiterhin **einem ganzen Sprecher-Turn** (ein zusammenhängender, mehrzeiliger Text-Block bis "✓ Schließen") — bewusst so belassen, ein Zwischenstand mit "ein Entry pro Zeile" wurde am 2026-07-02 wieder verworfen, weil er die Lesbarkeit des Protokolls zerstört hat. Kriterium-Zuordnung passiert stattdessen **pro Zeile innerhalb** eines Entry über `ConversationEntry.lineTags` (Matching per exaktem Zeilentext, nicht per Index — robust gegenüber nachträglichem Einfügen/Löschen von Zeilen). Zeilen splitten immer über `splitNoteLines()` in `src/utils/notationParser.tsx`, nicht erneut `note.split('\n')` inline schreiben (sonst laufen Trim-/Filter-Verhalten auseinander).
---
@ -83,19 +149,95 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
| Prio | Aufgabe | Datei |
|---|---|---|
| M | `Evaluation.tsx` auf neue Datenstruktur migrieren | `src/pages/Evaluation.tsx` |
| M | AI-Prompt auf `feedbackCriterionItems` umstellen | `src/pages/AssignmentFeedbackPage.tsx` |
| M | `meetingExport.ts` — Kriterien-Auflösung anpassen | `src/utils/meetingExport.ts` |
| N | Dashboard: Assignment-Nummer anzeigen | `src/pages/Dashboard.tsx` |
| N | AssignmentCreate: Feld für Assignment-Nummer | `src/pages/AssignmentCreate.tsx` |
| N | Toter Code in FeedbackStructureConfig entfernen | `src/pages/FeedbackStructureConfig.tsx` |
| L | SQLite WASM + OPFS Migration (kein festes Datum) | `src/db/index.ts` |
| M | **Migration der echten Daten aussteht:** Aktuelle IndexedDB-Daten müssen noch einmalig über Config → Backup → Export (alte Version) exportiert und über Config → Backup → Import (neue, serverbasierte Version) in die SQLite-DB eingespielt werden. Siehe Datenbank-Abschnitt oben. | — |
| L | Dexie/IndexedDB-Legacy-Code vollständig entfernen (`src/db/schema.ts`, `src/db/seeds/`) — aktuell bewusst nur stillgelegt, nicht gelöscht | `src/db/schema.ts`, `src/db/seeds/` |
| L | Server-seitiges "Seed-if-empty" für Assignment-Typen/Feedback-Struktur (aktuell nur clientseitig vorhanden und nicht mehr wirksam) — nicht dringend, da der Backup-Import denselben Zweck erfüllt | `server/db/queries/` (neu) |
| L | SQLite WASM + OPFS Migration — durch den lokalen Server-Ansatz (2026-07-04) überholt/hinfällig | `src/db/schema.ts` |
**Erledigt (2026-07-19): Live-Formulierungshilfe, Beobachter-Fokus, Meeting-Checkliste umgesetzt** (Konzept siehe Eintrag unten, dort auch die Design-Begründungen). Neue Dateien: `src/utils/criterionOptions.tsx` (aus `FeedbackStructureConfig.tsx` extrahiertes `criterionOptionGroups()`, jetzt auch von `AssignmentTypeConfig.tsx` genutzt), `server/db/queries/checklists.ts` + `src/db/queries/checklists.ts` (neues RPC-Modul `checklists`), `src/pages/meeting/ChecklistTab.tsx` (neuer dritter Meeting-Tab, nur sichtbar wenn `PhaseTemplate.hasChecklist`). `AssignmentTypeConfig.tsx` bekam pro Phase eine „Checkliste"-Checkbox + aufklappbaren Punkte-Editor (Label, Scope-Auswahl, bei `perInstitutee` optionales Kriterium-Dropdown). `deletePhaseTemplate`/`deleteAssignmentType` cascaden jetzt zusätzlich über `checklistItemDefs` (gleiches Verteidigungsprinzip wie bei `deleteFeedbackCriterionItem`). Live-Formulierungshilfe: neuer State `corpus` in `MeetingView.tsx` (einmal pro Meeting-Load geholt, nicht pro Tastendruck), debounced (~800ms) Aufruf von `findSimilarTaggedLines()` sowohl in `ConversationTab.tsx` (aktive Notiz) als auch für die allgemeinen Notizen — rein lesendes „💭 Ähnliche frühere Notizen"-Panel. Fokus-Übersicht als neue aufklappbare „📊 Fokus-Übersicht"-Box in `FeedbackStructureConfig.tsx` (Top 5 Kriterien nach `getCriterionUsageMap()`-Verwendung, inkl. `learnedPatternHints` als Kontext). Verifiziert: `npx tsc -b` fehlerfrei (dabei einen fehlenden `hasChecklist`-Wert im toten Dexie-Seed-Code `src/db/seeds/assignmentTypes.ts` nachgezogen, da dieser weiterhin vom Client-Build mitkompiliert wird); neue RPC-Endpunkte (`checklists.listChecklistItemDefsForPhaseTemplate`, `assignmentTypes.listPhaseTemplates`) direkt gegen die laufende Dev-Instanz getestet — Schema-Migration (neue Spalte `hasChecklist`, neue Tabellen `checklistItemDefs`/`checklistResponses`) griff automatisch ohne manuellen Server-Neustart. Manueller Ausfüll-Test einer Checkliste in einem echten Meeting (inkl. Prüfung, dass verknüpfte Punkte korrekt in `Assessment` landen) steht noch aus.
**Nachtrag (2026-07-19, nach erstem echten Nutzungstest):** Drei Korrekturen an der Checkliste, nachdem sich die ursprüngliche Einschränkung in der Praxis als zu eng erwies:
1. **Kriterium-Verknüpfung jetzt für beide Scopes möglich** (vorher nur `perInstitutee`). Bei `scope: meetingWide` + verknüpft schreibt `MeetingView.tsx::saveChecklistResponse` dieselbe Bewertung/Notiz per Fan-out in `Assessment` **für jeden aktuellen Institutee gleichzeitig** — ein meeting-weiter Checkpunkt gilt für alle Beteiligten gleichermaßen. Löst das ursprüngliche Problem („`Assessment` verlangt `instituteeId`") durch Duplizieren statt durch Ausschluss.
2. **`ChecklistResponse` ist jetzt immer die Quelle für die Checkliste-UI selbst**, unabhängig von Kriterium-Verknüpfung (vorher las/schrieb die UI bei verknüpften Punkten direkt `Assessment`, was zu einem unsauberen Round-Trip führte, sobald die Notiz auch das Checklisten-Label enthalten sollte). Bei Verknüpfung spiegelt `saveChecklistResponse()` zusätzlich **einseitig** (Checkliste → Assessment, nie zurück) nach `Assessment`, inkl. komponierter Notiz `Checkliste „<Label>" (<Bewertung>): <Kommentar>` über `composeChecklistNote()` — dadurch landet der Checkpunkt-Name selbst (nicht nur der freie Kommentar) in `aiPrompt.ts::kriterienScores`, das `Assessment.note` bereits einsammelt; kein Änderung an `aiPrompt.ts` nötig. Bekannte Einschränkung: Wird dasselbe Kriterium zusätzlich direkt über den (weiterhin vorhandenen) Gesamtbewertungs-Tab bearbeitet, gewinnt der jeweils letzte Schreibzugriff — gleiches Verhalten wie beim bestehenden „Aus Protokoll"-Button, keine neue Fehlerklasse.
3. **Keine eigene Checkliste-Tab-Karte mehr**`ChecklistTab` wird jetzt direkt oberhalb der Gesprächs-Zeitleiste innerhalb des Gesprächsprotokoll-Tabs gerendert (`tab === 'conversation'`), nicht mehr über einen separaten dritten Tab. Der Tab-Button „Gesprächsprotokoll" erscheint entsprechend schon bei `hasConv || hasChecklist`, damit auch reine Checklisten-Phasen (ohne Protokoll-Funktion) eine Heimat haben.
**Sechster Nachtrag (2026-07-20): Bug + fehlende Diagnose bei „Vorschläge analysieren" behoben.** Nutzer meldete: Aufruf lieferte keine Vorschläge, ohne jede Rückmeldung, was schiefläuft. Zwei Ursachen gefunden und behoben (`MeetingView.tsx::analyzeSuggestions`):
1. **Bug:** Checklisten-Punkte ohne effektives Kriterium wurden nur analysiert, wenn bereits ein Kommentar eingetragen war (`if (!comment) continue`) — reine Bewertungen ohne Freitext fielen dadurch komplett aus der Analyse heraus, obwohl der Checklisten-Punkt-Name (Label) selbst schon ein brauchbares Signal für die KI ist. Jetzt wird bei fehlendem Kommentar das Label allein als Text übergeben (`comment ? \`${ci.label}: ${comment}\` : ci.label`).
2. **Fehlende Diagnose:** Sowohl das stille `return` bei `lineRefs.length === 0` (nichts zu analysieren) als auch der Fall „KI hat für keine Zeile/keinen Punkt einen Vorschlag geliefert" gaben bisher keinerlei Rückmeldung. Neu: `suggestionInfo`-Statuszeile (blau, neben dem bestehenden roten Fehlerbanner) mit Zählung „X Zeile(n)/Punkt(e) analysiert, Y Vorschläge erhalten" sowie ein aufklappbares „🔍 Rohantwort ansehen"-Panel (`lastAnalysisRaw`), das die komplette, ungeparste KI-Antwort zeigt — macht Format-/Parsing-Probleme künftig direkt sichtbar statt raten zu müssen.
**Fünfter Nachtrag (2026-07-20): Checkliste in allen Export-/Berichts-Pfaden nachgezogen.** Bei der Prüfung stellte sich heraus, dass sämtliche bestehenden Export-/Berichtsfunktionen die Checkliste komplett ignorierten (vor Einführung der Checkliste geschrieben, nicht mitgepflegt). Nachgezogen:
- `server/db/queries/meetings.ts::getMeetingExportData()` liefert jetzt zusätzlich `checklistItems`/`checklistResponses` (nur wenn `template.hasChecklist`) — einzige Datenquelle für Meeting-Export, dadurch automatisch in MD/JSON verfügbar.
- Neue Bulk-Funktionen `listChecklistItemDefsForPhaseTemplates()`/`listChecklistResponsesForMeetings()` (Server + Client) analog zu `listAssessmentsForMeetings()` — für Berichte über mehrere Meetings/Phasentypen hinweg (`MeetingHistory.tsx`, Assignment-Report-PDF), vermeidet N+1-Abfragen.
- `src/utils/meetingExport.ts` (Markdown + JSON), `src/utils/pdfExport.tsx::AssignmentReportDocument` (PDF) und `src/pages/MeetingHistory.tsx` (Gesamtprotokoll-Ansicht) zeigen die Checkliste jetzt jeweils in derselben Position (nach dem Gesprächsprotokoll, vor der Gesamtbewertung) — Label, effektives Kriterium (individuelle Zuordnung > Konfigurations-Vorgabe, gleiche Auflösung wie in `MeetingView.tsx`), Bewertung, Kommentar.
- `src/utils/structureExport.ts` (Markdown) und `pdfExport.tsx::AssignmentTypesDocument` (PDF) — der Assignment-Typen-Bericht listet jetzt pro Phase zusätzlich `hasChecklist` und die konfigurierten Checklisten-Punkte (inkl. Scope und ggf. verknüpftem Kriterium), analog zum bereits bestehenden Kriterien-Mapping.
- Bewusst nicht angefasst: `AssignmentDetail.tsx`s Chip-Reihe (nur Rating-Aggregation, kein direkter Bezug zur Checkliste) — wäre ein separates, kleines UI-Ergänzung, kein Export/Bericht im eigentlichen Sinn.
**Vierter Nachtrag (2026-07-19): „Vorschläge analysieren" + Kriterium-Vorschläge jetzt auch für die Checkliste, individuelle Zuordnung pro Antwort.** Bisher war die Kriterium-Verknüpfung eines Checklisten-Punkts ausschließlich in der Config fix hinterlegt (`ChecklistItemDef.linkedCriterionItemId`) und für „🏷 Vorschläge analysieren" unsichtbar. Neu:
- **`ChecklistResponse.criterionItemId`** (neue, automatisch migrierte Spalte) überschreibt pro Antwort individuell die Konfigurations-Vorgabe — `getEffectiveChecklistCriterion()` in `MeetingView.tsx` liefert `response.criterionItemId ?? ci.linkedCriterionItemId ?? null`. Zuordnung direkt in `ChecklistTab.tsx` über denselben `CriterionTagPicker` wie im Protokoll (Chip + Popover, inkl. Suche und Vorschlägen) — keine separate Komponente nötig.
- **`analyzeSuggestions()` bezieht jetzt auch Checklisten-Kommentare ein:** Antworten ohne effektives Kriterium (weder individuell noch konfiguriert) mit vorhandenem Kommentar werden mit `${Label}: ${Kommentar}` als zusätzliche Zeile in denselben gebündelten KI-Call eingespeist wie Protokoll-/Notizzeilen. Da Checklisten-Antworten nicht textbasiert (wie `lineTags`) adressierbar sind, führt ein neuer optionaler `checklistKey`-Marker (`${checklistItemId}:${instituteeId ?? ''}`) am `LineRef` das Routing der KI-Antwort in eine eigene Map `checklistSuggestions` statt in die text-basierte `lineSuggestions`-Map.
- **Lokale (KI-freie) Checklisten-Vorschläge** analog zu `localLineSuggestions`: neuer `useMemo` `localChecklistSuggestions` wendet `findSimilarTaggedLines()` auf den Kommentartext jeder unzugeordneten Antwort an (Schwelle ≥ 0,25) — sofort verfügbar, ohne „Vorschläge analysieren" auszulösen. Merge mit den KI-Vorschlägen (`mergedChecklistSuggestions`, KI hat Vorrang) liefert `getChecklistSuggestions()`, das `ChecklistTab.tsx` genau wie im Protokoll als 💡-Chip im `CriterionTagPicker` anzeigt.
- **Mirroring nach `Assessment`** nutzt jetzt konsequent das *effektive* Kriterium (individuell > konfiguriert) statt nur `ci.linkedCriterionItemId`. Wechselt die individuelle Zuordnung nachträglich auf ein anderes Kriterium, bleibt ein bereits gespiegelter `Assessment`-Eintrag beim alten Kriterium bewusst unangetastet (keine rückwirkende Korrektur/Löschung) — gleiches einfaches Prinzip wie beim Löschen eines Checklisten-Punkts.
**Dritter Nachtrag (2026-07-19): Direkte Kriterien-Vorschläge ohne KI.** Zwei Ergänzungen zur Live-Formulierungshilfe, beide ohne KI-Call: (1) Das „💭 Ähnliche frühere Notizen"-Panel (`ConversationTab.tsx`/`MeetingView.tsx`) zeigt jetzt zusätzlich das Kriterium der jeweiligen Alt-Zeile an (`„Text" → 🏷 Kriterium`), aufgelöst aus dem bereits vorhandenen `criterionItemId` in `SimilarTaggedLine` — reine Zusatzinfo, keine neue Berechnung nötig. (2) Neuer `useMemo` `localLineSuggestions` in `MeetingView.tsx`: berechnet für **alle** noch nicht zugeordneten Zeilen (Gesprächsbeiträge + allgemeine Notizen, gleiche Sammel-/Ausschlusslogik wie `analyzeSuggestions`) lokale Kriterium-Vorschläge über `findSimilarTaggedLines()`, gefiltert auf Ähnlichkeit ≥ 0,25 („mittel", gleiche Schwelle wie in `criterionSuggestionPrompt.ts`) — macht den 💡-Vorschlags-Chip im `CriterionTagPicker` **sofort verfügbar, ohne „🏷 Vorschläge analysieren" auszulösen**. Wird mit den KI-Vorschlägen (`lineSuggestions`) zu `mergedLineSuggestions` zusammengeführt — KI-Vorschläge haben Vorrang, wenn für dieselbe Zeile beides vorliegt (`lineSuggestions`-Einträge überschreiben `localLineSuggestions` beim Merge). Geprüft und bestätigt: „🏷 Vorschläge analysieren" überschreibt nie eine bereits bestehende Zuordnung — `analyzeSuggestions` schließt bereits getaggte Zeilen von vornherein von der KI-Anfrage aus (`taggedTexts.has(line)`-Filter), und `CriterionTagPicker` zeigt bei vorhandener Zuordnung ohnehin immer den Tag-Chip statt des Vorschlags-Chips (Vorschläge sind nur bei leerer Zuordnung überhaupt sichtbar).
**Zweiter Nachtrag (2026-07-19):** Zwei UI-Korrekturen nach echtem Nutzungsfeedback. (1) `ChecklistTab.tsx` nahm zu viel Bildschirmplatz ein (eine Karte mit großen Buttons + immer sichtbarem Kommentarfeld pro Institutee/Punkt) — umgebaut auf Zeilen-Darstellung: `<select>` statt fünf breiter Bewertungs-Buttons, Kommentar nur über ein 💬-Icon ausklappbar (Icon zeigt blau eingefärbt an, wenn bereits ein Kommentar hinterlegt ist). (2) Die Live-Formulierungshilfe (`💭 Ähnliche frühere Notizen`, `ConversationTab.tsx` + `MeetingView.tsx`) zeigte Vorschläge bisher nur rein lesend an — jetzt per Klick übernehmbar, ersetzt dabei die gerade getippte (letzte) Zeile der Notiz. Wichtige Falle dabei: In `MeetingView.tsx` hätte ein reiner Klick auf den Vorschlag zuerst den `onBlur`-Handler der Notizen-Textarea ausgelöst (`finishEditingGeneralNotes()`, beendet den Editier-Modus und entfernt damit Textarea+Vorschlagsliste aus dem DOM, bevor der Klick verarbeitet wird) — behoben über `onMouseDown={e => e.preventDefault()}` auf den Vorschlags-Buttons, verhindert den Fokuswechsel/Blur überhaupt erst (gleiches Muster wie `stopPropagation` beim `CriterionTagPicker` in den allgemeinen Notizen, nur mit `preventDefault` statt `stopPropagation`, da hier kein Klick-Bubbling sondern der native Blur-vor-Click-Ablauf das Problem ist).
**Konzept (2026-07-18): Live-Formulierungshilfe, Beobachter-Fokus, Meeting-Checkliste.** Zwei unabhängige Erweiterungen, in einer Design-Session gemeinsam geprüft:
- **Live-Formulierungshilfe (kein KI-Call):** Während des Tippens in `ConversationTab.tsx`/allgemeinen Notizen liefert ein debounced Aufruf der bereits bestehenden `findSimilarTaggedLines()` (`src/utils/criterionSuggestion.ts`) ein rein lesendes Referenz-Panel „Ähnliche frühere Notizen" — bewusst **kein** Autocomplete/Text-Einfügung (würde die 2026-07-02 getroffene „nicht live während des Tippens"-Entscheidung für die Zuordnung unterlaufen; hier geht es nur um unverbindliche Formulierungs-Inspiration, kein Eingriff in den Text). Kein neues Backend nötig.
- **Beobachter-Fokus (global, keine Bewerter-Identität nötig):** Die App hat kein Login-/Nutzerkonzept — „was wird typischerweise fokussiert" bezieht sich daher bewusst auf alle Bewerter zusammen. Leistet im Kern bereits `learnedPatternHints` (Wie wird formuliert) + `getCriterionUsageMap()` (Was wird häufig kommentiert, bereits für die Config→Feedback-Verwendungs-Badges gebaut). Nur eine neue, rein lesende Anzeige nötig, kein neues Datenmodell.
- **Meeting-Checkliste pro Phasentyp:** Neues Feld `PhaseTemplate.hasChecklist` (Opt-in wie `hasConversation`/`hasAssessment`) + neue Tabelle `ChecklistItemDef` (`phaseTemplateId`, `label`, `order`, optional `linkedCriterionItemId`, `scope: 'perInstitutee' | 'meetingWide'` — pro Eintrag konfigurierbar). Wichtiger Fund: `PhaseTemplate.criteriaIds` existiert zwar schon, wird aber aktuell nirgends live im Meeting ausgewertet (nur für `getCriterionUsageMap`-Zählung) — bewusst NICHT für die Checkliste umgewidmet, da es bereits eine andere, etablierte Sichtbarkeits-Scoping-Semantik hat. Antwort-Ablage maximiert Wiederverwendung bestehender Strukturen statt einer vierten Bewertungs-Struktur: „pro Institutee + verknüpft" → direkt in `Assessment` (bestehende Funktionen); „meeting-weit + verknüpft" → direkt in `MeetingInstance.generalNoteTags` (bestehender, institutee-unabhängiger Mechanismus); nur „frei definiert" (verknüpft oder nicht, jeweils pro Institutee oder meeting-weit) braucht die einzige wirklich neue Tabelle `ChecklistResponse` (`meetingInstanceId`, `instituteeId?`, `checklistItemId`, `rating`, `note`) — bewusst **ohne** Aggregation in Evaluation/gewichteten Durchschnitt (reine lokale Meeting-Notiz, vermeidet eine dritte manuell synchron zu haltende Bewertungsstruktur neben Assessment/FeedbackCategoryRating). UI: neuer optionaler Tab „Checkliste" in `MeetingView.tsx`, Konfiguration als neuer Sub-Tab in `AssignmentTypeConfig.tsx`, Kriterium-Verknüpfung über das bestehende `criterionOptionGroups()`-Dropdown-Muster aus `FeedbackStructureConfig.tsx`. **Präzisiert bei der Umsetzungsplanung (gleicher Tag):** „meeting-weit + verknüpft" hat keine saubere Heimat (`Assessment` verlangt zwingend `instituteeId` — Kriterien sind in diesem Modell immer Personen-Kompetenzen, nie Meeting-Kollektiv-Eigenschaften; `generalNoteTags` kennt keine Bewertungsstufe, nur Text+Tag). Deshalb: Kriterium-Verknüpfung ist nur bei `scope: perInstitutee` möglich, `scope: meetingWide`-Punkte sind immer „frei" (kein Kriterium-Dropdown im UI). Vereinfacht die Ablage auf 3 statt 4 Fälle, siehe Umsetzungsplan.
**Konzept (2026-07-16, noch nicht umgesetzt): Selbstlernendes Kriterien-Tagging.** Löst den Offene-Punkte-Eintrag „Selbstlernendes Tagging" oben ab (bisher nur Idee, jetzt fertig gescoped in einer eigenen Design-Session, siehe Vorgabe oben). Ziel: eine priorisierte, KI-gestützte Vorschlagsliste für die Kriterium-Zuordnung noch nicht getaggter Protokollzeilen, direkt im `CriterionTagPicker` aufrufbar. Kernentscheidungen:
- **Kein neues Datenmodell für den Lernkorpus.** Die Lernbasis ist der bereits bestehende `lineTags`/`generalNoteTags`-Bestand, global über alle Assignments hinweg (siehe `getCriterionUsageMap()`/`getCriterionUsageDetails()` in `server/db/queries/feedbackStructure.ts`). Jede neu vergebene Zuordnung vergrößert den Korpus automatisch — kein separater Trainingsschritt, keine Migration nötig.
- **Zweistufiger Ablauf.** (1) Rein lokale Ähnlichkeitssuche ohne KI-Call (Wortüberschneidung/Jaccard über normalisierten Zeilentext; `parseNotationPrefix()`/`splitNoteLines()` zur Normalisierung wiederverwendet) liefert pro noch nicht zugeordneter Zeile ~58 ähnlichste Alt-Beispiele als Kontext-Hinweis. (2) EIN gebündelter KI-Call pro Protokoll-Ansicht (nicht pro Zeile) analysiert alle noch nicht zugeordneten Zeilen des gesamten offenen Protokolls auf einmal und liefert pro Zeile eine priorisierte Kandidatenliste (Kriterium-IDs) statt nur eines Einzelvorschlags. Fallback auf Einzel-Zeilen-Calls ist vorgesehen, aber erst nach einem ersten Testlauf zu entscheiden, falls die Bündelung qualitativ nicht ausreicht — nicht vorab bauen.
- **Expliziter Trigger-Button** („🏷 Vorschläge analysieren"), kein automatischer Call beim Öffnen des Meetings — gleiches Muster wie die bestehende KI-Feedback-Generierung (keine versteckten API-Kosten/Latenz im Hintergrund).
- **Neuer Platzhalter `{{KRITERIEN_STRUKTUR}}`** für den Tagging-Prompt: vollständige, ID-referenzierte Struktur Dimension → Kategorie (Name+Beschreibung) → Kriterium (Name+Beschreibung), gefiltert über die bestehende `filterVisibleFeedbackStructure()` anhand `AssignmentType.criteriaIds` — dieselbe Filterlogik wie überall sonst im Projekt, nicht neu implementiert. Kriterien werden über ihre ID referenziert statt über den Namen (robust gegen Namenskollisionen beim Parsen der KI-Antwort). `FeedbackDimension` hat aktuell kein `description`-Feld (anders als Kategorie/Kriterium) — „alle Erklärungsebenen" bezieht sich daher auf Dimension-Name + Kategorie-Name/Beschreibung + Kriterium-Name/Beschreibung. Weitere neue Platzhalter: `{{ASSIGNMENT_TYP}}`, `{{PROTOKOLL_ZEILEN}}` (nummerierte, noch nicht zugeordnete Zeilen des gesamten Protokolls), `{{AEHNLICHE_ALT_ZUORDNUNGEN}}` (Ergebnis aus Schritt 1).
- **EIN gemeinsames Prompt-Template statt eines pro Assignment-Typ** — die Typ-Abhängigkeit steckt im Inhalt von `{{KRITERIEN_STRUKTUR}}`, nicht in der Anzahl der Templates (gleiches Prinzip wie `{{KATEGORIEN_STRUKTUR}}` heute schon). Persistiert als neues Feld `AppSettings.criterionTaggingPromptTemplate` (automatische Spalten-Migration wie gehabt), editierbar in einem neuen, eigenen Abschnitt in `AiPromptConfig.tsx` („KI-Prompt: Kriterien-Zuordnungsvorschläge") — gleiches Aufklapp-Textarea-Muster + eigene Platzhalter-Legende wie der bestehende Standard-Prompt-Bereich. Kein Eintrag pro Dimension nötig, da es nur eine Protokoll-weite Analyse gibt, nicht pro Dimension.
- **Antwortformat:** Zeilennummer + priorisierte Kriterium-ID-Liste als Klartext-Marker (`ZEILE 3: 12, 47, 5`, `-` bei keinem Vorschlag) statt JSON — bewusst gleiches Prinzip wie `parseDimensionAiResponse` (JSON/Codefence-Parsing-Bugs waren 2026-07-07 der Grund für die Umstellung auf Marker-Text dort). Parser tolerant gegenüber fehlenden/kaputten Zeilen.
- **Datenschutz-Hinweis:** Gesprächsnotizen gehen für Schritt 2 an dieselbe bereits genutzte externe KI-API wie die bestehende Feedback-Generierung (gleiches Muster, kein neues Risiko-Muster) — vor Produktivnutzung trotzdem kurz mit dem Datenschutzbeauftragten abstimmen, da es eine neue Nutzungsart derselben Datenklasse ist.
**Nachtrag (2026-07-17):** Zusätzliche Lernebene beschlossen — bewusster Mittelweg zwischen reiner Wortüberschneidung und einem separaten trainierten Klassifikator (Letzteres verworfen, da ein eigener Trainings-/Aktualisierungsschritt der Prämisse „kein separater Trainingsschritt" widersprochen hätte). Der ohnehin gebündelte KI-Aufruf liefert pro Analyse-Lauf zusätzlich einen `MUSTER:`-Block mit kurzen, generalisierten Formulierungsmustern **pro Kriterium** (nicht pro Zeile), z.B. „Kunde wird häufig nach offenen Fragen gefragt, bevor...". Diese landen in einem neuen, optionalen Feld `FeedbackCriterionItem.learnedPatternHints` (automatische Spalten-Migration wie gehabt, kein neues Datenmodell/keine neue Tabelle) und werden bei jedem weiteren Lauf überschrieben/verfeinert — reines Nebenprodukt des normalen „🏷 Vorschläge analysieren"-Klicks, kein separater Trainingslauf. Neuer Platzhalter `{{GELERNTE_MUSTER}}` speist diese Muster (nur für die im aktuellen Assignment-Typ sichtbaren Kriterien) zusätzlich zu `{{AEHNLICHE_ALT_ZUORDNUNGEN}}` in künftige Prompts ein — verbessert die Vorschlagsqualität über die Zeit, ersetzt den KI-Call aber bewusst nicht.
**Erledigt (2026-07-17): Selbstlernendes Kriterien-Tagging umgesetzt** (siehe Konzept + Nachtrag oben). Neuer Button „🏷 Vorschläge analysieren" in `MeetingView.tsx` löst EINEN gebündelten KI-Call für das gesamte offene Protokoll aus (Gesprächsbeiträge + allgemeine Notizen, Kundenzitate ausgenommen). Neue Bausteine: `server/db/queries/criterionSuggestions.ts` (`getGlobalLineTagCorpus()`, flacht `lineTags`/`generalNoteTags` global ab, gleiches Iterationsmuster wie `getCriterionUsageMap()`), `src/utils/criterionSuggestion.ts` (rein lokale Jaccard-Ähnlichkeitssuche, kein KI-Call), `src/utils/criterionSuggestionPrompt.ts` (`buildCriterionStructureText()`/`buildCriterionSuggestionPromptContext()`/`parseCriterionSuggestionResponse()` — Response-Format bewusst Klartext-Marker `ZEILE n: <IDs>` + `MUSTER:`-Block statt JSON, gleiches Robustheitsprinzip wie `parseDimensionAiResponse`). `AppSettings.criterionTaggingPromptTemplate` (ein gemeinsames Template statt eines pro Assignment-Typ, editierbar in `AiPromptConfig.tsx` unter „KI-Prompt: Kriterien-Zuordnungsvorschläge") und `FeedbackCriterionItem.learnedPatternHints` sind neue, automatisch migrierte Spalten. `CriterionTagPicker.tsx` zeigt bei fehlender Zuordnung einen 💡-Vorschlags-Chip (Top-Vorschlag per Klick übernehmbar, Alternativen + volle Suche im Popover) statt des leeren „+"-Buttons. Nebenbei extrahiert: `src/utils/aiClient.ts` (`callOpenRouter()`/`getAiSettings()`/`saveAiSettings()`) aus `AssignmentFeedbackPage.tsx`, damit die bestehende KI-Feedback-Generierung und das neue Tagging dieselbe OpenRouter-Anbindung nutzen statt zwei leicht abweichende Implementierungen zu pflegen — reiner Refactor, kein Verhaltensunterschied. Verifiziert: `npx tsc -b` fehlerfrei; neue RPC-Endpunkte (`criterionSuggestions.getGlobalLineTagCorpus`, `appSettings.getOrSeedCriterionTaggingPromptTemplate`) direkt gegen die laufende Dev-Instanz getestet (der Server läuft unter `tsx watch` und hat Schema-Migration + neue Module automatisch übernommen, ohne manuellen Neustart). Manueller Browser-Test des Gesamtflusses (Klick auf „Vorschläge analysieren" in einem echten Meeting, Übernehmen eines Vorschlags) steht noch aus — sollte vor breiter Nutzung nachgeholt werden.
**Erledigt (2026-07-13, dritte Runde):** Kriterium-Zuordnung für „Allgemeine Notizen" (`MeetingInstance.generalNoteTags`, neue Spalte, gleicher Typ `ConversationLineTag[]` wie `ConversationEntry.lineTags`) — anders als die bestehende Zeilen-Zuordnung in Gesprächsbeiträgen ist diese **nicht** an ein Institutee gebunden, sondern gilt für **alle** Institutees des Assignments gleichzeitig, weil allgemeine Meeting-Notizen keinem einzelnen Sprecher zugeordnet sind. Erfassung in `MeetingView.tsx`: derselbe `CriterionTagPicker` wie bei Gesprächsbeiträgen, per `renderLineAddon` an `NotationText` gehängt (Klick auf den Picker mit `stopPropagation`, da der umgebende Notizen-Block sonst in den Editier-Modus wechseln würde); verwaiste Zuordnungen werden beim Verlassen des Editier-Modus anhand der aktuellen Zeilen bereinigt (gleiches Muster wie `saveEdit` bei Gesprächsbeiträgen). Fünf bestehende Konsumenten von `lineTags` wurden konsistent um die neue, ungefilterte Quelle ergänzt (Recherche in zwei unabhängigen Durchläufen bestätigt, dass dies eine vollständige Liste ist): `AssessmentTab.suggestedRating()` (💡-Vorschlags-Badges, jetzt pro Kriterium für jedes Institutee identisch beeinflusst), `aiPrompt.ts::buildDimensionPromptContext` (`{{KRITERIEN_SCORES}}` bekommt die zugeordneten Zeilen zusätzlich, `{{NOTIZEN_ALLGEMEIN_MEETING}}` blendet bereits zugeordnete Zeilen aus — exakt das bestehende Muster von `{{PROTOKOLL_UNZUGEORDNET}}`), `meetingExport.ts` (Kriterien-ID-Sammlung + `_(→ Kriteriumsname)_`-Annotation pro Zeile im Markdown-Export), `FeedbackDraftPage.tsx` (Legacy-Feedback-Entwurf, gleiche Erweiterung von Kriterien-Sammlung und Notizen), `MeetingHistory.tsx` (reine Anzeige, sonst wäre die neue Zuordnung dort unsichtbar geblieben). Bei leerem/fehlendem `generalNoteTags` ist das Verhalten überall identisch zu vorher — bestehende Daten/Prompts brechen nicht.
**Erledigt (2026-07-13, zweite Runde):** Chronologisches Gesamtprotokoll aller Meetings eines Assignments (`MeetingHistory.tsx`, Route `/assignment/:id/history`) — Anlass: Recap-Calls nach jedem Kundenmeeting brauchen einen schnellen Blick auf die Historie vorheriger Meetings, zusätzlich hilfreich bei der Gesamtdurchsprache im finalen Feedback-Call. Rein lesende Seite, baut bewusst auf bereits vorhandenen Bulk-Abfragen auf statt neuen Server-Code zu schreiben: `listDoneMeetingsChronological()` (kanonischer Trash-Filter + Sortierung, siehe unten) liefert die Meetings, `listConversationEntriesForMeetings()`/`listAssessmentsForMeetings()` (beide bereits vorhanden, u.a. schon von `AssignmentDetail.tsx` genutzt) liefern Notizen/Bewertungen über alle Meetings auf einen Schlag statt N+1-Abfragen pro Meeting. Rendering nutzt bestehende Bausteine statt Neuimplementierung: `NotationText` (`notationParser.tsx`) für Notiz-Formatierung, dieselbe `weightedAverageRating()`+`RATING_BG`-Chip-Logik wie die Meeting-Kacheln in `AssignmentDetail.tsx`. Kriterium-Tags werden hier bewusst nur als statisches Badge angezeigt (kein interaktiver `CriterionTagPicker`), da die Seite reine Anzeige ist. Das jeweils neueste Meeting wird optisch hervorgehoben und die Seite scrollt beim Laden automatisch dorthin — deckt den Recap-Anwendungsfall ab, während beim Hochscrollen die volle Historie für die Feedback-Gesamtdurchsprache verfügbar bleibt. Erreichbar über neue Buttons sowohl auf `AssignmentDetail.tsx` („📜 Gesamtprotokoll") als auch direkt aus `MeetingView.tsx` heraus („📜 Verlauf"), damit man auch mitten im Recap-Call ohne Umweg über die Detailseite wechseln kann.
**Erledigt (2026-07-14):** KI-Feedback-Generierung (siehe 2026-07-07) anhand echter Testläufe nachgeschärft. `RATING_OPTIONS` (`src/config/constants.ts`) hat ein neues `full`-Feld ("Not/Partially/Nearly/Fully Client Ready", "Not Applicable") als einzige Quelle der Wahrheit für ausgeschriebene Bewertungslabels — wichtig fürs KI-Prompting, da die kurzen Labels ("Not") für ein Modell ohne Kontext zum Zweck des Trainee-Programms (Ziel: "Client Ready" werden) nicht aussagekräftig genug sind. `src/utils/notationParser.tsx` hat eine neue Funktion `parseNotationPrefix()` (liefert Rating/Zitat-Flag/Resttext, `detectNotationRating()` ist jetzt darauf aufgebaut, keine doppelte Symbol-Zuordnung mehr). In `aiPrompt.ts` löst `formatNotationLine()` jede Notiz-Zeile (`{{KRITERIEN_SCORES}}`-Notizen, `{{NOTIZEN_ALLGEMEIN_MEETING}}`, `{{PROTOKOLL_UNZUGEORDNET}}`) konsistent auf — Format `Label — Text` (bewusst **keine** eckigen Klammern, da `[Titel]` bereits für Slide-/Themen-Referenzen reserviert ist und sonst mit aufgelösten Bewertungen kollidieren würde). Zwei fachliche Korrekturen dabei: (1) `{{KRITERIEN_SCORES}}` lässt Kriterien ohne jede Bewertung/Notiz jetzt komplett aus (reines Token-Rauschen), ebenso ganze Kategorien ohne verwertbare Kriterien. (2) Mit `>` markierte Kundenzitate werden aus `{{NOTIZEN_ALLGEMEIN_MEETING}}`/`{{PROTOKOLL_UNZUGEORDNET}}` jetzt **komplett herausgefiltert** statt nur aufgelöst — Kundenaussagen sind fachlicher Assignment-Kontext, keine Aussage über den Berater, und würden besonders beim Feedback-Call mit dem Kunden ein verzerrtes Bild erzeugen (der Kunde gibt ohnehin separates Feedback). Explizit einem Kriterium zugeordnete Zitate (bewusste Tagging-Entscheidung des Bewerters) werden davon nicht angefasst. Output-Format umgestellt von Fließtext-Absätzen auf Stichpunkte (`- `-Zeilen) — leichter in die offizielle Feedback-App zu übertragen; Development-Needs-Stichpunkte müssen jetzt explizit als Handlungsempfehlung formuliert sein (z.B. "Sollte darauf achten, dass ..."). Zusätzlich eine explizite Anti-Halluzinations-Anweisung im Prompt ("nutze ausschließlich die angegebenen Daten, erfinde nichts") ergänzt, nachdem die KI vereinzelt Details erfunden hatte. **Wichtige Einschränkung, die bei jeder künftigen Prompt-Engine-Änderung zu beachten ist:** Da jede Dimension einen **vollständig unabhängigen** Prompt-Text in der DB hat (bewusste Design-Entscheidung, siehe 2026-07-07), wirken Verbesserungen an `AI_CONFIG.defaultDimensionPromptTemplate` NICHT automatisch auf bereits existierende Dimensionen — nur auf neu angelegte oder explizit per "Auf Standard zurücksetzen" aktualisierte. Alle Änderungen dieser Runde wurden nach Rückfrage per Skript einmalig auf alle 5 bestehenden Dimensionen nachgezogen (nur unbedenklich, weil vorher per Read-Check bestätigt wurde, dass noch keine individuellen Anpassungen existierten).
**Erledigt (2026-07-14, dritte Runde):** PDF-Export ergänzt (`src/utils/pdfExport.tsx`, neue Abhängigkeit `@react-pdf/renderer` — reines JS, kein natives Kompilieren nötig, läuft browser- und Node-seitig). Zwei Dokument-Typen: `AssignmentReportDocument` (Gesamtprotokoll wie `MeetingHistory.tsx` + Feedback-Anhang aller Institutees, unabhängig vom Status Entwurf/Final) und `InstituteeFeedbackDocument` (nur das Feedback einer Person). Wichtig: Der Feedback-Teil zeigt ausschließlich die **finalen** Felder (`achievements`/`developmentNeeds`/`text`/`rating`) — niemals die `suggested*`-KI-Vorschläge, die noch nicht per „Übernehmen" bestätigt wurden. Notation in den Protokollzeilen wird wie in der App selbst als Icon/Farbe dargestellt (✓/✗/✓~/✗~/, aus `parseNotationPrefix()` abgeleitet) — bewusst anders als die textuelle "Label — Text"-Auflösung in `aiPrompt.ts`, da das PDF für Menschen ist, die die App-Notation schon kennen, nicht für ein KI-Modell ohne Kontext. Erzeugung/Download sind getrennt (`buildAssignmentReportPdfBlob`/`buildInstituteeFeedbackPdfBlob` liefern nur den Blob, `exportAssignmentReportPdf`/`exportInstituteeFeedbackPdf` triggern zusätzlich den Browser-Download) — dadurch auch außerhalb des Browsers (Node/tsx) testbar, was beim Verifizieren genutzt wurde (die Download-Hälfte braucht `document`/`URL`, ist also browser-only). Buttons an drei Stellen: `MeetingHistory.tsx` (Gesamt-Report), `AssignmentFeedbackPage.tsx` (eigenes Feedback), `AssignmentDetail.tsx` (Schnellzugriff für beide Varianten ohne Navigation). Nachträglich (nach erstem Testlauf) Seitenumbrüche korrigiert: `wrap={false}` auf ganzen Meeting-/Dimension-Blöcken erzwang unsinnige Umbrüche/Lücken, sobald ein Block länger als eine Seite war — stattdessen nur noch kleine Kopfzeilen-Blöcke (Meeting-Titel+Datum, Dimension-/Institutee-Name, Achievements/Development-Needs-Label) mit `wrap={false}` + `minPresenceAhead` vor Verwaisung geschützt, der eigentliche Inhalt darf frei umbrechen. Zusätzlich erzwungener Seitenwechsel (`break`) vor dem „Feedback"-Abschnitt und vor jedem weiteren Institutee, damit Protokoll und Feedback klar getrennt bleiben. Ebenfalls ergänzt: Kriterium-Zuordnung pro Protokollzeile (`lineTags`/`generalNoteTags`) wird jetzt wie in `MeetingHistory.tsx`/`meetingExport.ts` als kleiner „→ Kriteriumsname"-Zusatz hinter der Zeile angezeigt (indigo, per `visibleItems`-Lookup) — vorher zeigte das PDF nur die Notation, aber keine Kriterium-Zuordnung.
**Erledigt (2026-07-16, zweite Runde):** Sicherer Umbau der Feedback-Struktur (Config → Feedback), motiviert durch laufende Umbenennungen/Löschungen/Verschiebungen von Kriterien ohne Überblick, wo diese bereits verwendet werden. Neu in `server/db/queries/feedbackStructure.ts`: `getCriterionUsageMap()` — EIN Durchlauf über `assessments`, `conversationEntries.lineTags`, `meetingInstances.generalNoteTags`, `assignmentTypes.criteriaIds`, `phaseTemplates.criteriaIds` liefert eine `criterionId → CriterionUsage`-Map (Zählungen + betroffene Meetings), wird beim Laden von `FeedbackStructureConfig.tsx` mitgeladen und zeigt pro Kriterium einen aufklappbaren „N Verwendungen"-Badge. **Verschieben** zwischen Kategorien per einfachem Dropdown (kein Drag & Drop, keine neue Abhängigkeit — nutzt das bereits vorhandene `updateFeedbackCriterionItem(id, { categoryId })`). **Löschen mit Verwendung** zeigt jetzt ein Inline-Panel mit zwei Wegen: „Übertragen auf ▾" (`reassignCriterionReferences(fromId, toId)`, verschiebt Assessments/Tags/Sichtbarkeitslisten auf ein anderes Kriterium, dann Löschen) oder „Trotzdem löschen" (Bewertungen werden gelöscht, Protokoll-Zuordnungen nur entfernt, Notiztext bleibt). **Wichtiger Edge-Case bei `reassignCriterionReferences`:** `assessments` hat keine DB-Unique-Constraint auf `(meetingInstanceId, instituteeId, criteriaId)` — ein blindes Umschreiben von `criteriaId` könnte eine Dubletten-Zeile für dieselbe Meeting/Institutee-Kombination erzeugen. Bei Konflikt wird die Quell-Zeile stattdessen in die bestehende Ziel-Zeile gemergt (Notizen zusammengeführt, vorhandener Score der Zielzeile hat Vorrang) und gelöscht; Rückgabewert `mergedConflicts` macht das der UI gegenüber transparent. `deleteFeedbackCriterionItem()` räumt jetzt selbst alle Referenzen auf (Verteidigung in der Tiefe, unabhängig vom UI-Pfad), `deleteFeedbackCategory()`/`deleteFeedbackDimension()` cascaden ab jetzt **durch** diese Funktion statt per direktem Bulk-Delete, damit die Aufräum-Logik auch bei Kategorie-/Dimension-Löschung greift. Verifiziert per Skript auf rein synthetischen Testdaten (temporäre Dimension/Kategorie/2 Kriterien + ein Test-Meeting unter einem echten Assignment, absichtlich erzeugter Konfliktfall bestätigt korrektes Merge-Verhalten ohne Dublette, danach vollständig wieder entfernt).
**Nachtrag (gleicher Tag):** Reine Zählungen/Meeting-Liste reichten nicht — beim Konsolidieren zweier Kriterien musste man sonst weiterhin jedes Meeting einzeln durchsuchen. Neu: `getCriterionUsageDetails(criterionId)` liefert pro Kriterium eine **Einzelinstanzen-Liste** (`CriterionUsageInstance`, discriminated union `assessment`/`lineTag`/`generalNoteTag`, mit Meeting/Assignment/Person/Text aufgelöst) — bewusst lazy pro Kriterium beim Aufklappen geladen, nicht eager für alle (im Unterschied zu `getCriterionUsageMap`), da hier echte Texte/Namen aufgelöst werden. Direkt im aufgeklappten Verwendungs-Badge kann jede einzelne Instanz jetzt per „→ anderes Kriterium…"-Dropdown + „Übertragen" gezielt umgehängt oder per „Entfernen" einzeln entfernt werden (`reassignUsageInstance()`/`removeUsageInstance()`) — ohne das ganze Kriterium zu löschen und ohne alle Verwendungen auf einmal zu bewegen. Die Konflikt-Merge-Logik für Assessments wurde dafür aus `reassignCriterionReferences` in eine gemeinsame Hilfsfunktion `mergeOrReassignAssessment()` extrahiert, damit Bulk- und Einzel-Reassign nicht zwei leicht unterschiedliche Implementierungen derselben Regel pflegen. Ebenfalls per Skript auf synthetischen Testdaten verifiziert (Einzel-Entfernen erhält den Notiztext, Einzel-Übertragen löst denselben Merge-Konflikt korrekt wie die Bulk-Variante).
**Zweiter Nachtrag (gleicher Tag):** Nach erstem Testlauf gemeldet: Ein Kriterium zeigte nach Bereinigung aller Bewertungen/Protokoll-Tags weiterhin „4 Verwendungen" — Ursache war, dass `assignmentTypeNames`/`phaseTemplateLabels` (Sichtbarkeits-Zuordnung in `AssignmentType.criteriaIds`/`PhaseTemplate.criteriaIds`) zwar in die Zählung eingehen, aber bis dahin nur als reiner Text angezeigt wurden — keine Einzelinstanz, kein Weg, sie von hier aus zu bereinigen. `CriterionUsageInstance` um zwei weitere Kinds ergänzt (`assignmentTypeVisibility`, `phaseTemplateVisibility`, mit `criterionId`-Feld, damit `reassignUsageInstance`/`removeUsageInstance` wissen, welche ID sie in der jeweiligen `criteriaIds`-Liste ersetzen/entfernen sollen). Damit sind jetzt **alle** in der Zählung enthaltenen Verwendungsarten auch einzeln direkt aus dem aufgeklappten Badge heraus behebbar, nicht nur die drei Protokoll-/Bewertungsarten. Client-Anzeige unterscheidet per `isDataInstance()`-Typprädikat zwischen den beiden Darstellungsformen (Meeting/Person-Kontext vs. reiner Name). Ebenfalls per Skript auf synthetischen Testdaten (temporärer Assignment-Typ + Phase-Vorlage, danach entfernt) verifiziert.
**Dritter Nachtrag (gleicher Tag):** Zwei UI-Bugs nach erstem echten Nutzungstest gefixt (`FeedbackStructureConfig.tsx`). (1) Alle „Ziel-Kriterium"-Dropdowns (Verschieben/Übertragen) gruppierten nur nach Dimension und übersprangen die Kategorie-Ebene — HTML kennt kein verschachteltes `<optgroup>`, daher jetzt eine kombinierte Optgroup-Beschriftung `"Dimension → Kategorie"` über die neue gemeinsame Hilfsfunktion `criterionOptionGroups()` (an allen drei Stellen genutzt: Verschieben-Dropdown, Lösch-Dialog-Übertragen, Einzel-Verwendungs-Übertragen). (2) Schwerwiegenderer Bug: Die Ziel-Auswahl pro Einzelverwendung war über den **Array-Index** in der Instanzen-Liste verknüpft (`` `${itemId}:${idx}` ``). Nach jedem „Übertragen" wird die Liste neu vom Server geladen, wodurch sich die Indizes der verbleibenden Instanzen verschieben — eine bei Index 3 gespeicherte Ziel-Auswahl gehörte danach plötzlich zu einer anderen Instanz, und ein Klick auf „Übertragen" verschob nachweislich die falsche Zeile. Gefixt durch einen **stabilen, inhaltsbasierten Schlüssel** (`instanceKey()`: `assessmentId` bzw. `conversationEntryId`/`meetingInstanceId` + Zeilentext bzw. `assignmentTypeId`/`phaseTemplateId`) statt des Index — Auswahlen bleiben dadurch auch nach einem Reload an der richtigen Instanz hängen, und bereits übertragene/entfernte Instanzen verschwinden korrekt aus der Liste statt eine falsche Zeile zu verändern.
**Erledigt (2026-07-16):** Zwei weitere Exporte ergänzt, jeweils als Markdown UND PDF. (1) „Feedback-Struktur" (Config → Feedback, neue Buttons „⬇ MD"/„⬇ PDF" im Header): alle Dimensionen/Kategorien/Kriterien inkl. Gewichtung und aller Beschreibungen (`FeedbackCategory.description`/`FeedbackCriterionItem.description`, siehe 2026-07-07/2026-07-15). (2) „Assignment-Typen-Bericht" (Config → Ass.-Typen, gleiche Buttons): pro Assignment-Typ die Phasen sowie das Kriterien-Mapping (`AssignmentType.criteriaIds`) — bei leerer/undefined `criteriaIds` „Alle Kriterien", sonst nach Dimension/Kategorie gruppierte Liste der zugeordneten Kriteriennamen (exakt dieselbe Lookup-Logik wie im „Kriterien"-Tab von `AssignmentTypeConfig.tsx`, nicht neu erfunden). Markdown-Erzeugung in neuer Datei `src/utils/structureExport.ts` (nutzt bestehendes `downloadFile()` aus `meetingExport.ts`), PDF-Dokumente als zwei weitere Komponenten in `pdfExport.tsx` (gleiche Styles/Pagination-Helfer wie die bestehenden Berichte). Bei der Verifikation aufgefallen, aber bewusst nicht angefasst: Phasen-Labels enthalten in den Bestandsdaten teils schon "(Lead)"/"(Kunde)" als Teil des Freitexts, wodurch der Bericht das zusätzlich per `PhaseTemplate.withWhom` angehängte "(Lead)"/"(Kunde)" doppelt zeigt — das ist eine Dateneingabe-Redundanz, keine Automatisierung, mit der ein Freitextfeld sicher bereinigt werden könnte, daher nicht angetastet.
**Erledigt (2026-07-15):** Analog zu `FeedbackCriterionItem.description` (2026-07-07) jetzt auch `FeedbackCategory.description` — Erläuterung, welche Themen/Beobachtungen in eine Kategorie einzahlen sollen, editierbar in `FeedbackStructureConfig.tsx` direkt unter dem Kategorienamen. Grund: Die KI kannte bisher nur die Kriterien-Ebene im Detail, nicht aber, wonach auf Kategorie-/Dimensionsebene grundsätzlich gefragt ist. Bewusst **keinen neuen Platzhalter** eingeführt, sondern die bestehende `{{KATEGORIEN_STRUKTUR}}` in `aiPrompt.ts` angereichert (`Name — Erläuterung` statt nur `Name`) — dadurch profitieren auch bereits bestehende, individuell angepasste Dimension-Prompts automatisch, ohne dass sie erneut synchronisiert werden müssen (der Platzhalter-Token selbst ändert sich nicht, nur was dahinter berechnet wird).
**Erledigt (2026-07-14, zweite Runde):** Zwei weitere Nachschärfungen der KI-Feedback-Generierung (siehe Eintrag oben). (1) Abschnittsmarker wie `[Background]`/`[Whiteboard - Storyboard]` (reine Navigationshilfe des Bewerters, an welcher Stelle der Präsentation/des Dialogs eine Beobachtung entstand) werden jetzt aus `{{NOTIZEN_ALLGEMEIN_MEETING}}`/`{{PROTOKOLL_UNZUGEORDNET}}` gefiltert, wenn ihnen keine Inhalts-Zeile folgt (`dropDanglingMarkers()` in `aiPrompt.ts`, Lookahead auf die jeweils nächste Zeile) — bleiben aber erhalten, wenn direkt danach noch etwas steht, da sie dann als Kontext-Header dienen. (2) `AI_CONFIG.defaultDimensionPromptTemplate` (der Basis-Prompt für neue Dimensionen und „Auf Standard zurücksetzen") ist nicht mehr rein im Code gepflegt, sondern zusätzlich in `appSettings` (Singleton-Tabelle, neue Spalte `defaultDimensionPromptTemplate`) persistiert und über einen neuen, aufklappbaren Bereich ganz oben im „KI-Prompts"-Tab (`AiPromptConfig.tsx`) direkt in der App editierbar — analog zum bestehenden Zeitgewichtungs-Faktor-Muster (`getOrSeedDefaultDimensionPromptTemplate()`/`saveDefaultDimensionPromptTemplate()` in `server/db/queries/appSettings.ts`, exakt wie `getOrSeedRatingTrendWeightStep()`). Der Code-Konstante dient jetzt nur noch als einmaliger Erstbefüllungswert für neue Installationen; `feedbackStructure.ts` (Auto-Anlage neuer Dimensionen, Backfill, Lazy-Create) liest den Default jetzt ausschließlich aus `appSettings`. Wie bei jeder Custom-Prompt-Änderung gilt weiterhin: bereits bestehende, individuell abweichende Dimension-Prompts ändern sich dadurch nicht automatisch mit.
**Erledigt (2026-07-13):** Papierkorb + Archiv für Assignments umgesetzt. Dashboard zeigt standardmäßig nur noch aktive Assignments (`statusFilter`-Default auf `'active'`), trashed/archivierte Assignments sind in keinem Status-Tab mehr sichtbar. `Assignment` hat zwei neue optionale Felder `deletedAt`/`archivedAt` (analog zu `MeetingInstance.deletedAt`). Löschen (`softDeleteAssignment`) ist nur möglich, wenn `status === 'done'` UND für **alle** `instituteeIds` ein Feedback mit Status `'final'` vorliegt (`isAssignmentFeedbackComplete()` in `server/db/queries/assignments.ts`) — Gate wird sowohl serverseitig erzwungen (wirft sonst) als auch clientseitig auf `AssignmentDetail.tsx` zum Deaktivieren des Buttons genutzt. Löschung ist zweistufig: `softDeleteAssignment` setzt nur `deletedAt` (Papierkorb, neue Seite `/assignments/trash`), `restoreAssignment` löscht es wieder (zurück nach „Abgeschlossen"). Nach 30 Tagen wird der Eintrag automatisch endgültig entfernt — kein Cron/Timer, sondern ein Lazy-Sweep (`purgeExpiredAssignmentTrash()`), der am Anfang von `listAssignmentsWithDetails()` mitläuft (Server läuft nicht dauerhaft, ein echter Scheduler wäre unzuverlässig). `hardDeleteAssignment()` kaskadiert manuell durch den kompletten Assignment-Baum (`meetingInstances` inkl. deren Kindern über das bestehende `hardDeleteMeetingInstance()`, `feedbackDrafts`, `assignmentFeedbacks` inkl. `feedbackCategoryRatings`/`feedbackDimensionTexts`/`feedbackCategoryTexts`) — Muster 1:1 von der bestehenden Meeting-Papierkorb-Kaskade in `server/db/queries/meetings.ts` übernommen. Zusätzlich unabhängiges Archiv (`archiveAssignment`/`unarchiveAssignment`, neue Seite `/assignments/archive`) für dauerhafte Aufbewahrung (spätere Auswertung/ML) — bewusst ohne Feedback-Zwang und ohne Lösch-Option in der UI, aber umkehrbar (Rückholen möglich). Neue Spalten `deletedAt`/`archivedAt` auf `assignments` werden automatisch per bestehendem `ALTER TABLE`-Migrationsmechanismus in `database.ts` nachgezogen, kein manuelles Migrationsskript nötig.
**Erledigt (2026-07-07):** KI-Feedback-Generierung neu konzipiert und umgesetzt — löst den zuvor diagnostizierten `response_format`/Markdown-Codefence-Bug strukturell statt punktuell. Statt einem Monolith-Call für alle Dimensionen macht `generateAllDimensions()` (`AssignmentFeedbackPage.tsx`) jetzt **sequenziell einen Call pro Dimension**, mit Fortschrittsanzeige und Teil-Fehlerbehandlung (fehlgeschlagene Dimensionen einzeln erneut versuchbar, `dimensionErrors`). Jede Dimension hat einen **vollständig frei editierbaren Prompt** (`FeedbackDimensionPrompt`, neuer Admin-Tab „KI-Prompts" → `AiPromptConfig.tsx`), der bei `addFeedbackDimension()` automatisch mit einem Default-Template vorbelegt wird (`AI_CONFIG.defaultDimensionPromptTemplate`) — neue Dimensionen funktionieren dadurch ohne Zutun. Antwortformat ist jetzt einfacher Text statt JSON (`KATEGORIE:`/`ACHIEVEMENTS:`/`DEVELOPMENT_NEEDS:`-Marker, geparst in `src/utils/aiPrompt.ts::parseDimensionAiResponse`), da pro Call ohnehin nur eine Dimension behandelt wird — macht den JSON/Codefence-Bug strukturell unmöglich statt ihn nur zu patchen. KI-generierte Texte landen zunächst als **Vorschlag** (`suggestedAchievements`/`suggestedDevelopmentNeeds` auf `FeedbackDimensionText`, `suggestedText` auf neuer Tabelle `FeedbackCategoryText`) und werden erst durch expliziten „Übernehmen"-Klick in die echten Felder kopiert (gleiches Muster wie die bestehende Rating-Vorschlags-Badge) — persistiert, übersteht also einen Seitenwechsel. Neu: kurzer KI-Text zusätzlich auf Kategorie-Ebene (`FeedbackCategoryText`), nicht nur auf Dimension-Ebene. Cross-Boundary-Falle dabei gefunden: `src/config/constants.ts` importierte bisher `FeedbackRating` aus dem Barrel `'../db'` (re-exportiert Dexie/rpcClient/Seeds) statt direkt aus `'../db/types'` — beim Import von `AI_CONFIG` in `server/db/queries/feedbackStructure.ts` hätte das transitiv Browser-only-Code (`fetch`, `localStorage`) in den Server-TS-Scope gezogen; auf direkten Typ-Import umgestellt und `src/config/constants.ts` zusätzlich explizit in `tsconfig.server.json`s `include` aufgenommen (gleiches Muster wie das dort schon gelistete `src/db/types.ts`). Nachträglich (gleicher Tag, nach erstem Testlauf) zwei Lücken gefixt/ergänzt: (1) `database.ts` migrierte nur neue Tabellen, nie Spalten einer bereits bestehenden Tabelle — `suggestedAchievements` u.ä. auf `feedbackDimensionTexts` fehlten dadurch auf bereits laufenden Installationen und scheiterten erst zur Laufzeit; jetzt gleicht `database.ts` zusätzlich per `PRAGMA table_info` ab und zieht fehlende Spalten per `ALTER TABLE ... ADD COLUMN` nach (siehe Datenbank-Abschnitt oben). (2) Prompt-Vorschau ergänzt: pro Dimension ein „👁 KI-Prompt ansehen"-Toggle auf `AssignmentFeedbackPage.tsx`, der den vollständig gerenderten Prompt (Template + aufgelöste Platzhalter) ohne API-Call anzeigt — Feedback war, dass die generierten Texte „brauchbar, aber nicht konkret genug" waren und ohne Einblick in den tatsächlich gesendeten Prompt nicht nachvollziehbar. Zusätzlich neues optionales Feld `FeedbackCriterionItem.description` (editierbar in `FeedbackStructureConfig.tsx`, direkt unter dem Kriteriumsnamen), das automatisch in `{{KRITERIEN_SCORES}}` einfließt (`src/utils/aiPrompt.ts`) — macht die KI-Vorgabe pro Kriterium einmal zentral pflegbar statt in jedem Dimension-Prompt wiederholt werden zu müssen. Bewusst **nicht** reaktiviert: die bereits vorhandene, aber seit Langem unverdrahtete Legacy-Tabelle `CriterionLevelDescription` (Text pro Kriterium UND Rating-Stufe) — wäre eine präzisere Rubrik gewesen, aber deutlich mehr Pflege-/UI-Aufwand; bei Bedarf spätere, bewusste Entscheidung.
**Erledigt (2026-07-04, zweite Runde):** Lokaler Node/Express-Server mit `node:sqlite` (statt IndexedDB) eingeführt, RPC-Bridge (`POST /api/rpc`) zwischen `src/db/queries/*.ts` (Client) und neuem `server/db/queries/*.ts` (Server) — siehe eigener Abschnitt "Datenbank: lokaler Server statt IndexedDB" oben für Details, Abweichungen (node:sqlite statt better-sqlite3) und die wichtige `npx tsc -b`-statt-`--noEmit`-Korrektur. Ziel: mehrere Browser auf demselben Laptop teilen sich dieselben Daten, unabhängig vom Browser-Cache, als Zwischenschritt vor einem Docker/Linux-Deployment.
**Erledigt (2026-07-04):** `db/queries/`-Layer eingeführt — alle 20 Komponenten/Utils mit vorherigem direktem `db.<table>.*`-Zugriff nutzen jetzt benannte Funktionen aus `src/db/queries/*.ts` (siehe eigener Abschnitt oben), reine Extraktion ohne beabsichtigte Verhaltensänderung. Dabei bewusst mitkonsolidiert: die drei unabhängig implementierten Varianten der gewichteten-Durchschnitt-Logik (`ratingTrend.ts`, `AssessmentTab.tsx`, `AssignmentDetail.tsx`) laufen jetzt über eine einzige Funktion `weightedAverageScore()`/`weightedAverageRating()` in `ratingTrend.ts` — dabei einen fehlenden `Σweight===0`-Guard in `AssignmentDetail.tsx` gefixt (identische Bug-Klasse wie die zweite Bug-Klasse oben, dort war er noch nicht behoben). Ebenfalls konsolidiert: die "AssignmentType.criteriaIds schränkt sichtbare Kriterien ein"-Filterlogik (vorher dupliziert in `MeetingView.tsx`/`AssignmentDetail.tsx`, jetzt `filterVisibleFeedbackStructure()`) und die doppelten Datenabfragen in `meetingExport.ts`s Markdown-/JSON-Export (jetzt `getMeetingExportData()` in `meetings.ts`). `dbBackup.ts` und Seed-Code bleiben bewusst bei direktem `db`-Zugriff (siehe Abschnitt oben). `npx tsc --noEmit` läuft fehlerfrei.
**Erledigt (2026-07-02):** Konfiguration zentralisiert (`config/constants.ts`), `db/index.ts` in types/schema/seeds aufgeteilt, `MeetingView.tsx` in 3 Dateien aufgeteilt (`pages/meeting/`), `Evaluation.tsx` auf `feedbackCriterionItems` migriert + nach Assignment gruppiert + Papierkorb/Status-Filter + N/A-Bug gefixt, `zustand` entfernt (war ungenutzt). Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung umgesetzt (`src/utils/ratingTrend.ts`, neue Tabelle `appSettings`, neuer Config-Tab „Gewichtung“, Vorschlags-Badge + Übernehmen/Trend-in-Text auf `AssignmentFeedbackPage`, `buildAiPrompt` um Trend-Abschnitt erweitert und Legacy-`db.criteria`-Bug dabei gefixt). Kriterium-Zuordnung für Freitext-Notizen umgesetzt: `ConversationEntry.lineTags` (Zeilentext → Kriterium, nach einem verworfenen Zwischenstand mit "ein Entry pro Zeile" — siehe Sonstige Fallstricke), `CriterionTagPicker.tsx` (Chip+Popover mit Suche, unverändert seit erster Version) wird pro Zeile direkt an der Zeile via `NotationText`s neuer optionaler `renderLineAddon`-Prop gerendert; Zuordnung passiert bewusst nur nachträglich (nicht live während des Tippens), um den Schreibfluss nicht zu stören. `buildAiPrompt` führt Assessment-Notizen und getaggte Zeilen jetzt pro Kriterium zusammen, unzugeordnete Zeilen bleiben im allgemeinen Protokoll-Block. **(2026-07-03)** Vier Folge-Fixes: `meetingExport.ts` löste Kriterien über die Legacy-Tabelle `db.criteria` auf (identischer Bug wie der frühere `buildAiPrompt`-Fix) — dadurch war die Gesamtbewertung im Export faktisch leer; jetzt auf `feedbackCriterionItems` umgestellt, Markdown-Export zeigt zusätzlich das Kriterium pro getaggter Protokollzeile. `dbBackup.ts`s `TABLE_NAMES` enthielt `appSettings` nicht — Voll-Backups verloren die Gewichtungs-Config stillschweigend; jetzt ergänzt samt Kommentar, dass neue Tabellen dort manuell nachgezogen werden müssen. Notation → Bewertungs-Vorschlag: `detectNotationRating()` in `notationParser.tsx` (`!`→Not, `(!)`→Partially, `(+)`→Nearly, `+`→Fully) kombiniert mit `lineTags` liefert in `AssessmentTab.tsx` einen "Vorschlag aus Protokoll"-Badge pro Kriterium-Item, unabhängig vom bestehenden "Aus Protokoll"-Button (andere Datenquelle). `AssignmentCreate.tsx`: Assignment-Nummer ist jetzt Pflichtfeld (nur auf Formular-Ebene, Typ bleibt optional wegen Altdaten). Kriterium-Gewichtung auf `0,02,0` in `0,1`-Schritten verfeinert (`FeedbackStructureConfig.tsx`, vorher `×1``×5`), dabei einen latenten Division-durch-Null-Bug gefixt, der durch das neu erlaubte Gewicht `0` real wurde (`catMode()` in `AssessmentTab.tsx`, `buildGroupMeetingScores()` in `ratingTrend.ts` — siehe zweite Bug-Klasse oben). Sprecher eines bereits erfassten Protokoll-Beitrags kann nachträglich geändert werden (`ConversationTab.tsx`, im Bearbeiten-Modus neben Füllwörtern — ändert sofort `ConversationEntry.instituteeId`, kein separater Speichern-Schritt nötig). N/A-Aggregationsbug in `buildAiPrompt` (`AssignmentFeedbackPage.tsx`) gefixt — war die letzte offene Stelle dieser Bug-Klasse außer der jetzt migrierten `FeedbackDraftPage.tsx`. `FeedbackDraftPage.tsx` vollständig auf `feedbackCriterionItems`/`feedbackCategories` migriert (letzter Konsument der Legacy-Tabellen ist jetzt nur noch der Seed-Code), Notizen jetzt korrekt pro Kriterium über `lineTags` statt aller Protokoll-Notizen ungefiltert an jedes Kriterium gehängt, Score-Anzeige auf `RATING_OPTIONS`-Labels umgestellt (statt `/5`-Rohwert). Toter Code (verwaiste AssignmentType/PhaseTemplate-CRUD-Reste) aus `FeedbackStructureConfig.tsx` entfernt. „Dashboard zeigt keine Assignment-Nummer" war bereits gelöst, nur die Doku war veraltet — richtiggestellt.
---
## Datenschutz
- Alle Daten lokal (IndexedDB) — **kein Public Cloud Upload**
- Alle Daten lokal — seit 2026-07-04 in `server/data/assignment-monitor.sqlite3` (lokaler Server auf demselben Rechner) statt im Browser-IndexedDB; weiterhin **kein Cloud-Upload**, Server ist nur unter `localhost` erreichbar (kein Auth-Schutz nötig, solange das so bleibt — siehe Offene Punkte, falls sich das ändert, z.B. bei Docker/Linux-Deployment mit Netzwerkzugriff)
- `server/data/` ist per `.gitignore` ausgeschlossen — Datenbankdatei nie committen
- GDPR-relevant: Institutee-Namen, Kundennamen, Gesprächsnotizen
- KI-API-Key in `localStorage` — nie loggen oder ausgeben
- Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln

45
docs/DEV_ISOLATION.md Normal file
View File

@ -0,0 +1,45 @@
# Entwicklungs-Isolation — AssignmentMonitorV2
Dieses Projekt ist die **Neuentwicklung**. Das Produktivsystem darf nicht verändert werden.
| | Produktiv | DEV (dieses Repo) |
|---|---|---|
| Ordner | `c:\dev\AssigmentMonitor` | `c:\dev\AssigmentMonitorV2` |
| Frontend | http://localhost:5173 | http://localhost:5174 |
| API | http://localhost:4000 | http://localhost:4001 |
| SQLite | `server/data/assignment-monitor.sqlite3` | `server/data/assignment-monitor-v2-dev.sqlite3` |
| PWA-Name | Assignment Monitor | Assignment Monitor DEV |
## Start
```bash
# einmalig: .env aus Vorlage (bereits vorhanden, falls nicht:)
copy .env.example .env
npm run dev:all
```
Öffnen: **http://localhost:5174** (nicht 5173).
## Frische Datenbank
```bash
npm run db:reset
npm run dev:all
```
`db:reset` löscht nur die V2-Dev-Datei. Der Produktiv-Ordner wird per Pfad-Guard abgelehnt.
## Sicherheitsmechanismen
1. **Eigene Ports** (5174 / 4001) — Defaults und `.env`; Start bricht ab, wenn Port 5173 bzw. 4000 gewählt wird.
2. **Eigene DB-Datei** — anderer Dateiname; `SQLITE_FILENAME` darf kein Pfad sein.
3. **Pfad-Guard** in `server/database.ts` — DB nur innerhalb von `AssigmentMonitorV2`; Zugriff auf `AssigmentMonitor\server\data` wird blockiert.
4. **`server/data/` ist gitignored** — keine DB im Repo.
5. **strictPort** — Vite weicht nicht still auf den Produktiv-Port aus.
## Regeln
- Niemals Skripte aus V2 gegen den Produktiv-Ordner ausführen.
- Niemals die Produktiv-SQLite nach `AssigmentMonitorV2/server/data/` kopieren, außer bewusst als read-only Analyse (dann anderen Dateinamen verwenden und nicht als `SQLITE_FILENAME` setzen).
- Backup-Import in V2 nur mit **anonymisierten / Testdaten**, nicht mit dem Produktiv-Backup, solange ihr an der Skala umbaut — sonst vermischt ihr personenbezogene Produktivdaten mit Experimenten.

View File

@ -0,0 +1,51 @@
# Design-Brief: Zeitgewichteter Bewertungs-Vorschlag (Auswertung & Abschluss-Feedback)
Status: **Umgesetzt (2026-07-02).** Design-Fragen mit Lars geklärt und implementiert — siehe `CLAUDE.md` (Abschnitt "zwei parallele Bewertungs-Strukturen", Erledigt-Liste) für den aktuellen Stand. Dieses Dokument bleibt als Kontext/Historie der Design-Entscheidungen erhalten.
---
## Ausgangslage
Am 2026-07-02 wurde bei der Migration von `Evaluation.tsx` auf die neue Datenstruktur (`feedbackCriterionItems`) sichtbar, dass die App **zwei komplett unabhängige, nicht synchronisierte Bewertungs-Strukturen** hat:
| Struktur | Tabelle | Wo gesetzt | Zweck |
|---|---|---|---|
| Meeting-Assessment | `assessments` | `MeetingView` (`pages/meeting/AssessmentTab.tsx`), pro Meeting, pro Kriterium | Live-Bewertung während/nach jedem Kundentermin |
| Abschluss-Feedback | `feedbackCategoryRatings` | `AssignmentFeedbackPage.tsx`, manuell pro Kategorie | Finales, für den Institutee bestimmtes Feedback am Assignment-Ende |
Aktuell berechnet `/evaluation` (`Evaluation.tsx`) einen gleichgewichteten Durchschnitt aller `assessments` je Kriterium/Kategorie, gruppiert nach Assignment → Institutee. Das Abschluss-Feedback auf `AssignmentFeedbackPage` ist davon komplett entkoppelt — die Kategorie-Ratings dort werden per Klick frei gesetzt, nicht aus den Meeting-Daten abgeleitet. Der einzige bestehende Brückenschlag ist `buildAiPrompt()` in `AssignmentFeedbackPage.tsx`, der Assessment-Rohdaten in einen KI-Prompt packt (dieser Prompt hat aktuell noch einen offenen Bug: nutzt `db.criteria`, Legacy-Tabelle, sollte `feedbackCriterionItems` nutzen — siehe `docs/STATUS_UND_OFFENE_PUNKTE.md`, Punkt M2).
## Fachlicher Wunsch (Lars, 2026-07-02, wörtlich sinngemäß)
> Die Werte sollen aus den einzelnen Meetings berechnet werden — als **Vorschlag**. Dieser Vorschlag kann durch den Feedbackgeber überarbeitet werden. Eine parallele Ansicht ist daher möglicherweise sinnvoll. Bei der Berechnung der Vorschläge auf Kriteriumslevel über alle Meetings sollte auch die **zeitliche Reihenfolge** berücksichtigt werden. Es sollte honoriert werden, wenn sich der Berater über die Laufzeit des Assignments in bestimmten Kriterien **verbessert** hat. **Verschlechterung** über die Zeit sollte ebenfalls berücksichtigt werden (negative Lernkurve). Diese Entwicklung sollte dann auch **im Kommentar an der richtigen Stelle** berücksichtigt werden.
Kernpunkte:
1. **Zeitgewichtung** — spätere Meetings sollen stärker in den Kriterium-Durchschnitt einfließen als frühere (nicht der aktuelle gleichgewichtete Ansatz).
2. **Trend-Erkennung** — sowohl positive als auch negative Entwicklung über die Assignment-Laufzeit soll erkannt werden.
3. **Automatische Kommentar-Integration** — die erkannte Entwicklung soll an der "richtigen Stelle" im Feedback-Text auftauchen (vermutlich `FeedbackDimensionText.achievements`/`developmentNeeds`, evtl. über den bestehenden KI-Prompt-Mechanismus).
4. **Vorschlag bleibt überschreibbar** — die zwei Strukturen (`Assessment` vs. `FeedbackCategoryRating`) sollen bewusst getrennt bleiben; der berechnete Vorschlag ersetzt nicht die manuelle Entscheidung des Feedbackgebers.
## Bereits vorhandene Bausteine (wiederverwendbar)
- `RATING_NUM_MAP` in `src/config/constants.ts` — numerische Skala für Aggregation
- `weightedAvg()` in `src/pages/Evaluation.tsx` — aktueller (ungewichteter nach Zeit) Aggregationsansatz auf Item-/Kategorie-Ebene, als Ausgangspunkt für die zeitgewichtete Variante
- `MeetingInstance.date` — Zeitstempel pro Meeting, Basis für Zeitgewichtung
- `buildAiPrompt()` in `AssignmentFeedbackPage.tsx` — bestehender Mechanismus, der Assessment-Daten in strukturierten Text für die KI übersetzt; möglicher Ort, um Trend-Informationen einzuspeisen
- Bekannter Bug-Pattern, der bei jeder neuen Aggregationslogik zu beachten ist: `score !== null` schließt `'na'` nicht aus — immer `score !== null && score !== 'na'` prüfen (siehe `CLAUDE.md`)
## Offene Design-Fragen (in der Planungssession zu klären)
1. **Gewichtungsformel**: Linear nach Meeting-Reihenfolge? Exponentiell abfallend nach Alter? Feste Gewichte (z.B. letztes Meeting zählt doppelt)? Muss robust für Assignments mit 2 bis 7+ Meetings funktionieren.
2. **Trend-Schwellenwert**: Ab wann gilt eine Entwicklung als "signifikant" (z.B. mind. 1 Rating-Stufe Unterschied zwischen erstem und letztem gewerteten Meeting)? Wie viele Meetings mit Bewertung sind mindestens nötig, um überhaupt einen Trend zu behaupten?
3. **Granularität**: Wird der Trend auf Kriterium-Ebene, Kategorie-Ebene oder beidem berechnet?
4. **UI-Darstellung des Vorschlags**: Nebeneinander-Ansicht (Vorschlag vs. aktueller manueller Wert) mit "Übernehmen"-Button? Oder Vorschlag pre-fillt die Rating-Buttons direkt, überschreibbar wie jeder andere Wert?
5. **Kommentar-Generierung**: Deterministisch (Template-Satz wie "Zeigt über die Laufzeit eine Verbesserung in X") oder über den bestehenden KI-Prompt-Mechanismus? Wie wird das mit dem offenen AI-Prompt-Bug (M2) koordiniert?
6. **Code-Ort**: Soll die Trend-Berechnung eine gemeinsame Funktion sein, die sowohl `Evaluation.tsx` als auch `AssignmentFeedbackPage.tsx` nutzen (Vermeidung von Doppel-Logik)? Falls ja, wohin (`src/db/queries/`? neues `src/utils/ratingTrend.ts`?).
7. **Naming/Labeling**: Wie wird in der UI klar unterschieden zwischen "berechneter Vorschlag" und "final vom Feedbackgeber bestätigter Wert"?
## Betroffene Dateien (voraussichtlich)
- `src/pages/Evaluation.tsx` — zeigt den Vorschlag an
- `src/pages/AssignmentFeedbackPage.tsx` — übernimmt/überschreibt den Vorschlag, generiert Kommentar
- `src/config/constants.ts` — evtl. neue Konstanten für Gewichtungsparameter
- Neue Datei denkbar: `src/utils/ratingTrend.ts` oder `src/db/queries/ratingSuggestion.ts` für die gemeinsame Berechnungslogik

View File

@ -31,8 +31,9 @@ MeetingInstance ← Ein konkreter Termin (z.B. "Briefing C
└── ConversationEntry[] ← Zeitleiste: wer spricht wann
└── Assessment[] ← Gesamtbewertung pro Institutee am Ende
ConversationEntry ← Ein Gesprächsbeitrag eines Institutees
ConversationEntry ← Ein zusammenhängender Sprecher-Beitrag (mehrzeilig) eines Institutees
└── ConversationSkillScore[] ← Kategorie-Schnellbewertung (während des Beitrags)
└── lineTags[] ← Kriterium-Zuordnung einzelner Zeilen (nachträglich, per Zeilentext)
FeedbackDimension ← Übergeordnete Dimension für das Assignment-Feedback
└── FeedbackCategory[] ← Kategorie (z.B. "Kommunikationsfähigkeit")
@ -79,7 +80,7 @@ Wiederholbare Phasen (z.B. Alignment Calls) werden mit fortlaufendem `phaseIndex
### Aggregationslogik
**Gewichteter Durchschnitt** über alle Kriterien mit `weight`-Faktor (×1×5):
**Gewichteter Durchschnitt** über alle Kriterien mit `weight`-Faktor (konfigurierbar `0,0``2,0` in `0,1`-Schritten — `0` blendet ein Kriterium komplett aus, z.B. für sprachliche Weichmacher/Füllwörter mit reduziertem Einfluss):
```
avg = Σ(score_i × weight_i) / Σ(weight_i)
@ -99,6 +100,26 @@ Mapping auf Rating:
2. **Kriterium-Level (Gesamtbewertung am Ende des Meetings):** `Assessment.criteriaId``FeedbackCriterionItem.id`
Detaillierte Bewertung je Kriterium nach dem Meeting. Grundlage für die Kategorie-Farbkodierung im Gesamtbewertungs-Tab.
Verbindendes drittes Element: **Kriterium-Zuordnung einzelner Protokollzeilen** (`ConversationEntry.lineTags`). Der Lead schreibt während des Gesprächs einen zusammenhängenden, mehrzeiligen Beitrag pro Sprecher-Block; nachträglich (nicht während des Tippens, um den Schreibfluss nicht zu stören) kann jede einzelne Zeile über ein kleines Tag-Symbol direkt einem Kriterium zugeordnet werden. Diese Zuordnung ist die Grundlage für den Notation-Vorschlag (siehe unten) und fließt auch strukturiert in den KI-Prompt ein.
---
## Bewertungs-Vorschläge
Die App leitet an zwei Stellen automatisch einen Bewertungs-Vorschlag ab — beide bleiben reine Vorschläge, die der Lead frei übernehmen oder überschreiben kann.
### 1. Notation-Vorschlag in der Gesamtbewertung (pro Meeting)
Die Schnellnotations-Symbole (siehe unten) entsprechen einer Bewertungsstufe: `!` → Not, `(!)` → Partially, `(+)` → Nearly, `+` → Fully. Ist eine notierte Protokollzeile einem Kriterium zugeordnet (siehe oben), errechnet die App daraus einen Vorschlag für die Gesamtbewertung dieses Kriteriums ("Vorschlag aus Protokoll" mit Übernehmen-Button). Bei mehreren getaggten Zeilen zum selben Kriterium wird gemittelt. Jede Kategorie-Kopfzeile zeigt zusätzlich — auch zugeklappt — wie viele ihrer Kriterien einen offenen Vorschlag haben.
Füllwörter oder sprachliche Weichmacher ("ein bisschen") werden nicht automatisch erkannt, sondern über ein eigenes, niedrig gewichtetes Kriterium abgebildet, das ganz normal getaggt und notiert wird.
### 2. Zeitgewichteter Vorschlag über die Assignment-Laufzeit (`/evaluation`, Abschluss-Feedback)
Über mehrere Meetings hinweg gewichtet die App spätere Bewertungen stärker als frühere (Gewichtungsfaktor konfigurierbar unter Config → Gewichtung) und erkennt eine Verbesserung oder Verschlechterung eines Institutees über die Zeit. Der Vorschlag erscheint als Badge auf der Abschluss-Feedback-Seite, mit „Übernehmen" für den Bewertungswert und „Trend in Text übernehmen" für einen automatisch formulierten Hinweis auf die Entwicklung in Achievements/Development Needs.
Beide Vorschlags-Mechanismen greifen nicht in die manuell gepflegten Endergebnisse ein (`Assessment.score`, `FeedbackCategoryRating`) — sie liefern nur einen Ausgangspunkt.
---
## Feedback-Struktur (Abschluss-Feedback)
@ -142,8 +163,8 @@ Im Gesprächsprotokoll unterstützt die App eine Schnellnotations-Syntax, die fa
**Format:** `{AssignmentNummer}_{Datum}_{PhasenLabel}{-#Nr}.{md|json}`
**Beispiel:** `CGI-2024-001_02-07-2026_Alignment Call-2.md`
- **Markdown:** Lesbare Dokumentation mit Protokoll, Bewertungen und Notizen
- **JSON:** Vollständige Datensicherung eines einzelnen Meetings
- **Markdown:** Lesbare Dokumentation mit Protokoll, Bewertungen und Notizen — getaggte Protokollzeilen zeigen zusätzlich das zugeordnete Kriterium
- **JSON:** Vollständige Datensicherung eines einzelnen Meetings, inklusive Zeilen-Tags
### Datenbank-Backup
Vollständiger Export aller Tabellen als JSON-Datei. Import löscht alle Daten und stellt den gesicherten Stand wieder her. Backup sollte nach jedem Meeting erstellt werden.

View File

@ -0,0 +1,280 @@
# Plan: Feedback-Neukonzeption, KI-Kaskadierung & UX
Stand: 2026-08-06 · nur DEV (`AssigmentMonitorV2`)
Voraussetzung: Bewertungsskala 110 weitgehend umgesetzt (`docs/SCALE_1_TO_10.md`).
Dieses Dokument ist die **Arbeitsliste**. Pakete nacheinander abarbeiten; innerhalb eines Pakets Unterpunkte möglichst in der angegebenen Reihenfolge. Abhaken in diesem File oder in der Session-Todo-Liste.
---
## Zielbild (festgezogen)
### Assignment-Feedback (Gesamtauswertung pro Person)
| Ebene | Inhalt |
|---|---|
| **Kategorie** | Score 110 · Benefits · Concerns (Gesprächsbasis) |
| **Dimension** | Benefits · Concerns — **3 bis max. 5** Hauptpunkte (verdichtet) |
- PDF enthält **beides** (Kategorie + Dimension) — intern/Manager-Gespräch, spätere Anbindung Erfassungssystem.
- Keine Pflicht-Rubrik „Reifegrad pro Scorelevel 110“; Score-Vorschlag primär aus **zeitgewichteten Meeting-Kategorie-Scores**, KI darf ±1 mit Begründung feinjustieren.
- Achievements/Development Needs → fachlich **Benefits/Concerns** (UI-Sprache umstellen).
### KI-Kaskade Feedback
```text
Stufe A — 1 Call pro Kategorie (schmaler Kontext)
→ suggested Score, Benefits, Concerns (Kategorie)
Stufe B — 1 Call pro Dimension
Input: NUR Ergebnisse aus A (kein Rohprotokoll)
→ 35 Benefits + 35 Concerns (Dimension)
```
### KI-Kaskade Tagging („Vorschläge analysieren“)
```text
Nur ungetaggte Zeilen (bereits zugeordnete nie an KI)
→ Batches nach Zeilenanzahl UND Zeichenbudget
→ optional Hybrid: bei Monster-Batches Kriterien auf lokale Top-Kandidaten beschränken
→ Dimensions-Schnitt kein Standardweg
→ lokale Jaccard-Vorschläge + learnedPatternHints bleiben
```
---
## Entscheidungen (Nachtrag 2026-08-06)
1. **Score:** KI darf vorschlagen. Primärquelle = Meeting-Trend (wie bisherige Gewichtung). Keine 10-stufige Erwartungs-Rubrik als Voraussetzung; optional später globale Anker (z.B. 2/5/8/10), nicht jetzt.
2. **PDF:** Kategorie-Benefits/Concerns mit ausgeben.
3. **Tagging-Batches:** A (zeilen- + zeichenbasiert). B (Dimension) verworfen als Standard; Hybrid nur bei extrem großen Einträgen (Top-K Kriterien). C: getaggte Zeilen weiter ausschließen.
---
## Paket 0 — Aufräumen Skala 110 (Rest aus SCALE Phase 24)
Bereits weitgehend erledigt; nur nachziehen, was noch hinkt.
| # | Aufgabe | Done |
|---|---|---|
| 0.1 | Farbbänder speichern/laden (`getOrSeed`/`saveRatingColorBands` Server) — Hell-/Dunkelgrün unterscheidbar | ☐ prüfen |
| 0.2 | Chips Detail/History/PDF/MD aus `meetingCategoryRatings` (+ Fallback) | ☐ prüfen |
| 0.3 | Dimension-Prompts per „Auf Standard (110) setzen“ nachziehen / manuell testen | ☐ |
| 0.4 | Kurz-Smoke: Speichern Skala → Meeting-Einschätzung → Farben greifen nach Reload | ☐ |
**Exit:** Skala und Farben verhalten sich in Config, Meeting und Feedback konsistent.
---
## Paket 1 — Datenmodell Feedback (Benefits/Concerns)
| # | Aufgabe | Dateien (Orientierung) | Done |
|---|---|---|---|
| 1.1 | `FeedbackCategoryText`: `benefits`, `concerns`, `suggestedBenefits`, `suggestedConcerns` (+ Migration ALTER). Altes `text`/`suggestedText` entfernt (Dev: ggf. `npm run db:reset` oder Spalten bleiben ungenutzt in alter SQLite) | `types.ts`, `tableRegistry`, UI/PDF | ☑ |
| 1.2 | Dimension: DB `achievements`/`developmentNeeds` behalten; UI/PDF-Labels **Benefits/Concerns** | | ☑ |
| 1.3 | CRUD unverändert (generisch); Parser + Feedback-Seite + PDF angepasst | | ☑ |
| 1.4 | `npx tsc -b` grün | | ☑ |
**Exit:** Persistenz für Kategorie- und Dimension-Benefits/Concerns inkl. `suggested*`.
---
## Paket 2 — Prompt-Engine Stufe A (pro Kategorie)
| # | Aufgabe | Done |
|---|---|---|
| 2.1 | Neues Template (AppSettings oder pro Dimension): Kategorie-Call — Platzhalter nur für **eine** Kategorie (Struktur, Kriterien-Scores/Notizen dieser Cat, Meeting-Cat-Scores/Trend, getaggte Zeilen dieser Cat) | ☑ |
| 2.2 | Antwortformat Marker z.B. `SCORE:`, `BENEFITS:`, `CONCERNS:` — Parser analog `parseDimensionAiResponse` | ☑ |
| 2.3 | Score-Vorschlag: berechneten Trend **im Prompt vorgeben**; KI nur bestätigen oder ±1 mit Begründung (nicht frei aus Protokoll erfinden) | ☑ |
| 2.4 | Sequenz: alle Kategorien eines Institutee (Fortschritt, Teilfehler, Retry pro Kategorie) | ☑ |
| 2.5 | Config-UI: Kategorie-Prompt editierbar (+ Reset), Platzhalter-Legende | ☑ |
**Exit:** „KI generieren“ füllt `suggested*` auf Kategorie-Ebene; Score-Badge aus Trend bleibt führend.
---
## Paket 3 — Prompt-Engine Stufe B (Dimension verdichten)
| # | Aufgabe | Done |
|---|---|---|
| 3.1 | Dimension-Template umbauen: Input = Kategorie-Benefits/Concerns + Scores der Dimension; **kein** Rohprotokoll | ☑ |
| 3.2 | Output: genau 35 Benefits, 35 Concerns; Parser/Validierung (zu viele kürzen-Hinweis oder Prompt-Zwang) | ☑ |
| 3.3 | Orchestrierung: erst alle Cats der Dim (A), dann ein Call B; fehlende Cats = Retry A vor B | ☑ |
| 3.4 | Bestehende Dimension-Prompts in DB: Reset-Button / Sync auf neues Default-Template | ☑ |
**Exit:** Dimension-Texte sind Verdichtung, nicht Parallel-Erfindung aus dem Protokoll.
---
## Paket 4 — Feedback-UI
| # | Aufgabe | Done |
|---|---|---|
| 4.1 | Pro Kategorie: ScorePicker + Trend/KI-Score-Vorschlag („Übernehmen“) + Benefits/Concerns (editierbar + suggested-Boxen) | ☐ |
| 4.2 | Pro Dimension: nur Benefits/Concerns (35), suggested übernehmen/verwerfen | ☐ |
| 4.3 | Alten Ein-Text-pro-Kategorie und Achievements/DevNeeds-Labels entfernen bzw. umbenennen | ☐ |
| 4.4 | „Trend in Text“ → sinnvoll auf Concerns oder Benefits mappen (Regel festlegen, z.B. Verschlechterung→Concern) | ☑ |
| 4.5 | Fortschrittsanzeige Generierung: Kategorie X/Y, dann Dimension Z | ☑ |
**Exit:** Direktgespräch möglich: Kategorie-Details + Dimensions-Hauptpunkte auf einer Seite.
---
## Paket 5 — PDF & sonstige Exporte
| # | Aufgabe | Done |
|---|---|---|
| 5.1 | `InstituteeFeedbackDocument` / Assignment-Report: pro Kategorie Score + Benefits + Concerns; pro Dimension Benefits + Concerns | ☐ |
| 5.2 | Nur finale Felder, nie `suggested*` | ☐ |
| 5.3 | Markdown/JSON Assignment-Feedback falls vorhanden nachziehen | ☐ |
**Exit:** Internes PDF für Manager-Gespräch brauchbar.
---
## Paket 6 — Tagging-Kaskade (Zeichen-Batches)
| # | Aufgabe | Done |
|---|---|---|
| 6.1 | Hilfsfunktion: offene Zeilen sammeln (unverändert: ohne bereits Getaggte, ohne Kundenzitate wie bisher) | ☐ |
| 6.2 | Batch-Builder: max. Zeilen **und** max. Zeichensumme (Konstanten in `constants` oder AI_CONFIG, konfigurierbar später) | ☐ |
| 6.3 | `analyzeSuggestions`: Schleife über Batches; Ergebnisse mergen; Fortschritt „Batch i/n“; Teilfehler retrybar | ☐ |
| 6.4 | Hybrid-Notnagel: wenn ein Batch trotz 1 Zeile zu groß (Struktur+Text), `{{KRITERIEN_STRUKTUR}}` auf Jaccard-Top-K der Batch-Zeilen beschränken | ☐ |
| 6.5 | `MUSTER:`-Block: nach allen Batches einmal zusammenführen/schreiben oder nur aus Batches mit Treffern — Regel dokumentieren | ☐ |
| 6.6 | Info-Zeile + Rohantwort weiter nutzbar (pro Batch oder letzte) | ☐ |
**Exit:** Lange Protokolle brechen nicht mehr am Einzelcall; getaggte Zeilen bleiben draußen.
---
## Paket 7 — Robustheit KI-Client (gemeinsam)
Betrifft Feedback- und Tagging-Calls.
| # | Aufgabe | Done |
|---|---|---|
| 7.1 | Timeouts / Abbruch klar melden (nicht still) | ☐ |
| 7.2 | Retry mit Backoff für transient failures (einmalig) | ☐ |
| 7.3 | Grobe Prompt-Größen-Schätzung vor Call → Warnung oder Auto-Split | ☐ |
| 7.4 | Gleiche `aiClient`-Hilfen für Feedback und Tagging | ☐ |
**Exit:** Abbrüche sind erklärbar und oft automatisch reparierbar.
---
## Paket UX — Professionelle Oberfläche (Capgemini-Tool, kein Marketing-Landing)
Bisher wirkt die App funktional, aber wie ein schneller Prototyp (graue Fläche, Emoji-Navigation, uneinheitliche Karten/Abstände). Ziel: **ruhig, markenklar, gut lesbar im Direktgespräch und im Live-Meeting** — nicht „bunte Dashboard-Show“.
### Leitplanken
- Capgemini-Brand `#0070AD` / Tailwind `brand` als Primärsignal; keine lila/Indie-Themes.
- Bestehende Interaktionsmuster behalten (Tabs, ScorePicker, Tag-Picker) — visuell vereinheitlichen, nicht neu erfinden.
- Schreibfluss im Meeting schützen (keine zusätzlichen Overlays beim Tippen).
- Emojis in Nav/Buttons schrittweise durch klare Labels oder einfache Icons ersetzen (oder stark reduzieren).
- Typografie und Abstände einmal zentral (`index.css` / kleine UI-Primitives), nicht Seite für Seite wildwuchs.
| # | Aufgabe | Done |
|---|---|---|
| UX.1 | **Design-Tokens:** Schrift, Abstände, Radius, Schatten, Flächen (`bg-app`, `surface`, `border-subtle`) in `index.css` / `@theme` — App-Shell (`App.tsx`) darauf umstellen | ☐ |
| UX.2 | **App-Chrome:** Header (Titel/Kontext), Bottom-Nav ohne Emoji-Overload, aktiver Zustand klar, sichere Touch-Targets | ☐ |
| UX.3 | **Dashboard / Assignment-Listen:** einheitliche Zeilen/Kacheln, Status, Typografie-Hierarchie (Nummer, Kunde, Institutees) | ☐ |
| UX.4 | **Meeting-Live-UX:** Einschätzung + Protokoll dichter und ruhiger; ScorePicker/Chips konsistent; Fokus auf Tippen + schnelle Scores | ☐ |
| UX.5 | **Feedback-Seite:** nach Paket 4 — klare Hierarchie Dimension → Kategorie; Benefits/Concerns optisch getrennt; KI-Vorschläge als „Entwurf“, nicht als Lärm | ☐ |
| UX.6 | **Config:** Tabs und Formulare angleichen (Skala, KI-Prompts, Struktur) — gleiche Button-/Hinweis-Muster | ☐ |
| UX.7 | **Leere Zustände & Fehler:** kurze, professionelle Leer-/Fehlertexte statt stiller Lücken | ☐ |
| UX.8 | **PDF-Feinschliff:** Typo/Abstände an Capgemini-Report-Anmutung (ohne Corporate-Template-Zwang) | ☐ |
**Timing:** UX.1UX.2 früh (nach Paket 0) als Fundament. UX.4 mit Meeting-Arbeit; **UX.5 bewusst nach Paket 4** (sonst doppelte UI-Arbeit). UX.3/6/7 parallel zu funktionalen Paketen möglich. UX.8 nach Paket 5.
**Exit:** App wirkt wie ein internes Capgemini-Fachtool: einheitlich, ruhig, brandklar — ohne Feature-Regression.
---
## Paket 8 — SCALE / Struktur Prio-2 und bekannte Offenheiten
Nicht blockierend für Pakete 17 / UX; einplanen wenn Kapazität.
| # | Aufgabe | Quelle | Done |
|---|---|---|---|
| 8.1 | Sichtbarkeit Dim/Cat pro Typ — **vorgezogen nach Paket 1b** | SCALE Phase 6 | → 1b |
| 8.2 | Notation-Schnellbuttons / mobile Hit-Areas (kann in UX.4 aufgehen) | SCALE Phase 5 | ☐ |
| 8.3 | Dexie/IndexedDB-Legacy entfernen | CLAUDE Offene Punkte L | ☐ |
| 8.4 | Server Seed-if-empty Assignment-Typen/Struktur | CLAUDE L | ☐ |
| 8.5 | Evaluation.tsx / FeedbackDraftPage auf neue Benefits/Concerns + Bänder prüfen oder Legacy deprecaten | | ☐ |
| 8.6 | Datenschutz: Hinweis Tagging+Feedback (externe API) vor Prod | CLAUDE | ☐ |
---
## Empfohlene Abarbeitungsreihenfolge
```text
0 (Smoke Skala)
→ 1 (Schema Benefits/Concerns) ← erledigt
→ 1b (Typ → sichtbare Struktur) ← VOR Feedback-KI: gleiche Teilmenge in Meeting + Feedback + Kaskade
→ 6 + 7 parallel möglich ← Tagging-Abbrüche lindern
→ 2 → 3 → 4 → 5 ← Feedback-Neukonzeption Ende-zu-Ende
→ 8 Rest nach Bedarf
→ UX später
```
**Hinweis:** Paket UX ist spezifiziert, aber **zurückgestellt** — erst Features fertig, dann Optik.
**Warum 1b vor 2:** Stufe A (ein Call pro Kategorie) und Feedback-UI müssen nur Kategorien/Dimensionen des Assignment-Typs sehen. Ohne kanonische Sichtbarkeit generiert die KI später Inhalte für ausgeblendete Strukturteile.
---
## Paket 1b — Feedback-Struktur je Assignment-Typ (vorziehen aus 8.1)
Ziel: Ein Assignment-Typ blendet Dimensionen und/oder Kategorien aus → Meeting-Einschätzung, Tagging-Struktur und Assignment-Feedback arbeiten auf **derselben** Teilmenge.
| # | Aufgabe | Done |
|---|---|---|
| 1b.1 | Kanonischer Helper z.B. `resolveVisibleStructure(allDims, allCats, allItems, criteriaIds)``{ dimensions, categories, items }` (Cats ohne Items und Dims ohne Cats entfallen) — baut auf `filterVisibleFeedbackStructure` auf | ☑ |
| 1b.2 | Überall verdrahten: MeetingView, Assessment, Feedback-Seite, Tagging-Prompt, PDF-Feedback-Teil, History/Detail wo nötig | ☑ |
| 1b.3 | Config Ass.-Typen: Auswahl auf **Dimension- / Kategorie-Ebene** (Checkboxen), persistiert weiter als `criteriaIds` (alle Items der gewählten Cats) — kein zweites Parallel-Feld nötig | ☑ |
| 1b.4 | Leere `criteriaIds` = alles sichtbar (unverändert); Smoke mit Typ der z.B. nur 12 Dimensionen hat | ☐ manuell |
**Nicht nötig jetzt:** separates `dimensionIds[]`/`categoryIds[]` in der DB, solange die UI Dim/Cat pflegt und auf `criteriaIds` mappt.
**Exit:** Feedback und Einschätzung zeigen für denselben Assignment-Typ dieselbe Struktur; KI-Kaskade kann darauf aufsetzen.
---
## Empfohlene Abarbeitungsreihenfolge (Detail)
```text
0 → 1 → 1b → (6+7 optional) → 2 → 3 → 4 → 5 → 8 → UX
```
**Alternative, wenn Feedback-Calls zuerst stören:** nach 1b sofort `2→3→4→5`, Tagging `6→7` dazwischen wenn Abbrüche blockieren.
---
## Nicht-Ziele (jetzt)
- Produktiv-Migration / Mapping alter Enum-Skala
- 10-stufige Score-Rubrik pro Kategorie
- Dimensions-Score als eigene Pflicht-Ebene
- Tagging-Batches standardmäßig nach Dimension schneiden
- Automatischer KI-Call beim Meeting-Öffnen
- Anbindung externes Erfassungssystem (PDF/Struktur vorbereiten reicht)
- Komplett-Redesign als Marketing-Landing / Dark Mode / Illustration-Heavy UI
---
## Abnahmekriterien (Gesamt)
1. Feedback-Seite zeigt Kategorie-Score + Benefits/Concerns und Dimensions-Hauptpunkte (35).
2. Generierung läuft kaskadiert (Cat → Dim), Teilfehler retrybar, Prompt ohne wiederholtes Vollprotokoll in Stufe B.
3. PDF enthält Kategorie- und Dimension-Benefits/Concerns (final).
4. „Vorschläge analysieren“ arbeitet in zeichenbewussten Batches; bereits getaggte Zeilen gehen nicht an die KI.
5. Score-Vorschlag bleibt an Meeting-Trend gekoppelt; KI erfindet keinen Score aus dem Nichts.
6. Oberfläche wirkt professionell und einheitlich (Shell, Typo, Abstände, Meeting + Feedback).
7. `npx tsc -b` fehlerfrei; manuelle Smoke-Tests Meeting + Feedback + PDF.
---
## Nächster Schritt
Beim Start der Umsetzung: **Paket 0 kurz verifizieren**, optional **UX.1UX.2** für den ersten visuellen Sprung, dann entscheiden ob zuerst **Paket 6 (Tagging)** oder **Paket 1+2 (Feedback-Schema/KI)**.

115
docs/GITEA.md Normal file
View File

@ -0,0 +1,115 @@
# Gitea — Repository-Setup (Assignment Monitor V2)
Dieses Projekt ist lokal ein Git-Repo auf Branch `main`. Für Gitea fehlt nur noch der Remote und der erste Push.
## Voraussetzungen
- Gitea-Zugang (Benutzerkonto auf dem Server)
- **HTTPS + Personal Access Token** (empfohhen unter Windows / Firmennetz) **oder** SSH-Key bei Gitea hinterlegt
- Keine Secrets im Repo: `.env` und `server/data/` sind in `.gitignore`
## 1. Repository in Gitea anlegen
1. In Gitea einloggen
2. **„+“ → New Repository** (oder Organisation wählen, dann „New Repository“)
3. Einstellungen:
- **Name:** `AssigmentMonitorV2` (oder gewünschter Name)
- **Private** (empfohlen — personenbezogene Trainingsdaten im Backup-Format)
- **Initialize repository:** alle Häkchen **aus** (kein README, keine .gitignore — existiert lokal schon)
4. Erstellen
Gitea zeigt danach die Push-URLs an, z. B.:
- HTTPS: `https://gitea.example.com/lars/AssigmentMonitorV2.git`
- SSH: `git@gitea.example.com:lars/AssigmentMonitorV2.git`
## 2. Remote hinzufügen (lokal)
HTTPS (empfohlen):
```powershell
cd C:\dev\AssigmentMonitorV2
git remote add origin https://gitea.example.com/<user>/AssigmentMonitorV2.git
git remote -v
```
SSH (Alternative):
```powershell
git remote add origin git@gitea.example.com:<user>/AssigmentMonitorV2.git
```
## 3. Authentifizierung
### HTTPS — Personal Access Token (empfohlen)
1. Gitea → **Settings → Applications → Generate New Token**
2. Scopes: mindestens **`write:repository`** (bzw. „repo“ je nach Gitea-Version)
3. Beim ersten `git push`:
- **Username:** Gitea-Benutzername
- **Password:** der Token (nicht das Login-Passwort)
Windows speichert Credentials oft über den Credential Manager (`credential.helper=manager`).
### SSH
1. Key erzeugen (falls noch keiner existiert):
```powershell
ssh-keygen -t ed25519 -C "lars.stommer@capgemini.com"
```
2. Öffentlichen Key (`~/.ssh/id_ed25519.pub`) in Gitea unter **Settings → SSH Keys** eintragen
3. Verbindung testen:
```powershell
ssh -T git@gitea.example.com
```
## 4. Ersten Push ausführen
```powershell
cd C:\dev\AssigmentMonitorV2
git push -u origin main
```
Falls Gitea standardmäßig `master` erwartet und leer ist, reicht obiger Befehl. Bei Konflikt mit vorgefertigtem Initial-Commit auf dem Server:
```powershell
git pull origin main --rebase
git push -u origin main
```
## 5. Täglicher Workflow
```powershell
git status
git add -A
git commit -m "kurze Beschreibung des Warums"
git push
```
Feature-Branches (optional):
```powershell
git checkout -b feature/feedback-kaskade-stufe-a
# … arbeiten …
git push -u origin feature/feedback-kaskade-stufe-a
```
In Gitea dann Pull Request / Merge Request erstellen.
## Datenschutz
- **Nicht committen:** `.env`, `server/data/*.sqlite3`, OpenRouter-API-Keys, Produktiv-Backups mit echten Personendaten
- **Private Repo** auf Gitea verwenden
- Backup-JSONs aus der App nur verschlüsselt/teambasiert teilen, nicht ins Repo legen
## Troubleshooting
| Problem | Lösung |
|---|---|
| `403` / `Authentication failed` | Token statt Passwort; Token-Scopes prüfen |
| `remote origin already exists` | `git remote set-url origin <neue-url>` |
| Große Dateien abgelehnt | Keine DB/Backups committen; ggf. Gitea-LFS nur wenn wirklich nötig |
| SSL-Zertifikat (interne CA) | Firmen-Root-Zertifikat in Windows/Git hinterlegen oder IT fragen |

View File

@ -2,6 +2,24 @@
Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt nahtlos fortzuführen.
> **DEV-Isolation (V2):** Dieses Repo (`AssigmentMonitorV2`) läuft getrennt vom Produktivsystem (`c:\dev\AssigmentMonitor`). Ports **5174 / 4001**, eigene SQLite `assignment-monitor-v2-dev.sqlite3`. Details und Regeln: [`docs/DEV_ISOLATION.md`](./DEV_ISOLATION.md). Produktiv-Daten und -Code niemals anfassen.
---
## Stand & nächste Schritte
Zuletzt abgeschlossen (2026-07-13, dritte Runde): Kriterium-Zuordnung für „Allgemeine Notizen" (`MeetingInstance.generalNoteTags`) — wirkt anders als die bestehende `ConversationEntry.lineTags`-Zuordnung nicht auf ein einzelnes Institutee, sondern auf alle Institutees des Assignments gleichzeitig; alle fünf bestehenden Konsumenten von `lineTags` (Vorschlags-Badges, KI-Prompt, Markdown-Export, Legacy-Feedback-Entwurf, Meeting-Historie) wurden entsprechend erweitert. Details: `CLAUDE.md` → Erledigt-Historie (2026-07-13, dritte Runde).
Davor abgeschlossen (2026-07-13, zweite Runde): Chronologisches Gesamtprotokoll aller Meetings eines Assignments (`MeetingHistory.tsx`, `/assignment/:id/history`, erreichbar von `AssignmentDetail.tsx` und `MeetingView.tsx` aus) — für Recap-Calls nach Kundenmeetings und die Gesamtdurchsprache im Feedback-Call. Details: `CLAUDE.md` → Erledigt-Historie (2026-07-13, zweite Runde).
Davor abgeschlossen (2026-07-13): Papierkorb + Archiv für Assignments — Dashboard zeigt standardmäßig nur aktive Assignments, Löschen abgeschlossener Assignments erst möglich wenn für alle Institutees finales Feedback vorliegt, zweistufige Löschung (Papierkorb → automatische endgültige Löschung nach 30 Tagen, mit Wiederherstellen-Option) sowie ein unabhängiges, umkehrbares Archiv für dauerhafte Aufbewahrung. Details: `CLAUDE.md` → Erledigt-Historie (2026-07-13).
Davor abgeschlossen (2026-07-04): Umstellung von Dexie/IndexedDB auf einen lokalen Node/Express-Server mit `node:sqlite` (RPC-Bridge `POST /api/rpc`), damit mehrere Browser auf demselben Laptop dieselben Daten sehen. Details: `CLAUDE.md` → Abschnitt "Datenbank: lokaler Server statt IndexedDB". **Sofort nächster Schritt: die echten IndexedDB-Daten müssen noch per Backup-Export/-Import in die neue SQLite-DB migriert werden** — siehe CLAUDE.md-Offene-Punkte-Tabelle, Prio M.
Davor abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ratingTrend.ts`), Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags` + `CriterionTagPicker.tsx`), Notation → Bewertungs-Vorschlag in der Gesamtbewertung, feinere Kriterium-Gewichtung (0,02,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`.
**Vollständige, priorisierte Liste der offenen Punkte:** siehe `CLAUDE.md` → Abschnitt "Offene Punkte (priorisiert)" — das ist die laufend gepflegte Quelle, hier nicht dupliziert. Größere offene Themen, die eine eigene Design-Session brauchen: mehrstufiges Prompt-System für die KI-Generierung, selbstlernendes Tagging.
---
## Worum geht es?
@ -10,13 +28,15 @@ Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt naht
## Tech-Stack
- **React 18 + TypeScript + Vite** (PWA)
- **React 18 + TypeScript + Vite** (PWA-Frontend)
- **Tailwind CSS v4** (`@import "tailwindcss"` — kein `tailwind.config.js`!)
- **Dexie.js v5** als IndexedDB-Wrapper (lokale DB im Browser, kein Backend)
- **Lokaler Node/Express-Server + `node:sqlite`** (seit 2026-07-04) — löst Dexie/IndexedDB als Datenspeicher ab, siehe CLAUDE.md. `npm run dev:all` startet Frontend + Server zusammen.
- **React Router v6**
- **Capgemini-Brandfarbe:** `#0070AD`
- Arbeitsverzeichnis: `C:\dev\AssigmentMonitor`
**Wichtig für den TypeScript-Check:** `npx tsc --noEmit` prüft am Root **nichts** (Project-References-Setup, `files: []`) — immer `npx tsc -b` verwenden.
## Bewertungsskala (kritisch für alle Berechnungen)
```typescript
@ -34,8 +54,9 @@ Aggregation: **gewichteter Durchschnitt** (avg < 1.5 → Not, < 2.5 → Partiall
### Schlüssel-Unterschied: zwei `criteriaId`-Semantiken
```
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting)
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung während Gespräch)
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting)
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung während Gespräch)
ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Kriterium-Zuordnung einer Protokoll-Zeile, bewusst anders benannt als "criteriaId")
```
Das klingt inkonsistent — ist aber so gewollt und funktioniert so.
@ -70,12 +91,12 @@ const filteredCats = itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!))
/assignment/new Neues Assignment
/assignment/:id AssignmentDetail
/assignment/:id/edit AssignmentEdit
/assignment/:id/meeting/:meetingId MeetingView ← WICHTIG: meetingId != assignmentId
/assignment/:id/meeting/:meetingId MeetingView (src/pages/meeting/) ← WICHTIG: meetingId != assignmentId
/assignment/:id/feedback/:instituteeId AssignmentFeedbackPage
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
/evaluation Auswertung (veraltet!)
/evaluation Auswertung — zeitgewichteter Bewertungs-Vorschlag + Trend, nach Assignment gruppiert
/consultants Berater
/config Konfiguration (3 Tabs)
/config Konfiguration (4 Tabs: Feedback | Ass.-Typen | Gewichtung | Backup)
```
MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useParams()`
@ -83,7 +104,7 @@ MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useP
## Konfigurationsbereich
**Tab "Feedback"** (`FeedbackStructureConfig.tsx`):
- Dimensionen → Kategorien → Kriterium-Items (mit Gewichtung ×1×5)
- Dimensionen → Kategorien → Kriterium-Items (mit Gewichtung `0,02,0` in `0,1`-Schritten, seit 2026-07-03 — vorher grobe `×1``×5`-Stufen)
- Nur Feedback-Struktur, kein AssignmentType-CRUD mehr!
**Tab "Ass.-Typen"** (`AssignmentTypeConfig.tsx`):
@ -91,24 +112,30 @@ MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useP
- Pro Typ: Sub-Tabs "Phasen" und "Kriterien"
- Kriterien-Tab: Baumstruktur Dimension → Kategorie → Items (Checkbox, Indeterminate)
**Tab "Gewichtung"** (`RatingWeightConfig.tsx`, seit 2026-07-02):
- Ein Regler für den Zeitgewichtungs-Faktor des Bewertungs-Vorschlags (`AppSettings.ratingTrendWeightStep`, Tabelle `appSettings`)
**Tab "Backup"** (`Configuration.tsx`):
- JSON-Export aller Tabellen via `exportFullBackup()`
- JSON-Import via `importFullBackup(file)` (löscht alles, dann bulkAdd)
## Bekannte offene Punkte (Priorität nach Dringlichkeit)
### Sofort-Fixes (einfach)
1. **Dashboard zeigt keine Assignment-Nummer**`Dashboard.tsx` Zeile ~57: `{a.assignmentNumber && <span>...{a.assignmentNumber}</span>}` hinzufügen
2. **AssignmentCreate hat kein Nummer-Feld** — Inputfeld für `assignmentNumber` hinzufügen
3. **Toter Code in FeedbackStructureConfig** — State-Variablen `assignmentTypes`, `phaseTemplates`, `showCatalogs`, `showTypeConfig`, `expandedType`, `newTypeName`, `newPhase`, `typeError` + alle Handler `toggleCatalogCategory`, `addAssignmentType`, `updateAssignmentTypeName`, `deleteAssignmentType`, `addPhaseTemplate`, `updatePhaseTemplate`, `deletePhaseTemplate` entfernen
### Mittlere Aufgaben
4. **Evaluation.tsx neu schreiben** — nutzt `db.categories` + `db.criteria` (Legacy). Soll `feedbackCriterionItems` + `assessments` nutzen und Rating-Labels zeigen
5. **meetingExport.ts anpassen** — Score-Aufschlüsselung nutzt `db.criteria`, sollte `feedbackCriterionItems` nutzen; Kategorie-Namen im Markdown fehlen
6. **AI-Prompt in AssignmentFeedbackPage** — nutzt `db.criteria` (alt), sollte `feedbackCriterionItems` + neue Assessment-Scores nutzen
1. **KI-Generierung funktionsunfähig**`generateWithAi()` schlägt fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`. Root Cause nicht untersucht.
### Große Aufgaben
7. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus.
### Große Aufgaben (eigene Design-Session nötig)
2. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus.
3. **Mehrstufiges, pro Dimension konfigurierbares Prompt-System** — löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab, braucht Spezialwissen je Dimension + Platzhaltersystem.
4. **Selbstlernendes Tagging** — aus bisherigen Zeile-zu-Kriterium-Zuordnungen automatische Tag-Vorschläge beim Tippen ableiten.
**Erledigt (2026-07-04):** `db/queries/`-Layer eingeführt — DB-Zugriffe aus allen 20 betroffenen Komponenten/Utils in domänenspezifische Query-Funktionen unter `src/db/queries/` extrahiert (Details siehe CLAUDE.md). Dabei die drei unabhängigen Implementierungen der gewichteten-Durchschnitt-Logik auf eine gemeinsame Funktion in `ratingTrend.ts` konsolidiert (Nebeneffekt: fehlender `Σweight===0`-Guard in `AssignmentDetail.tsx` gefixt) sowie die duplizierte `criteriaIds`-Filterlogik und die doppelten Meeting-Export-Queries zusammengeführt.
**Erledigt (2026-07-03, zweite Runde):** N/A-Aggregationsbug in `buildAiPrompt` gefixt. `FeedbackDraftPage.tsx` vollständig auf `feedbackCriterionItems`/`feedbackCategories` migriert (Notizen jetzt korrekt pro Kriterium über `lineTags`, Score-Anzeige über `RATING_OPTIONS`). Toter Code in `FeedbackStructureConfig.tsx` entfernt. "Dashboard zeigt keine Assignment-Nummer" war bereits gelöst — nur die Doku war veraltet.
**Erledigt (2026-07-02):** Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung — `src/utils/ratingTrend.ts` berechnet pro Kategorie einen zeitgewichteten Vorschlag aus den Meeting-Assessments (Gewichtungsfaktor konfigurierbar unter Config → Gewichtung, neue Tabelle `appSettings`), erkennt Verbesserung/Verschlechterung über die Laufzeit und zeigt beides als Badge auf `AssignmentFeedbackPage` (mit „Übernehmen" + „Trend in Text übernehmen"). `buildAiPrompt` wurde um einen Trend-Abschnitt erweitert; dabei wurde auch der alte `db.criteria`-Bug mitgefixt.
**Erledigt (2026-07-03):** Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags`, `CriterionTagPicker.tsx`, nachträglich pro Zeile innerhalb eines zusammenhängenden Sprecher-Blocks). Notation → Bewertungs-Vorschlag in der Gesamtbewertung (`detectNotationRating()`, „Vorschlag aus Protokoll"-Badge + Kategorie-weite 💡-Zähler). `meetingExport.ts` und `dbBackup.ts` gefixt (Legacy-`db.criteria`-Bug bzw. fehlende `appSettings`-Tabelle im Backup). Assignment-Nummer ist jetzt Pflichtfeld. Kriterium-Gewichtung auf `0,02,0` verfeinert, dabei einen Division-durch-Null-Bug bei Gewicht `0` in allen gewichteten Mittelwerten gefunden und gefixt.
## Stil-Regeln (bitte einhalten)
@ -133,7 +160,7 @@ exportFullBackup(): Promise<void> // Download als JSON
importFullBackup(file: File): Promise<{ ok: boolean; error?: string }> // Clears + bulkAdd
```
Alle 20 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen dort ergänzen.
Alle 21 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen dort ergänzen (ein fehlender Eintrag fällt nicht auf, bis jemand ein Backup zurückspielt und Daten fehlen — genau so ist `appSettings` einmal vergessen worden, siehe Kommentar über der Liste).
## Seed-Daten
@ -145,7 +172,7 @@ Alle 20 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen
## Datenschutz-Hinweise
- Alle Daten lokal, kein Cloud-Backend
- Alle Daten lokal — seit 2026-07-04 in `server/data/assignment-monitor.sqlite3` (lokaler Server, kein Cloud-Backend), `server/data/` ist gitignored
- GDPR-relevant: Institutee-Namen + Kundennamen in der DB
- KI-API: OpenRouter, API-Key in localStorage (nicht im Klartext loggen!)
- Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln
@ -153,11 +180,11 @@ Alle 20 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen
## TypeScript-Build
```bash
npx tsc --noEmit # Muss ohne Fehler durchlaufen
npm run dev # Dev-Server starten
npx tsc -b # Muss ohne Fehler durchlaufen — NICHT --noEmit, das prüft am Root nichts (siehe Tech-Stack-Abschnitt)
npm run dev:all # Frontend + lokaler Server zusammen starten
```
Vor jeder Abgabe: `npx tsc --noEmit` ausführen und alle Fehler beheben.
Vor jeder Abgabe: `npx tsc -b` ausführen und alle Fehler beheben.
## Wichtige Eigenheiten / Fallstricke
@ -166,4 +193,6 @@ Vor jeder Abgabe: `npx tsc --noEmit` ausführen und alle Fehler beheben.
3. **`PhaseKey`**: Ist `type PhaseKey = string` (kein Union-Typ mehr)
4. **`aType2`** in `AssignmentDetail.tsx`: Doppelte Variable aus Refactoring — `aType` wird zweimal deklariert, zweite heißt `aType2`. Technische Schuld.
5. **`expandedCat`** in `MeetingView`: `Record<instituteeId, categoryId | null>` — öffnet pro Institutee genau eine Kategorie gleichzeitig
6. **Dexie v5**: `bulkAdd` schlägt fehl wenn IDs schon vorhanden → Backup-Import leert erst alle Tabellen
6. **Backup-Import** (`server/db/queries/backup.ts`, seit 2026-07-04 SQL-basiert): leert alle Tabellen und fügt Zeilen mit expliziter, ursprünglicher `id` wieder ein (`insertRowWithId`), damit Fremdschlüssel-Referenzen im importierten Datensatz stabil bleiben — kein Dexie/`bulkAdd` mehr, siehe CLAUDE.md-Datenbank-Abschnitt
7. **Gewichtete Mittelwerte**: immer `Σ(score×weight)/Σ(weight)`, nie `score×weight` roh interpretieren; bei `Σweight === 0` (seit Gewicht `0` erlaubt ist) `null`/`continue` statt Division — sonst `NaN < x` überall `false` → fällt auf „Fully" durch. Siehe `catMode()` (`AssessmentTab.tsx`) und `buildGroupMeetingScores()` (`ratingTrend.ts`).
8. **`ConversationEntry`** entspricht einem ganzen Sprecher-Turn (mehrzeiliger Text-Block), nicht einer einzelnen Zeile — ein Zwischenstand mit "ein Entry pro Zeile" wurde verworfen. Kriterium-Zuordnung läuft über `lineTags` (Matching per Zeilentext, nicht Index).

100
docs/SCALE_1_TO_10.md Normal file
View File

@ -0,0 +1,100 @@
# Konzept: Bewertungsskala 110 (AssignmentMonitorV2)
Stand: 2026-08-06 · nur DEV (`AssigmentMonitorV2`), Produktiv unberührt.
## Festgezogene Entscheidungen
| # | Entscheidung |
|---|---|
| Skala | **110**, ganzzahlig; `null` = nicht gesetzt (kein N/A-String mehr) |
| Primäre Ebene | **Kategorie** — Meeting-Bewertung und Assignment-Feedback setzen den Score hier |
| Kriterien | Primär **Beobachtungen** (Tags, Notizen, Gewichtung); nicht die Pflicht-Bewertungsebene |
| Warum Kategorie direkt? | Nicht alle Kriterien sind in jedem Meeting beobachtbar → Score darf **nicht** nur aus Kriterien-Ø abgeleitet werden müssen |
| Notation | Führende Zahl `1``10` am Zeilenanfang; **keine** `+` / `!` / `(+)` / `(!)` mehr; `>` Zitat und `[…]` Abschnitte bleiben |
| Farben | 4 Bänder, **konfigurierbar** unter Config (Defaults unten) |
| Labels | Nur Zahlen, keine Ankertexte |
| Altdaten | Keine Migration; frische Dev-DB (`npm run db:reset` bei Bedarf) |
| Struktur | Noch nicht angelegt — parallel in Config pflegbar, kein Blocker für Skalen-Umbau |
| Typ-Strukturen | Prio 2 (später) |
| Schnelle Zuordnung | `lineTags` / Tag-Picker / Vorschläge bleiben |
### Default-Farbbänder
Überlappung an der 5 in der Erstidee bereinigt:
| Score | Farbe |
|---|---|
| 12 | rot |
| 35 | orange |
| 68 | hellgrün |
| 910 | dunkelgrün |
Speicherung in `appSettings.ratingColorBands` (JSON), editierbar in einem neuen Config-Bereich „Skala / Farben“.
---
## Zielmodell (Daten)
```text
Beobachtung (Kriterium)
lineTags / generalNoteTags / Assessment.note
optionale Notations-Zahl auf der Zeile → Vorschlag / Detail
Meeting-Bewertung (Pflicht-Skala)
NEU bzw. klar: Score 110 pro Kategorie × Institutee × Meeting
direkt setzbar, unabhängig davon ob alle Kriterien getaggt sind
Assignment-Feedback
FeedbackCategoryRating.rating → 110
Vorschlag aus zeitgewichteten Meeting-Kategorie-Scores
Checkliste
ChecklistResponse.rating → 110 (gleiche Skala/Farben)
```
### Speicherung Meeting-Kategorie-Score
Neue Tabelle `meetingCategoryRatings`:
- `meetingInstanceId`, `instituteeId`, `categoryId`, `score` (INTEGER nullable 110), optional `note`
Bestehende `ConversationSkillScore` (Schnellscore am Protokoll-Eintrag) ebenfalls auf 110 umstellen — bleibt optionaler Feinschliff während des Tippens, ersetzt aber nicht die Meeting-Gesamtbewertung pro Kategorie.
`Assessment.score` (pro Kriterium): optional `number | null` für Detail/KI; UI der Gesamtbewertung fokussiert auf **Kategorien**. Kriterien darunter zeigen Beobachtungen + optionalen Detail-Score.
---
## Umsetzungsphasen
### Phase 1 — Skalen-Kern ✅ (2026-08-06)
- Types: `RatingScore` 110, `null` = unset; `meetingCategoryRatings`; `ratingColorBands`
- `constants.ts`: Bänder, `resolveScoreStyle`, `ScorePicker`
- Config-Tab „Skala“
- `ratingTrend` / Notation (führende Zahl) / Meeting-/Feedback-/Export-UI umgestellt
- Hard-Delete räumt `meetingCategoryRatings` + `checklistResponses` auf
### Phase 2 — Meeting-UI Feinschliff
- Notation-Schnellbuttons weiter verfeinern; Farbbänder live aus Config laden (nicht nur Defaults)
- Detail/History-Chips primär aus `meetingCategoryRatings`
### Phase 3 — Feedback & Auswertung
- Trend/Vorschlag konsequent aus Meeting-Kategorie-Scores
- **Neukonzeption Benefits/Concerns + KI-Kaskade:** siehe `docs/FEEDBACK_CASCADE_PLAN.md`
### Phase 4 — Export & KI
- Persistierte Dimension-Prompts auf neue Skalen-Sprache zurücksetzen/syncen
- Kategorie-Benefits/Concerns im PDF (Teil von `FEEDBACK_CASCADE_PLAN.md` Paket 5)
### Phase 5 — UX-Feinschliff
- Slider/Stepper, mobile Hit-Areas; Schreibfluss schützen
### Phase 6 (Prio 2) — Struktur je Assignment-Typ
- Sichtbarkeit Dimension/Kategorie pro Typ — **umgesetzt als Paket 1b** (`resolveVisibleStructure`, Config → Ass.-Typen → Struktur)
---
## Nicht-Ziele (jetzt)
- Produktiv-Migration / Mapping alter Enum-Werte
- Ankertexte auf der Skala
- Getrennte Taxonomien pro Assignment-Typ (erst Prio 2)

View File

@ -1,6 +1,6 @@
# Projektstatus und offene Punkte — Assignment Monitor
Stand: 2026-07-02
Stand: 2026-07-03
---
@ -27,13 +27,27 @@ Stand: 2026-07-02
| Strukturiertes Abschluss-Feedback pro Institutee | `AssignmentFeedbackPage.tsx` |
| KI-Feedback-Generierung via OpenRouter | `AssignmentFeedbackPage.tsx` |
| Feedback-Status: Entwurf → Final | `AssignmentFeedbackPage.tsx` |
| Feedback-Dimensionsstruktur CRUD mit Gewichtung ×1×5 | `FeedbackStructureConfig.tsx` |
| Feedback-Dimensionsstruktur CRUD mit Gewichtung (0,02,0 in 0,1-Schritten) | `FeedbackStructureConfig.tsx` |
| AssignmentTyp CRUD mit Phasen-Templates | `AssignmentTypeConfig.tsx` |
| Kriterien-Auswahl je Typ (Baumstruktur, Indeterminate) | `AssignmentTypeConfig.tsx` |
| Kriterien-Filterung je Typ in Meetings + Übersicht | `MeetingView.tsx`, `AssignmentDetail.tsx` |
| Vollständiges DB-Backup (JSON Export/Import) | `dbBackup.ts`, `Configuration.tsx` |
| Berater/Gruppen-Verwaltung | `Consultants.tsx` |
| PWA (installierbar, Offline-fähig) | Vite-PWA-Plugin |
| Konfiguration zentralisiert (Farben, Ratings, AI-Settings) | `src/config/constants.ts` |
| `db/index.ts` in types/schema/seeds aufgeteilt (Re-Export bleibt kompatibel) | `src/db/` |
| `MeetingView.tsx` in 3 Dateien aufgeteilt | `src/pages/meeting/` |
| Auswertung auf `feedbackCriterionItems` migriert, nach Assignment gruppiert, Papierkorb/Status gefiltert, N/A-Bug gefixt | `Evaluation.tsx` |
| Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung (konfigurierbarer Gewichtungsfaktor, Vorschlags-Badge auf Feedback-Seite, Trend-Satz-Insertion, KI-Prompt-Erweiterung) | `Evaluation.tsx`, `AssignmentFeedbackPage.tsx`, `src/utils/ratingTrend.ts`, `RatingWeightConfig.tsx` |
| Kriterium-Zuordnung für Freitext-Notizen — pro Zeile innerhalb eines zusammenhängenden Sprecher-Blocks (`ConversationEntry.lineTags`, Matching per Zeilentext), Chip+Popover mit Suche direkt an der Zeile, nachträglich im Protokoll; `buildAiPrompt` führt Assessment- und Konversations-Notizen pro Kriterium zusammen | `ConversationTab.tsx`, `CriterionTagPicker.tsx`, `notationParser.tsx`, `MeetingView.tsx`, `AssignmentFeedbackPage.tsx` |
| Notation → Bewertungs-Vorschlag in der Gesamtbewertung: `!`/`(!)`/`(+)`/`+` an getaggten Zeilen ergeben einen Rating-Vorschlag je Kriterium ("Vorschlag aus Protokoll" + Übernehmen), plus Kategorie-Kopfzeile zeigt Anzahl offener Vorschläge (💡 N) auch zugeklappt | `AssessmentTab.tsx`, `notationParser.tsx` (`detectNotationRating`) |
| Meeting-Export (Markdown) zeigt Kriterium-Namen korrekt (`db.criteria`-Legacy-Bug gefixt) + Kriterium pro getaggter Zeile; Voll-Backup deckt wieder alle Tabellen ab (`appSettings` ergänzt) | `meetingExport.ts`, `dbBackup.ts` |
| Assignment-Nummer ist Pflichtfeld beim Anlegen | `AssignmentCreate.tsx` |
| Kriterium-Gewichtung feiner (0,02,0 in 0,1-Schritten statt `×1``×5`), inkl. Division-durch-Null-Fix bei Gewicht 0 in allen gewichteten Mittelwerten | `FeedbackStructureConfig.tsx`, `AssessmentTab.tsx`, `ratingTrend.ts` |
| Sprecher eines Protokoll-Beitrags nachträglich änderbar (im Bearbeiten-Modus) | `ConversationTab.tsx`, `MeetingView.tsx` |
| N/A-Aggregationsbug in `buildAiPrompt` gefixt (letzte offene Stelle außer Legacy-Code) | `AssignmentFeedbackPage.tsx` |
| `FeedbackDraftPage.tsx` auf `feedbackCriterionItems`/`feedbackCategories` migriert, Notizen korrekt pro Kriterium über `lineTags` (nicht mehr alle Protokoll-Notizen an jedes Kriterium), Score-Anzeige auf `RATING_OPTIONS`-Labels | `FeedbackDraftPage.tsx` |
| Toter Code (verwaiste AssignmentType/PhaseTemplate-CRUD-Reste) entfernt | `FeedbackStructureConfig.tsx` |
---
@ -43,25 +57,22 @@ Stand: 2026-07-02
| # | Beschreibung | Datei | Aufwand |
|---|---|---|---|
| H1 | **IndexedDB-Robustheit**: Browser kann IndexedDB unter Speicherdruck löschen. Migration zu SQLite WASM + OPFS geplant. Bis dahin: regelmäßige Backups als einzige Sicherheit. | `db/index.ts` | L (Wochen) |
| H2 | **Backup-Reminder fehlt**: Es gibt keine automatische Erinnerung / keinen Hinweis nach abgeschlossenem Meeting, dass ein Backup erstellt werden sollte. | `MeetingView.tsx` | S |
| H1 | **IndexedDB-Robustheit**: Browser kann IndexedDB unter Speicherdruck löschen. Migration zu SQLite WASM + OPFS geplant. Bis dahin: regelmäßige Backups als einzige Sicherheit. | `db/schema.ts` | L (Wochen) |
| H2 | **Backup-Reminder fehlt**: Es gibt keine automatische Erinnerung / keinen Hinweis nach abgeschlossenem Meeting, dass ein Backup erstellt werden sollte. | `pages/meeting/MeetingView.tsx` | S |
| H6 | **KI-Generierung funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert den Call zu `openrouter.ai`. Root Cause noch nicht untersucht. | `AssignmentFeedbackPage.tsx` | ? |
| H7 | **Prompt-System-Neubau (noch nicht gescoped)**: Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Spezialwissen je Dimension + Platzhaltersystem, löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab. Braucht eigene Design-Session. | `AssignmentFeedbackPage.tsx` | L |
### Priorität Mittel
| # | Beschreibung | Datei | Aufwand |
|---|---|---|---|
| M1 | **Evaluation.tsx veraltet**: Nutzt die Legacy-`Category`/`Criterion`-Tabellen, nicht die neue `feedbackCriterionItems`-Struktur. Zeigt falsche / keine Daten aus neuen Meetings. | `Evaluation.tsx` | M |
| M2 | **AI-Prompt veraltet**: `AssignmentFeedbackPage` liest `db.criteria` (alt) für Score-Zeilen. Sollte auf `feedbackCriterionItems` + `assessments` umgestellt werden. | `AssignmentFeedbackPage.tsx` | M |
| M3 | **meetingExport.md veraltet**: Kriterien-Scores werden über `db.criteria` aufgelöst, nicht `feedbackCriterionItems`. Kategorie-Namen fehlen im Export. | `meetingExport.ts` | S |
| M5 | **`db/queries/`-Layer fehlt**: DB-Zugriffe direkt aus Komponenten (`db.*`), keine Abstraktionsschicht. Erschwert die geplante SQLite-Migration (H1). | `src/db/queries/` (neu) | M |
| M6 | **Selbstlernendes Tagging (Idee, noch nicht gescoped)**: Zeile-zu-Kriterium-Zuordnungen sammeln und daraus beim Tippen automatische Tag-Vorschläge ableiten. Braucht eigene Design-Session. | `CriterionTagPicker.tsx` | ? |
### Priorität Niedrig
| # | Beschreibung | Datei | Aufwand |
|---|---|---|---|
| N1 | **Dashboard**: Zeigt keine Assignment-Nummer. Nur Titel + Kunde + Typ. | `Dashboard.tsx` | XS |
| N2 | **AssignmentCreate**: Kein Feld für Assignment-Nummer. Muss nachträglich über Edit eingegeben werden. | `AssignmentCreate.tsx` | XS |
| N3 | **FeedbackStructureConfig**: Toter Code — Funktionen für AssignmentType-CRUD wurden verschoben, Deklarationen blieben (`assignmentTypes`, `phaseTemplates` State + alle zugehörigen Handler). | `FeedbackStructureConfig.tsx` | XS |
| N4 | **Evaluation-Route**: Zeigt Scores als Dezimalzahlen `/5` mit alter Skala statt neuer Rating-Labels. | `Evaluation.tsx` | S |
| N5 | **AssignmentCreate**: Erstellt keine `instituteeEnrollments`. Von/Bis-Daten können nur über Edit gepflegt werden. | `AssignmentCreate.tsx` | S |
| N6 | **Meeting-Route**: URL ist `/assignment/:id/meeting/:meetingId``:id` ist die Assignment-ID, aber die Assignment-Nummer (z.B. CGI-2024-001) ist nicht in der URL. Akzeptiert als nice-to-have. | `App.tsx` | M |
@ -75,11 +86,19 @@ Stand: 2026-07-02
**Vorteil:** Echte Datei auf Gerät, nicht löschbar wie IndexedDB. Kein Server nötig.
**Status:** Konzept beschlossen. Implementierung steht aus. Paralleles Backup-System ist die aktuelle Notlösung.
### Evaluation neu (M1 oben)
Auswertungsseite auf neue Datenstruktur migrieren. Soll zeigen:
- Pro Institutee: aggregierte Kriterien-Scores über alle Meetings
- Trend über mehrere Assignments
- Vergleich zwischen Institutees
### Evaluation — Zeitgewichteter Vorschlag (erledigt, 2026-07-02)
Vollständig umgesetzt, siehe `docs/EVALUATION_FEEDBACK_REDESIGN.md` für die Design-Entscheidungen:
- Kategorie-Scores werden über `src/utils/ratingTrend.ts` zeitgewichtet gemittelt (Rang = Reihenfolge der tatsächlich erfolgten Bewertungen dieser Kategorie, nicht fester Meeting-Index); Gewichtungsfaktor konfigurierbar unter Config → Gewichtung (`RatingWeightConfig.tsx`, Tabelle `appSettings`)
- Trend (Verbesserung/Verschlechterung) wird ab 2 bewerteten Meetings pro Kategorie erkannt (Delta ≥ 0,5 Punkte)
- Vorschlags-Badge auf `AssignmentFeedbackPage` dauerhaft sichtbar neben dem manuellen Rating, mit „Übernehmen“ und „Trend in Text übernehmen" (fügt deterministischen Satz in Achievements/Development Needs ein)
- `buildAiPrompt` um Trend-Abschnitt erweitert; dabei auch den Legacy-`db.criteria`-Bug (ehem. M2) gefixt
- `Assessment`- und `FeedbackCategoryRating`-Daten bleiben bewusst getrennte, unabhängige Strukturen — der Vorschlag ist rein additiv
### Kriterium-Tagging + Notation-Vorschlag (erledigt, 2026-07-03)
- `ConversationEntry.lineTags: { text, criterionItemId }[]` ordnet einzelne Zeilen innerhalb eines Sprecher-Blocks einem Kriterium zu (Matching per exaktem Zeilentext, nicht Index — überlebt nachträgliches Einfügen/Löschen von Zeilen). Ein früherer Zwischenstand mit "ein Entry pro Zeile" wurde verworfen, weil er die Protokoll-Lesbarkeit zerstört hat.
- `detectNotationRating()` (`notationParser.tsx`) mappt `!`/`(!)`/`(+)`/`+` auf eine Bewertungsstufe; kombiniert mit getaggten Zeilen liefert `AssessmentTab.tsx` einen Rating-Vorschlag pro Kriterium-Item ("Vorschlag aus Protokoll"), unabhängig von der bestehenden `ConversationSkillScore`-Kategorie-Schnellbewertung.
- Füllwörter/Weichmacher-Formulierungen werden bewusst nicht über eigene Text-Erkennung behandelt, sondern über ein niedrig gewichtetes Kriterium + normales Tagging abgedeckt.
- Kriterium-Gewichtung dabei auf `0,02,0` (0,1-Schritte) verfeinert; ein latenter Division-durch-Null-Bug bei Gewicht `0` in allen gewichteten Mittelwerten (`catMode`, `ratingTrend.ts`) wurde dabei gefunden und gefixt.
### Dashboard-Verbesserungen (N1 oben)
- Assignment-Nummer anzeigen
@ -95,4 +114,5 @@ Auswertungsseite auf neue Datenstruktur migrieren. Soll zeigen:
| `criteriaIds` auf `AssignmentType` | Referenziert `FeedbackCriterionItem.id`. Wenn Kriterien gelöscht werden, bleiben verwaiste IDs in `criteriaIds`. Filter ignoriert fehlende IDs stillschweigend. | Beim Löschen von Items: `criteriaIds` in allen AssignmentTypes bereinigen |
| `Assessment.criteriaId` | Referenziert `FeedbackCriterionItem.id`. Kein FK-Constraint in IndexedDB. | Beim Löschen von Items: zugehörige Assessments löschen |
| `ConversationSkillScore.criteriaId` | Referenziert `FeedbackCategory.id`. Gleiche Gefahr. | Beim Löschen von Kategorien: ConversationSkillScores bereinigen |
| `ConversationEntry.lineTags[].criterionItemId` | Referenziert `FeedbackCriterionItem.id`. Kein FK-Constraint — beim Löschen eines Kriterium-Items bleiben Zeilen-Tags verwaist (Anzeige zeigt dann kein Kriterium mehr, aber Datensatz bleibt). | Beim Löschen von Items: `lineTags` in betroffenen `conversationEntries` bereinigen |
| Typ-Wechsel bei Assignment | Zeigt Warnung, löscht aber keine alten Meeting-Bewertungen. Bewertungen mit IDs aus dem alten Typ-Kriteriensatz bleiben erhalten, werden aber möglicherweise nicht mehr angezeigt. | Dokumentiertes Verhalten, absichtlich so gestaltet |

View File

@ -17,29 +17,44 @@
---
## Projektstruktur
## Projektstruktur (aktualisiert nach Architektur-Refactoring, 2026-07-02)
```
src/
config/
constants.ts ← RATING_OPTIONS, INST_COLORS, AI_CONFIG, RATING_NUM_MAP, BRAND_COLOR, DEFAULT_RATING_TREND_WEIGHT_STEP, RATING_TREND_DELTA_THRESHOLD
db/
index.ts ← Dexie DB-Klasse, alle Interfaces, Seed-Funktionen, Migrations
index.ts ← reiner Re-Export von types/schema/seeds (bestehende Imports bleiben kompatibel)
types.ts ← alle Interfaces, FeedbackRating-Typ
schema.ts ← Dexie DB-Klasse + alle Migrationsversionen
seeds/
assignmentTypes.ts ← seedIfEmpty() — 3 AssignmentTypen + Legacy-Kriterien
feedbackStructure.ts ← seedFeedbackStructureIfEmpty() — Dimensionen/Kategorien/Items
migrations.ts ← seedCriterionMappingsIfEmpty(), migrateToUnifiedCriteriaIfNeeded()
index.ts ← Re-Export der Seed-Funktionen
pages/
Dashboard.tsx ← Assignment-Liste (Home)
AssignmentCreate.tsx ← Neues Assignment anlegen
AssignmentDetail.tsx ← Phasen-Flow, Meeting-Übersicht, Bewertungs-Chips
AssignmentEdit.tsx ← Assignment nachträglich bearbeiten
MeetingView.tsx ← Live-Meeting: Protokoll + Gesamtbewertung
AssignmentFeedbackPage.tsx ← Strukturiertes Abschluss-Feedback + KI-Generierung
FeedbackDraftPage.tsx ← Einfacher Feedback-Entwurf (Legacy)
Evaluation.tsx ← Auswertung (teilweise veraltet, nutzt alte Criterion-Tabelle)
meeting/
MeetingView.tsx ← Route-Komponente: State, DB-Handler, Header, Tabs
ConversationTab.tsx ← Gesprächsprotokoll-UI
AssessmentTab.tsx ← Gesamtbewertungs-UI (Accordion je Kategorie), Notation-Vorschlag
CriterionTagPicker.tsx ← Chip+Popover mit Suche, Kriterium-Zuordnung pro Protokoll-Zeile
AssignmentFeedbackPage.tsx ← Strukturiertes Abschluss-Feedback + KI-Generierung + Trend-Vorschlag
FeedbackDraftPage.tsx ← Einfacher Feedback-Entwurf (Legacy-UI, Datenzugriff auf feedbackCriterionItems migriert)
Evaluation.tsx ← Auswertung — zeitgewichteter Bewertungs-Vorschlag + Trend, gruppiert nach Assignment
Configuration.tsx ← Tab-Container für alle Konfigurationsseiten
FeedbackStructureConfig.tsx ← Dimensionen/Kategorien/Kriterien CRUD + Gewichtung
AssignmentTypeConfig.tsx ← AssignmentTypen + Phasen + Kriterien-Auswahl (Baum)
RatingWeightConfig.tsx ← Config-Tab "Gewichtung": Zeitgewichtungs-Faktor für Bewertungs-Vorschlag
Consultants.tsx ← Berater/Gruppen-Verwaltung
utils/
meetingExport.ts ← Markdown + JSON Export-Logik
dbBackup.ts ← Vollständiges DB-Backup (JSON Export/Import)
notationParser.tsx ← +/!/>/[…] Notation → farbiges JSX
notationParser.tsx ← +/!/>/[…] Notation → farbiges JSX, splitNoteLines(), detectNotationRating()
ratingTrend.ts ← Zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung
App.tsx ← Route-Definitionen, Navigation
main.tsx ← React-Einstiegspunkt, DB-Seed-Aufruf
```
@ -48,10 +63,10 @@ src/
## Datenmodell (IndexedDB via Dexie v5)
### DB-Version: 5
### DB-Version: 6
```typescript
// Konfiguration (Legacy, nur noch für Evaluation.tsx)
// Konfiguration (Legacy, kein aktiver Seiten-Konsument mehr)
categories: '++id, order'
criteria: '++id, categoryId, order'
@ -88,8 +103,13 @@ feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId'
// Mappings (Legacy, kaum genutzt)
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId'
criterionLevelDescriptions: '++id, criterionItemId, rating'
// App-Einstellungen (Singleton, id immer 1), seit Version 6
appSettings: '++id'
```
`ConversationEntry.lineTags` (Kriterium-Zuordnung pro Zeile) ist ein reines Objektfeld ohne eigenen Index — kein Versions-Bump nötig, da Dexies `.stores()` nur Indizes definiert, nicht das volle Objekt-Schema.
### Wichtige Interface-Details
```typescript
@ -135,12 +155,34 @@ interface FeedbackCriterionItem {
id?: number
categoryId: number
name: string
weight?: number // Gewichtung ×1×5, default 1
weight?: number // 0,02,0 in 0,1-Schritten, default 1 (seit 2026-07-03; vorher grobe ×1×5-Stufen)
order: number
}
// Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
// ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
interface ConversationLineTag {
text: string // exakter Zeilentext, 1:1 wie in ConversationEntry.note
criterionItemId: number // → FeedbackCriterionItem.id
}
interface ConversationEntry {
id?: number
meetingInstanceId: number
instituteeId: number
sequenceIndex: number
note: string // ganzer Sprecher-Turn, mehrzeilig
fillerCount: number
updatedAt: string
lineTags?: ConversationLineTag[] // Kriterium-Zuordnung pro Zeile, gematcht per Zeilentext
}
interface AppSettings {
id?: number // Singleton, immer 1
ratingTrendWeightStep: number // Zeitgewichtungs-Faktor für den Bewertungs-Vorschlag
}
// Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
// ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
// ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Zeilen-Zuordnung, bewusst anders benannt)
```
---
@ -157,7 +199,7 @@ interface FeedbackCriterionItem {
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
/evaluation Auswertungsübersicht
/consultants Berater-Verwaltung
/config Konfiguration (3 Tabs: Feedback | Ass.-Typen | Backup)
/config Konfiguration (4 Tabs: Feedback | Ass.-Typen | Gewichtung | Backup)
```
---
@ -168,7 +210,7 @@ interface FeedbackCriterionItem {
CRUD für:
- Feedback-Dimensionen (mit Reihenfolge ↑/↓)
- Feedback-Kategorien je Dimension
- Feedback-Kriterium-Items je Kategorie (mit Gewichtung ×1×5)
- Feedback-Kriterium-Items je Kategorie (mit Gewichtung `0,02,0` in `0,1`-Schritten)
### Tab "Ass.-Typen" → `AssignmentTypeConfig`
CRUD für:
@ -177,6 +219,9 @@ CRUD für:
- Kriterien-Auswahl je Typ: Baumstruktur Dimension → Kategorie → Item (mit Checkbox, Indeterminate-State)
- `criteriaIds = []` → alle Kriterien werden angezeigt
### Tab "Gewichtung" → `RatingWeightConfig`
- Ein Regler für `AppSettings.ratingTrendWeightStep` (Zeitgewichtungs-Faktor des Bewertungs-Vorschlags), mit Live-Vorschau der resultierenden Gewichte
### Tab "Backup" → in `Configuration.tsx`
- JSON-Export aller Tabellen
- Zweistufiger JSON-Import mit Überschreib-Warnung
@ -197,6 +242,7 @@ function catMode(cat: FeedbackCategory, fbItems: FeedbackCriterionItem[], getSco
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
if (scored.length === 0) return null
const wSum = scored.reduce((s, x) => s + x.w, 0)
if (wSum === 0) return null // alle beteiligten Kriterien auf Gewicht 0 → sonst NaN < x überall false, fällt auf "Fully" durch
const avg = scored.reduce((s, x) => s + NUM_MAP[x.score] * x.w, 0) / wSum
if (avg < 1.5) return 'not_client_ready'
if (avg < 2.5) return 'partially_client_ready'
@ -205,6 +251,8 @@ function catMode(cat: FeedbackCategory, fbItems: FeedbackCriterionItem[], getSco
}
```
Dieselbe `Σweight === 0`-Absicherung gilt für jede gewichtete Mittelung in der App (auch `buildGroupMeetingScores()` in `ratingTrend.ts`) — seit Gewicht `0` ein gültiger Wert ist, ist das kein theoretischer Randfall mehr.
---
## Export-Dateiname-Schema
@ -227,8 +275,9 @@ const buildFilename = (ext: string) => {
- **Provider:** OpenRouter (https://openrouter.ai) — ermöglicht Modellwechsel ohne eigenen API-Zugang
- **Modelle:** GPT-4o mini, GPT-4o, Claude Sonnet 4.5, Claude Haiku 4.5, Gemini Flash 1.5
- **API-Key:** Lokal in `localStorage` gespeichert (`ai_api_key`, `ai_model`)
- **Datenweitergabe:** Kriterien-Scores, Protokoll-Notizen, Kategorie-Bewertungen werden im Prompt strukturiert übermittelt
- **Hinweis:** Der AI-Prompt nutzt noch die alte `criteria`-Tabelle (Legacy) — Aktualisierung auf `feedbackCriterionItems` steht aus
- **Datenweitergabe:** Kriterien-Scores + Notizen (Assessment und getaggte Protokoll-Zeilen zusammengeführt pro Kriterium), Kategorie-Bewertungen, zeitgewichteter Trend pro Kategorie werden im Prompt strukturiert übermittelt (`buildAiPrompt`, nutzt korrekt `feedbackCriterionItems`, alter `db.criteria`-Bug ist gefixt)
- **Bekanntes Problem:** Der Call schlägt aktuell fehl (vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`), Root Cause nicht untersucht
- **Geplanter Umbau (nicht gescoped):** Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Platzhaltersystem soll den aktuellen Einzel-Prompt ablösen
---
@ -237,7 +286,7 @@ const buildFilename = (ext: string) => {
Die App legt beim ersten Start automatisch an:
1. **3 Assignment-Typen** mit je vollständigem Phasen-Flow (Generisch, Case Interview, Stakeholder Meeting / Pitch)
2. **Legacy-Kriterien** in `categories` + `criteria` (für Evaluation.tsx)
2. **Legacy-Kriterien** in `categories` + `criteria` (kein aktiver Seiten-Konsument mehr — `Evaluation.tsx` und `FeedbackDraftPage.tsx` sind beide auf `feedbackCriterionItems` migriert; die Tabellen bleiben aus historischen Gründen im Backup/Import)
3. **Feedback-Dimensionsstruktur** mit 5 Dimensionen, 14 Kategorien, ~70 Kriterium-Items (Capgemini-Kompetenzmodell)
4. **Criterion-Mappings** (Legacy, nicht mehr aktiv genutzt)
@ -247,10 +296,9 @@ Die App legt beim ersten Start automatisch an:
| Bereich | Problem | Priorität |
|---|---|---|
| `Evaluation.tsx` | Nutzt alte `Category`/`Criterion`-Tabellen statt neue `feedbackCriterionItems` | Mittel |
| `meetingExport.ts` | Bewertungs-Labels im Markdown-Export zeigen `FeedbackRating`-Keys statt Labels | Niedrig |
| `FeedbackStructureConfig.tsx` | Enthält toten Code (Funktionen für gelöschte AssignmentType-CRUD) | Niedrig |
| AI-Prompt in `AssignmentFeedbackPage` | Nutzt `db.criteria` (alt), sollte `feedbackCriterionItems` nutzen | Mittel |
| Dashboard | Zeigt keine Assignment-Nummer | Niedrig |
| `AssignmentCreate` | Kein Feld für Assignment-Nummer | Niedrig |
| `meetingExport.ts` | Bewertungs-Labels im Markdown-Export zeigen `FeedbackRating`-Keys (z.B. `client_ready`) statt sprechender Labels | Niedrig |
| KI-Generierung | `generateWithAi()` schlägt fehl (vermutlich Netzwerk/Firewall), Root Cause offen | Hoch |
| Prompt-System | Aktueller Einzel-Prompt soll mehrstufig/pro Dimension konfigurierbar werden — eigene Design-Session nötig | Hoch (nicht gescoped) |
| Selbstlernendes Tagging | Idee, automatische Tag-Vorschläge aus bisherigen Zuordnungen abzuleiten — eigene Design-Session nötig | Mittel (nicht gescoped) |
| IndexedDB | Keine robuste Persistenz-Garantie (Browser kann löschen) | Hoch → SQLite WASM geplant |
| `db/queries/`-Layer | Fehlt — DB-Zugriffe direkt aus Komponenten, erschwert SQLite-Migration | Mittel |

View File

@ -0,0 +1,199 @@
# Feedback-Struktur
## Client-Readiness
### Problem Structuring
Übersetzt eine zunächst offene oder komplexe Kundenfragestellung in eine präzise, logisch gegliederte und bearbeitbare Problemstruktur, die eine fokussierte Analyse und zielgerichtete Entscheidungsfindung ermöglicht.
- **Problem- und Zielklarheit** (Gewicht 1.0) — Klärt Ausgangslage, eigentliches Problem, angestrebtes Ergebnis und relevante Entscheidung, bevor mit der Bearbeitung begonnen wird.
- **Scope-Klarheit** (Gewicht 1.0) — Grenzt Untersuchungsgegenstand, Betrachtungszeitraum, Stakeholder, Annahmen und Nicht-Ziele nachvollziehbar ab.
- **Logische Zerlegung** (Gewicht 1.0) — Zerlegt komplexe Fragestellungen in verständliche, trennscharfe und gemeinsam hinreichend vollständige Teilfragen.
- **Hypothesenorientierung** (Gewicht 1.0) — Formuliert frühzeitig plausible, überprüfbare Hypothesen und nutzt sie zur Steuerung der weiteren Analyse.
- **Priorisierung nach Relevanz** (Gewicht 1.0) — Fokussiert die Struktur auf die entscheidungs- und wirkungsrelevanten Fragestellungen und vermeidet unnötige Analysebreite.
- **Treiber- und Abhängigkeitsverständnis** (Gewicht 1.0) — Erkennt wesentliche Ursachen, Wirkzusammenhänge, Abhängigkeiten und Wechselwirkungen innerhalb der Fragestellung.
- **Ableitung eines Analyseplans** (Gewicht 1.0) — Übersetzt die Problemstruktur in konkrete Arbeitsschritte, benötigte Informationen, Verantwortlichkeiten und sinnvolle Prüfsequenzen.
### Problem Solving
Bearbeitet strukturierte Fragestellungen analytisch belastbar, pragmatisch und ergebnisorientiert. Entwickelt aus Fakten und Erkenntnissen umsetzbare Optionen, Entscheidungen und Empfehlungen.
- **Fakten- und Evidenzorientierung** (Gewicht 1.0) — Stützt Aussagen auf belastbare Daten, Quellen, Interviews, Analysen oder transparent hergeleitete Annahmen.
- **Methodengerechte Analyse** (Gewicht 1.0) — Wählt geeignete qualitative und quantitative Methoden aus und wendet sie fachlich korrekt und zweckmäßig an.
- **Kritische Prüfung von Informationen** (Gewicht 1.0) — Hinterfragt Datenqualität, Quellen, Annahmen, Widersprüche und mögliche Verzerrungen, statt Ergebnisse ungeprüft zu übernehmen.
- **Hypothesenprüfung und Iteration** (Gewicht 1.0) — Prüft Hypothesen systematisch, verwirft oder schärft sie anhand neuer Erkenntnisse und passt das Vorgehen entsprechend an.
- **Entwicklung von Lösungsoptionen** (Gewicht 1.0) — Entwickelt mehrere relevante Handlungsoptionen und macht deren Unterschiede, Vor- und Nachteile sowie Konsequenzen sichtbar.
- **Impact- und Value-Orientierung** (Gewicht 1.0) — Richtet Analyse, Vorgehen und Lösung auf den höchsten realistischen Kundennutzen aus. Hinterfragt bei Bedarf den ursprünglich gewünschten Deliverable-Typ und entwickelt wirkungsvollere Alternativen, ohne Ziel, Scope, Risiken oder Umsetzbarkeit eigenmächtig zu verändern.
- **Machbarkeit und Realitätsnähe** (Gewicht 1.0) — Berücksichtigt organisatorische, technische, wirtschaftliche und zeitliche Rahmenbedingungen bei der Lösungsentwicklung.
- **Entscheidungsorientierte Empfehlung** (Gewicht 1.0) — Verdichtet Analyseergebnisse zu einer klaren Empfehlung mit Begründung, Auswirkungen, Risiken und nächsten Schritten.
### Storylining
Verdichtet komplexe Inhalte zu einer logisch schlüssigen, adressatengerechten und entscheidungsorientierten Argumentationslinie, die Kernbotschaften klar transportiert.
- **Answer First / Kernaussage** (Gewicht 1.0) — Stellt die zentrale Aussage, Empfehlung oder Antwort frühzeitig und eindeutig heraus.
- **Logische Argumentationsstruktur** (Gewicht 1.0) — Ordnet Aussagen so, dass Schlussfolgerungen nachvollziehbar aus Fakten, Analysen und Teilargumenten hervorgehen.
- **Relevanz und Fokus** (Gewicht 1.0) — Beschränkt die Storyline auf Inhalte, die für Fragestellung, Zielgruppe und Entscheidung tatsächlich erforderlich sind.
- **Belegführung** (Gewicht 1.0) — Verknüpft Kernaussagen sichtbar mit belastbaren Fakten, Analysen und nachvollziehbaren Begründungen.
- **Managementgerechte Dramaturgie** (Gewicht 1.0) — Führt verständlich von Ausgangslage und Handlungsdruck über Erkenntnisse zu Empfehlung, Wirkung und nächstem Schritt.
- **Konsistenz über Medien und Beiträge** (Gewicht 1.0) — Hält Botschaften, Begriffe, Zahlen und Argumentationslogik über Folien, Dokumente und mündliche Beiträge hinweg widerspruchsfrei.
### Precise Communication
Nimmt Informationen aktiv auf und kommuniziert mündlich wie schriftlich klar, prägnant, adressatengerecht und handlungsorientiert.
- **Active Listening** (Gewicht 1.0) — Hört aufmerksam zu, erfasst Inhalt und Kontext und greift relevante Aussagen, Bedenken und Signale gezielt auf.
- **Präzise Fragetechnik** (Gewicht 1.0) — Stellt klare, relevante und aufeinander aufbauende Fragen, die Verständnis, Erkenntnisgewinn oder Entscheidung fördern.
- **To the Point** (Gewicht 1.0) — Formuliert die wesentliche Aussage ohne unnötige Vorrede, Wiederholungen oder Nebenpfade.
- **Adressatengerechte Kommunikation** (Gewicht 1.0) — Passt Tonalität, Detailtiefe, Begriffswahl und Kommunikationsstil an Zielgruppe, Hierarchie und Situation an.
- **Sprachliche Klarheit und Verbindlichkeit** (Gewicht 1.0) — Verwendet eindeutige Formulierungen und vermeidet unnötige Füllwörter, Weichmacher, Mehrdeutigkeit und Scheingenauigkeit.
- **Recap und Verständnissicherung** (Gewicht 1.0) — Fasst Kernaussagen, Entscheidungen, offene Punkte und nächste Schritte prägnant zusammen und prüft gemeinsames Verständnis.
- **Souveräne Präsenz** (Gewicht 1.0) — Tritt ruhig, vorbereitet, klar und entscheidungsorientiert auf. Behält auch bei kritischen Rückfragen, Widerspruch oder unvollständigen Informationen die Handlungsfähigkeit, unterscheidet sicher zwischen Fakten, Annahmen und offenen Punkten und vermittelt Orientierung, ohne Sicherheit vorzutäuschen.
### Client Intimacy Building
Baut professionelle, vertrauensvolle Kundenbeziehungen auf, versteht fachliche und persönliche Stakeholderbedürfnisse und entwickelt sich vom Ausführenden zum wirksamen Berater.
- **Verständnis des Kundenkontexts** (Gewicht 1.0) — Erfasst Ziele, Geschäftsmodell, Prioritäten, Restriktionen, Entscheidungslogiken und relevante Rahmenbedingungen des Kunden.
- **Stakeholderverständnis** (Gewicht 1.0) — Erkennt Interessen, Einfluss, Erwartungen und mögliche Widerstände relevanter Stakeholder und berücksichtigt sie im Vorgehen.
- **Vertrauensaufbau und Beziehungsqualität** (Gewicht 1.0) — Schafft durch Verlässlichkeit, Integrität, professionelles Verhalten und respektvolle Zusammenarbeit eine belastbare Arbeitsbeziehung.
- **Fachliche Glaubwürdigkeit und Kompetenzwahrnehmung** (Gewicht 1.0) — Wird durch fachliche Substanz, Kundenverständnis, klare Einordnung und belastbare Empfehlungen als relevanter und kompetenter Ansprechpartner wahrgenommen. Gibt Orientierung und macht zugleich Grenzen des eigenen Wissens transparent.
- **Aktives Erwartungsmanagement** (Gewicht 1.0) — Klärt Ziele, Rollen, Umfang, Annahmen, Risiken und Liefererwartungen und steuert Abweichungen frühzeitig nach.
- **Active Advisory und Customer Steering** (Gewicht 1.0) — Führt den Kunden aktiv durch Fragestellungen, Optionen, Konsequenzen und notwendige Entscheidungen. Spricht klare Empfehlungen aus und verhindert eine rein ausführende Rolle, ohne die Entscheidungsverantwortung des Kunden zu übergehen.
- **Konstruktives Challengen** (Gewicht 1.0) — Hinterfragt Annahmen oder Kundenwünsche respektvoll, faktenbasiert und lösungsorientiert, wenn dies dem Ergebnis dient.
- **Beziehungsentwicklung über den Einzeltermin hinaus** (Gewicht 1.0) — Pflegt relevante Kontakte, erkennt Anschlussbedarfe und schafft Kontinuität in der Zusammenarbeit, ohne aufdringlich oder rein vertriebsorientiert zu handeln.
### Quality of Deliverables
Erstellt fachlich belastbare, konsistente und professionell aufbereitete Arbeitsergebnisse, die für den Kunden verständlich, relevant und unmittelbar nutzbar sind.
- **Fachliche Richtigkeit** (Gewicht 1.0) — Inhalte, Berechnungen, Aussagen und Schlussfolgerungen sind fachlich korrekt und belastbar.
- **Inhaltliche Vollständigkeit** (Gewicht 1.0) — Deckt die für Fragestellung und Entscheidung relevanten Aspekte, Perspektiven und Auswirkungen angemessen ab.
- **Logische Konsistenz** (Gewicht 1.0) — Stellt sicher, dass Aussagen, Zahlen, Argumente und Empfehlungen in sich sowie untereinander widerspruchsfrei sind.
- **Kundenspezifische Konkretisierung** (Gewicht 1.0) — Überträgt generische Erkenntnisse sichtbar auf Situation, Sprache, Daten und Handlungsrealität des Kunden.
- **Informationsdichte und Nutzbarkeit** (Gewicht 1.0) — Wählt eine angemessene Detailtiefe und ermöglicht dem Adressaten, Kernaussagen und Handlungsbedarf schnell zu erfassen.
- **Visualisierungs- und Darstellungsqualität** (Gewicht 1.0) — Nutzt Struktur, Layout, Tabellen, Grafiken und Visualisierungen so, dass sie Verständnis und Botschaft wirksam unterstützen.
- **Formale Qualität und Standardkonformität** (Gewicht 1.0) — Hält relevante Templates, Designvorgaben, Sprache, Quellenangaben, Versionierung und Dokumentationsstandards konsequent ein.
### Proposal Writing & Value Proposition Design
Übersetzt einen Kundenbedarf in ein überzeugendes, differenziertes und realistisch lieferbares Leistungsversprechen mit klarer Wirkung, Vorgehensweise und Nutzenargumentation.
- **Verständnis von Bedarf und Buying Context** (Gewicht 1.0) — Erfasst Problem, Auslöser, Zielbild, Entscheidungskriterien, Stakeholder und Beschaffungskontext hinter der Anfrage.
- **Klare Value Proposition** (Gewicht 1.0) — Formuliert prägnant, welchen konkreten Nutzen und welche angestrebten Ergebnisse das Angebot für den Kunden erzeugt.
- **Kundenspezifische Differenzierung** (Gewicht 1.0) — Verbindet Kundenbedarf mit relevanten Fähigkeiten, Erfahrungen, Assets und unterscheidbaren Stärken des eigenen Angebots.
- **Schlüssiger Scope und Deliverables** (Gewicht 1.0) — Beschreibt Leistungsumfang, Ergebnisse, Grenzen und Verantwortlichkeiten klar und vermeidet Interpretationsspielräume.
- **Plausibles Vorgehensmodell** (Gewicht 1.0) — Entwickelt einen nachvollziehbaren Ansatz mit Arbeitspaketen, Meilensteinen, Beteiligten und Entscheidungszeitpunkten.
- **Belastbare Annahmen und Machbarkeit** (Gewicht 1.0) — Macht Voraussetzungen, Abhängigkeiten, Risiken und Mitwirkungspflichten transparent und verspricht nur realistisch Lieferbares.
- **Kommerzielles Verständnis** (Gewicht 1.0) — Berücksichtigt Aufwand, Preislogik, Wirtschaftlichkeit, Risiken und den für beide Seiten tragfähigen Zuschnitt des Angebots.
- **Überzeugende Angebotsstory** (Gewicht 1.0) — Verbindet Kundensituation, Handlungsdruck, Lösung, Nutzen, Vorgehen und Glaubwürdigkeit zu einer konsistenten Entscheidungsgrundlage.
### Workshop Design & Facilitation
Konzipiert und moderiert Workshops so, dass relevante Beteiligte fokussiert zusammenarbeiten, belastbare Ergebnisse erzielen und klare Entscheidungen oder nächste Schritte vereinbaren.
- **Ziel- und Ergebnisdefinition** (Gewicht 1.0) — Klärt vorab, welches konkrete Ergebnis, welche Entscheidung oder welcher Arbeitsfortschritt im Workshop erreicht werden soll.
- **Teilnehmer- und Stakeholderdesign** (Gewicht 1.0) — Bindet die für Inhalt, Entscheidung und Umsetzung relevanten Personen mit passenden Rollen ein.
- **Dramaturgie und Methodenauswahl** (Gewicht 1.0) — Entwickelt eine logische Agenda und wählt Methoden, die Ziel, Teilnehmergruppe, Zeit und Arbeitsmodus unterstützen.
- **Vorbereitung und Arbeitsfähigkeit** (Gewicht 1.0) — Stellt Inhalte, Leitfragen, Daten, Materialien, Technik, Rollen und organisatorische Voraussetzungen rechtzeitig bereit.
- **Zielgerichtete Moderation** (Gewicht 1.0) — Führt verständlich durch den Prozess, hält Fokus und Zeit und macht Übergänge sowie Arbeitsaufträge klar.
- **Aktivierung und Gruppendynamik** (Gewicht 1.0) — Fördert ausgewogene Beteiligung, geht konstruktiv mit Dominanz, Zurückhaltung, Konflikten und Abschweifungen um.
- **Ergebnissicherung und Follow-up** (Gewicht 1.0) — Verdichtet Diskussionen, dokumentiert Entscheidungen, Verantwortlichkeiten und nächste Schritte und stellt eine klare Nachbereitung sicher.
## AI-Readiness
### AI Fundamentals
Versteht Funktionsweise, Einsatzvoraussetzungen, Chancen, Grenzen und Risiken von KI auf einem für die Beratung angemessenen Niveau und handelt verantwortungsvoll.
- **Grundverständnis generativer KI** (Gewicht 1.0) — Erklärt in angemessener Tiefe, wie Modelle, Trainingsdaten, Kontext, Prompts und probabilistische Ausgabe die Ergebnisse beeinflussen.
- **Use-Case-Eignung beurteilen** (Gewicht 1.0) — Erkennt, für welche Aufgaben KI sinnvoll ist und wo menschliche Expertise, klassische Analyse oder andere Werkzeuge geeigneter sind.
- **Grenzen und Fehlerbilder erkennen** (Gewicht 1.0) — Berücksichtigt insbesondere Halluzinationen, Bias, fehlende Aktualität, Scheingenauigkeit, Kontextverlust und unvollständige Quellenlage.
- **Datenschutz und Vertraulichkeit** (Gewicht 1.0) — Behandelt Kunden-, Personen- und Unternehmensdaten entsprechend den geltenden Sicherheits-, Datenschutz- und Vertraulichkeitsvorgaben.
- **Responsible-AI-Verständnis** (Gewicht 1.0) — Berücksichtigt ethische, rechtliche, sicherheitsbezogene und gesellschaftliche Auswirkungen beim Einsatz von KI.
- **Qualitätssicherung von KI-Ergebnissen** (Gewicht 1.0) — Prüft Ergebnisse fachlich, logisch, methodisch und quellenbezogen und übernimmt keine KI-Ausgabe ungeprüft.
### AI-Augmented Consulting
Integriert KI gezielt und verantwortungsvoll in typische Beratungsaufgaben, um Qualität, Geschwindigkeit und Erkenntnisgewinn zu verbessern, ohne die Verantwortung für Ergebnisse abzugeben.
- **Zielgerichtete Tool- und Use-Case-Auswahl** (Gewicht 1.0) — Wählt KI-Werkzeug und Einsatzform passend zu Aufgabe, Datenlage, Risiko und erwartetem Mehrwert aus.
- **Kontext- und Promptgestaltung** (Gewicht 1.0) — Formuliert klare Arbeitsaufträge, stellt relevanten Kontext bereit und verbessert Ergebnisse iterativ durch geeignete Steuerung.
- **KI-gestützte Recherche und Verdichtung** (Gewicht 1.0) — Nutzt KI zur Informationsstrukturierung und Synthese, überprüft jedoch Quellen, Aktualität und Vollständigkeit.
- **KI-gestützte Analyse und Hypothesenbildung** (Gewicht 1.0) — Nutzt KI zur Entwicklung von Analysefragen, Hypothesen, Strukturierungsansätzen oder Auswertungsoptionen und validiert diese eigenständig.
- **KI-gestützte Storyline- und Deliverable-Erstellung** (Gewicht 1.0) — Erstellt mit KI erste Entwürfe für Storylines, Texte, Folien oder Arbeitsmaterialien und veredelt sie fachlich sowie kundenspezifisch.
- **KI-gestützte Meeting- und Workshopvorbereitung** (Gewicht 1.0) — Nutzt KI für Interviewleitfäden, Fragen, Szenarien, Zusammenfassungen oder Moderationsvorbereitung und prüft die Praxistauglichkeit.
- **Systematische Workflow-Integration** (Gewicht 1.0) — Identifiziert wiederkehrende Arbeitsschritte, integriert KI reproduzierbar und dokumentiert Vorgehen, Qualitätskontrollen und menschliche Entscheidungen angemessen.
### AI Certification
Dokumentiert formale, rollenbezogene KI-Qualifikationsnachweise. Da Zertifizierungen primär einen Nachweisstatus und kein beobachtbares Verhalten darstellen, sollten diese Kriterien möglichst als erfüllt, nicht erfüllt oder nicht erforderlich geführt werden.
- **Vorgegebene Basiszertifizierung** (Gewicht 1.0) — Hat die für die Rolle oder Entwicklungsstufe vorgeschriebene grundlegende KI-Zertifizierung erfolgreich abgeschlossen.
- **Rollen- oder technologiespezifische Zertifizierung** (Gewicht 1.0) — Verfügt über die für Einsatzgebiet, Technologie oder Projektkontext relevanten weiterführenden Nachweise, soweit diese gefordert sind.
- **Aktualität des Nachweises** (Gewicht 1.0) — Hält verpflichtende Zertifizierungen, Rezertifizierungen und vorgeschriebene Lernstände aktuell.
- **Nachgewiesener Praxistransfer** (Gewicht 1.0) — Kann zentrale Inhalte der Zertifizierung in Übungen oder Assignments korrekt anwenden und deren Grenzen erläutern.
## Culture-Readiness
### Quality Excellence
Verankert Qualität als kontinuierliche Arbeitsweise und sorgt durch Standards, Reviews und kritische Prüfung dafür, dass belastbare Ergebnisse reproduzierbar entstehen.
- **Qualitätsanspruch** (Gewicht 1.0) — Richtet die eigene Arbeit sichtbar auf fachlich belastbare, verständliche und kundentaugliche Ergebnisse aus.
- **Systematische Qualitätschecks** (Gewicht 1.0) — Prüft Inhalte, Zahlen, Logik, Sprache, Quellen, Format und Konsistenz vor Weitergabe anhand geeigneter Kontrollschritte.
- **Prozess- und Standardtreue** (Gewicht 1.0) — Kennt relevante Prozesse, Methoden, Templates und Vorgaben und wendet sie zweckmäßig sowie nachvollziehbar an.
- **Reviewfähigkeit** (Gewicht 1.0) — Bereitet Arbeitsergebnisse so vor, dass andere sie effizient prüfen können, und arbeitet Reviewhinweise sauber und vollständig ein.
- **Frühzeitige Qualitätssicherung** (Gewicht 1.0) — Plant Reviews, Abstimmungen und Prüfschritte rechtzeitig ein, statt Qualität ausschließlich am Ende herzustellen.
- **Integrität und Compliance** (Gewicht 1.0) — Handelt ehrlich und regelkonform, macht Unsicherheiten oder Fehler transparent und wahrt Vertraulichkeit sowie relevante fachliche und organisatorische Vorgaben.
- **Kontinuierliche Verbesserung** (Gewicht 1.0) — Erkennt wiederkehrende Fehler oder Ineffizienzen und verbessert Vorgehen, Standards oder Hilfsmittel nachhaltig.
### Drive (Extra Mile)
Zeigt wirkungsorientierte Initiative, Ausdauer und Gestaltungswillen und leistet bei relevantem Mehrwert bewusst mehr als das formal Geforderte. Bewertet wird nicht die Anzahl der Arbeitsstunden, sondern der zusätzliche Beitrag zum Ergebnis.
- **Proaktive Initiative** (Gewicht 1.0) — Erkennt Handlungsbedarf oder Chancen und wird selbstständig aktiv, ohne auf detaillierte Einzelaufträge zu warten.
- **Ambitionierter Wirkungsanspruch** (Gewicht 1.0) — Strebt nicht nur formale Erledigung, sondern ein erkennbar nützliches und überzeugendes Ergebnis an.
- **Beharrlichkeit bei Hindernissen** (Gewicht 1.0) — Verfolgt wichtige Themen trotz Rückschlägen konstruktiv weiter und sucht aktiv nach alternativen Lösungswegen.
- **Mehrwert über den Auftrag hinaus** (Gewicht 1.0) — Identifiziert sinnvolle zusätzliche Beiträge, Impulse oder wiederverwendbare Lösungen, ohne Scope, Prioritäten oder Wirtschaftlichkeit aus dem Blick zu verlieren.
- **Anpassungsfähigkeit** (Gewicht 1.0) — Bleibt bei veränderten Prioritäten, unvollständigen Informationen oder neuen Anforderungen handlungsfähig und richtet das Vorgehen neu aus.
- **Konstruktive Energie unter Druck** (Gewicht 1.0) — Bewahrt auch bei hoher Belastung Fokus, Lösungsorientierung und professionelles Verhalten.
### Ownership
Übernimmt End-to-End-Verantwortung für übertragene Aufgaben und angestrebte Ergebnisse, steuert die eigene Arbeit aktiv und schafft jederzeit Transparenz über Fortschritt, Risiken und Handlungsbedarf.
- **Auftrags- und Ergebnisverständnis** (Gewicht 1.0) — Klärt Ziel, Qualitätsmaßstab, Umfang, Rollen, Abhängigkeiten und erwartete Lieferung, bevor die Umsetzung fortschreitet.
- **Eigenständige Arbeitssteuerung** (Gewicht 1.0) — Plant, priorisiert und organisiert die eigene Arbeit nachvollziehbar und benötigt nur ein der Entwicklungsstufe angemessenes Maß an Steuerung.
- **Verbindlichkeit und Zuverlässigkeit** (Gewicht 1.0) — Hält Zusagen ein und behandelt vereinbarte Termine, Qualitätsziele und Verantwortlichkeiten verbindlich.
- **Fortschritts- und Risikotransparenz** (Gewicht 1.0) — Kommuniziert Arbeitsstand, offene Punkte, Abhängigkeiten und mögliche Abweichungen frühzeitig und konkret und vermittelt eine realistische Einschätzung der Zielerreichung.
- **Stufenweises Alignment und Acceptance Management** (Gewicht 1.0) — Validiert Annahmen, Konzepte und Zwischenergebnisse rechtzeitig mit den relevanten Kundenvertretern. Sichert ein explizites gemeinsames Verständnis beziehungsweise eine Teilfreigabe als Grundlage für die weitere Bearbeitung und dokumentiert Entscheidungen, Vorbehalte und Änderungen nachvollziehbar.
- **Vorausschauendes Handeln** (Gewicht 1.0) — Erkennt Risiken, Engpässe und Entscheidungsbedarfe früh und leitet geeignete Maßnahmen ein.
- **Angemessene Entscheidung und Eskalation** (Gewicht 1.0) — Entscheidet im eigenen Verantwortungsbereich selbstständig und eskaliert rechtzeitig mit klarer Sachlage und Lösungsvorschlägen.
- **Konsequenter Abschluss** (Gewicht 1.0) — Verfolgt offene Punkte bis zu einem belastbaren Ergebnis, sichert Übergaben und schließt Kommunikations- und Dokumentationsschleifen.
### Coachability
Nutzt Feedback, Reflexion und neue Erfahrungen aktiv zur Weiterentwicklung und zeigt nachvollziehbare Verhaltensänderungen über mehrere Assignments hinweg.
- **Aktives Einholen von Feedback** (Gewicht 1.0) — Fragt gezielt und rechtzeitig nach Rückmeldung zu Wirkung, Vorgehen und Ergebnis, statt nur auf formale Feedbacktermine zu warten.
- **Offene Feedbackannahme** (Gewicht 1.0) — Hört Feedback aufmerksam an, bleibt sachlich und vermeidet vorschnelle Rechtfertigung oder Abwehr.
- **Verständnisklärung** (Gewicht 1.0) — Fragt bei unklarer Rückmeldung nach konkreten Situationen, Erwartungen und beobachtbaren Alternativen.
- **Übersetzung in Entwicklungsmaßnahmen** (Gewicht 1.0) — Leitet aus Feedback konkrete, realistische und überprüfbare Verhaltens- oder Lernschritte ab.
- **Sichtbarer Praxistransfer** (Gewicht 1.0) — Wendet vereinbarte Verbesserungen in folgenden Aufgaben erkennbar an und zeigt nachhaltigen Fortschritt.
- **Selbstreflexion und Lernagilität** (Gewicht 1.0) — Analysiert eigene Wirkung, Stärken, Fehler und Muster realistisch und passt Verhalten an neue Situationen an.
### Collaboration
Arbeitet verlässlich, respektvoll und lösungsorientiert mit anderen zusammen, stärkt den gemeinsamen Teamerfolg und nutzt unterschiedliche Perspektiven und Fähigkeiten konstruktiv.
- **Proaktive Abstimmung** (Gewicht 1.0) — Stimmt sich rechtzeitig mit relevanten Beteiligten ab, macht Schnittstellen transparent und vermeidet Informationslücken.
- **Wissens- und Ergebnisteilung** (Gewicht 1.0) — Teilt Informationen, Zwischenergebnisse, Erfahrungen und wiederverwendbare Inhalte so, dass das Team wirksam weiterarbeiten kann.
- **Unterstützungsbereitschaft** (Gewicht 1.0) — Unterstützt andere konstruktiv und bedarfsgerecht, ohne eigene Verantwortlichkeiten oder gemeinsame Prioritäten zu vernachlässigen.
- **Verlässliches Teamverhalten** (Gewicht 1.0) — Hält Teamzusagen ein, respektiert Rollen und schafft durch professionelles Verhalten eine belastbare Arbeitsbasis.
- **Konstruktiver Umgang mit Differenzen** (Gewicht 1.0) — Spricht abweichende Sichtweisen sachlich an, hört Gegenargumente an und arbeitet auf eine tragfähige Lösung hin.
- **Einbindung unterschiedlicher Perspektiven** (Gewicht 1.0) — Bezieht relevante Erfahrungen und Meinungen aktiv ein und schafft Raum für Beteiligung unabhängig von Rolle oder Hierarchie.
- **Team- und organisationsübergreifende Zusammenarbeit** (Gewicht 1.0) — Vernetzt sich bei Bedarf über das unmittelbare Team hinaus und nutzt passende Experten, Communities oder Kontakte für den gemeinsamen Erfolg.
---
_Export: 6.8.2026, finale Kriterienstruktur ohne Änderungen der Kategorie-Ebene

23
docs/preload/README.md Normal file
View File

@ -0,0 +1,23 @@
# Feedback-Struktur Preload
Finale Kriterienstruktur (Export 2026-08-06) für die Dev-Umgebung.
## Dateien
| Datei | Zweck |
|---|---|
| `docs/preload/Feedback-Struktur-final.md` | Quelle (Markdown, gleiches Format wie Config → Feedback → ⬇ MD) |
| `src/db/seeds/feedbackStructureFinal.json` | Geparste Seed-Kopie (3 Dimensionen, 16 Kategorien, 109 Kriterien) |
## Einspielen
**In der App (Config → Feedback):**
- **Preload final** — lädt `docs/preload/Feedback-Struktur-final.md` in die Dev-DB (mit Bestätigung; ersetzt bestehende Struktur)
- **↑ MD** — beliebige MD-Datei im Export-Format importieren
**CLI:**
```bash
npm run preload:feedback-structure
```
Ersetzt die komplette Feedback-Struktur (Dimensionen inkl. Cascade). Nur in `AssigmentMonitorV2` verwenden — nicht gegen Produktiv.

2045
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,43 @@
{
"name": "assignment-monitor",
"name": "assignment-monitor-v2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"server": "tsx watch --env-file=.env server/index.ts",
"dev:all": "concurrently -n web,api -c blue,green \"npm:dev\" \"npm:server\"",
"verify-backup": "tsx --env-file=.env server/scripts/verifyBackupRoundtrip.ts",
"db:reset": "tsx --env-file=.env server/scripts/resetDevDatabase.ts",
"preload:feedback-structure": "tsx --env-file=.env server/scripts/preloadFeedbackStructure.ts",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-pdf/renderer": "^4.5.1",
"dexie": "^4.0.11",
"express": "^4.21.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.6.2",
"dexie": "^4.0.11",
"zustand": "^5.0.5",
"recharts": "^2.15.3"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/vite": "^4.1.10",
"@types/express": "^4.17.21",
"@types/node": "^22.10.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.1",
"concurrently": "^9.1.2",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"tailwindcss": "^4.1.10",
"tsx": "^4.19.2",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0"

74
server/database.ts Normal file
View File

@ -0,0 +1,74 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { dirname, join, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TABLE_DEFINITIONS } from './db/tableRegistry'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = resolve(join(__dirname, '..'))
/** V2-Defaults weichen bewusst von Produktiv (assignment-monitor.sqlite3) ab. */
const sqliteFilename = process.env.SQLITE_FILENAME?.trim() || 'assignment-monitor-v2-dev.sqlite3'
if (sqliteFilename.includes('/') || sqliteFilename.includes('\\') || sqliteFilename.includes('..')) {
throw new Error(
`[db] SAFETY: SQLITE_FILENAME darf nur ein Dateiname sein (kein Pfad): "${sqliteFilename}"`,
)
}
const dbPath = resolve(join(__dirname, 'data', sqliteFilename))
/**
* Harte Absicherung: DB darf nur unter diesem V2-Projekt liegen und niemals unter
* dem Produktiv-Ordner c:\dev\AssigmentMonitor (ohne V2-Suffix).
*/
function assertDevDbPath(resolvedDbPath: string): void {
const dbNorm = resolvedDbPath.toLowerCase()
const rootNorm = projectRoot.toLowerCase()
const rootPrefix = rootNorm.endsWith(sep) ? rootNorm : rootNorm + sep
if (!dbNorm.startsWith(rootPrefix) && dbNorm !== rootNorm) {
throw new Error(
`[db] SAFETY: DB-Pfad liegt außerhalb von AssignmentMonitorV2.\n` +
` DB: ${resolvedDbPath}\n` +
` Root: ${projectRoot}`,
)
}
// Produktiv-Sibling: ...\AssigmentMonitor\server\data\... — V2-Pfad enthält "assigmentmonitorv2"
const looksLikeProdData =
/[/\\]assigmentmonitor[/\\]server[/\\]data[/\\]/.test(dbNorm) &&
!/[/\\]assigmentmonitorv2[/\\]/.test(dbNorm)
if (looksLikeProdData) {
throw new Error(
`[db] SAFETY: Versuch, die Produktiv-Datenbank zu öffnen wurde blockiert:\n ${resolvedDbPath}`,
)
}
}
assertDevDbPath(dbPath)
mkdirSync(dirname(dbPath), { recursive: true })
export const db = new DatabaseSync(dbPath)
/**
* `CREATE TABLE IF NOT EXISTS` legt eine fehlende Tabelle vollständig an, migriert aber niemals eine
* bereits bestehende neue Spalten, die tableRegistry.ts für eine vorhandene Tabelle ergänzt (z.B.
* suggestedAchievements auf feedbackDimensionTexts, 2026-07-07), fehlen dort sonst dauerhaft und
* führen erst zur Laufzeit zu "no column named ..."-Fehlern. Deshalb zusätzlich per PRAGMA table_info
* abgleichen und fehlende Spalten per ALTER TABLE nachziehen.
*/
for (const table of TABLE_DEFINITIONS) {
const columnsSql = table.columns.map(c => `"${c.name}" ${c.sqlType}`).join(', ')
db.exec(`CREATE TABLE IF NOT EXISTS "${table.name}" (id INTEGER PRIMARY KEY AUTOINCREMENT, ${columnsSql})`)
const existingColumns = new Set(
(db.prepare(`PRAGMA table_info("${table.name}")`).all() as { name: string }[]).map(c => c.name),
)
for (const col of table.columns) {
if (!existingColumns.has(col.name)) {
db.exec(`ALTER TABLE "${table.name}" ADD COLUMN "${col.name}" ${col.sqlType}`)
}
}
}
console.log(`[db] DEV-SQLite bereit unter ${dbPath}`)

133
server/db/crud.ts Normal file
View File

@ -0,0 +1,133 @@
/**
* Generische SQL-CRUD-Bausteine über die Tabellen-Registry (tableRegistry.ts) von allen
* server/db/queries/*.ts-Modulen genutzt, damit Spalten-Codecs (JSON/Bool) nur an einer
* Stelle behandelt werden.
*/
import { db } from '../database'
import { TABLE_DEFINITIONS, type TableDef, type ColumnDef, type ColumnCodec } from './tableRegistry'
const tablesByName = new Map(TABLE_DEFINITIONS.map(t => [t.name, t]))
function requireTable(name: string): TableDef {
const t = tablesByName.get(name)
if (!t) throw new Error(`Unbekannte Tabelle: ${name}`)
return t
}
type SqlPrimitive = number | string | bigint | null
function toBindValue(raw: unknown, codec: ColumnCodec): SqlPrimitive {
if (raw === undefined || raw === null) return null
if (codec === 'json') return JSON.stringify(raw)
if (codec === 'bool') return raw ? 1 : 0
return raw as SqlPrimitive
}
function fromSqlValue(raw: unknown, codec: ColumnCodec, sqlType?: ColumnDef['sqlType']): unknown {
if (raw === null || raw === undefined) return null
if (codec === 'json') return JSON.parse(raw as string)
if (codec === 'bool') return raw === 1
// SQLite/Affinity: INTEGER-Spalten können als string/bigint ankommen (v.a. nach TEXT→INTEGER-Schemawechsel)
if (sqlType === 'INTEGER' || sqlType === 'REAL') {
if (typeof raw === 'bigint') return Number(raw)
if (typeof raw === 'string' && raw.trim() !== '') {
const n = Number(raw)
return Number.isFinite(n) ? n : raw
}
}
return raw
}
export function decodeRow<T>(table: TableDef, row: Record<string, unknown>): T {
const result: Record<string, unknown> = { id: row.id }
for (const c of table.columns) result[c.name] = fromSqlValue(row[c.name], c.codec, c.sqlType)
return result as T
}
export function insertRow(tableName: string, obj: Record<string, unknown>): number {
const table = requireTable(tableName)
const cols = table.columns.map(c => `"${c.name}"`).join(', ')
const placeholders = table.columns.map(() => '?').join(', ')
const values = table.columns.map(c => toBindValue(obj[c.name], c.codec))
const info = db.prepare(`INSERT INTO "${tableName}" (${cols}) VALUES (${placeholders})`).run(...values)
return Number(info.lastInsertRowid)
}
/** Für Backup-Restore: fügt eine Zeile mit expliziter id ein, damit Fremdschlüssel-Referenzen im importierten Datensatz stabil bleiben. */
export function insertRowWithId(tableName: string, id: number, obj: Record<string, unknown>): void {
const table = requireTable(tableName)
const cols = ['"id"', ...table.columns.map(c => `"${c.name}"`)].join(', ')
const placeholders = ['?', ...table.columns.map(() => '?')].join(', ')
const values: SqlPrimitive[] = [id, ...table.columns.map(c => toBindValue(obj[c.name], c.codec))]
db.prepare(`INSERT INTO "${tableName}" (${cols}) VALUES (${placeholders})`).run(...values)
}
export function updateRow(tableName: string, id: number, patch: Record<string, unknown>): number {
const table = requireTable(tableName)
const entries = Object.entries(patch).filter(([k]) => k !== 'id')
if (entries.length === 0) return 0
const setSql = entries.map(([k]) => `"${k}" = ?`).join(', ')
const values = entries.map(([k, v]) => {
const colDef = table.columns.find(c => c.name === k)
return toBindValue(v, colDef?.codec ?? 'plain')
})
const info = db.prepare(`UPDATE "${tableName}" SET ${setSql} WHERE id = ?`).run(...values, id)
return Number(info.changes)
}
export function getRowById<T>(tableName: string, id: number): T | undefined {
const table = requireTable(tableName)
const row = db.prepare(`SELECT * FROM "${tableName}" WHERE id = ?`).get(id) as Record<string, unknown> | undefined
return row ? decodeRow<T>(table, row) : undefined
}
export function getAllRows<T>(tableName: string): T[] {
const table = requireTable(tableName)
const rows = db.prepare(`SELECT * FROM "${tableName}"`).all() as Record<string, unknown>[]
return rows.map(r => decodeRow<T>(table, r))
}
export function getAllRowsOrderedBy<T>(tableName: string, column: string): T[] {
const table = requireTable(tableName)
const rows = db.prepare(`SELECT * FROM "${tableName}" ORDER BY "${column}" ASC`).all() as Record<string, unknown>[]
return rows.map(r => decodeRow<T>(table, r))
}
export function getRowsWhereEquals<T>(tableName: string, column: string, value: SqlPrimitive): T[] {
const table = requireTable(tableName)
const rows = db.prepare(`SELECT * FROM "${tableName}" WHERE "${column}" = ?`).all(value) as Record<string, unknown>[]
return rows.map(r => decodeRow<T>(table, r))
}
export function getRowsWhereIn<T>(tableName: string, column: string, values: number[]): T[] {
if (values.length === 0) return []
const table = requireTable(tableName)
const placeholders = values.map(() => '?').join(', ')
const rows = db.prepare(`SELECT * FROM "${tableName}" WHERE "${column}" IN (${placeholders})`).all(...values) as Record<string, unknown>[]
return rows.map(r => decodeRow<T>(table, r))
}
export function deleteRowById(tableName: string, id: number): void {
requireTable(tableName)
db.prepare(`DELETE FROM "${tableName}" WHERE id = ?`).run(id)
}
export function deleteRowsWhereEquals(tableName: string, column: string, value: SqlPrimitive): number {
requireTable(tableName)
const info = db.prepare(`DELETE FROM "${tableName}" WHERE "${column}" = ?`).run(value)
return Number(info.changes)
}
export function deleteRowsWhereIn(tableName: string, column: string, values: number[]): number {
if (values.length === 0) return 0
requireTable(tableName)
const placeholders = values.map(() => '?').join(', ')
const info = db.prepare(`DELETE FROM "${tableName}" WHERE "${column}" IN (${placeholders})`).run(...values)
return Number(info.changes)
}
export function countWhereEquals(tableName: string, column: string, value: SqlPrimitive): number {
requireTable(tableName)
const row = db.prepare(`SELECT COUNT(*) as c FROM "${tableName}" WHERE "${column}" = ?`).get(value) as { c: number }
return row.c
}

View File

@ -0,0 +1,142 @@
import { getRowById, insertRowWithId, updateRow } from '../crud'
import type { AppSettings, RatingColorBand } from '../../../src/db/types'
import { AI_CONFIG, DEFAULT_RATING_COLOR_BANDS, normalizeRatingColorBands } from '../../../src/config/constants'
/** Muss mit DEFAULT_RATING_TREND_WEIGHT_STEP in src/config/constants.ts synchron gehalten werden. */
const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5
export function getRatingTrendWeightStep(): number {
const settings = getRowById<AppSettings>('appSettings', 1)
return settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP
}
export function getOrSeedRatingTrendWeightStep(): number {
let settings = getRowById<AppSettings>('appSettings', 1)
if (!settings) {
insertRowWithId('appSettings', 1, { ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP })
settings = getRowById<AppSettings>('appSettings', 1)
}
return settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP
}
export function saveRatingTrendWeightStep(weightStep: number): number {
const existing = getRowById('appSettings', 1)
if (existing) updateRow('appSettings', 1, { ratingTrendWeightStep: weightStep })
else insertRowWithId('appSettings', 1, { ratingTrendWeightStep: weightStep })
return 1
}
export function getOrSeedRatingColorBands(): RatingColorBand[] {
let settings = getRowById<AppSettings>('appSettings', 1)
if (!settings) {
insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
ratingColorBands: DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b })),
})
return DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b }))
}
if (!settings.ratingColorBands?.length) {
const seeded = DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b }))
updateRow('appSettings', 1, { ratingColorBands: seeded })
return seeded
}
return normalizeRatingColorBands(settings.ratingColorBands)
}
export function saveRatingColorBands(bands: RatingColorBand[]): number {
const normalized = normalizeRatingColorBands(bands)
const existing = getRowById('appSettings', 1)
if (existing) updateRow('appSettings', 1, { ratingColorBands: normalized })
else insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
ratingColorBands: normalized,
})
return 1
}
/**
* Der Standard-Prompt für neue Dimensionen/"Zurücksetzen" lebt jetzt in der DB statt im Code
* (`AI_CONFIG.defaultDimensionPromptTemplate` dient nur noch als einmaliger Erstbefüllungs-Wert
* beim allerersten Zugriff auf einer Installation, siehe Config KI-Prompts).
*/
export function getDefaultDimensionPromptTemplate(): string {
const settings = getRowById<AppSettings>('appSettings', 1)
return settings?.defaultDimensionPromptTemplate ?? AI_CONFIG.defaultDimensionPromptTemplate
}
export function getOrSeedDefaultDimensionPromptTemplate(): string {
const settings = getRowById<AppSettings>('appSettings', 1)
if (!settings) {
insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
defaultDimensionPromptTemplate: AI_CONFIG.defaultDimensionPromptTemplate,
})
return AI_CONFIG.defaultDimensionPromptTemplate
}
if (!settings.defaultDimensionPromptTemplate) {
updateRow('appSettings', 1, { defaultDimensionPromptTemplate: AI_CONFIG.defaultDimensionPromptTemplate })
return AI_CONFIG.defaultDimensionPromptTemplate
}
return settings.defaultDimensionPromptTemplate
}
export function saveDefaultDimensionPromptTemplate(template: string): number {
const existing = getRowById('appSettings', 1)
if (existing) updateRow('appSettings', 1, { defaultDimensionPromptTemplate: template })
else insertRowWithId('appSettings', 1, { ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP, defaultDimensionPromptTemplate: template })
return 1
}
/**
* Prompt-Template für 🏷 Vorschläge analysieren" (selbstlernendes Kriterien-Tagging) ein
* einziges, protokollweites Template statt eines pro Assignment-Typ, siehe CLAUDE.md. Gleiches
* Erstbefüllungs-/Persistenz-Muster wie `getOrSeedDefaultDimensionPromptTemplate`.
*/
export function getOrSeedCriterionTaggingPromptTemplate(): string {
let settings = getRowById<AppSettings>('appSettings', 1)
if (!settings) {
insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
criterionTaggingPromptTemplate: AI_CONFIG.defaultCriterionTaggingPromptTemplate,
})
return AI_CONFIG.defaultCriterionTaggingPromptTemplate
}
if (!settings.criterionTaggingPromptTemplate) {
updateRow('appSettings', 1, { criterionTaggingPromptTemplate: AI_CONFIG.defaultCriterionTaggingPromptTemplate })
return AI_CONFIG.defaultCriterionTaggingPromptTemplate
}
return settings.criterionTaggingPromptTemplate
}
export function saveCriterionTaggingPromptTemplate(template: string): number {
const existing = getRowById('appSettings', 1)
if (existing) updateRow('appSettings', 1, { criterionTaggingPromptTemplate: template })
else insertRowWithId('appSettings', 1, { ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP, criterionTaggingPromptTemplate: template })
return 1
}
export function getOrSeedDefaultCategoryPromptTemplate(): string {
let settings = getRowById<AppSettings>('appSettings', 1)
if (!settings) {
insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
defaultCategoryPromptTemplate: AI_CONFIG.defaultCategoryPromptTemplate,
})
return AI_CONFIG.defaultCategoryPromptTemplate
}
if (!settings.defaultCategoryPromptTemplate) {
updateRow('appSettings', 1, { defaultCategoryPromptTemplate: AI_CONFIG.defaultCategoryPromptTemplate })
return AI_CONFIG.defaultCategoryPromptTemplate
}
return settings.defaultCategoryPromptTemplate
}
export function saveDefaultCategoryPromptTemplate(template: string): number {
const existing = getRowById('appSettings', 1)
if (existing) updateRow('appSettings', 1, { defaultCategoryPromptTemplate: template })
else insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
defaultCategoryPromptTemplate: template,
})
return 1
}

View File

@ -0,0 +1,12 @@
import { getAllRows, getRowsWhereEquals, getRowsWhereIn, insertRow, updateRow, deleteRowsWhereEquals } from '../crud'
import type { Assessment } from '../../../src/db/types'
export const listAssessmentsAll = (): Assessment[] => getAllRows<Assessment>('assessments')
export const listAssessmentsForMeeting = (meetingInstanceId: number): Assessment[] =>
getRowsWhereEquals<Assessment>('assessments', 'meetingInstanceId', meetingInstanceId)
export const listAssessmentsForMeetings = (meetingInstanceIds: number[]): Assessment[] =>
getRowsWhereIn<Assessment>('assessments', 'meetingInstanceId', meetingInstanceIds)
export const addAssessment = (a: Omit<Assessment, 'id'>): number => insertRow('assessments', a)
export const updateAssessment = (id: number, patch: Partial<Assessment>): number => updateRow('assessments', id, patch)
export const deleteAssessmentsForMeeting = (meetingInstanceId: number): number =>
deleteRowsWhereEquals('assessments', 'meetingInstanceId', meetingInstanceId)

View File

@ -0,0 +1,63 @@
import { getAllRows, getRowById, getRowsWhereEquals, insertRow, updateRow, deleteRowById } from '../crud'
import type {
FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText,
} from '../../../src/db/types'
export const getFeedbackDraft = (assignmentId: number, instituteeId: number): FeedbackDraft | undefined =>
getRowsWhereEquals<FeedbackDraft>('feedbackDrafts', 'assignmentId', assignmentId).find(d => d.instituteeId === instituteeId)
export const addFeedbackDraft = (d: Omit<FeedbackDraft, 'id'>): number => insertRow('feedbackDrafts', d)
export const updateFeedbackDraft = (id: number, patch: Partial<FeedbackDraft>): number => updateRow('feedbackDrafts', id, patch)
export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): AssignmentFeedback | undefined =>
getRowsWhereEquals<AssignmentFeedback>('assignmentFeedbacks', 'assignmentId', assignmentId).find(f => f.instituteeId === instituteeId)
/** Für die Auswertungsübersicht: Feedback-Status (Entwurf/Final) aller Assignments auf einen Schlag, statt N+1-Abfragen pro Zeile. */
export const listAssignmentFeedbacksAll = (): AssignmentFeedback[] => getAllRows<AssignmentFeedback>('assignmentFeedbacks')
export const getAssignmentFeedbackById = (id: number): AssignmentFeedback | undefined =>
getRowById<AssignmentFeedback>('assignmentFeedbacks', id)
export const addAssignmentFeedback = (f: Omit<AssignmentFeedback, 'id'>): number => insertRow('assignmentFeedbacks', f)
export const updateAssignmentFeedback = (id: number, patch: Partial<AssignmentFeedback>): number =>
updateRow('assignmentFeedbacks', id, patch)
export function getOrCreateAssignmentFeedback(assignmentId: number, instituteeId: number): AssignmentFeedback | undefined {
let fb = getAssignmentFeedback(assignmentId, instituteeId)
if (!fb) {
const fbId = addAssignmentFeedback({
assignmentId, instituteeId, status: 'draft', aiModel: '', updatedAt: new Date().toISOString(),
})
fb = getAssignmentFeedbackById(fbId)
}
return fb
}
export const listFeedbackCategoryRatings = (assignmentFeedbackId: number): FeedbackCategoryRating[] =>
getRowsWhereEquals<FeedbackCategoryRating>('feedbackCategoryRatings', 'assignmentFeedbackId', assignmentFeedbackId)
export const addFeedbackCategoryRating = (r: Omit<FeedbackCategoryRating, 'id'>): number => insertRow('feedbackCategoryRatings', r)
export const updateFeedbackCategoryRating = (id: number, patch: Partial<FeedbackCategoryRating>): number =>
updateRow('feedbackCategoryRatings', id, patch)
export const deleteFeedbackCategoryRating = (id: number): void => {
deleteRowById('feedbackCategoryRatings', id)
}
export const listFeedbackDimensionTexts = (assignmentFeedbackId: number): FeedbackDimensionText[] =>
getRowsWhereEquals<FeedbackDimensionText>('feedbackDimensionTexts', 'assignmentFeedbackId', assignmentFeedbackId)
export const getFeedbackDimensionText = (
assignmentFeedbackId: number,
feedbackDimensionId: number,
): FeedbackDimensionText | undefined =>
getRowsWhereEquals<FeedbackDimensionText>('feedbackDimensionTexts', 'assignmentFeedbackId', assignmentFeedbackId)
.find(t => t.feedbackDimensionId === feedbackDimensionId)
export const addFeedbackDimensionText = (t: Omit<FeedbackDimensionText, 'id'>): number => insertRow('feedbackDimensionTexts', t)
export const updateFeedbackDimensionText = (id: number, patch: Partial<FeedbackDimensionText>): number =>
updateRow('feedbackDimensionTexts', id, patch)
export const listFeedbackCategoryTexts = (assignmentFeedbackId: number): FeedbackCategoryText[] =>
getRowsWhereEquals<FeedbackCategoryText>('feedbackCategoryTexts', 'assignmentFeedbackId', assignmentFeedbackId)
export const getFeedbackCategoryText = (
assignmentFeedbackId: number,
feedbackCategoryId: number,
): FeedbackCategoryText | undefined =>
getRowsWhereEquals<FeedbackCategoryText>('feedbackCategoryTexts', 'assignmentFeedbackId', assignmentFeedbackId)
.find(t => t.feedbackCategoryId === feedbackCategoryId)
export const addFeedbackCategoryText = (t: Omit<FeedbackCategoryText, 'id'>): number => insertRow('feedbackCategoryTexts', t)
export const updateFeedbackCategoryText = (id: number, patch: Partial<FeedbackCategoryText>): number =>
updateRow('feedbackCategoryTexts', id, patch)

View File

@ -0,0 +1,38 @@
import {
getAllRows, getRowById, insertRow, updateRow,
getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById, countWhereEquals,
} from '../crud'
import type { AssignmentType, PhaseTemplate } from '../../../src/db/types'
import { deleteChecklistItemDefsForPhaseTemplate } from './checklists'
export const listAssignmentTypes = (): AssignmentType[] => getAllRows<AssignmentType>('assignmentTypes')
export const getAssignmentType = (id: number): AssignmentType | undefined => getRowById<AssignmentType>('assignmentTypes', id)
export const addAssignmentType = (type: Omit<AssignmentType, 'id'>): number => insertRow('assignmentTypes', type)
export const updateAssignmentType = (id: number, patch: Partial<AssignmentType>): number =>
updateRow('assignmentTypes', id, patch)
/** Löscht nur, wenn keine Assignments mehr diesen Typ referenzieren (Cascade auf phaseTemplates sonst). */
export function deleteAssignmentType(id: number): { ok: true } | { ok: false; assignmentCount: number } {
const assignmentCount = countWhereEquals('assignments', 'assignmentTypeId', id)
if (assignmentCount > 0) return { ok: false, assignmentCount }
for (const tmpl of getRowsWhereEquals<PhaseTemplate>('phaseTemplates', 'assignmentTypeId', id)) {
deleteChecklistItemDefsForPhaseTemplate(tmpl.id!)
}
deleteRowsWhereEquals('phaseTemplates', 'assignmentTypeId', id)
deleteRowById('assignmentTypes', id)
return { ok: true }
}
export const listPhaseTemplates = (): PhaseTemplate[] => getAllRows<PhaseTemplate>('phaseTemplates')
export const getPhaseTemplate = (id: number): PhaseTemplate | undefined => getRowById<PhaseTemplate>('phaseTemplates', id)
export const listPhaseTemplatesForType = (assignmentTypeId: number): PhaseTemplate[] => {
const rows = getRowsWhereEquals<PhaseTemplate>('phaseTemplates', 'assignmentTypeId', assignmentTypeId)
return rows.sort((a, b) => a.order - b.order)
}
export const addPhaseTemplate = (tmpl: Omit<PhaseTemplate, 'id'>): number => insertRow('phaseTemplates', tmpl)
export const updatePhaseTemplate = (id: number, patch: Partial<PhaseTemplate>): number =>
updateRow('phaseTemplates', id, patch)
export function deletePhaseTemplate(id: number): void {
deleteChecklistItemDefsForPhaseTemplate(id)
deleteRowById('phaseTemplates', id)
}

View File

@ -0,0 +1,96 @@
import {
getAllRows, getRowById, getRowsWhereEquals, insertRow, updateRow,
deleteRowsWhereEquals, deleteRowsWhereIn, deleteRowById,
} from '../crud'
import type { Assignment, AssignmentType, Consultant, AssignmentFeedback } from '../../../src/db/types'
import { listMeetingInstancesForAssignment, hardDeleteMeetingInstance } from './meetings'
export const listAssignments = (): Assignment[] => getAllRows<Assignment>('assignments')
export const listAssignmentsNewestFirst = (): Assignment[] =>
getAllRows<Assignment>('assignments').sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
export const getAssignment = (id: number): Assignment | undefined => getRowById<Assignment>('assignments', id)
export const addAssignment = (a: Omit<Assignment, 'id'>): number => insertRow('assignments', a)
export const updateAssignment = (id: number, patch: Partial<Assignment>): number => updateRow('assignments', id, patch)
/** Erfüllt für's Löschen nötige Voraussetzung: jedes zugeordnete Institutee hat ein finales Feedback. */
export function isAssignmentFeedbackComplete(assignmentId: number): boolean {
const a = getRowById<Assignment>('assignments', assignmentId)
if (!a) return false
const feedbacks = getRowsWhereEquals<AssignmentFeedback>('assignmentFeedbacks', 'assignmentId', assignmentId)
return a.instituteeIds.every(iid => feedbacks.some(f => f.instituteeId === iid && f.status === 'final'))
}
export function softDeleteAssignment(id: number): number {
const a = getRowById<Assignment>('assignments', id)
if (!a) throw new Error('Assignment nicht gefunden')
if (a.status !== 'done' || !isAssignmentFeedbackComplete(id)) {
throw new Error('Löschen nur möglich, wenn das Assignment abgeschlossen ist und für alle Institutees finales Feedback vorliegt')
}
return updateRow('assignments', id, { deletedAt: new Date().toISOString() })
}
export const restoreAssignment = (id: number): number =>
updateRow('assignments', id, { deletedAt: null })
export function archiveAssignment(id: number): number {
const a = getRowById<Assignment>('assignments', id)
if (!a) throw new Error('Assignment nicht gefunden')
if (a.status !== 'done') throw new Error('Nur abgeschlossene Assignments können archiviert werden')
return updateRow('assignments', id, { archivedAt: new Date().toISOString() })
}
export const unarchiveAssignment = (id: number): number =>
updateRow('assignments', id, { archivedAt: null })
/** Cascade: meetingInstances (+ deren Kinder) → feedbackDrafts → assignmentFeedbacks (+ deren Kinder) → assignment. */
export function hardDeleteAssignment(id: number): void {
for (const m of listMeetingInstancesForAssignment(id)) hardDeleteMeetingInstance(m.id!)
deleteRowsWhereEquals('feedbackDrafts', 'assignmentId', id)
const feedbackIds = getRowsWhereEquals<AssignmentFeedback>('assignmentFeedbacks', 'assignmentId', id).map(f => f.id!)
deleteRowsWhereIn('feedbackCategoryRatings', 'assignmentFeedbackId', feedbackIds)
deleteRowsWhereIn('feedbackDimensionTexts', 'assignmentFeedbackId', feedbackIds)
deleteRowsWhereIn('feedbackCategoryTexts', 'assignmentFeedbackId', feedbackIds)
deleteRowsWhereEquals('assignmentFeedbacks', 'assignmentId', id)
deleteRowById('assignments', id)
}
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
/** Lazy-Sweep statt Cron: läuft bei jedem Dashboard-Load mit, da der Server nicht dauerhaft läuft. */
export function purgeExpiredAssignmentTrash(): void {
const now = Date.now()
for (const a of getAllRows<Assignment>('assignments')) {
if (a.deletedAt && now - new Date(a.deletedAt).getTime() > TRASH_RETENTION_MS) {
hardDeleteAssignment(a.id!)
}
}
}
export interface AssignmentWithDetails extends Assignment {
typeName: string
instituteeNames: string[]
/** Nur für status==='done' relevant: jedes Institutee hat ein finales Feedback — steuert den Löschen-Button in der Übersicht. */
feedbackComplete: boolean
}
/** Für die Dashboard-Liste: Assignments mit aufgelöstem Typ- und Institutee-Namen, neueste zuerst. */
export function listAssignmentsWithDetails(): AssignmentWithDetails[] {
purgeExpiredAssignmentTrash()
const raw = listAssignmentsNewestFirst()
const types = getAllRows<AssignmentType>('assignmentTypes')
const consultants = getAllRows<Consultant>('consultants')
const allFeedbacks = getAllRows<AssignmentFeedback>('assignmentFeedbacks')
const typeMap = new Map<number, AssignmentType>(types.map(t => [t.id!, t]))
const consMap = new Map<number, Consultant>(consultants.map(c => [c.id!, c]))
return raw.map(a => ({
...a,
typeName: typeMap.get(a.assignmentTypeId)?.name ?? '—',
instituteeNames: a.instituteeIds.map(id => {
const c = consMap.get(id)
return c ? `${c.firstName} ${c.lastName}` : '?'
}),
feedbackComplete: a.instituteeIds.every(iid =>
allFeedbacks.some(f => f.assignmentId === a.id && f.instituteeId === iid && f.status === 'final'),
),
}))
}

View File

@ -0,0 +1,45 @@
/**
* Generischer Dump/Restore aller Tabellen Server-Pendant zum bisherigen client-seitigen
* dbBackup.ts (das direkt gegen Dexie/IndexedDB lief). Iteriert exakt TABLE_DEFINITIONS aus
* tableRegistry.ts, derselben Liste, die auch das SQL-Schema erzeugt (siehe Vollständigkeits-
* Garantie im Plan) eine neue Tabelle kann dadurch nicht unbemerkt aus dem Backup fallen.
*/
import { db } from '../../database'
import { TABLE_DEFINITIONS } from '../tableRegistry'
import { getAllRows, insertRowWithId } from '../crud'
export interface BackupPayload {
version: number
exportedAt: string
tables: Record<string, Array<Record<string, unknown>>>
}
/** Liefert jede Tabelle mit dekodierten Spalten (echte Arrays/Booleans statt JSON-/Bit-Rohwerten). */
export function exportAll(): BackupPayload {
const tables: Record<string, Array<Record<string, unknown>>> = {}
for (const table of TABLE_DEFINITIONS) {
tables[table.name] = getAllRows<Record<string, unknown>>(table.name)
}
return { version: 1, exportedAt: new Date().toISOString(), tables }
}
/** Löscht alle Tabellen und fügt die übergebenen Zeilen mit ihrer ursprünglichen id wieder ein (erhält Fremdschlüssel-Referenzen). */
export function importAll(tables: Record<string, Array<Record<string, unknown>>>): void {
db.exec('BEGIN')
try {
for (const table of TABLE_DEFINITIONS) {
db.prepare(`DELETE FROM "${table.name}"`).run()
}
for (const table of TABLE_DEFINITIONS) {
for (const row of tables[table.name] ?? []) {
const { id, ...rest } = row
if (typeof id !== 'number') continue
insertRowWithId(table.name, id, rest)
}
}
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
throw err
}
}

View File

@ -0,0 +1,63 @@
import {
getAllRows, getRowsWhereEquals, getRowsWhereIn, insertRow, updateRow, deleteRowById, deleteRowsWhereEquals,
} from '../crud'
import type { ChecklistItemDef, ChecklistResponse } from '../../../src/db/types'
export const listChecklistItemDefsForPhaseTemplate = (phaseTemplateId: number): ChecklistItemDef[] => {
const rows = getRowsWhereEquals<ChecklistItemDef>('checklistItemDefs', 'phaseTemplateId', phaseTemplateId)
return rows.sort((a, b) => a.order - b.order)
}
/** Bulk-Variante für Berichte über mehrere Phasentypen hinweg (Assignment-Report-PDF, Gesamtprotokoll). */
export const listChecklistItemDefsForPhaseTemplates = (phaseTemplateIds: number[]): ChecklistItemDef[] =>
getRowsWhereIn<ChecklistItemDef>('checklistItemDefs', 'phaseTemplateId', phaseTemplateIds).sort((a, b) => a.order - b.order)
export const addChecklistItemDef = (def: Omit<ChecklistItemDef, 'id'>): number => insertRow('checklistItemDefs', def)
export const updateChecklistItemDef = (id: number, patch: Partial<ChecklistItemDef>): number =>
updateRow('checklistItemDefs', id, patch)
/** Räumt zugehörige `checklistResponses` mit auf — gleiches Verteidigungs-Prinzip wie `deleteFeedbackCriterionItem`. */
export function deleteChecklistItemDef(id: number): void {
deleteRowsWhereEquals('checklistResponses', 'checklistItemId', id)
deleteRowById('checklistItemDefs', id)
}
/** Cascade-Baustein für `deletePhaseTemplate`/`deleteAssignmentType` — löscht alle Checklisten-Punkte samt Antworten einer Phase. */
export function deleteChecklistItemDefsForPhaseTemplate(phaseTemplateId: number): void {
for (const def of listChecklistItemDefsForPhaseTemplate(phaseTemplateId)) {
deleteChecklistItemDef(def.id!)
}
}
export const listChecklistResponsesForMeeting = (meetingInstanceId: number): ChecklistResponse[] =>
getRowsWhereEquals<ChecklistResponse>('checklistResponses', 'meetingInstanceId', meetingInstanceId)
/** Bulk-Variante für Berichte über mehrere Meetings hinweg (Assignment-Report-PDF, Gesamtprotokoll). */
export const listChecklistResponsesForMeetings = (meetingInstanceIds: number[]): ChecklistResponse[] =>
getRowsWhereIn<ChecklistResponse>('checklistResponses', 'meetingInstanceId', meetingInstanceIds)
/**
* Upsert per Komposit-Schlüssel (meetingInstanceId, checklistItemId, instituteeId ?? null), gleiches
* Find-or-Insert-Muster wie `setAssessmentScore`/`saveAssessmentNote` in MeetingView.tsx. `criterionItemId`
* überschreibt pro Antwort individuell die Konfigurations-Vorgabe (`ChecklistItemDef.linkedCriterionItemId`)
* siehe CLAUDE.md Individuelle Kriterium-Zuordnung pro Checklisten-Antwort".
*/
export function upsertChecklistResponse(input: {
meetingInstanceId: number
instituteeId?: number
checklistItemId: number
rating: ChecklistResponse['rating']
note: string
criterionItemId?: number
}): number {
const existing = getAllRows<ChecklistResponse>('checklistResponses').find(r =>
r.meetingInstanceId === input.meetingInstanceId &&
r.checklistItemId === input.checklistItemId &&
(r.instituteeId ?? null) === (input.instituteeId ?? null),
)
if (existing?.id) {
updateRow('checklistResponses', existing.id, { rating: input.rating, note: input.note, criterionItemId: input.criterionItemId })
return existing.id
}
return insertRow('checklistResponses', input)
}

View File

@ -0,0 +1,12 @@
import { getAllRows, getRowById, getRowsWhereIn, insertRow, updateRow, deleteRowById } from '../crud'
import type { Group, Consultant } from '../../../src/db/types'
export const listGroups = (): Group[] => getAllRows<Group>('groups')
export const addGroup = (group: Omit<Group, 'id'>): number => insertRow('groups', group)
export const listConsultants = (): Consultant[] => getAllRows<Consultant>('consultants')
export const getConsultant = (id: number): Consultant | undefined => getRowById<Consultant>('consultants', id)
export const getConsultantsByIds = (ids: number[]): Consultant[] => getRowsWhereIn<Consultant>('consultants', 'id', ids)
export const addConsultant = (consultant: Omit<Consultant, 'id'>): number => insertRow('consultants', consultant)
export const updateConsultant = (id: number, patch: Partial<Consultant>): number => updateRow('consultants', id, patch)
export const deleteConsultant = (id: number): void => deleteRowById('consultants', id)

View File

@ -0,0 +1,19 @@
import { getAllRows, getRowsWhereEquals, getRowsWhereIn, insertRow, updateRow, deleteRowById, deleteRowsWhereEquals } from '../crud'
import type { ConversationEntry, ConversationSkillScore } from '../../../src/db/types'
export const listConversationEntriesForMeeting = (meetingInstanceId: number): ConversationEntry[] =>
getRowsWhereEquals<ConversationEntry>('conversationEntries', 'meetingInstanceId', meetingInstanceId)
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
export const listConversationEntriesForMeetings = (meetingInstanceIds: number[]): ConversationEntry[] =>
getRowsWhereIn<ConversationEntry>('conversationEntries', 'meetingInstanceId', meetingInstanceIds)
export const addConversationEntry = (e: Omit<ConversationEntry, 'id'>): number => insertRow('conversationEntries', e)
export const updateConversationEntry = (id: number, patch: Partial<ConversationEntry>): number =>
updateRow('conversationEntries', id, patch)
export const deleteConversationEntry = (id: number): void => deleteRowById('conversationEntries', id)
export const listConversationSkillScoresAll = (): ConversationSkillScore[] => getAllRows<ConversationSkillScore>('conversationSkillScores')
export const addConversationSkillScore = (s: Omit<ConversationSkillScore, 'id'>): number => insertRow('conversationSkillScores', s)
export const updateConversationSkillScore = (id: number, patch: Partial<ConversationSkillScore>): number =>
updateRow('conversationSkillScores', id, patch)
export const deleteConversationSkillScoresForEntry = (conversationEntryId: number): number =>
deleteRowsWhereEquals('conversationSkillScores', 'conversationEntryId', conversationEntryId)

View File

@ -0,0 +1,28 @@
import { getAllRows } from '../crud'
import type { ConversationEntry, MeetingInstance, ConversationLineTag } from '../../../src/db/types'
/**
* Flacht `lineTags`/`generalNoteTags` über ALLE Assignments hinweg zu einem globalen
* (Zeilentext Kriterium)-Korpus ab Grundlage für die lokale Ähnlichkeitssuche im
* selbstlernenden Kriterien-Tagging (Client, `src/utils/criterionSuggestion.ts`). Gleiches
* Iterationsmuster wie `getCriterionUsageMap()` in `feedbackStructure.ts`, hier aber die
* Rohdaten statt aggregierter Zählungen. Rückgabetyp ist bewusst `ConversationLineTag[]` (kein
* neuer Typ) jeder Korpus-Eintrag IST eine bereits vergebene Zeilen-Zuordnung, nur aus zwei
* Quellen (Gesprächsbeiträge + allgemeine Notizen) zusammengeführt.
*/
export function getGlobalLineTagCorpus(): ConversationLineTag[] {
const corpus: ConversationLineTag[] = []
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
for (const tag of e.lineTags ?? []) {
corpus.push({ text: tag.text, criterionItemId: tag.criterionItemId })
}
}
for (const m of getAllRows<MeetingInstance>('meetingInstances')) {
for (const tag of m.generalNoteTags ?? []) {
corpus.push({ text: tag.text, criterionItemId: tag.criterionItemId })
}
}
return corpus
}

View File

@ -0,0 +1,511 @@
import {
getAllRows, getRowsWhereIn, insertRow, updateRow, getRowById, insertRowWithId,
getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById,
} from '../crud'
import type {
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, FeedbackDimensionPrompt,
Assessment, ConversationEntry, MeetingInstance, Assignment, AssignmentType, PhaseTemplate, Consultant,
AppSettings,
} from '../../../src/db/types'
import { AI_CONFIG } from '../../../src/config/constants'
import { getOrSeedDefaultDimensionPromptTemplate } from './appSettings'
import {
parseFeedbackStructureMarkdown,
type FeedbackStructureSeed,
} from '../../../src/utils/feedbackStructureImport'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
export interface FeedbackStructure {
dimensions: FeedbackDimension[]
categories: FeedbackCategory[]
items: FeedbackCriterionItem[]
}
const byOrder = <T extends { order: number }>(rows: T[]) => [...rows].sort((a, b) => a.order - b.order)
/** Vollständige, nach `order` sortierte Feedback-Taxonomie. */
export function loadFeedbackStructure(): FeedbackStructure {
return {
dimensions: byOrder(getAllRows<FeedbackDimension>('feedbackDimensions')),
categories: byOrder(getAllRows<FeedbackCategory>('feedbackCategories')),
items: byOrder(getAllRows<FeedbackCriterionItem>('feedbackCriterionItems')),
}
}
export const listFeedbackCategoriesOrdered = (): FeedbackCategory[] => byOrder(getAllRows<FeedbackCategory>('feedbackCategories'))
export const listFeedbackCategoriesAll = (): FeedbackCategory[] => getAllRows<FeedbackCategory>('feedbackCategories')
export const listFeedbackCriterionItemsAll = (): FeedbackCriterionItem[] => getAllRows<FeedbackCriterionItem>('feedbackCriterionItems')
export const getFeedbackCriterionItemsByIds = (ids: number[]): FeedbackCriterionItem[] =>
getRowsWhereIn<FeedbackCriterionItem>('feedbackCriterionItems', 'id', ids)
export interface CriterionUsage {
assessmentCount: number
lineTagCount: number
generalNoteTagCount: number
assignmentTypeNames: string[]
phaseTemplateLabels: string[]
meetings: { meetingId: number; assignmentId: number; assignmentTitle: string; date: string }[]
}
/**
* Verwendungs-Übersicht für ALLE Kriterien in einem Durchlauf (nicht pro Kriterium einzeln)
* macht sichtbar, wo ein Kriterium schon referenziert ist, bevor man es beim Umbau der
* Feedback-Struktur umbenennt/verschiebt/löscht. Siehe CLAUDE.md.
*/
export function getCriterionUsageMap(): Record<number, CriterionUsage> {
const usage = new Map<number, CriterionUsage>()
const ensure = (id: number): CriterionUsage => {
let u = usage.get(id)
if (!u) {
u = { assessmentCount: 0, lineTagCount: 0, generalNoteTagCount: 0, assignmentTypeNames: [], phaseTemplateLabels: [], meetings: [] }
usage.set(id, u)
}
return u
}
const meetings = getAllRows<MeetingInstance>('meetingInstances')
const meetingMap = new Map(meetings.map(m => [m.id!, m]))
const assignments = getAllRows<Assignment>('assignments')
const assignmentMap = new Map(assignments.map(a => [a.id!, a]))
const addMeetingRef = (u: CriterionUsage, meetingId: number) => {
if (u.meetings.some(x => x.meetingId === meetingId)) return
const m = meetingMap.get(meetingId)
if (!m) return
const a = assignmentMap.get(m.assignmentId)
u.meetings.push({ meetingId, assignmentId: m.assignmentId, assignmentTitle: a?.title ?? '—', date: m.date })
}
for (const a of getAllRows<Assessment>('assessments')) {
const u = ensure(a.criteriaId)
u.assessmentCount++
addMeetingRef(u, a.meetingInstanceId)
}
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
for (const tag of e.lineTags ?? []) {
const u = ensure(tag.criterionItemId)
u.lineTagCount++
addMeetingRef(u, e.meetingInstanceId)
}
}
for (const m of meetings) {
for (const tag of m.generalNoteTags ?? []) {
const u = ensure(tag.criterionItemId)
u.generalNoteTagCount++
addMeetingRef(u, m.id!)
}
}
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
for (const cid of at.criteriaIds ?? []) ensure(cid).assignmentTypeNames.push(at.name)
}
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
for (const cid of pt.criteriaIds ?? []) ensure(cid).phaseTemplateLabels.push(pt.label)
}
return Object.fromEntries(usage)
}
export type CriterionUsageInstance =
| { kind: 'assessment'; assessmentId: number; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
| { kind: 'lineTag'; conversationEntryId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
| { kind: 'generalNoteTag'; meetingInstanceId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
| { kind: 'assignmentTypeVisibility'; assignmentTypeId: number; criterionId: number; text: string }
| { kind: 'phaseTemplateVisibility'; phaseTemplateId: number; criterionId: number; text: string }
/**
* Einzelinstanzen-Liste für EIN Kriterium (im Unterschied zu `getCriterionUsageMap`, das nur
* Zählungen für ALLE Kriterien liefert) Grundlage dafür, eine einzelne Zuordnung direkt
* umzuhängen oder zu entfernen, ohne dafür ins jeweilige Meeting navigieren zu müssen.
* Bewusst lazy/pro-Kriterium (nicht eager für alle), da hier echte Texte/Namen aufgelöst werden.
*/
export function getCriterionUsageDetails(criterionId: number): CriterionUsageInstance[] {
const instances: CriterionUsageInstance[] = []
const meetings = getAllRows<MeetingInstance>('meetingInstances')
const meetingMap = new Map(meetings.map(m => [m.id!, m]))
const assignments = getAllRows<Assignment>('assignments')
const assignmentMap = new Map(assignments.map(a => [a.id!, a]))
const consultants = getAllRows<Consultant>('consultants')
const consultantMap = new Map(consultants.map(c => [c.id!, c]))
const meetingInfo = (meetingId: number) => {
const m = meetingMap.get(meetingId)
const a = m ? assignmentMap.get(m.assignmentId) : undefined
return { meetingId, assignmentTitle: a?.title ?? '—', date: m?.date ?? '' }
}
const personLabel = (instituteeId: number | undefined) => {
if (instituteeId === undefined) return 'Allgemein'
const c = consultantMap.get(instituteeId)
return c ? `${c.firstName} ${c.lastName}` : '—'
}
for (const a of getAllRows<Assessment>('assessments')) {
if (a.criteriaId !== criterionId) continue
instances.push({
kind: 'assessment', assessmentId: a.id!, personLabel: personLabel(a.instituteeId),
text: `Score: ${a.score ?? '—'}${a.note ? ` · Notiz: ${a.note}` : ''}`,
...meetingInfo(a.meetingInstanceId),
})
}
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
for (const tag of e.lineTags ?? []) {
if (tag.criterionItemId !== criterionId) continue
instances.push({
kind: 'lineTag', conversationEntryId: e.id!, tagText: tag.text, personLabel: personLabel(e.instituteeId),
text: tag.text,
...meetingInfo(e.meetingInstanceId),
})
}
}
for (const m of meetings) {
for (const tag of m.generalNoteTags ?? []) {
if (tag.criterionItemId !== criterionId) continue
instances.push({
kind: 'generalNoteTag', meetingInstanceId: m.id!, tagText: tag.text, personLabel: personLabel(undefined),
text: tag.text,
...meetingInfo(m.id!),
})
}
}
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
if (!at.criteriaIds?.includes(criterionId)) continue
instances.push({ kind: 'assignmentTypeVisibility', assignmentTypeId: at.id!, criterionId, text: at.name })
}
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
if (!pt.criteriaIds?.includes(criterionId)) continue
instances.push({ kind: 'phaseTemplateVisibility', phaseTemplateId: pt.id!, criterionId, text: pt.label })
}
return instances
}
/** Hängt EINE einzelne Verwendungsinstanz auf ein anderes Kriterium um (Konsolidieren ohne den ganzen Rest mitzuziehen). */
export function reassignUsageInstance(instance: CriterionUsageInstance, toId: number): { merged: boolean } {
if (instance.kind === 'assessment') {
const allAssessments = getAllRows<Assessment>('assessments')
const row = allAssessments.find(a => a.id === instance.assessmentId)
if (!row) return { merged: false }
return { merged: mergeOrReassignAssessment(row, toId, allAssessments) }
}
if (instance.kind === 'lineTag') {
const e = getAllRows<ConversationEntry>('conversationEntries').find(x => x.id === instance.conversationEntryId)
if (!e?.lineTags) return { merged: false }
const lineTags = e.lineTags.map(t => t.text === instance.tagText ? { ...t, criterionItemId: toId } : t)
updateRow('conversationEntries', e.id!, { lineTags })
return { merged: false }
}
if (instance.kind === 'generalNoteTag') {
const m = getAllRows<MeetingInstance>('meetingInstances').find(x => x.id === instance.meetingInstanceId)
if (!m?.generalNoteTags) return { merged: false }
const generalNoteTags = m.generalNoteTags.map(t => t.text === instance.tagText ? { ...t, criterionItemId: toId } : t)
updateRow('meetingInstances', m.id!, { generalNoteTags })
return { merged: false }
}
if (instance.kind === 'assignmentTypeVisibility') {
const at = getAllRows<AssignmentType>('assignmentTypes').find(x => x.id === instance.assignmentTypeId)
if (!at?.criteriaIds) return { merged: false }
const criteriaIds = [...new Set(at.criteriaIds.map(id => id === instance.criterionId ? toId : id))]
updateRow('assignmentTypes', at.id!, { criteriaIds })
return { merged: false }
}
// phaseTemplateVisibility
const pt = getAllRows<PhaseTemplate>('phaseTemplates').find(x => x.id === instance.phaseTemplateId)
if (!pt?.criteriaIds) return { merged: false }
const criteriaIds = [...new Set(pt.criteriaIds.map(id => id === instance.criterionId ? toId : id))]
updateRow('phaseTemplates', pt.id!, { criteriaIds })
return { merged: false }
}
/** Entfernt EINE einzelne Verwendungsinstanz (Assessment wird gelöscht, Tags nur von der Kriterium-Zuordnung befreit, Text bleibt). */
export function removeUsageInstance(instance: CriterionUsageInstance): void {
if (instance.kind === 'assessment') {
deleteRowById('assessments', instance.assessmentId)
return
}
if (instance.kind === 'lineTag') {
const e = getAllRows<ConversationEntry>('conversationEntries').find(x => x.id === instance.conversationEntryId)
if (!e?.lineTags) return
updateRow('conversationEntries', e.id!, { lineTags: e.lineTags.filter(t => t.text !== instance.tagText) })
return
}
if (instance.kind === 'generalNoteTag') {
const m = getAllRows<MeetingInstance>('meetingInstances').find(x => x.id === instance.meetingInstanceId)
if (!m?.generalNoteTags) return
updateRow('meetingInstances', m.id!, { generalNoteTags: m.generalNoteTags.filter(t => t.text !== instance.tagText) })
return
}
if (instance.kind === 'assignmentTypeVisibility') {
const at = getAllRows<AssignmentType>('assignmentTypes').find(x => x.id === instance.assignmentTypeId)
if (!at?.criteriaIds) return
updateRow('assignmentTypes', at.id!, { criteriaIds: at.criteriaIds.filter(id => id !== instance.criterionId) })
return
}
// phaseTemplateVisibility
const pt = getAllRows<PhaseTemplate>('phaseTemplates').find(x => x.id === instance.phaseTemplateId)
if (!pt?.criteriaIds) return
updateRow('phaseTemplates', pt.id!, { criteriaIds: pt.criteriaIds.filter(id => id !== instance.criterionId) })
}
/**
* Schreibt EINE Assessment-Zeile auf `toId` um. Konfliktfall: existiert für dieselbe
* (meetingInstanceId, instituteeId) bereits eine Zeile mit criteriaId=toId (keine DB-Unique-
* Constraint verhindert das), wird `row` stattdessen in die bestehende Zeile gemergt (Notizen
* zusammengeführt, vorhandener Score der Zielzeile hat Vorrang) statt eine Dubletten-Zeile zu
* erzeugen. Von `reassignCriterionReferences` (alle Zeilen) UND `reassignUsageInstance` (eine
* einzelne Zeile) genutzt, damit die Konflikt-Logik nicht doppelt gepflegt werden muss.
*/
function mergeOrReassignAssessment(row: Assessment, toId: number, allAssessments: Assessment[]): boolean {
const conflict = allAssessments.find(a =>
a.id !== row.id && a.criteriaId === toId && a.meetingInstanceId === row.meetingInstanceId && a.instituteeId === row.instituteeId,
)
if (conflict) {
const mergedNote = [conflict.note, row.note].filter(n => n?.trim()).join('\n')
updateRow('assessments', conflict.id!, { note: mergedNote, score: conflict.score ?? row.score })
deleteRowById('assessments', row.id!)
return true
}
updateRow('assessments', row.id!, { criteriaId: toId })
return false
}
/**
* Überträgt alle Referenzen von `fromId` auf `toId` (Assessments, Protokoll-/Notiz-Tags,
* Sichtbarkeits-Listen) für sicheres Löschen mit Zusammenführung statt Datenverlust.
*/
export function reassignCriterionReferences(fromId: number, toId: number): { mergedConflicts: number } {
let mergedConflicts = 0
const allAssessments = getAllRows<Assessment>('assessments')
for (const row of allAssessments.filter(a => a.criteriaId === fromId)) {
if (mergeOrReassignAssessment(row, toId, allAssessments)) mergedConflicts++
}
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
if (!e.lineTags?.some(t => t.criterionItemId === fromId)) continue
const lineTags = e.lineTags.map(t => t.criterionItemId === fromId ? { ...t, criterionItemId: toId } : t)
updateRow('conversationEntries', e.id!, { lineTags })
}
for (const m of getAllRows<MeetingInstance>('meetingInstances')) {
if (!m.generalNoteTags?.some(t => t.criterionItemId === fromId)) continue
const generalNoteTags = m.generalNoteTags.map(t => t.criterionItemId === fromId ? { ...t, criterionItemId: toId } : t)
updateRow('meetingInstances', m.id!, { generalNoteTags })
}
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
if (!at.criteriaIds?.includes(fromId)) continue
const criteriaIds = [...new Set(at.criteriaIds.map(id => id === fromId ? toId : id))]
updateRow('assignmentTypes', at.id!, { criteriaIds })
}
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
if (!pt.criteriaIds?.includes(fromId)) continue
const criteriaIds = [...new Set(pt.criteriaIds.map(id => id === fromId ? toId : id))]
updateRow('phaseTemplates', pt.id!, { criteriaIds })
}
return { mergedConflicts }
}
/** Entfernt alle Referenzen auf `id` (Assessments werden gelöscht, Tags/Sichtbarkeitslisten nur von der ID befreit). */
function clearCriterionReferences(id: number): void {
deleteRowsWhereEquals('assessments', 'criteriaId', id)
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
if (!e.lineTags?.some(t => t.criterionItemId === id)) continue
updateRow('conversationEntries', e.id!, { lineTags: e.lineTags.filter(t => t.criterionItemId !== id) })
}
for (const m of getAllRows<MeetingInstance>('meetingInstances')) {
if (!m.generalNoteTags?.some(t => t.criterionItemId === id)) continue
updateRow('meetingInstances', m.id!, { generalNoteTags: m.generalNoteTags.filter(t => t.criterionItemId !== id) })
}
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
if (!at.criteriaIds?.includes(id)) continue
updateRow('assignmentTypes', at.id!, { criteriaIds: at.criteriaIds.filter(cid => cid !== id) })
}
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
if (!pt.criteriaIds?.includes(id)) continue
updateRow('phaseTemplates', pt.id!, { criteriaIds: pt.criteriaIds.filter(cid => cid !== id) })
}
}
/** Legt zusätzlich einen Default-KI-Prompt für die neue Dimension an, siehe CLAUDE.md „Mehrstufiges Prompt-System". */
export function addFeedbackDimension(d: Omit<FeedbackDimension, 'id'>): number {
const id = insertRow('feedbackDimensions', d)
insertRow('feedbackDimensionPrompts', { dimensionId: id, promptTemplate: getOrSeedDefaultDimensionPromptTemplate() })
return id
}
export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): number =>
updateRow('feedbackDimensions', id, patch)
/** Cascade: Items der Kategorien (über deleteFeedbackCriterionItem, räumt Verwendungen mit auf) → Kategorien → Prompt-Konfiguration → Dimension. */
export function deleteFeedbackDimension(id: number): void {
const cats = getRowsWhereEquals<FeedbackCategory>('feedbackCategories', 'dimensionId', id)
for (const cat of cats) {
const catItems = getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', cat.id!)
for (const item of catItems) deleteFeedbackCriterionItem(item.id!)
}
deleteRowsWhereEquals('feedbackCategories', 'dimensionId', id)
deleteRowsWhereEquals('feedbackDimensionPrompts', 'dimensionId', id)
deleteRowById('feedbackDimensions', id)
}
/**
* Selbstheilend: Dimensionen, die vor Einführung des Prompt-Systems (2026-07-07) angelegt wurden,
* haben noch keine `feedbackDimensionPrompts`-Zeile wird hier per Backfill nachgezogen, statt eine
* separate Migration zu brauchen.
*/
export function listFeedbackDimensionPrompts(): FeedbackDimensionPrompt[] {
const dims = getAllRows<FeedbackDimension>('feedbackDimensions')
const existingDimIds = new Set(getAllRows<FeedbackDimensionPrompt>('feedbackDimensionPrompts').map(p => p.dimensionId))
const missing = dims.filter(dim => !existingDimIds.has(dim.id!))
if (missing.length > 0) {
const defaultTemplate = getOrSeedDefaultDimensionPromptTemplate()
for (const dim of missing) {
insertRow('feedbackDimensionPrompts', { dimensionId: dim.id!, promptTemplate: defaultTemplate })
}
}
return getAllRows<FeedbackDimensionPrompt>('feedbackDimensionPrompts')
}
/** Selbstheilend wie `listFeedbackDimensionPrompts` — legt die Zeile bei Bedarf lazy an. */
export function getFeedbackDimensionPrompt(dimensionId: number): FeedbackDimensionPrompt {
const existing = getRowsWhereEquals<FeedbackDimensionPrompt>('feedbackDimensionPrompts', 'dimensionId', dimensionId)[0]
if (existing) return existing
const promptTemplate = getOrSeedDefaultDimensionPromptTemplate()
const id = insertRow('feedbackDimensionPrompts', { dimensionId, promptTemplate })
return { id, dimensionId, promptTemplate }
}
export const updateFeedbackDimensionPrompt = (id: number, patch: Partial<FeedbackDimensionPrompt>): number =>
updateRow('feedbackDimensionPrompts', id, patch)
/** Setzt alle Dimension-Prompts auf den aktuellen Code-Standard (AI_CONFIG) und synchronisiert appSettings. */
export function resetAllDimensionPromptsToDefault(): { updated: number } {
const template = AI_CONFIG.defaultDimensionPromptTemplate
const settings = getRowById<AppSettings>('appSettings', 1)
if (settings) updateRow('appSettings', 1, { defaultDimensionPromptTemplate: template })
else insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: 0.5,
defaultDimensionPromptTemplate: template,
})
const dims = getAllRows<FeedbackDimension>('feedbackDimensions')
let updated = 0
for (const dim of dims) {
const existing = getRowsWhereEquals<FeedbackDimensionPrompt>('feedbackDimensionPrompts', 'dimensionId', dim.id!)[0]
if (existing?.id) {
updateRow('feedbackDimensionPrompts', existing.id, { promptTemplate: template })
} else {
insertRow('feedbackDimensionPrompts', { dimensionId: dim.id!, promptTemplate: template })
}
updated++
}
return { updated }
}
export const addFeedbackCategory = (c: Omit<FeedbackCategory, 'id'>): number => insertRow('feedbackCategories', c)
export const updateFeedbackCategory = (id: number, patch: Partial<FeedbackCategory>): number =>
updateRow('feedbackCategories', id, patch)
/** Cascade: Items der Kategorie → Kategorie. */
export function deleteFeedbackCategory(id: number): void {
const catItems = getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', id)
for (const item of catItems) deleteFeedbackCriterionItem(item.id!)
deleteRowById('feedbackCategories', id)
}
export const addFeedbackCriterionItem = (i: Omit<FeedbackCriterionItem, 'id'>): number => insertRow('feedbackCriterionItems', i)
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): number =>
updateRow('feedbackCriterionItems', id, patch)
/**
* Räumt vor dem eigentlichen Löschen selbst alle Verwendungen auf (Verteidigung in der Tiefe,
* unabhängig davon ob die UI vorher per reassignCriterionReferences umgehängt hat) siehe
* clearCriterionReferences() oben. Dadurch kann nach dem Löschen nie eine verwaiste Referenz
* übrig bleiben, egal über welchen Aufrufpfad (auch Kategorie-/Dimension-Cascade).
*/
export function deleteFeedbackCriterionItem(id: number): void {
clearCriterionReferences(id)
deleteRowById('feedbackCriterionItems', id)
}
export interface ReplaceFeedbackStructureResult {
dimensions: number
categories: number
items: number
clearedDimensions: number
}
/**
* Ersetzt die gesamte Feedback-Struktur durch ein Seed-Objekt (MD-Import / Preload).
* Löscht bestehende Dimensionen inkl. Cascade (Kategorien, Kriterien, Verwendungen, Prompts).
*/
export function replaceFeedbackStructure(seed: FeedbackStructureSeed): ReplaceFeedbackStructureResult {
if (!seed?.dimensions?.length) throw new Error('Seed enthält keine Dimensionen.')
const existing = getAllRows<FeedbackDimension>('feedbackDimensions')
for (const dim of existing) deleteFeedbackDimension(dim.id!)
let categoryCount = 0
let itemCount = 0
for (let di = 0; di < seed.dimensions.length; di++) {
const dimSeed = seed.dimensions[di]
const dimId = addFeedbackDimension({ name: dimSeed.name, order: di + 1 })
for (let ci = 0; ci < dimSeed.categories.length; ci++) {
const catSeed = dimSeed.categories[ci]
const catId = addFeedbackCategory({
dimensionId: dimId,
name: catSeed.name,
order: ci + 1,
description: catSeed.description,
})
categoryCount++
for (let ii = 0; ii < catSeed.items.length; ii++) {
const itemSeed = catSeed.items[ii]
addFeedbackCriterionItem({
categoryId: catId,
name: itemSeed.name,
order: ii + 1,
weight: itemSeed.weight,
description: itemSeed.description,
})
itemCount++
}
}
}
return {
dimensions: seed.dimensions.length,
categories: categoryCount,
items: itemCount,
clearedDimensions: existing.length,
}
}
/** Parst Markdown (Export-Format) und ersetzt die Struktur. */
export function importFeedbackStructureFromMarkdown(md: string): ReplaceFeedbackStructureResult {
return replaceFeedbackStructure(parseFeedbackStructureMarkdown(md))
}
/**
* Lädt die finale Struktur aus docs/preload/Feedback-Struktur-final.md (DEV-Preload).
* Pfad relativ zum Projektroot nur innerhalb AssigmentMonitorV2.
*/
export function preloadFinalFeedbackStructure(): ReplaceFeedbackStructureResult {
const projectRoot = resolve(join(__dirname, '../../..'))
const mdPath = resolve(join(projectRoot, 'docs', 'preload', 'Feedback-Struktur-final.md'))
const rootNorm = projectRoot.toLowerCase()
const pathNorm = mdPath.toLowerCase()
if (!pathNorm.startsWith(rootNorm)) {
throw new Error(`[preload] SAFETY: Preload-Datei außerhalb des V2-Projekts: ${mdPath}`)
}
if (!existsSync(mdPath)) {
throw new Error(`Preload-Datei fehlt: ${mdPath}`)
}
const md = readFileSync(mdPath, 'utf8')
return importFeedbackStructureFromMarkdown(md)
}

View File

@ -0,0 +1,47 @@
import {
deleteRowsWhereEquals,
getAllRows,
getRowsWhereEquals,
getRowsWhereIn,
insertRow,
updateRow,
} from '../crud'
import type { MeetingCategoryRating, RatingScore } from '../../../src/db/types'
export const listMeetingCategoryRatingsForMeeting = (meetingInstanceId: number): MeetingCategoryRating[] =>
getRowsWhereEquals<MeetingCategoryRating>('meetingCategoryRatings', 'meetingInstanceId', meetingInstanceId)
export const listMeetingCategoryRatingsForMeetings = (meetingInstanceIds: number[]): MeetingCategoryRating[] =>
getRowsWhereIn<MeetingCategoryRating>('meetingCategoryRatings', 'meetingInstanceId', meetingInstanceIds)
export function upsertMeetingCategoryRating(input: {
meetingInstanceId: number
instituteeId: number
categoryId: number
score: RatingScore | null
note?: string
}): number {
const existing = getAllRows<MeetingCategoryRating>('meetingCategoryRatings').find(r =>
r.meetingInstanceId === input.meetingInstanceId &&
r.instituteeId === input.instituteeId &&
r.categoryId === input.categoryId,
)
if (existing?.id != null) {
updateRow('meetingCategoryRatings', existing.id, {
score: input.score,
note: input.note ?? existing.note ?? '',
})
return existing.id
}
return insertRow('meetingCategoryRatings', {
meetingInstanceId: input.meetingInstanceId,
instituteeId: input.instituteeId,
categoryId: input.categoryId,
score: input.score,
note: input.note ?? '',
})
}
export const deleteMeetingCategoryRatingsForMeeting = (meetingInstanceId: number): void => {
deleteRowsWhereEquals('meetingCategoryRatings', 'meetingInstanceId', meetingInstanceId)
}

View File

@ -0,0 +1,83 @@
import {
getAllRows, getRowById, getRowsWhereEquals, insertRow, updateRow,
deleteRowsWhereEquals, deleteRowsWhereIn, deleteRowById,
} from '../crud'
import type {
MeetingInstance, PhaseTemplate, Assignment, Consultant,
ConversationEntry, ConversationSkillScore, Assessment,
ChecklistItemDef, ChecklistResponse, MeetingCategoryRating,
} from '../../../src/db/types'
import { getConsultantsByIds } from './consultants'
import { listChecklistItemDefsForPhaseTemplate, listChecklistResponsesForMeeting } from './checklists'
import { listMeetingCategoryRatingsForMeeting } from './meetingCategoryRatings'
export const getMeetingInstance = (id: number): MeetingInstance | undefined => getRowById<MeetingInstance>('meetingInstances', id)
export const listMeetingInstancesForAssignment = (assignmentId: number): MeetingInstance[] =>
getRowsWhereEquals<MeetingInstance>('meetingInstances', 'assignmentId', assignmentId)
/**
* Kanonischer Filter für Aggregationen über mehrere Meetings: Papierkorb-/unfertige Meetings ausschließen,
* chronologisch sortiert. Siehe CLAUDE.md immer diese Funktion nutzen, nicht erneut inline filtern.
*/
export function listDoneMeetingsChronological(assignmentId: number): MeetingInstance[] {
const instances = listMeetingInstancesForAssignment(assignmentId)
return instances
.filter(m => !m.deletedAt && m.status === 'done')
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
}
export const addMeetingInstance = (m: Omit<MeetingInstance, 'id'>): number => insertRow('meetingInstances', m)
export const updateMeetingInstance = (id: number, patch: Partial<MeetingInstance>): number =>
updateRow('meetingInstances', id, patch)
export const softDeleteMeetingInstance = (id: number): number =>
updateRow('meetingInstances', id, { deletedAt: new Date().toISOString() })
export const restoreMeetingInstance = (id: number): number =>
updateRow('meetingInstances', id, { deletedAt: null })
/** Cascade: conversationSkillScores der Entries → conversationEntries → assessments → meetingCategoryRatings → meetingInstance. */
export function hardDeleteMeetingInstance(id: number): void {
const entryIds = getRowsWhereEquals<ConversationEntry>('conversationEntries', 'meetingInstanceId', id).map(e => e.id!)
deleteRowsWhereIn('conversationSkillScores', 'conversationEntryId', entryIds)
deleteRowsWhereEquals('conversationEntries', 'meetingInstanceId', id)
deleteRowsWhereEquals('assessments', 'meetingInstanceId', id)
deleteRowsWhereEquals('meetingCategoryRatings', 'meetingInstanceId', id)
deleteRowsWhereEquals('checklistResponses', 'meetingInstanceId', id)
deleteRowById('meetingInstances', id)
}
export interface MeetingExportData {
meeting: MeetingInstance
template: PhaseTemplate | undefined
assignment: Assignment | undefined
consultants: Consultant[]
entries: ConversationEntry[]
allScores: ConversationSkillScore[]
assessments: Assessment[]
categoryRatings: MeetingCategoryRating[]
checklistItems: ChecklistItemDef[]
checklistResponses: ChecklistResponse[]
}
/** Gebündelte Rohdaten für Markdown-/JSON-Export eines Meetings — vorher in meetingExport.ts dupliziert. */
export function getMeetingExportData(meetingId: number): MeetingExportData {
const meeting = getRowById<MeetingInstance>('meetingInstances', meetingId)
if (!meeting) throw new Error('Meeting nicht gefunden')
const template = getRowById<PhaseTemplate>('phaseTemplates', meeting.phaseTemplateId)
const assignment = getRowById<Assignment>('assignments', meeting.assignmentId)
const instituteeIds = assignment?.instituteeIds ?? []
const consultants = getConsultantsByIds(instituteeIds)
const entries = getRowsWhereEquals<ConversationEntry>('conversationEntries', 'meetingInstanceId', meetingId)
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
const allScores = getAllRows<ConversationSkillScore>('conversationSkillScores')
const assessments = getRowsWhereEquals<Assessment>('assessments', 'meetingInstanceId', meetingId)
const categoryRatings = listMeetingCategoryRatingsForMeeting(meetingId)
const checklistItems = template?.hasChecklist ? listChecklistItemDefsForPhaseTemplate(template.id!) : []
const checklistResponses = template?.hasChecklist ? listChecklistResponsesForMeeting(meetingId) : []
return {
meeting, template, assignment, consultants, entries, allScores,
assessments, categoryRatings, checklistItems, checklistResponses,
}
}

207
server/db/tableRegistry.ts Normal file
View File

@ -0,0 +1,207 @@
/**
* Einzige Quelle der Wahrheit für alle SQL-Tabellen treibt sowohl die Schema-Erstellung
* (database.ts) als auch Export/Import (queries/backup.ts) an. Eine neue Tabelle, die hier
* fehlt, fehlt dadurch gleichzeitig im Schema UND im Backup sichtbarer Fehler statt eines
* Datenverlusts, der erst beim nächsten Restore aus dem Backup auffällt (siehe CLAUDE.md:
* appSettings fehlte einmal in TABLE_NAMES).
*
* 1:1 abgeleitet aus TABLE_NAMES (src/utils/dbBackup.ts) und den Interfaces in src/db/types.ts.
*/
export type ColumnCodec = 'json' | 'bool' | 'plain'
export interface ColumnDef {
name: string
sqlType: 'INTEGER' | 'TEXT' | 'REAL'
codec: ColumnCodec
}
export interface TableDef {
name: string
columns: ColumnDef[]
}
const col = (name: string, sqlType: ColumnDef['sqlType'], codec: ColumnCodec = 'plain'): ColumnDef =>
({ name, sqlType, codec })
export const TABLE_DEFINITIONS: TableDef[] = [
{ name: 'categories', columns: [
col('name', 'TEXT'),
col('order', 'INTEGER'),
]},
{ name: 'criteria', columns: [
col('categoryId', 'INTEGER'),
col('name', 'TEXT'),
col('description', 'TEXT'),
col('weight', 'REAL'),
col('order', 'INTEGER'),
]},
{ name: 'assignmentTypes', columns: [
col('name', 'TEXT'),
col('defaultPhaseKeys', 'TEXT', 'json'),
col('criteriaIds', 'TEXT', 'json'),
]},
{ name: 'phaseTemplates', columns: [
col('assignmentTypeId', 'INTEGER'),
col('phaseKey', 'TEXT'),
col('label', 'TEXT'),
col('withWhom', 'TEXT'),
col('isRepeatable', 'INTEGER', 'bool'),
col('hasConversation', 'INTEGER', 'bool'),
col('hasAssessment', 'INTEGER', 'bool'),
col('hasChecklist', 'INTEGER', 'bool'),
col('criteriaIds', 'TEXT', 'json'),
col('order', 'INTEGER'),
]},
{ name: 'groups', columns: [
col('name', 'TEXT'),
col('cohortYear', 'INTEGER'),
]},
{ name: 'consultants', columns: [
col('groupId', 'INTEGER'),
col('firstName', 'TEXT'),
col('lastName', 'TEXT'),
]},
{ name: 'assignments', columns: [
col('title', 'TEXT'),
col('client', 'TEXT'),
col('assignmentTypeId', 'INTEGER'),
col('instituteeIds', 'TEXT', 'json'),
col('leadName', 'TEXT'),
col('status', 'TEXT'),
col('createdAt', 'TEXT'),
col('assignmentNumber', 'TEXT'),
col('description', 'TEXT'),
col('instituteeEnrollments', 'TEXT', 'json'),
col('deletedAt', 'TEXT'),
col('archivedAt', 'TEXT'),
]},
{ name: 'meetingInstances', columns: [
col('assignmentId', 'INTEGER'),
col('phaseTemplateId', 'INTEGER'),
col('phaseIndex', 'INTEGER'),
col('date', 'TEXT'),
col('status', 'TEXT'),
col('generalNotes', 'TEXT'),
col('deletedAt', 'TEXT'),
col('generalNoteTags', 'TEXT', 'json'),
]},
{ name: 'conversationEntries', columns: [
col('meetingInstanceId', 'INTEGER'),
col('instituteeId', 'INTEGER'),
col('sequenceIndex', 'INTEGER'),
col('note', 'TEXT'),
col('fillerCount', 'INTEGER'),
col('updatedAt', 'TEXT'),
col('lineTags', 'TEXT', 'json'),
]},
{ name: 'conversationSkillScores', columns: [
col('conversationEntryId', 'INTEGER'),
col('criteriaId', 'INTEGER'),
col('score', 'INTEGER'),
]},
{ name: 'assessments', columns: [
col('meetingInstanceId', 'INTEGER'),
col('instituteeId', 'INTEGER'),
col('criteriaId', 'INTEGER'),
col('score', 'INTEGER'),
col('note', 'TEXT'),
col('updatedAt', 'TEXT'),
]},
{ name: 'meetingCategoryRatings', columns: [
col('meetingInstanceId', 'INTEGER'),
col('instituteeId', 'INTEGER'),
col('categoryId', 'INTEGER'),
col('score', 'INTEGER'),
col('note', 'TEXT'),
]},
{ name: 'feedbackDrafts', columns: [
col('assignmentId', 'INTEGER'),
col('instituteeId', 'INTEGER'),
col('content', 'TEXT'),
col('updatedAt', 'TEXT'),
]},
{ name: 'feedbackDimensions', columns: [
col('name', 'TEXT'),
col('order', 'INTEGER'),
]},
{ name: 'feedbackCategories', columns: [
col('dimensionId', 'INTEGER'),
col('name', 'TEXT'),
col('order', 'INTEGER'),
col('description', 'TEXT'),
]},
{ name: 'feedbackCriterionItems', columns: [
col('categoryId', 'INTEGER'),
col('name', 'TEXT'),
col('weight', 'REAL'),
col('order', 'INTEGER'),
col('description', 'TEXT'),
col('learnedPatternHints', 'TEXT'),
]},
{ name: 'assignmentFeedbacks', columns: [
col('assignmentId', 'INTEGER'),
col('instituteeId', 'INTEGER'),
col('status', 'TEXT'),
col('aiModel', 'TEXT'),
col('updatedAt', 'TEXT'),
]},
{ name: 'feedbackCategoryRatings', columns: [
col('assignmentFeedbackId', 'INTEGER'),
col('feedbackCategoryId', 'INTEGER'),
col('rating', 'INTEGER'),
]},
{ name: 'feedbackDimensionTexts', columns: [
col('assignmentFeedbackId', 'INTEGER'),
col('feedbackDimensionId', 'INTEGER'),
col('achievements', 'TEXT'),
col('developmentNeeds', 'TEXT'),
col('suggestedAchievements', 'TEXT'),
col('suggestedDevelopmentNeeds', 'TEXT'),
]},
{ name: 'feedbackDimensionPrompts', columns: [
col('dimensionId', 'INTEGER'),
col('promptTemplate', 'TEXT'),
]},
{ name: 'feedbackCategoryTexts', columns: [
col('assignmentFeedbackId', 'INTEGER'),
col('feedbackCategoryId', 'INTEGER'),
col('benefits', 'TEXT'),
col('concerns', 'TEXT'),
col('suggestedBenefits', 'TEXT'),
col('suggestedConcerns', 'TEXT'),
]},
{ name: 'criterionCategoryMappings', columns: [
col('criterionId', 'INTEGER'),
col('feedbackCategoryId', 'INTEGER'),
]},
{ name: 'criterionLevelDescriptions', columns: [
col('criterionItemId', 'INTEGER'),
col('rating', 'INTEGER'),
col('text', 'TEXT'),
]},
{ name: 'appSettings', columns: [
col('ratingTrendWeightStep', 'REAL'),
col('defaultDimensionPromptTemplate', 'TEXT'),
col('defaultCategoryPromptTemplate', 'TEXT'),
col('criterionTaggingPromptTemplate', 'TEXT'),
col('ratingColorBands', 'TEXT', 'json'),
]},
{ name: 'checklistItemDefs', columns: [
col('phaseTemplateId', 'INTEGER'),
col('label', 'TEXT'),
col('linkedCriterionItemId', 'INTEGER'),
col('scope', 'TEXT'),
col('order', 'INTEGER'),
]},
{ name: 'checklistResponses', columns: [
col('meetingInstanceId', 'INTEGER'),
col('instituteeId', 'INTEGER'),
col('checklistItemId', 'INTEGER'),
col('rating', 'INTEGER'),
col('note', 'TEXT'),
col('criterionItemId', 'INTEGER'),
]},
]
export const TABLE_NAMES = TABLE_DEFINITIONS.map(t => t.name)

38
server/index.ts Normal file
View File

@ -0,0 +1,38 @@
import express from 'express'
import './database'
import { rpcModules } from './rpcModules'
const app = express()
app.use(express.json({ limit: '50mb' })) // Backup-Import kann alle Tabellen als ein JSON-Body enthalten
app.post('/api/rpc', (req, res) => {
const { module, fn, args } = req.body as { module?: string; fn?: string; args?: unknown[] }
const mod = module ? rpcModules[module] : undefined
const handler = fn ? mod?.[fn] : undefined
if (!mod || typeof handler !== 'function') {
res.json({ ok: false, error: `Unbekannte RPC-Zielfunktion: ${module}.${fn}` })
return
}
try {
const result = handler(...(args ?? []))
res.json({ ok: true, result: result ?? null })
} catch (err) {
res.json({ ok: false, error: err instanceof Error ? err.message : 'Unbekannter Server-Fehler' })
}
})
/** V2-Default 4001 — Produktiv bleibt auf 4000. */
const port = process.env.API_PORT ? Number(process.env.API_PORT) : 4001
if (!Number.isFinite(port) || port === 4000) {
throw new Error(
`[server] SAFETY: API_PORT=${process.env.API_PORT ?? '(unset)'} ist ungültig oder kollidiert mit Produktiv-Port 4000. ` +
`V2 erwartet z.B. 4001 (siehe .env).`,
)
}
app.listen(port, () => {
console.log(`[server] DEV-RPC-API (AssignmentMonitorV2) auf http://localhost:${port}/api/rpc`)
console.log(`[server] Produktiv (AssigmentMonitor) nutzt Port 4000 — diese Instanz ist davon getrennt.`)
})

39
server/rpcModules.ts Normal file
View File

@ -0,0 +1,39 @@
import * as appSettings from './db/queries/appSettings'
import * as consultants from './db/queries/consultants'
import * as assignmentTypes from './db/queries/assignmentTypes'
import * as assignments from './db/queries/assignments'
import * as feedbackStructure from './db/queries/feedbackStructure'
import * as criterionSuggestions from './db/queries/criterionSuggestions'
import * as checklists from './db/queries/checklists'
import * as meetings from './db/queries/meetings'
import * as conversationEntries from './db/queries/conversationEntries'
import * as assessments from './db/queries/assessments'
import * as assignmentFeedback from './db/queries/assignmentFeedback'
import * as meetingCategoryRatings from './db/queries/meetingCategoryRatings'
import * as backup from './db/queries/backup'
const modules = {
appSettings,
consultants,
assignmentTypes,
assignments,
feedbackStructure,
criterionSuggestions,
checklists,
meetings,
conversationEntries,
assessments,
assignmentFeedback,
meetingCategoryRatings,
backup,
}
export type RpcModuleName = keyof typeof modules
/**
* Modul-/Funktionsname kommen zur Laufzeit aus dem HTTP-Body, nicht aus statisch bekannten
* Aufrufstellen `any[]` ist hier eine bewusste, eng begrenzte Typgrenze für den dynamischen
* Dispatch in index.ts (wie bei Express' req.body), nicht nachlässige Typisierung.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const rpcModules = modules as Record<string, Record<string, (...args: any[]) => unknown> | undefined>

View File

@ -0,0 +1,13 @@
/**
* CLI: finale Feedback-Struktur aus docs/preload in die Dev-SQLite laden.
* Usage: npm run preload:feedback-structure
*/
import '../database'
import { preloadFinalFeedbackStructure } from '../db/queries/feedbackStructure'
const result = preloadFinalFeedbackStructure()
console.log(
`[preload] Feedback-Struktur geladen: ${result.dimensions} Dimensionen, `
+ `${result.categories} Kategorien, ${result.items} Kriterien `
+ `(ersetzt ${result.clearedDimensions} bestehende Dimensionen)`,
)

View File

@ -0,0 +1,42 @@
/**
* Setzt NUR die V2-Dev-Datenbank zurück (löscht die Datei unter server/data/).
* Produktiv (c:\dev\AssigmentMonitor) wird nicht angefasst gleiche Pfad-Guards wie database.ts.
*
* Usage: npm run db:reset
*/
import { existsSync, unlinkSync } from 'node:fs'
import { dirname, join, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = resolve(join(__dirname, '../..'))
const sqliteFilename = process.env.SQLITE_FILENAME?.trim() || 'assignment-monitor-v2-dev.sqlite3'
if (sqliteFilename.includes('/') || sqliteFilename.includes('\\') || sqliteFilename.includes('..')) {
throw new Error(`[db:reset] SAFETY: SQLITE_FILENAME ungültig: "${sqliteFilename}"`)
}
const dbPath = resolve(join(__dirname, '..', 'data', sqliteFilename))
const dbNorm = dbPath.toLowerCase()
const rootNorm = projectRoot.toLowerCase()
const rootPrefix = rootNorm.endsWith(sep) ? rootNorm : rootNorm + sep
if (!dbNorm.startsWith(rootPrefix)) {
throw new Error(`[db:reset] SAFETY: Pfad außerhalb V2: ${dbPath}`)
}
if (
/[/\\]assigmentmonitor[/\\]server[/\\]data[/\\]/.test(dbNorm) &&
!/[/\\]assigmentmonitorv2[/\\]/.test(dbNorm)
) {
throw new Error(`[db:reset] SAFETY: Produktiv-DB blockiert: ${dbPath}`)
}
if (!existsSync(dbPath)) {
console.log(`[db:reset] Keine Datei vorhanden — nichts zu löschen:\n ${dbPath}`)
console.log(`[db:reset] Beim nächsten Server-Start wird eine leere DB angelegt.`)
process.exit(0)
}
unlinkSync(dbPath)
console.log(`[db:reset] Dev-DB gelöscht:\n ${dbPath}`)
console.log(`[db:reset] Beim nächsten \`npm run server\` / \`npm run dev:all\` wird eine frische DB erzeugt.`)

View File

@ -0,0 +1,66 @@
/**
* Harter Vollständigkeits-Beweis für Backup-Export/-Import (siehe Plan "Vollständigkeits-Garantie").
* Nimmt eine zuvor exportierte Backup-JSON (egal ob aus der alten Dexie-Version oder aus einem
* früheren exportAll()), importiert sie in die aktuelle SQLite-DB, exportiert sofort wieder und
* vergleicht pro Tabelle Zeilenzahl + Inhalt. ACHTUNG: überschreibt den aktuellen Inhalt der
* lokalen SQLite-Datenbank mit dem Inhalt der Backup-Datei.
*
* Aufruf: npm run verify-backup -- <pfad-zur-backup.json>
*/
import { readFileSync } from 'node:fs'
import '../database'
import { TABLE_DEFINITIONS } from '../db/tableRegistry'
import { exportAll, importAll } from '../db/queries/backup'
const filePath = process.argv[2]
if (!filePath) {
console.error('Nutzung: npm run verify-backup -- <pfad-zur-backup.json>')
process.exit(1)
}
const backup = JSON.parse(readFileSync(filePath, 'utf8')) as { tables?: Record<string, Array<Record<string, unknown>>> }
if (!backup.tables || typeof backup.tables !== 'object') {
console.error('Ungültiges Backup-Format: kein "tables"-Feld gefunden.')
process.exit(1)
}
const sourceTables = backup.tables
console.log(`Importiere ${filePath} in die SQLite-DB ...`)
importAll(sourceTables)
console.log('Exportiere sofort wieder zum Vergleich ...\n')
const reexported = exportAll()
const byId = (rows: Array<Record<string, unknown>>) =>
[...rows].sort((a, b) => (a.id as number) - (b.id as number))
let allPass = true
for (const table of TABLE_DEFINITIONS) {
const name = table.name
const sourceRows = byId(sourceTables[name] ?? [])
const targetRows = byId(reexported.tables[name] ?? [])
if (sourceRows.length !== targetRows.length) {
console.log(`${name}: FAIL — Zeilenzahl weicht ab (Quelle: ${sourceRows.length}, Reexport: ${targetRows.length})`)
allPass = false
continue
}
const sourceJson = JSON.stringify(sourceRows)
const targetJson = JSON.stringify(targetRows)
if (sourceJson !== targetJson) {
console.log(`${name}: FAIL — Inhalt weicht ab`)
console.log(` Quelle (Ausschnitt): ${sourceJson.slice(0, 300)}`)
console.log(` Reexport (Ausschnitt): ${targetJson.slice(0, 300)}`)
allPass = false
continue
}
console.log(`${name}: PASS (${sourceRows.length} Zeile${sourceRows.length === 1 ? '' : 'n'}, identisch)`)
}
console.log(allPass
? '\n✅ Alle 21 Tabellen vollständig und inhaltlich identisch importiert/exportiert.'
: '\n❌ Mindestens eine Tabelle weicht ab — siehe oben.')
process.exit(allPass ? 0 : 1)

View File

@ -6,7 +6,10 @@ import Consultants from './pages/Consultants'
import AssignmentCreate from './pages/AssignmentCreate'
import AssignmentDetail from './pages/AssignmentDetail'
import AssignmentEdit from './pages/AssignmentEdit'
import MeetingView from './pages/MeetingView'
import AssignmentTrash from './pages/AssignmentTrash'
import AssignmentArchive from './pages/AssignmentArchive'
import MeetingHistory from './pages/MeetingHistory'
import MeetingView from './pages/meeting/MeetingView'
import FeedbackDraftPage from './pages/FeedbackDraftPage'
import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage'
import Evaluation from './pages/Evaluation'
@ -21,8 +24,11 @@ export default function App() {
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/assignment/new" element={<AssignmentCreate />} />
<Route path="/assignments/trash" element={<AssignmentTrash />} />
<Route path="/assignments/archive" element={<AssignmentArchive />} />
<Route path="/assignment/:id" element={<AssignmentDetail />} />
<Route path="/assignment/:id/edit" element={<AssignmentEdit />} />
<Route path="/assignment/:id/history" element={<MeetingHistory />} />
<Route path="/assignment/:id/meeting/:meetingId" element={<MeetingView />} />
<Route path="/assignment/:id/feedback/:instituteeId" element={<AssignmentFeedbackPage />} />
<Route path="/assignment/:id/feedback-draft/:instituteeId" element={<FeedbackDraftPage />} />

View File

@ -0,0 +1,85 @@
import {
DEFAULT_RATING_COLOR_BANDS,
RATING_SCORES,
normalizeRatingScore,
resolveScoreStyle,
} from '../config/constants'
import type { RatingColorBand, RatingScore } from '../db/types'
interface ScorePickerProps {
value: RatingScore | null
onChange: (score: RatingScore | null) => void
bands?: RatingColorBand[]
/** 'buttons' = 110-Leiste, 'select' = kompaktes Dropdown */
variant?: 'buttons' | 'select'
className?: string
/** Zeigt n/a / Leeren — setzt Score auf null. */
allowClear?: boolean
}
export function ScorePicker({
value,
onChange,
bands = DEFAULT_RATING_COLOR_BANDS,
variant = 'buttons',
className = '',
allowClear = true,
}: ScorePickerProps) {
const normalized = normalizeRatingScore(value)
if (variant === 'select') {
const style = resolveScoreStyle(normalized, bands)
return (
<select
value={normalized ?? ''}
onChange={e => {
const v = e.target.value
onChange(v === '' ? null : Number(v) as RatingScore)
}}
className={`text-xs rounded-lg border px-1 py-1 focus:outline-none flex-shrink-0 tabular-nums ${
normalized !== null ? style.active : style.color
} ${className}`}
>
<option value="">{allowClear ? 'n/a' : ''}</option>
{RATING_SCORES.map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
)
}
const clearStyle = resolveScoreStyle(null, bands)
return (
<div className={`flex gap-0.5 ${className}`}>
{allowClear && (
<button
type="button"
title="Nicht bewertet / zurücksetzen"
onClick={() => onChange(null)}
className={`flex-shrink-0 px-1.5 py-1.5 rounded text-xs font-bold border transition ${
normalized === null ? clearStyle.active : clearStyle.color
}`}
>
n/a
</button>
)}
{RATING_SCORES.map(n => {
const style = resolveScoreStyle(n, bands)
const selected = normalized === n
return (
<button
key={n}
type="button"
onClick={() => onChange(n)}
className={`flex-1 min-w-0 py-1.5 rounded text-xs font-bold border transition tabular-nums ${
selected ? style.active : style.color
}`}
>
{n}
</button>
)
})}
</div>
)
}

View File

@ -1,26 +1,101 @@
import type { FeedbackRating } from '../db'
import type { RatingBandKey, RatingColorBand, RatingScore } from '../db/types'
export const BRAND_COLOR = '#0070AD'
export const RATING_MIN = 1
export const RATING_MAX = 10
/** Kanonische Scores 110 — einzige Quelle für Picker/Selects. */
export const RATING_SCORES: readonly RatingScore[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
/**
* Kanonische Reihenfolge der Rating-Skala (nicht ändern DB-Werte sind stabil).
* `color` = Badge/Display-State (inaktiv)
* `active` = ausgewählter Button-State
* Abwärtskompatible Options-Liste (short/label/full = Zahl als Text).
* Farben kommen dynamisch über `resolveScoreStyle` / Farbbänder `color`/`active` hier nur Fallback.
*/
export const RATING_OPTIONS: ReadonlyArray<{
value: FeedbackRating
value: RatingScore
short: string
label: string
full: string
color: string
active: string
}> = [
{ value: 'na', short: 'N/A', label: 'N/A (0)', color: 'bg-gray-100 text-gray-500 border-gray-300', active: 'bg-gray-400 text-white border-gray-400' },
{ value: 'not_client_ready', short: 'Not', label: 'Not (1)', color: 'bg-red-100 text-red-700 border-red-300', active: 'bg-red-500 text-white border-red-500' },
{ value: 'partially_client_ready', short: 'Partially', label: 'Partially (2)', color: 'bg-orange-100 text-orange-700 border-orange-300', active: 'bg-orange-500 text-white border-orange-500' },
{ value: 'nearly_client_ready', short: 'Nearly', label: 'Nearly (3)', color: 'bg-green-100 text-green-600 border-green-300', active: 'bg-green-400 text-white border-green-400' },
{ value: 'client_ready', short: 'Fully', label: 'Fully (4)', color: 'bg-green-100 text-green-800 border-green-400', active: 'bg-green-700 text-white border-green-700' },
}> = RATING_SCORES.map(n => ({
value: n,
short: String(n),
label: String(n),
full: String(n),
color: 'bg-gray-100 text-gray-600 border-gray-300',
active: 'bg-brand text-white border-brand',
}))
export const BAND_STYLES: Record<RatingBandKey, { color: string; active: string; hex: string }> = {
red: { color: 'bg-red-100 text-red-700 border-red-300', active: 'bg-red-500 text-white border-red-500', hex: '#ef4444' },
orange: { color: 'bg-orange-100 text-orange-700 border-orange-300', active: 'bg-orange-500 text-white border-orange-500', hex: '#f97316' },
// Unselected: light lime vs saturated green — both used bg-green-100 before and looked identical.
lightGreen: { color: 'bg-lime-50 text-lime-700 border-lime-300', active: 'bg-lime-400 text-white border-lime-400', hex: '#a3e635' },
darkGreen: { color: 'bg-green-200 text-green-900 border-green-700', active: 'bg-green-700 text-white border-green-700', hex: '#15803d' },
}
/** Default: 12 rot, 35 orange, 68 hellgrün, 910 dunkelgrün (siehe docs/SCALE_1_TO_10.md). */
export const DEFAULT_RATING_COLOR_BANDS: RatingColorBand[] = [
{ max: 2, key: 'red' },
{ max: 5, key: 'orange' },
{ max: 8, key: 'lightGreen' },
{ max: 10, key: 'darkGreen' },
]
export function isValidRatingScore(value: unknown): value is RatingScore {
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN
return Number.isInteger(n) && n >= RATING_MIN && n <= RATING_MAX
}
/** Normalisiert DB-/UI-Werte auf 110 oder null (SQLite kann INTEGER-Spalten als String liefern). */
export function normalizeRatingScore(value: unknown): RatingScore | null {
if (value === null || value === undefined || value === '') return null
const n = typeof value === 'number' ? value : Number(value)
if (!Number.isInteger(n) || n < RATING_MIN || n > RATING_MAX) return null
return n
}
/** Rundet Mittelwerte auf 110; außerhalb / NaN → null. */
export function clampRatingScore(avg: number): RatingScore | null {
if (!Number.isFinite(avg)) return null
const rounded = Math.round(avg)
if (rounded < RATING_MIN || rounded > RATING_MAX) return null
return rounded
}
export function normalizeRatingColorBands(bands: RatingColorBand[] | null | undefined): RatingColorBand[] {
if (!bands?.length) return DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b }))
const sorted = [...bands].sort((a, b) => a.max - b.max)
if (sorted[sorted.length - 1]?.max !== RATING_MAX) return DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b }))
return sorted
}
export function bandForScore(score: RatingScore, bands: RatingColorBand[] = DEFAULT_RATING_COLOR_BANDS): RatingColorBand {
const normalized = normalizeRatingColorBands(bands)
return normalized.find(b => score <= b.max) ?? normalized[normalized.length - 1]
}
export function resolveScoreStyle(
score: RatingScore | null | undefined,
bands: RatingColorBand[] = DEFAULT_RATING_COLOR_BANDS,
): { value: RatingScore | null; short: string; color: string; active: string; hex: string } {
const normalized = normalizeRatingScore(score)
if (normalized === null) {
return {
value: null,
short: '',
color: 'bg-gray-100 text-gray-500 border-gray-300',
active: 'bg-gray-400 text-white border-gray-400',
hex: '#9ca3af',
}
}
const band = bandForScore(normalized, bands)
const style = BAND_STYLES[band.key]
return { value: normalized, short: String(normalized), color: style.color, active: style.active, hex: style.hex }
}
/** Farb-Schemata für Teilnehmer-Badges (Index = Reihenfolge im Assignment). */
export const INST_COLORS = [
{ bg: 'bg-blue-50', border: 'border-blue-300', tag: 'bg-blue-500', text: 'text-blue-700' },
@ -34,13 +109,120 @@ export const AI_CONFIG = {
localStorageKeyModel: 'ai_model',
defaultModel: 'openai/gpt-4o-mini',
openRouterBaseUrl: 'https://openrouter.ai/api/v1',
/**
* Seit 2026-07-14 nur noch einmaliger Erstbefüllungs-Wert für `appSettings.defaultDimensionPromptTemplate`
* (siehe `getOrSeedDefaultDimensionPromptTemplate()` in server/db/queries/appSettings.ts) der tatsächlich
* aktive Standard-Prompt lebt danach in der DB und ist im Admin-Tab KI-Prompts" editierbar, nicht mehr hier.
*/
defaultCategoryPromptTemplate: `Du bist Führungskraft bei Capgemini. Erstelle Feedback-Inhalte für EINE Kategorie auf Basis der Beobachtungsdaten aus Kundenterminen.
BERATER: {{BERATER_NAME}}
ASSIGNMENT: {{ASSIGNMENT_TITEL}} | Kunde: {{ASSIGNMENT_KUNDE}}
DIMENSION: {{DIMENSION_NAME}}
KATEGORIE: {{KATEGORIE_NAME}}
{{KATEGORIE_ERLAEUTERUNG}}
KRITERIEN & BEOBACHTUNGEN (nur diese Kategorie):
{{KRITERIEN_SCORES}}
MANUELLE KATEGORIE-BEWERTUNG: {{KATEGORIE_BEWERTUNG}}
ENTWICKLUNG ÜBER MEETINGS: {{ENTWICKLUNG_VERLAUF}}
{{TREND_VORSCHLAG}}
GETAGGTE PROTOKOLLZEILEN (dieser Kategorie zugeordnet):
{{PROTOKOLL_GETAGGT}}
Schreibe:
- SCORE: 110 (Trend-Vorschlag beachten bestätigen oder maximal ±1 abweichen)
- BENEFITS: 13 kurze positive Beobachtungen (Stichpunkte mit "- ")
- CONCERNS: 13 kurze kritische Beobachtungen (Stichpunkte mit "- "). Concerns sind Feststellungen, KEINE Handlungsempfehlungen.
Nutze ausschließlich die oben genannten Daten. Reicht die Datenlage nicht: SCORE weglassen, BENEFITS/CONCERNS mit "- n/a".
Antwortformat (Marker in eigener Zeile oder mit Text in derselben Zeile), ohne Markdown-Codefences:
SCORE: <1-10>
BENEFITS:
- <Stichpunkt>
CONCERNS:
- <Stichpunkt>`,
defaultDimensionPromptTemplate: `Du bist Führungskraft bei Capgemini. Verdichte die folgenden Kategorie-Feedbacks zu übergreifenden Dimension-Hauptpunkten — ohne Rohprotokoll, nur auf Basis der Kategorie-Ergebnisse.
BERATER: {{BERATER_NAME}}
ASSIGNMENT: {{ASSIGNMENT_TITEL}} | Kunde: {{ASSIGNMENT_KUNDE}}
DIMENSION: {{DIMENSION_NAME}}
KATEGORIE-ERGEBNISSE:
{{KATEGORIE_ERGEBNISSE}}
Schreibe für die gesamte Dimension:
- ACHIEVEMENTS: genau 3 bis 5 übergreifende Benefits (keine bloße Wiederholung einzelner Kategorien)
- DEVELOPMENT_NEEDS: genau 3 bis 5 übergreifende Concerns als Beobachtungen (keine Empfehlungsformeln wie "Sollte …")
Nutze ausschließlich die Kategorie-Ergebnisse oben. Antwortformat:
ACHIEVEMENTS:
- <Hauptpunkt>
DEVELOPMENT_NEEDS:
- <Beobachtung>`,
categoryPromptPlaceholders: [
{ token: '{{BERATER_NAME}}', description: 'Vor- und Nachname des Institutee' },
{ token: '{{ASSIGNMENT_TITEL}}', description: 'Titel des Assignments' },
{ token: '{{ASSIGNMENT_KUNDE}}', description: 'Kundenname des Assignments' },
{ token: '{{DIMENSION_NAME}}', description: 'Name der übergeordneten Dimension' },
{ token: '{{KATEGORIE_NAME}}', description: 'Name der aktuellen Kategorie' },
{ token: '{{KATEGORIE_ERLAEUTERUNG}}', description: 'Optional: Erläuterung der Kategorie' },
{ token: '{{KRITERIEN_SCORES}}', description: 'Kriterien dieser Kategorie mit Ø-Score und Notizen' },
{ token: '{{KATEGORIE_BEWERTUNG}}', description: 'Manuelle Kategorie-Bewertung (110)' },
{ token: '{{ENTWICKLUNG_VERLAUF}}', description: 'Meeting-Verlauf dieser Kategorie' },
{ token: '{{TREND_VORSCHLAG}}', description: 'Vorgeschlagener Score aus Meeting-Trend (±1 Regel)' },
{ token: '{{PROTOKOLL_GETAGGT}}', description: 'Protokollzeilen, die Kriterien dieser Kategorie zugeordnet sind' },
],
dimensionPromptPlaceholders: [
{ token: '{{BERATER_NAME}}', description: 'Vor- und Nachname des Institutee' },
{ token: '{{ASSIGNMENT_TITEL}}', description: 'Titel des Assignments' },
{ token: '{{ASSIGNMENT_KUNDE}}', description: 'Kundenname des Assignments' },
{ token: '{{DIMENSION_NAME}}', description: 'Name der aktuellen Dimension' },
{ token: '{{KATEGORIE_ERGEBNISSE}}', description: 'Scores + Benefits/Concerns aller Kategorien (Stufe A)' },
],
defaultCriterionTaggingPromptTemplate: `Du bist Assistent bei der Auswertung von Kundentermin-Protokollen für Capgemini-Trainees (Institutees). Analysiere die folgenden noch nicht einem Bewertungskriterium zugeordneten Protokollzeilen und schlage pro Zeile passende Kriterien vor.
ASSIGNMENT-TYP: {{ASSIGNMENT_TYP}}
VERFÜGBARE KRITERIEN (Dimension Kategorie Kriterium, referenziert über die Zahl in eckigen Klammern):
{{KRITERIEN_STRUKTUR}}
BEREITS BEKANNTE, TYPISCHE FORMULIERUNGSMUSTER PRO KRITERIUM (aus früheren Analyse-Läufen, sofern vorhanden):
{{GELERNTE_MUSTER}}
ÄHNLICHE, BEREITS ZUGEORDNETE ALT-ZEILEN (Kontext-Hinweis, keine feste Vorgabe):
{{AEHNLICHE_ALT_ZUORDNUNGEN}}
NOCH NICHT ZUGEORDNETE PROTOKOLLZEILEN (nummeriert):
{{PROTOKOLL_ZEILEN}}
Ordne jede Zeile 0 bis 3 Kriterien zu, priorisiert nach Passung (wahrscheinlichstes zuerst). Nutze ausschließlich die oben gelisteten Kriterium-IDs. Wenn keine sinnvolle Zuordnung erkennbar ist, gib "-" an. Erfinde keine Kriterien und keine Beobachtungen, die nicht im Zeilentext stehen.
Antworte AUSSCHLIESSLICH in exakt diesem Format, ohne zusätzliche Erklärungen, ohne Markdown-Formatierung:
ZEILE <Nummer>: <Kriterium-ID>, <Kriterium-ID>, ...
ZEILE <Nummer>: -
Ergänze danach, nur für Kriterien mit mindestens einer Zuordnung in dieser Analyse, kurze generalisierte Formulierungsmuster (1-2 Sätze, keine Zitate einzelner Zeilen):
MUSTER:
<Kriterium-ID>: <kurze Beschreibung typischer Formulierungen für dieses Kriterium>`,
criterionTaggingPromptPlaceholders: [
{ token: '{{ASSIGNMENT_TYP}}', description: 'Name des Assignment-Typs des aktuellen Meetings' },
{ token: '{{KRITERIEN_STRUKTUR}}', description: 'ID-referenzierte Struktur Dimension → Kategorie → Kriterium, gefiltert auf die im Assignment-Typ sichtbaren Kriterien, inkl. aller Beschreibungen' },
{ token: '{{GELERNTE_MUSTER}}', description: 'KI-destillierte Formulierungsmuster pro Kriterium aus früheren Analyse-Läufen (Nebenprodukt, siehe FeedbackCriterionItem.learnedPatternHints)' },
{ token: '{{AEHNLICHE_ALT_ZUORDNUNGEN}}', description: 'Lokal (ohne KI) ermittelte, ähnlichste bereits getaggte Alt-Zeilen je noch offener Zeile' },
{ token: '{{PROTOKOLL_ZEILEN}}', description: 'Nummerierte, noch nicht zugeordnete Zeilen des gesamten offenen Protokolls (Gesprächsbeiträge + allgemeine Notizen)' },
],
} as const
/** Numerische Gewichte für Rating-Aggregation. N/A wird aus Berechnungen ausgeschlossen. */
export const RATING_NUM_MAP: Record<FeedbackRating, number> = {
na: 0,
not_client_ready: 1,
partially_client_ready: 2,
nearly_client_ready: 3,
client_ready: 4,
}
/** Default-Zeitgewichtung für den Bewertungs-Vorschlag, überschreibbar über Config > Gewichtung. */
export const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5
/** Mindest-Punktedelta auf der 110-Skala zwischen erstem und letztem Meeting, ab dem ein Trend gilt. */
export const RATING_TREND_DELTA_THRESHOLD = 1

View File

@ -1,5 +1,6 @@
export * from './types'
export { db } from './schema'
export * from './queries'
export {
seedIfEmpty,
seedFeedbackStructureIfEmpty,

View File

@ -0,0 +1,29 @@
import { rpc } from '../rpcClient'
import type { RatingColorBand } from '../types'
export const getRatingTrendWeightStep = (): Promise<number> => rpc('appSettings', 'getRatingTrendWeightStep')
export const getOrSeedRatingTrendWeightStep = (): Promise<number> => rpc('appSettings', 'getOrSeedRatingTrendWeightStep')
export const saveRatingTrendWeightStep = (weightStep: number): Promise<number> =>
rpc('appSettings', 'saveRatingTrendWeightStep', weightStep)
export const getOrSeedRatingColorBands = (): Promise<RatingColorBand[]> =>
rpc('appSettings', 'getOrSeedRatingColorBands')
export const saveRatingColorBands = (bands: RatingColorBand[]): Promise<number> =>
rpc('appSettings', 'saveRatingColorBands', bands)
export const getDefaultDimensionPromptTemplate = (): Promise<string> =>
rpc('appSettings', 'getDefaultDimensionPromptTemplate')
export const getOrSeedDefaultDimensionPromptTemplate = (): Promise<string> =>
rpc('appSettings', 'getOrSeedDefaultDimensionPromptTemplate')
export const saveDefaultDimensionPromptTemplate = (template: string): Promise<number> =>
rpc('appSettings', 'saveDefaultDimensionPromptTemplate', template)
export const getOrSeedCriterionTaggingPromptTemplate = (): Promise<string> =>
rpc('appSettings', 'getOrSeedCriterionTaggingPromptTemplate')
export const saveCriterionTaggingPromptTemplate = (template: string): Promise<number> =>
rpc('appSettings', 'saveCriterionTaggingPromptTemplate', template)
export const getOrSeedDefaultCategoryPromptTemplate = (): Promise<string> =>
rpc('appSettings', 'getOrSeedDefaultCategoryPromptTemplate')
export const saveDefaultCategoryPromptTemplate = (template: string): Promise<number> =>
rpc('appSettings', 'saveDefaultCategoryPromptTemplate', template)

View File

@ -0,0 +1,13 @@
import { rpc } from '../rpcClient'
import type { Assessment } from '../types'
export const listAssessmentsAll = (): Promise<Assessment[]> => rpc('assessments', 'listAssessmentsAll')
export const listAssessmentsForMeeting = (meetingInstanceId: number): Promise<Assessment[]> =>
rpc('assessments', 'listAssessmentsForMeeting', meetingInstanceId)
export const listAssessmentsForMeetings = (meetingInstanceIds: number[]): Promise<Assessment[]> =>
rpc('assessments', 'listAssessmentsForMeetings', meetingInstanceIds)
export const addAssessment = (a: Omit<Assessment, 'id'>): Promise<number> => rpc('assessments', 'addAssessment', a)
export const updateAssessment = (id: number, patch: Partial<Assessment>): Promise<number> =>
rpc('assessments', 'updateAssessment', id, patch)
export const deleteAssessmentsForMeeting = (meetingInstanceId: number): Promise<number> =>
rpc('assessments', 'deleteAssessmentsForMeeting', meetingInstanceId)

View File

@ -0,0 +1,59 @@
import { rpc } from '../rpcClient'
import type {
FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText,
} from '../types'
export const getFeedbackDraft = (assignmentId: number, instituteeId: number): Promise<FeedbackDraft | undefined> =>
rpc('assignmentFeedback', 'getFeedbackDraft', assignmentId, instituteeId)
export const addFeedbackDraft = (d: Omit<FeedbackDraft, 'id'>): Promise<number> => rpc('assignmentFeedback', 'addFeedbackDraft', d)
export const updateFeedbackDraft = (id: number, patch: Partial<FeedbackDraft>): Promise<number> =>
rpc('assignmentFeedback', 'updateFeedbackDraft', id, patch)
export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): Promise<AssignmentFeedback | undefined> =>
rpc('assignmentFeedback', 'getAssignmentFeedback', assignmentId, instituteeId)
/** Für die Auswertungsübersicht: Feedback-Status (Entwurf/Final) aller Assignments auf einen Schlag, statt N+1-Abfragen pro Zeile. */
export const listAssignmentFeedbacksAll = (): Promise<AssignmentFeedback[]> => rpc('assignmentFeedback', 'listAssignmentFeedbacksAll')
export const getAssignmentFeedbackById = (id: number): Promise<AssignmentFeedback | undefined> =>
rpc('assignmentFeedback', 'getAssignmentFeedbackById', id)
export const addAssignmentFeedback = (f: Omit<AssignmentFeedback, 'id'>): Promise<number> =>
rpc('assignmentFeedback', 'addAssignmentFeedback', f)
export const updateAssignmentFeedback = (id: number, patch: Partial<AssignmentFeedback>): Promise<number> =>
rpc('assignmentFeedback', 'updateAssignmentFeedback', id, patch)
export const getOrCreateAssignmentFeedback = (
assignmentId: number,
instituteeId: number,
): Promise<AssignmentFeedback | undefined> => rpc('assignmentFeedback', 'getOrCreateAssignmentFeedback', assignmentId, instituteeId)
export const listFeedbackCategoryRatings = (assignmentFeedbackId: number): Promise<FeedbackCategoryRating[]> =>
rpc('assignmentFeedback', 'listFeedbackCategoryRatings', assignmentFeedbackId)
export const addFeedbackCategoryRating = (r: Omit<FeedbackCategoryRating, 'id'>): Promise<number> =>
rpc('assignmentFeedback', 'addFeedbackCategoryRating', r)
export const updateFeedbackCategoryRating = (id: number, patch: Partial<FeedbackCategoryRating>): Promise<number> =>
rpc('assignmentFeedback', 'updateFeedbackCategoryRating', id, patch)
export const deleteFeedbackCategoryRating = (id: number): Promise<void> =>
rpc('assignmentFeedback', 'deleteFeedbackCategoryRating', id)
export const listFeedbackDimensionTexts = (assignmentFeedbackId: number): Promise<FeedbackDimensionText[]> =>
rpc('assignmentFeedback', 'listFeedbackDimensionTexts', assignmentFeedbackId)
export const getFeedbackDimensionText = (
assignmentFeedbackId: number,
feedbackDimensionId: number,
): Promise<FeedbackDimensionText | undefined> =>
rpc('assignmentFeedback', 'getFeedbackDimensionText', assignmentFeedbackId, feedbackDimensionId)
export const addFeedbackDimensionText = (t: Omit<FeedbackDimensionText, 'id'>): Promise<number> =>
rpc('assignmentFeedback', 'addFeedbackDimensionText', t)
export const updateFeedbackDimensionText = (id: number, patch: Partial<FeedbackDimensionText>): Promise<number> =>
rpc('assignmentFeedback', 'updateFeedbackDimensionText', id, patch)
export const listFeedbackCategoryTexts = (assignmentFeedbackId: number): Promise<FeedbackCategoryText[]> =>
rpc('assignmentFeedback', 'listFeedbackCategoryTexts', assignmentFeedbackId)
export const getFeedbackCategoryText = (
assignmentFeedbackId: number,
feedbackCategoryId: number,
): Promise<FeedbackCategoryText | undefined> =>
rpc('assignmentFeedback', 'getFeedbackCategoryText', assignmentFeedbackId, feedbackCategoryId)
export const addFeedbackCategoryText = (t: Omit<FeedbackCategoryText, 'id'>): Promise<number> =>
rpc('assignmentFeedback', 'addFeedbackCategoryText', t)
export const updateFeedbackCategoryText = (id: number, patch: Partial<FeedbackCategoryText>): Promise<number> =>
rpc('assignmentFeedback', 'updateFeedbackCategoryText', id, patch)

View File

@ -0,0 +1,23 @@
import { rpc } from '../rpcClient'
import type { AssignmentType, PhaseTemplate } from '../types'
export const listAssignmentTypes = (): Promise<AssignmentType[]> => rpc('assignmentTypes', 'listAssignmentTypes')
export const getAssignmentType = (id: number): Promise<AssignmentType | undefined> => rpc('assignmentTypes', 'getAssignmentType', id)
export const addAssignmentType = (type: Omit<AssignmentType, 'id'>): Promise<number> =>
rpc('assignmentTypes', 'addAssignmentType', type)
export const updateAssignmentType = (id: number, patch: Partial<AssignmentType>): Promise<number> =>
rpc('assignmentTypes', 'updateAssignmentType', id, patch)
/** Löscht nur, wenn keine Assignments mehr diesen Typ referenzieren (Cascade auf phaseTemplates sonst). */
export const deleteAssignmentType = (id: number): Promise<{ ok: true } | { ok: false; assignmentCount: number }> =>
rpc('assignmentTypes', 'deleteAssignmentType', id)
export const listPhaseTemplates = (): Promise<PhaseTemplate[]> => rpc('assignmentTypes', 'listPhaseTemplates')
export const getPhaseTemplate = (id: number): Promise<PhaseTemplate | undefined> => rpc('assignmentTypes', 'getPhaseTemplate', id)
export const listPhaseTemplatesForType = (assignmentTypeId: number): Promise<PhaseTemplate[]> =>
rpc('assignmentTypes', 'listPhaseTemplatesForType', assignmentTypeId)
export const addPhaseTemplate = (tmpl: Omit<PhaseTemplate, 'id'>): Promise<number> =>
rpc('assignmentTypes', 'addPhaseTemplate', tmpl)
export const updatePhaseTemplate = (id: number, patch: Partial<PhaseTemplate>): Promise<number> =>
rpc('assignmentTypes', 'updatePhaseTemplate', id, patch)
export const deletePhaseTemplate = (id: number): Promise<void> => rpc('assignmentTypes', 'deletePhaseTemplate', id)

View File

@ -0,0 +1,29 @@
import { rpc } from '../rpcClient'
import type { Assignment } from '../types'
export const listAssignments = (): Promise<Assignment[]> => rpc('assignments', 'listAssignments')
export const listAssignmentsNewestFirst = (): Promise<Assignment[]> => rpc('assignments', 'listAssignmentsNewestFirst')
export const getAssignment = (id: number): Promise<Assignment | undefined> => rpc('assignments', 'getAssignment', id)
export const addAssignment = (a: Omit<Assignment, 'id'>): Promise<number> => rpc('assignments', 'addAssignment', a)
export const updateAssignment = (id: number, patch: Partial<Assignment>): Promise<number> =>
rpc('assignments', 'updateAssignment', id, patch)
export const isAssignmentFeedbackComplete = (assignmentId: number): Promise<boolean> =>
rpc('assignments', 'isAssignmentFeedbackComplete', assignmentId)
export const softDeleteAssignment = (id: number): Promise<number> => rpc('assignments', 'softDeleteAssignment', id)
export const restoreAssignment = (id: number): Promise<number> => rpc('assignments', 'restoreAssignment', id)
export const archiveAssignment = (id: number): Promise<number> => rpc('assignments', 'archiveAssignment', id)
export const unarchiveAssignment = (id: number): Promise<number> => rpc('assignments', 'unarchiveAssignment', id)
/** Cascade: meetingInstances (+ deren Kinder) → feedbackDrafts → assignmentFeedbacks (+ deren Kinder) → assignment. */
export const hardDeleteAssignment = (id: number): Promise<void> => rpc('assignments', 'hardDeleteAssignment', id)
export interface AssignmentWithDetails extends Assignment {
typeName: string
instituteeNames: string[]
/** Nur für status==='done' relevant: jedes Institutee hat ein finales Feedback — steuert den Löschen-Button in der Übersicht. */
feedbackComplete: boolean
}
/** Für die Dashboard-Liste: Assignments mit aufgelöstem Typ- und Institutee-Namen, neueste zuerst. */
export const listAssignmentsWithDetails = (): Promise<AssignmentWithDetails[]> => rpc('assignments', 'listAssignmentsWithDetails')

View File

@ -0,0 +1,27 @@
import { rpc } from '../rpcClient'
import type { ChecklistItemDef, ChecklistResponse } from '../types'
export const listChecklistItemDefsForPhaseTemplate = (phaseTemplateId: number): Promise<ChecklistItemDef[]> =>
rpc('checklists', 'listChecklistItemDefsForPhaseTemplate', phaseTemplateId)
export const listChecklistItemDefsForPhaseTemplates = (phaseTemplateIds: number[]): Promise<ChecklistItemDef[]> =>
rpc('checklists', 'listChecklistItemDefsForPhaseTemplates', phaseTemplateIds)
export const addChecklistItemDef = (def: Omit<ChecklistItemDef, 'id'>): Promise<number> =>
rpc('checklists', 'addChecklistItemDef', def)
export const updateChecklistItemDef = (id: number, patch: Partial<ChecklistItemDef>): Promise<number> =>
rpc('checklists', 'updateChecklistItemDef', id, patch)
export const deleteChecklistItemDef = (id: number): Promise<void> =>
rpc('checklists', 'deleteChecklistItemDef', id)
export const listChecklistResponsesForMeeting = (meetingInstanceId: number): Promise<ChecklistResponse[]> =>
rpc('checklists', 'listChecklistResponsesForMeeting', meetingInstanceId)
export const listChecklistResponsesForMeetings = (meetingInstanceIds: number[]): Promise<ChecklistResponse[]> =>
rpc('checklists', 'listChecklistResponsesForMeetings', meetingInstanceIds)
export const upsertChecklistResponse = (input: {
meetingInstanceId: number
instituteeId?: number
checklistItemId: number
rating: ChecklistResponse['rating']
note: string
criterionItemId?: number
}): Promise<number> => rpc('checklists', 'upsertChecklistResponse', input)

View File

@ -0,0 +1,12 @@
import { rpc } from '../rpcClient'
import type { Group, Consultant } from '../types'
export const listGroups = (): Promise<Group[]> => rpc('consultants', 'listGroups')
export const addGroup = (group: Omit<Group, 'id'>): Promise<number> => rpc('consultants', 'addGroup', group)
export const listConsultants = (): Promise<Consultant[]> => rpc('consultants', 'listConsultants')
export const getConsultant = (id: number): Promise<Consultant | undefined> => rpc('consultants', 'getConsultant', id)
export const getConsultantsByIds = (ids: number[]): Promise<Consultant[]> => rpc('consultants', 'getConsultantsByIds', ids)
export const addConsultant = (consultant: Omit<Consultant, 'id'>): Promise<number> => rpc('consultants', 'addConsultant', consultant)
export const updateConsultant = (id: number, patch: Partial<Consultant>): Promise<number> => rpc('consultants', 'updateConsultant', id, patch)
export const deleteConsultant = (id: number): Promise<void> => rpc('consultants', 'deleteConsultant', id)

View File

@ -0,0 +1,21 @@
import { rpc } from '../rpcClient'
import type { ConversationEntry, ConversationSkillScore } from '../types'
export const listConversationEntriesForMeeting = (meetingInstanceId: number): Promise<ConversationEntry[]> =>
rpc('conversationEntries', 'listConversationEntriesForMeeting', meetingInstanceId)
export const listConversationEntriesForMeetings = (meetingInstanceIds: number[]): Promise<ConversationEntry[]> =>
rpc('conversationEntries', 'listConversationEntriesForMeetings', meetingInstanceIds)
export const addConversationEntry = (e: Omit<ConversationEntry, 'id'>): Promise<number> =>
rpc('conversationEntries', 'addConversationEntry', e)
export const updateConversationEntry = (id: number, patch: Partial<ConversationEntry>): Promise<number> =>
rpc('conversationEntries', 'updateConversationEntry', id, patch)
export const deleteConversationEntry = (id: number): Promise<void> => rpc('conversationEntries', 'deleteConversationEntry', id)
export const listConversationSkillScoresAll = (): Promise<ConversationSkillScore[]> =>
rpc('conversationEntries', 'listConversationSkillScoresAll')
export const addConversationSkillScore = (s: Omit<ConversationSkillScore, 'id'>): Promise<number> =>
rpc('conversationEntries', 'addConversationSkillScore', s)
export const updateConversationSkillScore = (id: number, patch: Partial<ConversationSkillScore>): Promise<number> =>
rpc('conversationEntries', 'updateConversationSkillScore', id, patch)
export const deleteConversationSkillScoresForEntry = (conversationEntryId: number): Promise<number> =>
rpc('conversationEntries', 'deleteConversationSkillScoresForEntry', conversationEntryId)

View File

@ -0,0 +1,6 @@
import { rpc } from '../rpcClient'
import type { ConversationLineTag } from '../types'
/** Globaler (Zeilentext → Kriterium)-Korpus über alle Assignments — siehe server/db/queries/criterionSuggestions.ts. */
export const getGlobalLineTagCorpus = (): Promise<ConversationLineTag[]> =>
rpc('criterionSuggestions', 'getGlobalLineTagCorpus')

View File

@ -0,0 +1,154 @@
import { rpc } from '../rpcClient'
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, FeedbackDimensionPrompt } from '../types'
export interface FeedbackStructure {
dimensions: FeedbackDimension[]
categories: FeedbackCategory[]
items: FeedbackCriterionItem[]
}
/** Vollständige, nach `order` sortierte Feedback-Taxonomie. */
export const loadFeedbackStructure = (): Promise<FeedbackStructure> => rpc('feedbackStructure', 'loadFeedbackStructure')
/** Unsortierte Rohtabellen — für Stellen, die selbst filtern/sortieren (z.B. MeetingView, AssignmentDetail). */
export const listFeedbackCategoriesOrdered = (): Promise<FeedbackCategory[]> => rpc('feedbackStructure', 'listFeedbackCategoriesOrdered')
export const listFeedbackCategoriesAll = (): Promise<FeedbackCategory[]> => rpc('feedbackStructure', 'listFeedbackCategoriesAll')
export const listFeedbackCriterionItemsAll = (): Promise<FeedbackCriterionItem[]> => rpc('feedbackStructure', 'listFeedbackCriterionItemsAll')
export const getFeedbackCriterionItemsByIds = (ids: number[]): Promise<FeedbackCriterionItem[]> =>
rpc('feedbackStructure', 'getFeedbackCriterionItemsByIds', ids)
/**
* "AssignmentType.criteriaIds restrict die sichtbaren Kriterien" identische Filterlogik, die zuvor
* in MeetingView.tsx und AssignmentDetail.tsx dupliziert war. Leere/undefined criteriaIds = alles sichtbar.
* Reine Client-Berechnung, kein Server-Roundtrip nötig.
*/
export function filterVisibleFeedbackStructure<
C extends { id?: number },
I extends { id?: number; categoryId: number },
>(allCategories: C[], allItems: I[], criteriaIds: number[] | undefined): { categories: C[]; items: I[] } {
const itemFilter = criteriaIds?.length ? new Set(criteriaIds) : null
const items = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
const visibleCatIds = new Set(items.map(i => i.categoryId))
const categories = itemFilter ? allCategories.filter(c => visibleCatIds.has(c.id!)) : allCategories
return { categories, items }
}
/**
* Kanonische Sichtbarkeit für Assignment-Typ: Dimension Kategorie Kriterien.
* Kategorien ohne sichtbare Items und Dimensionen ohne sichtbare Kategorien entfallen.
* Leere/undefined criteriaIds = alles sichtbar. Baut auf `filterVisibleFeedbackStructure`.
*/
export function resolveVisibleStructure<
D extends { id?: number },
C extends { id?: number; dimensionId: number },
I extends { id?: number; categoryId: number },
>(
allDimensions: D[],
allCategories: C[],
allItems: I[],
criteriaIds: number[] | undefined,
): { dimensions: D[]; categories: C[]; items: I[] } {
const { categories, items } = filterVisibleFeedbackStructure(allCategories, allItems, criteriaIds)
const visibleDimIds = new Set(categories.map(c => c.dimensionId))
const dimensions = allDimensions.filter(d => visibleDimIds.has(d.id!))
return { dimensions, categories, items }
}
/** Alle Kriterium-IDs der gegebenen Kategorien — für Config „Kategorie/Dimension wählen → criteriaIds“. */
export function criterionIdsForCategories(
categoryIds: number[],
allItems: { id?: number; categoryId: number }[],
): number[] {
const catSet = new Set(categoryIds)
return allItems.filter(i => catSet.has(i.categoryId) && i.id != null).map(i => i.id!)
}
export const addFeedbackDimension = (d: Omit<FeedbackDimension, 'id'>): Promise<number> =>
rpc('feedbackStructure', 'addFeedbackDimension', d)
export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): Promise<number> =>
rpc('feedbackStructure', 'updateFeedbackDimension', id, patch)
/** Cascade: Items der Kategorien → Kategorien → Dimension. */
export const deleteFeedbackDimension = (id: number): Promise<void> => rpc('feedbackStructure', 'deleteFeedbackDimension', id)
export const addFeedbackCategory = (c: Omit<FeedbackCategory, 'id'>): Promise<number> =>
rpc('feedbackStructure', 'addFeedbackCategory', c)
export const updateFeedbackCategory = (id: number, patch: Partial<FeedbackCategory>): Promise<number> =>
rpc('feedbackStructure', 'updateFeedbackCategory', id, patch)
/** Cascade: Items der Kategorie → Kategorie. */
export const deleteFeedbackCategory = (id: number): Promise<void> => rpc('feedbackStructure', 'deleteFeedbackCategory', id)
export const addFeedbackCriterionItem = (i: Omit<FeedbackCriterionItem, 'id'>): Promise<number> =>
rpc('feedbackStructure', 'addFeedbackCriterionItem', i)
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): Promise<number> =>
rpc('feedbackStructure', 'updateFeedbackCriterionItem', id, patch)
/** Räumt vorher selbst alle Verwendungen auf (Assessments/Tags/Sichtbarkeitslisten) — siehe CLAUDE.md. */
export const deleteFeedbackCriterionItem = (id: number): Promise<void> => rpc('feedbackStructure', 'deleteFeedbackCriterionItem', id)
export interface CriterionUsage {
assessmentCount: number
lineTagCount: number
generalNoteTagCount: number
assignmentTypeNames: string[]
phaseTemplateLabels: string[]
meetings: { meetingId: number; assignmentId: number; assignmentTitle: string; date: string }[]
}
/** Verwendungs-Übersicht für alle Kriterien in einem Rutsch — siehe CLAUDE.md „Kriterien-Umbau". */
export const getCriterionUsageMap = (): Promise<Record<number, CriterionUsage>> =>
rpc('feedbackStructure', 'getCriterionUsageMap')
/** Überträgt alle Referenzen von `fromId` auf `toId` (für sicheres Löschen mit Zusammenführung statt Datenverlust). */
export const reassignCriterionReferences = (fromId: number, toId: number): Promise<{ mergedConflicts: number }> =>
rpc('feedbackStructure', 'reassignCriterionReferences', fromId, toId)
export type CriterionUsageInstance =
| { kind: 'assessment'; assessmentId: number; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
| { kind: 'lineTag'; conversationEntryId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
| { kind: 'generalNoteTag'; meetingInstanceId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
| { kind: 'assignmentTypeVisibility'; assignmentTypeId: number; criterionId: number; text: string }
| { kind: 'phaseTemplateVisibility'; phaseTemplateId: number; criterionId: number; text: string }
/** Einzelinstanzen-Liste für EIN Kriterium — Grundlage, um eine Zuordnung direkt umzuhängen/zu entfernen. */
export const getCriterionUsageDetails = (criterionId: number): Promise<CriterionUsageInstance[]> =>
rpc('feedbackStructure', 'getCriterionUsageDetails', criterionId)
/** Hängt EINE einzelne Verwendungsinstanz auf ein anderes Kriterium um. */
export const reassignUsageInstance = (instance: CriterionUsageInstance, toId: number): Promise<{ merged: boolean }> =>
rpc('feedbackStructure', 'reassignUsageInstance', instance, toId)
/** Entfernt EINE einzelne Verwendungsinstanz (Assessment wird gelöscht, Tags nur von der Zuordnung befreit). */
export const removeUsageInstance = (instance: CriterionUsageInstance): Promise<void> =>
rpc('feedbackStructure', 'removeUsageInstance', instance)
export const listFeedbackDimensionPrompts = (): Promise<FeedbackDimensionPrompt[]> =>
rpc('feedbackStructure', 'listFeedbackDimensionPrompts')
/** Selbstheilend — legt serverseitig bei Bedarf eine fehlende Prompt-Zeile mit Default-Template an. */
export const getFeedbackDimensionPrompt = (dimensionId: number): Promise<FeedbackDimensionPrompt> =>
rpc('feedbackStructure', 'getFeedbackDimensionPrompt', dimensionId)
export const updateFeedbackDimensionPrompt = (id: number, patch: Partial<FeedbackDimensionPrompt>): Promise<number> =>
rpc('feedbackStructure', 'updateFeedbackDimensionPrompt', id, patch)
export const resetAllDimensionPromptsToDefault = (): Promise<{ updated: number }> =>
rpc('feedbackStructure', 'resetAllDimensionPromptsToDefault')
export type { FeedbackStructureSeed } from '../../utils/feedbackStructureImport'
export type ReplaceFeedbackStructureResult = {
dimensions: number
categories: number
items: number
clearedDimensions: number
}
/** Ersetzt die gesamte Feedback-Struktur durch ein Seed-Objekt. */
export const replaceFeedbackStructure = (seed: import('../../utils/feedbackStructureImport').FeedbackStructureSeed): Promise<ReplaceFeedbackStructureResult> =>
rpc('feedbackStructure', 'replaceFeedbackStructure', seed)
/** Parst Markdown (Export-Format) und ersetzt die Struktur. */
export const importFeedbackStructureFromMarkdown = (md: string): Promise<ReplaceFeedbackStructureResult> =>
rpc('feedbackStructure', 'importFeedbackStructureFromMarkdown', md)
/** Lädt docs/preload/Feedback-Struktur-final.md in die Dev-DB. */
export const preloadFinalFeedbackStructure = (): Promise<ReplaceFeedbackStructureResult> =>
rpc('feedbackStructure', 'preloadFinalFeedbackStructure')

12
src/db/queries/index.ts Normal file
View File

@ -0,0 +1,12 @@
export * from './appSettings'
export * from './consultants'
export * from './assignmentTypes'
export * from './assignments'
export * from './feedbackStructure'
export * from './criterionSuggestions'
export * from './checklists'
export * from './meetings'
export * from './conversationEntries'
export * from './assessments'
export * from './assignmentFeedback'
export * from './meetingCategoryRatings'

View File

@ -0,0 +1,16 @@
import { rpc } from '../rpcClient'
import type { MeetingCategoryRating, RatingScore } from '../types'
export const listMeetingCategoryRatingsForMeeting = (meetingInstanceId: number): Promise<MeetingCategoryRating[]> =>
rpc('meetingCategoryRatings', 'listMeetingCategoryRatingsForMeeting', meetingInstanceId)
export const listMeetingCategoryRatingsForMeetings = (meetingInstanceIds: number[]): Promise<MeetingCategoryRating[]> =>
rpc('meetingCategoryRatings', 'listMeetingCategoryRatingsForMeetings', meetingInstanceIds)
export const upsertMeetingCategoryRating = (input: {
meetingInstanceId: number
instituteeId: number
categoryId: number
score: RatingScore | null
note?: string
}): Promise<number> => rpc('meetingCategoryRatings', 'upsertMeetingCategoryRating', input)

View File

@ -0,0 +1,43 @@
import { rpc } from '../rpcClient'
import type {
MeetingInstance, PhaseTemplate, Assignment, Consultant,
ConversationEntry, ConversationSkillScore, Assessment,
ChecklistItemDef, ChecklistResponse, MeetingCategoryRating,
} from '../types'
export const getMeetingInstance = (id: number): Promise<MeetingInstance | undefined> => rpc('meetings', 'getMeetingInstance', id)
export const listMeetingInstancesForAssignment = (assignmentId: number): Promise<MeetingInstance[]> =>
rpc('meetings', 'listMeetingInstancesForAssignment', assignmentId)
/**
* Kanonischer Filter für Aggregationen über mehrere Meetings: Papierkorb-/unfertige Meetings ausschließen,
* chronologisch sortiert. Siehe CLAUDE.md immer diese Funktion nutzen, nicht erneut inline filtern.
*/
export const listDoneMeetingsChronological = (assignmentId: number): Promise<MeetingInstance[]> =>
rpc('meetings', 'listDoneMeetingsChronological', assignmentId)
export const addMeetingInstance = (m: Omit<MeetingInstance, 'id'>): Promise<number> => rpc('meetings', 'addMeetingInstance', m)
export const updateMeetingInstance = (id: number, patch: Partial<MeetingInstance>): Promise<number> =>
rpc('meetings', 'updateMeetingInstance', id, patch)
export const softDeleteMeetingInstance = (id: number): Promise<number> => rpc('meetings', 'softDeleteMeetingInstance', id)
export const restoreMeetingInstance = (id: number): Promise<number> => rpc('meetings', 'restoreMeetingInstance', id)
/** Cascade: conversationSkillScores der Entries → conversationEntries → assessments → meetingInstance. */
export const hardDeleteMeetingInstance = (id: number): Promise<void> => rpc('meetings', 'hardDeleteMeetingInstance', id)
export interface MeetingExportData {
meeting: MeetingInstance
template: PhaseTemplate | undefined
assignment: Assignment | undefined
consultants: Consultant[]
entries: ConversationEntry[]
allScores: ConversationSkillScore[]
assessments: Assessment[]
categoryRatings: MeetingCategoryRating[]
checklistItems: ChecklistItemDef[]
checklistResponses: ChecklistResponse[]
}
/** Gebündelte Rohdaten für Markdown-/JSON-Export eines Meetings — vorher in meetingExport.ts dupliziert. */
export const getMeetingExportData = (meetingId: number): Promise<MeetingExportData> =>
rpc('meetings', 'getMeetingExportData', meetingId)

30
src/db/rpcClient.ts Normal file
View File

@ -0,0 +1,30 @@
/**
* Bridge zum lokalen Server (server/index.ts) ersetzt den bisherigen direkten Dexie-Zugriff
* in src/db/queries/*.ts. Ein Endpunkt statt ~80 REST-Routen: `{ module, fn, args }` Server
* ruft `rpcModules[module][fn](...args)` auf. Relativer Pfad `/api/rpc`, damit derselbe Code
* unverändert über den Vite-Dev-Proxy UND später hinter einem Reverse Proxy/Docker funktioniert.
*/
export async function rpc<T>(module: string, fn: string, ...args: unknown[]): Promise<T> {
let res: Response
try {
res = await fetch('/api/rpc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module, fn, args }),
})
} catch (err) {
throw new Error(
`API nicht erreichbar (${err instanceof Error ? err.message : String(err)}). `
+ `Läuft der Dev-Server? In AssigmentMonitorV2: npm run dev:all (API-Port 4001).`,
)
}
if (!res.ok) {
const hint = res.status === 502 || res.status === 504 || res.status === 500
? ` — vermutlich läuft die API auf Port 4001 nicht (npm run server / npm run dev:all).`
: ''
throw new Error(`RPC-Fehler: ${res.status} ${res.statusText}${hint}`)
}
const body = await res.json() as { ok: boolean; result?: T; error?: string }
if (!body.ok) throw new Error(body.error ?? 'RPC-Fehler')
return body.result as T
}

View File

@ -9,6 +9,7 @@ import type {
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText,
CriterionCategoryMapping, CriterionLevelDescription,
AppSettings,
} from './types'
class AssignmentMonitorDB extends Dexie {
@ -32,6 +33,7 @@ class AssignmentMonitorDB extends Dexie {
feedbackDimensionTexts!: Table<FeedbackDimensionText>
criterionCategoryMappings!: Table<CriterionCategoryMapping>
criterionLevelDescriptions!: Table<CriterionLevelDescription>
appSettings!: Table<AppSettings>
constructor() {
super('AssignmentMonitorDB')
@ -129,6 +131,31 @@ class AssignmentMonitorDB extends Dexie {
meetings: null,
consultantNotes: null,
})
this.version(6).stores({
categories: '++id, order',
criteria: '++id, categoryId, order',
assignmentTypes: '++id',
phaseTemplates: '++id, assignmentTypeId, order',
groups: '++id',
consultants: '++id, groupId',
assignments: '++id, status, createdAt',
meetingInstances: '++id, assignmentId, phaseTemplateId',
conversationEntries: '++id, meetingInstanceId, instituteeId, sequenceIndex',
conversationSkillScores: '++id, conversationEntryId, criteriaId',
assessments: '++id, meetingInstanceId, instituteeId, criteriaId',
feedbackDrafts: '++id, assignmentId, instituteeId',
feedbackDimensions: '++id, order',
feedbackCategories: '++id, dimensionId, order',
feedbackCriterionItems: '++id, categoryId, order',
assignmentFeedbacks: '++id, assignmentId, instituteeId',
feedbackCategoryRatings: '++id, assignmentFeedbackId, feedbackCategoryId',
feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId',
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId',
criterionLevelDescriptions: '++id, criterionItemId, rating',
appSettings: '++id',
meetings: null,
consultantNotes: null,
})
}
}

View File

@ -41,6 +41,7 @@ export async function seedIfEmpty() {
isRepeatable: opts.repeatable ?? false,
hasConversation: opts.conv ?? false,
hasAssessment: opts.assess ?? false,
hasChecklist: false,
criteriaIds: opts.criteriaIds ?? [],
order: opts.order,
})

View File

@ -0,0 +1,660 @@
{
"dimensions": [
{
"name": "Client-Readiness",
"categories": [
{
"name": "Problem Structuring",
"items": [
{
"name": "Problem- und Zielklarheit",
"weight": 1,
"description": "Klärt Ausgangslage, eigentliches Problem, angestrebtes Ergebnis und relevante Entscheidung, bevor mit der Bearbeitung begonnen wird."
},
{
"name": "Scope-Klarheit",
"weight": 1,
"description": "Grenzt Untersuchungsgegenstand, Betrachtungszeitraum, Stakeholder, Annahmen und Nicht-Ziele nachvollziehbar ab."
},
{
"name": "Logische Zerlegung",
"weight": 1,
"description": "Zerlegt komplexe Fragestellungen in verständliche, trennscharfe und gemeinsam hinreichend vollständige Teilfragen."
},
{
"name": "Hypothesenorientierung",
"weight": 1,
"description": "Formuliert frühzeitig plausible, überprüfbare Hypothesen und nutzt sie zur Steuerung der weiteren Analyse."
},
{
"name": "Priorisierung nach Relevanz",
"weight": 1,
"description": "Fokussiert die Struktur auf die entscheidungs- und wirkungsrelevanten Fragestellungen und vermeidet unnötige Analysebreite."
},
{
"name": "Treiber- und Abhängigkeitsverständnis",
"weight": 1,
"description": "Erkennt wesentliche Ursachen, Wirkzusammenhänge, Abhängigkeiten und Wechselwirkungen innerhalb der Fragestellung."
},
{
"name": "Ableitung eines Analyseplans",
"weight": 1,
"description": "Übersetzt die Problemstruktur in konkrete Arbeitsschritte, benötigte Informationen, Verantwortlichkeiten und sinnvolle Prüfsequenzen."
}
],
"description": "Übersetzt eine zunächst offene oder komplexe Kundenfragestellung in eine präzise, logisch gegliederte und bearbeitbare Problemstruktur, die eine fokussierte Analyse und zielgerichtete Entscheidungsfindung ermöglicht."
},
{
"name": "Problem Solving",
"items": [
{
"name": "Fakten- und Evidenzorientierung",
"weight": 1,
"description": "Stützt Aussagen auf belastbare Daten, Quellen, Interviews, Analysen oder transparent hergeleitete Annahmen."
},
{
"name": "Methodengerechte Analyse",
"weight": 1,
"description": "Wählt geeignete qualitative und quantitative Methoden aus und wendet sie fachlich korrekt und zweckmäßig an."
},
{
"name": "Kritische Prüfung von Informationen",
"weight": 1,
"description": "Hinterfragt Datenqualität, Quellen, Annahmen, Widersprüche und mögliche Verzerrungen, statt Ergebnisse ungeprüft zu übernehmen."
},
{
"name": "Hypothesenprüfung und Iteration",
"weight": 1,
"description": "Prüft Hypothesen systematisch, verwirft oder schärft sie anhand neuer Erkenntnisse und passt das Vorgehen entsprechend an."
},
{
"name": "Entwicklung von Lösungsoptionen",
"weight": 1,
"description": "Entwickelt mehrere relevante Handlungsoptionen und macht deren Unterschiede, Vor- und Nachteile sowie Konsequenzen sichtbar."
},
{
"name": "Impact- und Value-Orientierung",
"weight": 1,
"description": "Richtet Analyse, Vorgehen und Lösung auf den höchsten realistischen Kundennutzen aus. Hinterfragt bei Bedarf den ursprünglich gewünschten Deliverable-Typ und entwickelt wirkungsvollere Alternativen, ohne Ziel, Scope, Risiken oder Umsetzbarkeit eigenmächtig zu verändern."
},
{
"name": "Machbarkeit und Realitätsnähe",
"weight": 1,
"description": "Berücksichtigt organisatorische, technische, wirtschaftliche und zeitliche Rahmenbedingungen bei der Lösungsentwicklung."
},
{
"name": "Entscheidungsorientierte Empfehlung",
"weight": 1,
"description": "Verdichtet Analyseergebnisse zu einer klaren Empfehlung mit Begründung, Auswirkungen, Risiken und nächsten Schritten."
}
],
"description": "Bearbeitet strukturierte Fragestellungen analytisch belastbar, pragmatisch und ergebnisorientiert. Entwickelt aus Fakten und Erkenntnissen umsetzbare Optionen, Entscheidungen und Empfehlungen."
},
{
"name": "Storylining",
"items": [
{
"name": "Answer First / Kernaussage",
"weight": 1,
"description": "Stellt die zentrale Aussage, Empfehlung oder Antwort frühzeitig und eindeutig heraus."
},
{
"name": "Logische Argumentationsstruktur",
"weight": 1,
"description": "Ordnet Aussagen so, dass Schlussfolgerungen nachvollziehbar aus Fakten, Analysen und Teilargumenten hervorgehen."
},
{
"name": "Relevanz und Fokus",
"weight": 1,
"description": "Beschränkt die Storyline auf Inhalte, die für Fragestellung, Zielgruppe und Entscheidung tatsächlich erforderlich sind."
},
{
"name": "Belegführung",
"weight": 1,
"description": "Verknüpft Kernaussagen sichtbar mit belastbaren Fakten, Analysen und nachvollziehbaren Begründungen."
},
{
"name": "Managementgerechte Dramaturgie",
"weight": 1,
"description": "Führt verständlich von Ausgangslage und Handlungsdruck über Erkenntnisse zu Empfehlung, Wirkung und nächstem Schritt."
},
{
"name": "Konsistenz über Medien und Beiträge",
"weight": 1,
"description": "Hält Botschaften, Begriffe, Zahlen und Argumentationslogik über Folien, Dokumente und mündliche Beiträge hinweg widerspruchsfrei."
}
],
"description": "Verdichtet komplexe Inhalte zu einer logisch schlüssigen, adressatengerechten und entscheidungsorientierten Argumentationslinie, die Kernbotschaften klar transportiert."
},
{
"name": "Precise Communication",
"items": [
{
"name": "Active Listening",
"weight": 1,
"description": "Hört aufmerksam zu, erfasst Inhalt und Kontext und greift relevante Aussagen, Bedenken und Signale gezielt auf."
},
{
"name": "Präzise Fragetechnik",
"weight": 1,
"description": "Stellt klare, relevante und aufeinander aufbauende Fragen, die Verständnis, Erkenntnisgewinn oder Entscheidung fördern."
},
{
"name": "To the Point",
"weight": 1,
"description": "Formuliert die wesentliche Aussage ohne unnötige Vorrede, Wiederholungen oder Nebenpfade."
},
{
"name": "Adressatengerechte Kommunikation",
"weight": 1,
"description": "Passt Tonalität, Detailtiefe, Begriffswahl und Kommunikationsstil an Zielgruppe, Hierarchie und Situation an."
},
{
"name": "Sprachliche Klarheit und Verbindlichkeit",
"weight": 1,
"description": "Verwendet eindeutige Formulierungen und vermeidet unnötige Füllwörter, Weichmacher, Mehrdeutigkeit und Scheingenauigkeit."
},
{
"name": "Recap und Verständnissicherung",
"weight": 1,
"description": "Fasst Kernaussagen, Entscheidungen, offene Punkte und nächste Schritte prägnant zusammen und prüft gemeinsames Verständnis."
},
{
"name": "Souveräne Präsenz",
"weight": 1,
"description": "Tritt ruhig, vorbereitet, klar und entscheidungsorientiert auf. Behält auch bei kritischen Rückfragen, Widerspruch oder unvollständigen Informationen die Handlungsfähigkeit, unterscheidet sicher zwischen Fakten, Annahmen und offenen Punkten und vermittelt Orientierung, ohne Sicherheit vorzutäuschen."
}
],
"description": "Nimmt Informationen aktiv auf und kommuniziert mündlich wie schriftlich klar, prägnant, adressatengerecht und handlungsorientiert."
},
{
"name": "Client Intimacy Building",
"items": [
{
"name": "Verständnis des Kundenkontexts",
"weight": 1,
"description": "Erfasst Ziele, Geschäftsmodell, Prioritäten, Restriktionen, Entscheidungslogiken und relevante Rahmenbedingungen des Kunden."
},
{
"name": "Stakeholderverständnis",
"weight": 1,
"description": "Erkennt Interessen, Einfluss, Erwartungen und mögliche Widerstände relevanter Stakeholder und berücksichtigt sie im Vorgehen."
},
{
"name": "Vertrauensaufbau und Beziehungsqualität",
"weight": 1,
"description": "Schafft durch Verlässlichkeit, Integrität, professionelles Verhalten und respektvolle Zusammenarbeit eine belastbare Arbeitsbeziehung."
},
{
"name": "Fachliche Glaubwürdigkeit und Kompetenzwahrnehmung",
"weight": 1,
"description": "Wird durch fachliche Substanz, Kundenverständnis, klare Einordnung und belastbare Empfehlungen als relevanter und kompetenter Ansprechpartner wahrgenommen. Gibt Orientierung und macht zugleich Grenzen des eigenen Wissens transparent."
},
{
"name": "Aktives Erwartungsmanagement",
"weight": 1,
"description": "Klärt Ziele, Rollen, Umfang, Annahmen, Risiken und Liefererwartungen und steuert Abweichungen frühzeitig nach."
},
{
"name": "Active Advisory und Customer Steering",
"weight": 1,
"description": "Führt den Kunden aktiv durch Fragestellungen, Optionen, Konsequenzen und notwendige Entscheidungen. Spricht klare Empfehlungen aus und verhindert eine rein ausführende Rolle, ohne die Entscheidungsverantwortung des Kunden zu übergehen."
},
{
"name": "Konstruktives Challengen",
"weight": 1,
"description": "Hinterfragt Annahmen oder Kundenwünsche respektvoll, faktenbasiert und lösungsorientiert, wenn dies dem Ergebnis dient."
},
{
"name": "Beziehungsentwicklung über den Einzeltermin hinaus",
"weight": 1,
"description": "Pflegt relevante Kontakte, erkennt Anschlussbedarfe und schafft Kontinuität in der Zusammenarbeit, ohne aufdringlich oder rein vertriebsorientiert zu handeln."
}
],
"description": "Baut professionelle, vertrauensvolle Kundenbeziehungen auf, versteht fachliche und persönliche Stakeholderbedürfnisse und entwickelt sich vom Ausführenden zum wirksamen Berater."
},
{
"name": "Quality of Deliverables",
"items": [
{
"name": "Fachliche Richtigkeit",
"weight": 1,
"description": "Inhalte, Berechnungen, Aussagen und Schlussfolgerungen sind fachlich korrekt und belastbar."
},
{
"name": "Inhaltliche Vollständigkeit",
"weight": 1,
"description": "Deckt die für Fragestellung und Entscheidung relevanten Aspekte, Perspektiven und Auswirkungen angemessen ab."
},
{
"name": "Logische Konsistenz",
"weight": 1,
"description": "Stellt sicher, dass Aussagen, Zahlen, Argumente und Empfehlungen in sich sowie untereinander widerspruchsfrei sind."
},
{
"name": "Kundenspezifische Konkretisierung",
"weight": 1,
"description": "Überträgt generische Erkenntnisse sichtbar auf Situation, Sprache, Daten und Handlungsrealität des Kunden."
},
{
"name": "Informationsdichte und Nutzbarkeit",
"weight": 1,
"description": "Wählt eine angemessene Detailtiefe und ermöglicht dem Adressaten, Kernaussagen und Handlungsbedarf schnell zu erfassen."
},
{
"name": "Visualisierungs- und Darstellungsqualität",
"weight": 1,
"description": "Nutzt Struktur, Layout, Tabellen, Grafiken und Visualisierungen so, dass sie Verständnis und Botschaft wirksam unterstützen."
},
{
"name": "Formale Qualität und Standardkonformität",
"weight": 1,
"description": "Hält relevante Templates, Designvorgaben, Sprache, Quellenangaben, Versionierung und Dokumentationsstandards konsequent ein."
}
],
"description": "Erstellt fachlich belastbare, konsistente und professionell aufbereitete Arbeitsergebnisse, die für den Kunden verständlich, relevant und unmittelbar nutzbar sind."
},
{
"name": "Proposal Writing & Value Proposition Design",
"items": [
{
"name": "Verständnis von Bedarf und Buying Context",
"weight": 1,
"description": "Erfasst Problem, Auslöser, Zielbild, Entscheidungskriterien, Stakeholder und Beschaffungskontext hinter der Anfrage."
},
{
"name": "Klare Value Proposition",
"weight": 1,
"description": "Formuliert prägnant, welchen konkreten Nutzen und welche angestrebten Ergebnisse das Angebot für den Kunden erzeugt."
},
{
"name": "Kundenspezifische Differenzierung",
"weight": 1,
"description": "Verbindet Kundenbedarf mit relevanten Fähigkeiten, Erfahrungen, Assets und unterscheidbaren Stärken des eigenen Angebots."
},
{
"name": "Schlüssiger Scope und Deliverables",
"weight": 1,
"description": "Beschreibt Leistungsumfang, Ergebnisse, Grenzen und Verantwortlichkeiten klar und vermeidet Interpretationsspielräume."
},
{
"name": "Plausibles Vorgehensmodell",
"weight": 1,
"description": "Entwickelt einen nachvollziehbaren Ansatz mit Arbeitspaketen, Meilensteinen, Beteiligten und Entscheidungszeitpunkten."
},
{
"name": "Belastbare Annahmen und Machbarkeit",
"weight": 1,
"description": "Macht Voraussetzungen, Abhängigkeiten, Risiken und Mitwirkungspflichten transparent und verspricht nur realistisch Lieferbares."
},
{
"name": "Kommerzielles Verständnis",
"weight": 1,
"description": "Berücksichtigt Aufwand, Preislogik, Wirtschaftlichkeit, Risiken und den für beide Seiten tragfähigen Zuschnitt des Angebots."
},
{
"name": "Überzeugende Angebotsstory",
"weight": 1,
"description": "Verbindet Kundensituation, Handlungsdruck, Lösung, Nutzen, Vorgehen und Glaubwürdigkeit zu einer konsistenten Entscheidungsgrundlage."
}
],
"description": "Übersetzt einen Kundenbedarf in ein überzeugendes, differenziertes und realistisch lieferbares Leistungsversprechen mit klarer Wirkung, Vorgehensweise und Nutzenargumentation."
},
{
"name": "Workshop Design & Facilitation",
"items": [
{
"name": "Ziel- und Ergebnisdefinition",
"weight": 1,
"description": "Klärt vorab, welches konkrete Ergebnis, welche Entscheidung oder welcher Arbeitsfortschritt im Workshop erreicht werden soll."
},
{
"name": "Teilnehmer- und Stakeholderdesign",
"weight": 1,
"description": "Bindet die für Inhalt, Entscheidung und Umsetzung relevanten Personen mit passenden Rollen ein."
},
{
"name": "Dramaturgie und Methodenauswahl",
"weight": 1,
"description": "Entwickelt eine logische Agenda und wählt Methoden, die Ziel, Teilnehmergruppe, Zeit und Arbeitsmodus unterstützen."
},
{
"name": "Vorbereitung und Arbeitsfähigkeit",
"weight": 1,
"description": "Stellt Inhalte, Leitfragen, Daten, Materialien, Technik, Rollen und organisatorische Voraussetzungen rechtzeitig bereit."
},
{
"name": "Zielgerichtete Moderation",
"weight": 1,
"description": "Führt verständlich durch den Prozess, hält Fokus und Zeit und macht Übergänge sowie Arbeitsaufträge klar."
},
{
"name": "Aktivierung und Gruppendynamik",
"weight": 1,
"description": "Fördert ausgewogene Beteiligung, geht konstruktiv mit Dominanz, Zurückhaltung, Konflikten und Abschweifungen um."
},
{
"name": "Ergebnissicherung und Follow-up",
"weight": 1,
"description": "Verdichtet Diskussionen, dokumentiert Entscheidungen, Verantwortlichkeiten und nächste Schritte und stellt eine klare Nachbereitung sicher."
}
],
"description": "Konzipiert und moderiert Workshops so, dass relevante Beteiligte fokussiert zusammenarbeiten, belastbare Ergebnisse erzielen und klare Entscheidungen oder nächste Schritte vereinbaren."
}
]
},
{
"name": "AI-Readiness",
"categories": [
{
"name": "AI Fundamentals",
"items": [
{
"name": "Grundverständnis generativer KI",
"weight": 1,
"description": "Erklärt in angemessener Tiefe, wie Modelle, Trainingsdaten, Kontext, Prompts und probabilistische Ausgabe die Ergebnisse beeinflussen."
},
{
"name": "Use-Case-Eignung beurteilen",
"weight": 1,
"description": "Erkennt, für welche Aufgaben KI sinnvoll ist und wo menschliche Expertise, klassische Analyse oder andere Werkzeuge geeigneter sind."
},
{
"name": "Grenzen und Fehlerbilder erkennen",
"weight": 1,
"description": "Berücksichtigt insbesondere Halluzinationen, Bias, fehlende Aktualität, Scheingenauigkeit, Kontextverlust und unvollständige Quellenlage."
},
{
"name": "Datenschutz und Vertraulichkeit",
"weight": 1,
"description": "Behandelt Kunden-, Personen- und Unternehmensdaten entsprechend den geltenden Sicherheits-, Datenschutz- und Vertraulichkeitsvorgaben."
},
{
"name": "Responsible-AI-Verständnis",
"weight": 1,
"description": "Berücksichtigt ethische, rechtliche, sicherheitsbezogene und gesellschaftliche Auswirkungen beim Einsatz von KI."
},
{
"name": "Qualitätssicherung von KI-Ergebnissen",
"weight": 1,
"description": "Prüft Ergebnisse fachlich, logisch, methodisch und quellenbezogen und übernimmt keine KI-Ausgabe ungeprüft."
}
],
"description": "Versteht Funktionsweise, Einsatzvoraussetzungen, Chancen, Grenzen und Risiken von KI auf einem für die Beratung angemessenen Niveau und handelt verantwortungsvoll."
},
{
"name": "AI-Augmented Consulting",
"items": [
{
"name": "Zielgerichtete Tool- und Use-Case-Auswahl",
"weight": 1,
"description": "Wählt KI-Werkzeug und Einsatzform passend zu Aufgabe, Datenlage, Risiko und erwartetem Mehrwert aus."
},
{
"name": "Kontext- und Promptgestaltung",
"weight": 1,
"description": "Formuliert klare Arbeitsaufträge, stellt relevanten Kontext bereit und verbessert Ergebnisse iterativ durch geeignete Steuerung."
},
{
"name": "KI-gestützte Recherche und Verdichtung",
"weight": 1,
"description": "Nutzt KI zur Informationsstrukturierung und Synthese, überprüft jedoch Quellen, Aktualität und Vollständigkeit."
},
{
"name": "KI-gestützte Analyse und Hypothesenbildung",
"weight": 1,
"description": "Nutzt KI zur Entwicklung von Analysefragen, Hypothesen, Strukturierungsansätzen oder Auswertungsoptionen und validiert diese eigenständig."
},
{
"name": "KI-gestützte Storyline- und Deliverable-Erstellung",
"weight": 1,
"description": "Erstellt mit KI erste Entwürfe für Storylines, Texte, Folien oder Arbeitsmaterialien und veredelt sie fachlich sowie kundenspezifisch."
},
{
"name": "KI-gestützte Meeting- und Workshopvorbereitung",
"weight": 1,
"description": "Nutzt KI für Interviewleitfäden, Fragen, Szenarien, Zusammenfassungen oder Moderationsvorbereitung und prüft die Praxistauglichkeit."
},
{
"name": "Systematische Workflow-Integration",
"weight": 1,
"description": "Identifiziert wiederkehrende Arbeitsschritte, integriert KI reproduzierbar und dokumentiert Vorgehen, Qualitätskontrollen und menschliche Entscheidungen angemessen."
}
],
"description": "Integriert KI gezielt und verantwortungsvoll in typische Beratungsaufgaben, um Qualität, Geschwindigkeit und Erkenntnisgewinn zu verbessern, ohne die Verantwortung für Ergebnisse abzugeben."
},
{
"name": "AI Certification",
"items": [
{
"name": "Vorgegebene Basiszertifizierung",
"weight": 1,
"description": "Hat die für die Rolle oder Entwicklungsstufe vorgeschriebene grundlegende KI-Zertifizierung erfolgreich abgeschlossen."
},
{
"name": "Rollen- oder technologiespezifische Zertifizierung",
"weight": 1,
"description": "Verfügt über die für Einsatzgebiet, Technologie oder Projektkontext relevanten weiterführenden Nachweise, soweit diese gefordert sind."
},
{
"name": "Aktualität des Nachweises",
"weight": 1,
"description": "Hält verpflichtende Zertifizierungen, Rezertifizierungen und vorgeschriebene Lernstände aktuell."
},
{
"name": "Nachgewiesener Praxistransfer",
"weight": 1,
"description": "Kann zentrale Inhalte der Zertifizierung in Übungen oder Assignments korrekt anwenden und deren Grenzen erläutern."
}
],
"description": "Dokumentiert formale, rollenbezogene KI-Qualifikationsnachweise. Da Zertifizierungen primär einen Nachweisstatus und kein beobachtbares Verhalten darstellen, sollten diese Kriterien möglichst als erfüllt, nicht erfüllt oder nicht erforderlich geführt werden."
}
]
},
{
"name": "Culture-Readiness",
"categories": [
{
"name": "Quality Excellence",
"items": [
{
"name": "Qualitätsanspruch",
"weight": 1,
"description": "Richtet die eigene Arbeit sichtbar auf fachlich belastbare, verständliche und kundentaugliche Ergebnisse aus."
},
{
"name": "Systematische Qualitätschecks",
"weight": 1,
"description": "Prüft Inhalte, Zahlen, Logik, Sprache, Quellen, Format und Konsistenz vor Weitergabe anhand geeigneter Kontrollschritte."
},
{
"name": "Prozess- und Standardtreue",
"weight": 1,
"description": "Kennt relevante Prozesse, Methoden, Templates und Vorgaben und wendet sie zweckmäßig sowie nachvollziehbar an."
},
{
"name": "Reviewfähigkeit",
"weight": 1,
"description": "Bereitet Arbeitsergebnisse so vor, dass andere sie effizient prüfen können, und arbeitet Reviewhinweise sauber und vollständig ein."
},
{
"name": "Frühzeitige Qualitätssicherung",
"weight": 1,
"description": "Plant Reviews, Abstimmungen und Prüfschritte rechtzeitig ein, statt Qualität ausschließlich am Ende herzustellen."
},
{
"name": "Integrität und Compliance",
"weight": 1,
"description": "Handelt ehrlich und regelkonform, macht Unsicherheiten oder Fehler transparent und wahrt Vertraulichkeit sowie relevante fachliche und organisatorische Vorgaben."
},
{
"name": "Kontinuierliche Verbesserung",
"weight": 1,
"description": "Erkennt wiederkehrende Fehler oder Ineffizienzen und verbessert Vorgehen, Standards oder Hilfsmittel nachhaltig."
}
],
"description": "Verankert Qualität als kontinuierliche Arbeitsweise und sorgt durch Standards, Reviews und kritische Prüfung dafür, dass belastbare Ergebnisse reproduzierbar entstehen."
},
{
"name": "Drive (Extra Mile)",
"items": [
{
"name": "Proaktive Initiative",
"weight": 1,
"description": "Erkennt Handlungsbedarf oder Chancen und wird selbstständig aktiv, ohne auf detaillierte Einzelaufträge zu warten."
},
{
"name": "Ambitionierter Wirkungsanspruch",
"weight": 1,
"description": "Strebt nicht nur formale Erledigung, sondern ein erkennbar nützliches und überzeugendes Ergebnis an."
},
{
"name": "Beharrlichkeit bei Hindernissen",
"weight": 1,
"description": "Verfolgt wichtige Themen trotz Rückschlägen konstruktiv weiter und sucht aktiv nach alternativen Lösungswegen."
},
{
"name": "Mehrwert über den Auftrag hinaus",
"weight": 1,
"description": "Identifiziert sinnvolle zusätzliche Beiträge, Impulse oder wiederverwendbare Lösungen, ohne Scope, Prioritäten oder Wirtschaftlichkeit aus dem Blick zu verlieren."
},
{
"name": "Anpassungsfähigkeit",
"weight": 1,
"description": "Bleibt bei veränderten Prioritäten, unvollständigen Informationen oder neuen Anforderungen handlungsfähig und richtet das Vorgehen neu aus."
},
{
"name": "Konstruktive Energie unter Druck",
"weight": 1,
"description": "Bewahrt auch bei hoher Belastung Fokus, Lösungsorientierung und professionelles Verhalten."
}
],
"description": "Zeigt wirkungsorientierte Initiative, Ausdauer und Gestaltungswillen und leistet bei relevantem Mehrwert bewusst mehr als das formal Geforderte. Bewertet wird nicht die Anzahl der Arbeitsstunden, sondern der zusätzliche Beitrag zum Ergebnis."
},
{
"name": "Ownership",
"items": [
{
"name": "Auftrags- und Ergebnisverständnis",
"weight": 1,
"description": "Klärt Ziel, Qualitätsmaßstab, Umfang, Rollen, Abhängigkeiten und erwartete Lieferung, bevor die Umsetzung fortschreitet."
},
{
"name": "Eigenständige Arbeitssteuerung",
"weight": 1,
"description": "Plant, priorisiert und organisiert die eigene Arbeit nachvollziehbar und benötigt nur ein der Entwicklungsstufe angemessenes Maß an Steuerung."
},
{
"name": "Verbindlichkeit und Zuverlässigkeit",
"weight": 1,
"description": "Hält Zusagen ein und behandelt vereinbarte Termine, Qualitätsziele und Verantwortlichkeiten verbindlich."
},
{
"name": "Fortschritts- und Risikotransparenz",
"weight": 1,
"description": "Kommuniziert Arbeitsstand, offene Punkte, Abhängigkeiten und mögliche Abweichungen frühzeitig und konkret und vermittelt eine realistische Einschätzung der Zielerreichung."
},
{
"name": "Stufenweises Alignment und Acceptance Management",
"weight": 1,
"description": "Validiert Annahmen, Konzepte und Zwischenergebnisse rechtzeitig mit den relevanten Kundenvertretern. Sichert ein explizites gemeinsames Verständnis beziehungsweise eine Teilfreigabe als Grundlage für die weitere Bearbeitung und dokumentiert Entscheidungen, Vorbehalte und Änderungen nachvollziehbar."
},
{
"name": "Vorausschauendes Handeln",
"weight": 1,
"description": "Erkennt Risiken, Engpässe und Entscheidungsbedarfe früh und leitet geeignete Maßnahmen ein."
},
{
"name": "Angemessene Entscheidung und Eskalation",
"weight": 1,
"description": "Entscheidet im eigenen Verantwortungsbereich selbstständig und eskaliert rechtzeitig mit klarer Sachlage und Lösungsvorschlägen."
},
{
"name": "Konsequenter Abschluss",
"weight": 1,
"description": "Verfolgt offene Punkte bis zu einem belastbaren Ergebnis, sichert Übergaben und schließt Kommunikations- und Dokumentationsschleifen."
}
],
"description": "Übernimmt End-to-End-Verantwortung für übertragene Aufgaben und angestrebte Ergebnisse, steuert die eigene Arbeit aktiv und schafft jederzeit Transparenz über Fortschritt, Risiken und Handlungsbedarf."
},
{
"name": "Coachability",
"items": [
{
"name": "Aktives Einholen von Feedback",
"weight": 1,
"description": "Fragt gezielt und rechtzeitig nach Rückmeldung zu Wirkung, Vorgehen und Ergebnis, statt nur auf formale Feedbacktermine zu warten."
},
{
"name": "Offene Feedbackannahme",
"weight": 1,
"description": "Hört Feedback aufmerksam an, bleibt sachlich und vermeidet vorschnelle Rechtfertigung oder Abwehr."
},
{
"name": "Verständnisklärung",
"weight": 1,
"description": "Fragt bei unklarer Rückmeldung nach konkreten Situationen, Erwartungen und beobachtbaren Alternativen."
},
{
"name": "Übersetzung in Entwicklungsmaßnahmen",
"weight": 1,
"description": "Leitet aus Feedback konkrete, realistische und überprüfbare Verhaltens- oder Lernschritte ab."
},
{
"name": "Sichtbarer Praxistransfer",
"weight": 1,
"description": "Wendet vereinbarte Verbesserungen in folgenden Aufgaben erkennbar an und zeigt nachhaltigen Fortschritt."
},
{
"name": "Selbstreflexion und Lernagilität",
"weight": 1,
"description": "Analysiert eigene Wirkung, Stärken, Fehler und Muster realistisch und passt Verhalten an neue Situationen an."
}
],
"description": "Nutzt Feedback, Reflexion und neue Erfahrungen aktiv zur Weiterentwicklung und zeigt nachvollziehbare Verhaltensänderungen über mehrere Assignments hinweg."
},
{
"name": "Collaboration",
"items": [
{
"name": "Proaktive Abstimmung",
"weight": 1,
"description": "Stimmt sich rechtzeitig mit relevanten Beteiligten ab, macht Schnittstellen transparent und vermeidet Informationslücken."
},
{
"name": "Wissens- und Ergebnisteilung",
"weight": 1,
"description": "Teilt Informationen, Zwischenergebnisse, Erfahrungen und wiederverwendbare Inhalte so, dass das Team wirksam weiterarbeiten kann."
},
{
"name": "Unterstützungsbereitschaft",
"weight": 1,
"description": "Unterstützt andere konstruktiv und bedarfsgerecht, ohne eigene Verantwortlichkeiten oder gemeinsame Prioritäten zu vernachlässigen."
},
{
"name": "Verlässliches Teamverhalten",
"weight": 1,
"description": "Hält Teamzusagen ein, respektiert Rollen und schafft durch professionelles Verhalten eine belastbare Arbeitsbasis."
},
{
"name": "Konstruktiver Umgang mit Differenzen",
"weight": 1,
"description": "Spricht abweichende Sichtweisen sachlich an, hört Gegenargumente an und arbeitet auf eine tragfähige Lösung hin."
},
{
"name": "Einbindung unterschiedlicher Perspektiven",
"weight": 1,
"description": "Bezieht relevante Erfahrungen und Meinungen aktiv ein und schafft Raum für Beteiligung unabhängig von Rolle oder Hierarchie."
},
{
"name": "Team- und organisationsübergreifende Zusammenarbeit",
"weight": 1,
"description": "Vernetzt sich bei Bedarf über das unmittelbare Team hinaus und nutzt passende Experten, Communities oder Kontakte für den gemeinsamen Erfolg."
}
],
"description": "Arbeitet verlässlich, respektvoll und lösungsorientiert mit anderen zusammen, stärkt den gemeinsamen Teamerfolg und nutzt unterschiedliche Perspektiven und Fähigkeiten konstruktiv."
}
]
}
]
}

View File

@ -1,11 +1,16 @@
// ─── Bewertungs-Skala ────────────────────────────────────────────────────────
// ─── Bewertungs-Skala (110) ─────────────────────────────────────────────────
export type FeedbackRating =
| 'na'
| 'not_client_ready'
| 'partially_client_ready'
| 'nearly_client_ready'
| 'client_ready'
/** Ganzzahlige Bewertung 110. Speicherung: INTEGER; „nicht gesetzt" = null (kein N/A-String). */
export type RatingScore = number
/** Farbband für die 110-Skala — Grenzen/Keys editierbar in Config → Skala. */
export type RatingBandKey = 'red' | 'orange' | 'lightGreen' | 'darkGreen'
export interface RatingColorBand {
/** Inklusive Obergrenze dieses Bandes (aufsteigend, letzte max muss 10 sein). */
max: number
key: RatingBandKey
}
// ─── Legacy-Konfiguration (nur noch für Evaluation.tsx) ───────────────────────
@ -44,10 +49,45 @@ export interface PhaseTemplate {
isRepeatable: boolean
hasConversation: boolean
hasAssessment: boolean
/** Opt-in wie hasConversation/hasAssessment — schaltet den Checkliste-Tab in MeetingView frei. */
hasChecklist: boolean
criteriaIds: number[]
order: number
}
/**
* Definition eines Checklisten-Punkts für einen Phasentyp (Config Assignment-Typen).
* Kriterium-Verknüpfung ist bewusst nur bei scope==='perInstitutee' sinnvoll/erlaubt Kriterien
* sind in diesem Modell immer Personen-Kompetenzen, nie Meeting-Kollektiv-Eigenschaften (siehe
* CLAUDE.md Präzisiert bei der Umsetzungsplanung"). Verknüpfte Punkte schreiben direkt in
* `Assessment` (keine eigene Antwort-Speicherung), unverknüpfte in `ChecklistResponse`.
*/
export interface ChecklistItemDef {
id?: number
phaseTemplateId: number
label: string
linkedCriterionItemId?: number
scope: 'perInstitutee' | 'meetingWide'
order: number
}
/**
* Antwort auf einen Checklisten-Punkt für dieses Meeting. `criterionItemId` überschreibt pro
* Antwort individuell die Konfigurations-Vorgabe aus `ChecklistItemDef.linkedCriterionItemId`
* (leer = es gilt die konfigurierte Vorgabe, falls vorhanden) nur bei effektiv vorhandenem
* Kriterium wird nach `Assessment` gespiegelt (siehe CLAUDE.md).
*/
export interface ChecklistResponse {
id?: number
meetingInstanceId: number
/** Gesetzt bei scope 'perInstitutee', leer bei 'meetingWide'. */
instituteeId?: number
checklistItemId: number
rating: RatingScore | null
note: string
criterionItemId?: number
}
// ─── Stammdaten ───────────────────────────────────────────────────────────────
export interface Group {
@ -81,6 +121,10 @@ export interface Assignment {
from?: string
to?: string
}>
/** Papierkorb-Zeitpunkt — analog MeetingInstance.deletedAt, nach 30 Tagen automatisch endgültig gelöscht. */
deletedAt?: string
/** Archiv-Zeitpunkt — dauerhafte Aufbewahrung (spätere Auswertung/ML), unabhängig vom Papierkorb. */
archivedAt?: string
}
// ─── Meeting-Instanzen ────────────────────────────────────────────────────────
@ -94,10 +138,17 @@ export interface MeetingInstance {
status: 'planned' | 'active' | 'done'
generalNotes: string
deletedAt?: string
/** Kriterium-Zuordnung pro Zeile der allgemeinen Notizen — anders als ConversationEntry.lineTags NICHT an ein Institutee gebunden, gilt für alle Institutees des Assignments. */
generalNoteTags?: ConversationLineTag[]
}
// ─── Gesprächs-Zeitleiste ─────────────────────────────────────────────────────
export interface ConversationLineTag {
text: string
criterionItemId: number // → FeedbackCriterionItem.id
}
export interface ConversationEntry {
id?: number
meetingInstanceId: number
@ -106,6 +157,7 @@ export interface ConversationEntry {
note: string
fillerCount: number
updatedAt: string
lineTags?: ConversationLineTag[] // Kriterium-Zuordnung pro Zeile, gematcht per exaktem Zeilentext
}
// criteriaId → FeedbackCategory.id (Schnellbewertung während Gesprächsbeitrag)
@ -113,22 +165,35 @@ export interface ConversationSkillScore {
id?: number
conversationEntryId: number
criteriaId: number
score: FeedbackRating | null
score: RatingScore | null
}
// ─── Gesamtbewertung ─────────────────────────────────────────────────────────
// criteriaId → FeedbackCriterionItem.id (Gesamtbewertung am Meeting-Ende)
// criteriaId → FeedbackCriterionItem.id (optionale Detail-Bewertung / Beobachtungsnotiz am Meeting)
export interface Assessment {
id?: number
meetingInstanceId: number
instituteeId: number
criteriaId: number
score: FeedbackRating | null
score: RatingScore | null
note: string
updatedAt: string
}
/**
* Pflicht-Meeting-Bewertung auf Kategorie-Ebene (110), unabhängig davon ob alle
* Kriterien beobachtet wurden siehe docs/SCALE_1_TO_10.md.
*/
export interface MeetingCategoryRating {
id?: number
meetingInstanceId: number
instituteeId: number
categoryId: number
score: RatingScore | null
note?: string
}
// ─── Feedback-Entwurf (Legacy) ────────────────────────────────────────────────
export interface FeedbackDraft {
@ -152,6 +217,8 @@ export interface FeedbackCategory {
dimensionId: number
name: string
order: number
/** Erläuterung, welche Themen/Beobachtungen in diese Kategorie einzahlen — fließt in {{KATEGORIEN_STRUKTUR}} der KI-Prompts ein. */
description?: string
}
export interface FeedbackCriterionItem {
@ -160,6 +227,10 @@ export interface FeedbackCriterionItem {
name: string
weight?: number
order: number
/** Konkretisierung, worauf bei diesem Kriterium zu achten ist — fließt in {{KRITERIEN_SCORES}} der KI-Prompts ein. */
description?: string
/** KI-destillierte, generalisierte Formulierungsmuster (Nebenprodukt des „🏷 Vorschläge analysieren"-Laufs) — fließt in {{GELERNTE_MUSTER}} des Tagging-Prompts ein, wird bei jedem weiteren Lauf überschrieben. */
learnedPatternHints?: string
}
export interface AssignmentFeedback {
@ -175,15 +246,37 @@ export interface FeedbackCategoryRating {
id?: number
assignmentFeedbackId: number
feedbackCategoryId: number
rating: FeedbackRating
rating: RatingScore
}
export interface FeedbackDimensionText {
id?: number
assignmentFeedbackId: number
feedbackDimensionId: number
/** DB-Feldname historisch; UI/PDF: Benefits */
achievements: string
/** DB-Feldname historisch; UI/PDF: Concerns */
developmentNeeds: string
suggestedAchievements?: string
suggestedDevelopmentNeeds?: string
}
// ─── KI-Prompt-Konfiguration & Kategorie-Texte ────────────────────────────────
export interface FeedbackDimensionPrompt {
id?: number
dimensionId: number
promptTemplate: string
}
export interface FeedbackCategoryText {
id?: number
assignmentFeedbackId: number
feedbackCategoryId: number
benefits: string
concerns: string
suggestedBenefits?: string
suggestedConcerns?: string
}
// ─── Legacy-Mappings ──────────────────────────────────────────────────────────
@ -197,6 +290,21 @@ export interface CriterionCategoryMapping {
export interface CriterionLevelDescription {
id?: number
criterionItemId: number
rating: FeedbackRating
rating: RatingScore
text: string
}
// ─── App-Einstellungen (Singleton, id immer 1) ───────────────────────────────
export interface AppSettings {
id?: number
ratingTrendWeightStep: number // 0 = keine Zeitgewichtung, höher = stärkere Gewichtung späterer Meetings
/** Verdichtungs-Prompt Stufe B (Dimension aus Kategorie-Ergebnissen), editierbar in Config → KI-Prompts. */
defaultDimensionPromptTemplate?: string
/** Kategorie-Prompt Stufe A (1 Call pro Kategorie), editierbar in Config → KI-Prompts. */
defaultCategoryPromptTemplate?: string
/** Prompt-Template für „🏷 Vorschläge analysieren" (selbstlernendes Kriterien-Tagging), editierbar in Config → KI-Prompts. */
criterionTaggingPromptTemplate?: string
/** Farbbänder der 110-Skala (Defaults in constants.ts), editierbar in Config → Skala. */
ratingColorBands?: RatingColorBand[]
}

View File

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react'
import { DEFAULT_RATING_COLOR_BANDS, normalizeRatingColorBands } from '../config/constants'
import { getOrSeedRatingColorBands } from '../db'
import type { RatingColorBand } from '../db/types'
/** Lädt die konfigurierten Farbbänder einmalig (Config → Skala). */
export function useRatingColorBands(): RatingColorBand[] {
const [bands, setBands] = useState<RatingColorBand[]>(DEFAULT_RATING_COLOR_BANDS)
useEffect(() => {
getOrSeedRatingColorBands()
.then(b => setBands(normalizeRatingColorBands(b)))
.catch(() => setBands(DEFAULT_RATING_COLOR_BANDS))
}, [])
return bands
}

View File

@ -3,13 +3,6 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import { seedIfEmpty, seedFeedbackStructureIfEmpty, seedCriterionMappingsIfEmpty, migrateToUnifiedCriteriaIfNeeded } from './db/index.ts'
seedIfEmpty()
seedFeedbackStructureIfEmpty().then(() => {
seedCriterionMappingsIfEmpty()
migrateToUnifiedCriteriaIfNeeded()
})
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@ -0,0 +1,326 @@
import { useEffect, useRef, useState } from 'react'
import {
type FeedbackDimension, type FeedbackDimensionPrompt,
loadFeedbackStructure, listFeedbackDimensionPrompts, updateFeedbackDimensionPrompt,
resetAllDimensionPromptsToDefault,
getOrSeedDefaultDimensionPromptTemplate, saveDefaultDimensionPromptTemplate,
getOrSeedDefaultCategoryPromptTemplate, saveDefaultCategoryPromptTemplate,
getOrSeedCriterionTaggingPromptTemplate, saveCriterionTaggingPromptTemplate,
} from '../db'
import { AI_CONFIG } from '../config/constants'
import { getAiSettings, saveAiSettings } from '../utils/aiClient'
export default function AiPromptConfig() {
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
const [prompts, setPrompts] = useState<FeedbackDimensionPrompt[]>([])
const [defaultTemplate, setDefaultTemplate] = useState('')
const [defaultExpanded, setDefaultExpanded] = useState(false)
const [defaultSaved, setDefaultSaved] = useState(false)
const [categoryTemplate, setCategoryTemplate] = useState('')
const [categoryExpanded, setCategoryExpanded] = useState(false)
const [categorySaved, setCategorySaved] = useState(false)
const [taggingTemplate, setTaggingTemplate] = useState('')
const [taggingExpanded, setTaggingExpanded] = useState(false)
const [taggingSaved, setTaggingSaved] = useState(false)
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [savedDimId, setSavedDimId] = useState<number | null>(null)
const [bulkResetMsg, setBulkResetMsg] = useState('')
const saveTimer = useRef<Map<number | 'default' | 'category' | 'tagging', ReturnType<typeof setTimeout>>>(new Map())
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
const [aiModel, setAiModel] = useState(getAiSettings().model)
const [aiSaved, setAiSaved] = useState(false)
const load = async () => {
const [structure, promptRows, defTemplate, catTemplate, taggingTpl] = await Promise.all([
loadFeedbackStructure(),
listFeedbackDimensionPrompts(),
getOrSeedDefaultDimensionPromptTemplate(),
getOrSeedDefaultCategoryPromptTemplate(),
getOrSeedCriterionTaggingPromptTemplate(),
])
setDimensions([...structure.dimensions].sort((a, b) => a.order - b.order))
setPrompts(promptRows)
setDefaultTemplate(defTemplate)
setCategoryTemplate(catTemplate)
setTaggingTemplate(taggingTpl)
}
useEffect(() => { load() }, [])
const getPrompt = (dimId: number) => prompts.find(p => p.dimensionId === dimId)
const saveTemplate = (dimId: number, value: string) => {
setPrompts(prev => prev.map(p => p.dimensionId === dimId ? { ...p, promptTemplate: value } : p))
const prevTimer = saveTimer.current.get(dimId)
if (prevTimer) clearTimeout(prevTimer)
saveTimer.current.set(dimId, setTimeout(async () => {
const existing = getPrompt(dimId)
if (!existing?.id) return
await updateFeedbackDimensionPrompt(existing.id, { promptTemplate: value })
setSavedDimId(dimId)
setTimeout(() => setSavedDimId(null), 2000)
}, 600))
}
const resetToDefault = async (dimId: number) => {
const existing = getPrompt(dimId)
if (!existing?.id) return
await updateFeedbackDimensionPrompt(existing.id, { promptTemplate: defaultTemplate })
setPrompts(prev => prev.map(p => p.dimensionId === dimId ? { ...p, promptTemplate: defaultTemplate } : p))
}
const resetAllToDefault = async () => {
if (!confirm('Alle Dimension-Prompts wirklich auf den Standard (110) setzen? Individuelle Anpassungen gehen verloren.')) return
const { updated } = await resetAllDimensionPromptsToDefault()
const promptRows = await listFeedbackDimensionPrompts()
setPrompts(promptRows)
setBulkResetMsg(`${updated} Dimension-Prompt(s) auf Standard gesetzt.`)
setTimeout(() => setBulkResetMsg(''), 4000)
}
const saveDefaultTemplate = (value: string) => {
setDefaultTemplate(value)
const prevTimer = saveTimer.current.get('default')
if (prevTimer) clearTimeout(prevTimer)
saveTimer.current.set('default', setTimeout(async () => {
await saveDefaultDimensionPromptTemplate(value)
setDefaultSaved(true)
setTimeout(() => setDefaultSaved(false), 2000)
}, 600))
}
const saveCategoryTemplate = (value: string) => {
setCategoryTemplate(value)
const prevTimer = saveTimer.current.get('category')
if (prevTimer) clearTimeout(prevTimer)
saveTimer.current.set('category', setTimeout(async () => {
await saveDefaultCategoryPromptTemplate(value)
setCategorySaved(true)
setTimeout(() => setCategorySaved(false), 2000)
}, 600))
}
const saveTaggingTemplate = (value: string) => {
setTaggingTemplate(value)
const prevTimer = saveTimer.current.get('tagging')
if (prevTimer) clearTimeout(prevTimer)
saveTimer.current.set('tagging', setTimeout(async () => {
await saveCriterionTaggingPromptTemplate(value)
setTaggingSaved(true)
setTimeout(() => setTaggingSaved(false), 2000)
}, 600))
}
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold text-gray-800">KI-Feedback (Kaskade)</h2>
<p className="text-sm text-gray-500 mt-1">
Stufe A: ein Call pro Kategorie (globaler Kategorie-Prompt). Stufe B: ein Call pro Dimension
(Verdichtung aus Kategorie-Ergebnissen pro Dimension editierbar unten).
</p>
</div>
<div className="bg-white rounded-xl border border-blue-100 shadow-sm p-4 space-y-3">
<div className="text-sm font-semibold text-blue-800">OpenRouter API-Key</div>
<p className="text-xs text-gray-500">
Wird lokal im Browser gespeichert (<span className="font-mono">localStorage</span>) nötig für
KI-Feedback und Vorschläge analysieren". Key von{' '}
<span className="font-mono">openrouter.ai</span>.
</p>
<input
type="password"
className="w-full border border-blue-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
placeholder="sk-or-..."
value={aiKey}
onChange={e => setAiKey(e.target.value)}
/>
<select
className="w-full border border-blue-200 rounded-lg px-3 py-2 text-sm bg-white"
value={aiModel}
onChange={e => setAiModel(e.target.value)}>
<option value="openai/gpt-4o-mini">GPT-4o mini (günstig)</option>
<option value="openai/gpt-4o">GPT-4o</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="anthropic/claude-haiku-4-5">Claude Haiku 4.5 (schnell)</option>
<option value="google/gemini-flash-1.5">Gemini Flash 1.5</option>
</select>
<button
onClick={() => {
saveAiSettings(aiKey, aiModel)
setAiSaved(true)
setTimeout(() => setAiSaved(false), 2000)
}}
className="w-full py-2.5 bg-brand text-white rounded-xl text-sm font-semibold">
API-Key speichern
</button>
{aiSaved && (
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded-lg px-3 py-2 text-center">
Gespeichert.
</div>
)}
</div>
{/* Kategorie-Prompt Stufe A */}
<div className="bg-white rounded-xl border border-green-100 shadow-sm overflow-hidden">
<button
onClick={() => setCategoryExpanded(p => !p)}
className="w-full flex items-center justify-between p-3 bg-green-50 border-b border-green-100 text-left">
<span className="font-semibold text-sm text-green-800">Stufe A Kategorie-Prompt (1 Call pro Kategorie)</span>
<span className="text-xs text-green-400">{categoryExpanded ? '▲' : '▼'}</span>
</button>
{categoryExpanded && (
<div className="p-3 space-y-2">
<p className="text-xs text-gray-500">
Wird für jede Kategorie einzeln aufgerufen. Der Trend-Vorschlag aus Meetings wird im Prompt
vorgegeben; die KI bestätigt oder passt maximal ±1 an.
</p>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[280px]"
value={categoryTemplate}
onChange={e => saveCategoryTemplate(e.target.value)}
/>
{categorySaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
<div className="grid gap-1.5 pt-2">
{AI_CONFIG.categoryPromptPlaceholders.map(p => (
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
<code className="bg-white border border-gray-200 rounded px-1.5 py-0.5 text-brand font-mono flex-shrink-0">{p.token}</code>
<span>{p.description}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Standard-Prompt Stufe B */}
<div className="bg-white rounded-xl border border-blue-100 shadow-sm overflow-hidden">
<button
onClick={() => setDefaultExpanded(p => !p)}
className="w-full flex items-center justify-between p-3 bg-blue-50 border-b border-blue-100 text-left">
<span className="font-semibold text-sm text-blue-800">Stufe B Dimensions-Verdichtung (Basis für neue Dimensionen &amp; Zurücksetzen")</span>
<span className="text-xs text-blue-400">{defaultExpanded ? '▲' : '▼'}</span>
</button>
{defaultExpanded && (
<div className="p-3 space-y-2">
<p className="text-xs text-gray-500">
Input sind nur die Kategorie-Ergebnisse aus Stufe A kein Rohprotokoll. Wird beim Anlegen
neuer Dimensionen automatisch übernommen und ist die Basis für Auf Standard zurücksetzen".
</p>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[320px]"
value={defaultTemplate}
onChange={e => saveDefaultTemplate(e.target.value)}
/>
{defaultSaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
</div>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={resetAllToDefault}
className="text-xs bg-amber-50 hover:bg-amber-100 text-amber-800 border border-amber-200 px-3 py-1.5 rounded-lg font-medium">
Alle Dimension-Prompts auf Standard (Stufe B) setzen
</button>
{bulkResetMsg && <span className="text-xs text-green-600 font-medium">{bulkResetMsg}</span>}
</div>
{/* Platzhalter-Legende */}
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Verfügbare Platzhalter</div>
<div className="grid gap-1.5">
{AI_CONFIG.dimensionPromptPlaceholders.map(p => (
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
<code className="bg-white border border-gray-200 rounded px-1.5 py-0.5 text-brand font-mono flex-shrink-0">{p.token}</code>
<span>{p.description}</span>
</div>
))}
</div>
</div>
<div>
<h2 className="text-lg font-bold text-gray-800">KI-Prompt: Kriterien-Zuordnungsvorschläge</h2>
<p className="text-sm text-gray-500 mt-1">
Wird durch den 🏷 Vorschläge analysieren"-Button im Protokoll ausgelöst ein gebündelter Call pro
Protokoll-Ansicht, nicht pro Zeile. Lernt aus bereits vergebenen Kriterium-Zuordnungen.
</p>
</div>
<div className="bg-white rounded-xl border border-indigo-100 shadow-sm overflow-hidden">
<button
onClick={() => setTaggingExpanded(p => !p)}
className="w-full flex items-center justify-between p-3 bg-indigo-50 border-b border-indigo-100 text-left">
<span className="font-semibold text-sm text-indigo-800">🏷 Tagging-Prompt</span>
<span className="text-xs text-indigo-400">{taggingExpanded ? '▲' : '▼'}</span>
</button>
{taggingExpanded && (
<div className="p-3 space-y-2">
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[320px]"
value={taggingTemplate}
onChange={e => saveTaggingTemplate(e.target.value)}
/>
{taggingSaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
</div>
)}
</div>
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Platzhalter im Tagging-Prompt</div>
<div className="grid gap-1.5">
{AI_CONFIG.criterionTaggingPromptPlaceholders.map(p => (
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
<code className="bg-white border border-gray-200 rounded px-1.5 py-0.5 text-brand font-mono flex-shrink-0">{p.token}</code>
<span>{p.description}</span>
</div>
))}
</div>
</div>
{/* Dimensionen */}
{dimensions.map(dim => {
const isOpen = expanded.has(dim.id!)
const prompt = getPrompt(dim.id!)
return (
<div key={dim.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<button
onClick={() => setExpanded(prev => {
const next = new Set(prev)
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
return next
})}
className="w-full flex items-center justify-between p-3 bg-gray-50 border-b border-gray-100 text-left">
<span className="font-semibold text-sm text-gray-800">{dim.name}</span>
<span className="text-xs text-gray-400">{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<div className="p-3 space-y-2">
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[320px]"
value={prompt?.promptTemplate ?? ''}
onChange={e => saveTemplate(dim.id!, e.target.value)}
/>
<div className="flex items-center gap-2">
<button onClick={() => resetToDefault(dim.id!)}
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 px-3 py-1.5 rounded-lg font-medium">
Auf Standard zurücksetzen
</button>
{savedDimId === dim.id && (
<span className="text-xs text-green-600 font-medium">Gespeichert.</span>
)}
</div>
</div>
)}
</div>
)
})}
{dimensions.length === 0 && (
<div className="text-sm text-gray-400 text-center py-6">
Noch keine Dimensionen angelegt siehe Tab Feedback".
</div>
)}
</div>
)
}

View File

@ -0,0 +1,57 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { listAssignmentsWithDetails, unarchiveAssignment, type AssignmentWithDetails } from '../db'
export default function AssignmentArchive() {
const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
const navigate = useNavigate()
const load = () => { listAssignmentsWithDetails().then(setAssignments) }
useEffect(() => { load() }, [])
const archived = assignments.filter(a => a.archivedAt)
const unarchive = async (id: number) => {
await unarchiveAssignment(id)
load()
}
return (
<div className="space-y-3">
<button onClick={() => navigate('/')} className="text-sm text-brand mb-2 block"> Zurück</button>
<h1 className="text-xl font-bold text-gray-800">🗄 Archiv</h1>
{archived.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">Archiv ist leer.</p>
)}
{archived.map(a => (
<div key={a.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{a.assignmentNumber && (
<div className="text-xs font-semibold text-brand uppercase tracking-wide leading-none mb-0.5">
{a.assignmentNumber}
</div>
)}
<div className="font-semibold text-gray-800 truncate">{a.title}</div>
<div className="text-sm text-gray-500">{a.client}</div>
<div className="text-xs text-gray-400 mt-0.5">
{a.typeName} · {a.instituteeNames.join(', ')}
</div>
{a.archivedAt && (
<div className="text-xs text-gray-400 mt-1">
Archiviert am {new Date(a.archivedAt).toLocaleDateString('de-DE')}
</div>
)}
</div>
<button onClick={() => unarchive(a.id!)}
className="flex-shrink-0 text-xs bg-white border border-gray-200 text-gray-600 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Aus Archiv zurückholen
</button>
</div>
</div>
))}
</div>
)
}

View File

@ -1,6 +1,11 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { db, type AssignmentType, type PhaseTemplate, type Consultant, type Group } from '../db'
import {
type AssignmentType, type PhaseTemplate, type Consultant, type Group,
listAssignmentTypes, listPhaseTemplatesForType,
listGroups, listConsultants, addGroup, addConsultant,
addAssignment,
} from '../db'
export default function AssignmentCreate() {
const navigate = useNavigate()
@ -12,6 +17,7 @@ export default function AssignmentCreate() {
title: '',
client: '',
leadName: '',
assignmentNumber: '',
assignmentTypeId: 0,
selectedGroupId: 0,
instituteeIds: [] as number[],
@ -23,9 +29,9 @@ export default function AssignmentCreate() {
const reload = async () => {
const [t, g, c] = await Promise.all([
db.assignmentTypes.toArray(),
db.groups.toArray(),
db.consultants.toArray(),
listAssignmentTypes(),
listGroups(),
listConsultants(),
])
setTypes(t)
setGroups(g)
@ -36,7 +42,7 @@ export default function AssignmentCreate() {
useEffect(() => {
if (!form.assignmentTypeId) { setPhases([]); return }
db.phaseTemplates.where('assignmentTypeId').equals(form.assignmentTypeId).sortBy('order').then(setPhases)
listPhaseTemplatesForType(form.assignmentTypeId).then(setPhases)
}, [form.assignmentTypeId])
const groupConsultants = consultants.filter(c => c.groupId === form.selectedGroupId)
@ -54,10 +60,10 @@ export default function AssignmentCreate() {
let groupId = form.selectedGroupId
if (!groupId) {
// Erstelle eine Standardgruppe falls keine vorhanden
groupId = await db.groups.add({ name: 'Neue Gruppe', cohortYear: new Date().getFullYear() })
groupId = await addGroup({ name: 'Neue Gruppe', cohortYear: new Date().getFullYear() })
setForm(p => ({ ...p, selectedGroupId: groupId }))
}
const id = await db.consultants.add({ groupId, firstName: newFirst.trim(), lastName: newLast.trim() })
const id = await addConsultant({ groupId, firstName: newFirst.trim(), lastName: newLast.trim() })
await reload()
setForm(p => ({ ...p, instituteeIds: [...p.instituteeIds, id] }))
setNewFirst('')
@ -66,11 +72,12 @@ export default function AssignmentCreate() {
}
const create = async () => {
if (!form.title.trim() || !form.client.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return
const id = await db.assignments.add({
if (!form.title.trim() || !form.client.trim() || !form.assignmentNumber.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return
const id = await addAssignment({
title: form.title,
client: form.client,
leadName: form.leadName,
assignmentNumber: form.assignmentNumber.trim(),
assignmentTypeId: form.assignmentTypeId,
instituteeIds: form.instituteeIds,
status: 'active',
@ -79,7 +86,7 @@ export default function AssignmentCreate() {
navigate(`/assignment/${id}`)
}
const canCreate = form.title.trim() && form.client.trim() && form.assignmentTypeId && form.instituteeIds.length > 0
const canCreate = form.title.trim() && form.client.trim() && form.assignmentNumber.trim() && form.assignmentTypeId && form.instituteeIds.length > 0
const selectedInstitutees = consultants.filter(c => form.instituteeIds.includes(c.id!))
return (
@ -88,6 +95,9 @@ export default function AssignmentCreate() {
{/* Basisdaten */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
<input className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
placeholder="Assignment-Nummer (Pflichtfeld)" value={form.assignmentNumber}
onChange={e => setForm(p => ({ ...p, assignmentNumber: e.target.value }))} />
<input className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
placeholder="Titel des Assignments" value={form.title}
onChange={e => setForm(p => ({ ...p, title: e.target.value }))} />

View File

@ -1,19 +1,22 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { db, type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type FeedbackRating, type FeedbackCategory, type FeedbackCriterionItem } from '../db'
import {
type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type RatingScore, type FeedbackCategory, type FeedbackCriterionItem,
getAssignment, getAssignmentType,
listPhaseTemplatesForType,
listMeetingInstancesForAssignment, addMeetingInstance, softDeleteMeetingInstance, restoreMeetingInstance, hardDeleteMeetingInstance,
getConsultantsByIds,
listAssessmentsForMeetings,
listMeetingCategoryRatingsForMeetings,
loadFeedbackStructure, resolveVisibleStructure,
isAssignmentFeedbackComplete, softDeleteAssignment, archiveAssignment,
} from '../db'
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
const RATING_BG: Record<FeedbackRating, string> = {
na: 'bg-gray-400 text-white',
not_client_ready: 'bg-red-500 text-white',
partially_client_ready: 'bg-orange-500 text-white',
nearly_client_ready: 'bg-green-400 text-white',
client_ready: 'bg-green-700 text-white',
}
const NUM_MAP: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
const avgToRating = (avg: number): FeedbackRating =>
avg < 1.5 ? 'not_client_ready' : avg < 2.5 ? 'partially_client_ready' : avg < 3.5 ? 'nearly_client_ready' : 'client_ready'
import { exportAssignmentReportPdf, exportInstituteeFeedbackPdf } from '../utils/pdfExport'
import { resolveScoreStyle } from '../config/constants'
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
import { useRatingColorBands } from '../hooks/useRatingColorBands'
import type { MeetingCategoryRating } from '../db'
interface PhaseRow {
template: PhaseTemplate
@ -37,19 +40,26 @@ export default function AssignmentDetail() {
const [confirmSoftDelete, setConfirmSoftDelete] = useState<number | null>(null)
const [confirmHardDelete, setConfirmHardDelete] = useState<number | null>(null)
const [allAssessments, setAllAssessments] = useState<Assessment[]>([])
const [categoryRatings, setCategoryRatings] = useState<MeetingCategoryRating[]>([])
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const colorBands = useRatingColorBands()
const [feedbackComplete, setFeedbackComplete] = useState(false)
const [confirmAssignmentTrash, setConfirmAssignmentTrash] = useState(false)
const [confirmAssignmentArchive, setConfirmAssignmentArchive] = useState(false)
const [pdfExportingKey, setPdfExportingKey] = useState<string | null>(null)
const load = async () => {
const a = await db.assignments.get(assignmentId)
const a = await getAssignment(assignmentId)
if (!a) return
setAssignment(a)
setFeedbackComplete(a.status === 'done' ? await isAssignmentFeedbackComplete(assignmentId) : false)
const [aType, templates, instances, cons] = await Promise.all([
db.assignmentTypes.get(a.assignmentTypeId),
db.phaseTemplates.where('assignmentTypeId').equals(a.assignmentTypeId).sortBy('order'),
db.meetingInstances.where('assignmentId').equals(assignmentId).toArray(),
db.consultants.where('id').anyOf(a.instituteeIds).toArray(),
getAssignmentType(a.assignmentTypeId),
listPhaseTemplatesForType(a.assignmentTypeId),
listMeetingInstancesForAssignment(assignmentId),
getConsultantsByIds(a.instituteeIds),
])
setAssignmentType(aType ?? null)
@ -63,24 +73,23 @@ export default function AssignmentDetail() {
instances: active.filter(i => i.phaseTemplateId === t.id).sort((a, b) => a.phaseIndex - b.phaseIndex),
})))
const instIds = active.map(i => i.id!)
const [asmts, allCats, allItems, aType2] = await Promise.all([
instIds.length > 0 ? db.assessments.where('meetingInstanceId').anyOf(instIds).toArray() : Promise.resolve([]),
db.feedbackCategories.toArray(),
db.feedbackCriterionItems.toArray(),
db.assignmentTypes.get(a.assignmentTypeId),
const [asmts, catRatings, structure, aType2] = await Promise.all([
listAssessmentsForMeetings(instIds),
listMeetingCategoryRatingsForMeetings(instIds),
loadFeedbackStructure(),
getAssignmentType(a.assignmentTypeId),
])
setAllAssessments(asmts)
const itemFilter = aType2?.criteriaIds?.length ? new Set(aType2.criteriaIds) : null
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
setFbItems(filteredItems)
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId))
setFbCategories(itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats)
setCategoryRatings(catRatings)
const visible = resolveVisibleStructure(structure.dimensions, structure.categories, structure.items, aType2?.criteriaIds)
setFbItems(visible.items)
setFbCategories(visible.categories)
}
useEffect(() => { load() }, [assignmentId])
const startMeeting = async (templateId: number, existingCount: number) => {
const mid = await db.meetingInstances.add({
const mid = await addMeetingInstance({
assignmentId,
phaseTemplateId: templateId,
phaseIndex: existingCount + 1,
@ -113,28 +122,51 @@ export default function AssignmentDetail() {
}
const softDeleteMeeting = async (instId: number) => {
await db.meetingInstances.update(instId, { deletedAt: new Date().toISOString() })
await softDeleteMeetingInstance(instId)
setConfirmSoftDelete(null)
await load()
}
const restoreMeeting = async (instId: number) => {
await db.meetingInstances.update(instId, { deletedAt: undefined })
await restoreMeetingInstance(instId)
await load()
}
const hardDeleteMeeting = async (instId: number) => {
await db.conversationSkillScores
.where('conversationEntryId')
.anyOf((await db.conversationEntries.where('meetingInstanceId').equals(instId).primaryKeys()))
.delete()
await db.conversationEntries.where('meetingInstanceId').equals(instId).delete()
await db.assessments.where('meetingInstanceId').equals(instId).delete()
await db.meetingInstances.delete(instId)
await hardDeleteMeetingInstance(instId)
setConfirmHardDelete(null)
await load()
}
const trashAssignment = async () => {
await softDeleteAssignment(assignmentId)
navigate('/')
}
const archiveThisAssignment = async () => {
await archiveAssignment(assignmentId)
navigate('/')
}
const handleExportReportPdf = async () => {
setPdfExportingKey('report')
try {
await exportAssignmentReportPdf(assignmentId)
} finally {
setPdfExportingKey(null)
}
}
const handleExportInstituteeFeedbackPdf = async (instituteeId: number) => {
const key = `inst-${instituteeId}`
setPdfExportingKey(key)
try {
await exportInstituteeFeedbackPdf(assignmentId, instituteeId)
} finally {
setPdfExportingKey(null)
}
}
const statusBadge = (status: MeetingInstance['status']) => {
const map = {
planned: 'bg-yellow-100 text-yellow-700',
@ -158,11 +190,25 @@ export default function AssignmentDetail() {
)}
<h1 className="text-xl font-bold text-gray-800 leading-tight">{assignment.title}</h1>
</div>
<button
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
className="flex-shrink-0 text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
Bearbeiten
</button>
<div className="flex-shrink-0 flex gap-1.5">
<button
onClick={() => navigate(`/assignment/${assignmentId}/history`)}
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
📜 Gesamtprotokoll
</button>
<button
onClick={handleExportReportPdf}
disabled={pdfExportingKey === 'report'}
title="Gesamtprotokoll + Feedback als PDF"
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition disabled:opacity-50">
{pdfExportingKey === 'report' ? '⏳' : '⬇ PDF'}
</button>
<button
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
Bearbeiten
</button>
</div>
</div>
<div className="text-sm text-gray-500">{assignment.client} · {assignmentType?.name}</div>
<div className="text-xs text-gray-400 mt-1">
@ -170,6 +216,52 @@ export default function AssignmentDetail() {
</div>
</div>
{/* Assignment-Aktionen (nur für abgeschlossene Assignments) */}
{assignment.status === 'done' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-2">
<span className="text-sm font-semibold text-gray-700">Assignment-Aktionen</span>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setConfirmAssignmentTrash(p => !p)}
disabled={!feedbackComplete}
title={!feedbackComplete ? 'Erst möglich, wenn für alle Institutees ein finales Feedback vorliegt' : undefined}
className={`text-xs px-3 py-1.5 rounded-lg font-medium ${
feedbackComplete ? 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}>
🗑 In Papierkorb verschieben
</button>
<button
onClick={() => setConfirmAssignmentArchive(p => !p)}
className="text-xs px-3 py-1.5 rounded-lg font-medium bg-gray-50 text-gray-600 border border-gray-200 hover:bg-gray-100">
🗄 Archivieren
</button>
</div>
{!feedbackComplete && (
<div className="text-xs text-gray-400">
Löschen erst möglich, sobald für alle Institutees ein finales Feedback vorliegt.
</div>
)}
{confirmAssignmentTrash && (
<div className="bg-red-50 border border-red-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2">
<span className="text-xs text-red-700">Assignment in den Papierkorb verschieben?</span>
<div className="flex gap-1.5">
<button onClick={trashAssignment} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmAssignmentTrash(false)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
{confirmAssignmentArchive && (
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2">
<span className="text-xs text-gray-600">Assignment archivieren?</span>
<div className="flex gap-1.5">
<button onClick={archiveThisAssignment} className="text-xs bg-gray-700 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmAssignmentArchive(false)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
</div>
)}
{/* Ad-hoc Meeting */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div className="flex items-center justify-between">
@ -204,11 +296,20 @@ export default function AssignmentDetail() {
<span className="text-sm text-amber-800 font-medium">Feedback-Entwürfe</span>
<div className="flex gap-2 flex-wrap">
{institutees.map(c => (
<button key={c.id}
onClick={() => navigate(`/assignment/${assignmentId}/feedback/${c.id}`)}
className="text-xs bg-amber-600 text-white px-3 py-1 rounded-lg">
{c.firstName}
</button>
<div key={c.id} className="flex items-center gap-1">
<button
onClick={() => navigate(`/assignment/${assignmentId}/feedback/${c.id}`)}
className="text-xs bg-amber-600 text-white px-3 py-1 rounded-lg">
{c.firstName}
</button>
<button
onClick={() => handleExportInstituteeFeedbackPdf(c.id!)}
disabled={pdfExportingKey === `inst-${c.id}`}
title={`Feedback für ${c.firstName} als PDF`}
className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-lg disabled:opacity-50">
{pdfExportingKey === `inst-${c.id}` ? '⏳' : '⬇'}
</button>
</div>
))}
</div>
</div>
@ -258,28 +359,24 @@ export default function AssignmentDetail() {
{institutees.map(c => {
const chips = fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
const scored = catItems.map(item => {
const asmt = allAssessments.find(a =>
a.meetingInstanceId === inst.id &&
a.instituteeId === c.id &&
a.criteriaId === item.id &&
a.score !== null && a.score !== 'na'
)
return asmt ? { score: asmt.score as FeedbackRating, w: item.weight ?? 1 } : null
}).filter(Boolean) as { score: FeedbackRating; w: number }[]
if (scored.length === 0) return null
const wSum = scored.reduce((s, x) => s + x.w, 0)
const avg = scored.reduce((s, x) => s + NUM_MAP[x.score] * x.w, 0) / wSum
return { cat, mode: avgToRating(avg) }
}).filter(Boolean) as { cat: FeedbackCategory; mode: FeedbackRating }[]
const mode = resolveCategoryChipScore({
meetingId: inst.id!,
instituteeId: c.id!,
categoryId: cat.id!,
categoryRatings,
assessments: allAssessments,
catItems,
})
return mode ? { cat, mode } : null
}).filter(Boolean) as { cat: FeedbackCategory; mode: RatingScore }[]
if (chips.length === 0) return null
return (
<div key={c.id} className="mt-1">
<span className="text-xs text-gray-400 mr-1">{c.firstName}:</span>
{chips.map(({ cat, mode }) => (
<span key={cat.id} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${RATING_BG[mode]}`}>
{cat.name}
<span key={cat.id} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 tabular-nums ${resolveScoreStyle(mode, colorBands).active}`}>
{cat.name} {mode}
</span>
))}
</div>

View File

@ -1,6 +1,9 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { db, type Assignment, type AssignmentType, type Consultant } from '../db'
import {
type Assignment, type AssignmentType, type Consultant,
getAssignment, updateAssignment, listAssignmentTypes, listConsultants,
} from '../db'
export default function AssignmentEdit() {
const { id } = useParams()
@ -27,9 +30,9 @@ export default function AssignmentEdit() {
useEffect(() => {
const loadData = async () => {
const [a, types, cons] = await Promise.all([
db.assignments.get(assignmentId),
db.assignmentTypes.toArray(),
db.consultants.toArray(),
getAssignment(assignmentId),
listAssignmentTypes(),
listConsultants(),
])
if (!a) return
setAssignment(a)
@ -50,7 +53,7 @@ export default function AssignmentEdit() {
}, [assignmentId])
const handleSave = async () => {
await db.assignments.update(assignmentId, {
await updateAssignment(assignmentId, {
assignmentNumber: assignmentNumber.trim() || undefined,
title,
client,

View File

@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
db,
type Assignment,
type Consultant,
type FeedbackDimension,
@ -10,119 +9,64 @@ import {
type AssignmentFeedback,
type FeedbackCategoryRating,
type FeedbackDimensionText,
type FeedbackRating,
type FeedbackCategoryText,
type RatingScore,
type MeetingInstance,
type Assessment,
type ConversationEntry,
getAssignment, getConsultant, getAssignmentType,
loadFeedbackStructure, resolveVisibleStructure,
listDoneMeetingsChronological,
listAssessmentsForMeetings, listConversationEntriesForMeetings,
listMeetingCategoryRatingsForMeetings,
getRatingTrendWeightStep,
getOrCreateAssignmentFeedback, updateAssignmentFeedback,
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, deleteFeedbackCategoryRating,
listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText,
listFeedbackCategoryTexts, getFeedbackCategoryText, addFeedbackCategoryText, updateFeedbackCategoryText,
getFeedbackDimensionPrompt,
getOrSeedDefaultCategoryPromptTemplate,
getOrSeedDefaultDimensionPromptTemplate,
} from '../db'
import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG } from '../config/constants'
import {
AI_CONFIG, isValidRatingScore, resolveScoreStyle,
} from '../config/constants'
import { ScorePicker } from '../components/ScorePicker'
import { useRatingColorBands } from '../hooks/useRatingColorBands'
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
import { computeTrend, numberToRating, type MeetingScorePoint, type TrendResult } from '../utils/ratingTrend'
import {
buildDimensionCondenseContext, buildCategoryPromptContext,
renderPrompt, parseCategoryAiResponse, parseDimensionCondenseResponse,
type CategoryCondenseEntry,
} from '../utils/aiPrompt'
import { getAiSettings, saveAiSettings, callOpenRouter } from '../utils/aiClient'
import { exportInstituteeFeedbackPdf } from '../utils/pdfExport'
const ratingToNum = (r: FeedbackRating | null | undefined): number =>
r ? (RATING_NUM_MAP[r] ?? 0) : 0
const ratingColor = (r: FeedbackRating | undefined) =>
RATING_OPTIONS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200'
function getAiSettings() {
return {
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
model: localStorage.getItem(AI_CONFIG.localStorageKeyModel) ?? AI_CONFIG.defaultModel,
}
const trendArrow = (trend: TrendResult['trend']) => {
if (trend === 'up') return <span title="Verbesserung über die Laufzeit"></span>
if (trend === 'down') return <span title="Verschlechterung über die Laufzeit"></span>
return null
}
function saveAiSettings(apiKey: string, model: string) {
localStorage.setItem(AI_CONFIG.localStorageKeyApiKey, apiKey)
localStorage.setItem(AI_CONFIG.localStorageKeyModel, model)
}
// ── Prompt-Builder ────────────────────────────────────────────────────────────
async function buildAiPrompt(
institutee: Consultant,
assignment: Assignment,
dimensions: FeedbackDimension[],
categories: FeedbackCategory[],
_items: FeedbackCriterionItem[],
ratings: FeedbackCategoryRating[],
_feedbackId: number,
): Promise<string> {
const meetings = await db.meetingInstances.where('assignmentId').equals(assignment.id!).toArray()
const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([
db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray(),
db.conversationEntries.where('meetingInstanceId').anyOf(meetingIds).toArray(),
])
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
const critIds = [...new Set(instAssessments.map(a => a.criteriaId))]
const crits = critIds.length > 0
? await db.criteria.where('id').anyOf(critIds).toArray()
: []
const scoreLines = crits.map(c => {
const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null)
const avg = scores.length > 0
? (scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length).toFixed(1)
: '—'
const notes = instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note)
return ` ${c.name}: ${avg}/5${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
}).join('\n')
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
const protocolLines = instEntries
.filter(e => e.note)
.map(e => ` - ${e.note}${(e.fillerCount ?? 0) > 0 ? ` [äh×${e.fillerCount}]` : ''}`)
.join('\n')
// Kategorie-Ratings
const ratingLines = dimensions.map(dim => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
const lines = dimCats.map(cat => {
const r = ratings.find(r => r.feedbackCategoryId === cat.id)
const rating = RATING_OPTIONS.find(x => x.value === r?.rating)?.label ?? 'nicht bewertet'
return ` ${cat.name}: ${rating}`
}).join('\n')
return ` Dimension "${dim.name}":\n${lines}`
}).join('\n')
// Feedback-Struktur für Output
const dimStructure = dimensions.map((dim, i) =>
`${i + 1}. ${dim.name}`
).join('\n')
return `Du bist Führungskraft und Mentor bei Capgemini. Erstelle ein professionelles, konstruktives Performance-Feedback für einen Berater/Trainee auf Basis der folgenden Beobachtungsdaten aus echten Kundenterminen.
BERATER: ${institutee.firstName} ${institutee.lastName}
ASSIGNMENT: ${assignment.title} | Kunde: ${assignment.client}
MEETINGS: ${meetings.length} Kundentermin(e)
FÜLLWÖRTER GESAMT: ${fillerTotal}
BEOBACHTETE KRITERIEN UND SCORES (1-5):
${scoreLines || '(keine Scores erfasst)'}
PROTOKOLL-NOTIZEN (Kurznotizen aus dem Gespräch, Notation: + gut, ! negativ, > Kundeninput):
${protocolLines || '(keine Protokolleinträge)'}
KATEGORIE-BEWERTUNGEN:
${ratingLines}
Generiere für jede der folgenden ${dimensions.length} Dimensionen je einen kurzen Absatz "Achievements" (2-4 Sätze, konkret, anerkennend) und "Development Needs" (2-4 Sätze, konstruktiv, handlungsorientiert). Falls eine Dimension aus den Daten nicht beurteilbar ist, schreibe "n/a".
Ausgabe als JSON:
{
"dimensions": [
{
"name": "<Dimensionsname>",
"achievements": "<Text>",
"developmentNeeds": "<Text>"
}
]
}
DIMENSIONEN:
${dimStructure}
Wichtig: Beziehe dich auf konkrete Beobachtungen aus den Daten. Schreibe direkt und persönlich (du-Form). Vermeide generische Phrasen.`
/** Klein gehaltene KI-Vorschlag-Box mit Übernehmen/Verwerfen — gleiche Bedienlogik überall im Feedback. */
function AiSuggestionBox({ text, onAccept, onDiscard }: { text: string; onAccept: () => void; onDiscard: () => void }) {
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2.5 space-y-1.5">
<div className="text-xs font-semibold text-blue-700"> KI-Vorschlag</div>
<div className="text-sm text-blue-900 whitespace-pre-wrap">{text}</div>
<div className="flex gap-1.5">
<button onClick={onAccept}
className="text-xs bg-blue-600 text-white px-2 py-1 rounded-lg font-medium hover:bg-blue-700">
Übernehmen
</button>
<button onClick={onDiscard}
className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-lg font-medium hover:bg-gray-200">
Verwerfen
</button>
</div>
</div>
)
}
// ─── Komponente ───────────────────────────────────────────────────────────────
@ -132,6 +76,7 @@ export default function AssignmentFeedbackPage() {
const navigate = useNavigate()
const assignmentId = Number(id)
const instId = Number(instituteeId)
const colorBands = useRatingColorBands()
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [institutee, setInstitutee] = useState<Consultant | null>(null)
@ -141,76 +86,119 @@ export default function AssignmentFeedbackPage() {
const [feedback, setFeedback] = useState<AssignmentFeedback | null>(null)
const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([])
const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([])
const [categoryTexts, setCategoryTexts] = useState<FeedbackCategoryText[]>([])
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [catTrends, setCatTrends] = useState<Map<number, TrendResult>>(new Map())
const [showAiSettings, setShowAiSettings] = useState(false)
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
const [aiModel, setAiModel] = useState(getAiSettings().model)
const [generating, setGenerating] = useState(false)
const [generationProgress, setGenerationProgress] = useState('')
const [aiError, setAiError] = useState('')
const [dimensionErrors, setDimensionErrors] = useState<Record<number, string>>({})
const [categoryErrors, setCategoryErrors] = useState<Record<string, string>>({})
const [exportingPdf, setExportingPdf] = useState(false)
const [previewOpen, setPreviewOpen] = useState<Set<number>>(new Set())
const [previewText, setPreviewText] = useState<Record<number, string>>({})
const [previewLoading, setPreviewLoading] = useState<Set<number>>(new Set())
const saveTimer = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const load = useCallback(async () => {
const [a, cons, dims, cats, itms] = await Promise.all([
db.assignments.get(assignmentId),
db.consultants.get(instId),
db.feedbackDimensions.orderBy('order').toArray(),
db.feedbackCategories.orderBy('order').toArray(),
db.feedbackCriterionItems.orderBy('order').toArray(),
const [a, cons, structure] = await Promise.all([
getAssignment(assignmentId),
getConsultant(instId),
loadFeedbackStructure(),
])
setAssignment(a ?? null)
setInstitutee(cons ?? null)
setDimensions(dims)
setCategories(cats)
setItems(itms)
let fb = await db.assignmentFeedbacks
.where('assignmentId').equals(assignmentId)
.filter(f => f.instituteeId === instId)
.first()
if (!fb) {
const fbId = await db.assignmentFeedbacks.add({
assignmentId, instituteeId: instId,
status: 'draft', aiModel: '', updatedAt: new Date().toISOString(),
})
fb = await db.assignmentFeedbacks.get(fbId)
const aType = a ? await getAssignmentType(a.assignmentTypeId) : undefined
const visible = resolveVisibleStructure(
structure.dimensions,
structure.categories,
structure.items,
aType?.criteriaIds,
)
setDimensions(visible.dimensions)
setCategories(visible.categories)
setItems(visible.items)
const { categories: cats, items: itms } = visible
const meetings = await listDoneMeetingsChronological(assignmentId)
const meetingIds = meetings.map(m => m.id!)
const [meetingAssessments, categoryRatings, weightStep] = await Promise.all([
listAssessmentsForMeetings(meetingIds),
listMeetingCategoryRatingsForMeetings(meetingIds),
getRatingTrendWeightStep(),
])
const instAssessments = meetingAssessments.filter(as => as.instituteeId === instId)
const trends = new Map<number, TrendResult>()
for (const cat of cats) {
const catItems = itms.filter(i => i.categoryId === cat.id)
const history: MeetingScorePoint[] = []
for (const meeting of meetings) {
const score = resolveCategoryChipScore({
meetingId: meeting.id!,
instituteeId: instId,
categoryId: cat.id!,
categoryRatings,
assessments: instAssessments,
catItems,
})
if (score === null) continue
history.push({ meetingId: meeting.id!, date: meeting.date, score })
}
trends.set(cat.id!, computeTrend(history, weightStep))
}
setCatTrends(trends)
const fb = await getOrCreateAssignmentFeedback(assignmentId, instId)
setFeedback(fb ?? null)
if (fb?.id) {
const [rs, txts] = await Promise.all([
db.feedbackCategoryRatings.where('assignmentFeedbackId').equals(fb.id).toArray(),
db.feedbackDimensionTexts.where('assignmentFeedbackId').equals(fb.id).toArray(),
const [rs, txts, catTxts] = await Promise.all([
listFeedbackCategoryRatings(fb.id),
listFeedbackDimensionTexts(fb.id),
listFeedbackCategoryTexts(fb.id),
])
setRatings(rs)
setDimTexts(txts)
setCategoryTexts(catTxts)
}
// Alle Dimensionen initial expandiert
setExpanded(new Set(dims.map(d => d.id!)))
setExpanded(new Set(visible.dimensions.map(d => d.id!)))
}, [assignmentId, instId])
useEffect(() => { load() }, [load])
// ── Rating setzen ─────────────────────────────────────────────────────────
const setRating = async (categoryId: number, rating: FeedbackRating) => {
const setRating = async (categoryId: number, rating: RatingScore | null) => {
if (!feedback?.id) return
const existing = ratings.find(r => r.feedbackCategoryId === categoryId)
if (rating === null) {
if (existing?.id) {
await deleteFeedbackCategoryRating(existing.id)
setRatings(prev => prev.filter(r => r.feedbackCategoryId !== categoryId))
}
return
}
if (existing?.id) {
await db.feedbackCategoryRatings.update(existing.id, { rating })
await updateFeedbackCategoryRating(existing.id, { rating })
setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r))
} else {
const newId = await db.feedbackCategoryRatings.add({
const newId = await addFeedbackCategoryRating({
assignmentFeedbackId: feedback.id, feedbackCategoryId: categoryId, rating,
})
setRatings(prev => [...prev, { id: newId, assignmentFeedbackId: feedback.id!, feedbackCategoryId: categoryId, rating }])
}
}
const getRating = (categoryId: number): FeedbackRating | undefined =>
ratings.find(r => r.feedbackCategoryId === categoryId)?.rating
const getRating = (categoryId: number): RatingScore | null =>
ratings.find(r => r.feedbackCategoryId === categoryId)?.rating ?? null
// ── Text speichern (debounced) ────────────────────────────────────────────
// ── Dimension-Text speichern (debounced) ──────────────────────────────────
const saveDimText = (dimId: number, field: 'achievements' | 'developmentNeeds', value: string) => {
setDimTexts(prev => {
@ -218,19 +206,16 @@ export default function AssignmentFeedbackPage() {
if (existing) return prev.map(t => t.feedbackDimensionId === dimId ? { ...t, [field]: value } : t)
return [...prev, { assignmentFeedbackId: feedback?.id ?? 0, feedbackDimensionId: dimId, achievements: '', developmentNeeds: '', [field]: value }]
})
const key = `${dimId}-${field}`
const key = `dim-${dimId}-${field}`
const prev = saveTimer.current.get(key)
if (prev) clearTimeout(prev)
saveTimer.current.set(key, setTimeout(async () => {
if (!feedback?.id) return
const existing = await db.feedbackDimensionTexts
.where('assignmentFeedbackId').equals(feedback.id)
.filter(t => t.feedbackDimensionId === dimId)
.first()
const existing = await getFeedbackDimensionText(feedback.id, dimId)
if (existing?.id) {
await db.feedbackDimensionTexts.update(existing.id, { [field]: value })
await updateFeedbackDimensionText(existing.id, { [field]: value })
} else {
await db.feedbackDimensionTexts.add({
await addFeedbackDimensionText({
assignmentFeedbackId: feedback.id,
feedbackDimensionId: dimId,
achievements: field === 'achievements' ? value : '',
@ -243,77 +228,333 @@ export default function AssignmentFeedbackPage() {
const getDimText = (dimId: number) =>
dimTexts.find(t => t.feedbackDimensionId === dimId)
// ── KI-Generierung ────────────────────────────────────────────────────────
const appendTrendSentence = (cat: FeedbackCategory, trend: TrendResult) => {
if (trend.trend !== 'up' && trend.trend !== 'down') return
const field = trend.trend === 'up' ? 'benefits' : 'concerns'
const firstLabel = String(numberToRating(trend.history[0].score) ?? trend.history[0].score.toFixed(1))
const lastLabel = String(numberToRating(trend.history[trend.history.length - 1].score) ?? trend.history[trend.history.length - 1].score.toFixed(1))
const verb = trend.trend === 'up' ? 'Verbesserung' : 'Verschlechterung'
const sentence = `Zeigt im Verlauf des Assignments in ${cat.name} eine ${verb} von ${firstLabel} zu ${lastLabel}.`
const existing = getCategoryText(cat.id!)?.[field] ?? ''
saveCategoryField(cat.id!, field, existing ? `${existing}\n${sentence}` : sentence)
}
const generateWithAi = async () => {
const acceptDimSuggestion = (dimId: number, field: 'achievements' | 'developmentNeeds') => {
const t = getDimText(dimId)
const sugField = field === 'achievements' ? 'suggestedAchievements' : 'suggestedDevelopmentNeeds'
const value = t?.[sugField]
if (!value) return
saveDimText(dimId, field, value)
discardDimSuggestion(dimId, field)
}
const discardDimSuggestion = async (dimId: number, field: 'achievements' | 'developmentNeeds') => {
const sugField = field === 'achievements' ? 'suggestedAchievements' : 'suggestedDevelopmentNeeds'
setDimTexts(prev => prev.map(t => t.feedbackDimensionId === dimId ? { ...t, [sugField]: '' } : t))
if (!feedback?.id) return
const existing = await getFeedbackDimensionText(feedback.id, dimId)
if (existing?.id) await updateFeedbackDimensionText(existing.id, { [sugField]: '' })
}
// ── Kategorie Benefits/Concerns speichern (debounced) ─────────────────────
const getCategoryText = (catId: number) => categoryTexts.find(t => t.feedbackCategoryId === catId)
const saveCategoryField = (catId: number, field: 'benefits' | 'concerns', value: string) => {
setCategoryTexts(prev => {
const existing = prev.find(t => t.feedbackCategoryId === catId)
if (existing) return prev.map(t => t.feedbackCategoryId === catId ? { ...t, [field]: value } : t)
return [...prev, {
assignmentFeedbackId: feedback?.id ?? 0,
feedbackCategoryId: catId,
benefits: '',
concerns: '',
[field]: value,
}]
})
const key = `cat-${catId}-${field}`
const prev = saveTimer.current.get(key)
if (prev) clearTimeout(prev)
saveTimer.current.set(key, setTimeout(async () => {
if (!feedback?.id) return
const existing = await getFeedbackCategoryText(feedback.id, catId)
if (existing?.id) {
await updateFeedbackCategoryText(existing.id, { [field]: value })
} else {
await addFeedbackCategoryText({
assignmentFeedbackId: feedback.id,
feedbackCategoryId: catId,
benefits: field === 'benefits' ? value : '',
concerns: field === 'concerns' ? value : '',
})
}
}, 600))
}
const acceptCategorySuggestion = (catId: number, field: 'benefits' | 'concerns') => {
const row = getCategoryText(catId)
const value = field === 'benefits' ? row?.suggestedBenefits : row?.suggestedConcerns
if (!value) return
saveCategoryField(catId, field, value)
discardCategorySuggestion(catId, field)
}
const discardCategorySuggestion = async (catId: number, field: 'benefits' | 'concerns') => {
const sugField = field === 'benefits' ? 'suggestedBenefits' : 'suggestedConcerns'
setCategoryTexts(prev => prev.map(t => t.feedbackCategoryId === catId ? { ...t, [sugField]: '' } : t))
if (!feedback?.id) return
const existing = await getFeedbackCategoryText(feedback.id, catId)
if (existing?.id) await updateFeedbackCategoryText(existing.id, { [sugField]: '' })
}
// ── Prompt-Vorschau (ohne API-Call) ───────────────────────────────────────
const togglePreview = async (dim: FeedbackDimension) => {
if (previewOpen.has(dim.id!)) {
setPreviewOpen(prev => { const next = new Set(prev); next.delete(dim.id!); return next })
return
}
setPreviewOpen(prev => new Set(prev).add(dim.id!))
if (previewText[dim.id!] || !institutee || !assignment) return
setPreviewLoading(prev => new Set(prev).add(dim.id!))
try {
const [meetings, categoryTemplate, dimensionTemplate] = await Promise.all([
listDoneMeetingsChronological(assignmentId),
getOrSeedDefaultCategoryPromptTemplate(),
getOrSeedDefaultDimensionPromptTemplate(),
])
const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([
listAssessmentsForMeetings(meetingIds),
listConversationEntriesForMeetings(meetingIds),
])
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
const dimCategories = categories.filter(c => c.dimensionId === dim.id)
const firstCat = dimCategories[0]
const catPreview = firstCat
? renderPrompt(categoryTemplate, buildCategoryPromptContext({
institutee, assignment, dimension: dim, category: firstCat,
catItems: items.filter(i => i.categoryId === firstCat.id),
ratings, catTrend: catTrends.get(firstCat.id!), instAssessments, instEntries, meetings,
}))
: '(keine Kategorien)'
const sampleResults: CategoryCondenseEntry[] = dimCategories.map(c => ({
name: c.name,
score: catTrends.get(c.id!)?.suggestion ?? null,
benefits: '(Beispiel — wird aus Stufe A befüllt)',
concerns: '(Beispiel — wird aus Stufe A befüllt)',
}))
const dimPreview = renderPrompt(dimensionTemplate, buildDimensionCondenseContext({
institutee, assignment, dimension: dim, categoryResults: sampleResults,
}))
const rendered = `=== Stufe A: Kategorie-Prompt (Beispiel: ${firstCat?.name ?? '—'}) ===\n\n${catPreview}\n\n=== Stufe B: Dimensions-Verdichtung ===\n\n${dimPreview}`
setPreviewText(prev => ({ ...prev, [dim.id!]: rendered }))
} finally {
setPreviewLoading(prev => { const next = new Set(prev); next.delete(dim.id!); return next })
}
}
const saveCategorySuggestions = async (
catId: number,
parsed: { benefits: string; concerns: string },
) => {
if (!feedback?.id) return
const existing = await getFeedbackCategoryText(feedback.id, catId)
if (existing?.id) {
await updateFeedbackCategoryText(existing.id, {
suggestedBenefits: parsed.benefits,
suggestedConcerns: parsed.concerns,
})
} else {
await addFeedbackCategoryText({
assignmentFeedbackId: feedback.id,
feedbackCategoryId: catId,
benefits: '', concerns: '',
suggestedBenefits: parsed.benefits,
suggestedConcerns: parsed.concerns,
})
}
}
// ── KI-Kaskade: Stufe A (Kategorie) → Stufe B (Dimension) ─────────────────
const generateOneCategory = async (
dim: FeedbackDimension,
cat: FeedbackCategory,
meetings: MeetingInstance[],
instAssessments: Assessment[],
instEntries: ConversationEntry[],
categoryTemplate: string,
settings: { apiKey: string; model: string },
): Promise<CategoryCondenseEntry> => {
if (!institutee || !assignment || !feedback?.id) {
return { name: cat.name, score: null, benefits: '', concerns: '' }
}
const catItems = items.filter(i => i.categoryId === cat.id)
const context = buildCategoryPromptContext({
institutee, assignment, dimension: dim, category: cat, catItems,
ratings, catTrend: catTrends.get(cat.id!), instAssessments, instEntries, meetings,
})
const prompt = renderPrompt(categoryTemplate, context)
const text = await callOpenRouter(prompt, settings)
const parsed = parseCategoryAiResponse(text)
await saveCategorySuggestions(cat.id!, parsed)
return {
name: cat.name,
score: parsed.score,
benefits: parsed.benefits,
concerns: parsed.concerns,
}
}
const generateDimensionCondense = async (
dim: FeedbackDimension,
categoryResults: CategoryCondenseEntry[],
settings: { apiKey: string; model: string },
) => {
if (!institutee || !assignment || !feedback?.id) return
const promptCfg = await getFeedbackDimensionPrompt(dim.id!)
const template = promptCfg?.promptTemplate ?? AI_CONFIG.defaultDimensionPromptTemplate
const context = buildDimensionCondenseContext({
institutee, assignment, dimension: dim, categoryResults,
})
const prompt = renderPrompt(template, context)
const text = await callOpenRouter(prompt, settings)
const parsed = parseDimensionCondenseResponse(text)
const existingDimText = await getFeedbackDimensionText(feedback.id, dim.id!)
if (existingDimText?.id) {
await updateFeedbackDimensionText(existingDimText.id, {
suggestedAchievements: parsed.achievements,
suggestedDevelopmentNeeds: parsed.developmentNeeds,
})
} else {
await addFeedbackDimensionText({
assignmentFeedbackId: feedback.id,
feedbackDimensionId: dim.id!,
achievements: '', developmentNeeds: '',
suggestedAchievements: parsed.achievements,
suggestedDevelopmentNeeds: parsed.developmentNeeds,
})
}
}
const generateOneDimension = async (
dim: FeedbackDimension,
dimIndex: number,
dimTotal: number,
meetings: MeetingInstance[],
instAssessments: Assessment[],
instEntries: ConversationEntry[],
categoryTemplate: string,
settings: { apiKey: string; model: string },
) => {
if (!institutee || !assignment || !feedback?.id) return
const dimCategories = categories.filter(c => c.dimensionId === dim.id)
const categoryResults: CategoryCondenseEntry[] = []
for (let ci = 0; ci < dimCategories.length; ci++) {
const cat = dimCategories[ci]
const errKey = `${dim.id}:${cat.id}`
setGenerationProgress(
`Dimension ${dimIndex + 1}/${dimTotal} · Kategorie ${ci + 1}/${dimCategories.length}: ${cat.name}`,
)
try {
const result = await generateOneCategory(
dim, cat, meetings, instAssessments, instEntries, categoryTemplate, settings,
)
categoryResults.push(result)
setCategoryErrors(prev => { const next = { ...prev }; delete next[errKey]; return next })
} catch (e) {
const msg = e instanceof Error ? e.message : 'Unbekannter Fehler'
setCategoryErrors(prev => ({ ...prev, [errKey]: msg }))
throw new Error(`Kategorie „${cat.name}": ${msg}`)
}
}
setGenerationProgress(`Dimension ${dimIndex + 1}/${dimTotal}: ${dim.name} verdichten…`)
await generateDimensionCondense(dim, categoryResults, settings)
}
/** Sequenziell mit Fortschrittsanzeige — Fehler einzelner Dimensionen stoppen den Lauf nicht. */
const runDimensionBatch = async (dimsToRun: FeedbackDimension[]) => {
if (!institutee || !assignment || !feedback?.id || dimsToRun.length === 0) return
const settings = getAiSettings()
if (!settings.apiKey) { setShowAiSettings(true); return }
setGenerating(true)
setAiError('')
setDimensionErrors(prev => {
const next = { ...prev }
for (const d of dimsToRun) delete next[d.id!]
return next
})
setCategoryErrors({})
try {
const prompt = await buildAiPrompt(institutee, assignment, dimensions, categories, items, ratings, feedback.id)
const [meetings, categoryTemplate] = await Promise.all([
listDoneMeetingsChronological(assignmentId),
getOrSeedDefaultCategoryPromptTemplate(),
])
const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([
listAssessmentsForMeetings(meetingIds),
listConversationEntriesForMeetings(meetingIds),
])
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${settings.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'Assignment Monitor',
},
body: JSON.stringify({
model: settings.model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
}),
})
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`)
const data = await res.json()
const text = data.choices?.[0]?.message?.content ?? ''
const parsed = JSON.parse(text) as { dimensions: { name: string; achievements: string; developmentNeeds: string }[] }
// Texte in DB schreiben
for (const item of parsed.dimensions) {
const dim = dimensions.find(d => d.name === item.name || item.name.includes(d.name.split(',')[0]))
if (!dim?.id) continue
const existing = await db.feedbackDimensionTexts
.where('assignmentFeedbackId').equals(feedback.id)
.filter(t => t.feedbackDimensionId === dim.id)
.first()
if (existing?.id) {
await db.feedbackDimensionTexts.update(existing.id, {
achievements: item.achievements,
developmentNeeds: item.developmentNeeds,
})
} else {
await db.feedbackDimensionTexts.add({
assignmentFeedbackId: feedback.id,
feedbackDimensionId: dim.id,
achievements: item.achievements,
developmentNeeds: item.developmentNeeds,
})
for (let i = 0; i < dimsToRun.length; i++) {
const dim = dimsToRun[i]
try {
await generateOneDimension(
dim, i, dimsToRun.length, meetings, instAssessments, instEntries, categoryTemplate, settings,
)
} catch (e) {
setDimensionErrors(prev => ({
...prev,
[dim.id!]: e instanceof Error ? e.message : 'Unbekannter Fehler',
}))
}
}
await db.assignmentFeedbacks.update(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() })
await updateAssignmentFeedback(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() })
await load()
} catch (e) {
setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setGenerating(false)
setGenerationProgress('')
}
}
const generateAllDimensions = () => runDimensionBatch(dimensions)
const retryFailedDimensions = () => {
const failedIds = new Set(Object.keys(dimensionErrors).map(Number))
runDimensionBatch(dimensions.filter(d => failedIds.has(d.id!)))
}
const toggleFinal = async () => {
if (!feedback?.id) return
const next = feedback.status === 'final' ? 'draft' : 'final'
await db.assignmentFeedbacks.update(feedback.id, { status: next, updatedAt: new Date().toISOString() })
await updateAssignmentFeedback(feedback.id, { status: next, updatedAt: new Date().toISOString() })
setFeedback(prev => prev ? { ...prev, status: next } : null)
}
const handleExportPdf = async () => {
setExportingPdf(true)
try {
await exportInstituteeFeedbackPdf(assignmentId, instId)
} finally {
setExportingPdf(false)
}
}
if (!assignment || !institutee) return <div className="text-center py-10 text-gray-400">Lädt</div>
return (
@ -341,6 +582,10 @@ export default function AssignmentFeedbackPage() {
className="text-xs text-gray-400 hover:text-gray-600">
KI-Einstellungen
</button>
<button onClick={handleExportPdf} disabled={exportingPdf}
className="text-xs text-gray-400 hover:text-gray-600 disabled:opacity-50">
{exportingPdf ? 'Erstelle PDF…' : '⬇ Als PDF exportieren'}
</button>
</div>
</div>
@ -384,14 +629,14 @@ export default function AssignmentFeedbackPage() {
{/* KI-Generieren Button */}
<div className="flex items-center gap-3">
<button
onClick={generateWithAi}
onClick={generateAllDimensions}
disabled={generating}
className={`flex-1 py-3 rounded-xl text-sm font-medium transition ${
generating
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-brand text-white hover:bg-brand-dark'
}`}>
{generating ? 'Generiere Entwurf…' : '✦ KI-Feedback-Entwurf generieren'}
{generating ? (generationProgress || 'Generiere…') : '✦ KI-Feedback-Entwurf generieren'}
</button>
</div>
{aiError && (
@ -399,6 +644,30 @@ export default function AssignmentFeedbackPage() {
{aiError}
</div>
)}
{!generating && (Object.keys(dimensionErrors).length > 0 || Object.keys(categoryErrors).length > 0) && (
<div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 space-y-1.5">
{Object.keys(dimensionErrors).length > 0 && (
<>
<div className="font-medium">Fehler bei {Object.keys(dimensionErrors).length} Dimension(en):</div>
{Object.entries(dimensionErrors).map(([dimId, msg]) => (
<div key={dimId}> {dimensions.find(d => d.id === Number(dimId))?.name}: {msg}</div>
))}
</>
)}
{Object.entries(categoryErrors).map(([key, msg]) => {
const [dimId, catId] = key.split(':').map(Number)
const dimName = dimensions.find(d => d.id === dimId)?.name ?? 'Dimension'
const catName = categories.find(c => c.id === catId)?.name ?? 'Kategorie'
return <div key={key}> {dimName} {catName}: {msg}</div>
})}
{Object.keys(dimensionErrors).length > 0 && (
<button onClick={retryFailedDimensions}
className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded-lg font-medium hover:bg-red-200">
Fehlgeschlagene Dimensionen erneut versuchen
</button>
)}
</div>
)}
{/* Dimensionen */}
{dimensions.map(dim => {
@ -406,14 +675,9 @@ export default function AssignmentFeedbackPage() {
const isOpen = expanded.has(dim.id!)
const text = getDimText(dim.id!)
// Zusammenfassung der Bewertungen
const catRatings = dimCats.map(c => getRating(c.id!)).filter(Boolean) as FeedbackRating[]
const worstRating = catRatings.includes('not_client_ready') ? 'not_client_ready'
: catRatings.includes('partially_client_ready') ? 'partially_client_ready'
: catRatings.includes('nearly_client_ready') ? 'nearly_client_ready'
: catRatings.includes('client_ready') ? 'client_ready'
: catRatings.length > 0 ? 'na' : undefined
const summaryColor = ratingColor(worstRating)
const catRatings = dimCats.map(c => getRating(c.id!)).filter(isValidRatingScore)
const worstRating = catRatings.length > 0 ? Math.min(...catRatings) : null
const summaryStyle = resolveScoreStyle(worstRating, colorBands)
return (
<div key={dim.id} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
@ -428,9 +692,9 @@ export default function AssignmentFeedbackPage() {
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-800 text-sm">{dim.name}</span>
{worstRating && (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${summaryColor}`}>
{RATING_OPTIONS.find(r => r.value === worstRating)?.short}
{worstRating !== null && (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium tabular-nums ${summaryStyle.color}`}>
{summaryStyle.short}
</span>
)}
</div>
@ -439,28 +703,72 @@ export default function AssignmentFeedbackPage() {
{isOpen && (
<div className="border-t border-gray-100 p-4 space-y-4">
{/* Prompt-Vorschau */}
<div>
<button onClick={() => togglePreview(dim)}
className="text-xs text-gray-400 hover:text-gray-600 underline">
{previewOpen.has(dim.id!) ? 'KI-Prompt ausblenden' : '👁 KI-Prompt für diese Dimension ansehen'}
</button>
{previewOpen.has(dim.id!) && (
<div className="mt-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
{previewLoading.has(dim.id!) ? (
<div className="text-xs text-gray-400">Lädt</div>
) : (
<>
<pre className="text-xs text-gray-700 whitespace-pre-wrap font-mono max-h-96 overflow-y-auto">
{previewText[dim.id!]}
</pre>
<button
onClick={() => navigator.clipboard.writeText(previewText[dim.id!] ?? '')}
className="mt-2 text-xs bg-gray-200 hover:bg-gray-300 text-gray-700 px-2 py-1 rounded-lg font-medium">
Kopieren
</button>
</>
)}
</div>
)}
</div>
{/* Kategorien */}
{dimCats.map(cat => {
const catItems = items.filter(i => i.categoryId === cat.id)
const currentRating = getRating(cat.id!)
const catText = getCategoryText(cat.id!)
return (
<div key={cat.id} className="space-y-2">
<div className="font-medium text-sm text-gray-700">{cat.name}</div>
{/* Rating-Buttons */}
<div className="flex gap-1 flex-wrap">
{RATING_OPTIONS.map(r => (
<button key={r.value}
onClick={() => setRating(cat.id!, r.value)}
className={`px-2.5 py-1.5 rounded-lg text-xs font-semibold border transition ${
currentRating === r.value
? r.color + ' ring-2 ring-offset-1 ring-current'
: 'bg-gray-50 text-gray-400 border-gray-200 hover:bg-gray-100'
}`}>
{r.short}
</button>
))}
</div>
<ScorePicker
value={currentRating ?? null}
onChange={score => setRating(cat.id!, score)}
bands={colorBands}
/>
{(() => {
const trend = catTrends.get(cat.id!)
if (!trend || trend.suggestion === null) return null
const suggestedRating = numberToRating(trend.suggestion)
if (suggestedRating === null) return null
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-1 rounded-lg font-medium tabular-nums">
Vorschlag: {suggestedRating} ({trend.suggestion.toFixed(1)}) {trendArrow(trend.trend)}
</span>
<button
onClick={() => setRating(cat.id!, suggestedRating)}
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-lg font-medium hover:bg-blue-200">
Übernehmen
</button>
{(trend.trend === 'up' || trend.trend === 'down') && (
<button
onClick={() => appendTrendSentence(cat, trend)}
className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-lg font-medium hover:bg-gray-200">
Trend in Text übernehmen
</button>
)}
</div>
)
})()}
{/* Kriterium-Items als Referenz */}
{catItems.length > 0 && (
@ -473,33 +781,83 @@ export default function AssignmentFeedbackPage() {
))}
</div>
)}
{/* Kategorie Benefits / Concerns */}
<div className="space-y-2">
<div className="space-y-1">
<label className="text-xs font-medium text-green-700">Benefits</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand min-h-[44px]"
placeholder="Positive Beobachtungen zu dieser Kategorie…"
value={catText?.benefits ?? ''}
onChange={e => saveCategoryField(cat.id!, 'benefits', e.target.value)}
/>
{catText?.suggestedBenefits && (
<AiSuggestionBox
text={catText.suggestedBenefits}
onAccept={() => acceptCategorySuggestion(cat.id!, 'benefits')}
onDiscard={() => discardCategorySuggestion(cat.id!, 'benefits')}
/>
)}
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-amber-700">Concerns</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand min-h-[44px]"
placeholder="Entwicklungsfelder / Risiken zu dieser Kategorie…"
value={catText?.concerns ?? ''}
onChange={e => saveCategoryField(cat.id!, 'concerns', e.target.value)}
/>
{catText?.suggestedConcerns && (
<AiSuggestionBox
text={catText.suggestedConcerns}
onAccept={() => acceptCategorySuggestion(cat.id!, 'concerns')}
onDiscard={() => discardCategorySuggestion(cat.id!, 'concerns')}
/>
)}
</div>
</div>
</div>
)
})}
{/* Textfelder */}
<div className="space-y-3 pt-2 border-t border-gray-50">
<div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-1.5">
Achievements
Benefits (Dimension)
</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-green-400 min-h-[80px]"
placeholder="Konkrete Stärken und positive Beobachtungen…"
placeholder="35 Hauptstärken dieser Dimension…"
value={text?.achievements ?? ''}
onChange={e => saveDimText(dim.id!, 'achievements', e.target.value)}
/>
{text?.suggestedAchievements && (
<AiSuggestionBox
text={text.suggestedAchievements}
onAccept={() => acceptDimSuggestion(dim.id!, 'achievements')}
onDiscard={() => discardDimSuggestion(dim.id!, 'achievements')}
/>
)}
</div>
<div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5">
Development Needs
Concerns (Dimension)
</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-amber-400 min-h-[80px]"
placeholder="Entwicklungsfelder und konkrete Handlungsempfehlungen…"
placeholder="35 zentrale Entwicklungsfelder dieser Dimension…"
value={text?.developmentNeeds ?? ''}
onChange={e => saveDimText(dim.id!, 'developmentNeeds', e.target.value)}
/>
{text?.suggestedDevelopmentNeeds && (
<AiSuggestionBox
text={text.suggestedDevelopmentNeeds}
onAccept={() => acceptDimSuggestion(dim.id!, 'developmentNeeds')}
onDiscard={() => discardDimSuggestion(dim.id!, 'developmentNeeds')}
/>
)}
</div>
</div>
</div>

View File

@ -0,0 +1,97 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
listAssignmentsWithDetails, restoreAssignment, hardDeleteAssignment,
type AssignmentWithDetails,
} from '../db'
const TRASH_RETENTION_DAYS = 30
export default function AssignmentTrash() {
const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
const [confirmHardDelete, setConfirmHardDelete] = useState<number | null>(null)
const navigate = useNavigate()
const load = () => { listAssignmentsWithDetails().then(setAssignments) }
useEffect(() => { load() }, [])
const trashed = assignments.filter(a => a.deletedAt)
const restore = async (id: number) => {
await restoreAssignment(id)
load()
}
const hardDelete = async (id: number) => {
await hardDeleteAssignment(id)
setConfirmHardDelete(null)
load()
}
const remainingDays = (deletedAt: string) => {
const elapsedDays = Math.floor((Date.now() - new Date(deletedAt).getTime()) / 86_400_000)
return Math.max(0, TRASH_RETENTION_DAYS - elapsedDays)
}
return (
<div className="space-y-3">
<button onClick={() => navigate('/')} className="text-sm text-brand mb-2 block"> Zurück</button>
<h1 className="text-xl font-bold text-gray-800">🗑 Papierkorb</h1>
{trashed.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">Papierkorb ist leer.</p>
)}
{trashed.map(a => (
<div key={a.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{a.assignmentNumber && (
<div className="text-xs font-semibold text-brand uppercase tracking-wide leading-none mb-0.5">
{a.assignmentNumber}
</div>
)}
<div className="font-semibold text-gray-800 truncate">{a.title}</div>
<div className="text-sm text-gray-500">{a.client}</div>
<div className="text-xs text-gray-400 mt-0.5">
{a.typeName} · {a.instituteeNames.join(', ')}
</div>
{a.deletedAt && (
<div className="text-xs text-gray-400 mt-1">
Gelöscht am {new Date(a.deletedAt).toLocaleDateString('de-DE')} · noch {remainingDays(a.deletedAt)} Tage bis zur endgültigen Löschung
</div>
)}
</div>
<div className="flex flex-col gap-1.5 items-end flex-shrink-0">
<button onClick={() => restore(a.id!)}
className="text-xs bg-white border border-gray-200 text-gray-600 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Wiederherstellen
</button>
<button onClick={() => setConfirmHardDelete(confirmHardDelete === a.id ? null : a.id!)}
className="text-xs text-red-400 hover:text-red-600 px-2 py-1">
Endgültig löschen
</button>
</div>
</div>
{confirmHardDelete === a.id && (
<div className="mt-2 bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
<div className="text-xs text-red-700 font-medium">
Endgültig löschen? Alle Meetings, Protokolle und Feedback-Daten dieses Assignments werden unwiderruflich entfernt.
</div>
<div className="flex gap-2">
<button onClick={() => hardDelete(a.id!)}
className="flex-1 py-1.5 rounded-lg text-xs font-medium bg-red-600 text-white">
Endgültig löschen
</button>
<button onClick={() => setConfirmHardDelete(null)}
className="px-3 py-1.5 rounded-lg text-xs border border-gray-200 text-gray-500">
Abbrechen
</button>
</div>
</div>
)}
</div>
))}
</div>
)
}

View File

@ -1,7 +1,18 @@
import { useEffect, useState } from 'react'
import { db, type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem } from '../db'
import {
type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
type ChecklistItemDef,
listAssignmentTypes, listPhaseTemplates, loadFeedbackStructure,
addAssignmentType, updateAssignmentType, deleteAssignmentType,
addPhaseTemplate, updatePhaseTemplate, deletePhaseTemplate,
listChecklistItemDefsForPhaseTemplate, addChecklistItemDef, updateChecklistItemDef, deleteChecklistItemDef,
} from '../db'
import { exportAssignmentTypesReportAsMarkdown } from '../utils/structureExport'
import { exportAssignmentTypesReportPdf } from '../utils/pdfExport'
import { criterionOptionGroups } from '../utils/criterionOptions'
export default function AssignmentTypeConfig() {
const [exportingPdf, setExportingPdf] = useState(false)
const [types, setTypes] = useState<AssignmentType[]>([])
const [templates, setTemplates] = useState<PhaseTemplate[]>([])
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
@ -14,22 +25,27 @@ export default function AssignmentTypeConfig() {
const [typeError, setTypeError] = useState<Record<number, string>>({})
const [newPhase, setNewPhase] = useState<Record<number, {
label: string; withWhom: 'lead' | 'client'
isRepeatable: boolean; hasConversation: boolean; hasAssessment: boolean
isRepeatable: boolean; hasConversation: boolean; hasAssessment: boolean; hasChecklist: boolean
}>>({})
const [checklistItems, setChecklistItems] = useState<ChecklistItemDef[]>([])
const [expandedChecklist, setExpandedChecklist] = useState<Set<number>>(new Set())
const [newChecklistItem, setNewChecklistItem] = useState<Record<number, {
label: string; scope: 'perInstitutee' | 'meetingWide'; linkedCriterionItemId: string
}>>({})
const load = async () => {
const [t, tmpl, dims, cats, itms] = await Promise.all([
db.assignmentTypes.toArray(),
db.phaseTemplates.toArray(),
db.feedbackDimensions.orderBy('order').toArray(),
db.feedbackCategories.orderBy('order').toArray(),
db.feedbackCriterionItems.orderBy('order').toArray(),
const [t, tmpl, structure] = await Promise.all([
listAssignmentTypes(),
listPhaseTemplates(),
loadFeedbackStructure(),
])
setTypes(t)
setTemplates(tmpl)
setDimensions(dims)
setCategories(cats)
setItems(itms)
setDimensions(structure.dimensions)
setCategories(structure.categories)
setItems(structure.items)
const checklistLists = await Promise.all(tmpl.map(p => listChecklistItemDefsForPhaseTemplate(p.id!)))
setChecklistItems(checklistLists.flat())
}
useEffect(() => { load() }, [])
@ -39,25 +55,23 @@ export default function AssignmentTypeConfig() {
const addType = async () => {
const name = newTypeName.trim()
if (!name) return
const id = await db.assignmentTypes.add({ name, defaultPhaseKeys: [], criteriaIds: [] })
const id = await addAssignmentType({ name, defaultPhaseKeys: [], criteriaIds: [] })
setNewTypeName('')
await load()
setExpanded(id)
}
const updateTypeName = async (id: number, name: string) => {
await db.assignmentTypes.update(id, { name })
await updateAssignmentType(id, { name })
setTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t))
}
const deleteType = async (id: number) => {
const count = await db.assignments.where('assignmentTypeId').equals(id).count()
if (count > 0) {
setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden — ${count} Assignment(s) verwenden diesen Typ.` }))
const result = await deleteAssignmentType(id)
if (!result.ok) {
setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden — ${result.assignmentCount} Assignment(s) verwenden diesen Typ.` }))
return
}
await db.phaseTemplates.where('assignmentTypeId').equals(id).delete()
await db.assignmentTypes.delete(id)
await load()
}
@ -66,28 +80,45 @@ export default function AssignmentTypeConfig() {
const toggleItem = async (type: AssignmentType, itemId: number) => {
const current = type.criteriaIds ?? []
const next = current.includes(itemId) ? current.filter(id => id !== itemId) : [...current, itemId]
await db.assignmentTypes.update(type.id!, { criteriaIds: next })
await updateAssignmentType(type.id!, { criteriaIds: next })
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
}
const toggleAllInCategory = async (type: AssignmentType, catId: number) => {
const catItemIds = items.filter(i => i.categoryId === catId).map(i => i.id!)
const current = type.criteriaIds ?? []
const allSelected = catItemIds.every(id => current.includes(id))
const allSelected = catItemIds.length > 0 && catItemIds.every(id => current.includes(id))
const next = allSelected
? current.filter(id => !catItemIds.includes(id))
: [...new Set([...current, ...catItemIds])]
await db.assignmentTypes.update(type.id!, { criteriaIds: next })
await updateAssignmentType(type.id!, { criteriaIds: next })
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
}
const toggleAllInDimension = async (type: AssignmentType, dimId: number) => {
const dimCatIds = categories.filter(c => c.dimensionId === dimId).map(c => c.id!)
const dimItemIds = items.filter(i => dimCatIds.includes(i.categoryId)).map(i => i.id!)
const current = type.criteriaIds ?? []
const allSelected = dimItemIds.length > 0 && dimItemIds.every(id => current.includes(id))
const next = allSelected
? current.filter(id => !dimItemIds.includes(id))
: [...new Set([...current, ...dimItemIds])]
await updateAssignmentType(type.id!, { criteriaIds: next })
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
}
const clearStructureFilter = async (type: AssignmentType) => {
await updateAssignmentType(type.id!, { criteriaIds: [] })
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: [] } : t))
}
// ── Phasen-Templates ───────────────────────────────────────────────────────
const addPhase = async (typeId: number) => {
const np = newPhase[typeId]
if (!np?.label.trim()) return
const existing = templates.filter(t => t.assignmentTypeId === typeId)
await db.phaseTemplates.add({
await addPhaseTemplate({
assignmentTypeId: typeId,
phaseKey: np.label.trim().toLowerCase().replace(/\s+/g, '_'),
label: np.label.trim(),
@ -95,25 +126,69 @@ export default function AssignmentTypeConfig() {
isRepeatable: np.isRepeatable,
hasConversation: np.hasConversation,
hasAssessment: np.hasAssessment,
hasChecklist: np.hasChecklist,
criteriaIds: [],
order: existing.length + 1,
})
setNewPhase(prev => ({ ...prev, [typeId]: { label: '', withWhom: 'lead', isRepeatable: false, hasConversation: false, hasAssessment: false } }))
setNewPhase(prev => ({ ...prev, [typeId]: { label: '', withWhom: 'lead', isRepeatable: false, hasConversation: false, hasAssessment: false, hasChecklist: false } }))
await load()
}
const updatePhase = async (id: number, patch: Partial<PhaseTemplate>) => {
await db.phaseTemplates.update(id, patch)
await updatePhaseTemplate(id, patch)
setTemplates(prev => prev.map(t => t.id === id ? { ...t, ...patch } : t))
}
const deletePhase = async (id: number) => {
await db.phaseTemplates.delete(id)
await deletePhaseTemplate(id)
await load()
}
// ── Checkliste pro Phase ───────────────────────────────────────────────────
const addChecklistItem = async (phaseId: number) => {
const nci = newChecklistItem[phaseId]
if (!nci?.label.trim()) return
const existing = checklistItems.filter(c => c.phaseTemplateId === phaseId)
await addChecklistItemDef({
phaseTemplateId: phaseId,
label: nci.label.trim(),
scope: nci.scope,
linkedCriterionItemId: nci.linkedCriterionItemId ? Number(nci.linkedCriterionItemId) : undefined,
order: existing.length + 1,
})
setNewChecklistItem(prev => ({ ...prev, [phaseId]: { label: '', scope: 'perInstitutee', linkedCriterionItemId: '' } }))
await load()
}
const updateChecklistItem = async (id: number, patch: Partial<ChecklistItemDef>) => {
await updateChecklistItemDef(id, patch)
setChecklistItems(prev => prev.map(c => c.id === id ? { ...c, ...patch } : c))
}
const deleteChecklistItem = async (id: number) => {
await deleteChecklistItemDef(id)
setChecklistItems(prev => prev.filter(c => c.id !== id))
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-lg font-bold text-gray-800">Assignment-Typen</h2>
<div className="flex gap-1.5 flex-shrink-0">
<button onClick={() => exportAssignmentTypesReportAsMarkdown()}
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
MD
</button>
<button
onClick={async () => { setExportingPdf(true); try { await exportAssignmentTypesReportPdf() } finally { setExportingPdf(false) } }}
disabled={exportingPdf}
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition disabled:opacity-50">
{exportingPdf ? '⏳' : '⬇ PDF'}
</button>
</div>
</div>
{/* Neuer Typ */}
<div className="flex gap-2">
<input
@ -137,7 +212,7 @@ export default function AssignmentTypeConfig() {
const isOpen = expanded === type.id
const typPhases = templates.filter(t => t.assignmentTypeId === type.id).sort((a, b) => a.order - b.order)
const tab = activeTab[type.id!] ?? 'phases'
const np = newPhase[type.id!] ?? { label: '', withWhom: 'lead' as const, isRepeatable: false, hasConversation: false, hasAssessment: false }
const np = newPhase[type.id!] ?? { label: '', withWhom: 'lead' as const, isRepeatable: false, hasConversation: false, hasAssessment: false, hasChecklist: false }
const selected = type.criteriaIds ?? []
return (
@ -149,7 +224,7 @@ export default function AssignmentTypeConfig() {
value={type.name}
onChange={e => updateTypeName(type.id!, e.target.value)}
/>
<span className="text-xs text-gray-400">{typPhases.length} Phasen · {selected.length ? `${selected.length} Kriterien` : 'alle Kriterien'}</span>
<span className="text-xs text-gray-400">{typPhases.length} Phasen · {selected.length ? `${selected.length} Kriterien` : 'gesamte Struktur'}</span>
<button onClick={() => setExpanded(isOpen ? null : type.id!)}
className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
<button onClick={() => deleteType(type.id!)}
@ -172,7 +247,7 @@ export default function AssignmentTypeConfig() {
className={`flex-1 py-2 text-xs font-medium transition ${
tab === t ? 'text-brand border-b-2 border-brand' : 'text-gray-400'
}`}>
{t === 'phases' ? `Phasen (${typPhases.length})` : `Kriterien (${selected.length || 'alle'})`}
{t === 'phases' ? `Phasen (${typPhases.length})` : `Struktur (${selected.length || 'alle'})`}
</button>
))}
@ -215,7 +290,90 @@ export default function AssignmentTypeConfig() {
onChange={e => updatePhase(phase.id!, { hasAssessment: e.target.checked })} />
Bewertung
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={phase.hasChecklist}
onChange={e => updatePhase(phase.id!, { hasChecklist: e.target.checked })} />
Checkliste
</label>
</div>
{phase.hasChecklist && (() => {
const phaseChecklist = checklistItems
.filter(c => c.phaseTemplateId === phase.id)
.sort((a, b) => a.order - b.order)
const isChecklistOpen = expandedChecklist.has(phase.id!)
const nci = newChecklistItem[phase.id!] ?? { label: '', scope: 'perInstitutee' as const, linkedCriterionItemId: '' }
return (
<div className="border-t border-gray-100 pt-1.5">
<button
onClick={() => setExpandedChecklist(prev => {
const next = new Set(prev)
if (next.has(phase.id!)) next.delete(phase.id!); else next.add(phase.id!)
return next
})}
className="text-xs text-gray-500 font-medium">
🗹 Checkliste ({phaseChecklist.length}) {isChecklistOpen ? '▲' : '▼'}
</button>
{isChecklistOpen && (
<div className="mt-1.5 space-y-1.5">
{phaseChecklist.map(ci => (
<div key={ci.id} className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1.5">
<input
className="flex-1 text-xs bg-transparent border-b border-transparent focus:border-brand focus:outline-none"
value={ci.label}
onChange={e => updateChecklistItem(ci.id!, { label: e.target.value })}
/>
<select
value={ci.scope}
onChange={e => updateChecklistItem(ci.id!, { scope: e.target.value as 'perInstitutee' | 'meetingWide' })}
className="text-xs border border-gray-200 rounded px-1 py-0.5 bg-white focus:outline-none">
<option value="perInstitutee">pro Institutee</option>
<option value="meetingWide">meeting-weit</option>
</select>
<select
value={ci.linkedCriterionItemId ?? ''}
onChange={e => updateChecklistItem(ci.id!, { linkedCriterionItemId: e.target.value ? Number(e.target.value) : undefined })}
className="text-xs border border-gray-200 rounded px-1 py-0.5 bg-white focus:outline-none max-w-[140px]">
<option value=""> frei </option>
{criterionOptionGroups(dimensions, categories, items)}
</select>
<button onClick={() => deleteChecklistItem(ci.id!)}
className="text-red-300 hover:text-red-500 text-xs">×</button>
</div>
))}
<div className="flex items-center gap-1.5">
<input
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-brand bg-white"
placeholder="Neuer Checklisten-Punkt…"
value={nci.label}
onChange={e => setNewChecklistItem(prev => ({ ...prev, [phase.id!]: { ...nci, label: e.target.value } }))}
onKeyDown={e => e.key === 'Enter' && addChecklistItem(phase.id!)}
/>
<select
value={nci.scope}
onChange={e => setNewChecklistItem(prev => ({ ...prev, [phase.id!]: { ...nci, scope: e.target.value as 'perInstitutee' | 'meetingWide' } }))}
className="text-xs border border-gray-200 rounded px-1 py-1 bg-white focus:outline-none">
<option value="perInstitutee">pro Institutee</option>
<option value="meetingWide">meeting-weit</option>
</select>
<select
value={nci.linkedCriterionItemId}
onChange={e => setNewChecklistItem(prev => ({ ...prev, [phase.id!]: { ...nci, linkedCriterionItemId: e.target.value } }))}
className="text-xs border border-gray-200 rounded px-1 py-1 bg-white focus:outline-none max-w-[140px]">
<option value=""> frei </option>
{criterionOptionGroups(dimensions, categories, items)}
</select>
<button onClick={() => addChecklistItem(phase.id!)}
className="text-xs bg-brand text-white px-2 py-1 rounded-lg font-medium">+ Punkt</button>
</div>
<p className="text-[10px] text-gray-400">
Verknüpfte Punkte fließen automatisch in Gesamtbewertung und KI-Auswertung ein bei meeting-weit" für alle Institutees gleichzeitig.
</p>
</div>
)}
</div>
)
})()}
</div>
))}
@ -254,6 +412,11 @@ export default function AssignmentTypeConfig() {
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, hasAssessment: e.target.checked } }))} />
Bewertung
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={np.hasChecklist}
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, hasChecklist: e.target.checked } }))} />
Checkliste
</label>
</div>
<button onClick={() => addPhase(type.id!)}
className="px-4 py-1.5 bg-brand text-white text-xs rounded-lg font-medium">
@ -266,9 +429,18 @@ export default function AssignmentTypeConfig() {
{/* Kriterien-Tab */}
{tab === 'criteria' && (
<div className="p-3 space-y-1">
<p className="text-xs text-gray-400 pb-2">
Kein Haken = alle Kriterien werden angezeigt.
</p>
<div className="flex items-start justify-between gap-2 pb-2">
<p className="text-xs text-gray-400">
Steuert, welche Dimensionen/Kategorien in Meeting und Feedback sichtbar sind.
Kein Haken = gesamte Struktur. Haken setzen schränkt auf die Auswahl ein (gespeichert als Kriterien-IDs).
</p>
{selected.length > 0 && (
<button type="button" onClick={() => clearStructureFilter(type)}
className="text-xs text-brand whitespace-nowrap flex-shrink-0">
Alles freigeben
</button>
)}
</div>
{items.length === 0 && (
<div className="text-xs text-gray-400">Keine Kriterien konfiguriert. Erst unter "Feedback" anlegen.</div>
)}
@ -278,20 +450,33 @@ export default function AssignmentTypeConfig() {
if (dimItems.length === 0) return null
const isDimOpen = expandedDim.has(dim.id!)
const dimSelectedCount = dimItems.filter(i => selected.includes(i.id!)).length
const allDimSelected = dimItems.every(i => selected.includes(i.id!))
const someDimSelected = dimItems.some(i => selected.includes(i.id!))
return (
<div key={dim.id} className="border border-gray-100 rounded-lg overflow-hidden">
<button
onClick={() => setExpandedDim(prev => {
const next = new Set(prev)
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
return next
})}
className="w-full flex items-center justify-between px-3 py-2 bg-gray-50 hover:bg-gray-100 text-left">
<span className="text-xs font-semibold text-gray-700">{dim.name}</span>
<span className="text-xs text-gray-400">
{dimSelectedCount > 0 ? `${dimSelectedCount}/${dimItems.length}` : 'alle'} · {isDimOpen ? '▲' : '▼'}
</span>
</button>
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50">
<input
type="checkbox"
checked={allDimSelected}
ref={el => { if (el) el.indeterminate = someDimSelected && !allDimSelected }}
onChange={() => toggleAllInDimension(type, dim.id!)}
className="rounded border-gray-300"
title="Gesamte Dimension"
/>
<button
type="button"
onClick={() => setExpandedDim(prev => {
const next = new Set(prev)
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
return next
})}
className="flex-1 flex items-center justify-between text-left hover:opacity-80">
<span className="text-xs font-semibold text-gray-700">{dim.name}</span>
<span className="text-xs text-gray-400">
{selected.length === 0 ? 'alle' : `${dimSelectedCount}/${dimItems.length}`} · {isDimOpen ? '▲' : '▼'}
</span>
</button>
</div>
{isDimOpen && (
<div className="divide-y divide-gray-50">
{dimCats.map(cat => {
@ -310,6 +495,7 @@ export default function AssignmentTypeConfig() {
className="rounded border-gray-300"
/>
<span className="text-xs font-semibold text-gray-600">{cat.name}</span>
<span className="text-xs text-gray-300">{catItems.length} Krit.</span>
</label>
<div className="pl-5 space-y-1">
{catItems.map(item => (
@ -321,7 +507,7 @@ export default function AssignmentTypeConfig() {
className="rounded border-gray-300"
/>
<span className="text-xs text-gray-600">{item.name}</span>
{(item.weight ?? 1) > 1 && (
{(item.weight ?? 1) !== 1 && (
<span className="text-xs text-gray-300">×{item.weight}</span>
)}
</label>

View File

@ -1,10 +1,13 @@
import { useRef, useState } from 'react'
import FeedbackStructureConfig from './FeedbackStructureConfig'
import AssignmentTypeConfig from './AssignmentTypeConfig'
import RatingWeightConfig from './RatingWeightConfig'
import RatingScaleConfig from './RatingScaleConfig'
import AiPromptConfig from './AiPromptConfig'
import { exportFullBackup, importFullBackup } from '../utils/dbBackup'
export default function Configuration() {
const [tab, setTab] = useState<'feedback' | 'types' | 'backup'>('feedback')
const [tab, setTab] = useState<'feedback' | 'types' | 'weighting' | 'scale' | 'aiPrompts' | 'backup'>('feedback')
const [backupStatus, setBackupStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
const [restoreConfirm, setRestoreConfirm] = useState(false)
const [importing, setImporting] = useState(false)
@ -38,18 +41,29 @@ export default function Configuration() {
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">Konfiguration</h1>
{/* Tab-Switcher */}
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5">
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5 flex-wrap">
<button onClick={() => setTab('feedback')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'feedback' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
className={`flex-1 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'feedback' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
Feedback
</button>
<button onClick={() => setTab('types')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'types' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
className={`flex-1 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'types' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
Ass.-Typen
</button>
<button onClick={() => setTab('weighting')}
className={`flex-1 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'weighting' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
Gewichtung
</button>
<button onClick={() => setTab('scale')}
className={`flex-1 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'scale' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
Skala
</button>
<button onClick={() => setTab('aiPrompts')}
className={`flex-1 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'aiPrompts' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
KI-Prompts
</button>
<button onClick={() => setTab('backup')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'backup' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
className={`flex-1 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'backup' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
Backup
</button>
</div>
@ -63,8 +77,11 @@ export default function Configuration() {
</div>
)}
{tab === 'feedback' && <FeedbackStructureConfig />}
{tab === 'types' && <AssignmentTypeConfig />}
{tab === 'feedback' && <FeedbackStructureConfig />}
{tab === 'types' && <AssignmentTypeConfig />}
{tab === 'weighting' && <RatingWeightConfig />}
{tab === 'scale' && <RatingScaleConfig />}
{tab === 'aiPrompts' && <AiPromptConfig />}
{tab === 'backup' && (
<div className="space-y-4">

View File

@ -1,35 +1,55 @@
import { useEffect, useState } from 'react'
import { db, type Consultant, type Group } from '../db'
import {
type Consultant, type Group,
listGroups, listConsultants, addGroup as addGroupQuery, addConsultant as addConsultantQuery,
updateConsultant as updateConsultantQuery, deleteConsultant as deleteConsultantQuery,
} from '../db'
export default function Consultants() {
const [groups, setGroups] = useState<Group[]>([])
const [consultants, setConsultants] = useState<Consultant[]>([])
const [newGroup, setNewGroup] = useState({ name: '', cohortYear: new Date().getFullYear() })
const [newCons, setNewCons] = useState({ groupId: 0, firstName: '', lastName: '' })
const [editingId, setEditingId] = useState<number | null>(null)
const [editValues, setEditValues] = useState({ firstName: '', lastName: '' })
const load = async () => {
setGroups(await db.groups.toArray())
setConsultants(await db.consultants.toArray())
setGroups(await listGroups())
setConsultants(await listConsultants())
}
useEffect(() => { load() }, [])
const addGroup = async () => {
if (!newGroup.name.trim()) return
await db.groups.add({ ...newGroup })
await addGroupQuery({ ...newGroup })
setNewGroup({ name: '', cohortYear: new Date().getFullYear() })
load()
}
const addConsultant = async () => {
if (!newCons.firstName.trim() || !newCons.lastName.trim() || !newCons.groupId) return
await db.consultants.add({ ...newCons })
await addConsultantQuery({ ...newCons })
setNewCons(p => ({ ...p, firstName: '', lastName: '' }))
load()
}
const deleteConsultant = async (id: number) => {
await db.consultants.delete(id)
await deleteConsultantQuery(id)
load()
}
const startEdit = (c: Consultant) => {
setEditingId(c.id!)
setEditValues({ firstName: c.firstName, lastName: c.lastName })
}
const cancelEdit = () => setEditingId(null)
const saveEdit = async () => {
if (!editValues.firstName.trim() || !editValues.lastName.trim() || editingId === null) return
await updateConsultantQuery(editingId, { ...editValues })
setEditingId(null)
load()
}
@ -77,9 +97,36 @@ export default function Consultants() {
<div key={g.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<h3 className="font-semibold text-gray-700 mb-2">{g.name}</h3>
{members.map(c => (
<div key={c.id} className="flex items-center justify-between py-1 text-sm text-gray-700 border-b border-gray-50 last:border-0">
<span>{c.firstName} {c.lastName}</span>
<button onClick={() => deleteConsultant(c.id!)} className="text-red-400 text-xs hover:text-red-600">×</button>
<div key={c.id} className="flex items-center justify-between py-1 text-sm text-gray-700 border-b border-gray-50 last:border-0 gap-2">
{editingId === c.id ? (
<>
<div className="flex gap-2 flex-1">
<input
className="flex-1 border border-gray-200 rounded-lg px-2 py-1 text-sm"
value={editValues.firstName}
onChange={e => setEditValues(p => ({ ...p, firstName: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && saveEdit()}
autoFocus
/>
<input
className="flex-1 border border-gray-200 rounded-lg px-2 py-1 text-sm"
value={editValues.lastName}
onChange={e => setEditValues(p => ({ ...p, lastName: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && saveEdit()}
/>
</div>
<button onClick={saveEdit} className="text-brand text-xs hover:underline">Speichern</button>
<button onClick={cancelEdit} className="text-gray-400 text-xs hover:text-gray-600">Abbrechen</button>
</>
) : (
<>
<span>{c.firstName} {c.lastName}</span>
<div className="flex gap-2">
<button onClick={() => startEdit(c)} className="text-gray-400 text-xs hover:text-brand">Bearbeiten</button>
<button onClick={() => deleteConsultant(c.id!)} className="text-red-400 text-xs hover:text-red-600">×</button>
</div>
</>
)}
</div>
))}
</div>

View File

@ -1,40 +1,37 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { db, type Assignment, type AssignmentType, type Consultant } from '../db'
interface AssignmentRow extends Assignment {
typeName: string
instituteeNames: string[]
}
import { listAssignmentsWithDetails, softDeleteAssignment, archiveAssignment, type AssignmentWithDetails } from '../db'
export default function Dashboard() {
const [assignments, setAssignments] = useState<AssignmentRow[]>([])
const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
const [query, setQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'done'>('all')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'done'>('active')
const [confirmTrashId, setConfirmTrashId] = useState<number | null>(null)
const [confirmArchiveId, setConfirmArchiveId] = useState<number | null>(null)
const navigate = useNavigate()
useEffect(() => {
const load = async () => {
const raw = await db.assignments.orderBy('createdAt').reverse().toArray()
const types = await db.assignmentTypes.toArray()
const consultants = await db.consultants.toArray()
const typeMap = new Map<number, AssignmentType>(types.map(t => [t.id!, t]))
const consMap = new Map<number, Consultant>(consultants.map(c => [c.id!, c]))
setAssignments(raw.map(a => ({
...a,
typeName: typeMap.get(a.assignmentTypeId)?.name ?? '—',
instituteeNames: a.instituteeIds.map(id => {
const c = consMap.get(id)
return c ? `${c.firstName} ${c.lastName}` : '?'
}),
})))
}
const load = () => { listAssignmentsWithDetails().then(setAssignments) }
useEffect(() => { load() }, [])
const trashAssignment = async (id: number) => {
await softDeleteAssignment(id)
setConfirmTrashId(null)
load()
}, [])
}
const archiveThisAssignment = async (id: number) => {
await archiveAssignment(id)
setConfirmArchiveId(null)
load()
}
const trashCount = useMemo(() => assignments.filter(a => a.deletedAt).length, [assignments])
const archiveCount = useMemo(() => assignments.filter(a => a.archivedAt).length, [assignments])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return assignments.filter(a => {
if (a.deletedAt || a.archivedAt) return false
if (statusFilter !== 'all' && a.status !== statusFilter) return false
if (!q) return true
return (
@ -51,12 +48,26 @@ export default function Dashboard() {
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800">Assignments</h1>
<button
onClick={() => navigate('/assignment/new')}
className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium"
>
+ Neues
</button>
<div className="flex items-center gap-2">
<button
onClick={() => navigate('/assignments/trash')}
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition"
>
🗑 Papierkorb{trashCount > 0 && ` (${trashCount})`}
</button>
<button
onClick={() => navigate('/assignments/archive')}
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition"
>
🗄 Archiv{archiveCount > 0 && ` (${archiveCount})`}
</button>
<button
onClick={() => navigate('/assignment/new')}
className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium"
>
+ Neues
</button>
</div>
</div>
{/* Suche + Status-Filter */}
@ -112,6 +123,45 @@ export default function Dashboard() {
{a.status === 'active' ? 'Aktiv' : 'Fertig'}
</span>
</div>
{a.status === 'done' && (
<div className="mt-2 pt-2 border-t border-gray-100 flex gap-2" onClick={e => e.stopPropagation()}>
<button
onClick={() => { setConfirmTrashId(confirmTrashId === a.id ? null : a.id!); setConfirmArchiveId(null) }}
disabled={!a.feedbackComplete}
title={!a.feedbackComplete ? 'Erst möglich, wenn für alle Institutees ein finales Feedback vorliegt' : undefined}
className={`text-xs px-2.5 py-1 rounded-lg font-medium ${
a.feedbackComplete ? 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}>
🗑 Papierkorb
</button>
<button
onClick={() => { setConfirmArchiveId(confirmArchiveId === a.id ? null : a.id!); setConfirmTrashId(null) }}
className="text-xs px-2.5 py-1 rounded-lg font-medium bg-gray-50 text-gray-600 border border-gray-200 hover:bg-gray-100">
🗄 Archivieren
</button>
</div>
)}
{confirmTrashId === a.id && (
<div className="mt-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
<span className="text-xs text-red-700">In den Papierkorb verschieben?</span>
<div className="flex gap-1.5">
<button onClick={() => trashAssignment(a.id!)} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmTrashId(null)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
{confirmArchiveId === a.id && (
<div className="mt-2 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
<span className="text-xs text-gray-600">Archivieren?</span>
<div className="flex gap-1.5">
<button onClick={() => archiveThisAssignment(a.id!)} className="text-xs bg-gray-700 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmArchiveId(null)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
</div>
))}
</div>

View File

@ -1,67 +1,111 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { db, type Assignment, type Consultant, type Category, type Criterion, type FeedbackRating } from '../db'
import {
type Assignment, type Consultant,
type FeedbackDimension, type FeedbackCategory,
type RatingScore,
listAssignments, listConsultants,
loadFeedbackStructure,
listAssessmentsAll,
getRatingTrendWeightStep,
listDoneMeetingsChronological,
listAssignmentFeedbacksAll,
} from '../db'
import { DEFAULT_RATING_COLOR_BANDS, resolveScoreStyle } from '../config/constants'
import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend'
const ratingToNum = (r: FeedbackRating | null | undefined): number => {
const m: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
return r ? (m[r] ?? 0) : 0
interface CatScore {
category: FeedbackCategory
avg: number | null
trend: TrendResult['trend']
}
interface InstScore {
institutee: Consultant
assignment: Assignment
scores: { criterion: Criterion; avg: number | null }[]
weightedTotal: number | null
institutee: Consultant
assignment: Assignment
catScores: CatScore[]
overall: number | null
overallTrend: TrendResult['trend']
feedbackStatus: 'none' | 'draft' | 'final'
}
function feedbackStatusBadge(status: InstScore['feedbackStatus']) {
if (status === 'final') return <span className="text-xs font-medium px-2 py-1 rounded-lg bg-green-100 text-green-700"> Final</span>
if (status === 'draft') return <span className="text-xs font-medium px-2 py-1 rounded-lg bg-amber-100 text-amber-700">Entwurf</span>
return <span className="text-xs font-medium px-2 py-1 rounded-lg bg-gray-100 text-gray-400">Kein Feedback</span>
}
function ratingColor(avg: number | null): string {
const rounded = avg === null ? null : Math.round(avg)
return resolveScoreStyle(rounded as RatingScore | null, DEFAULT_RATING_COLOR_BANDS).color
}
function ratingLabel(avg: number | null): string {
if (avg === null) return '—'
return avg.toFixed(1)
}
function trendArrow(trend: TrendResult['trend']) {
if (trend === 'up') return <span className="text-green-600" title="Verbesserung über die Laufzeit"></span>
if (trend === 'down') return <span className="text-red-600" title="Verschlechterung über die Laufzeit"></span>
return null
}
export default function Evaluation() {
const [data, setData] = useState<InstScore[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [data, setData] = useState<InstScore[]>([])
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
const [categories, setCategories] = useState<FeedbackCategory[]>([])
const [selected, setSelected] = useState<string | null>(null)
const navigate = useNavigate()
useEffect(() => {
const load = async () => {
const [assignments, consultants, criteria, cats, assessments] = await Promise.all([
db.assignments.toArray(),
db.consultants.toArray(),
db.criteria.orderBy('order').toArray(),
db.categories.orderBy('order').toArray(),
db.assessments.toArray(),
const [allAssignments, consultants, structure, assessments, weightStep, feedbacks] = await Promise.all([
listAssignments(),
listConsultants(),
loadFeedbackStructure(),
listAssessmentsAll(),
getRatingTrendWeightStep(),
listAssignmentFeedbacksAll(),
])
const { dimensions: dims, categories: cats, items } = structure
setDimensions(dims)
setCategories(cats)
// Papierkorb/Archiv ausgeschlossen — analog zum Dashboard-Filter, siehe CLAUDE.md.
const assignments = allAssignments.filter(a => !a.deletedAt && !a.archivedAt)
const rows: InstScore[] = []
for (const a of assignments) {
const meetings = await listDoneMeetingsChronological(a.id!)
const meetingIds = meetings.map(m => m.id!)
for (const instId of a.instituteeIds) {
const inst = consultants.find(c => c.id === instId)
if (!inst) continue
// Nur Meetings dieses Assignments
const meetingIds = (await db.meetingInstances.where('assignmentId').equals(a.id!).toArray()).map(m => m.id!)
const instAssessments = assessments.filter(
as => as.instituteeId === instId && meetingIds.includes(as.meetingInstanceId)
)
if (instAssessments.length === 0) continue
const usedCritIds = [...new Set(instAssessments.map(a => a.criteriaId))]
const usedCrit = criteria.filter(c => usedCritIds.includes(c.id!))
const getScore = (meetingId: number, itemId: number): RatingScore | null =>
instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null
const scores = usedCrit.map(crit => {
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null)
const avg = relevant.length > 0
? relevant.reduce((s, a) => s + ratingToNum(a.score), 0) / relevant.length
: null
return { criterion: crit, avg }
const catScores: CatScore[] = cats.map(cat => {
const history = buildGroupMeetingScores(items.filter(i => i.categoryId === cat.id), meetings, getScore)
const { suggestion, trend } = computeTrend(history, weightStep)
return { category: cat, avg: suggestion, trend }
})
const rated = scores.filter(s => s.avg !== null)
const weightedTotal = rated.length > 0
? rated.reduce((s, x) => s + x.avg! * x.criterion.weight, 0) /
rated.reduce((s, x) => s + x.criterion.weight, 0)
: null
const overallHistory = buildGroupMeetingScores(items, meetings, getScore)
const { suggestion: overall, trend: overallTrend } = computeTrend(overallHistory, weightStep)
if (overall === null && catScores.every(c => c.avg === null)) continue
rows.push({ institutee: inst, assignment: a, scores, weightedTotal })
const fb = feedbacks.find(f => f.assignmentId === a.id && f.instituteeId === instId)
const feedbackStatus: InstScore['feedbackStatus'] = fb?.status === 'final' ? 'final' : fb?.status === 'draft' ? 'draft' : 'none'
rows.push({ institutee: inst, assignment: a, catScores, overall, overallTrend, feedbackStatus })
}
}
setData(rows)
@ -69,66 +113,85 @@ export default function Evaluation() {
load()
}, [])
const scoreColor = (avg: number | null) => {
if (avg === null) return 'bg-gray-100 text-gray-400'
if (avg >= 4) return 'bg-green-100 text-green-700'
if (avg >= 3) return 'bg-yellow-100 text-yellow-700'
return 'bg-red-100 text-red-700'
}
const rowKey = (r: InstScore) => `${r.assignment.id}-${r.institutee.id}`
const assignmentGroups = data.reduce<Map<number, { assignment: Assignment; rows: InstScore[] }>>(
(map, row) => {
const key = row.assignment.id!
const group = map.get(key)
if (group) group.rows.push(row)
else map.set(key, { assignment: row.assignment, rows: [row] })
return map
},
new Map()
)
return (
<div className="space-y-4">
<div className="space-y-6">
<h1 className="text-xl font-bold text-gray-800">Auswertung</h1>
{data.length === 0 && (
<p className="text-gray-500 text-sm">Noch keine Bewertungen vorhanden.</p>
)}
{data.map(row => (
<div key={rowKey(row)} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div
className="flex items-start justify-between cursor-pointer"
onClick={() => setSelected(selected === rowKey(row) ? null : rowKey(row))}
>
<div>
<div className="font-semibold text-gray-800">
{row.institutee.firstName} {row.institutee.lastName}
</div>
<div className="text-xs text-gray-400">{row.assignment.title} · {row.assignment.client}</div>
</div>
<div className="flex items-center gap-2">
<div className={`px-3 py-1 rounded-full text-sm font-bold ${scoreColor(row.weightedTotal)}`}>
{row.weightedTotal !== null ? row.weightedTotal.toFixed(1) : '—'} / 5
</div>
<button
onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }}
className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-lg">
Feedback
</button>
</div>
{[...assignmentGroups.values()].map(({ assignment, rows }) => (
<div key={assignment.id} className="space-y-2">
<div className="px-1">
{assignment.assignmentNumber && (
<div className="text-xs font-semibold text-brand uppercase tracking-wide">{assignment.assignmentNumber}</div>
)}
<h2 className="font-bold text-gray-800">{assignment.title}</h2>
<div className="text-xs text-gray-400">{assignment.client}</div>
</div>
{selected === rowKey(row) && (
<div className="mt-4 space-y-3">
{categories.map(cat => {
const catScores = row.scores.filter(s => s.criterion.categoryId === cat.id)
if (catScores.every(s => s.avg === null)) return null
return (
<div key={cat.id}>
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">{cat.name}</div>
{catScores.map(({ criterion, avg }) => (
<div key={criterion.id} className="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0">
<span className="text-sm text-gray-700">{criterion.name}</span>
<span className={`text-sm font-semibold px-2 py-0.5 rounded min-w-[36px] text-center ${scoreColor(avg)}`}>
{avg !== null ? avg.toFixed(1) : '—'}
</span>
</div>
))}
{rows.map(row => (
<div key={rowKey(row)} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div
className="flex items-start justify-between cursor-pointer"
onClick={() => setSelected(selected === rowKey(row) ? null : rowKey(row))}>
<div className="font-semibold text-gray-800">
{row.institutee.firstName} {row.institutee.lastName}
</div>
<div className="flex items-center gap-2">
<div className={`px-3 py-1 rounded-full text-sm font-bold flex items-center gap-1 ${ratingColor(row.overall)}`}>
{ratingLabel(row.overall)} {trendArrow(row.overallTrend)}
</div>
)
})}
{feedbackStatusBadge(row.feedbackStatus)}
<button
onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }}
className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-lg">
Feedback
</button>
</div>
</div>
{selected === rowKey(row) && (
<div className="mt-4 space-y-4">
{dimensions.map(dim => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
const dimScores = row.catScores.filter(cs =>
dimCats.some(c => c.id === cs.category.id) && cs.avg !== null
)
if (dimScores.length === 0) return null
return (
<div key={dim.id}>
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">
{dim.name}
</div>
{dimScores.map(({ category, avg, trend }) => (
<div key={category.id}
className="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0">
<span className="text-sm text-gray-700">{category.name}</span>
<span className={`text-xs font-semibold px-2 py-0.5 rounded flex items-center gap-1 ${ratingColor(avg)}`}>
{ratingLabel(avg)} {trendArrow(trend)}
</span>
</div>
))}
</div>
)
})}
</div>
)}
</div>
)}
))}
</div>
))}
</div>

View File

@ -1,22 +1,35 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
db,
type Assignment,
type Consultant,
type Category,
type Criterion,
type FeedbackCategory,
type FeedbackCriterionItem,
type FeedbackDraft,
type FeedbackRating,
getAssignment, getConsultant,
listFeedbackCategoriesOrdered, getFeedbackCriterionItemsByIds,
listMeetingInstancesForAssignment,
listAssessmentsForMeetings,
listConversationEntriesForMeetings,
getFeedbackDraft, addFeedbackDraft, updateFeedbackDraft,
} from '../db'
import {
DEFAULT_RATING_COLOR_BANDS, isValidRatingScore, resolveScoreStyle,
} from '../config/constants'
import type { RatingScore } from '../db/types'
const ratingToNum = (r: FeedbackRating | null | undefined): number => {
const m: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
return r ? (m[r] ?? 0) : 0
const ratingLabel = (avg: number | null): string => {
if (avg === null) return '—'
return `${avg.toFixed(1)}/10`
}
const scoreColor = (avg: number | null): string => {
const rounded = avg === null ? null : Math.round(avg)
return resolveScoreStyle(rounded as RatingScore | null, DEFAULT_RATING_COLOR_BANDS).color
}
interface CriterionSummary {
criterion: Criterion
criterion: FeedbackCriterionItem
avg: number | null
notes: string[]
}
@ -29,7 +42,7 @@ export default function FeedbackDraftPage() {
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [institutee, setInstitutee] = useState<Consultant | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [categories, setCategories] = useState<FeedbackCategory[]>([])
const [summary, setSummary] = useState<CriterionSummary[]>([])
const [draft, setDraft] = useState<FeedbackDraft | null>(null)
const [content, setContent] = useState('')
@ -38,47 +51,50 @@ export default function FeedbackDraftPage() {
const load = useCallback(async () => {
const [a, cons, cats] = await Promise.all([
db.assignments.get(assignmentId),
db.consultants.get(instId),
db.categories.orderBy('order').toArray(),
getAssignment(assignmentId),
getConsultant(instId),
listFeedbackCategoriesOrdered(),
])
setAssignment(a ?? null)
setInstitutee(cons ?? null)
setCategories(cats)
const meetings = await db.meetingInstances.where('assignmentId').equals(assignmentId).toArray()
const meetings = await listMeetingInstancesForAssignment(assignmentId)
const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([
db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray(),
db.conversationEntries.where('meetingInstanceId').anyOf(meetingIds).toArray(),
listAssessmentsForMeetings(meetingIds),
listConversationEntriesForMeetings(meetingIds),
])
const instAssessments = allAssessments.filter(a => a.instituteeId === instId)
const instEntries = allEntries.filter(e => e.instituteeId === instId)
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
// Allgemeine Notizen sind an kein Institutee gebunden — zählen daher ungefiltert für jedes Institutee.
const generalNoteTags = meetings.flatMap(m => m.generalNoteTags ?? [])
const criteriaIds = [...new Set(instAssessments.map(a => a.criteriaId))]
const crits = criteriaIds.length > 0
? await db.criteria.where('id').anyOf(criteriaIds).toArray()
: []
const criteriaIds = [...new Set([
...instAssessments.map(a => a.criteriaId),
...allLineTags.map(t => t.criterionItemId),
...generalNoteTags.map(t => t.criterionItemId),
])]
const crits = await getFeedbackCriterionItemsByIds(criteriaIds)
const sum: CriterionSummary[] = crits.map(crit => {
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null)
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && isValidRatingScore(a.score))
const avg = relevant.length > 0
? relevant.reduce((s, a) => s + ratingToNum(a.score), 0) / relevant.length
? relevant.reduce((s, a) => s + (a.score as number), 0) / relevant.length
: null
const notes = [
...instAssessments.filter(a => a.criteriaId === crit.id && a.note).map(a => a.note),
...instEntries.filter(e => e.note).map(e => e.note),
].filter(Boolean)
...allLineTags.filter(t => t.criterionItemId === crit.id).map(t => t.text),
...generalNoteTags.filter(t => t.criterionItemId === crit.id).map(t => t.text),
]
return { criterion: crit, avg, notes }
})
setSummary(sum)
const existing = await db.feedbackDrafts
.where('assignmentId').equals(assignmentId)
.filter(d => d.instituteeId === instId)
.first()
const existing = await getFeedbackDraft(assignmentId, instId)
if (existing) {
setDraft(existing)
setContent(existing.content)
@ -104,8 +120,7 @@ export default function FeedbackDraftPage() {
for (const [catId, items] of grouped) {
text += `## ${catMap.get(catId) ?? 'Sonstige'}\n`
for (const item of items) {
const avgStr = item.avg !== null ? item.avg.toFixed(1) : '—'
text += `${item.criterion.name}: ${avgStr}/5\n`
text += `${item.criterion.name}: ${ratingLabel(item.avg)}\n`
if (item.notes.length > 0) {
const uniqueNotes = [...new Set(item.notes)]
text += uniqueNotes.map(n => `${n}`).join('\n') + '\n'
@ -120,10 +135,10 @@ export default function FeedbackDraftPage() {
const now = new Date().toISOString()
if (draft?.id) {
await db.feedbackDrafts.update(draft.id, { content: text, updatedAt: now })
await updateFeedbackDraft(draft.id, { content: text, updatedAt: now })
setDraft(prev => prev ? { ...prev, content: text, updatedAt: now } : null)
} else {
const newId = await db.feedbackDrafts.add({
const newId = await addFeedbackDraft({
assignmentId, instituteeId: instId, content: text, updatedAt: now,
})
setDraft({ id: newId, assignmentId, instituteeId: instId, content: text, updatedAt: now })
@ -136,9 +151,9 @@ export default function FeedbackDraftPage() {
saveTimer.current = setTimeout(async () => {
const now = new Date().toISOString()
if (draft?.id) {
await db.feedbackDrafts.update(draft.id, { content: val, updatedAt: now })
await updateFeedbackDraft(draft.id, { content: val, updatedAt: now })
} else {
const newId = await db.feedbackDrafts.add({
const newId = await addFeedbackDraft({
assignmentId, instituteeId: instId, content: val, updatedAt: now,
})
setDraft({ id: newId, assignmentId, instituteeId: instId, content: val, updatedAt: now })
@ -146,13 +161,6 @@ export default function FeedbackDraftPage() {
}, 600)
}
const scoreColor = (avg: number | null) => {
if (avg === null) return 'bg-gray-100 text-gray-400'
if (avg >= 4) return 'bg-green-100 text-green-700'
if (avg >= 3) return 'bg-yellow-100 text-yellow-700'
return 'bg-red-100 text-red-700'
}
if (!assignment || !institutee) return <div className="text-center py-10 text-gray-400">Lädt</div>
return (
@ -179,7 +187,7 @@ export default function FeedbackDraftPage() {
className="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0">
<span className="text-sm text-gray-700">{s.criterion.name}</span>
<span className={`text-sm font-semibold px-2 py-0.5 rounded min-w-[40px] text-center ${scoreColor(s.avg)}`}>
{s.avg !== null ? s.avg.toFixed(1) : '—'}
{ratingLabel(s.avg)}
</span>
</div>
))}

View File

@ -1,39 +1,87 @@
import { useEffect, useState } from 'react'
import { db, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem, type AssignmentType, type PhaseTemplate } from '../db'
import {
type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem, type CriterionUsage, type CriterionUsageInstance,
loadFeedbackStructure,
addFeedbackDimension, updateFeedbackDimension, deleteFeedbackDimension,
addFeedbackCategory, updateFeedbackCategory, deleteFeedbackCategory,
addFeedbackCriterionItem, updateFeedbackCriterionItem, deleteFeedbackCriterionItem,
getCriterionUsageMap, reassignCriterionReferences,
getCriterionUsageDetails, reassignUsageInstance, removeUsageInstance,
preloadFinalFeedbackStructure, importFeedbackStructureFromMarkdown,
} from '../db'
import { exportFeedbackStructureAsMarkdown } from '../utils/structureExport'
import { exportFeedbackStructurePdf } from '../utils/pdfExport'
import { criterionOptionGroups } from '../utils/criterionOptions'
import { summarizeFeedbackStructureSeed, parseFeedbackStructureMarkdown } from '../utils/feedbackStructureImport'
const totalUsage = (u: CriterionUsage | undefined): number =>
!u ? 0 : u.assessmentCount + u.lineTagCount + u.generalNoteTagCount + u.assignmentTypeNames.length + u.phaseTemplateLabels.length
const instanceKindLabel: Record<CriterionUsageInstance['kind'], string> = {
assessment: 'Bewertung',
lineTag: 'Protokollzeile',
generalNoteTag: 'Allgemeine Notiz',
assignmentTypeVisibility: 'Sichtbarkeit (Assignment-Typ)',
phaseTemplateVisibility: 'Sichtbarkeit (Phase)',
}
/** Die drei "Daten"-Kinds haben Meeting/Person-Kontext, die zwei "Sichtbarkeits"-Kinds nicht. */
const isDataInstance = (
instance: CriterionUsageInstance,
): instance is Extract<CriterionUsageInstance, { kind: 'assessment' | 'lineTag' | 'generalNoteTag' }> =>
instance.kind === 'assessment' || instance.kind === 'lineTag' || instance.kind === 'generalNoteTag'
/**
* Stabiler, inhaltsbasierter Schlüssel statt Array-Index der Index verschiebt sich nach jedem
* Übertragen/Entfernen, weil die Instanzen-Liste neu vom Server geladen wird. Mit dem Index als
* Schlüssel hingen ausgewählte Ziel-Kriterien danach plötzlich an der falschen (neu an dieser
* Position stehenden) Instanz.
*/
const instanceKey = (instance: CriterionUsageInstance): string => {
switch (instance.kind) {
case 'assessment': return `assessment:${instance.assessmentId}`
case 'lineTag': return `lineTag:${instance.conversationEntryId}:${instance.tagText}`
case 'generalNoteTag': return `generalNoteTag:${instance.meetingInstanceId}:${instance.tagText}`
case 'assignmentTypeVisibility': return `atVis:${instance.assignmentTypeId}`
case 'phaseTemplateVisibility': return `ptVis:${instance.phaseTemplateId}`
}
}
export default function FeedbackStructureConfig() {
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
const [categories, setCategories] = useState<FeedbackCategory[]>([])
const [items, setItems] = useState<FeedbackCriterionItem[]>([])
const [assignmentTypes, setAssignmentTypes] = useState<AssignmentType[]>([])
const [usageMap, setUsageMap] = useState<Record<number, CriterionUsage>>({})
const [usageDetails, setUsageDetails] = useState<Record<number, CriterionUsageInstance[]>>({})
const [loadingUsage, setLoadingUsage] = useState<Set<number>>(new Set())
const [instanceTarget, setInstanceTarget] = useState<Record<string, number>>({})
const [instanceNotice, setInstanceNotice] = useState<Record<number, string>>({})
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [expandedCat, setExpandedCat] = useState<Set<number>>(new Set())
const [showCatalogs, setShowCatalogs] = useState(false)
const [phaseTemplates, setPhaseTemplates] = useState<PhaseTemplate[]>([])
const [showTypeConfig, setShowTypeConfig] = useState(false)
const [expandedType, setExpandedType] = useState<number | null>(null)
const [newTypeName, setNewTypeName] = useState('')
const [newPhase, setNewPhase] = useState<Record<number, { label: string; withWhom: 'lead'|'client'; isRepeatable: boolean; hasConversation: boolean; hasAssessment: boolean }>>({})
const [typeError, setTypeError] = useState<Record<number, string>>({})
const [expandedUsage, setExpandedUsage] = useState<Set<number>>(new Set())
// Neue Einträge
const [newDimName, setNewDimName] = useState('')
const [newCatName, setNewCatName] = useState<Record<number, string>>({})
const [newItemName, setNewItemName] = useState<Record<number, string>>({})
const [exportingPdf, setExportingPdf] = useState(false)
const [importingStructure, setImportingStructure] = useState(false)
const [importNotice, setImportNotice] = useState<string | null>(null)
const [confirmPreload, setConfirmPreload] = useState(false)
// Lösch-Bestätigungen & Übertragen-auf-anderes-Kriterium
const [confirmDeleteDimId, setConfirmDeleteDimId] = useState<number | null>(null)
const [confirmDeleteCatId, setConfirmDeleteCatId] = useState<number | null>(null)
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState<number | null>(null)
const [reassignTarget, setReassignTarget] = useState<Record<number, number>>({})
const [mergeNotice, setMergeNotice] = useState('')
const load = async () => {
const [dims, cats, itms, types, phases] = await Promise.all([
db.feedbackDimensions.orderBy('order').toArray(),
db.feedbackCategories.orderBy('order').toArray(),
db.feedbackCriterionItems.orderBy('order').toArray(),
db.assignmentTypes.toArray(),
db.phaseTemplates.toArray(),
])
setDimensions(dims)
setCategories(cats)
setItems(itms)
setAssignmentTypes(types)
setPhaseTemplates(phases)
const [structure, usage] = await Promise.all([loadFeedbackStructure(), getCriterionUsageMap()])
setDimensions(structure.dimensions)
setCategories(structure.categories)
setItems(structure.items)
setUsageMap(usage)
}
useEffect(() => { load() }, [])
@ -43,24 +91,29 @@ export default function FeedbackStructureConfig() {
const addDimension = async () => {
if (!newDimName.trim()) return
const maxOrder = dimensions.reduce((m, d) => Math.max(m, d.order), 0)
const id = await db.feedbackDimensions.add({ name: newDimName.trim(), order: maxOrder + 1 })
const id = await addFeedbackDimension({ name: newDimName.trim(), order: maxOrder + 1 })
setNewDimName('')
await load()
setExpanded(prev => new Set([...prev, id]))
}
const updateDimension = async (id: number, name: string) => {
await db.feedbackDimensions.update(id, { name })
const updateDimension = (id: number, name: string) => {
setDimensions(prev => prev.map(d => d.id === id ? { ...d, name } : d))
updateFeedbackDimension(id, { name })
}
const dimensionUsageTotal = (dimId: number): number =>
categories.filter(c => c.dimensionId === dimId)
.reduce((sum, cat) => sum + categoryUsageTotal(cat.id!), 0)
const requestDeleteDimension = (id: number) => {
if (dimensionUsageTotal(id) === 0) { deleteDimension(id); return }
setConfirmDeleteDimId(id)
}
const deleteDimension = async (id: number) => {
const cats = await db.feedbackCategories.where('dimensionId').equals(id).toArray()
for (const cat of cats) {
await db.feedbackCriterionItems.where('categoryId').equals(cat.id!).delete()
}
await db.feedbackCategories.where('dimensionId').equals(id).delete()
await db.feedbackDimensions.delete(id)
await deleteFeedbackDimension(id)
setConfirmDeleteDimId(null)
await load()
}
@ -69,8 +122,8 @@ export default function FeedbackStructureConfig() {
const idx = sorted.findIndex(d => d.id === dim.id)
const other = sorted[idx + dir]
if (!other) return
await db.feedbackDimensions.update(dim.id!, { order: other.order })
await db.feedbackDimensions.update(other.id!, { order: dim.order })
await updateFeedbackDimension(dim.id!, { order: other.order })
await updateFeedbackDimension(other.id!, { order: dim.order })
await load()
}
@ -81,20 +134,33 @@ export default function FeedbackStructureConfig() {
if (!name) return
const dimCats = categories.filter(c => c.dimensionId === dimId)
const maxOrder = dimCats.reduce((m, c) => Math.max(m, c.order), 0)
const id = await db.feedbackCategories.add({ dimensionId: dimId, name, order: maxOrder + 1 })
const id = await addFeedbackCategory({ dimensionId: dimId, name, order: maxOrder + 1 })
setNewCatName(prev => ({ ...prev, [dimId]: '' }))
await load()
setExpandedCat(prev => new Set([...prev, id]))
}
const updateCategory = async (id: number, name: string) => {
await db.feedbackCategories.update(id, { name })
const updateCategory = (id: number, name: string) => {
setCategories(prev => prev.map(c => c.id === id ? { ...c, name } : c))
updateFeedbackCategory(id, { name })
}
const updateCategoryDescription = (id: number, description: string) => {
setCategories(prev => prev.map(c => c.id === id ? { ...c, description } : c))
updateFeedbackCategory(id, { description })
}
const categoryUsageTotal = (catId: number): number =>
items.filter(i => i.categoryId === catId).reduce((sum, i) => sum + totalUsage(usageMap[i.id!]), 0)
const requestDeleteCategory = (id: number) => {
if (categoryUsageTotal(id) === 0) { deleteCategory(id); return }
setConfirmDeleteCatId(id)
}
const deleteCategory = async (id: number) => {
await db.feedbackCriterionItems.where('categoryId').equals(id).delete()
await db.feedbackCategories.delete(id)
await deleteFeedbackCategory(id)
setConfirmDeleteCatId(null)
await load()
}
@ -105,95 +171,225 @@ export default function FeedbackStructureConfig() {
if (!name) return
const catItems = items.filter(i => i.categoryId === catId)
const maxOrder = catItems.reduce((m, i) => Math.max(m, i.order), 0)
await db.feedbackCriterionItems.add({ categoryId: catId, name, order: maxOrder + 1 })
await addFeedbackCriterionItem({ categoryId: catId, name, order: maxOrder + 1 })
setNewItemName(prev => ({ ...prev, [catId]: '' }))
await load()
}
const updateItem = async (id: number, name: string) => {
await db.feedbackCriterionItems.update(id, { name })
const updateItem = (id: number, name: string) => {
setItems(prev => prev.map(i => i.id === id ? { ...i, name } : i))
updateFeedbackCriterionItem(id, { name })
}
const requestDeleteItem = (id: number) => {
if (totalUsage(usageMap[id]) === 0) { deleteItem(id); return }
setMergeNotice('')
setConfirmDeleteItemId(id)
}
const deleteItem = async (id: number) => {
await db.feedbackCriterionItems.delete(id)
await deleteFeedbackCriterionItem(id)
setItems(prev => prev.filter(i => i.id !== id))
setConfirmDeleteItemId(null)
await load()
}
const updateItemWeight = async (id: number, weight: number) => {
await db.feedbackCriterionItems.update(id, { weight })
const reassignAndDeleteItem = async (fromId: number) => {
const toId = reassignTarget[fromId]
if (!toId) return
const { mergedConflicts } = await reassignCriterionReferences(fromId, toId)
await deleteFeedbackCriterionItem(fromId)
setConfirmDeleteItemId(null)
setMergeNotice(mergedConflicts > 0 ? `${mergedConflicts} Bewertung(en) wurden dabei mit bereits vorhandenen Bewertungen des Ziel-Kriteriums zusammengeführt.` : '')
await load()
}
const updateItemWeight = (id: number, weight: number) => {
setItems(prev => prev.map(i => i.id === id ? { ...i, weight } : i))
updateFeedbackCriterionItem(id, { weight })
}
const toggleCatalogCategory = async (type: AssignmentType, catId: number) => {
const current = type.criteriaIds ?? []
const next = current.includes(catId) ? current.filter(id => id !== catId) : [...current, catId]
await db.assignmentTypes.update(type.id!, { criteriaIds: next })
setAssignmentTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
const updateItemDescription = (id: number, description: string) => {
setItems(prev => prev.map(i => i.id === id ? { ...i, description } : i))
updateFeedbackCriterionItem(id, { description })
}
// ── AssignmentType CRUD ───────────────────────────────────────────────────
const addAssignmentType = async () => {
if (!newTypeName.trim()) return
await db.assignmentTypes.add({ name: newTypeName.trim(), defaultPhaseKeys: [] })
setNewTypeName('')
await load()
const moveItemToCategory = (itemId: number, newCategoryId: number) => {
setItems(prev => prev.map(i => i.id === itemId ? { ...i, categoryId: newCategoryId } : i))
updateFeedbackCriterionItem(itemId, { categoryId: newCategoryId })
}
const updateAssignmentTypeName = async (id: number, name: string) => {
await db.assignmentTypes.update(id, { name })
setAssignmentTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t))
}
const deleteAssignmentType = async (id: number) => {
const count = await db.assignments.where('assignmentTypeId').equals(id).count()
if (count > 0) {
setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden ${count} Assignment(s) verwenden diesen Typ.` }))
return
}
await db.phaseTemplates.where('assignmentTypeId').equals(id).delete()
await db.assignmentTypes.delete(id)
setTypeError(prev => { const n = { ...prev }; delete n[id]; return n })
await load()
}
// ── PhaseTemplate CRUD ────────────────────────────────────────────────────
const addPhaseTemplate = async (typeId: number) => {
const p = newPhase[typeId]
if (!p?.label.trim()) return
const existing = phaseTemplates.filter(t => t.assignmentTypeId === typeId)
await db.phaseTemplates.add({
assignmentTypeId: typeId,
phaseKey: p.label.trim().toLowerCase().replace(/\s+/g, '_'),
label: p.label.trim(),
withWhom: p.withWhom,
isRepeatable: p.isRepeatable,
hasConversation: p.hasConversation,
hasAssessment: p.hasAssessment,
criteriaIds: [],
order: existing.length + 1,
const toggleUsageExpanded = async (itemId: number) => {
const willOpen = !expandedUsage.has(itemId)
setExpandedUsage(prev => {
const next = new Set(prev)
if (next.has(itemId)) next.delete(itemId); else next.add(itemId)
return next
})
setNewPhase(prev => ({ ...prev, [typeId]: { label: '', withWhom: 'lead', isRepeatable: false, hasConversation: false, hasAssessment: false } }))
await load()
if (willOpen && !usageDetails[itemId]) {
setLoadingUsage(prev => new Set(prev).add(itemId))
const details = await getCriterionUsageDetails(itemId)
setUsageDetails(prev => ({ ...prev, [itemId]: details }))
setLoadingUsage(prev => { const next = new Set(prev); next.delete(itemId); return next })
}
}
const updatePhaseTemplate = async (id: number, fields: Partial<PhaseTemplate>) => {
await db.phaseTemplates.update(id, fields)
setPhaseTemplates(prev => prev.map(t => t.id === id ? { ...t, ...fields } : t))
const refreshUsageAfterInstanceChange = async (itemId: number) => {
const [details, usage] = await Promise.all([getCriterionUsageDetails(itemId), getCriterionUsageMap()])
setUsageDetails(prev => ({ ...prev, [itemId]: details }))
setUsageMap(usage)
}
const deletePhaseTemplate = async (id: number) => {
await db.phaseTemplates.delete(id)
setPhaseTemplates(prev => prev.filter(t => t.id !== id))
const reassignOneInstance = async (itemId: number, instance: CriterionUsageInstance) => {
const key = `${itemId}:${instanceKey(instance)}`
const toId = instanceTarget[key]
if (!toId) return
const { merged } = await reassignUsageInstance(instance, toId)
setInstanceTarget(prev => { const next = { ...prev }; delete next[key]; return next })
setInstanceNotice(prev => ({ ...prev, [itemId]: merged ? 'Mit vorhandener Bewertung des Ziel-Kriteriums zusammengeführt.' : 'Übertragen.' }))
await refreshUsageAfterInstanceChange(itemId)
}
const removeOneInstance = async (itemId: number, instance: CriterionUsageInstance) => {
await removeUsageInstance(instance)
setInstanceNotice(prev => ({ ...prev, [itemId]: 'Zuordnung entfernt.' }))
await refreshUsageAfterInstanceChange(itemId)
}
const runPreloadFinal = async () => {
setImportingStructure(true)
setImportNotice(null)
setConfirmPreload(false)
try {
const result = await preloadFinalFeedbackStructure()
setImportNotice(
`Finale Struktur geladen: ${result.dimensions} Dimensionen, ${result.categories} Kategorien, ${result.items} Kriterien`
+ (result.clearedDimensions ? ` (vorher ${result.clearedDimensions} Dimensionen ersetzt)` : ''),
)
await load()
} catch (e) {
setImportNotice(`Import fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setImportingStructure(false)
}
}
const importMarkdownFile = async (file: File) => {
setImportingStructure(true)
setImportNotice(null)
try {
const md = await file.text()
const seed = parseFeedbackStructureMarkdown(md)
const sum = summarizeFeedbackStructureSeed(seed)
const result = await importFeedbackStructureFromMarkdown(md)
setImportNotice(
`MD importiert: ${result.dimensions} Dimensionen, ${result.categories} Kategorien, ${result.items} Kriterien`
+ ` (Datei: ${sum.items} Kriterien erkannt)`,
)
await load()
} catch (e) {
setImportNotice(`MD-Import fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setImportingStructure(false)
}
}
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
return (
<div className="space-y-4">
<h2 className="text-lg font-bold text-gray-800">Feedback-Dimensionen & Kategorien</h2>
<div className="flex items-center justify-between gap-2 flex-wrap">
<h2 className="text-lg font-bold text-gray-800">Feedback-Dimensionen & Kategorien</h2>
<div className="flex gap-1.5 flex-shrink-0 flex-wrap">
{!confirmPreload ? (
<button
onClick={() => setConfirmPreload(true)}
disabled={importingStructure}
className="text-xs px-2.5 py-1.5 border border-brand/30 rounded-lg text-brand hover:bg-brand/5 transition disabled:opacity-50">
Preload final
</button>
) : (
<div className="flex gap-1">
<button
onClick={runPreloadFinal}
disabled={importingStructure}
className="text-xs px-2.5 py-1.5 bg-brand text-white rounded-lg disabled:opacity-50">
{importingStructure ? '…' : 'Ersetzen bestätigen'}
</button>
<button onClick={() => setConfirmPreload(false)}
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500">
Abbrechen
</button>
</div>
)}
<label className={`text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition cursor-pointer ${importingStructure ? 'opacity-50 pointer-events-none' : ''}`}>
MD
<input
type="file"
accept=".md,text/markdown,text/plain"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (file) importMarkdownFile(file)
e.target.value = ''
}}
/>
</label>
<button onClick={() => exportFeedbackStructureAsMarkdown()}
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
MD
</button>
<button
onClick={async () => { setExportingPdf(true); try { await exportFeedbackStructurePdf() } finally { setExportingPdf(false) } }}
disabled={exportingPdf}
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition disabled:opacity-50">
{exportingPdf ? '⏳' : '⬇ PDF'}
</button>
</div>
</div>
{importNotice && (
<div className="rounded-xl px-4 py-3 text-sm bg-blue-50 text-blue-800 border border-blue-200">
{importNotice}
</div>
)}
{confirmPreload && (
<div className="rounded-xl px-4 py-3 text-sm bg-amber-50 text-amber-800 border border-amber-200">
Ersetzt die gesamte Feedback-Struktur durch die finale Preload-Datei
(<code className="mx-1 text-xs bg-amber-100 px-1 rounded">docs/preload/Feedback-Struktur-final.md</code>
(3 Dimensionen, 16 Kategorien, 109 Kriterien). Bestehende Dimensionen/Kategorien/Kriterien werden gelöscht.
</div>
)}
{/* Fokus-Übersicht: was wird über alle Bewerter hinweg am häufigsten kommentiert (siehe CLAUDE.md „Beobachter-Fokus") */}
{items.length > 0 && (
<details className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
<summary className="text-sm font-semibold text-gray-700 cursor-pointer select-none">📊 Fokus-Übersicht am häufigsten kommentierte Kriterien</summary>
<div className="mt-2 space-y-1.5">
{[...items]
.sort((a, b) => totalUsage(usageMap[b.id!]) - totalUsage(usageMap[a.id!]))
.slice(0, 5)
.filter(i => totalUsage(usageMap[i.id!]) > 0)
.map(i => (
<div key={i.id} className="flex items-start justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="font-medium text-gray-700">{i.name}</span>
{i.learnedPatternHints && (
<div className="text-gray-400 truncate">{i.learnedPatternHints}</div>
)}
</div>
<span className="flex-shrink-0 bg-blue-50 text-blue-700 border border-blue-200 px-1.5 py-0.5 rounded-full font-semibold">
{totalUsage(usageMap[i.id!])}
</span>
</div>
))}
{items.every(i => totalUsage(usageMap[i.id!]) === 0) && (
<div className="text-xs text-gray-400">Noch keine Verwendungen erfasst.</div>
)}
</div>
</details>
)}
{/* Neue Dimension */}
<div className="flex gap-2">
@ -235,10 +431,22 @@ export default function FeedbackStructureConfig() {
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
return next
})} className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
<button onClick={() => deleteDimension(dim.id!)}
<button onClick={() => requestDeleteDimension(dim.id!)}
className="text-xs text-red-300 hover:text-red-500 px-1">🗑</button>
</div>
{confirmDeleteDimId === dim.id && (
<div className="px-3 py-2 bg-red-50 border-b border-red-100 flex items-center justify-between gap-2">
<span className="text-xs text-red-700">
Diese Dimension enthält Kriterien mit insgesamt {dimensionUsageTotal(dim.id!)} Verwendung(en) werden beim Löschen mit entfernt.
</span>
<div className="flex gap-1.5 flex-shrink-0">
<button onClick={() => deleteDimension(dim.id!)} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">Trotzdem löschen</button>
<button onClick={() => setConfirmDeleteDimId(null)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Abbrechen</button>
</div>
</div>
)}
{isOpen && (
<div className="p-3 space-y-3">
{dimCats.map(cat => {
@ -257,31 +465,170 @@ export default function FeedbackStructureConfig() {
if (next.has(cat.id!)) next.delete(cat.id!); else next.add(cat.id!)
return next
})} className="text-xs text-gray-400">{isCatOpen ? '▲' : '▼'}</button>
<button onClick={() => deleteCategory(cat.id!)}
<button onClick={() => requestDeleteCategory(cat.id!)}
className="text-xs text-red-300 hover:text-red-500">🗑</button>
</div>
<input
className="w-full px-3 py-1.5 text-xs text-gray-400 border-b border-gray-100 focus:outline-none focus:border-brand bg-white"
placeholder="Erläuterung, welche Themen/Beobachtungen in diese Kategorie einzahlen (optional, fließt in KI-Prompts ein)…"
value={cat.description ?? ''}
onChange={e => updateCategoryDescription(cat.id!, e.target.value)}
/>
{confirmDeleteCatId === cat.id && (
<div className="px-3 py-2 bg-red-50 border-b border-red-100 flex items-center justify-between gap-2">
<span className="text-xs text-red-700">
Diese Kategorie enthält Kriterien mit insgesamt {categoryUsageTotal(cat.id!)} Verwendung(en) werden beim Löschen mit entfernt.
</span>
<div className="flex gap-1.5 flex-shrink-0">
<button onClick={() => deleteCategory(cat.id!)} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">Trotzdem löschen</button>
<button onClick={() => setConfirmDeleteCatId(null)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Abbrechen</button>
</div>
</div>
)}
{isCatOpen && (
<div className="p-2 space-y-1.5">
{catItems.map(item => (
<div key={item.id} className="flex items-center gap-2">
<span className="text-gray-300 text-xs">·</span>
{catItems.map(item => {
const usage = usageMap[item.id!]
const usageCount = totalUsage(usage)
const isUsageOpen = expandedUsage.has(item.id!)
return (
<div key={item.id} className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-gray-300 text-xs">·</span>
<input
className="flex-1 text-xs text-gray-600 border-b border-transparent focus:border-gray-300 focus:outline-none bg-transparent py-0.5"
value={item.name}
onChange={e => updateItem(item.id!, e.target.value)}
/>
<input
type="number" min={0} max={2} step={0.1}
value={item.weight ?? 1}
onChange={e => updateItemWeight(item.id!, Number(e.target.value))}
title="Gewichtung"
className="w-14 text-xs border border-gray-200 rounded px-1 py-0.5 text-gray-500 bg-white focus:outline-none focus:border-brand"
/>
<select
value=""
onChange={e => e.target.value && moveItemToCategory(item.id!, Number(e.target.value))}
title="In andere Kategorie verschieben"
className="text-xs border border-gray-200 rounded px-1 py-0.5 text-gray-500 bg-white focus:outline-none focus:border-brand max-w-[7rem]">
<option value="">Verschieben</option>
{sorted.map(d => {
const dimCats = categories.filter(c => c.dimensionId === d.id && c.id !== item.categoryId).sort((a, b) => a.order - b.order)
if (dimCats.length === 0) return null
return (
<optgroup key={d.id} label={d.name}>
{dimCats.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</optgroup>
)
})}
</select>
<button onClick={() => requestDeleteItem(item.id!)}
className="text-xs text-red-200 hover:text-red-400">×</button>
</div>
<input
className="flex-1 text-xs text-gray-600 border-b border-transparent focus:border-gray-300 focus:outline-none bg-transparent py-0.5"
value={item.name}
onChange={e => updateItem(item.id!, e.target.value)}
className="w-full ml-3.5 text-xs text-gray-400 border-b border-transparent focus:border-gray-300 focus:outline-none bg-transparent py-0.5"
placeholder="Beschreibung, worauf zu achten ist (optional, fließt in KI-Prompts ein)…"
value={item.description ?? ''}
onChange={e => updateItemDescription(item.id!, e.target.value)}
/>
<select
value={item.weight ?? 1}
onChange={e => updateItemWeight(item.id!, Number(e.target.value))}
title="Gewichtung"
className="text-xs border border-gray-200 rounded px-1 py-0.5 text-gray-500 bg-white focus:outline-none focus:border-brand">
{[1,2,3,4,5].map(w => <option key={w} value={w}>×{w}</option>)}
</select>
<button onClick={() => deleteItem(item.id!)}
className="text-xs text-red-200 hover:text-red-400">×</button>
{usageCount > 0 && (
<div className="ml-3.5">
<button onClick={() => toggleUsageExpanded(item.id!)}
className="text-xs bg-indigo-50 text-indigo-600 border border-indigo-100 px-1.5 py-0.5 rounded-full">
{usageCount} Verwendung(en) {isUsageOpen ? '▲' : '▼'}
</button>
{isUsageOpen && (
<div className="mt-1 space-y-1.5">
{instanceNotice[item.id!] && (
<div className="text-xs text-indigo-600 bg-indigo-50 border border-indigo-100 rounded-lg px-2 py-1">
{instanceNotice[item.id!]}
</div>
)}
{loadingUsage.has(item.id!) && <div className="text-xs text-gray-400">Lädt</div>}
{(usageDetails[item.id!] ?? []).map(instance => {
const key = `${item.id}:${instanceKey(instance)}`
return (
<div key={key} className="bg-gray-50 border border-gray-100 rounded-lg p-1.5 space-y-1">
<div className="text-xs text-gray-600">
<span className="font-medium">{instanceKindLabel[instance.kind]}</span>
{isDataInstance(instance) && (
<>
{' · '}{instance.assignmentTitle} ({instance.date ? new Date(instance.date).toLocaleDateString('de-DE') : '—'})
{' · '}{instance.personLabel}
</>
)}
<div className="text-gray-400 truncate">{instance.text}</div>
</div>
<div className="flex items-center gap-1 flex-wrap">
<select
value={instanceTarget[key] ?? ''}
onChange={e => setInstanceTarget(prev => ({ ...prev, [key]: Number(e.target.value) }))}
className="text-xs border border-gray-200 rounded px-1 py-0.5 bg-white focus:outline-none">
<option value=""> anderes Kriterium</option>
{criterionOptionGroups(dimensions, categories, items, item.id)}
</select>
<button
onClick={() => reassignOneInstance(item.id!, instance)}
disabled={!instanceTarget[key]}
className="text-xs bg-indigo-600 text-white px-1.5 py-0.5 rounded font-medium disabled:opacity-40">
Übertragen
</button>
<button
onClick={() => removeOneInstance(item.id!, instance)}
className="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded font-medium">
Entfernen
</button>
</div>
</div>
)
})}
</div>
)}
</div>
)}
{confirmDeleteItemId === item.id && (
<div className="ml-3.5 bg-red-50 border border-red-200 rounded-lg p-2 space-y-1.5">
<div className="text-xs text-red-700">
Wird an {usageCount} Stelle(n) verwendet. Übertragen" verschiebt alle Bewertungen/Zuordnungen auf ein anderes Kriterium (kein Datenverlust). „Trotzdem löschen" entfernt Bewertungen vollständig, Protokollzeilen bleiben als Text erhalten, nur die Zuordnung geht verloren.
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<select
value={reassignTarget[item.id!] ?? ''}
onChange={e => setReassignTarget(prev => ({ ...prev, [item.id!]: Number(e.target.value) }))}
className="text-xs border border-red-200 rounded px-1.5 py-1 bg-white focus:outline-none">
<option value="">Übertragen auf</option>
{criterionOptionGroups(dimensions, categories, items, item.id)}
</select>
<button
onClick={() => reassignAndDeleteItem(item.id!)}
disabled={!reassignTarget[item.id!]}
className="text-xs bg-indigo-600 text-white px-2 py-1 rounded-lg font-medium disabled:opacity-40">
Übertragen & löschen
</button>
<button onClick={() => deleteItem(item.id!)}
className="text-xs bg-red-600 text-white px-2 py-1 rounded-lg font-medium">
Trotzdem löschen
</button>
<button onClick={() => setConfirmDeleteItemId(null)}
className="text-xs border border-gray-200 text-gray-500 px-2 py-1 rounded-lg">
Abbrechen
</button>
</div>
</div>
)}
</div>
))}
)
})}
{mergeNotice && (
<div className="text-xs text-indigo-600 bg-indigo-50 border border-indigo-100 rounded-lg px-2 py-1.5">
{mergeNotice}
</div>
)}
<div className="flex gap-1.5 mt-1">
<input
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-brand"

View File

@ -0,0 +1,266 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
type Assignment, type PhaseTemplate, type MeetingInstance, type Consultant,
type FeedbackCategory, type FeedbackCriterionItem, type ConversationEntry, type Assessment, type RatingScore,
type ChecklistItemDef, type ChecklistResponse,
getAssignment, getAssignmentType,
listPhaseTemplatesForType,
listDoneMeetingsChronological,
getConsultantsByIds,
loadFeedbackStructure, resolveVisibleStructure,
listConversationEntriesForMeetings,
listAssessmentsForMeetings,
listMeetingCategoryRatingsForMeetings,
listChecklistItemDefsForPhaseTemplates,
listChecklistResponsesForMeetings,
} from '../db'
import { NotationText } from '../utils/notationParser'
import { INST_COLORS, resolveScoreStyle } from '../config/constants'
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
import { useRatingColorBands } from '../hooks/useRatingColorBands'
import { exportAssignmentReportPdf } from '../utils/pdfExport'
import type { MeetingCategoryRating } from '../db'
export default function MeetingHistory() {
const { id } = useParams()
const navigate = useNavigate()
const assignmentId = Number(id)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [templates, setTemplates] = useState<PhaseTemplate[]>([])
const [meetings, setMeetings] = useState<MeetingInstance[]>([])
const [institutees, setInstitutees] = useState<Consultant[]>([])
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const [entries, setEntries] = useState<ConversationEntry[]>([])
const [assessments, setAssessments] = useState<Assessment[]>([])
const [categoryRatings, setCategoryRatings] = useState<MeetingCategoryRating[]>([])
const [checklistItems, setChecklistItems] = useState<ChecklistItemDef[]>([])
const [checklistResponses, setChecklistResponses] = useState<ChecklistResponse[]>([])
const [loaded, setLoaded] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const latestRef = useRef<HTMLDivElement>(null)
const colorBands = useRatingColorBands()
useEffect(() => {
const load = async () => {
const a = await getAssignment(assignmentId)
if (!a) return
setAssignment(a)
const [aType, tmpls, doneMeetings, cons] = await Promise.all([
getAssignmentType(a.assignmentTypeId),
listPhaseTemplatesForType(a.assignmentTypeId),
listDoneMeetingsChronological(assignmentId),
getConsultantsByIds(a.instituteeIds),
])
setTemplates(tmpls)
setMeetings(doneMeetings)
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
const structure = await loadFeedbackStructure()
const visible = resolveVisibleStructure(structure.dimensions, structure.categories, structure.items, aType?.criteriaIds)
setFbCategories(visible.categories)
setFbItems(visible.items)
const meetingIds = doneMeetings.map(m => m.id!)
const templateIds = tmpls.map(t => t.id!)
const [convEntries, asmts, catRatings, checklistDefs, checklistResp] = await Promise.all([
listConversationEntriesForMeetings(meetingIds),
listAssessmentsForMeetings(meetingIds),
listMeetingCategoryRatingsForMeetings(meetingIds),
listChecklistItemDefsForPhaseTemplates(templateIds),
listChecklistResponsesForMeetings(meetingIds),
])
setEntries(convEntries)
setAssessments(asmts)
setCategoryRatings(catRatings)
setChecklistItems(checklistDefs)
setChecklistResponses(checklistResp)
setLoaded(true)
}
load()
}, [assignmentId])
useEffect(() => {
if (loaded) latestRef.current?.scrollIntoView({ block: 'start' })
}, [loaded])
const handleExportPdf = async () => {
setExportingPdf(true)
try {
await exportAssignmentReportPdf(assignmentId)
} finally {
setExportingPdf(false)
}
}
const displayName = (inst: Consultant) => {
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
return dup ? `${inst.firstName} ${inst.lastName.charAt(0)}.` : inst.firstName
}
if (!assignment) return <div className="text-center py-10 text-gray-400">Lädt</div>
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-2">
<div>
<button onClick={() => navigate(`/assignment/${assignmentId}`)} className="text-sm text-brand mb-2 block"> Assignment</button>
<h1 className="text-xl font-bold text-gray-800">📜 Gesamtprotokoll</h1>
<div className="text-sm text-gray-500">{assignment.title} · {assignment.client}</div>
</div>
<button onClick={handleExportPdf} disabled={exportingPdf}
className="flex-shrink-0 text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition disabled:opacity-50">
{exportingPdf ? 'Erstelle PDF…' : '⬇ Als PDF exportieren'}
</button>
</div>
{meetings.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">Noch keine abgeschlossenen Meetings.</p>
)}
{meetings.map((meeting, idx) => {
const template = templates.find(t => t.id === meeting.phaseTemplateId)
const meetingEntries = entries
.filter(e => e.meetingInstanceId === meeting.id)
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
const meetingChecklistItems = checklistItems.filter(ci => ci.phaseTemplateId === template?.id).sort((a, b) => a.order - b.order)
const isLatest = idx === meetings.length - 1
return (
<div key={meeting.id}
ref={isLatest ? latestRef : undefined}
className={`bg-white rounded-xl shadow-sm p-4 space-y-3 ${isLatest ? 'border-2 border-brand' : 'border border-gray-100'}`}>
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-gray-800">
{template?.label ?? 'Meeting'}{template?.isRepeatable ? ` #${meeting.phaseIndex}` : ''}
</div>
<div className="text-xs text-gray-400">{new Date(meeting.date).toLocaleDateString('de-DE')}</div>
</div>
{isLatest && (
<span className="text-xs bg-brand text-white px-2 py-1 rounded-full font-medium flex-shrink-0">
Aktuellstes Meeting
</span>
)}
</div>
{meeting.generalNotes?.trim() && (
<div className="bg-gray-50 rounded-lg px-3 py-2">
<NotationText
text={meeting.generalNotes}
renderLineAddon={line => {
const critId = meeting.generalNoteTags?.find(t => t.text === line)?.criterionItemId
const critName = critId ? fbItems.find(i => i.id === critId)?.name : null
return critName
? <span className="flex-shrink-0 text-xs bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-full px-2 py-0.5 mt-0.5">{critName}</span>
: null
}}
/>
</div>
)}
{meetingEntries.length > 0 && (
<div className="space-y-1.5">
{meetingEntries.map(entry => {
const instIdx = institutees.findIndex(c => c.id === entry.instituteeId)
const inst = institutees[instIdx]
const color = INST_COLORS[instIdx % INST_COLORS.length]
return (
<div key={entry.id}
className={`rounded-lg border px-3 py-2 ${color?.bg ?? 'bg-gray-50'} ${color?.border ?? 'border-gray-200'}`}>
<div className="flex items-start gap-2">
<span className={`flex-shrink-0 text-xs font-bold text-white px-2 py-0.5 rounded mt-0.5 ${color?.tag ?? 'bg-gray-400'}`}>
{inst ? displayName(inst) : '?'}
</span>
<div className="flex-1 min-w-0">
{entry.note && (
<NotationText
text={entry.note}
renderLineAddon={line => {
const critId = entry.lineTags?.find(t => t.text === line)?.criterionItemId
const critName = critId ? fbItems.find(i => i.id === critId)?.name : null
return critName
? <span className="flex-shrink-0 text-xs bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-full px-2 py-0.5 mt-0.5">{critName}</span>
: null
}}
/>
)}
{(entry.fillerCount ?? 0) > 0 && (
<span className="inline-block mt-1 text-xs bg-red-50 text-red-500 border border-red-200 px-1.5 py-0.5 rounded font-medium">
äh×{entry.fillerCount}
</span>
)}
</div>
</div>
</div>
)
})}
</div>
)}
{meetingChecklistItems.length > 0 && (
<div className="space-y-1 pt-1 border-t border-gray-50">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide">🗹 Checkliste</div>
{meetingChecklistItems.map(ci => {
const responsesForItem = checklistResponses.filter(r => r.checklistItemId === ci.id)
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
return targets.map(instituteeId => {
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
if (!response) return null
const effectiveCritId = response.criterionItemId ?? ci.linkedCriterionItemId
const critName = effectiveCritId ? fbItems.find(i => i.id === effectiveCritId)?.name : null
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
return (
<div key={`${ci.id}-${instituteeId ?? 'm'}`} className="text-xs text-gray-600 flex items-center gap-1.5 flex-wrap">
<span className="font-medium">{ci.label}</span>
{inst && <span className="text-gray-400"> {displayName(inst)}</span>}
<span className={`px-1.5 py-0.5 rounded font-medium tabular-nums ${resolveScoreStyle(response.rating, colorBands).active}`}>
{response.rating ?? '—'}
</span>
{critName && <span className="text-indigo-600"> {critName}</span>}
{response.note?.trim() && <span className="text-gray-500"> {response.note.trim()}</span>}
</div>
)
})
})}
</div>
)}
{institutees.length > 0 && fbCategories.length > 0 && (
<div className="space-y-1 pt-1 border-t border-gray-50">
{institutees.map(inst => {
const chips = fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
const mode = resolveCategoryChipScore({
meetingId: meeting.id!,
instituteeId: inst.id!,
categoryId: cat.id!,
categoryRatings,
assessments,
catItems,
})
return mode ? { cat, mode } : null
}).filter(Boolean) as { cat: FeedbackCategory; mode: RatingScore }[]
if (chips.length === 0) return null
return (
<div key={inst.id} className="flex items-center gap-1 flex-wrap">
<span className="text-xs text-gray-400">{displayName(inst)}:</span>
{chips.map(({ cat, mode }) => (
<span key={cat.id} className={`text-xs px-2 py-0.5 rounded font-medium tabular-nums ${resolveScoreStyle(mode, colorBands).active}`}>
{cat.name} {mode}
</span>
))}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}

View File

@ -1,745 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
db,
type Assignment,
type MeetingInstance,
type PhaseTemplate,
type FeedbackCategory,
type FeedbackCriterionItem,
type FeedbackRating,
type Consultant,
type ConversationEntry,
type ConversationSkillScore,
type Assessment,
} from '../db'
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
import { NotationText } from '../utils/notationParser'
import { INST_COLORS, RATING_OPTIONS } from '../config/constants'
const NOTATIONS = [
{ sym: '+', tip: 'Gut' },
{ sym: '(+)', tip: 'Gut mit Abstrichen' },
{ sym: '!', tip: 'Negativ' },
{ sym: '(!)', tip: 'Negativ mit Abstrichen' },
{ sym: '>', tip: 'Einwand / Kundeninput' },
{ sym: '[…]', tip: 'Slide-Referenz' },
]
type Tab = 'conversation' | 'assessment'
export default function MeetingView() {
const { id, meetingId: meetingIdParam } = useParams()
const navigate = useNavigate()
const meetingId = Number(meetingIdParam ?? id)
const [meeting, setMeeting] = useState<MeetingInstance | null>(null)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [template, setTemplate] = useState<PhaseTemplate | null>(null)
const [institutees, setInstitutees] = useState<Consultant[]>([])
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const [entries, setEntries] = useState<ConversationEntry[]>([])
const [scores, setScores] = useState<ConversationSkillScore[]>([])
const [assessments, setAssessments] = useState<Assessment[]>([])
const [tab, setTab] = useState<Tab>('conversation')
const [activeInstId, setActiveInstId] = useState<number | null>(null)
const [activeEntryId, setActiveEntryId] = useState<number | null>(null)
const [entryNote, setEntryNote] = useState('')
const [generalNotes, setGeneralNotes] = useState('')
const [editingGeneralNotes, setEditingGeneralNotes] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const [editNote, setEditNote] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showExportMenu, setShowExportMenu] = useState(false)
// accordion: per instituteeId → expanded categoryId
const [expandedCat, setExpandedCat] = useState<Record<number, number | null>>({})
const noteRef = useRef<HTMLTextAreaElement>(null)
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Vollständiger Vorname, bei Duplikaten + erster Buchstabe Nachname
const displayName = (inst: Consultant) => {
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
return dup ? `${inst.firstName} ${inst.lastName.charAt(0)}.` : inst.firstName
}
// Tag-Label für farbige Badges im Protokoll
const tagLabel = (inst: Consultant) => {
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
return dup ? `${inst.firstName} ${inst.lastName.charAt(0)}.` : inst.firstName
}
const loadData = useCallback(async () => {
const m = await db.meetingInstances.get(meetingId)
if (!m) return
setMeeting(m)
setGeneralNotes(m.generalNotes ?? '')
const [tmpl, a] = await Promise.all([
db.phaseTemplates.get(m.phaseTemplateId),
db.assignments.get(m.assignmentId),
])
setTemplate(tmpl ?? null)
setAssignment(a ?? null)
if (a) {
const cons = await db.consultants.where('id').anyOf(a.instituteeIds).toArray()
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
}
const [allCats, allItems, aType] = await Promise.all([
db.feedbackCategories.toArray(),
db.feedbackCriterionItems.toArray(),
a ? db.assignmentTypes.get(a.assignmentTypeId) : Promise.resolve(undefined),
])
const itemFilter = aType?.criteriaIds?.length ? new Set(aType.criteriaIds) : null
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
setFbItems(filteredItems)
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId))
setFbCategories(itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats)
const [convEntries, convScores, asmts] = await Promise.all([
db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'),
db.conversationSkillScores.toArray(),
db.assessments.where('meetingInstanceId').equals(meetingId).toArray(),
])
setEntries(convEntries)
setScores(convScores)
setAssessments(asmts)
}, [meetingId])
useEffect(() => { loadData() }, [loadData])
// ── Sprecher ──────────────────────────────────────────────────────────────
const activateInstitutee = async (instId: number) => {
if (activeEntryId !== null) {
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
}
const maxSeq = entries.reduce((m, e) => Math.max(m, e.sequenceIndex), 0)
const eid = await db.conversationEntries.add({
meetingInstanceId: meetingId,
instituteeId: instId,
sequenceIndex: maxSeq + 1,
note: '',
fillerCount: 0,
updatedAt: new Date().toISOString(),
})
setActiveInstId(instId)
setActiveEntryId(eid)
setEntryNote('')
await loadData()
setTimeout(() => noteRef.current?.focus(), 50)
}
const insertNotation = (sym: string) => {
const el = noteRef.current
if (!el) { setEntryNote(p => p + sym + ' '); return }
const start = el.selectionStart ?? entryNote.length
const end = el.selectionEnd ?? entryNote.length
const next = entryNote.slice(0, start) + sym + ' ' + entryNote.slice(end)
setEntryNote(next)
setTimeout(() => {
el.selectionStart = el.selectionEnd = start + sym.length + 1
el.focus()
}, 0)
}
const saveEntryNote = async () => {
if (activeEntryId === null) return
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, note: entryNote } : e))
}
const incrementFiller = async () => {
if (activeEntryId === null) return
const entry = entries.find(e => e.id === activeEntryId)
const newCount = (entry?.fillerCount ?? 0) + 1
await db.conversationEntries.update(activeEntryId, { fillerCount: newCount })
setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, fillerCount: newCount } : e))
}
const closeActiveEntry = async () => {
await saveEntryNote()
setActiveInstId(null)
setActiveEntryId(null)
setEntryNote('')
await loadData()
}
// ── Edit / Delete ─────────────────────────────────────────────────────────
const startEdit = (entry: ConversationEntry) => {
setEditingId(entry.id!)
setEditNote(entry.note)
}
const saveEdit = async (entryId: number) => {
await db.conversationEntries.update(entryId, { note: editNote, updatedAt: new Date().toISOString() })
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote } : e))
setEditingId(null)
}
const deleteEntry = async (entryId: number) => {
await db.conversationSkillScores.where('conversationEntryId').equals(entryId).delete()
await db.conversationEntries.delete(entryId)
setEntries(prev => prev.filter(e => e.id !== entryId))
}
const adjustFiller = async (entry: ConversationEntry, delta: number) => {
const newCount = Math.max(0, (entry.fillerCount ?? 0) + delta)
await db.conversationEntries.update(entry.id!, { fillerCount: newCount })
setEntries(prev => prev.map(e => e.id === entry.id ? { ...e, fillerCount: newCount } : e))
}
// ── Scores ────────────────────────────────────────────────────────────────
const setEntryScore = async (entryId: number, categoryId: number, score: FeedbackRating) => {
const existing = scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)
if (existing?.id) {
await db.conversationSkillScores.update(existing.id, { score })
} else {
await db.conversationSkillScores.add({ conversationEntryId: entryId, criteriaId: categoryId, score })
}
setScores(await db.conversationSkillScores.toArray())
}
const getEntryScore = (entryId: number, categoryId: number): FeedbackRating | null =>
scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score ?? null
// ── Gesamtbewertung ───────────────────────────────────────────────────────
// Assessment scored per FeedbackCriterionItem.id
const setAssessmentScore = async (instituteeId: number, itemId: number, score: FeedbackRating) => {
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
const now = new Date().toISOString()
if (existing?.id) {
await db.assessments.update(existing.id, { score, updatedAt: now })
} else {
await db.assessments.add({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now })
}
setAssessments(await db.assessments.where('meetingInstanceId').equals(meetingId).toArray())
}
const getAssessmentScore = (instituteeId: number, itemId: number): FeedbackRating | null =>
assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)?.score ?? null
const saveAssessmentNote = async (instituteeId: number, itemId: number, note: string) => {
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
const now = new Date().toISOString()
if (existing?.id) {
await db.assessments.update(existing.id, { note, updatedAt: now })
} else {
await db.assessments.add({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score: null, note, updatedAt: now })
}
setAssessments(await db.assessments.where('meetingInstanceId').equals(meetingId).toArray())
}
// Aus Protokoll: Kategorie-Durchschnitt → alle Items der Kategorie setzen
const autoCalcAssessment = async (instituteeId: number) => {
const numMap: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
const instEntries = entries.filter(e => e.instituteeId === instituteeId)
for (const cat of fbCategories) {
const vals = instEntries
.map(e => getEntryScore(e.id!, cat.id!))
.filter((s): s is FeedbackRating => s !== null && s !== 'na')
if (vals.length === 0) continue
const avg = vals.reduce((s, v) => s + numMap[v], 0) / vals.length
const rating: FeedbackRating = avg < 1.5 ? 'not_client_ready' : avg < 2.5 ? 'partially_client_ready' : avg < 3.5 ? 'nearly_client_ready' : 'client_ready'
for (const item of fbItems.filter(i => i.categoryId === cat.id)) {
await setAssessmentScore(instituteeId, item.id!, rating)
}
}
}
const buildFilename = (ext: string) => {
const date = new Date(meeting?.date ?? '').toLocaleDateString('de-DE').replace(/\./g, '-')
const label = template?.label ?? 'Meeting'
const idx = (meeting?.phaseIndex ?? 1) > 1 ? `-${meeting!.phaseIndex}` : ''
const id = assignment?.assignmentNumber ?? assignment?.title ?? 'export'
return `${id}_${date}_${label}${idx}.${ext}`
}
const handleExportMd = async () => {
try {
const md = await exportMeetingAsMarkdown(meetingId)
downloadFile(md, buildFilename('md'), 'text/markdown')
} catch (e) { console.error(e) }
setShowExportMenu(false)
}
const handleExportJson = async () => {
try {
const json = await exportMeetingAsJson(meetingId)
downloadFile(json, buildFilename('json'), 'application/json')
} catch (e) { console.error(e) }
setShowExportMenu(false)
}
const handleDeleteMeeting = async () => {
await saveEntryNote()
await db.meetingInstances.update(meetingId, { deletedAt: new Date().toISOString() })
navigate(`/assignment/${assignment?.id}`)
}
const saveGeneralNotes = (val: string) => {
setGeneralNotes(val)
if (notesTimer.current) clearTimeout(notesTimer.current)
notesTimer.current = setTimeout(() => {
db.meetingInstances.update(meetingId, { generalNotes: val })
}, 600)
}
const finishMeeting = async () => {
await saveEntryNote()
await db.meetingInstances.update(meetingId, { status: 'done' })
navigate(`/assignment/${assignment?.id}`)
}
const fillerTotal = (instId: number) =>
entries.filter(e => e.instituteeId === instId).reduce((s, e) => s + (e.fillerCount ?? 0), 0)
if (!meeting || !template) return <div className="text-center py-10 text-gray-400">Lädt</div>
const hasConv = template.hasConversation
const hasAssess = template.hasAssessment
const activeInstIdx = institutees.findIndex(c => c.id === activeInstId)
const activeColor = INST_COLORS[activeInstIdx % INST_COLORS.length]
const pastEntries = [...entries].filter(e => e.id !== activeEntryId).reverse()
const activeEntry = entries.find(e => e.id === activeEntryId)
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<button onClick={() => navigate(`/assignment/${assignment?.id}`)}
className="text-xs text-brand mb-0.5 block"> Assignment</button>
{assignment?.assignmentNumber && (
<div className="text-xs font-semibold text-brand uppercase tracking-wide leading-none mb-0.5">{assignment.assignmentNumber}</div>
)}
<div className="text-sm font-semibold text-gray-600 truncate">{assignment?.title}</div>
<div className="font-bold text-gray-800 truncate">{template.label}</div>
<div className="text-xs text-gray-400">
{assignment?.client} · {new Date(meeting.date).toLocaleDateString('de-DE')}
</div>
</div>
<div className="flex gap-1.5 flex-shrink-0 items-start">
{/* Export-Menü */}
<div className="relative">
<button onClick={() => setShowExportMenu(p => !p)}
className="text-sm bg-white border border-gray-200 text-gray-600 px-2.5 py-1.5 rounded-lg font-medium">
Export
</button>
{showExportMenu && (
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-xl shadow-lg z-10 min-w-[160px] overflow-hidden">
<button onClick={handleExportMd}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 border-b border-gray-100">
📄 Markdown
</button>
<button onClick={handleExportJson}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50">
🗂 JSON-Backup
</button>
</div>
)}
</div>
<button onClick={() => setShowDeleteConfirm(p => !p)}
title="Meeting löschen"
className="text-sm bg-white border border-red-200 text-red-400 hover:text-red-600 px-2.5 py-1.5 rounded-lg font-medium">
🗑
</button>
<button onClick={finishMeeting}
className="text-sm bg-green-600 text-white px-3 py-1.5 rounded-lg font-medium">
Fertig
</button>
</div>
</div>
{/* Löschen-Bestätigung */}
{showDeleteConfirm && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 space-y-3">
<div className="text-sm font-semibold text-red-700">Meeting in den Papierkorb verschieben?</div>
<div className="text-xs text-red-500">Das Meeting wird in den Papierkorb verschoben und kann dort wiederhergestellt oder endgültig gelöscht werden.</div>
<div className="flex gap-2">
<button onClick={handleDeleteMeeting}
className="flex-1 py-2 rounded-lg text-sm font-medium bg-red-600 text-white">
Ja, in Papierkorb
</button>
<button onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
Abbrechen
</button>
</div>
</div>
)}
{/* Allgemeine Notizen */}
{editingGeneralNotes ? (
<textarea
autoFocus
className="w-full border border-brand rounded-xl px-3 py-2 text-sm bg-white focus:outline-none resize-none"
style={{ minHeight: '4rem', fieldSizing: 'content' } as React.CSSProperties}
placeholder="Allgemeine Notizen zum Meeting… (+ gut, ! negativ, > Kundeninput)"
value={generalNotes}
onChange={e => saveGeneralNotes(e.target.value)}
onBlur={() => setEditingGeneralNotes(false)}
/>
) : (
<div
onClick={() => setEditingGeneralNotes(true)}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm bg-white cursor-text min-h-[2.5rem]">
{generalNotes.trim()
? <NotationText text={generalNotes} />
: <span className="text-gray-300">Allgemeine Notizen zum Meeting</span>
}
</div>
)}
{/* Tabs */}
{(hasConv || hasAssess) && (
<div className="flex bg-gray-100 rounded-xl p-1">
{hasConv && (
<button onClick={() => setTab('conversation')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
tab === 'conversation' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
}`}>
Gesprächsprotokoll
</button>
)}
{hasAssess && (
<button onClick={() => setTab('assessment')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
tab === 'assessment' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
}`}>
Gesamtbewertung
</button>
)}
</div>
)}
{/* ── GESPRÄCHSPROTOKOLL ───────────────────────────────────────────── */}
{tab === 'conversation' && hasConv && (
<div className="space-y-3">
{/* Aktiver Sprecher-Bereich */}
<div className={`bg-white rounded-xl shadow-sm border-2 p-4 space-y-3 ${
activeInstId !== null ? (activeColor?.border ?? 'border-brand') : 'border-gray-200'
}`}>
<div>
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">Wer spricht?</div>
<div className="flex gap-2 flex-wrap">
{institutees.map((c, idx) => {
const color = INST_COLORS[idx % INST_COLORS.length]
const isActive = activeInstId === c.id
const filler = fillerTotal(c.id!)
return (
<button key={c.id}
onClick={() => activateInstitutee(c.id!)}
className={`px-3 py-2 rounded-lg text-sm font-semibold border-2 transition ${
isActive
? `${color.tag} text-white border-transparent`
: `bg-white ${color.text} border-current`
}`}>
{displayName(c)}
{filler > 0 && (
<span className={`ml-1.5 text-xs font-normal ${isActive ? 'text-white/80' : 'text-gray-400'}`}>
äh×{filler}
</span>
)}
</button>
)
})}
{activeInstId !== null && (
<button onClick={closeActiveEntry}
className="px-3 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
Schließen
</button>
)}
</div>
</div>
{activeInstId !== null && (
<>
{/* Notation + Äh-Counter */}
<div className="flex items-start gap-2">
<div className="flex gap-1.5 flex-wrap flex-1">
{NOTATIONS.map(({ sym, tip }) => (
<button key={sym}
onClick={() => insertNotation(sym)}
title={tip}
className="px-2.5 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-mono rounded-lg transition">
{sym}
</button>
))}
</div>
<button
onClick={incrementFiller}
className={`flex-shrink-0 flex flex-col items-center justify-center w-14 h-14 rounded-xl border-2 border-dashed font-bold transition active:scale-95
${activeColor?.text ?? 'text-blue-700'} ${activeColor?.border ?? 'border-blue-300'}
hover:bg-gray-50`}>
<span className="text-lg leading-none">äh</span>
<span className="text-base font-bold leading-none mt-0.5">
{activeEntry?.fillerCount ?? 0}
</span>
</button>
</div>
{/* Notiz */}
<textarea
ref={noteRef}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
rows={4}
placeholder="Notizen… (+ gut, (!) negativ mit Abstrichen, > Kundeninput, [Slide 3])"
value={entryNote}
onChange={e => setEntryNote(e.target.value)}
onBlur={saveEntryNote}
/>
{/* Bewertungen per Kategorie (optional) */}
{fbCategories.length > 0 && activeEntryId !== null && (
<details>
<summary className="text-xs text-gray-400 cursor-pointer select-none hover:text-gray-600">
Kategorie-Bewertung für diesen Beitrag (optional)
</summary>
<div className="mt-2 space-y-3">
{fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
const current = getEntryScore(activeEntryId, cat.id!)
return (
<div key={cat.id}>
<div className="text-xs font-medium text-gray-600 mb-1">{cat.name}</div>
{catItems.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1.5">
{catItems.map(i => (
<span key={i.id} className="text-xs text-gray-400 bg-gray-50 border border-gray-100 px-1.5 py-0.5 rounded-full">{i.name}</span>
))}
</div>
)}
<div className="flex gap-1">
{RATING_OPTIONS.map(r => (
<button key={r.value}
onClick={() => setEntryScore(activeEntryId, cat.id!, r.value)}
className={`flex-1 py-1.5 rounded text-xs font-bold border transition ${
current === r.value ? r.active : r.color
}`}>
{r.short}
</button>
))}
</div>
</div>
)
})}
</div>
</details>
)}
</>
)}
</div>
{/* Protokoll-Zeitleiste */}
{pastEntries.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1">Protokoll</div>
{pastEntries.map(entry => {
const instIdx = institutees.findIndex(c => c.id === entry.instituteeId)
const inst = institutees[instIdx]
const color = INST_COLORS[instIdx % INST_COLORS.length]
const scoreChips = fbCategories
.map(c => ({ name: c.name, s: getEntryScore(entry.id!, c.id!) }))
.filter(x => x.s !== null)
const isEditing = editingId === entry.id
return (
<div key={entry.id}
className={`rounded-xl border px-3 py-2.5 ${color?.bg ?? 'bg-gray-50'} ${color?.border ?? 'border-gray-200'}`}>
<div className="flex items-start gap-2">
<span className={`flex-shrink-0 text-xs font-bold text-white px-2 py-0.5 rounded mt-0.5 ${color?.tag ?? 'bg-gray-400'}`}>
{inst ? tagLabel(inst) : '?'}
</span>
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="space-y-2">
<textarea
className="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:border-brand bg-white"
rows={3}
value={editNote}
onChange={e => setEditNote(e.target.value)}
autoFocus
/>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">Füllwörter:</span>
<button onClick={() => adjustFiller(entry, -1)}
className="w-7 h-7 rounded-full border border-gray-300 text-gray-600 flex items-center justify-center"></button>
<span className="text-sm font-semibold w-6 text-center">{entry.fillerCount ?? 0}</span>
<button onClick={() => adjustFiller(entry, +1)}
className="w-7 h-7 rounded-full border border-gray-300 text-gray-600 flex items-center justify-center">+</button>
</div>
<div className="flex gap-2">
<button onClick={() => saveEdit(entry.id!)}
className="text-xs bg-brand text-white px-3 py-1.5 rounded-lg">Speichern</button>
<button onClick={() => setEditingId(null)}
className="text-xs border border-gray-200 text-gray-500 px-3 py-1.5 rounded-lg">Abbrechen</button>
</div>
</div>
) : (
<>
{entry.note && (
<NotationText text={entry.note} />
)}
<div className="flex gap-1 flex-wrap mt-1">
{(entry.fillerCount ?? 0) > 0 && (
<span className="text-xs bg-red-50 text-red-500 border border-red-200 px-1.5 py-0.5 rounded font-medium">
äh×{entry.fillerCount}
</span>
)}
{scoreChips.map(({ name, s }) => {
const ro = RATING_OPTIONS.find(r => r.value === s)
return (
<span key={name} className={`text-xs px-1.5 py-0.5 rounded border font-medium ${ro?.active ?? 'bg-gray-100 text-gray-500 border-gray-200'}`}>
{name.slice(0, 8)}: {ro?.short ?? s}
</span>
)}
)}
</div>
</>
)}
</div>
{!isEditing && (
<>
<div className="flex flex-col gap-1 flex-shrink-0">
<button onClick={() => startEdit(entry)}
className="text-xs text-gray-400 hover:text-gray-600 px-1.5 py-1 rounded leading-none"></button>
<button onClick={() => deleteEntry(entry.id!)}
className="text-xs text-red-300 hover:text-red-500 px-1.5 py-1 rounded leading-none">🗑</button>
</div>
<span className="text-xs text-gray-300 flex-shrink-0 self-start">#{entry.sequenceIndex}</span>
</>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* ── GESAMTBEWERTUNG ────────────────────────────────────────────────── */}
{tab === 'assessment' && hasAssess && (
<div className="space-y-4">
{institutees.map((inst, idx) => {
const color = INST_COLORS[idx % INST_COLORS.length]
const filler = fillerTotal(inst.id!)
// Helper: weighted average rating for a category (N/A excluded, scale 1-4)
const catMode = (cat: FeedbackCategory): FeedbackRating | null => {
const numMap: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
const scored = fbItems
.filter(i => i.categoryId === cat.id)
.map(i => ({ score: getAssessmentScore(inst.id!, i.id!), w: i.weight ?? 1 }))
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
if (scored.length === 0) return null
const wSum = scored.reduce((s, x) => s + x.w, 0)
const avg = scored.reduce((s, x) => s + numMap[x.score] * x.w, 0) / wSum
if (avg < 1.5) return 'not_client_ready'
if (avg < 2.5) return 'partially_client_ready'
if (avg < 3.5) return 'nearly_client_ready'
return 'client_ready'
}
return (
<div key={inst.id} className={`rounded-xl border-2 ${color.border} p-4 space-y-3 bg-white`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-bold text-white px-2 py-1 rounded ${color.tag}`}>
{tagLabel(inst)}
</span>
<h3 className={`font-semibold ${color.text}`}>{inst.firstName} {inst.lastName}</h3>
{filler > 0 && (
<span className="text-xs bg-red-50 text-red-500 border border-red-200 px-2 py-0.5 rounded">
äh×{filler}
</span>
)}
</div>
{hasConv && (
<button onClick={() => autoCalcAssessment(inst.id!)}
className="text-xs bg-gray-100 text-gray-600 border border-gray-200 px-3 py-1 rounded-lg">
Aus Protokoll
</button>
)}
</div>
{fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
if (catItems.length === 0) return null
const ratedCount = catItems.filter(i => { const s = getAssessmentScore(inst.id!, i.id!); return s !== null && s !== 'na' }).length
const isOpen = expandedCat[inst.id!] === cat.id
const mode = catMode(cat)
const headerRo = mode ? RATING_OPTIONS.find(r => r.value === mode) : null
return (
<div key={cat.id} className="rounded-xl overflow-hidden border border-gray-100">
{/* Category header — colored when rated */}
<button
onClick={() => setExpandedCat(prev => ({
...prev,
[inst.id!]: prev[inst.id!] === cat.id ? null : cat.id!,
}))}
className={`w-full flex items-center justify-between px-3 py-2.5 text-left transition ${
headerRo ? headerRo.active : 'bg-white hover:bg-gray-50'
}`}>
<span className={`text-sm font-medium ${headerRo ? 'text-white' : 'text-gray-700'}`}>{cat.name}</span>
<div className="flex items-center gap-2">
{!headerRo && ratedCount > 0 && (
<span className="text-xs text-brand font-medium">{ratedCount}/{catItems.length}</span>
)}
<span className={`text-xs ${headerRo ? 'text-white/70' : 'text-gray-300'}`}>{isOpen ? '▲' : '▼'}</span>
</div>
</button>
{/* Criterion items */}
{isOpen && (
<div className="border-t border-gray-100 divide-y divide-gray-50">
{catItems.map(item => {
const current = getAssessmentScore(inst.id!, item.id!)
return (
<div key={item.id} className="px-3 py-2.5 space-y-1.5">
<div className="text-xs text-gray-600 font-medium">{item.name}</div>
<div className="flex gap-1">
{RATING_OPTIONS.map(r => (
<button key={r.value}
onClick={() => setAssessmentScore(inst.id!, item.id!, r.value)}
className={`flex-1 py-1.5 rounded-lg text-xs font-semibold border transition ${
current === r.value ? r.active : r.color
}`}>
{r.short}
</button>
))}
</div>
</div>
)
})}
<div className="px-3 py-2 bg-gray-50">
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-brand bg-white"
rows={1}
placeholder="Kommentar zur Kategorie"
defaultValue={assessments.find(a => a.instituteeId === inst.id && a.criteriaId === cat.id)?.note ?? ''}
onBlur={e => saveAssessmentNote(inst.id!, cat.id!, e.target.value)}
/>
</div>
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import {
BAND_STYLES,
DEFAULT_RATING_COLOR_BANDS,
RATING_MAX,
normalizeRatingColorBands,
resolveScoreStyle,
} from '../config/constants'
import { getOrSeedRatingColorBands, saveRatingColorBands } from '../db'
import type { RatingBandKey, RatingColorBand } from '../db/types'
const BAND_KEYS: RatingBandKey[] = ['red', 'orange', 'lightGreen', 'darkGreen']
const BAND_LABELS: Record<RatingBandKey, string> = {
red: 'Rot',
orange: 'Orange',
lightGreen: 'Hellgrün',
darkGreen: 'Dunkelgrün',
}
export default function RatingScaleConfig() {
const [bands, setBands] = useState<RatingColorBand[]>(DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b })))
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getOrSeedRatingColorBands().then(b => setBands(normalizeRatingColorBands(b)))
}, [])
const updateMax = (idx: number, max: number) => {
setBands(prev => prev.map((b, i) => i === idx ? { ...b, max } : b))
}
const updateKey = (idx: number, key: RatingBandKey) => {
setBands(prev => prev.map((b, i) => i === idx ? { ...b, key } : b))
}
const save = async () => {
const sorted = [...bands].sort((a, b) => a.max - b.max)
if (sorted[sorted.length - 1]?.max !== RATING_MAX) {
setError(`Das letzte Band muss bei ${RATING_MAX} enden.`)
return
}
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].max <= sorted[i - 1].max) {
setError('Obergrenzen müssen streng aufsteigend sein.')
return
}
}
setError(null)
try {
await saveRatingColorBands(sorted)
setBands(sorted)
setSaved(true)
setTimeout(() => setSaved(false), 2500)
} catch (e) {
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen.')
}
}
const reset = () => setBands(DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b })))
return (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
<div>
<h2 className="font-semibold text-gray-800">Bewertungsskala 110 · Farbbänder</h2>
<p className="text-sm text-gray-500 mt-1">
Steuert die farbliche Darstellung von Scores in Notation, Meeting und Feedback.
Jedes Band gilt von der vorherigen Obergrenze + 1 bis zur eigenen Obergrenze (inklusive).
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-gray-400">Vorschau (nicht ausgewählt / ausgewählt)</p>
<div className="flex flex-wrap gap-1">
{Array.from({ length: 10 }, (_, i) => i + 1).map(n => {
const style = resolveScoreStyle(n, bands)
return (
<span key={`idle-${n}`} className={`w-8 text-center text-xs font-bold py-1.5 rounded border tabular-nums ${style.color}`}>
{n}
</span>
)
})}
</div>
<div className="flex flex-wrap gap-1">
{Array.from({ length: 10 }, (_, i) => i + 1).map(n => {
const style = resolveScoreStyle(n, bands)
return (
<span key={`active-${n}`} className={`w-8 text-center text-xs font-bold py-1.5 rounded border tabular-nums ${style.active}`}>
{n}
</span>
)
})}
</div>
</div>
<div className="space-y-2">
{bands.map((band, idx) => {
const prevMax = idx === 0 ? 0 : bands[idx - 1].max
return (
<div key={idx} className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-500 w-16">{prevMax + 1}{band.max}</span>
<label className="text-xs text-gray-600">bis</label>
<input
type="number"
min={1}
max={10}
value={band.max}
onChange={e => updateMax(idx, Number(e.target.value))}
className="w-14 border border-gray-200 rounded-lg px-2 py-1 text-sm tabular-nums"
/>
<select
value={band.key}
onChange={e => updateKey(idx, e.target.value as RatingBandKey)}
className={`text-xs border rounded-lg px-2 py-1 ${BAND_STYLES[band.key].color}`}
>
{BAND_KEYS.map(k => (
<option key={k} value={k}>{BAND_LABELS[k]}</option>
))}
</select>
</div>
)
})}
</div>
{error && (
<div className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</div>
)}
<div className="flex gap-2">
<button onClick={save} className="flex-1 py-2.5 bg-brand text-white rounded-xl text-sm font-semibold">
Speichern
</button>
<button onClick={reset} className="px-4 py-2.5 border border-gray-200 text-gray-600 rounded-xl text-sm">
Standard
</button>
</div>
{saved && (
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded-lg px-3 py-2 text-center">
Gespeichert.
</div>
)}
</div>
)
}

View File

@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { getOrSeedRatingTrendWeightStep, saveRatingTrendWeightStep } from '../db'
import { DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants'
import { previewWeights } from '../utils/ratingTrend'
export default function RatingWeightConfig() {
const [weightStep, setWeightStep] = useState(DEFAULT_RATING_TREND_WEIGHT_STEP)
const [saved, setSaved] = useState(false)
useEffect(() => {
getOrSeedRatingTrendWeightStep().then(setWeightStep)
}, [])
const save = async () => {
await saveRatingTrendWeightStep(weightStep)
setSaved(true)
setTimeout(() => setSaved(false), 2500)
}
return (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
<div>
<h2 className="font-semibold text-gray-800">Zeitgewichtung des Bewertungs-Vorschlags</h2>
<p className="text-sm text-gray-500 mt-1">
Steuert, wie stark spätere Meetings in den Bewertungs-Vorschlag auf der Auswertungs- und
Feedback-Seite einfließen. 0 = alle Meetings gleich gewichtet, höher = stärkere Gewichtung
späterer Bewertungen.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Gewichtungsfaktor</label>
<span className="text-sm font-semibold text-brand">{weightStep.toFixed(1)}</span>
</div>
<input
type="range"
min={0}
max={2}
step={0.1}
value={weightStep}
onChange={e => setWeightStep(Number(e.target.value))}
className="w-full"
/>
</div>
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">
Beispiel bei 3 bewerteten Meetings
</div>
<div className="flex gap-2 text-sm text-gray-600">
{previewWeights(3, weightStep).map((w, i) => (
<span key={i} className="bg-white border border-gray-200 rounded px-2 py-1">
Meeting {i + 1}: ×{w.toFixed(1)}
</span>
))}
</div>
</div>
<button onClick={save}
className="w-full py-2.5 bg-brand text-white rounded-xl text-sm font-semibold">
Speichern
</button>
{saved && (
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded-lg px-3 py-2 text-center">
Gespeichert.
</div>
)}
</div>
)
}

View File

@ -0,0 +1,197 @@
import { useState } from 'react'
import type {
Consultant, FeedbackCategory, FeedbackCriterionItem,
Assessment, ConversationEntry, ConversationLineTag, RatingScore, RatingColorBand,
} from '../../db'
import {
INST_COLORS, DEFAULT_RATING_COLOR_BANDS, isValidRatingScore, resolveScoreStyle,
} from '../../config/constants'
import { ScorePicker } from '../../components/ScorePicker'
import { detectNotationRating } from '../../utils/notationParser'
import { numberToRating, weightedAverageRating } from '../../utils/ratingTrend'
interface Props {
institutees: Consultant[]
fbCategories: FeedbackCategory[]
fbItems: FeedbackCriterionItem[]
assessments: Assessment[]
entries: ConversationEntry[]
/** Kriterium-Zuordnungen aus den allgemeinen Notizen — gelten für ALLE Institutees, nicht gefiltert. */
generalNoteTags: ConversationLineTag[]
hasConv: boolean
tagLabel: (inst: Consultant) => string
fillerTotal: (instId: number) => number
getAssessmentScore: (instituteeId: number, itemId: number) => RatingScore | null
onSetAssessmentScore: (instituteeId: number, itemId: number, score: RatingScore | null) => void
onSaveAssessmentNote: (instituteeId: number, itemId: number, note: string) => void
onAutoCalc: (instituteeId: number) => void
colorBands?: RatingColorBand[]
}
function catMode(
cat: FeedbackCategory,
fbItems: FeedbackCriterionItem[],
getScore: (itemId: number) => RatingScore | null,
): RatingScore | null {
return weightedAverageRating(fbItems.filter(i => i.categoryId === cat.id), getScore)
}
function suggestedRating(
entries: ConversationEntry[],
generalNoteTags: ConversationLineTag[],
instituteeId: number,
itemId: number,
): RatingScore | null {
const entryTexts = entries
.filter(e => e.instituteeId === instituteeId)
.flatMap(e => e.lineTags ?? [])
.filter(t => t.criterionItemId === itemId)
.map(t => t.text)
const generalTexts = generalNoteTags
.filter(t => t.criterionItemId === itemId)
.map(t => t.text)
const ratings = [...entryTexts, ...generalTexts]
.map(detectNotationRating)
.filter(isValidRatingScore)
if (ratings.length === 0) return null
const avg = ratings.reduce((s, r) => s + r, 0) / ratings.length
return numberToRating(avg)
}
export function AssessmentTab({
institutees, fbCategories, fbItems, assessments, entries, generalNoteTags, hasConv,
tagLabel, fillerTotal,
getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc,
colorBands = DEFAULT_RATING_COLOR_BANDS,
}: Props) {
const [expandedCat, setExpandedCat] = useState<Record<number, number | null>>({})
return (
<div className="space-y-4">
{institutees.map((inst, idx) => {
const color = INST_COLORS[idx % INST_COLORS.length]
const filler = fillerTotal(inst.id!)
return (
<div key={inst.id} className={`rounded-xl border-2 ${color.border} p-4 space-y-3 bg-white`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-bold text-white px-2 py-1 rounded ${color.tag}`}>
{tagLabel(inst)}
</span>
<h3 className={`font-semibold ${color.text}`}>{inst.firstName} {inst.lastName}</h3>
{filler > 0 && (
<span className="text-xs bg-red-50 text-red-500 border border-red-200 px-2 py-0.5 rounded">
äh×{filler}
</span>
)}
</div>
{hasConv && (
<button onClick={() => onAutoCalc(inst.id!)}
className="text-xs bg-gray-100 text-gray-600 border border-gray-200 px-3 py-1 rounded-lg">
Aus Protokoll
</button>
)}
</div>
{fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
const ratedCount = catItems.filter(i => isValidRatingScore(getAssessmentScore(inst.id!, i.id!))).length
const isOpen = expandedCat[inst.id!] === cat.id
const mode = catMode(cat, fbItems, itemId => getAssessmentScore(inst.id!, itemId))
const headerRo = resolveScoreStyle(mode, colorBands)
const hasScore = isValidRatingScore(mode)
const suggestionCount = catItems.filter(item => {
const current = getAssessmentScore(inst.id!, item.id!)
const suggestion = suggestedRating(entries, generalNoteTags, inst.id!, item.id!)
return suggestion !== null && suggestion !== current
}).length
return (
<div key={cat.id} className="rounded-xl overflow-hidden border border-gray-100">
<button
onClick={() => setExpandedCat(prev => ({
...prev,
[inst.id!]: prev[inst.id!] === cat.id ? null : cat.id!,
}))}
className={`w-full flex items-center justify-between px-3 py-2.5 text-left transition ${
hasScore ? headerRo.active : 'bg-white hover:bg-gray-50'
}`}>
<span className={`text-sm font-medium ${hasScore ? 'text-white' : 'text-gray-700'}`}>
{cat.name}
{hasScore && <span className="ml-2 tabular-nums opacity-90">{headerRo.short}</span>}
</span>
<div className="flex items-center gap-2">
{suggestionCount > 0 && (
<span title="Vorschläge aus dem Protokoll verfügbar"
className={`text-xs px-1.5 py-0.5 rounded-full font-semibold ${
hasScore ? 'bg-white/20 text-white' : 'bg-blue-100 text-blue-700'
}`}>
💡 {suggestionCount}
</span>
)}
<span className={`text-xs ${hasScore ? 'text-white/70' : 'text-gray-400'}`}>
{ratedCount}/{catItems.length}
</span>
<span className={`text-xs ${hasScore ? 'text-white/70' : 'text-gray-300'}`}>{isOpen ? '▲' : '▼'}</span>
</div>
</button>
{isOpen && (
<div className="border-t border-gray-100 divide-y divide-gray-50">
{catItems.length === 0 ? (
<div className="px-3 py-2.5 text-xs text-gray-400">Keine Kriterien in dieser Kategorie.</div>
) : catItems.map(item => {
const current = getAssessmentScore(inst.id!, item.id!)
const suggestion = suggestedRating(entries, generalNoteTags, inst.id!, item.id!)
const note = assessments.find(a => a.instituteeId === inst.id && a.criteriaId === item.id)?.note ?? ''
return (
<div key={item.id} className="px-3 py-2.5 space-y-1.5">
<div className="flex items-baseline justify-between gap-2">
<div className="text-xs text-gray-700 font-medium">{item.name}</div>
{(item.weight ?? 1) !== 1 && (
<span className="text-xs text-gray-400 flex-shrink-0">×{item.weight}</span>
)}
</div>
{item.description && (
<div className="text-xs text-gray-400 leading-snug">{item.description}</div>
)}
<ScorePicker
value={current}
onChange={score => onSetAssessmentScore(inst.id!, item.id!, score)}
bands={colorBands}
allowClear
/>
{suggestion !== null && suggestion !== current && (
<div className="flex items-center gap-2">
<span className="text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-0.5 rounded-lg font-medium tabular-nums">
Vorschlag aus Protokoll: {suggestion}
</span>
<button onClick={() => onSetAssessmentScore(inst.id!, item.id!, suggestion)}
className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-lg font-medium hover:bg-blue-200">
Übernehmen
</button>
</div>
)}
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-brand bg-white"
rows={1}
placeholder="Notiz zum Kriterium"
defaultValue={note}
key={`note-${item.id}-${inst.id}-${note}`}
onBlur={e => onSaveAssessmentNote(inst.id!, item.id!, e.target.value)}
/>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
)
}

View File

@ -0,0 +1,157 @@
import { useState } from 'react'
import type {
Consultant, FeedbackCategory, FeedbackCriterionItem, RatingScore, RatingColorBand,
ChecklistItemDef, ChecklistResponse,
} from '../../db'
import { DEFAULT_RATING_COLOR_BANDS } from '../../config/constants'
import { ScorePicker } from '../../components/ScorePicker'
import { CriterionTagPicker, type CriterionSuggestion } from './CriterionTagPicker'
interface Props {
institutees: Consultant[]
checklist: ChecklistItemDef[]
fbItems: FeedbackCriterionItem[]
fbCategories: FeedbackCategory[]
tagLabel: (inst: Consultant) => string
getChecklistResponse: (checklistItemId: number, instituteeId: number | undefined) => ChecklistResponse | undefined
onSetChecklistRating: (item: ChecklistItemDef, instituteeId: number | undefined, rating: RatingScore | null) => void
onSaveChecklistNote: (item: ChecklistItemDef, instituteeId: number | undefined, note: string) => void
getEffectiveCriterion: (item: ChecklistItemDef, instituteeId: number | undefined) => number | null
onSetChecklistCriterion: (item: ChecklistItemDef, instituteeId: number | undefined, criterionItemId: number | null) => void
getChecklistSuggestions: (checklistItemId: number, instituteeId: number | undefined) => CriterionSuggestion[] | undefined
colorBands?: RatingColorBand[]
}
function ChecklistLine({
label, items, categories, criterionValue, onSetCriterion, suggestions,
rating, note, open, onToggleComment, onSelectRating, onSaveNote, colorBands,
}: {
label: string
items: FeedbackCriterionItem[]
categories: FeedbackCategory[]
criterionValue: number | null
onSetCriterion: (id: number | null) => void
suggestions: CriterionSuggestion[] | undefined
rating: RatingScore | null
note: string
open: boolean
onToggleComment: () => void
onSelectRating: (r: RatingScore | null) => void
onSaveNote: (note: string) => void
colorBands: RatingColorBand[]
}) {
return (
<div>
<div className="flex items-center gap-1.5 py-1">
<span className="flex-1 truncate text-xs text-gray-700">{label}</span>
<CriterionTagPicker
items={items}
categories={categories}
value={criterionValue}
onChange={onSetCriterion}
suggestions={suggestions}
/>
<ScorePicker
value={rating}
onChange={onSelectRating}
bands={colorBands}
variant="select"
/>
<button onClick={onToggleComment} title="Kommentar"
className={`text-xs flex-shrink-0 rounded-lg px-1.5 py-1 ${note.trim() ? 'bg-blue-100 text-blue-700' : 'text-gray-300 hover:text-gray-500'}`}>
💬
</button>
</div>
{open && (
<input
autoFocus
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:border-brand mb-1"
placeholder="Kommentar"
defaultValue={note}
onBlur={e => onSaveNote(e.target.value)}
/>
)}
</div>
)
}
/**
* Ganz normal im Gesprächsprotokoll angezeigt" (siehe CLAUDE.md) direkt oberhalb der
* Gesprächs-Zeitleiste gerendert, kein eigener Meeting-Tab. Zeilenbasiert statt kartenbasiert
* (Feedback nach Nutzungstests: nahm zu viel Platz ein). Die Kriterium-Zuordnung ist pro Antwort
* individuell überschreibbar (`CriterionTagPicker`, gleiche Komponente wie im Protokoll) die
* Konfigurations-Vorgabe (`ChecklistItemDef.linkedCriterionItemId`) ist nur der Startwert.
* `ChecklistResponse` ist immer die Quelle für die UI selbst; bei effektiv vorhandenem Kriterium
* spiegelt MeetingView.tsx die Eingabe zusätzlich einseitig nach `Assessment`.
*/
export function ChecklistTab({
institutees, checklist, fbItems, fbCategories, tagLabel,
getChecklistResponse, onSetChecklistRating, onSaveChecklistNote,
getEffectiveCriterion, onSetChecklistCriterion, getChecklistSuggestions,
colorBands = DEFAULT_RATING_COLOR_BANDS,
}: Props) {
const [openComments, setOpenComments] = useState<Set<string>>(new Set())
const toggleComment = (key: string) => setOpenComments(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key); else next.add(key)
return next
})
if (checklist.length === 0) return null
return (
<div>
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">🗹 Checkliste</div>
<div className="bg-white rounded-xl border border-gray-100 divide-y divide-gray-50 px-3">
{checklist.map(ci => {
if (ci.scope === 'meetingWide') {
const key = `${ci.id}`
return (
<ChecklistLine key={ci.id}
label={ci.label}
items={fbItems}
categories={fbCategories}
criterionValue={getEffectiveCriterion(ci, undefined)}
onSetCriterion={id => onSetChecklistCriterion(ci, undefined, id)}
suggestions={getChecklistSuggestions(ci.id!, undefined)}
rating={getChecklistResponse(ci.id!, undefined)?.rating ?? null}
note={getChecklistResponse(ci.id!, undefined)?.note ?? ''}
open={openComments.has(key)}
onToggleComment={() => toggleComment(key)}
onSelectRating={r => onSetChecklistRating(ci, undefined, r)}
onSaveNote={note => onSaveChecklistNote(ci, undefined, note)}
colorBands={colorBands}
/>
)
}
return (
<div key={ci.id} className="py-1">
<div className="text-xs font-medium text-gray-500 truncate">{ci.label}</div>
{institutees.map(inst => {
const key = `${ci.id}:${inst.id}`
return (
<ChecklistLine key={inst.id}
label={tagLabel(inst)}
items={fbItems}
categories={fbCategories}
criterionValue={getEffectiveCriterion(ci, inst.id!)}
onSetCriterion={id => onSetChecklistCriterion(ci, inst.id!, id)}
suggestions={getChecklistSuggestions(ci.id!, inst.id!)}
rating={getChecklistResponse(ci.id!, inst.id!)?.rating ?? null}
note={getChecklistResponse(ci.id!, inst.id!)?.note ?? ''}
open={openComments.has(key)}
onToggleComment={() => toggleComment(key)}
onSelectRating={r => onSetChecklistRating(ci, inst.id!, r)}
onSaveNote={note => onSaveChecklistNote(ci, inst.id!, note)}
colorBands={colorBands}
/>
)
})}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,335 @@
import type React from 'react'
import { useEffect, useRef, useState } from 'react'
import type {
Consultant, FeedbackCategory, FeedbackCriterionItem,
ConversationEntry, RatingScore, ConversationLineTag, RatingColorBand,
} from '../../db'
import { NotationText } from '../../utils/notationParser'
import { INST_COLORS, resolveScoreStyle, DEFAULT_RATING_COLOR_BANDS } from '../../config/constants'
import { ScorePicker } from '../../components/ScorePicker'
import { findSimilarTaggedLines, type SimilarTaggedLine } from '../../utils/criterionSuggestion'
import { CriterionTagPicker, type CriterionSuggestion } from './CriterionTagPicker'
const NOTATIONS = [
{ sym: '1 ', tip: 'Score 1' },
{ sym: '3 ', tip: 'Score 3' },
{ sym: '5 ', tip: 'Score 5' },
{ sym: '7 ', tip: 'Score 7' },
{ sym: '9 ', tip: 'Score 9' },
{ sym: '10 ', tip: 'Score 10' },
{ sym: '>', tip: 'Einwand / Kundeninput' },
{ sym: '[…]', tip: 'Slide-Referenz' },
]
interface Props {
institutees: Consultant[]
fbCategories: FeedbackCategory[]
fbItems: FeedbackCriterionItem[]
entries: ConversationEntry[]
/** Vorschläge aus „🏷 Vorschläge analysieren", Schlüssel = exakter Zeilentext. */
lineSuggestions: Map<string, CriterionSuggestion[]>
/** Einmal pro Meeting-Load geholter globaler Lern-Korpus, für die rein lokale Live-Formulierungshilfe. */
corpus: ConversationLineTag[]
activeInstId: number | null
activeEntryId: number | null
entryNote: string
editingId: number | null
editNote: string
noteRef: React.RefObject<HTMLTextAreaElement | null>
displayName: (inst: Consultant) => string
tagLabel: (inst: Consultant) => string
fillerTotal: (instId: number) => number
getEntryScore: (entryId: number, categoryId: number) => RatingScore | null
onActivateInstitutee: (id: number) => void
onCloseActiveEntry: () => void
onInsertNotation: (sym: string) => void
onEntryNoteChange: (note: string) => void
onSaveEntryNote: () => void
onIncrementFiller: () => void
onSetEntryScore: (entryId: number, categoryId: number, score: RatingScore | null) => void
onSetLineCriterion: (entryId: number, lineText: string, criterionItemId: number | null) => void
onChangeSpeaker: (entryId: number, instituteeId: number) => void
onStartEdit: (entry: ConversationEntry) => void
onSaveEdit: (entryId: number) => void
onCancelEdit: () => void
onEditNoteChange: (note: string) => void
onDeleteEntry: (entryId: number) => void
onAdjustFiller: (entry: ConversationEntry, delta: number) => void
colorBands?: RatingColorBand[]
}
export function ConversationTab({
institutees, fbCategories, fbItems, entries, lineSuggestions, corpus,
activeInstId, activeEntryId, entryNote, editingId, editNote, noteRef,
displayName, tagLabel, fillerTotal, getEntryScore,
onActivateInstitutee, onCloseActiveEntry, onInsertNotation,
onEntryNoteChange, onSaveEntryNote, onIncrementFiller, onSetEntryScore, onSetLineCriterion, onChangeSpeaker,
onStartEdit, onSaveEdit, onCancelEdit, onEditNoteChange, onDeleteEntry, onAdjustFiller,
colorBands = DEFAULT_RATING_COLOR_BANDS,
}: Props) {
const activeInstIdx = institutees.findIndex(c => c.id === activeInstId)
const activeColor = INST_COLORS[activeInstIdx % INST_COLORS.length]
const activeEntry = entries.find(e => e.id === activeEntryId)
const pastEntries = [...entries].filter(e => e.id !== activeEntryId).reverse()
const [formulationHints, setFormulationHints] = useState<SimilarTaggedLine[]>([])
const hintsTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (hintsTimer.current) clearTimeout(hintsTimer.current)
hintsTimer.current = setTimeout(() => {
const lastLine = entryNote.split('\n').pop()?.trim() ?? ''
setFormulationHints(lastLine ? findSimilarTaggedLines(lastLine, corpus) : [])
}, 800)
return () => { if (hintsTimer.current) clearTimeout(hintsTimer.current) }
}, [entryNote, corpus])
const criterionName = (id: number) => fbItems.find(i => i.id === id)?.name
const applyHint = (hintText: string) => {
const lines = entryNote.split('\n')
lines[lines.length - 1] = hintText
onEntryNoteChange(lines.join('\n'))
setFormulationHints([])
setTimeout(() => noteRef.current?.focus(), 0)
}
return (
<div className="space-y-3">
<div className={`bg-white rounded-xl shadow-sm border-2 p-4 space-y-3 ${
activeInstId !== null ? (activeColor?.border ?? 'border-brand') : 'border-gray-200'
}`}>
<div>
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">Wer spricht?</div>
<div className="flex gap-2 flex-wrap">
{institutees.map((c, idx) => {
const color = INST_COLORS[idx % INST_COLORS.length]
const isActive = activeInstId === c.id
const filler = fillerTotal(c.id!)
return (
<button key={c.id}
onClick={() => onActivateInstitutee(c.id!)}
className={`px-3 py-2 rounded-lg text-sm font-semibold border-2 transition ${
isActive
? `${color.tag} text-white border-transparent`
: `bg-white ${color.text} border-current`
}`}>
{displayName(c)}
{filler > 0 && (
<span className={`ml-1.5 text-xs font-normal ${isActive ? 'text-white/80' : 'text-gray-400'}`}>
äh×{filler}
</span>
)}
</button>
)
})}
{activeInstId !== null && (
<button onClick={onCloseActiveEntry}
className="px-3 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
Schließen
</button>
)}
</div>
</div>
{activeInstId !== null && (
<>
<div className="flex items-start gap-2">
<div className="flex gap-1.5 flex-wrap flex-1">
{NOTATIONS.map(({ sym, tip }) => (
<button key={sym}
onClick={() => onInsertNotation(sym)}
title={tip}
className="px-2.5 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-mono rounded-lg transition">
{sym}
</button>
))}
</div>
<button
onClick={onIncrementFiller}
className={`flex-shrink-0 flex flex-col items-center justify-center w-14 h-14 rounded-xl border-2 border-dashed font-bold transition active:scale-95
${activeColor?.text ?? 'text-blue-700'} ${activeColor?.border ?? 'border-blue-300'}
hover:bg-gray-50`}>
<span className="text-lg leading-none">äh</span>
<span className="text-base font-bold leading-none mt-0.5">
{activeEntry?.fillerCount ?? 0}
</span>
</button>
</div>
<textarea
ref={noteRef}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
rows={4}
placeholder="Notizen… (110 Score am Zeilenanfang, > Kundeninput, [Slide 3])"
value={entryNote}
onChange={e => onEntryNoteChange(e.target.value)}
onBlur={onSaveEntryNote}
/>
{formulationHints.length > 0 && (
<details className="bg-blue-50 border border-blue-100 rounded-lg px-3 py-1.5" open>
<summary className="text-xs font-medium text-blue-700 cursor-pointer select-none">💭 Ähnliche frühere Notizen klicken zum Übernehmen</summary>
<ul className="mt-1 space-y-0.5">
{formulationHints.map((h, i) => (
<li key={i}>
<button type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyHint(h.text)}
className="text-xs text-blue-800 hover:text-blue-950 hover:underline truncate text-left w-full">
{h.text}"
{criterionName(h.criterionItemId) && (
<span className="text-blue-400"> 🏷 {criterionName(h.criterionItemId)}</span>
)}
</button>
</li>
))}
</ul>
</details>
)}
{fbCategories.length > 0 && activeEntryId !== null && (
<details>
<summary className="text-xs text-gray-400 cursor-pointer select-none hover:text-gray-600">
Kategorie-Bewertung für diesen Beitrag (optional)
</summary>
<div className="mt-2 space-y-3">
{fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
const current = getEntryScore(activeEntryId, cat.id!)
return (
<div key={cat.id}>
<div className="text-xs font-medium text-gray-600 mb-1">{cat.name}</div>
{catItems.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1.5">
{catItems.map(i => (
<span key={i.id} className="text-xs text-gray-400 bg-gray-50 border border-gray-100 px-1.5 py-0.5 rounded-full">{i.name}</span>
))}
</div>
)}
<ScorePicker
value={current}
onChange={score => onSetEntryScore(activeEntryId, cat.id!, score)}
bands={colorBands}
/>
</div>
)
})}
</div>
</details>
)}
</>
)}
</div>
{pastEntries.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1">Protokoll</div>
{pastEntries.map(entry => {
const instIdx = institutees.findIndex(c => c.id === entry.instituteeId)
const inst = institutees[instIdx]
const color = INST_COLORS[instIdx % INST_COLORS.length]
const scoreChips = fbCategories
.map(c => ({ name: c.name, s: getEntryScore(entry.id!, c.id!) }))
.filter(x => x.s !== null)
const isEditing = editingId === entry.id
return (
<div key={entry.id}
className={`rounded-xl border px-3 py-2.5 ${color?.bg ?? 'bg-gray-50'} ${color?.border ?? 'border-gray-200'}`}>
<div className="flex items-start gap-2">
<span className={`flex-shrink-0 text-xs font-bold text-white px-2 py-0.5 rounded mt-0.5 ${color?.tag ?? 'bg-gray-400'}`}>
{inst ? tagLabel(inst) : '?'}
</span>
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="space-y-2">
<textarea
className="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:border-brand bg-white"
rows={3}
value={editNote}
onChange={e => onEditNoteChange(e.target.value)}
autoFocus
/>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Sprecher:</span>
<select
value={entry.instituteeId}
onChange={e => onChangeSpeaker(entry.id!, Number(e.target.value))}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:border-brand">
{institutees.map(c => (
<option key={c.id} value={c.id}>{displayName(c)}</option>
))}
</select>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">Füllwörter:</span>
<button onClick={() => onAdjustFiller(entry, -1)}
className="w-7 h-7 rounded-full border border-gray-300 text-gray-600 flex items-center justify-center"></button>
<span className="text-sm font-semibold w-6 text-center">{entry.fillerCount ?? 0}</span>
<button onClick={() => onAdjustFiller(entry, +1)}
className="w-7 h-7 rounded-full border border-gray-300 text-gray-600 flex items-center justify-center">+</button>
</div>
<div className="flex gap-2">
<button onClick={() => onSaveEdit(entry.id!)}
className="text-xs bg-brand text-white px-3 py-1.5 rounded-lg">Speichern</button>
<button onClick={onCancelEdit}
className="text-xs border border-gray-200 text-gray-500 px-3 py-1.5 rounded-lg">Abbrechen</button>
</div>
</div>
) : (
<>
{entry.note && (
<NotationText
text={entry.note}
colorBands={colorBands}
renderLineAddon={line => (
<CriterionTagPicker
items={fbItems}
categories={fbCategories}
value={(entry.lineTags ?? []).find(t => t.text === line)?.criterionItemId ?? null}
onChange={itemId => onSetLineCriterion(entry.id!, line, itemId)}
suggestions={lineSuggestions.get(line)}
/>
)}
/>
)}
<div className="flex items-center gap-1 flex-wrap mt-1">
{(entry.fillerCount ?? 0) > 0 && (
<span className="text-xs bg-red-50 text-red-500 border border-red-200 px-1.5 py-0.5 rounded font-medium">
äh×{entry.fillerCount}
</span>
)}
{scoreChips.map(({ name, s }) => {
const style = resolveScoreStyle(s, colorBands)
return (
<span key={name} className={`text-xs px-1.5 py-0.5 rounded border font-medium tabular-nums ${style.active}`}>
{name.slice(0, 8)}: {style.short}
</span>
)
})}
</div>
</>
)}
</div>
{!isEditing && (
<>
<div className="flex flex-col gap-1 flex-shrink-0">
<button onClick={() => onStartEdit(entry)}
className="text-xs text-gray-400 hover:text-gray-600 px-1.5 py-1 rounded leading-none"></button>
<button onClick={() => onDeleteEntry(entry.id!)}
className="text-xs text-red-300 hover:text-red-500 px-1.5 py-1 rounded leading-none">🗑</button>
</div>
<span className="text-xs text-gray-300 flex-shrink-0 self-start">#{entry.sequenceIndex}</span>
</>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,111 @@
import { useState } from 'react'
import type { FeedbackCategory, FeedbackCriterionItem } from '../../db'
/** Priorisierter Vorschlag aus „🏷 Vorschläge analysieren" — rank 1 = wahrscheinlichstes Kriterium. */
export interface CriterionSuggestion {
criterionItemId: number
rank: number
}
interface Props {
items: FeedbackCriterionItem[]
categories: FeedbackCategory[]
value: number | null | undefined
onChange: (itemId: number | null) => void
/** Nur relevant solange `value` leer ist — überschreibt den leeren „+"-Button durch einen Vorschlags-Chip. */
suggestions?: CriterionSuggestion[]
}
export function CriterionTagPicker({ items, categories, value, onChange, suggestions }: Props) {
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
const current = items.find(i => i.id === value)
const categoryName = (categoryId: number) => categories.find(c => c.id === categoryId)?.name ?? ''
const filtered = items.filter(i => {
const q = filter.trim().toLowerCase()
if (!q) return true
return i.name.toLowerCase().includes(q) || categoryName(i.categoryId).toLowerCase().includes(q)
})
const select = (itemId: number) => {
onChange(itemId)
setOpen(false)
setFilter('')
}
const sortedSuggestions = suggestions ? [...suggestions].sort((a, b) => a.rank - b.rank) : []
const topItem = sortedSuggestions[0] ? items.find(i => i.id === sortedSuggestions[0].criterionItemId) : undefined
return (
<div className="relative inline-block">
{current ? (
<div className="inline-flex items-center gap-0.5 bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-full pl-2 pr-1 py-0.5">
<button onClick={() => setOpen(p => !p)} className="text-xs font-medium max-w-[120px] truncate">
🏷 {current.name}
</button>
<button onClick={() => onChange(null)} className="text-indigo-400 hover:text-indigo-700 text-xs leading-none px-1"></button>
</div>
) : topItem ? (
<div className="inline-flex items-center gap-0.5 bg-blue-50 text-blue-700 border border-blue-200 rounded-full pl-2 pr-1 py-0.5">
<button onClick={() => select(topItem.id!)} title="Vorschlag übernehmen"
className="text-xs font-medium max-w-[120px] truncate">
💡 {topItem.name}
</button>
<button onClick={() => setOpen(p => !p)} title="Weitere Vorschläge / Suche"
className="text-blue-400 hover:text-blue-700 text-xs leading-none px-1"></button>
</div>
) : (
<button onClick={() => setOpen(p => !p)}
title="Kriterium zuordnen"
className="w-6 h-6 rounded-full border border-dashed border-gray-300 text-gray-400 text-xs flex items-center justify-center hover:bg-gray-50 hover:text-gray-600">
+
</button>
)}
{open && (
<div className="absolute z-20 mt-1 w-64 bg-white border border-gray-200 rounded-xl shadow-lg p-2">
{sortedSuggestions.length > 0 && (
<div className="mb-2 space-y-0.5">
<div className="text-[10px] font-semibold text-blue-400 uppercase tracking-wide px-2">Vorschläge</div>
{sortedSuggestions.map(s => {
const item = items.find(i => i.id === s.criterionItemId)
if (!item) return null
return (
<button key={s.criterionItemId} onClick={() => select(item.id!)}
className="w-full text-left px-2 py-1.5 rounded-lg hover:bg-blue-50">
<div className="text-xs font-medium text-blue-700">💡 {item.name}</div>
<div className="text-[10px] text-gray-400">{categoryName(item.categoryId)}</div>
</button>
)
})}
<div className="border-t border-gray-100 my-1" />
</div>
)}
<input
type="text"
autoFocus
placeholder="Kriterium suchen…"
value={filter}
onChange={e => setFilter(e.target.value)}
className="w-full text-xs border border-gray-200 rounded-lg px-2 py-1.5 mb-2 focus:outline-none focus:border-brand"
/>
<div className="max-h-48 overflow-y-auto space-y-0.5">
{filtered.length === 0 && (
<div className="text-xs text-gray-400 px-2 py-1.5">Kein Treffer.</div>
)}
{filtered.map(item => (
<button key={item.id}
onClick={() => select(item.id!)}
className="w-full text-left px-2 py-1.5 rounded-lg hover:bg-gray-50">
<div className="text-xs font-medium text-gray-700">{item.name}</div>
<div className="text-[10px] text-gray-400">{categoryName(item.categoryId)}</div>
</button>
))}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,898 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
type Assignment, type MeetingInstance, type PhaseTemplate,
type FeedbackCategory, type FeedbackCriterionItem, type RatingScore,
type Consultant, type ConversationEntry, type ConversationSkillScore, type Assessment, type ConversationLineTag,
type ChecklistItemDef, type ChecklistResponse,
getMeetingInstance, updateMeetingInstance, softDeleteMeetingInstance,
getAssignmentType,
getAssignment,
getPhaseTemplate,
getConsultantsByIds,
loadFeedbackStructure, resolveVisibleStructure, updateFeedbackCriterionItem,
getGlobalLineTagCorpus,
getOrSeedCriterionTaggingPromptTemplate,
listConversationEntriesForMeeting, addConversationEntry, updateConversationEntry, deleteConversationEntry,
listConversationSkillScoresAll, addConversationSkillScore, updateConversationSkillScore, deleteConversationSkillScoresForEntry,
listAssessmentsForMeeting, addAssessment, updateAssessment,
listChecklistItemDefsForPhaseTemplate, listChecklistResponsesForMeeting, upsertChecklistResponse,
upsertMeetingCategoryRating,
} from '../../db'
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport'
import { NotationText, splitNoteLines, parseNotationPrefix } from '../../utils/notationParser'
import { clampRatingScore, isValidRatingScore, normalizeRatingScore } from '../../config/constants'
import { getAiSettings, callOpenRouter } from '../../utils/aiClient'
import { weightedAverageRating } from '../../utils/ratingTrend'
import { renderDimensionPrompt } from '../../utils/aiPrompt'
import { buildCriterionSuggestionPromptContext, parseCriterionSuggestionResponse } from '../../utils/criterionSuggestionPrompt'
import { findSimilarTaggedLines, type SimilarTaggedLine } from '../../utils/criterionSuggestion'
import { useRatingColorBands } from '../../hooks/useRatingColorBands'
import { ConversationTab } from './ConversationTab'
import { AssessmentTab } from './AssessmentTab'
import { ChecklistTab } from './ChecklistTab'
import { CriterionTagPicker, type CriterionSuggestion } from './CriterionTagPicker'
type Tab = 'conversation' | 'assessment'
export default function MeetingView() {
const { id, meetingId: meetingIdParam } = useParams()
const navigate = useNavigate()
const meetingId = Number(meetingIdParam ?? id)
const colorBands = useRatingColorBands()
const [meeting, setMeeting] = useState<MeetingInstance | null>(null)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [template, setTemplate] = useState<PhaseTemplate | null>(null)
const [institutees, setInstitutees] = useState<Consultant[]>([])
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const [entries, setEntries] = useState<ConversationEntry[]>([])
const [scores, setScores] = useState<ConversationSkillScore[]>([])
const [assessments, setAssessments] = useState<Assessment[]>([])
const [checklist, setChecklist] = useState<ChecklistItemDef[]>([])
const [checklistResponses, setChecklistResponses] = useState<ChecklistResponse[]>([])
const [tab, setTab] = useState<Tab>('conversation')
const [activeInstId, setActiveInstId] = useState<number | null>(null)
const [activeEntryId, setActiveEntryId] = useState<number | null>(null)
const [entryNote, setEntryNote] = useState('')
const [generalNotes, setGeneralNotes] = useState('')
const [generalNoteTags, setGeneralNoteTags] = useState<ConversationLineTag[]>([])
const [editingGeneralNotes, setEditingGeneralNotes] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const [editNote, setEditNote] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showExportMenu, setShowExportMenu] = useState(false)
const [lineSuggestions, setLineSuggestions] = useState<Map<string, CriterionSuggestion[]>>(new Map())
/** KI-Vorschläge für Checklisten-Antworten, Schlüssel `${checklistItemId}:${instituteeId ?? ''}`. */
const [checklistSuggestions, setChecklistSuggestions] = useState<Map<string, CriterionSuggestion[]>>(new Map())
const [analyzingSuggestions, setAnalyzingSuggestions] = useState(false)
const [suggestionError, setSuggestionError] = useState('')
/** Status/Diagnose nach „Vorschläge analysieren" — macht sichtbar, ob überhaupt etwas zu analysieren war und was die KI zurückgegeben hat. */
const [suggestionInfo, setSuggestionInfo] = useState('')
const [lastAnalysisRaw, setLastAnalysisRaw] = useState<string | null>(null)
/** Einmal pro Meeting-Load geholt, dient der rein lokalen Live-Formulierungshilfe (kein KI-Call). */
const [corpus, setCorpus] = useState<ConversationLineTag[]>([])
const [generalNotesHints, setGeneralNotesHints] = useState<SimilarTaggedLine[]>([])
const noteRef = useRef<HTMLTextAreaElement>(null)
const generalNotesRef = useRef<HTMLTextAreaElement>(null)
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const hintsTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const displayName = (inst: Consultant) => {
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
return dup ? `${inst.firstName} ${inst.lastName.charAt(0)}.` : inst.firstName
}
const tagLabel = (inst: Consultant) => displayName(inst)
const fillerTotal = (instId: number) =>
entries.filter(e => e.instituteeId === instId).reduce((s, e) => s + (e.fillerCount ?? 0), 0)
/**
* Rein lokale (KI-freie) Kriterium-Vorschläge für noch nicht zugeordnete Zeilen direkt verfügbar,
* ohne 🏷 Vorschläge analysieren" auszulösen (siehe CLAUDE.md „Direkte Kriterien-Vorschläge ohne KI").
* Mindest-Ähnlichkeit 0,25 ("mittel", gleiche Schwelle wie in criterionSuggestionPrompt.ts), damit
* nur halbwegs verlässliche Treffer als klickbarer Vorschlags-Chip erscheinen schwächere Treffer
* bleiben im 💭 Ähnliche frühere Notizen"-Panel weiterhin als reine Textreferenz sichtbar.
*/
const localLineSuggestions = useMemo(() => {
const map = new Map<string, CriterionSuggestion[]>()
if (corpus.length === 0) return map
const untaggedLines: string[] = []
for (const entry of entries) {
const taggedTexts = new Set((entry.lineTags ?? []).map(t => t.text))
for (const line of splitNoteLines(entry.note)) {
if (taggedTexts.has(line) || parseNotationPrefix(line).isQuote) continue
untaggedLines.push(line)
}
}
const taggedGeneralTexts = new Set(generalNoteTags.map(t => t.text))
for (const line of splitNoteLines(generalNotes)) {
if (taggedGeneralTexts.has(line) || parseNotationPrefix(line).isQuote) continue
untaggedLines.push(line)
}
for (const line of untaggedLines) {
const hints = findSimilarTaggedLines(line, corpus, 3).filter(h => h.score >= 0.25)
if (hints.length === 0) continue
map.set(line, hints.map((h, idx) => ({ criterionItemId: h.criterionItemId, rank: idx + 1 })))
}
return map
}, [entries, generalNotes, generalNoteTags, corpus])
/** KI-Vorschläge (aus „🏷 Vorschläge analysieren") haben Vorrang vor den lokalen, wenn beide für dieselbe Zeile vorliegen. */
const mergedLineSuggestions = useMemo(() => {
const merged = new Map(localLineSuggestions)
for (const [text, sugg] of lineSuggestions) merged.set(text, sugg)
return merged
}, [localLineSuggestions, lineSuggestions])
const loadData = useCallback(async () => {
const m = await getMeetingInstance(meetingId)
if (!m) return
setMeeting(m)
setGeneralNotes(m.generalNotes ?? '')
setGeneralNoteTags(m.generalNoteTags ?? [])
const [tmpl, a] = await Promise.all([
getPhaseTemplate(m.phaseTemplateId),
getAssignment(m.assignmentId),
])
setTemplate(tmpl ?? null)
setAssignment(a ?? null)
if (a) {
const cons = await getConsultantsByIds(a.instituteeIds)
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
}
const [structure, aType] = await Promise.all([
loadFeedbackStructure(),
a ? getAssignmentType(a.assignmentTypeId) : Promise.resolve(undefined),
])
const visible = resolveVisibleStructure(structure.dimensions, structure.categories, structure.items, aType?.criteriaIds)
setFbItems(visible.items)
setFbCategories(visible.categories)
const [convEntries, convScores, asmts] = await Promise.all([
listConversationEntriesForMeeting(meetingId),
listConversationSkillScoresAll(),
listAssessmentsForMeeting(meetingId),
])
setEntries(convEntries)
setScores(convScores)
setAssessments(asmts)
getGlobalLineTagCorpus().then(setCorpus)
if (tmpl?.hasChecklist) {
const [checklistDefs, responses] = await Promise.all([
listChecklistItemDefsForPhaseTemplate(tmpl.id!),
listChecklistResponsesForMeeting(meetingId),
])
setChecklist(checklistDefs)
setChecklistResponses(responses)
} else {
setChecklist([])
setChecklistResponses([])
}
}, [meetingId])
useEffect(() => { loadData() }, [loadData])
// ── Conversation handlers ─────────────────────────────────────────────────
const activateInstitutee = async (instId: number) => {
if (activeEntryId !== null) {
if (entryNote.trim() === '') {
await deleteConversationEntry(activeEntryId)
} else {
await updateConversationEntry(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
}
}
const maxSeq = entries.reduce((m, e) => Math.max(m, e.sequenceIndex), 0)
const eid = await addConversationEntry({
meetingInstanceId: meetingId, instituteeId: instId,
sequenceIndex: maxSeq + 1, note: '', fillerCount: 0, updatedAt: new Date().toISOString(),
})
setActiveInstId(instId)
setActiveEntryId(eid)
setEntryNote('')
await loadData()
setTimeout(() => noteRef.current?.focus(), 50)
}
const insertNotation = (sym: string) => {
const el = noteRef.current
if (!el) { setEntryNote(p => p + sym + ' '); return }
const start = el.selectionStart ?? entryNote.length
const end = el.selectionEnd ?? entryNote.length
const next = entryNote.slice(0, start) + sym + ' ' + entryNote.slice(end)
setEntryNote(next)
setTimeout(() => { el.selectionStart = el.selectionEnd = start + sym.length + 1; el.focus() }, 0)
}
const saveEntryNote = async () => {
if (activeEntryId === null) return
await updateConversationEntry(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, note: entryNote } : e))
}
const incrementFiller = async () => {
if (activeEntryId === null) return
const entry = entries.find(e => e.id === activeEntryId)
const newCount = (entry?.fillerCount ?? 0) + 1
await updateConversationEntry(activeEntryId, { fillerCount: newCount })
setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, fillerCount: newCount } : e))
}
const closeActiveEntry = async () => {
if (activeEntryId !== null && entryNote.trim() === '') {
await deleteConversationEntry(activeEntryId)
} else {
await saveEntryNote()
}
setActiveInstId(null)
setActiveEntryId(null)
setEntryNote('')
await loadData()
}
const startEdit = (entry: ConversationEntry) => { setEditingId(entry.id!); setEditNote(entry.note) }
const saveEdit = async (entryId: number) => {
const entry = entries.find(e => e.id === entryId)
const currentLines = splitNoteLines(editNote)
const prunedTags = (entry?.lineTags ?? []).filter(t => currentLines.includes(t.text))
await updateConversationEntry(entryId, { note: editNote, lineTags: prunedTags, updatedAt: new Date().toISOString() })
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote, lineTags: prunedTags } : e))
setEditingId(null)
}
const setLineCriterion = async (entryId: number, lineText: string, criterionItemId: number | null) => {
const entry = entries.find(e => e.id === entryId)
if (!entry) return
const currentLines = splitNoteLines(entry.note)
const kept = (entry.lineTags ?? []).filter(t => t.text !== lineText && currentLines.includes(t.text))
const nextTags = criterionItemId === null ? kept : [...kept, { text: lineText, criterionItemId }]
await updateConversationEntry(entryId, { lineTags: nextTags })
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, lineTags: nextTags } : e))
}
const deleteEntry = async (entryId: number) => {
await deleteConversationSkillScoresForEntry(entryId)
await deleteConversationEntry(entryId)
setEntries(prev => prev.filter(e => e.id !== entryId))
}
const adjustFiller = async (entry: ConversationEntry, delta: number) => {
const newCount = Math.max(0, (entry.fillerCount ?? 0) + delta)
await updateConversationEntry(entry.id!, { fillerCount: newCount })
setEntries(prev => prev.map(e => e.id === entry.id ? { ...e, fillerCount: newCount } : e))
}
const changeEntrySpeaker = async (entryId: number, instituteeId: number) => {
await updateConversationEntry(entryId, { instituteeId })
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, instituteeId } : e))
}
const getEntryScore = (entryId: number, categoryId: number): RatingScore | null =>
normalizeRatingScore(scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score)
const setEntryScore = async (entryId: number, categoryId: number, score: RatingScore | null) => {
const existing = scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)
if (score === null) {
if (existing?.id) {
await updateConversationSkillScore(existing.id, { score: null })
}
} else if (existing?.id) {
await updateConversationSkillScore(existing.id, { score })
} else {
await addConversationSkillScore({ conversationEntryId: entryId, criteriaId: categoryId, score })
}
setScores(await listConversationSkillScoresAll())
}
// ── Assessment handlers ───────────────────────────────────────────────────
const getAssessmentScore = (instituteeId: number, itemId: number): RatingScore | null =>
normalizeRatingScore(assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)?.score)
/** Kategorie-Score = gewichteter Ø der Kriterien (wie Altsystem); null wenn nichts bewertet. */
const syncCategoryFromCriteria = async (
instituteeId: number,
categoryId: number,
nextAssessments: Assessment[],
) => {
const catItems = fbItems.filter(i => i.categoryId === categoryId)
const avg = weightedAverageRating(
catItems,
itemId => nextAssessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)?.score ?? null,
)
await upsertMeetingCategoryRating({ meetingInstanceId: meetingId, instituteeId, categoryId, score: avg })
}
const setAssessmentScore = async (instituteeId: number, itemId: number, score: RatingScore | null) => {
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
const now = new Date().toISOString()
if (existing?.id) {
await updateAssessment(existing.id, { score, updatedAt: now })
} else {
await addAssessment({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now })
}
const next = await listAssessmentsForMeeting(meetingId)
setAssessments(next)
const item = fbItems.find(i => i.id === itemId)
if (item) {
await syncCategoryFromCriteria(instituteeId, item.categoryId, next)
}
}
const saveAssessmentNote = async (instituteeId: number, itemId: number, note: string) => {
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
const now = new Date().toISOString()
if (existing?.id) {
await updateAssessment(existing.id, { note, updatedAt: now })
} else {
await addAssessment({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score: null, note, updatedAt: now })
}
setAssessments(await listAssessmentsForMeeting(meetingId))
}
const autoCalcAssessment = async (instituteeId: number) => {
const instEntries = entries.filter(e => e.instituteeId === instituteeId)
for (const cat of fbCategories) {
const vals = instEntries
.map(e => getEntryScore(e.id!, cat.id!))
.filter(isValidRatingScore)
if (vals.length === 0) continue
const avg = vals.reduce((s, v) => s + v, 0) / vals.length
const rating = clampRatingScore(avg)
if (rating === null) continue
for (const item of fbItems.filter(i => i.categoryId === cat.id)) {
await setAssessmentScore(instituteeId, item.id!, rating)
}
}
}
// ── Checkliste-Handler ─────────────────────────────────────────────────────
const getChecklistResponse = (checklistItemId: number, instituteeId: number | undefined): ChecklistResponse | undefined =>
checklistResponses.find(r => r.checklistItemId === checklistItemId && (r.instituteeId ?? undefined) === instituteeId)
/**
* Checklisten-Text für die KI/Assessment-Notiz Checkpunkt-Label und Bewertung werden explizit
* mitgegeben (nicht nur der freie Kommentar), siehe CLAUDE.md Checkliste an Assessment gespiegelt".
*/
const composeChecklistNote = (label: string, note: string, rating: RatingScore): string => {
const ratingLabel = String(rating)
return note.trim() ? `Checkliste „${label}" (${ratingLabel}): ${note.trim()}` : `Checkliste „${label}" (${ratingLabel})`
}
/** Effektives Kriterium einer Antwort — individuelle Zuordnung überschreibt die Konfigurations-Vorgabe. */
const getEffectiveChecklistCriterion = (ci: ChecklistItemDef, instituteeId: number | undefined): number | null =>
getChecklistResponse(ci.id!, instituteeId)?.criterionItemId ?? ci.linkedCriterionItemId ?? null
/**
* `ChecklistResponse` ist immer die Quelle für die Checkliste-UI selbst. Bei effektiv vorhandenem
* Kriterium (individuelle Zuordnung ODER Konfigurations-Vorgabe) wird zusätzlich einseitig nach
* `Assessment` gespiegelt (score+note), damit Gesamtbewertung/Evaluation und die KI-Prompt-Auswertung
* (aiPrompt.ts::kriterienScores liest Assessment.note bereits) die Checklisten-Eintragung ganz normal
* als Protokolleintrag mitverwenden bei `scope: meetingWide` für JEDEN aktuellen Institutee
* identisch (Kriterien sind Personen-Kompetenzen, ein meeting-weiter Checkpunkt gilt für alle
* gleichermaßen). Wechselt die individuelle Zuordnung auf ein anderes Kriterium, bleibt ein bereits
* gespiegelter Eintrag beim alten Kriterium unangetastet (keine rückwirkende Korrektur) bewusst
* einfach gehalten, gleiches Prinzip wie beim Löschen eines Checklisten-Punkts.
*/
const saveChecklistResponse = async (
ci: ChecklistItemDef,
instituteeId: number | undefined,
patch: { rating?: RatingScore | null; note?: string; criterionItemId?: number },
) => {
const existing = getChecklistResponse(ci.id!, instituteeId)
const rating = 'rating' in patch ? (patch.rating ?? null) : (existing?.rating ?? null)
const note = patch.note ?? existing?.note ?? ''
const criterionItemId = 'criterionItemId' in patch ? patch.criterionItemId : existing?.criterionItemId
await upsertChecklistResponse({ meetingInstanceId: meetingId, instituteeId, checklistItemId: ci.id!, rating, note, criterionItemId })
setChecklistResponses(await listChecklistResponsesForMeeting(meetingId))
const effectiveCriterion = criterionItemId ?? ci.linkedCriterionItemId
if (effectiveCriterion && isValidRatingScore(rating)) {
const composed = composeChecklistNote(ci.label, note, rating)
const targets = ci.scope === 'meetingWide' ? institutees.map(i => i.id!) : [instituteeId!]
for (const targetId of targets) {
await setAssessmentScore(targetId, effectiveCriterion, rating)
await saveAssessmentNote(targetId, effectiveCriterion, composed)
}
}
}
const setChecklistRating = (ci: ChecklistItemDef, instituteeId: number | undefined, rating: RatingScore | null) =>
saveChecklistResponse(ci, instituteeId, { rating })
const saveChecklistNote = (ci: ChecklistItemDef, instituteeId: number | undefined, note: string) =>
saveChecklistResponse(ci, instituteeId, { note })
const setChecklistCriterion = (ci: ChecklistItemDef, instituteeId: number | undefined, criterionItemId: number | null) =>
saveChecklistResponse(ci, instituteeId, { criterionItemId: criterionItemId ?? undefined })
/** Lokale (KI-freie) Kriterium-Vorschläge für Checklisten-Antworten ohne effektives Kriterium — analog `localLineSuggestions`. */
const localChecklistSuggestions = useMemo(() => {
const map = new Map<string, CriterionSuggestion[]>()
if (corpus.length === 0) return map
for (const ci of checklist) {
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
for (const instituteeId of targets) {
if (getEffectiveChecklistCriterion(ci, instituteeId) !== null) continue
const comment = getChecklistResponse(ci.id!, instituteeId)?.note?.trim()
if (!comment) continue
const hints = findSimilarTaggedLines(comment, corpus, 3).filter(h => h.score >= 0.25)
if (hints.length === 0) continue
map.set(`${ci.id}:${instituteeId ?? ''}`, hints.map((h, idx) => ({ criterionItemId: h.criterionItemId, rank: idx + 1 })))
}
}
return map
}, [checklist, checklistResponses, institutees, corpus])
const mergedChecklistSuggestions = useMemo(() => {
const merged = new Map(localChecklistSuggestions)
for (const [key, sugg] of checklistSuggestions) merged.set(key, sugg)
return merged
}, [localChecklistSuggestions, checklistSuggestions])
const getChecklistSuggestions = (checklistItemId: number, instituteeId: number | undefined): CriterionSuggestion[] | undefined =>
mergedChecklistSuggestions.get(`${checklistItemId}:${instituteeId ?? ''}`)
// ── Meeting-level handlers ────────────────────────────────────────────────
const saveGeneralNotes = (val: string) => {
setGeneralNotes(val)
if (notesTimer.current) clearTimeout(notesTimer.current)
notesTimer.current = setTimeout(() => updateMeetingInstance(meetingId, { generalNotes: val }), 600)
if (hintsTimer.current) clearTimeout(hintsTimer.current)
hintsTimer.current = setTimeout(() => {
const lastLine = val.split('\n').pop()?.trim() ?? ''
setGeneralNotesHints(lastLine ? findSimilarTaggedLines(lastLine, corpus) : [])
}, 800)
}
/** Verwaiste Zuordnungen entfernen, falls eine getaggte Zeile beim Editieren verändert/gelöscht wurde. */
const finishEditingGeneralNotes = () => {
const currentLines = splitNoteLines(generalNotes)
const prunedTags = generalNoteTags.filter(t => currentLines.includes(t.text))
if (prunedTags.length !== generalNoteTags.length) {
setGeneralNoteTags(prunedTags)
updateMeetingInstance(meetingId, { generalNoteTags: prunedTags })
}
setEditingGeneralNotes(false)
setGeneralNotesHints([])
}
/** Übernimmt einen Formulierungsvorschlag als Ersatz für die gerade getippte (letzte) Zeile. */
const applyGeneralHint = (hintText: string) => {
const lines = generalNotes.split('\n')
lines[lines.length - 1] = hintText
saveGeneralNotes(lines.join('\n'))
setGeneralNotesHints([])
setTimeout(() => generalNotesRef.current?.focus(), 0)
}
/** Wie setLineCriterion, aber meeting-weit statt an ein Institutee gebunden — gilt für alle Institutees. */
const setGeneralNoteCriterion = async (lineText: string, criterionItemId: number | null) => {
const currentLines = splitNoteLines(generalNotes)
const kept = generalNoteTags.filter(t => t.text !== lineText && currentLines.includes(t.text))
const nextTags = criterionItemId === null ? kept : [...kept, { text: lineText, criterionItemId }]
await updateMeetingInstance(meetingId, { generalNoteTags: nextTags })
setGeneralNoteTags(nextTags)
}
/**
* 🏷 Vorschläge analysieren" EIN gebündelter KI-Call für das gesamte offene Protokoll
* (nicht pro Zeile), siehe CLAUDE.md Selbstlernendes Kriterien-Tagging". Sammelt alle noch
* nicht zugeordneten Zeilen (Gesprächsbeiträge + allgemeine Notizen, Kundenzitate ausgenommen
* gleiches Ausschlussprinzip wie in aiPrompt.ts), lässt die KI pro Zeile priorisierte
* Kriterium-IDs vorschlagen und persistiert die als Nebenprodukt gelieferten Formulierungsmuster
* direkt auf `FeedbackCriterionItem.learnedPatternHints`.
*/
const analyzeSuggestions = async () => {
if (!assignment) return
const settings = getAiSettings()
if (!settings.apiKey) {
setSuggestionError('Kein KI-API-Key hinterlegt (Config → KI-Prompts bzw. Feedback-Seite → KI-Einstellungen).')
return
}
setAnalyzingSuggestions(true)
setSuggestionError('')
setSuggestionInfo('')
setLastAnalysisRaw(null)
try {
const [aType, structure, corpus, template] = await Promise.all([
getAssignmentType(assignment.assignmentTypeId),
loadFeedbackStructure(),
getGlobalLineTagCorpus(),
getOrSeedCriterionTaggingPromptTemplate(),
])
const visible = resolveVisibleStructure(
structure.dimensions,
structure.categories,
structure.items,
aType?.criteriaIds,
)
interface LineRef { lineNumber: number; text: string; speakerLabel?: string; checklistKey?: string }
const lineRefs: LineRef[] = []
let n = 1
for (const entry of entries) {
const inst = institutees.find(i => i.id === entry.instituteeId)
const taggedTexts = new Set((entry.lineTags ?? []).map(t => t.text))
for (const line of splitNoteLines(entry.note)) {
if (taggedTexts.has(line) || parseNotationPrefix(line).isQuote) continue
lineRefs.push({ lineNumber: n++, text: line, speakerLabel: inst ? tagLabel(inst) : undefined })
}
}
const taggedGeneralTexts = new Set(generalNoteTags.map(t => t.text))
for (const line of splitNoteLines(generalNotes)) {
if (taggedGeneralTexts.has(line) || parseNotationPrefix(line).isQuote) continue
lineRefs.push({ lineNumber: n++, text: line })
}
// Checklisten-Antworten ohne effektives Kriterium (weder individuell noch konfiguriert) — auch
// ohne Kommentar einbeziehen (Label allein ist bereits ein brauchbares Signal für die KI),
// sonst bleiben reine Bewertungen ohne Freitext fälschlich unanalysiert.
for (const ci of checklist) {
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
for (const instituteeId of targets) {
if (getEffectiveChecklistCriterion(ci, instituteeId) !== null) continue
const comment = getChecklistResponse(ci.id!, instituteeId)?.note?.trim()
const inst = instituteeId ? institutees.find(i => i.id === instituteeId) : undefined
lineRefs.push({
lineNumber: n++,
text: comment ? `${ci.label}: ${comment}` : ci.label,
speakerLabel: inst ? tagLabel(inst) : undefined,
checklistKey: `${ci.id}:${instituteeId ?? ''}`,
})
}
}
if (lineRefs.length === 0) {
setSuggestionInfo('Keine offenen Protokollzeilen oder Checklisten-Punkte gefunden — alles bereits zugeordnet oder leer.')
return
}
const context = buildCriterionSuggestionPromptContext({
assignmentTypeName: aType?.name ?? '—',
dimensions: visible.dimensions,
categories: visible.categories,
items: visible.items,
visibleCriteriaIds: aType?.criteriaIds,
lines: lineRefs,
corpus,
})
const prompt = renderDimensionPrompt(template, context)
const raw = await callOpenRouter(prompt, settings)
setLastAnalysisRaw(raw)
const { lineSuggestions: parsedLines, patternHints } = parseCriterionSuggestionResponse(raw)
const nextSuggestions = new Map<string, CriterionSuggestion[]>()
const nextChecklistSuggestions = new Map<string, CriterionSuggestion[]>()
for (const ref of lineRefs) {
const ids = parsedLines.get(ref.lineNumber)
if (!ids || ids.length === 0) continue
const suggestions = ids.map((criterionItemId, idx) => ({ criterionItemId, rank: idx + 1 }))
if (ref.checklistKey) nextChecklistSuggestions.set(ref.checklistKey, suggestions)
else nextSuggestions.set(ref.text, suggestions)
}
setLineSuggestions(nextSuggestions)
setChecklistSuggestions(nextChecklistSuggestions)
const totalSuggestions = nextSuggestions.size + nextChecklistSuggestions.size
setSuggestionInfo(totalSuggestions === 0
? `${lineRefs.length} Zeile(n)/Punkt(e) analysiert, aber die KI hat für keine(n) davon einen Vorschlag geliefert — Rohantwort unten einsehbar.`
: `${lineRefs.length} Zeile(n)/Punkt(e) analysiert, ${totalSuggestions} Vorschlag/Vorschläge erhalten.`)
if (patternHints.size > 0) {
await Promise.all([...patternHints.entries()].map(([criterionItemId, hint]) =>
updateFeedbackCriterionItem(criterionItemId, { learnedPatternHints: hint }),
))
setFbItems(prev => prev.map(i => patternHints.has(i.id!) ? { ...i, learnedPatternHints: patternHints.get(i.id!) } : i))
}
} catch (e) {
setSuggestionError(e instanceof Error ? e.message : 'Unbekannter Fehler bei der Analyse.')
} finally {
setAnalyzingSuggestions(false)
}
}
const buildFilename = (ext: string) => {
const date = new Date(meeting?.date ?? '').toLocaleDateString('de-DE').replace(/\./g, '-')
const label = template?.label ?? 'Meeting'
const idx = (meeting?.phaseIndex ?? 1) > 1 ? `-${meeting!.phaseIndex}` : ''
const name = assignment?.assignmentNumber ?? assignment?.title ?? 'export'
return `${name}_${date}_${label}${idx}.${ext}`
}
const handleExportMd = async () => {
try { downloadFile(await exportMeetingAsMarkdown(meetingId), buildFilename('md'), 'text/markdown') }
catch (e) { console.error(e) }
setShowExportMenu(false)
}
const handleExportJson = async () => {
try { downloadFile(await exportMeetingAsJson(meetingId), buildFilename('json'), 'application/json') }
catch (e) { console.error(e) }
setShowExportMenu(false)
}
const handleDeleteMeeting = async () => {
await saveEntryNote()
await softDeleteMeetingInstance(meetingId)
navigate(`/assignment/${assignment?.id}`)
}
const finishMeeting = async () => {
await saveEntryNote()
await updateMeetingInstance(meetingId, { status: 'done' })
navigate(`/assignment/${assignment?.id}`)
}
if (!meeting || !template) return <div className="text-center py-10 text-gray-400">Lädt</div>
const hasConv = template.hasConversation
const hasAssess = template.hasAssessment
const hasChecklist = template.hasChecklist
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<button onClick={() => navigate(`/assignment/${assignment?.id}`)}
className="text-xs text-brand mb-0.5 block"> Assignment</button>
{assignment?.assignmentNumber && (
<div className="text-xs font-semibold text-brand uppercase tracking-wide leading-none mb-0.5">{assignment.assignmentNumber}</div>
)}
<div className="text-sm font-semibold text-gray-600 truncate">{assignment?.title}</div>
<div className="font-bold text-gray-800 truncate">{template.label}</div>
<div className="text-xs text-gray-400">
{assignment?.client} · {new Date(meeting.date).toLocaleDateString('de-DE')}
</div>
</div>
<div className="flex gap-1.5 flex-shrink-0 items-start">
<button onClick={() => navigate(`/assignment/${assignment?.id}/history`)}
title="Gesamtprotokoll aller Meetings"
className="text-sm bg-white border border-gray-200 text-gray-600 px-2.5 py-1.5 rounded-lg font-medium">
📜 Verlauf
</button>
<button onClick={analyzeSuggestions}
disabled={analyzingSuggestions}
title="KI-Vorschläge für noch nicht zugeordnete Protokollzeilen"
className="text-sm bg-white border border-blue-200 text-blue-600 px-2.5 py-1.5 rounded-lg font-medium disabled:opacity-50">
{analyzingSuggestions ? '⏳ Analysiere…' : '🏷 Vorschläge analysieren'}
</button>
<div className="relative">
<button onClick={() => setShowExportMenu(p => !p)}
className="text-sm bg-white border border-gray-200 text-gray-600 px-2.5 py-1.5 rounded-lg font-medium">
Export
</button>
{showExportMenu && (
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-xl shadow-lg z-10 min-w-[160px] overflow-hidden">
<button onClick={handleExportMd}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 border-b border-gray-100">
📄 Markdown
</button>
<button onClick={handleExportJson}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50">
🗂 JSON-Backup
</button>
</div>
)}
</div>
<button onClick={() => setShowDeleteConfirm(p => !p)}
title="Meeting löschen"
className="text-sm bg-white border border-red-200 text-red-400 hover:text-red-600 px-2.5 py-1.5 rounded-lg font-medium">
🗑
</button>
<button onClick={finishMeeting}
className="text-sm bg-green-600 text-white px-3 py-1.5 rounded-lg font-medium">
Fertig
</button>
</div>
</div>
{suggestionError && (
<div className="bg-red-50 border border-red-200 rounded-xl px-3 py-2 text-xs text-red-600">
{suggestionError}
</div>
)}
{suggestionInfo && (
<div className="bg-blue-50 border border-blue-200 rounded-xl px-3 py-2 text-xs text-blue-700 space-y-1">
<div>{suggestionInfo}</div>
{lastAnalysisRaw && (
<details>
<summary className="cursor-pointer select-none font-medium">🔍 Rohantwort ansehen</summary>
<pre className="mt-1 whitespace-pre-wrap text-[10px] text-blue-900 bg-white border border-blue-100 rounded-lg p-2 max-h-64 overflow-y-auto">{lastAnalysisRaw}</pre>
</details>
)}
</div>
)}
{/* Löschen-Bestätigung */}
{showDeleteConfirm && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 space-y-3">
<div className="text-sm font-semibold text-red-700">Meeting in den Papierkorb verschieben?</div>
<div className="text-xs text-red-500">Das Meeting wird in den Papierkorb verschoben und kann dort wiederhergestellt oder endgültig gelöscht werden.</div>
<div className="flex gap-2">
<button onClick={handleDeleteMeeting}
className="flex-1 py-2 rounded-lg text-sm font-medium bg-red-600 text-white">
Ja, in Papierkorb
</button>
<button onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
Abbrechen
</button>
</div>
</div>
)}
{/* Allgemeine Notizen */}
{editingGeneralNotes ? (
<textarea
ref={generalNotesRef}
autoFocus
className="w-full border border-brand rounded-xl px-3 py-2 text-sm bg-white focus:outline-none resize-none"
style={{ minHeight: '4rem', fieldSizing: 'content' } as React.CSSProperties}
placeholder="Allgemeine Notizen zum Meeting… (+ gut, ! negativ, > Kundeninput)"
value={generalNotes}
onChange={e => saveGeneralNotes(e.target.value)}
onBlur={finishEditingGeneralNotes}
/>
) : null}
{editingGeneralNotes && generalNotesHints.length > 0 && (
<details className="bg-blue-50 border border-blue-100 rounded-lg px-3 py-1.5" open>
<summary className="text-xs font-medium text-blue-700 cursor-pointer select-none">💭 Ähnliche frühere Notizen klicken zum Übernehmen</summary>
<ul className="mt-1 space-y-0.5">
{generalNotesHints.map((h, i) => (
<li key={i}>
<button type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyGeneralHint(h.text)}
className="text-xs text-blue-800 hover:text-blue-950 hover:underline truncate text-left w-full">
{h.text}"
{fbItems.find(i => i.id === h.criterionItemId)?.name && (
<span className="text-blue-400"> 🏷 {fbItems.find(i => i.id === h.criterionItemId)?.name}</span>
)}
</button>
</li>
))}
</ul>
</details>
)}
{!editingGeneralNotes && (
<div onClick={() => setEditingGeneralNotes(true)}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm bg-white cursor-text min-h-[2.5rem]">
{generalNotes.trim()
? (
<NotationText
text={generalNotes}
colorBands={colorBands}
renderLineAddon={line => (
<span onClick={e => e.stopPropagation()}>
<CriterionTagPicker
items={fbItems}
categories={fbCategories}
value={generalNoteTags.find(t => t.text === line)?.criterionItemId ?? null}
onChange={itemId => setGeneralNoteCriterion(line, itemId)}
suggestions={mergedLineSuggestions.get(line)}
/>
</span>
)}
/>
)
: <span className="text-gray-300">Allgemeine Notizen zum Meeting</span>
}
</div>
)}
{/* Tabs */}
{(hasConv || hasAssess || hasChecklist) && (
<div className="flex bg-gray-100 rounded-xl p-1">
{(hasConv || hasChecklist) && (
<button onClick={() => setTab('conversation')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
tab === 'conversation' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
}`}>
Gesprächsprotokoll
</button>
)}
{hasAssess && (
<button onClick={() => setTab('assessment')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
tab === 'assessment' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
}`}>
Einschätzung
</button>
)}
</div>
)}
{/* Checkliste — ganz normal im Gesprächsprotokoll angezeigt, kein eigener Tab (siehe CLAUDE.md) */}
{tab === 'conversation' && hasChecklist && (
<ChecklistTab
institutees={institutees}
checklist={checklist}
fbItems={fbItems}
fbCategories={fbCategories}
tagLabel={tagLabel}
getChecklistResponse={getChecklistResponse}
onSetChecklistRating={setChecklistRating}
onSaveChecklistNote={saveChecklistNote}
getEffectiveCriterion={getEffectiveChecklistCriterion}
onSetChecklistCriterion={setChecklistCriterion}
getChecklistSuggestions={getChecklistSuggestions}
colorBands={colorBands}
/>
)}
{tab === 'conversation' && hasConv && (
<ConversationTab
institutees={institutees}
fbCategories={fbCategories}
fbItems={fbItems}
entries={entries}
lineSuggestions={mergedLineSuggestions}
corpus={corpus}
activeInstId={activeInstId}
activeEntryId={activeEntryId}
entryNote={entryNote}
editingId={editingId}
editNote={editNote}
noteRef={noteRef}
displayName={displayName}
tagLabel={tagLabel}
fillerTotal={fillerTotal}
getEntryScore={getEntryScore}
onActivateInstitutee={activateInstitutee}
onCloseActiveEntry={closeActiveEntry}
onInsertNotation={insertNotation}
onEntryNoteChange={setEntryNote}
onSaveEntryNote={saveEntryNote}
onIncrementFiller={incrementFiller}
onSetEntryScore={setEntryScore}
onSetLineCriterion={setLineCriterion}
onChangeSpeaker={changeEntrySpeaker}
onStartEdit={startEdit}
onSaveEdit={saveEdit}
onCancelEdit={() => setEditingId(null)}
onEditNoteChange={setEditNote}
onDeleteEntry={deleteEntry}
onAdjustFiller={adjustFiller}
colorBands={colorBands}
/>
)}
{tab === 'assessment' && hasAssess && (
<AssessmentTab
institutees={institutees}
fbCategories={fbCategories}
fbItems={fbItems}
assessments={assessments}
entries={entries}
generalNoteTags={generalNoteTags}
hasConv={hasConv}
tagLabel={tagLabel}
fillerTotal={fillerTotal}
getAssessmentScore={getAssessmentScore}
onSetAssessmentScore={setAssessmentScore}
onSaveAssessmentNote={saveAssessmentNote}
onAutoCalc={autoCalcAssessment}
colorBands={colorBands}
/>
)}
</div>
)
}

44
src/utils/aiClient.ts Normal file
View File

@ -0,0 +1,44 @@
import { AI_CONFIG } from '../config/constants'
export interface AiSettings {
apiKey: string
model: string
}
export function getAiSettings(): AiSettings {
return {
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
model: localStorage.getItem(AI_CONFIG.localStorageKeyModel) ?? AI_CONFIG.defaultModel,
}
}
export function saveAiSettings(apiKey: string, model: string): void {
localStorage.setItem(AI_CONFIG.localStorageKeyApiKey, apiKey)
localStorage.setItem(AI_CONFIG.localStorageKeyModel, model)
}
/**
* Einziger Netzwerk-Kontaktpunkt zu OpenRouter läuft bewusst client-seitig direkt gegen die
* API (nicht über den RPC-Server), API-Key kommt aus `localStorage`. Extrahiert aus
* `AssignmentFeedbackPage.tsx` (KI-Feedback-Generierung), damit das selbstlernende
* Kriterien-Tagging keine zweite, leicht abweichende Fetch-Implementierung braucht.
*/
export async function callOpenRouter(prompt: string, settings: AiSettings): Promise<string> {
const res = await fetch(`${AI_CONFIG.openRouterBaseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${settings.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'Assignment Monitor',
},
body: JSON.stringify({
model: settings.model,
messages: [{ role: 'user', content: prompt }],
}),
})
if (!res.ok) throw new Error(`API-Fehler: ${res.status} ${res.statusText}`)
const data = await res.json()
return data.choices?.[0]?.message?.content ?? ''
}

604
src/utils/aiPrompt.ts Normal file
View File

@ -0,0 +1,604 @@
import type {
Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, RatingScore,
} from '../db/types'
import { clampRatingScore, isValidRatingScore } from '../config/constants'
import { splitNoteLines, parseNotationPrefix } from './notationParser'
import type { TrendResult } from './ratingTrend'
/**
* Löst ein Notations-Präfix (führende Score-Zahl 110 bzw. `>`) am Zeilenanfang auf.
* Format bewusst Score N/10 Text" bzw. „Kundenzitat — Text" keine eckigen Klammern
* als Wrapper, da `[Titel]` bereits für Slide-/Themen-Referenzen reserviert ist.
*/
const formatNotationLine = (line: string): string => {
const { rating, isQuote, text } = parseNotationPrefix(line)
const tag = isQuote ? 'Kundenzitat' : rating !== null ? `Score ${rating}/10` : null
return tag ? `${tag}${text}` : text
}
/** Ganze Zeile besteht nur aus "[Titel]" — Abschnittsmarker des Bewerters (Slide-/Themen-Referenz). */
const MARKER_ONLY_RE = /^\[[^\]]*\]$/
/**
* Abschnittsmarker wie "[Background]" sind reine Navigationshilfe für den Bewerter (an welcher
* Stelle der Präsentation/des Dialogs eine Beobachtung entstand) für die KI ohne nachfolgenden
* Inhalt bedeutungslos und keine Bewertungsgrundlage. Bleibt erhalten, wenn die direkt folgende
* Zeile noch Inhalt liefert (dann als Kontext-Header sinnvoll), sonst wird die Marker-Zeile entfernt.
*/
const dropDanglingMarkers = (lines: string[]): string[] =>
lines.filter((line, i) => {
if (!MARKER_ONLY_RE.test(line.trim())) return true
const next = lines[i + 1]
return next !== undefined && !MARKER_ONLY_RE.test(next.trim())
})
export interface DimensionPromptInput {
institutee: Consultant
assignment: Assignment
dimension: FeedbackDimension
dimCategories: FeedbackCategory[]
dimItems: FeedbackCriterionItem[]
ratings: FeedbackCategoryRating[]
catTrends: Map<number, TrendResult>
/** Bereits auf diesen Berater gefiltert. */
instAssessments: Assessment[]
/** Bereits auf diesen Berater gefiltert. */
instEntries: ConversationEntry[]
/** Alle `done`-Meetings des Assignments, chronologisch — siehe `listDoneMeetingsChronological`. */
meetings: MeetingInstance[]
}
/**
* Baut die Platzhalter-Werte für EINE Dimension. Kriterien/Kategorien werden vom Aufrufer
* bereits auf die Dimension gefiltert übergeben, siehe CLAUDE.md Mehrstufiges Prompt-System".
*/
export function buildDimensionPromptContext(input: DimensionPromptInput): Record<string, string> {
const {
institutee, assignment, dimension, dimCategories, dimItems,
ratings, catTrends, instAssessments, instEntries, meetings,
} = input
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
const kategorienStruktur = dimCategories
.map((c, i) => `${i + 1}. ${c.name}${c.description ? `${c.description}` : ''}`)
.join('\n') || '(keine Kategorien)'
const kriterienScores = dimCategories.map(cat => {
const catItems = dimItems.filter(i => i.categoryId === cat.id)
const lines = catItems.map(c => {
const scores = instAssessments.filter(a => a.criteriaId === c.id && isValidRatingScore(a.score))
const generalNotesForCrit = meetings.flatMap(m =>
(m.generalNoteTags ?? []).filter(t => t.criterionItemId === c.id).map(t => t.text),
)
const notes = [
...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note),
...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text),
...generalNotesForCrit,
].map(formatNotationLine)
if (scores.length === 0 && notes.length === 0) return null
const avgNum = scores.length > 0
? scores.reduce((s, a) => s + (a.score as number), 0) / scores.length
: null
const clamped = avgNum !== null ? clampRatingScore(avgNum) : null
const avgLabel = avgNum !== null
? `${clamped ?? avgNum.toFixed(1)}/10 (Ø ${avgNum.toFixed(1)})`
: 'nur Notiz, keine Bewertung'
const label = c.description ? `${c.name} (${c.description})` : c.name
return ` ${label}: ${avgLabel}${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
}).filter((l): l is string => l !== null)
if (lines.length === 0) return null
return ` ${cat.name}:\n${lines.join('\n')}`
}).filter((l): l is string => l !== null).join('\n') || '(keine Scores erfasst)'
const kategorieBewertungen = dimCategories.map(cat => {
const r = ratings.find(x => x.feedbackCategoryId === cat.id)
const rating: RatingScore | undefined = r?.rating
const label = isValidRatingScore(rating) ? `${rating}/10` : 'nicht bewertet'
return ` ${cat.name}: ${label}`
}).join('\n') || '(keine Kategorien)'
const entwicklungVerlauf = dimCategories.map(cat => {
const t = catTrends.get(cat.id!)
if (!t || t.history.length === 0) return ` ${cat.name}: keine Verlaufsdaten`
const seq = t.history.map(h => h.score.toFixed(1)).join(' → ')
const trendLabel = t.trend === 'up' ? 'Verbesserung über die Laufzeit'
: t.trend === 'down' ? 'Verschlechterung über die Laufzeit'
: t.trend === 'stable' ? 'stabil'
: 'nur ein Meeting, kein Trend ableitbar'
return ` ${cat.name}: Verlauf ${seq} (${trendLabel})`
}).join('\n') || '(keine Kategorien)'
const notizenAllgemeinMeeting = meetings
.map(m => {
if (!m.generalNotes || m.generalNotes.trim() === '') return null
const rawLines = splitNoteLines(m.generalNotes)
.filter(line => !(m.generalNoteTags ?? []).some(t => t.text === line))
.filter(line => !parseNotationPrefix(line).isQuote)
const lines = dropDanglingMarkers(rawLines).map(line => ` - ${formatNotationLine(line)}`)
if (lines.length === 0) return null
return ` [${m.date}]\n${lines.join('\n')}`
})
.filter((x): x is string => x !== null)
.join('\n') || '(keine allgemeinen Meeting-Notizen)'
const protokollUnzugeordnet = meetings
.map(m => {
const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id)
const rawLines = meetingEntries
.flatMap(e => splitNoteLines(e.note).map(line => ({
line, tagged: (e.lineTags ?? []).some(t => t.text === line),
})))
.filter(x => !x.tagged)
.filter(x => !parseNotationPrefix(x.line).isQuote)
.map(x => x.line)
const lines = dropDanglingMarkers(rawLines).map(line => ` - ${formatNotationLine(line)}`)
if (lines.length === 0) return null
return ` [${m.date}]\n${lines.join('\n')}`
})
.filter((x): x is string => x !== null)
.join('\n') || '(keine nicht zugeordneten Protokolleinträge)'
return {
'{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`,
'{{ASSIGNMENT_TITEL}}': assignment.title,
'{{ASSIGNMENT_KUNDE}}': assignment.client,
'{{DIMENSION_NAME}}': dimension.name,
'{{KATEGORIEN_STRUKTUR}}': kategorienStruktur,
'{{KRITERIEN_SCORES}}': kriterienScores,
'{{KATEGORIE_BEWERTUNGEN}}': kategorieBewertungen,
'{{ENTWICKLUNG_VERLAUF}}': entwicklungVerlauf,
'{{NOTIZEN_ALLGEMEIN_MEETING}}': notizenAllgemeinMeeting,
'{{PROTOKOLL_UNZUGEORDNET}}': protokollUnzugeordnet,
'{{FUELLWOERTER_GESAMT}}': String(fillerTotal),
}
}
export function renderDimensionPrompt(template: string, context: Record<string, string>): string {
let result = template
for (const [token, value] of Object.entries(context)) {
result = result.split(token).join(value)
}
return result
}
export const renderPrompt = renderDimensionPrompt
export interface CategoryPromptInput {
institutee: Consultant
assignment: Assignment
dimension: FeedbackDimension
category: FeedbackCategory
catItems: FeedbackCriterionItem[]
ratings: FeedbackCategoryRating[]
catTrend: TrendResult | undefined
instAssessments: Assessment[]
instEntries: ConversationEntry[]
meetings: MeetingInstance[]
}
export interface CategoryAiResult {
score: number | null
benefits: string
concerns: string
}
export interface CategoryCondenseEntry {
name: string
score: number | null
benefits: string
concerns: string
}
export interface DimensionCondenseInput {
institutee: Consultant
assignment: Assignment
dimension: FeedbackDimension
categoryResults: CategoryCondenseEntry[]
}
export interface ParsedDimensionCondenseResponse {
achievements: string
developmentNeeds: string
}
/** Stufe A: schmaler Kontext für genau eine Kategorie. */
export function buildCategoryPromptContext(input: CategoryPromptInput): Record<string, string> {
const {
institutee, assignment, dimension, category, catItems,
ratings, catTrend, instAssessments, instEntries, meetings,
} = input
const catItemIds = new Set(catItems.map(i => i.id))
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
const kriterienScores = catItems.map(c => {
const scores = instAssessments.filter(a => a.criteriaId === c.id && isValidRatingScore(a.score))
const generalNotesForCrit = meetings.flatMap(m =>
(m.generalNoteTags ?? []).filter(t => t.criterionItemId === c.id).map(t => t.text),
)
const notes = [
...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note!),
...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text),
...generalNotesForCrit,
].map(formatNotationLine)
if (scores.length === 0 && notes.length === 0) return null
const avgNum = scores.length > 0
? scores.reduce((s, a) => s + (a.score as number), 0) / scores.length
: null
const clamped = avgNum !== null ? clampRatingScore(avgNum) : null
const avgLabel = avgNum !== null
? `${clamped ?? avgNum.toFixed(1)}/10 (Ø ${avgNum.toFixed(1)})`
: 'nur Notiz, keine Bewertung'
const label = c.description ? `${c.name} (${c.description})` : c.name
return ` ${label}: ${avgLabel}${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
}).filter((l): l is string => l !== null).join('\n') || '(keine Scores/Notizen)'
const r = ratings.find(x => x.feedbackCategoryId === category.id)
const manualRating = isValidRatingScore(r?.rating) ? `${r!.rating}/10` : 'nicht bewertet'
let entwicklungVerlauf = 'keine Verlaufsdaten'
if (catTrend && catTrend.history.length > 0) {
const seq = catTrend.history.map(h => h.score.toFixed(1)).join(' → ')
const trendLabel = catTrend.trend === 'up' ? 'Verbesserung über die Laufzeit'
: catTrend.trend === 'down' ? 'Verschlechterung über die Laufzeit'
: catTrend.trend === 'stable' ? 'stabil'
: 'nur ein Meeting, kein Trend ableitbar'
entwicklungVerlauf = `Verlauf ${seq} (${trendLabel})`
}
let trendVorschlag = 'Kein Meeting-Trend berechenbar — SCORE nur setzen wenn manuelle Bewertung oder Kriterien-Daten vorliegen.'
if (catTrend?.suggestion !== null && catTrend?.suggestion !== undefined) {
const s = catTrend.suggestion.toFixed(1)
trendVorschlag = `Vorgeschlagener Score aus Meeting-Verlauf: ${s}/10. Bestätige diesen Wert im SCORE-Feld oder passe maximal um ±1 an (nicht frei erfinden).`
}
const protokollGetaggt = meetings
.map(m => {
const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id)
const rawLines = meetingEntries
.flatMap(e => splitNoteLines(e.note).map(line => ({
line,
tags: (e.lineTags ?? []).filter(t => t.text === line && catItemIds.has(t.criterionItemId)),
})))
.filter(x => x.tags.length > 0)
.filter(x => !parseNotationPrefix(x.line).isQuote)
.map(x => x.line)
const generalTagged = (m.generalNoteTags ?? [])
.filter(t => catItemIds.has(t.criterionItemId))
.map(t => t.text)
const allLines = [...rawLines, ...generalTagged]
const lines = dropDanglingMarkers(allLines).map(line => ` - ${formatNotationLine(line)}`)
if (lines.length === 0) return null
return ` [${m.date}]\n${lines.join('\n')}`
})
.filter((x): x is string => x !== null)
.join('\n') || '(keine getaggten Zeilen)'
const erlaeuterung = category.description
? `Erläuterung: ${category.description}`
: ''
return {
'{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`,
'{{ASSIGNMENT_TITEL}}': assignment.title,
'{{ASSIGNMENT_KUNDE}}': assignment.client,
'{{DIMENSION_NAME}}': dimension.name,
'{{KATEGORIE_NAME}}': category.name,
'{{KATEGORIE_ERLAEUTERUNG}}': erlaeuterung,
'{{KRITERIEN_SCORES}}': kriterienScores,
'{{KATEGORIE_BEWERTUNG}}': manualRating,
'{{ENTWICKLUNG_VERLAUF}}': entwicklungVerlauf,
'{{TREND_VORSCHLAG}}': trendVorschlag,
'{{PROTOKOLL_GETAGGT}}': protokollGetaggt,
}
}
/** Stufe B: Verdichtung aus Kategorie-Ergebnissen (ohne Rohprotokoll). */
export function buildDimensionCondenseContext(input: DimensionCondenseInput): Record<string, string> {
const { institutee, assignment, dimension, categoryResults } = input
const kategorieErgebnisse = categoryResults.map(cr => {
const scoreLine = cr.score !== null ? `Score: ${cr.score}/10` : 'Score: nicht gesetzt'
const ben = cr.benefits.trim() || '- n/a'
const con = cr.concerns.trim() || '- n/a'
return `${cr.name}\n${scoreLine}\nBenefits:\n${ben}\nConcerns:\n${con}`
}).join('\n\n') || '(keine Kategorie-Ergebnisse)'
return {
'{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`,
'{{ASSIGNMENT_TITEL}}': assignment.title,
'{{ASSIGNMENT_KUNDE}}': assignment.client,
'{{DIMENSION_NAME}}': dimension.name,
'{{KATEGORIE_ERGEBNISSE}}': kategorieErgebnisse,
}
}
export function parseCategoryAiResponse(raw: string): CategoryAiResult {
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
const lines = cleaned.split('\n')
let score: number | null = null
let benefits = ''
let concerns = ''
type Mode = 'none' | 'benefits' | 'concerns'
let mode: Mode = 'none'
let buffer: string[] = []
const takeBuffer = () => {
const t = buffer.join('\n').trim()
buffer = []
return t
}
const append = (prev: string, next: string) => [prev, next].filter(Boolean).join('\n').trim()
for (const line of lines) {
const scoreMatch = line.match(SCORE_HDR)
const benMatch = line.match(BENEFITS_HDR)
const conMatch = line.match(CONCERNS_HDR)
if (scoreMatch) {
score = Number(scoreMatch[1])
continue
}
if (benMatch) {
const leftover = takeBuffer()
if (leftover && mode === 'concerns') concerns = append(concerns, leftover)
else if (leftover && mode === 'none') {
const split = splitLooseCategoryBody(leftover)
benefits = append(benefits, split.benefits)
concerns = append(concerns, split.concerns)
}
mode = 'benefits'
if (benMatch[2].trim()) benefits = append(benefits, benMatch[2].trim())
continue
}
if (conMatch) {
const leftover = takeBuffer()
if (leftover && mode === 'benefits') benefits = append(benefits, leftover)
mode = 'concerns'
if (conMatch[2].trim()) concerns = append(concerns, conMatch[2].trim())
continue
}
buffer.push(line)
}
const leftover = takeBuffer()
if (leftover) {
if (mode === 'benefits') benefits = append(benefits, leftover)
else if (mode === 'concerns') concerns = append(concerns, leftover)
else {
const split = splitLooseCategoryBody(leftover)
benefits = append(benefits, split.benefits)
concerns = append(concerns, split.concerns)
}
}
return { score, benefits, concerns }
}
export function parseDimensionCondenseResponse(raw: string): ParsedDimensionCondenseResponse {
const parsed = parseDimensionAiResponse(raw)
return {
achievements: parsed.achievements,
developmentNeeds: parsed.developmentNeeds,
}
}
export interface ParsedDimensionResponse {
categoryTexts: { name: string; benefits: string; concerns: string; score?: number | null }[]
achievements: string
developmentNeeds: string
}
const BENEFITS_HDR = /^(BENEFITS|STÄRKEN|STAERKEN)\s*:\s*(.*)$/i
const CONCERNS_HDR = /^(CONCERNS|RISIKEN|SCHWÄCHEN|SCHWAECHEN)\s*:\s*(.*)$/i
const ACHIEVEMENTS_HDR = /^ACHIEVEMENTS\s*:\s*(.*)$/i
const DEV_NEEDS_HDR = /^DEVELOPMENT_NEEDS\s*:\s*(.*)$/i
const SCORE_HDR = /^SCORE\s*:\s*(\d{1,2})\s*$/i
/** Lose Stichpunkte ohne BENEFITS/CONCERNS-Marker: nach Score-/Präfix-Hinweisen aufteilen. */
function splitLooseCategoryBody(raw: string): { benefits: string; concerns: string } {
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean)
if (lines.length === 0) return { benefits: '', concerns: '' }
const benefits: string[] = []
const concerns: string[] = []
let bucket: 'b' | 'c' | null = null
for (const line of lines) {
const benInline = line.match(BENEFITS_HDR)
if (benInline) {
bucket = 'b'
if (benInline[2].trim()) benefits.push(benInline[2].trim())
continue
}
const conInline = line.match(CONCERNS_HDR)
if (conInline) {
bucket = 'c'
if (conInline[2].trim()) concerns.push(conInline[2].trim())
continue
}
const bullet = line.replace(/^[-*•]\s*/, '')
if (/^(benefit|stärke|staerke|positiv)\b/i.test(bullet)) {
benefits.push(line)
continue
}
if (/^(concern|risiko|schwäche|schwaeche|negativ|sollte|empfehl)/i.test(bullet)) {
concerns.push(line)
continue
}
const scoreInLine = bullet.match(/\b(?:score|bewertung)?\s*([1-9]|10)\s*(?:\/\s*10)?\b/i)
?? bullet.match(/^([1-9]|10)\s*[:–—-]/)
if (scoreInLine) {
const n = Number(scoreInLine[1])
if (n >= 6) benefits.push(line)
else concerns.push(line)
continue
}
if (bucket === 'b') benefits.push(line)
else if (bucket === 'c') concerns.push(line)
else concerns.push(line)
}
return {
benefits: benefits.join('\n').trim(),
concerns: concerns.join('\n').trim(),
}
}
/**
* Marker-basiert (kein JSON). Tolerant gegenüber Text in derselben Zeile wie der Marker
* (`BENEFITS: - …`) und deutschen Synonymen. Ohne Untermarker: Heuristik über Score/Präfixe.
*/
export function parseDimensionAiResponse(raw: string): ParsedDimensionResponse {
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
const lines = cleaned.split('\n')
const categoryTexts: { name: string; benefits: string; concerns: string; score?: number | null }[] = []
let achievements = ''
let developmentNeeds = ''
type Mode = 'none' | 'category' | 'catBenefits' | 'catConcerns' | 'achievements' | 'development'
let mode: Mode = 'none'
let currentName = ''
let catBenefits = ''
let catConcerns = ''
let catScore: number | null = null
let buffer: string[] = []
const takeBuffer = () => {
const t = buffer.join('\n').trim()
buffer = []
return t
}
const append = (prev: string, next: string) => [prev, next].filter(Boolean).join('\n').trim()
const flushCategory = () => {
if (!currentName) { buffer = []; return }
const leftover = takeBuffer()
let benefits = catBenefits
let concerns = catConcerns
if (mode === 'catBenefits' && leftover) benefits = append(benefits, leftover)
else if (mode === 'catConcerns' && leftover) concerns = append(concerns, leftover)
else if (mode === 'category' && leftover) {
if (!benefits && !concerns) {
const split = splitLooseCategoryBody(leftover)
benefits = split.benefits
concerns = split.concerns
} else {
concerns = append(concerns, leftover)
}
}
categoryTexts.push({ name: currentName, benefits, concerns, score: catScore })
currentName = ''
catBenefits = ''
catConcerns = ''
catScore = null
}
const flush = () => {
if (mode === 'category' || mode === 'catBenefits' || mode === 'catConcerns') flushCategory()
else if (mode === 'achievements') achievements = takeBuffer()
else if (mode === 'development') developmentNeeds = takeBuffer()
else buffer = []
}
const startDimBenefits = (sameLine: string) => {
flush()
mode = 'achievements'
currentName = ''
if (sameLine.trim()) buffer = [sameLine.trim()]
}
const startDimConcerns = (sameLine: string) => {
flush()
mode = 'development'
currentName = ''
if (sameLine.trim()) buffer = [sameLine.trim()]
}
for (const line of lines) {
const catMatch = line.match(/^KATEGORIE:\s*(.+)$/i)
const scoreMatch = line.match(SCORE_HDR)
const benMatch = line.match(BENEFITS_HDR)
const conMatch = line.match(CONCERNS_HDR)
const achMatch = line.match(ACHIEVEMENTS_HDR)
const devMatch = line.match(DEV_NEEDS_HDR)
const inCat = mode === 'category' || mode === 'catBenefits' || mode === 'catConcerns'
if (catMatch) {
flush()
mode = 'category'
currentName = catMatch[1].trim()
catBenefits = ''
catConcerns = ''
catScore = null
continue
}
if (inCat && scoreMatch) {
catScore = Number(scoreMatch[1])
continue
}
if (inCat && benMatch) {
const leftover = takeBuffer()
if (leftover && mode === 'category') {
const split = splitLooseCategoryBody(leftover)
catBenefits = append(catBenefits, split.benefits)
catConcerns = append(catConcerns, split.concerns)
} else if (leftover && mode === 'catConcerns') catConcerns = append(catConcerns, leftover)
else if (leftover && mode === 'catBenefits') catBenefits = append(catBenefits, leftover)
mode = 'catBenefits'
if (benMatch[2].trim()) catBenefits = append(catBenefits, benMatch[2].trim())
continue
}
if (inCat && conMatch) {
const leftover = takeBuffer()
if (leftover && mode === 'catBenefits') catBenefits = append(catBenefits, leftover)
else if (leftover && mode === 'catConcerns') catConcerns = append(catConcerns, leftover)
else if (leftover && mode === 'category') {
const split = splitLooseCategoryBody(leftover)
catBenefits = append(catBenefits, split.benefits)
catConcerns = append(catConcerns, split.concerns)
}
mode = 'catConcerns'
if (conMatch[2].trim()) catConcerns = append(catConcerns, conMatch[2].trim())
continue
}
if (achMatch) {
startDimBenefits(achMatch[1] ?? '')
continue
}
if (devMatch) {
startDimConcerns(devMatch[1] ?? '')
continue
}
if (!inCat && benMatch) {
startDimBenefits(benMatch[2] ?? '')
continue
}
if (!inCat && conMatch) {
startDimConcerns(conMatch[2] ?? '')
continue
}
buffer.push(line)
}
flush()
return { categoryTexts, achievements, developmentNeeds }
}

View File

@ -0,0 +1,27 @@
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem } from '../db/types'
/**
* Optgroups Dimension Kategorie (HTML kennt kein verschachteltes optgroup) für "Ziel-Kriterium"-
* Dropdowns extrahiert aus FeedbackStructureConfig.tsx, damit AssignmentTypeConfig.tsx (Checklisten-
* Kriterium-Verknüpfung) dieselbe Logik nutzt statt sie zu duplizieren.
*/
export function criterionOptionGroups(
dimensions: FeedbackDimension[],
categories: FeedbackCategory[],
items: FeedbackCriterionItem[],
excludeItemId?: number,
) {
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
return sorted.flatMap(d => {
const dimCats = categories.filter(c => c.dimensionId === d.id).sort((a, b) => a.order - b.order)
return dimCats.map(c => {
const catItems = items.filter(i => i.categoryId === c.id && i.id !== excludeItemId).sort((a, b) => a.order - b.order)
if (catItems.length === 0) return null
return (
<optgroup key={c.id} label={`${d.name}${c.name}`}>
{catItems.map(i => <option key={i.id} value={i.id}>{i.name}</option>)}
</optgroup>
)
})
})
}

View File

@ -0,0 +1,61 @@
import type { ConversationLineTag } from '../db/types'
import { parseNotationPrefix } from './notationParser'
/** Wörter unter dieser Länge sind zu unspezifisch (Artikel/Präpositionen) und werden verworfen. */
const MIN_TOKEN_LENGTH = 3
/**
* Zerlegt eine Protokollzeile in ein normalisiertes Token-Set für die lokale Ähnlichkeitssuche
* Notation-Präfix (führende Score-Zahl 110 bzw. `>`) wird über `parseNotationPrefix()` entfernt (gleiche
* Normalisierung wie überall sonst), Rest kleingeschrieben und auf Wortzeichen aufgesplittet.
*/
export function normalizeLine(text: string): string[] {
const { text: withoutPrefix } = parseNotationPrefix(text)
return withoutPrefix
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter(token => token.length >= MIN_TOKEN_LENGTH)
}
export interface SimilarTaggedLine {
text: string
criterionItemId: number
score: number
}
const jaccard = (a: Set<string>, b: Set<string>): number => {
if (a.size === 0 || b.size === 0) return 0
let intersection = 0
for (const token of a) if (b.has(token)) intersection++
const union = a.size + b.size - intersection
return union === 0 ? 0 : intersection / union
}
/**
* Rein lokale, KI-freie Vorauswahl ähnlicher bereits getaggter Alt-Zeilen (Wortüberschneidung/
* Jaccard) liefert der KI Kontext-Hinweise, ersetzt aber nicht deren Entscheidung. Pro
* Kriterium wird nur der jeweils beste Treffer behalten, damit die Hinweisliste nicht von einem
* einzelnen, oft wiederholten Kriterium dominiert wird.
*/
export function findSimilarTaggedLines(
line: string,
corpus: ConversationLineTag[],
topK = 8,
): SimilarTaggedLine[] {
const lineTokens = new Set(normalizeLine(line))
if (lineTokens.size === 0) return []
const bestPerCriterion = new Map<number, SimilarTaggedLine>()
for (const entry of corpus) {
const score = jaccard(lineTokens, new Set(normalizeLine(entry.text)))
if (score <= 0) continue
const existing = bestPerCriterion.get(entry.criterionItemId)
if (!existing || score > existing.score) {
bestPerCriterion.set(entry.criterionItemId, { text: entry.text, criterionItemId: entry.criterionItemId, score })
}
}
return [...bestPerCriterion.values()]
.sort((a, b) => b.score - a.score)
.slice(0, topK)
}

View File

@ -0,0 +1,160 @@
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, ConversationLineTag } from '../db/types'
import { resolveVisibleStructure } from '../db/queries/feedbackStructure'
import { findSimilarTaggedLines } from './criterionSuggestion'
const byOrder = <T extends { order: number }>(rows: T[]) => [...rows].sort((a, b) => a.order - b.order)
/**
* `{{KRITERIEN_STRUKTUR}}` ID-referenzierte, verschachtelte Liste Dimension Kategorie
* (Name+Beschreibung) Kriterium (Name+Beschreibung), gefiltert auf die im Assignment-Typ
* sichtbaren Kriterien über `resolveVisibleStructure()` (dieselbe Logik wie Meeting/Feedback).
*/
export function buildCriterionStructureText(
dimensions: FeedbackDimension[],
allCategories: FeedbackCategory[],
allItems: FeedbackCriterionItem[],
visibleCriteriaIds: number[] | undefined,
): string {
const {
dimensions: visibleDimensions,
categories: visibleCategories,
items: visibleItems,
} = resolveVisibleStructure(dimensions, allCategories, allItems, visibleCriteriaIds)
const text = byOrder(visibleDimensions).map(dim => {
const dimCategories = byOrder(visibleCategories.filter(c => c.dimensionId === dim.id))
const catLines = dimCategories.map(cat => {
const catItems = byOrder(visibleItems.filter(i => i.categoryId === cat.id))
const itemLines = catItems.map(item => {
const label = item.description ? `${item.name}${item.description}` : item.name
return ` [${item.id}] ${label}`
}).join('\n')
const catLabel = cat.description ? `${cat.name}${cat.description}` : cat.name
return ` ${catLabel}\n${itemLines}`
}).join('\n')
return `${dim.name}\n${catLines}`
}).join('\n')
return text || '(keine sichtbaren Kriterien)'
}
export interface CriterionSuggestionLine {
/** Stabiler, nur innerhalb dieser Analyse gültiger Anker — nicht persistiert. */
lineNumber: number
text: string
/** Institutee-Name bei Gesprächsbeiträgen, undefined bei allgemeinen Notizen. */
speakerLabel?: string
}
export interface CriterionSuggestionPromptInput {
assignmentTypeName: string
dimensions: FeedbackDimension[]
categories: FeedbackCategory[]
items: FeedbackCriterionItem[]
visibleCriteriaIds: number[] | undefined
lines: CriterionSuggestionLine[]
/** Globaler Lern-Korpus, siehe `getGlobalLineTagCorpus()`. */
corpus: ConversationLineTag[]
}
const similarityLabel = (score: number): string => score >= 0.5 ? 'hoch' : score >= 0.25 ? 'mittel' : 'niedrig'
export function buildCriterionSuggestionPromptContext(input: CriterionSuggestionPromptInput): Record<string, string> {
const { assignmentTypeName, dimensions, categories, items, visibleCriteriaIds, lines, corpus } = input
const { items: visibleItems } = resolveVisibleStructure(dimensions, categories, items, visibleCriteriaIds)
const itemName = (id: number) => items.find(i => i.id === id)?.name ?? `#${id}`
const kriterienStruktur = buildCriterionStructureText(dimensions, categories, items, visibleCriteriaIds)
const protokollZeilen = lines
.map(l => `${l.lineNumber}. ${l.speakerLabel ? `[${l.speakerLabel}] ` : ''}${l.text}`)
.join('\n') || '(keine offenen Zeilen)'
const aehnlicheAltZuordnungen = lines.map(l => {
const hints = findSimilarTaggedLines(l.text, corpus)
if (hints.length === 0) return `Zeile ${l.lineNumber}: (keine ähnlichen Alt-Zeilen gefunden)`
const hintLines = hints.map(h => ` - "${h.text}" → [${h.criterionItemId}] ${itemName(h.criterionItemId)} (Ähnlichkeit ${similarityLabel(h.score)})`)
return `Zeile ${l.lineNumber}:\n${hintLines.join('\n')}`
}).join('\n') || '(keine offenen Zeilen)'
const gelernteMuster = visibleItems
.filter(i => i.learnedPatternHints?.trim())
.map(i => ` [${i.id}] ${i.name}: ${i.learnedPatternHints}`)
.join('\n') || '(noch keine gelernten Muster vorhanden)'
return {
'{{ASSIGNMENT_TYP}}': assignmentTypeName,
'{{KRITERIEN_STRUKTUR}}': kriterienStruktur,
'{{GELERNTE_MUSTER}}': gelernteMuster,
'{{AEHNLICHE_ALT_ZUORDNUNGEN}}': aehnlicheAltZuordnungen,
'{{PROTOKOLL_ZEILEN}}': protokollZeilen,
}
}
export interface ParsedCriterionSuggestionResponse {
/** Zeilennummer → priorisierte Kriterium-ID-Liste (leer = kein Vorschlag). */
lineSuggestions: Map<number, number[]>
/** Kriterium-ID → destilliertes Formulierungsmuster (Nebenprodukt dieses Laufs). */
patternHints: Map<number, string>
}
/**
* Parst das Klartext-Antwortformat (`ZEILE 3: 12, 47, 5` / `ZEILE 9: -`, gefolgt von einem
* `MUSTER:`-Block) bewusst kein JSON, gleiches Robustheitsprinzip wie `parseDimensionAiResponse`
* (Codefence-Stripping vorab, tolerant gegenüber fehlenden/kaputten Zeilen, kein Absturz).
*/
export function parseCriterionSuggestionResponse(raw: string): ParsedCriterionSuggestionResponse {
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
const lines = cleaned.split('\n')
const lineSuggestions = new Map<number, number[]>()
const patternHints = new Map<number, string>()
let mode: 'lines' | 'patterns' = 'lines'
let currentPatternId: number | null = null
let patternBuffer: string[] = []
const flushPattern = () => {
if (currentPatternId !== null) {
const text = patternBuffer.join(' ').trim()
if (text) patternHints.set(currentPatternId, text)
}
currentPatternId = null
patternBuffer = []
}
for (const rawLine of lines) {
const line = rawLine.trim()
if (/^MUSTER:\s*$/i.test(line)) {
flushPattern()
mode = 'patterns'
continue
}
if (mode === 'lines') {
const m = line.match(/^ZEILE\s+(\d+)\s*:\s*(.*)$/i)
if (!m) continue
const lineNumber = Number(m[1])
const rest = m[2].trim()
if (rest === '' || rest === '-') {
lineSuggestions.set(lineNumber, [])
} else {
const ids = rest.split(',').map(s => Number(s.trim())).filter(n => Number.isFinite(n))
lineSuggestions.set(lineNumber, ids)
}
} else {
const m = line.match(/^(\d+)\s*:\s*(.*)$/)
if (m) {
flushPattern()
currentPatternId = Number(m[1])
patternBuffer = [m[2].trim()]
} else if (currentPatternId !== null && line !== '') {
patternBuffer.push(line)
}
}
}
flushPattern()
return { lineSuggestions, patternHints }
}

View File

@ -1,29 +1,6 @@
import { db } from '../db'
import { rpc } from '../db/rpcClient'
import { downloadFile } from './meetingExport'
const TABLE_NAMES = [
'categories',
'criteria',
'assignmentTypes',
'phaseTemplates',
'groups',
'consultants',
'assignments',
'meetingInstances',
'conversationEntries',
'conversationSkillScores',
'assessments',
'feedbackDrafts',
'feedbackDimensions',
'feedbackCategories',
'feedbackCriterionItems',
'assignmentFeedbacks',
'feedbackCategoryRatings',
'feedbackDimensionTexts',
'criterionCategoryMappings',
'criterionLevelDescriptions',
] as const
export interface DbBackup {
version: number
exportedAt: string
@ -31,16 +8,7 @@ export interface DbBackup {
}
export async function exportFullBackup(): Promise<void> {
const tables: Record<string, unknown[]> = {}
for (const name of TABLE_NAMES) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tables[name] = await (db as any)[name].toArray()
}
const backup: DbBackup = {
version: 1,
exportedAt: new Date().toISOString(),
tables,
}
const backup = await rpc<DbBackup>('backup', 'exportAll')
const date = new Date().toLocaleDateString('de-DE').replace(/\./g, '-')
downloadFile(JSON.stringify(backup, null, 2), `assignment-monitor-backup-${date}.json`, 'application/json')
}
@ -54,19 +22,7 @@ export async function importFullBackup(file: File): Promise<{ ok: boolean; error
return { ok: false, error: 'Ungültiges Backup-Format' }
}
// Clear all tables and reimport in dependency order
await db.transaction('rw', TABLE_NAMES.map(n => (db as any)[n]), async () => {
for (const name of [...TABLE_NAMES].reverse()) {
await (db as any)[name].clear()
}
for (const name of TABLE_NAMES) {
const rows = backup.tables[name]
if (Array.isArray(rows) && rows.length > 0) {
await (db as any)[name].bulkAdd(rows)
}
}
})
await rpc('backup', 'importAll', backup.tables)
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }

View File

@ -0,0 +1,99 @@
/**
* Parst den Markdown-Export der Feedback-Struktur (gleiches Format wie
* `exportFeedbackStructureAsMarkdown`) in ein importierbares Seed-Objekt.
*/
export interface FeedbackStructureSeedItem {
name: string
weight: number
description?: string
}
export interface FeedbackStructureSeedCategory {
name: string
description?: string
items: FeedbackStructureSeedItem[]
}
export interface FeedbackStructureSeedDimension {
name: string
categories: FeedbackStructureSeedCategory[]
}
export interface FeedbackStructureSeed {
dimensions: FeedbackStructureSeedDimension[]
}
const ITEM_RE = /^- \*\*(.+?)\*\* \(Gewicht ([0-9]+(?:\.[0-9]+)?)\)(?: — (.+))?$/
export function parseFeedbackStructureMarkdown(md: string): FeedbackStructureSeed {
const lines = md.replace(/\r\n/g, '\n').split('\n')
const dimensions: FeedbackStructureSeedDimension[] = []
let dim: FeedbackStructureSeedDimension | null = null
let cat: FeedbackStructureSeedCategory | null = null
let pendingCatDesc: string[] = []
const flushCatDesc = () => {
if (!cat || pendingCatDesc.length === 0) return
const text = pendingCatDesc.join('\n').trim()
if (text) cat.description = text
pendingCatDesc = []
}
for (const raw of lines) {
const line = raw.trimEnd()
if (line.startsWith('# ') || line === '---' || line.startsWith('_Export:')) continue
if (!line.trim()) {
continue
}
if (line.startsWith('## ')) {
flushCatDesc()
dim = { name: line.slice(3).trim(), categories: [] }
dimensions.push(dim)
cat = null
continue
}
if (line.startsWith('### ')) {
flushCatDesc()
if (!dim) throw new Error(`Kategorie ohne Dimension: "${line}"`)
cat = { name: line.slice(4).trim(), items: [] }
dim.categories.push(cat)
pendingCatDesc = []
continue
}
const itemMatch = line.trim().match(ITEM_RE)
if (itemMatch) {
flushCatDesc()
if (!cat) throw new Error(`Kriterium ohne Kategorie: "${line}"`)
cat.items.push({
name: itemMatch[1].trim(),
weight: Number(itemMatch[2]),
description: itemMatch[3]?.trim() || undefined,
})
continue
}
if (cat && !line.trim().startsWith('- ')) {
pendingCatDesc.push(line.trim())
}
}
flushCatDesc()
if (dimensions.length === 0) throw new Error('Keine Dimensionen im Markdown gefunden.')
return { dimensions }
}
export function summarizeFeedbackStructureSeed(seed: FeedbackStructureSeed): {
dimensions: number
categories: number
items: number
} {
const categories = seed.dimensions.reduce((s, d) => s + d.categories.length, 0)
const items = seed.dimensions.reduce(
(s, d) => s + d.categories.reduce((cs, c) => cs + c.items.length, 0),
0,
)
return { dimensions: seed.dimensions.length, categories, items }
}

View File

@ -0,0 +1,36 @@
import type { Assessment, FeedbackCriterionItem, MeetingCategoryRating, RatingScore } from '../db/types'
import { normalizeRatingScore } from '../config/constants'
import { weightedAverageRating } from './ratingTrend'
/**
* Kategorie-Score für Chips/Historie: zuerst gespeicherter Meeting-Kategorie-Score
* (aus Einschätzung synchronisiert), sonst gewichteter Ø der Kriterium-Assessments.
*/
export function resolveCategoryChipScore(input: {
meetingId: number
instituteeId: number
categoryId: number
categoryRatings: MeetingCategoryRating[]
assessments: Assessment[]
catItems: FeedbackCriterionItem[]
}): RatingScore | null {
const stored = normalizeRatingScore(
input.categoryRatings.find(r =>
r.meetingInstanceId === input.meetingId &&
r.instituteeId === input.instituteeId &&
r.categoryId === input.categoryId,
)?.score,
)
if (stored !== null) return stored
return weightedAverageRating(
input.catItems,
itemId => normalizeRatingScore(
input.assessments.find(a =>
a.meetingInstanceId === input.meetingId &&
a.instituteeId === input.instituteeId &&
a.criteriaId === itemId,
)?.score,
),
)
}

View File

@ -1,33 +1,25 @@
import { db } from '../db'
import { getMeetingExportData, getFeedbackCriterionItemsByIds, listFeedbackCategoriesAll } from '../db'
import { normalizeRatingScore } from '../config/constants'
export async function exportMeetingAsMarkdown(meetingId: number): Promise<string> {
const meeting = await db.meetingInstances.get(meetingId)
if (!meeting) throw new Error('Meeting nicht gefunden')
const [template, assignment] = await Promise.all([
db.phaseTemplates.get(meeting.phaseTemplateId),
db.assignments.get(meeting.assignmentId),
])
const {
meeting, template, assignment, consultants, entries, allScores,
assessments, categoryRatings, checklistItems, checklistResponses,
} = await getMeetingExportData(meetingId)
const instituteeIds = assignment?.instituteeIds ?? []
const [consultants, entries, allScores, assessments] = await Promise.all([
instituteeIds.length > 0
? db.consultants.where('id').anyOf(instituteeIds).toArray()
: Promise.resolve([]),
db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'),
db.conversationSkillScores.toArray(),
db.assessments.where('meetingInstanceId').equals(meetingId).toArray(),
])
const critIds = [
...new Set([
...(template?.criteriaIds ?? []),
...assessments.map(a => a.criteriaId),
...entries.flatMap(e => (e.lineTags ?? []).map(t => t.criterionItemId)),
...(meeting.generalNoteTags ?? []).map(t => t.criterionItemId),
...checklistItems.map(ci => ci.linkedCriterionItemId).filter((id): id is number => id !== undefined),
...checklistResponses.map(r => r.criterionItemId).filter((id): id is number => id !== undefined),
]),
]
const criteria = critIds.length > 0
? await db.criteria.where('id').anyOf(critIds).toArray()
: []
const criteria = await getFeedbackCriterionItemsByIds(critIds)
const categories = await listFeedbackCategoriesAll()
const institutees = instituteeIds
.map(id => consultants.find(c => c.id === id))
@ -48,7 +40,11 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
if (meeting.generalNotes?.trim()) {
lines.push('## Allgemeine Notizen')
lines.push(meeting.generalNotes.trim())
for (const noteLine of meeting.generalNotes.trim().split('\n')) {
const lineTag = meeting.generalNoteTags?.find(t => t.text === noteLine.trim())
const critName = lineTag ? criteria.find(c => c.id === lineTag.criterionItemId)?.name : null
lines.push(`${noteLine}${critName ? ` _(→ ${critName})_` : ''}`)
}
lines.push('')
}
@ -63,7 +59,9 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
if (entry.note?.trim()) {
for (const noteLine of entry.note.trim().split('\n')) {
lines.push(`> ${noteLine}`)
const lineTag = entry.lineTags?.find(t => t.text === noteLine.trim())
const critName = lineTag ? criteria.find(c => c.id === lineTag.criterionItemId)?.name : null
lines.push(`> ${noteLine}${critName ? ` _(→ ${critName})_` : ''}`)
}
}
@ -87,8 +85,52 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
}
}
if (checklistItems.length > 0) {
lines.push('## Checkliste')
lines.push('')
for (const ci of checklistItems) {
const responsesForItem = checklistResponses.filter(r => r.checklistItemId === ci.id)
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
for (const instituteeId of targets) {
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
if (!response) continue
const effectiveCritId = response.criterionItemId ?? ci.linkedCriterionItemId
const critName = effectiveCritId ? criteria.find(c => c.id === effectiveCritId)?.name : null
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
const who = ci.scope === 'meetingWide' ? '' : `${inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'}`
const scoreStr = response.rating ?? '—'
const noteStr = response.note?.trim() ? `${response.note.trim()}` : ''
lines.push(`- **${ci.label}**${who}: ${scoreStr}${critName ? ` _(→ ${critName})_` : ''}${noteStr}`)
}
}
lines.push('')
}
if (categoryRatings.some(r => normalizeRatingScore(r.score) !== null)) {
lines.push('## Einschätzung (Kategorien)')
lines.push('')
for (const inst of institutees) {
const instRatings = categoryRatings.filter(
r => r.instituteeId === inst.id && normalizeRatingScore(r.score) !== null,
)
if (instRatings.length === 0) continue
lines.push(`### ${inst.firstName} ${inst.lastName}`)
for (const r of instRatings) {
const cat = categories.find(c => c.id === r.categoryId)
if (!cat) continue
const noteStr = r.note?.trim() ? `${r.note.trim()}` : ''
lines.push(`- **${cat.name}:** ${normalizeRatingScore(r.score)}/10${noteStr}`)
}
lines.push('')
}
}
if (assessments.length > 0) {
lines.push('## Gesamtbewertung')
lines.push('## Kriterien-Beobachtungen')
lines.push('')
for (const inst of institutees) {
@ -100,7 +142,7 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
for (const a of instAssessments) {
const crit = criteria.find(c => c.id === a.criteriaId)
if (!crit) continue
const scoreStr = a.score !== null ? `${a.score}/5` : '—'
const scoreStr = a.score !== null ? `${a.score}/10` : '—'
const noteStr = a.note?.trim() ? `${a.note.trim()}` : ''
lines.push(`- **${crit.name}:** ${scoreStr}${noteStr}`)
}
@ -121,23 +163,10 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
}
export async function exportMeetingAsJson(meetingId: number): Promise<string> {
const meeting = await db.meetingInstances.get(meetingId)
if (!meeting) throw new Error('Meeting nicht gefunden')
const [template, assignment] = await Promise.all([
db.phaseTemplates.get(meeting.phaseTemplateId),
db.assignments.get(meeting.assignmentId),
])
const instituteeIds = assignment?.instituteeIds ?? []
const [consultants, entries, allScores, assessments] = await Promise.all([
instituteeIds.length > 0
? db.consultants.where('id').anyOf(instituteeIds).toArray()
: Promise.resolve([]),
db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'),
db.conversationSkillScores.toArray(),
db.assessments.where('meetingInstanceId').equals(meetingId).toArray(),
])
const {
meeting, template, assignment, consultants, entries, allScores,
assessments, categoryRatings, checklistItems, checklistResponses,
} = await getMeetingExportData(meetingId)
const entryIds = entries.map(e => e.id!)
const entryScores = allScores.filter(s => entryIds.includes(s.conversationEntryId))
@ -153,6 +182,9 @@ export async function exportMeetingAsJson(meetingId: number): Promise<string> {
scores: entryScores.filter(s => s.conversationEntryId === e.id),
})),
assessments,
categoryRatings,
checklistItems,
checklistResponses,
}
return JSON.stringify(payload, null, 2)

View File

@ -1,22 +1,52 @@
import React from 'react'
import type { RatingColorBand, RatingScore } from '../db/types'
import {
DEFAULT_RATING_COLOR_BANDS,
isValidRatingScore,
resolveScoreStyle,
} from '../config/constants'
// Notations-Regeln (Reihenfolge wichtig: längere Symbole zuerst)
const NOTATION_RULES = [
{ prefix: '(+)', className: 'text-green-600', icon: '✓~', bg: 'bg-green-50', indent: false },
{ prefix: '(!)', className: 'text-red-500', icon: '✗~', bg: 'bg-red-50', indent: false },
{ prefix: '+', className: 'text-green-700 font-semibold', icon: '✓', bg: 'bg-green-50', indent: false },
{ prefix: '!', className: 'text-red-600 font-semibold', icon: '✗', bg: 'bg-red-50', indent: false },
{ prefix: '>', className: 'text-blue-600 italic', icon: '', bg: 'bg-blue-50', indent: true },
] as const
export function detectNotationRating(line: string): RatingScore | null {
return parseNotationPrefix(line).rating
}
export interface NotationPrefixMatch {
rating: RatingScore | null
/** `>`-Präfix — Zitat/Kundeninput, keine Bewertung. */
isQuote: boolean
/** Zeile ohne das erkannte Präfix. */
text: string
}
/**
* Führende Score-Zahl 110 (zuerst `10`, dann `1``9`), optional gefolgt von Leerzeichen/Satzzeichen.
* `>` bleibt Zitat; `[…]` wird in NotationText separat behandelt.
*/
export function parseNotationPrefix(line: string): NotationPrefixMatch {
const trimmed = line.trim()
if (trimmed.startsWith('>')) return { rating: null, isQuote: true, text: trimmed.slice(1).trim() }
const m = trimmed.match(/^(10|[1-9])(?:\s+|[.:)\-]|$)/)
if (m) {
const score = Number(m[1])
if (isValidRatingScore(score)) {
const rest = trimmed.slice(m[1].length).replace(/^[\s.:)\-]+/, '').trim()
return { rating: score, isQuote: false, text: rest }
}
}
return { rating: null, isQuote: false, text: trimmed }
}
// Erkennt [Slide 3] oder […] am Anfang einer Zeile
const SLIDE_RE = /^\[([^\]]*)\]/
function parseLine(line: string, key: number): React.ReactElement {
function parseLine(
line: string,
key: number,
bands: RatingColorBand[],
): React.ReactElement {
const trimmed = line.trim()
if (!trimmed) return <div key={key} className="h-1" />
// Slide-Referenz [Titel…]
const slideMatch = trimmed.match(SLIDE_RE)
if (slideMatch) {
const rest = trimmed.slice(slideMatch[0].length).trim()
@ -30,23 +60,29 @@ function parseLine(line: string, key: number): React.ReactElement {
)
}
// Notations-Präfixe
for (const rule of NOTATION_RULES) {
if (trimmed.startsWith(rule.prefix)) {
const rest = trimmed.slice(rule.prefix.length).trim()
return (
<div key={key}
className={`flex items-start gap-2 py-0.5 px-2 rounded ${rule.bg} ${rule.indent ? 'ml-2' : ''}`}>
<span className={`flex-shrink-0 text-xs font-bold mt-0.5 ${rule.className}`}>
{rule.icon}
</span>
<span className={`text-sm ${rule.className}`}>{rest}</span>
</div>
)
}
if (trimmed.startsWith('>')) {
const rest = trimmed.slice(1).trim()
return (
<div key={key} className="flex items-start gap-2 py-0.5 px-2 rounded bg-blue-50 ml-2">
<span className="flex-shrink-0 text-xs font-bold mt-0.5 text-blue-600 italic"></span>
<span className="text-sm text-blue-600 italic">{rest}</span>
</div>
)
}
const { rating, text } = parseNotationPrefix(trimmed)
if (rating !== null) {
const style = resolveScoreStyle(rating, bands)
return (
<div key={key} className={`flex items-start gap-2 py-0.5 px-2 rounded ${style.color}`}>
<span className={`flex-shrink-0 text-xs font-bold mt-0.5 tabular-nums ${style.active.includes('text-white') ? '' : ''}`.trim()}>
<span className={`inline-block px-1.5 py-0.5 rounded border ${style.active}`}>{rating}</span>
</span>
<span className="text-sm text-gray-800">{text}</span>
</div>
)
}
// Normaler Text
return (
<div key={key} className="py-0.5 text-sm text-gray-800">
{trimmed}
@ -57,14 +93,29 @@ function parseLine(line: string, key: number): React.ReactElement {
interface Props {
text: string
className?: string
renderLineAddon?: (line: string, index: number) => React.ReactNode
/** Optional — Default aus constants, Config-Bänder können später durchgereicht werden. */
colorBands?: RatingColorBand[]
}
export function NotationText({ text, className }: Props) {
export function NotationText({ text, className, renderLineAddon, colorBands }: Props) {
if (!text?.trim()) return null
const bands = colorBands ?? DEFAULT_RATING_COLOR_BANDS
const lines = text.split('\n')
return (
<div className={className}>
{lines.map((line, i) => parseLine(line, i))}
{lines.map((line, i) => renderLineAddon
? (
<div key={i} className="flex items-start gap-1">
<div className="flex-1 min-w-0">{parseLine(line, i, bands)}</div>
{renderLineAddon(line, i)}
</div>
)
: parseLine(line, i, bands))}
</div>
)
}
export function splitNoteLines(note: string): string[] {
return note.split('\n').map(l => l.trim()).filter(Boolean)
}

613
src/utils/pdfExport.tsx Normal file
View File

@ -0,0 +1,613 @@
import { Document, Page, Text, View, StyleSheet, pdf } from '@react-pdf/renderer'
import {
type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant,
type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
type ConversationEntry, type Assessment, type AssignmentFeedback,
type FeedbackCategoryRating, type FeedbackDimensionText, type FeedbackCategoryText,
type ChecklistItemDef, type ChecklistResponse, type MeetingCategoryRating, type RatingColorBand,
getAssignment, getAssignmentType, listPhaseTemplatesForType,
listDoneMeetingsChronological, getConsultantsByIds, getConsultant,
loadFeedbackStructure, resolveVisibleStructure,
listConversationEntriesForMeetings, listAssessmentsForMeetings, listMeetingCategoryRatingsForMeetings,
listChecklistItemDefsForPhaseTemplates, listChecklistResponsesForMeetings,
getAssignmentFeedback, listFeedbackCategoryRatings, listFeedbackDimensionTexts, listFeedbackCategoryTexts,
listAssignmentTypes, listPhaseTemplates, getOrSeedRatingColorBands,
} from '../db'
import { DEFAULT_RATING_COLOR_BANDS, normalizeRatingColorBands, resolveScoreStyle } from '../config/constants'
import { splitNoteLines, parseNotationPrefix } from './notationParser'
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
// ─── Styles ────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
page: { padding: 32, fontSize: 9, fontFamily: 'Helvetica', color: '#1f2937' },
h1: { fontSize: 16, fontWeight: 700, marginBottom: 2 },
assignmentNumber: {
fontSize: 12, fontWeight: 700, color: '#0070AD', marginBottom: 4, letterSpacing: 0.5,
},
h2: { fontSize: 13, fontWeight: 700, marginTop: 16, marginBottom: 6, borderBottomWidth: 1, borderBottomColor: '#d1d5db', paddingBottom: 3 },
h3: { fontSize: 11, fontWeight: 700, marginTop: 10, marginBottom: 3 },
h4: { fontSize: 9.5, fontWeight: 700, marginTop: 6, marginBottom: 2 },
meta: { fontSize: 9, color: '#6b7280', marginBottom: 1.5 },
meetingCard: { marginBottom: 10, padding: 8, backgroundColor: '#f9fafb', borderRadius: 4 },
meetingHeader: { fontSize: 10, fontWeight: 700, marginBottom: 1 },
meetingDate: { fontSize: 8, color: '#9ca3af', marginBottom: 4 },
speakerLabel: {
fontSize: 7.5, fontWeight: 700, color: '#ffffff', backgroundColor: '#4b5563',
paddingHorizontal: 4, paddingVertical: 1.5, borderRadius: 2, marginTop: 4, marginBottom: 1.5, alignSelf: 'flex-start',
},
line: { fontSize: 9, marginBottom: 1.5, lineHeight: 1.3 },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 3 },
chip: {
fontSize: 7, color: '#ffffff', backgroundColor: '#0070AD',
paddingHorizontal: 4, paddingVertical: 1.5, borderRadius: 3, marginRight: 3, marginBottom: 3,
},
bullet: { fontSize: 9, marginBottom: 2, lineHeight: 1.3 },
statusBadge: {
fontSize: 8, fontWeight: 700, color: '#ffffff', paddingHorizontal: 5, paddingVertical: 2,
borderRadius: 3, alignSelf: 'flex-start', marginBottom: 6,
},
categoryBlock: {
marginBottom: 8, paddingBottom: 6, paddingTop: 4, paddingHorizontal: 6,
backgroundColor: '#f3f4f6', borderRadius: 3,
borderLeftWidth: 3, borderLeftColor: '#0070AD',
},
categoryName: { fontSize: 9, fontWeight: 700, color: '#0070AD', flexGrow: 1 },
categoryRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 3, gap: 4 },
ratingBadge: { fontSize: 7.5, fontWeight: 700, color: '#ffffff', paddingHorizontal: 4, paddingVertical: 1.5, borderRadius: 3 },
footer: { position: 'absolute', bottom: 16, left: 32, right: 32, fontSize: 7, color: '#9ca3af', textAlign: 'center' },
})
// ─── Notation-Auflösung fürs PDF (führende Score-Zahl + Farbband) ─────────────
function classifyLine(
line: string,
colorBands: RatingColorBand[] = DEFAULT_RATING_COLOR_BANDS,
): { icon: string; color: string; bold: boolean; italic: boolean; text: string } {
const { rating, isQuote, text } = parseNotationPrefix(line)
if (isQuote) return { icon: '', color: '#2563eb', bold: false, italic: true, text }
if (rating !== null) {
const style = resolveScoreStyle(rating, colorBands)
return { icon: String(rating), color: style.hex, bold: true, italic: false, text }
}
return { icon: '', color: '#1f2937', bold: false, italic: false, text }
}
function ProtocolLine({ line, criterionName, colorBands }: {
line: string; criterionName?: string; colorBands?: RatingColorBand[]
}) {
const cls = classifyLine(line, colorBands)
return (
<Text style={[styles.line, { color: cls.color, fontWeight: cls.bold ? 700 : 400, fontStyle: cls.italic ? 'italic' : 'normal' }]}>
{cls.icon ? `${cls.icon} ` : ''}{cls.text}
{criterionName && <Text style={{ color: '#4f46e5', fontSize: 7.5 }}> {criterionName}</Text>}
</Text>
)
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
const sanitizeFilename = (s: string) => s.replace(/[^a-z0-9äöüß]+/gi, '-').replace(/^-+|-+$/g, '')
/** Primäres Zuordnungskriterium — am Dateinamen-Anfang; Fallback wenn Nummer fehlt. */
function assignmentFilenamePrefix(assignment: Assignment): string {
const num = assignment.assignmentNumber?.trim()
return sanitizeFilename(num || 'ohne-Nr') || 'ohne-Nr'
}
function AssignmentNumberHeader({ assignment }: { assignment: Assignment }) {
const num = assignment.assignmentNumber?.trim()
return (
<Text style={styles.assignmentNumber}>
Assignment-Nr.: {num || '—'}
</Text>
)
}
// ─── Feedback-Abschnitt (gemeinsam für Einzel- und Sammel-PDF) ──────────────
interface FeedbackSectionData {
institutee: Consultant
feedback: AssignmentFeedback | undefined
dimensions: FeedbackDimension[]
categories: FeedbackCategory[]
ratings: FeedbackCategoryRating[]
dimTexts: FeedbackDimensionText[]
categoryTexts: FeedbackCategoryText[]
colorBands?: RatingColorBand[]
}
function FeedbackSection({
institutee, feedback, dimensions, categories, ratings, dimTexts, categoryTexts,
colorBands = DEFAULT_RATING_COLOR_BANDS,
}: FeedbackSectionData) {
const isFinal = feedback?.status === 'final'
return (
<View>
<View wrap={false} minPresenceAhead={50}>
<Text style={styles.h3}>{institutee.firstName} {institutee.lastName}</Text>
<Text style={[styles.statusBadge, { backgroundColor: isFinal ? '#15803d' : '#9ca3af' }]}>
{isFinal ? '✓ Final' : 'Entwurf'}
</Text>
</View>
{!feedback && <Text style={styles.meta}>Noch kein Feedback erfasst.</Text>}
{dimensions.map(dim => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
const text = dimTexts.find(t => t.feedbackDimensionId === dim.id)
const hasContent = dimCats.some(c => {
const ct = categoryTexts.find(t => t.feedbackCategoryId === c.id)
return ratings.find(r => r.feedbackCategoryId === c.id)
|| ct?.benefits || ct?.concerns
})
|| text?.achievements || text?.developmentNeeds
if (!hasContent) return null
return (
<View key={dim.id} style={{ marginBottom: 8 }}>
<View wrap={false} minPresenceAhead={40}>
<Text style={styles.h4}>{dim.name}</Text>
</View>
{dimCats.map(cat => {
const rating = ratings.find(r => r.feedbackCategoryId === cat.id)?.rating
const ct = categoryTexts.find(t => t.feedbackCategoryId === cat.id)
if (!rating && !ct?.benefits && !ct?.concerns) return null
return (
<View key={cat.id} style={styles.categoryBlock} wrap={false}>
<View style={styles.categoryRow}>
<Text style={styles.categoryName}>{cat.name}</Text>
{rating != null && (
<Text style={[styles.ratingBadge, { backgroundColor: resolveScoreStyle(rating, colorBands).hex }]}>
{rating}/10
</Text>
)}
</View>
{ct?.benefits ? (
<View style={{ marginTop: 2 }}>
<Text style={{ fontSize: 7.5, fontWeight: 700, color: '#15803d' }}>Benefits</Text>
{splitNoteLines(ct.benefits).map((l, i) => <Text key={`b-${i}`} style={styles.bullet}>{l}</Text>)}
</View>
) : null}
{ct?.concerns ? (
<View style={{ marginTop: 2 }}>
<Text style={{ fontSize: 7.5, fontWeight: 700, color: '#b45309' }}>Concerns</Text>
{splitNoteLines(ct.concerns).map((l, i) => <Text key={`c-${i}`} style={styles.bullet}>{l}</Text>)}
</View>
) : null}
</View>
)
})}
{(text?.achievements || text?.developmentNeeds) ? (
<View style={{ marginTop: 6, paddingTop: 4, borderTopWidth: 1, borderTopColor: '#d1d5db' }}>
{text?.achievements ? (
<View style={{ marginBottom: 4 }}>
<View wrap={false} minPresenceAhead={30}>
<Text style={{ fontSize: 8.5, fontWeight: 700, color: '#15803d' }}>Top Benefits (Dimension)</Text>
</View>
{splitNoteLines(text.achievements).map((l, i) => <Text key={i} style={styles.bullet}>{l}</Text>)}
</View>
) : null}
{text?.developmentNeeds ? (
<View>
<View wrap={false} minPresenceAhead={30}>
<Text style={{ fontSize: 8.5, fontWeight: 700, color: '#b45309' }}>Top Concerns (Dimension)</Text>
</View>
{splitNoteLines(text.developmentNeeds).map((l, i) => <Text key={i} style={styles.bullet}>{l}</Text>)}
</View>
) : null}
</View>
) : null}
</View>
)
})}
</View>
)
}
// ─── Kombiniertes Assignment-Report-Dokument ────────────────────────────────
interface AssignmentReportData {
assignment: Assignment
templates: PhaseTemplate[]
meetings: MeetingInstance[]
institutees: Consultant[]
visibleCategories: FeedbackCategory[]
visibleItems: FeedbackCriterionItem[]
entries: ConversationEntry[]
assessments: Assessment[]
categoryRatings: MeetingCategoryRating[]
colorBands: RatingColorBand[]
checklistItems: ChecklistItemDef[]
checklistResponses: ChecklistResponse[]
dimensions: FeedbackDimension[]
allCategories: FeedbackCategory[]
feedbackByInstitutee: FeedbackSectionData[]
}
function AssignmentReportDocument({ data }: { data: AssignmentReportData }) {
const {
assignment, templates, meetings, institutees, visibleCategories, visibleItems, entries, assessments,
categoryRatings, colorBands, checklistItems, checklistResponses, dimensions, allCategories, feedbackByInstitutee,
} = data
return (
<Document>
<Page size="A4" style={styles.page} wrap>
<AssignmentNumberHeader assignment={assignment} />
<Text style={styles.h1}>{assignment.title}</Text>
<Text style={styles.meta}>Kunde: {assignment.client}</Text>
<Text style={styles.meta}>Institutees: {institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}</Text>
<Text style={styles.h2}>Gesamtprotokoll</Text>
{meetings.length === 0 && <Text style={styles.meta}>Keine abgeschlossenen Meetings.</Text>}
{meetings.map(meeting => {
const template = templates.find(t => t.id === meeting.phaseTemplateId)
const meetingEntries = entries
.filter(e => e.meetingInstanceId === meeting.id)
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
const meetingAssessments = assessments.filter(a => a.meetingInstanceId === meeting.id)
const meetingChecklistItems = checklistItems.filter(ci => ci.phaseTemplateId === template?.id).sort((a, b) => a.order - b.order)
return (
<View key={meeting.id} style={styles.meetingCard}>
<View wrap={false} minPresenceAhead={60}>
<Text style={styles.meetingHeader}>
{template?.label ?? 'Meeting'}{template?.isRepeatable ? ` #${meeting.phaseIndex}` : ''}
</Text>
<Text style={styles.meetingDate}>{formatDate(meeting.date)}</Text>
</View>
{meeting.generalNotes?.trim() && splitNoteLines(meeting.generalNotes).map((l, i) => {
const critId = meeting.generalNoteTags?.find(t => t.text === l)?.criterionItemId
const critName = critId ? visibleItems.find(item => item.id === critId)?.name : undefined
return <ProtocolLine key={`g${i}`} line={l} criterionName={critName} colorBands={colorBands} />
})}
{meetingEntries.map(entry => {
const inst = institutees.find(c => c.id === entry.instituteeId)
return (
<View key={entry.id} wrap={false}>
<Text style={styles.speakerLabel}>{inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'}</Text>
{entry.note && splitNoteLines(entry.note).map((l, i) => {
const critId = entry.lineTags?.find(t => t.text === l)?.criterionItemId
const critName = critId ? visibleItems.find(item => item.id === critId)?.name : undefined
return <ProtocolLine key={i} line={l} criterionName={critName} colorBands={colorBands} />
})}
{(entry.fillerCount ?? 0) > 0 && (
<Text style={[styles.line, { color: '#dc2626' }]}>Füllwörter: {entry.fillerCount}</Text>
)}
</View>
)
})}
{meetingChecklistItems.length > 0 && (
<View style={{ marginTop: 4 }}>
<Text style={{ fontSize: 8, fontWeight: 700, color: '#4b5563', marginBottom: 1.5 }}>Checkliste</Text>
{meetingChecklistItems.map(ci => {
const responsesForItem = checklistResponses.filter(r => r.checklistItemId === ci.id)
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
return targets.map(instituteeId => {
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
if (!response) return null
const effectiveCritId = response.criterionItemId ?? ci.linkedCriterionItemId
const critName = effectiveCritId ? visibleItems.find(i => i.id === effectiveCritId)?.name : undefined
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
const who = ci.scope === 'meetingWide' ? '' : `${inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'}`
const ratingLabel = response.rating != null ? `${response.rating}/10` : '—'
return (
<Text key={`${ci.id}-${instituteeId ?? 'm'}`} style={styles.bullet}>
<Text style={{ fontWeight: 700 }}>{ci.label}</Text>{who}: {ratingLabel}
{critName && <Text style={{ color: '#4f46e5' }}> ({critName})</Text>}
{response.note?.trim() ? `${response.note.trim()}` : ''}
</Text>
)
})
})}
</View>
)}
{institutees.length > 0 && visibleCategories.length > 0 && (
<View style={styles.chipRow}>
{institutees.map(inst => visibleCategories.map(cat => {
const catItems = visibleItems.filter(i => i.categoryId === cat.id)
const mode = resolveCategoryChipScore({
meetingId: meeting.id!,
instituteeId: inst.id!,
categoryId: cat.id!,
categoryRatings,
assessments: meetingAssessments,
catItems,
})
if (!mode) return null
return (
<Text key={`${inst.id}-${cat.id}`} style={[styles.chip, { backgroundColor: resolveScoreStyle(mode, colorBands).hex }]}>
{inst.firstName} · {cat.name} {mode}
</Text>
)
}))}
</View>
)}
</View>
)
})}
<Text style={styles.h2} break>Feedback</Text>
{feedbackByInstitutee.map((fb, i) => (
<View key={fb.institutee.id} break={i > 0}>
<FeedbackSection {...fb} dimensions={dimensions} categories={allCategories} colorBands={colorBands} />
</View>
))}
<Text style={styles.footer} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} / ${totalPages} — Export: ${new Date().toLocaleString('de-DE')}`} fixed />
</Page>
</Document>
)
}
// ─── Einzel-Feedback-Dokument ────────────────────────────────────────────────
function InstituteeFeedbackDocument({ assignment, data }: { assignment: Assignment; data: FeedbackSectionData }) {
return (
<Document>
<Page size="A4" style={styles.page} wrap>
<AssignmentNumberHeader assignment={assignment} />
<Text style={styles.h1}>Feedback: {data.institutee.firstName} {data.institutee.lastName}</Text>
<Text style={styles.meta}>{assignment.title} · {assignment.client}</Text>
<View style={{ marginTop: 10 }}>
<FeedbackSection {...data} />
</View>
<Text style={styles.footer} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} / ${totalPages} — Export: ${new Date().toLocaleString('de-DE')}`} fixed />
</Page>
</Document>
)
}
// ─── Download-Helfer ─────────────────────────────────────────────────────────
function downloadPdfBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// ─── Daten sammeln + Export auslösen ─────────────────────────────────────────
async function loadFeedbackSectionData(assignmentId: number, institutee: Consultant): Promise<FeedbackSectionData> {
const feedback = await getAssignmentFeedback(assignmentId, institutee.id!)
if (!feedback?.id) {
return { institutee, feedback: undefined, dimensions: [], categories: [], ratings: [], dimTexts: [], categoryTexts: [] }
}
const [ratings, dimTexts, categoryTexts] = await Promise.all([
listFeedbackCategoryRatings(feedback.id),
listFeedbackDimensionTexts(feedback.id),
listFeedbackCategoryTexts(feedback.id),
])
return { institutee, feedback, dimensions: [], categories: [], ratings, dimTexts, categoryTexts }
}
/** Reine Erzeugung ohne Download-Nebeneffekt — auch außerhalb des Browsers (z.B. Node/tsx) testbar. */
export async function buildAssignmentReportPdfBlob(assignmentId: number): Promise<{ blob: Blob; filename: string }> {
const assignment = await getAssignment(assignmentId)
if (!assignment) throw new Error('Assignment nicht gefunden')
const [aType, templates, meetings, institutees, structure] = await Promise.all([
getAssignmentType(assignment.assignmentTypeId),
listPhaseTemplatesForType(assignment.assignmentTypeId),
listDoneMeetingsChronological(assignmentId),
getConsultantsByIds(assignment.instituteeIds),
loadFeedbackStructure(),
])
const {
dimensions: visibleDimensions,
categories: visibleCategories,
items: visibleItems,
} = resolveVisibleStructure(structure.dimensions, structure.categories, structure.items, aType?.criteriaIds)
const meetingIds = meetings.map(m => m.id!)
const templateIds = templates.map(t => t.id!)
const [entries, assessments, categoryRatings, checklistItems, checklistResponses, rawBands] = await Promise.all([
listConversationEntriesForMeetings(meetingIds),
listAssessmentsForMeetings(meetingIds),
listMeetingCategoryRatingsForMeetings(meetingIds),
listChecklistItemDefsForPhaseTemplates(templateIds),
listChecklistResponsesForMeetings(meetingIds),
getOrSeedRatingColorBands(),
])
const colorBands = normalizeRatingColorBands(rawBands)
const orderedInstitutees = assignment.instituteeIds
.map(id => institutees.find(c => c.id === id))
.filter((c): c is Consultant => !!c)
const feedbackByInstitutee = await Promise.all(
orderedInstitutees.map(inst => loadFeedbackSectionData(assignmentId, inst)),
)
const data: AssignmentReportData = {
assignment, templates, meetings, institutees: orderedInstitutees,
visibleCategories, visibleItems, entries, assessments, categoryRatings, colorBands,
checklistItems, checklistResponses,
dimensions: visibleDimensions, allCategories: visibleCategories,
feedbackByInstitutee,
}
const blob = await pdf(<AssignmentReportDocument data={data} />).toBlob()
const filename = `${assignmentFilenamePrefix(assignment)}_Assignment-Report-${sanitizeFilename(assignment.title)}-${new Date().toISOString().slice(0, 10)}.pdf`
return { blob, filename }
}
export async function exportAssignmentReportPdf(assignmentId: number): Promise<void> {
const { blob, filename } = await buildAssignmentReportPdfBlob(assignmentId)
downloadPdfBlob(blob, filename)
}
/** Reine Erzeugung ohne Download-Nebeneffekt — auch außerhalb des Browsers (z.B. Node/tsx) testbar. */
export async function buildInstituteeFeedbackPdfBlob(assignmentId: number, instituteeId: number): Promise<{ blob: Blob; filename: string }> {
const [assignment, institutee, structure, rawBands] = await Promise.all([
getAssignment(assignmentId),
getConsultant(instituteeId),
loadFeedbackStructure(),
getOrSeedRatingColorBands(),
])
if (!assignment || !institutee) throw new Error('Assignment oder Institutee nicht gefunden')
const aType = await getAssignmentType(assignment.assignmentTypeId)
const visible = resolveVisibleStructure(
structure.dimensions,
structure.categories,
structure.items,
aType?.criteriaIds,
)
const base = await loadFeedbackSectionData(assignmentId, institutee)
const data: FeedbackSectionData = {
...base,
dimensions: visible.dimensions,
categories: visible.categories,
colorBands: normalizeRatingColorBands(rawBands),
}
const blob = await pdf(<InstituteeFeedbackDocument assignment={assignment} data={data} />).toBlob()
const filename = `${assignmentFilenamePrefix(assignment)}_Feedback-${sanitizeFilename(institutee.firstName + '-' + institutee.lastName)}-${sanitizeFilename(assignment.title)}.pdf`
return { blob, filename }
}
export async function exportInstituteeFeedbackPdf(assignmentId: number, instituteeId: number): Promise<void> {
const { blob, filename } = await buildInstituteeFeedbackPdfBlob(assignmentId, instituteeId)
downloadPdfBlob(blob, filename)
}
// ─── Feedback-Struktur-Dokument (Dimensionen/Kategorien/Kriterien + Beschreibungen) ──
function FeedbackStructureDocument({ dimensions, categories, items }: {
dimensions: FeedbackDimension[]; categories: FeedbackCategory[]; items: FeedbackCriterionItem[]
}) {
return (
<Document>
<Page size="A4" style={styles.page} wrap>
<Text style={styles.h1}>Feedback-Struktur</Text>
{dimensions.map((dim, di) => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
return (
<View key={dim.id} break={di > 0}>
<Text style={styles.h2}>{dim.name}</Text>
{dimCats.map(cat => {
const catItems = items.filter(i => i.categoryId === cat.id)
return (
<View key={cat.id} style={{ marginBottom: 8 }}>
<View wrap={false} minPresenceAhead={40}>
<Text style={styles.h4}>{cat.name}</Text>
{cat.description && <Text style={[styles.meta, { marginBottom: 2 }]}>{cat.description}</Text>}
</View>
{catItems.length === 0 && <Text style={styles.meta}>(keine Kriterien)</Text>}
{catItems.map(item => (
<Text key={item.id} style={styles.bullet}>
<Text style={{ fontWeight: 700 }}>{item.name}</Text>
{` (Gewicht ${(item.weight ?? 1).toFixed(1)})`}
{item.description ? `${item.description}` : ''}
</Text>
))}
</View>
)
})}
</View>
)
})}
<Text style={styles.footer} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} / ${totalPages} — Export: ${new Date().toLocaleString('de-DE')}`} fixed />
</Page>
</Document>
)
}
export async function exportFeedbackStructurePdf(): Promise<void> {
const { dimensions, categories, items } = await loadFeedbackStructure()
const blob = await pdf(<FeedbackStructureDocument dimensions={dimensions} categories={categories} items={items} />).toBlob()
downloadPdfBlob(blob, `Feedback-Struktur-${new Date().toISOString().slice(0, 10)}.pdf`)
}
// ─── Assignment-Typen-Bericht (inkl. Kriterien-Mapping) ─────────────────────
function AssignmentTypesDocument({ types, templates, checklistItems, dimensions, categories, items }: {
types: AssignmentType[]; templates: PhaseTemplate[]; checklistItems: ChecklistItemDef[]
dimensions: FeedbackDimension[]; categories: FeedbackCategory[]; items: FeedbackCriterionItem[]
}) {
return (
<Document>
<Page size="A4" style={styles.page} wrap>
<Text style={styles.h1}>Assignment-Typen</Text>
{types.map((type, ti) => {
const typPhases = templates.filter(t => t.assignmentTypeId === type.id).sort((a, b) => a.order - b.order)
const criteriaIds = type.criteriaIds ?? []
return (
<View key={type.id} break={ti > 0}>
<Text style={styles.h2}>{type.name}</Text>
{typPhases.length > 0 && (
<View style={{ marginBottom: 6 }}>
<Text style={styles.h4}>Phasen</Text>
{typPhases.map(p => {
const phaseChecklist = checklistItems.filter(ci => ci.phaseTemplateId === p.id).sort((a, b) => a.order - b.order)
return (
<View key={p.id} style={{ marginBottom: 2 }}>
<Text style={styles.bullet}>
{p.label} ({p.withWhom === 'client' ? 'Kunde' : 'Lead'}{p.isRepeatable ? ', wiederholbar' : ''}{p.hasChecklist ? ', mit Checkliste' : ''})
</Text>
{phaseChecklist.map(ci => {
const critName = ci.linkedCriterionItemId ? items.find(i => i.id === ci.linkedCriterionItemId)?.name : undefined
return (
<Text key={ci.id} style={[styles.bullet, { marginLeft: 10, fontSize: 8 }]}>
{ci.label} ({ci.scope === 'meetingWide' ? 'meeting-weit' : 'pro Institutee'})
{critName && <Text style={{ color: '#4f46e5' }}> {critName}</Text>}
</Text>
)
})}
</View>
)
})}
</View>
)}
<View wrap={false} minPresenceAhead={30}>
<Text style={styles.h4}>Kriterien-Mapping</Text>
</View>
{criteriaIds.length === 0 ? (
<Text style={styles.bullet}>Alle Kriterien (keine Einschränkung)</Text>
) : dimensions.map(dim => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
const relevantCats = dimCats.filter(c => items.some(i => i.categoryId === c.id && criteriaIds.includes(i.id!)))
if (relevantCats.length === 0) return null
return (
<View key={dim.id} style={{ marginBottom: 3 }} wrap={false}>
<Text style={{ fontSize: 8.5, fontWeight: 700 }}>{dim.name}</Text>
{relevantCats.map(cat => {
const names = items.filter(i => i.categoryId === cat.id && criteriaIds.includes(i.id!)).map(i => i.name).join(', ')
return <Text key={cat.id} style={styles.bullet}>{cat.name}: {names}</Text>
})}
</View>
)
})}
</View>
)
})}
<Text style={styles.footer} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} / ${totalPages} — Export: ${new Date().toLocaleString('de-DE')}`} fixed />
</Page>
</Document>
)
}
export async function exportAssignmentTypesReportPdf(): Promise<void> {
const [types, templates, structure] = await Promise.all([
listAssignmentTypes(),
listPhaseTemplates(),
loadFeedbackStructure(),
])
const checklistItems = await listChecklistItemDefsForPhaseTemplates(templates.map(t => t.id!))
const blob = await pdf(
<AssignmentTypesDocument types={types} templates={templates} checklistItems={checklistItems}
dimensions={structure.dimensions} categories={structure.categories} items={structure.items} />,
).toBlob()
downloadPdfBlob(blob, `Assignment-Typen-Bericht-${new Date().toISOString().slice(0, 10)}.pdf`)
}

89
src/utils/ratingTrend.ts Normal file
View File

@ -0,0 +1,89 @@
import type { FeedbackCriterionItem, RatingScore } from '../db'
import { clampRatingScore, normalizeRatingScore, RATING_TREND_DELTA_THRESHOLD } from '../config/constants'
export interface MeetingScorePoint {
meetingId: number
date: string
score: number
}
export interface TrendResult {
suggestion: number | null
history: MeetingScorePoint[]
trend: 'up' | 'down' | 'stable' | null
delta: number | null
}
/**
* Gewichteter Rohwert (110) über eine Menge von Kriterien; ungesetzte Scores ausgeschlossen.
* `null` wenn nichts bewertet ist oder Σweight===0.
*/
export function weightedAverageScore(
items: { id?: number; weight?: number }[],
getScore: (itemId: number) => RatingScore | null,
): number | null {
const rated = items
.map(i => ({ score: normalizeRatingScore(getScore(i.id!)), w: i.weight ?? 1 }))
.filter((x): x is { score: RatingScore; w: number } => x.score !== null)
if (rated.length === 0) return null
const wSum = rated.reduce((s, x) => s + x.w, 0)
if (wSum === 0) return null
return rated.reduce((s, x) => s + x.score * x.w, 0) / wSum
}
/** Wie weightedAverageScore, aber auf ganze Score-Stufe 110 gerundet. */
export function weightedAverageRating(
items: { id?: number; weight?: number }[],
getScore: (itemId: number) => RatingScore | null,
): RatingScore | null {
const avg = weightedAverageScore(items, getScore)
return avg === null ? null : clampRatingScore(avg)
}
/**
* Ein Wert pro Meeting für eine Gruppe von Kriterien (eine Kategorie oder alle Items für "overall").
* Meetings ohne bewertetes Item aus der Gruppe werden übersprungen.
*/
export function buildGroupMeetingScores(
items: FeedbackCriterionItem[],
meetingsChronological: { id?: number; date: string }[],
getScore: (meetingId: number, itemId: number) => RatingScore | null,
): MeetingScorePoint[] {
const points: MeetingScorePoint[] = []
for (const meeting of meetingsChronological) {
const score = weightedAverageScore(items, itemId => getScore(meeting.id!, itemId))
if (score === null) continue
points.push({ meetingId: meeting.id!, date: meeting.date, score })
}
return points
}
/**
* Lineare Zeitgewichtung: rank r (1-basiert, 1 = ältester Datenpunkt) weight = 1 + (r-1) * weightStep.
* Trend aus Delta zwischen erstem und letztem Rohwert (Schwelle auf 110-Skala).
*/
export function computeTrend(history: MeetingScorePoint[], weightStep: number): TrendResult {
if (history.length === 0) return { suggestion: null, history, trend: null, delta: null }
const weights = previewWeights(history.length, weightStep)
const wSum = weights.reduce((s, w) => s + w, 0)
const suggestion = history.reduce((s, p, i) => s + p.score * weights[i], 0) / wSum
if (history.length < 2) return { suggestion, history, trend: null, delta: null }
const delta = history[history.length - 1].score - history[0].score
const trend = delta >= RATING_TREND_DELTA_THRESHOLD ? 'up'
: delta <= -RATING_TREND_DELTA_THRESHOLD ? 'down'
: 'stable'
return { suggestion, history, trend, delta }
}
/** @deprecated Alias — Rundung auf 110; früher Bucket auf Client-Ready-Stufen. */
export function numberToRating(avg: number): RatingScore | null {
return clampRatingScore(avg)
}
/** Resultierende Gewichte für k chronologische Datenpunkte, z.B. previewWeights(3, 0.5) => [1, 1.5, 2]. */
export function previewWeights(k: number, weightStep: number): number[] {
return Array.from({ length: k }, (_, i) => 1 + i * weightStep)
}

View File

@ -0,0 +1,80 @@
import { loadFeedbackStructure, listAssignmentTypes, listPhaseTemplates, listChecklistItemDefsForPhaseTemplates } from '../db'
import { downloadFile } from './meetingExport'
export async function exportFeedbackStructureAsMarkdown(): Promise<void> {
const { dimensions, categories, items } = await loadFeedbackStructure()
const lines: string[] = ['# Feedback-Struktur', '']
for (const dim of dimensions) {
lines.push(`## ${dim.name}`, '')
const dimCats = categories.filter(c => c.dimensionId === dim.id)
for (const cat of dimCats) {
lines.push(`### ${cat.name}`)
if (cat.description) lines.push('', cat.description)
lines.push('')
const catItems = items.filter(i => i.categoryId === cat.id)
for (const item of catItems) {
const weight = (item.weight ?? 1).toFixed(1)
const desc = item.description ? `${item.description}` : ''
lines.push(`- **${item.name}** (Gewicht ${weight})${desc}`)
}
lines.push('')
}
}
lines.push('---', `_Export: ${new Date().toLocaleString('de-DE')}_`)
downloadFile(lines.join('\n'), `Feedback-Struktur-${new Date().toISOString().slice(0, 10)}.md`, 'text/markdown')
}
export async function exportAssignmentTypesReportAsMarkdown(): Promise<void> {
const [types, templates, structure] = await Promise.all([
listAssignmentTypes(),
listPhaseTemplates(),
loadFeedbackStructure(),
])
const checklistItems = await listChecklistItemDefsForPhaseTemplates(templates.map(t => t.id!))
const lines: string[] = ['# Assignment-Typen', '']
for (const type of types) {
lines.push(`## ${type.name}`, '')
const typPhases = templates.filter(t => t.assignmentTypeId === type.id).sort((a, b) => a.order - b.order)
if (typPhases.length > 0) {
lines.push('**Phasen:**')
for (const p of typPhases) {
const tags = [p.withWhom === 'client' ? 'Kunde' : 'Lead', p.isRepeatable ? 'wiederholbar' : null, p.hasChecklist ? 'mit Checkliste' : null].filter(Boolean).join(', ')
lines.push(`- ${p.label} (${tags})`)
const phaseChecklist = checklistItems.filter(ci => ci.phaseTemplateId === p.id).sort((a, b) => a.order - b.order)
for (const ci of phaseChecklist) {
const critName = ci.linkedCriterionItemId ? structure.items.find(i => i.id === ci.linkedCriterionItemId)?.name : undefined
const scopeLabel = ci.scope === 'meetingWide' ? 'meeting-weit' : 'pro Institutee'
lines.push(` - 🗹 ${ci.label} (${scopeLabel})${critName ? `${critName}` : ''}`)
}
}
lines.push('')
}
lines.push('**Kriterien-Mapping:**')
const criteriaIds = type.criteriaIds ?? []
if (criteriaIds.length === 0) {
lines.push('Alle Kriterien (keine Einschränkung)', '')
} else {
for (const dim of structure.dimensions) {
const dimCats = structure.categories.filter(c => c.dimensionId === dim.id)
const relevantCats = dimCats.filter(c => structure.items.some(i => i.categoryId === c.id && criteriaIds.includes(i.id!)))
if (relevantCats.length === 0) continue
lines.push(`- ${dim.name}`)
for (const cat of relevantCats) {
const catItemNames = structure.items
.filter(i => i.categoryId === cat.id && criteriaIds.includes(i.id!))
.map(i => i.name)
lines.push(` - ${cat.name}: ${catItemNames.join(', ')}`)
}
}
lines.push('')
}
}
lines.push('---', `_Export: ${new Date().toLocaleString('de-DE')}_`)
downloadFile(lines.join('\n'), `Assignment-Typen-Bericht-${new Date().toISOString().slice(0, 10)}.md`, 'text/markdown')
}

View File

@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.server.json" }
]
}

Some files were not shown because too many files have changed in this diff Show More