Compare commits
10 Commits
068c4cdcfd
...
234f87d408
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
234f87d408 | ||
|
|
782678a1cf | ||
|
|
019c686254 | ||
|
|
1a1f3bb33f | ||
|
|
9c68c414d8 | ||
|
|
911dcace72 | ||
|
|
f47353dbe1 | ||
|
|
f57db9d161 | ||
|
|
63ca6583fa | ||
|
|
594a643068 |
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal 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
16
.env.example
Normal 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
3
.gitignore
vendored
|
|
@ -11,6 +11,9 @@ dist-ssr/
|
||||||
# TypeScript build info
|
# TypeScript build info
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Lokale SQLite-Datenbank (enthält personenbezogene Daten, siehe CLAUDE.md → Datenschutz)
|
||||||
|
server/data/
|
||||||
|
|
||||||
# Local environment / secrets
|
# Local environment / secrets
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
|
||||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"syncfusioninc.document-viewer-vscode-extensions"
|
||||||
|
]
|
||||||
|
}
|
||||||
186
CLAUDE.md
186
CLAUDE.md
|
|
@ -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.
|
**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
|
## Stack
|
||||||
|
|
||||||
- React 18 + TypeScript + Vite (PWA)
|
- React 18 + TypeScript + Vite (PWA)
|
||||||
- Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js`
|
- Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js`; Brandfarbe als `@theme { --color-brand }` in `src/index.css`
|
||||||
- Dexie.js v5 — IndexedDB-Wrapper, kein Backend
|
- **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
|
- 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
|
## Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Dev-Server starten
|
npm run dev:all # Frontend (Vite, :5173) + lokaler Server (Express, :4000) zusammen starten
|
||||||
npx tsc --noEmit # TypeScript-Check — muss vor jeder Abgabe fehlerfrei sein
|
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
|
## Kritische Eigenheiten
|
||||||
|
|
@ -29,20 +87,15 @@ npx tsc --noEmit # TypeScript-Check — muss vor jeder Abgabe fehlerfrei sei
|
||||||
|
|
||||||
```
|
```
|
||||||
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
|
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
|
||||||
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
|
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
|
### AssignmentType.criteriaIds
|
||||||
|
|
||||||
Referenziert `FeedbackCriterionItem.id[]`. Leer = alle Kriterien anzeigen.
|
Referenziert `FeedbackCriterionItem.id[]`. Leer = alle Kriterien anzeigen.
|
||||||
|
|
||||||
Filterlogik (immer so anwenden):
|
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.
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rating-Skala
|
### 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
|
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
|
### Routing
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -66,7 +123,16 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
|
||||||
|
|
||||||
- `MeetingInstance.generalNotes` kann `undefined` sein → immer `?? ''` als Fallback
|
- `MeetingInstance.generalNotes` kann `undefined` sein → immer `?? ''` als Fallback
|
||||||
- `PhaseKey` ist `string`, kein Union-Typ
|
- `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 |
|
| Prio | Aufgabe | Datei |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| M | `Evaluation.tsx` auf neue Datenstruktur migrieren | `src/pages/Evaluation.tsx` |
|
| 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. | — |
|
||||||
| M | AI-Prompt auf `feedbackCriterionItems` umstellen | `src/pages/AssignmentFeedbackPage.tsx` |
|
| 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/` |
|
||||||
| M | `meetingExport.ts` — Kriterien-Auflösung anpassen | `src/utils/meetingExport.ts` |
|
| 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) |
|
||||||
| N | Dashboard: Assignment-Nummer anzeigen | `src/pages/Dashboard.tsx` |
|
| L | SQLite WASM + OPFS Migration — durch den lokalen Server-Ansatz (2026-07-04) überholt/hinfällig | `src/db/schema.ts` |
|
||||||
| N | AssignmentCreate: Feld für Assignment-Nummer | `src/pages/AssignmentCreate.tsx` |
|
|
||||||
| N | Toter Code in FeedbackStructureConfig entfernen | `src/pages/FeedbackStructureConfig.tsx` |
|
**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.
|
||||||
| L | SQLite WASM + OPFS Migration (kein festes Datum) | `src/db/index.ts` |
|
|
||||||
|
**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 ~5–8 ä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,0–2,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
|
## 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
|
- GDPR-relevant: Institutee-Namen, Kundennamen, Gesprächsnotizen
|
||||||
- KI-API-Key in `localStorage` — nie loggen oder ausgeben
|
- KI-API-Key in `localStorage` — nie loggen oder ausgeben
|
||||||
- Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln
|
- Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln
|
||||||
|
|
|
||||||
45
docs/DEV_ISOLATION.md
Normal file
45
docs/DEV_ISOLATION.md
Normal 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.
|
||||||
51
docs/EVALUATION_FEEDBACK_REDESIGN.md
Normal file
51
docs/EVALUATION_FEEDBACK_REDESIGN.md
Normal 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
|
||||||
|
|
@ -31,8 +31,9 @@ MeetingInstance ← Ein konkreter Termin (z.B. "Briefing C
|
||||||
└── ConversationEntry[] ← Zeitleiste: wer spricht wann
|
└── ConversationEntry[] ← Zeitleiste: wer spricht wann
|
||||||
└── Assessment[] ← Gesamtbewertung pro Institutee am Ende
|
└── 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)
|
└── ConversationSkillScore[] ← Kategorie-Schnellbewertung (während des Beitrags)
|
||||||
|
└── lineTags[] ← Kriterium-Zuordnung einzelner Zeilen (nachträglich, per Zeilentext)
|
||||||
|
|
||||||
FeedbackDimension ← Übergeordnete Dimension für das Assignment-Feedback
|
FeedbackDimension ← Übergeordnete Dimension für das Assignment-Feedback
|
||||||
└── FeedbackCategory[] ← Kategorie (z.B. "Kommunikationsfähigkeit")
|
└── FeedbackCategory[] ← Kategorie (z.B. "Kommunikationsfähigkeit")
|
||||||
|
|
@ -79,7 +80,7 @@ Wiederholbare Phasen (z.B. Alignment Calls) werden mit fortlaufendem `phaseIndex
|
||||||
|
|
||||||
### Aggregationslogik
|
### 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)
|
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`
|
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.
|
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)
|
## 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}`
|
**Format:** `{AssignmentNummer}_{Datum}_{PhasenLabel}{-#Nr}.{md|json}`
|
||||||
**Beispiel:** `CGI-2024-001_02-07-2026_Alignment Call-2.md`
|
**Beispiel:** `CGI-2024-001_02-07-2026_Alignment Call-2.md`
|
||||||
|
|
||||||
- **Markdown:** Lesbare Dokumentation mit Protokoll, Bewertungen und Notizen
|
- **Markdown:** Lesbare Dokumentation mit Protokoll, Bewertungen und Notizen — getaggte Protokollzeilen zeigen zusätzlich das zugeordnete Kriterium
|
||||||
- **JSON:** Vollständige Datensicherung eines einzelnen Meetings
|
- **JSON:** Vollständige Datensicherung eines einzelnen Meetings, inklusive Zeilen-Tags
|
||||||
|
|
||||||
### Datenbank-Backup
|
### 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.
|
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.
|
||||||
|
|
|
||||||
280
docs/FEEDBACK_CASCADE_PLAN.md
Normal file
280
docs/FEEDBACK_CASCADE_PLAN.md
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
# Plan: Feedback-Neukonzeption, KI-Kaskadierung & UX
|
||||||
|
|
||||||
|
Stand: 2026-08-06 · nur DEV (`AssigmentMonitorV2`)
|
||||||
|
Voraussetzung: Bewertungsskala 1–10 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 1–10 · 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 1–10“; 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)
|
||||||
|
→ 3–5 Benefits + 3–5 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 1–10 (Rest aus SCALE Phase 2–4)
|
||||||
|
|
||||||
|
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 (1–10) 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 3–5 Benefits, 3–5 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 (3–5), 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.1–UX.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 1–7 / 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 1–2 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 (3–5).
|
||||||
|
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.1–UX.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
115
docs/GITEA.md
Normal 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 |
|
||||||
|
|
@ -2,6 +2,24 @@
|
||||||
|
|
||||||
Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt nahtlos fortzuführen.
|
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,0–2,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?
|
## Worum geht es?
|
||||||
|
|
@ -10,13 +28,15 @@ Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt naht
|
||||||
|
|
||||||
## Tech-Stack
|
## Tech-Stack
|
||||||
|
|
||||||
- **React 18 + TypeScript + Vite** (PWA)
|
- **React 18 + TypeScript + Vite** (PWA-Frontend)
|
||||||
- **Tailwind CSS v4** (`@import "tailwindcss"` — kein `tailwind.config.js`!)
|
- **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**
|
- **React Router v6**
|
||||||
- **Capgemini-Brandfarbe:** `#0070AD`
|
- **Capgemini-Brandfarbe:** `#0070AD`
|
||||||
- Arbeitsverzeichnis: `C:\dev\AssigmentMonitor`
|
- 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)
|
## Bewertungsskala (kritisch für alle Berechnungen)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -36,6 +56,7 @@ Aggregation: **gewichteter Durchschnitt** (avg < 1.5 → Not, < 2.5 → Partiall
|
||||||
```
|
```
|
||||||
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting)
|
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting)
|
||||||
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung während Gespräch)
|
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.
|
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/new Neues Assignment
|
||||||
/assignment/:id AssignmentDetail
|
/assignment/:id AssignmentDetail
|
||||||
/assignment/:id/edit AssignmentEdit
|
/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/:instituteeId AssignmentFeedbackPage
|
||||||
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
|
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
|
||||||
/evaluation Auswertung (veraltet!)
|
/evaluation Auswertung — zeitgewichteter Bewertungs-Vorschlag + Trend, nach Assignment gruppiert
|
||||||
/consultants Berater
|
/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()`
|
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
|
## Konfigurationsbereich
|
||||||
|
|
||||||
**Tab "Feedback"** (`FeedbackStructureConfig.tsx`):
|
**Tab "Feedback"** (`FeedbackStructureConfig.tsx`):
|
||||||
- Dimensionen → Kategorien → Kriterium-Items (mit Gewichtung ×1–×5)
|
- Dimensionen → Kategorien → Kriterium-Items (mit Gewichtung `0,0–2,0` in `0,1`-Schritten, seit 2026-07-03 — vorher grobe `×1`–`×5`-Stufen)
|
||||||
- Nur Feedback-Struktur, kein AssignmentType-CRUD mehr!
|
- Nur Feedback-Struktur, kein AssignmentType-CRUD mehr!
|
||||||
|
|
||||||
**Tab "Ass.-Typen"** (`AssignmentTypeConfig.tsx`):
|
**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"
|
- Pro Typ: Sub-Tabs "Phasen" und "Kriterien"
|
||||||
- Kriterien-Tab: Baumstruktur Dimension → Kategorie → Items (Checkbox, Indeterminate)
|
- 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`):
|
**Tab "Backup"** (`Configuration.tsx`):
|
||||||
- JSON-Export aller Tabellen via `exportFullBackup()`
|
- JSON-Export aller Tabellen via `exportFullBackup()`
|
||||||
- JSON-Import via `importFullBackup(file)` (löscht alles, dann bulkAdd)
|
- JSON-Import via `importFullBackup(file)` (löscht alles, dann bulkAdd)
|
||||||
|
|
||||||
## Bekannte offene Punkte (Priorität nach Dringlichkeit)
|
## 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
|
### Mittlere Aufgaben
|
||||||
4. **Evaluation.tsx neu schreiben** — nutzt `db.categories` + `db.criteria` (Legacy). Soll `feedbackCriterionItems` + `assessments` nutzen und Rating-Labels zeigen
|
1. **KI-Generierung funktionsunfähig** — `generateWithAi()` schlägt fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`. Root Cause nicht untersucht.
|
||||||
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
|
|
||||||
|
|
||||||
### Große Aufgaben
|
### Große Aufgaben (eigene Design-Session nötig)
|
||||||
7. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus.
|
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,0–2,0` verfeinert, dabei einen Division-durch-Null-Bug bei Gewicht `0` in allen gewichteten Mittelwerten gefunden und gefixt.
|
||||||
|
|
||||||
## Stil-Regeln (bitte einhalten)
|
## Stil-Regeln (bitte einhalten)
|
||||||
|
|
||||||
|
|
@ -133,7 +160,7 @@ exportFullBackup(): Promise<void> // Download als JSON
|
||||||
importFullBackup(file: File): Promise<{ ok: boolean; error?: string }> // Clears + bulkAdd
|
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
|
## Seed-Daten
|
||||||
|
|
||||||
|
|
@ -145,7 +172,7 @@ Alle 20 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen
|
||||||
|
|
||||||
## Datenschutz-Hinweise
|
## 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
|
- GDPR-relevant: Institutee-Namen + Kundennamen in der DB
|
||||||
- KI-API: OpenRouter, API-Key in localStorage (nicht im Klartext loggen!)
|
- KI-API: OpenRouter, API-Key in localStorage (nicht im Klartext loggen!)
|
||||||
- Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln
|
- 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
|
## TypeScript-Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx tsc --noEmit # Muss ohne Fehler durchlaufen
|
npx tsc -b # Muss ohne Fehler durchlaufen — NICHT --noEmit, das prüft am Root nichts (siehe Tech-Stack-Abschnitt)
|
||||||
npm run dev # Dev-Server starten
|
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
|
## 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)
|
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.
|
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
|
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
100
docs/SCALE_1_TO_10.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Konzept: Bewertungsskala 1–10 (AssignmentMonitorV2)
|
||||||
|
|
||||||
|
Stand: 2026-08-06 · nur DEV (`AssigmentMonitorV2`), Produktiv unberührt.
|
||||||
|
|
||||||
|
## Festgezogene Entscheidungen
|
||||||
|
|
||||||
|
| # | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| Skala | **1–10**, 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 |
|
||||||
|
|---|---|
|
||||||
|
| 1–2 | rot |
|
||||||
|
| 3–5 | orange |
|
||||||
|
| 6–8 | hellgrün |
|
||||||
|
| 9–10 | 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 1–10 pro Kategorie × Institutee × Meeting
|
||||||
|
direkt setzbar, unabhängig davon ob alle Kriterien getaggt sind
|
||||||
|
|
||||||
|
Assignment-Feedback
|
||||||
|
FeedbackCategoryRating.rating → 1–10
|
||||||
|
Vorschlag aus zeitgewichteten Meeting-Kategorie-Scores
|
||||||
|
|
||||||
|
Checkliste
|
||||||
|
ChecklistResponse.rating → 1–10 (gleiche Skala/Farben)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Speicherung Meeting-Kategorie-Score
|
||||||
|
|
||||||
|
Neue Tabelle `meetingCategoryRatings`:
|
||||||
|
|
||||||
|
- `meetingInstanceId`, `instituteeId`, `categoryId`, `score` (INTEGER nullable 1–10), optional `note`
|
||||||
|
|
||||||
|
Bestehende `ConversationSkillScore` (Schnellscore am Protokoll-Eintrag) ebenfalls auf 1–10 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` 1–10, `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)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Projektstatus und offene Punkte — Assignment Monitor
|
# 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` |
|
| Strukturiertes Abschluss-Feedback pro Institutee | `AssignmentFeedbackPage.tsx` |
|
||||||
| KI-Feedback-Generierung via OpenRouter | `AssignmentFeedbackPage.tsx` |
|
| KI-Feedback-Generierung via OpenRouter | `AssignmentFeedbackPage.tsx` |
|
||||||
| Feedback-Status: Entwurf → Final | `AssignmentFeedbackPage.tsx` |
|
| Feedback-Status: Entwurf → Final | `AssignmentFeedbackPage.tsx` |
|
||||||
| Feedback-Dimensionsstruktur CRUD mit Gewichtung ×1–×5 | `FeedbackStructureConfig.tsx` |
|
| Feedback-Dimensionsstruktur CRUD mit Gewichtung (0,0–2,0 in 0,1-Schritten) | `FeedbackStructureConfig.tsx` |
|
||||||
| AssignmentTyp CRUD mit Phasen-Templates | `AssignmentTypeConfig.tsx` |
|
| AssignmentTyp CRUD mit Phasen-Templates | `AssignmentTypeConfig.tsx` |
|
||||||
| Kriterien-Auswahl je Typ (Baumstruktur, Indeterminate) | `AssignmentTypeConfig.tsx` |
|
| Kriterien-Auswahl je Typ (Baumstruktur, Indeterminate) | `AssignmentTypeConfig.tsx` |
|
||||||
| Kriterien-Filterung je Typ in Meetings + Übersicht | `MeetingView.tsx`, `AssignmentDetail.tsx` |
|
| Kriterien-Filterung je Typ in Meetings + Übersicht | `MeetingView.tsx`, `AssignmentDetail.tsx` |
|
||||||
| Vollständiges DB-Backup (JSON Export/Import) | `dbBackup.ts`, `Configuration.tsx` |
|
| Vollständiges DB-Backup (JSON Export/Import) | `dbBackup.ts`, `Configuration.tsx` |
|
||||||
| Berater/Gruppen-Verwaltung | `Consultants.tsx` |
|
| Berater/Gruppen-Verwaltung | `Consultants.tsx` |
|
||||||
| PWA (installierbar, Offline-fähig) | Vite-PWA-Plugin |
|
| 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,0–2,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 |
|
| # | 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) |
|
| 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. | `MeetingView.tsx` | S |
|
| 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
|
### Priorität Mittel
|
||||||
|
|
||||||
| # | Beschreibung | Datei | Aufwand |
|
| # | 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 |
|
| M5 | **`db/queries/`-Layer fehlt**: DB-Zugriffe direkt aus Komponenten (`db.*`), keine Abstraktionsschicht. Erschwert die geplante SQLite-Migration (H1). | `src/db/queries/` (neu) | M |
|
||||||
| M2 | **AI-Prompt veraltet**: `AssignmentFeedbackPage` liest `db.criteria` (alt) für Score-Zeilen. Sollte auf `feedbackCriterionItems` + `assessments` umgestellt werden. | `AssignmentFeedbackPage.tsx` | 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` | ? |
|
||||||
| M3 | **meetingExport.md veraltet**: Kriterien-Scores werden über `db.criteria` aufgelöst, nicht `feedbackCriterionItems`. Kategorie-Namen fehlen im Export. | `meetingExport.ts` | S |
|
|
||||||
|
|
||||||
### Priorität Niedrig
|
### Priorität Niedrig
|
||||||
|
|
||||||
| # | Beschreibung | Datei | Aufwand |
|
| # | 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 |
|
| 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 |
|
| 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.
|
**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.
|
**Status:** Konzept beschlossen. Implementierung steht aus. Paralleles Backup-System ist die aktuelle Notlösung.
|
||||||
|
|
||||||
### Evaluation neu (M1 oben)
|
### Evaluation — Zeitgewichteter Vorschlag (erledigt, 2026-07-02)
|
||||||
Auswertungsseite auf neue Datenstruktur migrieren. Soll zeigen:
|
Vollständig umgesetzt, siehe `docs/EVALUATION_FEEDBACK_REDESIGN.md` für die Design-Entscheidungen:
|
||||||
- Pro Institutee: aggregierte Kriterien-Scores über alle Meetings
|
- 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 über mehrere Assignments
|
- Trend (Verbesserung/Verschlechterung) wird ab 2 bewerteten Meetings pro Kategorie erkannt (Delta ≥ 0,5 Punkte)
|
||||||
- Vergleich zwischen Institutees
|
- 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,0–2,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)
|
### Dashboard-Verbesserungen (N1 oben)
|
||||||
- Assignment-Nummer anzeigen
|
- 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -17,29 +17,44 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur (aktualisiert nach Architektur-Refactoring, 2026-07-02)
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
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/
|
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/
|
pages/
|
||||||
Dashboard.tsx ← Assignment-Liste (Home)
|
Dashboard.tsx ← Assignment-Liste (Home)
|
||||||
AssignmentCreate.tsx ← Neues Assignment anlegen
|
AssignmentCreate.tsx ← Neues Assignment anlegen
|
||||||
AssignmentDetail.tsx ← Phasen-Flow, Meeting-Übersicht, Bewertungs-Chips
|
AssignmentDetail.tsx ← Phasen-Flow, Meeting-Übersicht, Bewertungs-Chips
|
||||||
AssignmentEdit.tsx ← Assignment nachträglich bearbeiten
|
AssignmentEdit.tsx ← Assignment nachträglich bearbeiten
|
||||||
MeetingView.tsx ← Live-Meeting: Protokoll + Gesamtbewertung
|
meeting/
|
||||||
AssignmentFeedbackPage.tsx ← Strukturiertes Abschluss-Feedback + KI-Generierung
|
MeetingView.tsx ← Route-Komponente: State, DB-Handler, Header, Tabs
|
||||||
FeedbackDraftPage.tsx ← Einfacher Feedback-Entwurf (Legacy)
|
ConversationTab.tsx ← Gesprächsprotokoll-UI
|
||||||
Evaluation.tsx ← Auswertung (teilweise veraltet, nutzt alte Criterion-Tabelle)
|
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
|
Configuration.tsx ← Tab-Container für alle Konfigurationsseiten
|
||||||
FeedbackStructureConfig.tsx ← Dimensionen/Kategorien/Kriterien CRUD + Gewichtung
|
FeedbackStructureConfig.tsx ← Dimensionen/Kategorien/Kriterien CRUD + Gewichtung
|
||||||
AssignmentTypeConfig.tsx ← AssignmentTypen + Phasen + Kriterien-Auswahl (Baum)
|
AssignmentTypeConfig.tsx ← AssignmentTypen + Phasen + Kriterien-Auswahl (Baum)
|
||||||
|
RatingWeightConfig.tsx ← Config-Tab "Gewichtung": Zeitgewichtungs-Faktor für Bewertungs-Vorschlag
|
||||||
Consultants.tsx ← Berater/Gruppen-Verwaltung
|
Consultants.tsx ← Berater/Gruppen-Verwaltung
|
||||||
utils/
|
utils/
|
||||||
meetingExport.ts ← Markdown + JSON Export-Logik
|
meetingExport.ts ← Markdown + JSON Export-Logik
|
||||||
dbBackup.ts ← Vollständiges DB-Backup (JSON Export/Import)
|
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
|
App.tsx ← Route-Definitionen, Navigation
|
||||||
main.tsx ← React-Einstiegspunkt, DB-Seed-Aufruf
|
main.tsx ← React-Einstiegspunkt, DB-Seed-Aufruf
|
||||||
```
|
```
|
||||||
|
|
@ -48,10 +63,10 @@ src/
|
||||||
|
|
||||||
## Datenmodell (IndexedDB via Dexie v5)
|
## Datenmodell (IndexedDB via Dexie v5)
|
||||||
|
|
||||||
### DB-Version: 5
|
### DB-Version: 6
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Konfiguration (Legacy, nur noch für Evaluation.tsx)
|
// Konfiguration (Legacy, kein aktiver Seiten-Konsument mehr)
|
||||||
categories: '++id, order'
|
categories: '++id, order'
|
||||||
criteria: '++id, categoryId, order'
|
criteria: '++id, categoryId, order'
|
||||||
|
|
||||||
|
|
@ -88,8 +103,13 @@ feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId'
|
||||||
// Mappings (Legacy, kaum genutzt)
|
// Mappings (Legacy, kaum genutzt)
|
||||||
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId'
|
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId'
|
||||||
criterionLevelDescriptions: '++id, criterionItemId, rating'
|
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
|
### Wichtige Interface-Details
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -135,12 +155,34 @@ interface FeedbackCriterionItem {
|
||||||
id?: number
|
id?: number
|
||||||
categoryId: number
|
categoryId: number
|
||||||
name: string
|
name: string
|
||||||
weight?: number // Gewichtung ×1–×5, default 1
|
weight?: number // 0,0–2,0 in 0,1-Schritten, default 1 (seit 2026-07-03; vorher grobe ×1–×5-Stufen)
|
||||||
order: number
|
order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
// Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
|
||||||
// ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
|
// 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)
|
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
|
||||||
/evaluation Auswertungsübersicht
|
/evaluation Auswertungsübersicht
|
||||||
/consultants Berater-Verwaltung
|
/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:
|
CRUD für:
|
||||||
- Feedback-Dimensionen (mit Reihenfolge ↑/↓)
|
- Feedback-Dimensionen (mit Reihenfolge ↑/↓)
|
||||||
- Feedback-Kategorien je Dimension
|
- Feedback-Kategorien je Dimension
|
||||||
- Feedback-Kriterium-Items je Kategorie (mit Gewichtung ×1–×5)
|
- Feedback-Kriterium-Items je Kategorie (mit Gewichtung `0,0–2,0` in `0,1`-Schritten)
|
||||||
|
|
||||||
### Tab "Ass.-Typen" → `AssignmentTypeConfig`
|
### Tab "Ass.-Typen" → `AssignmentTypeConfig`
|
||||||
CRUD für:
|
CRUD für:
|
||||||
|
|
@ -177,6 +219,9 @@ CRUD für:
|
||||||
- Kriterien-Auswahl je Typ: Baumstruktur Dimension → Kategorie → Item (mit Checkbox, Indeterminate-State)
|
- Kriterien-Auswahl je Typ: Baumstruktur Dimension → Kategorie → Item (mit Checkbox, Indeterminate-State)
|
||||||
- `criteriaIds = []` → alle Kriterien werden angezeigt
|
- `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`
|
### Tab "Backup" → in `Configuration.tsx`
|
||||||
- JSON-Export aller Tabellen
|
- JSON-Export aller Tabellen
|
||||||
- Zweistufiger JSON-Import mit Überschreib-Warnung
|
- 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')
|
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
||||||
if (scored.length === 0) return null
|
if (scored.length === 0) return null
|
||||||
const wSum = scored.reduce((s, x) => s + x.w, 0)
|
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
|
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 < 1.5) return 'not_client_ready'
|
||||||
if (avg < 2.5) return 'partially_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
|
## Export-Dateiname-Schema
|
||||||
|
|
@ -227,8 +275,9 @@ const buildFilename = (ext: string) => {
|
||||||
- **Provider:** OpenRouter (https://openrouter.ai) — ermöglicht Modellwechsel ohne eigenen API-Zugang
|
- **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
|
- **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`)
|
- **API-Key:** Lokal in `localStorage` gespeichert (`ai_api_key`, `ai_model`)
|
||||||
- **Datenweitergabe:** Kriterien-Scores, Protokoll-Notizen, Kategorie-Bewertungen werden im Prompt strukturiert übermittelt
|
- **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)
|
||||||
- **Hinweis:** Der AI-Prompt nutzt noch die alte `criteria`-Tabelle (Legacy) — Aktualisierung auf `feedbackCriterionItems` steht aus
|
- **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:
|
Die App legt beim ersten Start automatisch an:
|
||||||
|
|
||||||
1. **3 Assignment-Typen** mit je vollständigem Phasen-Flow (Generisch, Case Interview, Stakeholder Meeting / Pitch)
|
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)
|
3. **Feedback-Dimensionsstruktur** mit 5 Dimensionen, 14 Kategorien, ~70 Kriterium-Items (Capgemini-Kompetenzmodell)
|
||||||
4. **Criterion-Mappings** (Legacy, nicht mehr aktiv genutzt)
|
4. **Criterion-Mappings** (Legacy, nicht mehr aktiv genutzt)
|
||||||
|
|
||||||
|
|
@ -247,10 +296,9 @@ Die App legt beim ersten Start automatisch an:
|
||||||
|
|
||||||
| Bereich | Problem | Priorität |
|
| 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 (z.B. `client_ready`) statt sprechender Labels | Niedrig |
|
||||||
| `meetingExport.ts` | Bewertungs-Labels im Markdown-Export zeigen `FeedbackRating`-Keys statt Labels | Niedrig |
|
| KI-Generierung | `generateWithAi()` schlägt fehl (vermutlich Netzwerk/Firewall), Root Cause offen | Hoch |
|
||||||
| `FeedbackStructureConfig.tsx` | Enthält toten Code (Funktionen für gelöschte AssignmentType-CRUD) | Niedrig |
|
| Prompt-System | Aktueller Einzel-Prompt soll mehrstufig/pro Dimension konfigurierbar werden — eigene Design-Session nötig | Hoch (nicht gescoped) |
|
||||||
| AI-Prompt in `AssignmentFeedbackPage` | Nutzt `db.criteria` (alt), sollte `feedbackCriterionItems` nutzen | Mittel |
|
| Selbstlernendes Tagging | Idee, automatische Tag-Vorschläge aus bisherigen Zuordnungen abzuleiten — eigene Design-Session nötig | Mittel (nicht gescoped) |
|
||||||
| Dashboard | Zeigt keine Assignment-Nummer | Niedrig |
|
|
||||||
| `AssignmentCreate` | Kein Feld für Assignment-Nummer | Niedrig |
|
|
||||||
| IndexedDB | Keine robuste Persistenz-Garantie (Browser kann löschen) | Hoch → SQLite WASM geplant |
|
| 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 |
|
||||||
|
|
|
||||||
199
docs/preload/Feedback-Struktur-final.md
Normal file
199
docs/preload/Feedback-Struktur-final.md
Normal 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
23
docs/preload/README.md
Normal 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
2045
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
|
|
@ -1,33 +1,43 @@
|
||||||
{
|
{
|
||||||
"name": "assignment-monitor",
|
"name": "assignment-monitor-v2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
|
"dexie": "^4.0.11",
|
||||||
|
"express": "^4.21.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
"dexie": "^4.0.11",
|
|
||||||
"zustand": "^5.0.5",
|
|
||||||
"recharts": "^2.15.3"
|
"recharts": "^2.15.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
"@tailwindcss/vite": "^4.1.10",
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-pwa": "^1.0.0"
|
"vite-plugin-pwa": "^1.0.0"
|
||||||
|
|
|
||||||
74
server/database.ts
Normal file
74
server/database.ts
Normal 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
133
server/db/crud.ts
Normal 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
|
||||||
|
}
|
||||||
142
server/db/queries/appSettings.ts
Normal file
142
server/db/queries/appSettings.ts
Normal 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
|
||||||
|
}
|
||||||
12
server/db/queries/assessments.ts
Normal file
12
server/db/queries/assessments.ts
Normal 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)
|
||||||
63
server/db/queries/assignmentFeedback.ts
Normal file
63
server/db/queries/assignmentFeedback.ts
Normal 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)
|
||||||
38
server/db/queries/assignmentTypes.ts
Normal file
38
server/db/queries/assignmentTypes.ts
Normal 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)
|
||||||
|
}
|
||||||
96
server/db/queries/assignments.ts
Normal file
96
server/db/queries/assignments.ts
Normal 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'),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
45
server/db/queries/backup.ts
Normal file
45
server/db/queries/backup.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
63
server/db/queries/checklists.ts
Normal file
63
server/db/queries/checklists.ts
Normal 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)
|
||||||
|
}
|
||||||
12
server/db/queries/consultants.ts
Normal file
12
server/db/queries/consultants.ts
Normal 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)
|
||||||
19
server/db/queries/conversationEntries.ts
Normal file
19
server/db/queries/conversationEntries.ts
Normal 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)
|
||||||
28
server/db/queries/criterionSuggestions.ts
Normal file
28
server/db/queries/criterionSuggestions.ts
Normal 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
|
||||||
|
}
|
||||||
511
server/db/queries/feedbackStructure.ts
Normal file
511
server/db/queries/feedbackStructure.ts
Normal 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)
|
||||||
|
}
|
||||||
47
server/db/queries/meetingCategoryRatings.ts
Normal file
47
server/db/queries/meetingCategoryRatings.ts
Normal 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)
|
||||||
|
}
|
||||||
83
server/db/queries/meetings.ts
Normal file
83
server/db/queries/meetings.ts
Normal 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
207
server/db/tableRegistry.ts
Normal 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
38
server/index.ts
Normal 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
39
server/rpcModules.ts
Normal 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>
|
||||||
13
server/scripts/preloadFeedbackStructure.ts
Normal file
13
server/scripts/preloadFeedbackStructure.ts
Normal 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)`,
|
||||||
|
)
|
||||||
42
server/scripts/resetDevDatabase.ts
Normal file
42
server/scripts/resetDevDatabase.ts
Normal 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.`)
|
||||||
66
server/scripts/verifyBackupRoundtrip.ts
Normal file
66
server/scripts/verifyBackupRoundtrip.ts
Normal 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)
|
||||||
|
|
@ -6,7 +6,10 @@ import Consultants from './pages/Consultants'
|
||||||
import AssignmentCreate from './pages/AssignmentCreate'
|
import AssignmentCreate from './pages/AssignmentCreate'
|
||||||
import AssignmentDetail from './pages/AssignmentDetail'
|
import AssignmentDetail from './pages/AssignmentDetail'
|
||||||
import AssignmentEdit from './pages/AssignmentEdit'
|
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 FeedbackDraftPage from './pages/FeedbackDraftPage'
|
||||||
import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage'
|
import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage'
|
||||||
import Evaluation from './pages/Evaluation'
|
import Evaluation from './pages/Evaluation'
|
||||||
|
|
@ -21,8 +24,11 @@ export default function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/assignment/new" element={<AssignmentCreate />} />
|
<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" element={<AssignmentDetail />} />
|
||||||
<Route path="/assignment/:id/edit" element={<AssignmentEdit />} />
|
<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/meeting/:meetingId" element={<MeetingView />} />
|
||||||
<Route path="/assignment/:id/feedback/:instituteeId" element={<AssignmentFeedbackPage />} />
|
<Route path="/assignment/:id/feedback/:instituteeId" element={<AssignmentFeedbackPage />} />
|
||||||
<Route path="/assignment/:id/feedback-draft/:instituteeId" element={<FeedbackDraftPage />} />
|
<Route path="/assignment/:id/feedback-draft/:instituteeId" element={<FeedbackDraftPage />} />
|
||||||
|
|
|
||||||
85
src/components/ScorePicker.tsx
Normal file
85
src/components/ScorePicker.tsx
Normal 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' = 1–10-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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,101 @@
|
||||||
import type { FeedbackRating } from '../db'
|
import type { RatingBandKey, RatingColorBand, RatingScore } from '../db/types'
|
||||||
|
|
||||||
export const BRAND_COLOR = '#0070AD'
|
export const BRAND_COLOR = '#0070AD'
|
||||||
|
|
||||||
|
export const RATING_MIN = 1
|
||||||
|
export const RATING_MAX = 10
|
||||||
|
|
||||||
|
/** Kanonische Scores 1–10 — 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).
|
* Abwärtskompatible Options-Liste (short/label/full = Zahl als Text).
|
||||||
* `color` = Badge/Display-State (inaktiv)
|
* Farben kommen dynamisch über `resolveScoreStyle` / Farbbänder — `color`/`active` hier nur Fallback.
|
||||||
* `active` = ausgewählter Button-State
|
|
||||||
*/
|
*/
|
||||||
export const RATING_OPTIONS: ReadonlyArray<{
|
export const RATING_OPTIONS: ReadonlyArray<{
|
||||||
value: FeedbackRating
|
value: RatingScore
|
||||||
short: string
|
short: string
|
||||||
label: string
|
label: string
|
||||||
|
full: string
|
||||||
color: string
|
color: string
|
||||||
active: string
|
active: string
|
||||||
}> = [
|
}> = RATING_SCORES.map(n => ({
|
||||||
{ 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: n,
|
||||||
{ 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' },
|
short: String(n),
|
||||||
{ 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' },
|
label: String(n),
|
||||||
{ 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' },
|
full: String(n),
|
||||||
{ 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' },
|
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: 1–2 rot, 3–5 orange, 6–8 hellgrün, 9–10 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 1–10 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 1–10; 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). */
|
/** Farb-Schemata für Teilnehmer-Badges (Index = Reihenfolge im Assignment). */
|
||||||
export const INST_COLORS = [
|
export const INST_COLORS = [
|
||||||
{ bg: 'bg-blue-50', border: 'border-blue-300', tag: 'bg-blue-500', text: 'text-blue-700' },
|
{ 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',
|
localStorageKeyModel: 'ai_model',
|
||||||
defaultModel: 'openai/gpt-4o-mini',
|
defaultModel: 'openai/gpt-4o-mini',
|
||||||
openRouterBaseUrl: 'https://openrouter.ai/api/v1',
|
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: 1–10 (Trend-Vorschlag beachten — bestätigen oder maximal ±1 abweichen)
|
||||||
|
- BENEFITS: 1–3 kurze positive Beobachtungen (Stichpunkte mit "- ")
|
||||||
|
- CONCERNS: 1–3 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 (1–10)' },
|
||||||
|
{ 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
|
} as const
|
||||||
|
|
||||||
/** Numerische Gewichte für Rating-Aggregation. N/A wird aus Berechnungen ausgeschlossen. */
|
/** Default-Zeitgewichtung für den Bewertungs-Vorschlag, überschreibbar über Config > Gewichtung. */
|
||||||
export const RATING_NUM_MAP: Record<FeedbackRating, number> = {
|
export const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5
|
||||||
na: 0,
|
|
||||||
not_client_ready: 1,
|
/** Mindest-Punktedelta auf der 1–10-Skala zwischen erstem und letztem Meeting, ab dem ein Trend gilt. */
|
||||||
partially_client_ready: 2,
|
export const RATING_TREND_DELTA_THRESHOLD = 1
|
||||||
nearly_client_ready: 3,
|
|
||||||
client_ready: 4,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './types'
|
export * from './types'
|
||||||
export { db } from './schema'
|
export { db } from './schema'
|
||||||
|
export * from './queries'
|
||||||
export {
|
export {
|
||||||
seedIfEmpty,
|
seedIfEmpty,
|
||||||
seedFeedbackStructureIfEmpty,
|
seedFeedbackStructureIfEmpty,
|
||||||
|
|
|
||||||
29
src/db/queries/appSettings.ts
Normal file
29
src/db/queries/appSettings.ts
Normal 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)
|
||||||
13
src/db/queries/assessments.ts
Normal file
13
src/db/queries/assessments.ts
Normal 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)
|
||||||
59
src/db/queries/assignmentFeedback.ts
Normal file
59
src/db/queries/assignmentFeedback.ts
Normal 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)
|
||||||
23
src/db/queries/assignmentTypes.ts
Normal file
23
src/db/queries/assignmentTypes.ts
Normal 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)
|
||||||
29
src/db/queries/assignments.ts
Normal file
29
src/db/queries/assignments.ts
Normal 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')
|
||||||
27
src/db/queries/checklists.ts
Normal file
27
src/db/queries/checklists.ts
Normal 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)
|
||||||
12
src/db/queries/consultants.ts
Normal file
12
src/db/queries/consultants.ts
Normal 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)
|
||||||
21
src/db/queries/conversationEntries.ts
Normal file
21
src/db/queries/conversationEntries.ts
Normal 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)
|
||||||
6
src/db/queries/criterionSuggestions.ts
Normal file
6
src/db/queries/criterionSuggestions.ts
Normal 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')
|
||||||
154
src/db/queries/feedbackStructure.ts
Normal file
154
src/db/queries/feedbackStructure.ts
Normal 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
12
src/db/queries/index.ts
Normal 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'
|
||||||
16
src/db/queries/meetingCategoryRatings.ts
Normal file
16
src/db/queries/meetingCategoryRatings.ts
Normal 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)
|
||||||
43
src/db/queries/meetings.ts
Normal file
43
src/db/queries/meetings.ts
Normal 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
30
src/db/rpcClient.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
|
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
|
||||||
AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText,
|
AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText,
|
||||||
CriterionCategoryMapping, CriterionLevelDescription,
|
CriterionCategoryMapping, CriterionLevelDescription,
|
||||||
|
AppSettings,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
class AssignmentMonitorDB extends Dexie {
|
class AssignmentMonitorDB extends Dexie {
|
||||||
|
|
@ -32,6 +33,7 @@ class AssignmentMonitorDB extends Dexie {
|
||||||
feedbackDimensionTexts!: Table<FeedbackDimensionText>
|
feedbackDimensionTexts!: Table<FeedbackDimensionText>
|
||||||
criterionCategoryMappings!: Table<CriterionCategoryMapping>
|
criterionCategoryMappings!: Table<CriterionCategoryMapping>
|
||||||
criterionLevelDescriptions!: Table<CriterionLevelDescription>
|
criterionLevelDescriptions!: Table<CriterionLevelDescription>
|
||||||
|
appSettings!: Table<AppSettings>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('AssignmentMonitorDB')
|
super('AssignmentMonitorDB')
|
||||||
|
|
@ -129,6 +131,31 @@ class AssignmentMonitorDB extends Dexie {
|
||||||
meetings: null,
|
meetings: null,
|
||||||
consultantNotes: 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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export async function seedIfEmpty() {
|
||||||
isRepeatable: opts.repeatable ?? false,
|
isRepeatable: opts.repeatable ?? false,
|
||||||
hasConversation: opts.conv ?? false,
|
hasConversation: opts.conv ?? false,
|
||||||
hasAssessment: opts.assess ?? false,
|
hasAssessment: opts.assess ?? false,
|
||||||
|
hasChecklist: false,
|
||||||
criteriaIds: opts.criteriaIds ?? [],
|
criteriaIds: opts.criteriaIds ?? [],
|
||||||
order: opts.order,
|
order: opts.order,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
660
src/db/seeds/feedbackStructureFinal.json
Normal file
660
src/db/seeds/feedbackStructureFinal.json
Normal 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
132
src/db/types.ts
132
src/db/types.ts
|
|
@ -1,11 +1,16 @@
|
||||||
// ─── Bewertungs-Skala ────────────────────────────────────────────────────────
|
// ─── Bewertungs-Skala (1–10) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export type FeedbackRating =
|
/** Ganzzahlige Bewertung 1–10. Speicherung: INTEGER; „nicht gesetzt" = null (kein N/A-String). */
|
||||||
| 'na'
|
export type RatingScore = number
|
||||||
| 'not_client_ready'
|
|
||||||
| 'partially_client_ready'
|
/** Farbband für die 1–10-Skala — Grenzen/Keys editierbar in Config → Skala. */
|
||||||
| 'nearly_client_ready'
|
export type RatingBandKey = 'red' | 'orange' | 'lightGreen' | 'darkGreen'
|
||||||
| 'client_ready'
|
|
||||||
|
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) ───────────────────────
|
// ─── Legacy-Konfiguration (nur noch für Evaluation.tsx) ───────────────────────
|
||||||
|
|
||||||
|
|
@ -44,10 +49,45 @@ export interface PhaseTemplate {
|
||||||
isRepeatable: boolean
|
isRepeatable: boolean
|
||||||
hasConversation: boolean
|
hasConversation: boolean
|
||||||
hasAssessment: boolean
|
hasAssessment: boolean
|
||||||
|
/** Opt-in wie hasConversation/hasAssessment — schaltet den Checkliste-Tab in MeetingView frei. */
|
||||||
|
hasChecklist: boolean
|
||||||
criteriaIds: number[]
|
criteriaIds: number[]
|
||||||
order: 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 ───────────────────────────────────────────────────────────────
|
// ─── Stammdaten ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
|
|
@ -81,6 +121,10 @@ export interface Assignment {
|
||||||
from?: string
|
from?: string
|
||||||
to?: 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 ────────────────────────────────────────────────────────
|
// ─── Meeting-Instanzen ────────────────────────────────────────────────────────
|
||||||
|
|
@ -94,10 +138,17 @@ export interface MeetingInstance {
|
||||||
status: 'planned' | 'active' | 'done'
|
status: 'planned' | 'active' | 'done'
|
||||||
generalNotes: string
|
generalNotes: string
|
||||||
deletedAt?: 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 ─────────────────────────────────────────────────────
|
// ─── Gesprächs-Zeitleiste ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ConversationLineTag {
|
||||||
|
text: string
|
||||||
|
criterionItemId: number // → FeedbackCriterionItem.id
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConversationEntry {
|
export interface ConversationEntry {
|
||||||
id?: number
|
id?: number
|
||||||
meetingInstanceId: number
|
meetingInstanceId: number
|
||||||
|
|
@ -106,6 +157,7 @@ export interface ConversationEntry {
|
||||||
note: string
|
note: string
|
||||||
fillerCount: number
|
fillerCount: number
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
lineTags?: ConversationLineTag[] // Kriterium-Zuordnung pro Zeile, gematcht per exaktem Zeilentext
|
||||||
}
|
}
|
||||||
|
|
||||||
// criteriaId → FeedbackCategory.id (Schnellbewertung während Gesprächsbeitrag)
|
// criteriaId → FeedbackCategory.id (Schnellbewertung während Gesprächsbeitrag)
|
||||||
|
|
@ -113,22 +165,35 @@ export interface ConversationSkillScore {
|
||||||
id?: number
|
id?: number
|
||||||
conversationEntryId: number
|
conversationEntryId: number
|
||||||
criteriaId: number
|
criteriaId: number
|
||||||
score: FeedbackRating | null
|
score: RatingScore | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Gesamtbewertung ─────────────────────────────────────────────────────────
|
// ─── Gesamtbewertung ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// criteriaId → FeedbackCriterionItem.id (Gesamtbewertung am Meeting-Ende)
|
// criteriaId → FeedbackCriterionItem.id (optionale Detail-Bewertung / Beobachtungsnotiz am Meeting)
|
||||||
export interface Assessment {
|
export interface Assessment {
|
||||||
id?: number
|
id?: number
|
||||||
meetingInstanceId: number
|
meetingInstanceId: number
|
||||||
instituteeId: number
|
instituteeId: number
|
||||||
criteriaId: number
|
criteriaId: number
|
||||||
score: FeedbackRating | null
|
score: RatingScore | null
|
||||||
note: string
|
note: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pflicht-Meeting-Bewertung auf Kategorie-Ebene (1–10), 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) ────────────────────────────────────────────────
|
// ─── Feedback-Entwurf (Legacy) ────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface FeedbackDraft {
|
export interface FeedbackDraft {
|
||||||
|
|
@ -152,6 +217,8 @@ export interface FeedbackCategory {
|
||||||
dimensionId: number
|
dimensionId: number
|
||||||
name: string
|
name: string
|
||||||
order: number
|
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 {
|
export interface FeedbackCriterionItem {
|
||||||
|
|
@ -160,6 +227,10 @@ export interface FeedbackCriterionItem {
|
||||||
name: string
|
name: string
|
||||||
weight?: number
|
weight?: number
|
||||||
order: 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 {
|
export interface AssignmentFeedback {
|
||||||
|
|
@ -175,15 +246,37 @@ export interface FeedbackCategoryRating {
|
||||||
id?: number
|
id?: number
|
||||||
assignmentFeedbackId: number
|
assignmentFeedbackId: number
|
||||||
feedbackCategoryId: number
|
feedbackCategoryId: number
|
||||||
rating: FeedbackRating
|
rating: RatingScore
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackDimensionText {
|
export interface FeedbackDimensionText {
|
||||||
id?: number
|
id?: number
|
||||||
assignmentFeedbackId: number
|
assignmentFeedbackId: number
|
||||||
feedbackDimensionId: number
|
feedbackDimensionId: number
|
||||||
|
/** DB-Feldname historisch; UI/PDF: Benefits */
|
||||||
achievements: string
|
achievements: string
|
||||||
|
/** DB-Feldname historisch; UI/PDF: Concerns */
|
||||||
developmentNeeds: string
|
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 ──────────────────────────────────────────────────────────
|
// ─── Legacy-Mappings ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -197,6 +290,21 @@ export interface CriterionCategoryMapping {
|
||||||
export interface CriterionLevelDescription {
|
export interface CriterionLevelDescription {
|
||||||
id?: number
|
id?: number
|
||||||
criterionItemId: number
|
criterionItemId: number
|
||||||
rating: FeedbackRating
|
rating: RatingScore
|
||||||
text: string
|
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 1–10-Skala (Defaults in constants.ts), editierbar in Config → Skala. */
|
||||||
|
ratingColorBands?: RatingColorBand[]
|
||||||
|
}
|
||||||
|
|
|
||||||
17
src/hooks/useRatingColorBands.ts
Normal file
17
src/hooks/useRatingColorBands.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -3,13 +3,6 @@ import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { seedIfEmpty, seedFeedbackStructureIfEmpty, seedCriterionMappingsIfEmpty, migrateToUnifiedCriteriaIfNeeded } from './db/index.ts'
|
|
||||||
|
|
||||||
seedIfEmpty()
|
|
||||||
seedFeedbackStructureIfEmpty().then(() => {
|
|
||||||
seedCriterionMappingsIfEmpty()
|
|
||||||
migrateToUnifiedCriteriaIfNeeded()
|
|
||||||
})
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|
|
||||||
326
src/pages/AiPromptConfig.tsx
Normal file
326
src/pages/AiPromptConfig.tsx
Normal 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 (1–10) 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 & „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>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/pages/AssignmentArchive.tsx
Normal file
57
src/pages/AssignmentArchive.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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() {
|
export default function AssignmentCreate() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -12,6 +17,7 @@ export default function AssignmentCreate() {
|
||||||
title: '',
|
title: '',
|
||||||
client: '',
|
client: '',
|
||||||
leadName: '',
|
leadName: '',
|
||||||
|
assignmentNumber: '',
|
||||||
assignmentTypeId: 0,
|
assignmentTypeId: 0,
|
||||||
selectedGroupId: 0,
|
selectedGroupId: 0,
|
||||||
instituteeIds: [] as number[],
|
instituteeIds: [] as number[],
|
||||||
|
|
@ -23,9 +29,9 @@ export default function AssignmentCreate() {
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
const [t, g, c] = await Promise.all([
|
const [t, g, c] = await Promise.all([
|
||||||
db.assignmentTypes.toArray(),
|
listAssignmentTypes(),
|
||||||
db.groups.toArray(),
|
listGroups(),
|
||||||
db.consultants.toArray(),
|
listConsultants(),
|
||||||
])
|
])
|
||||||
setTypes(t)
|
setTypes(t)
|
||||||
setGroups(g)
|
setGroups(g)
|
||||||
|
|
@ -36,7 +42,7 @@ export default function AssignmentCreate() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!form.assignmentTypeId) { setPhases([]); return }
|
if (!form.assignmentTypeId) { setPhases([]); return }
|
||||||
db.phaseTemplates.where('assignmentTypeId').equals(form.assignmentTypeId).sortBy('order').then(setPhases)
|
listPhaseTemplatesForType(form.assignmentTypeId).then(setPhases)
|
||||||
}, [form.assignmentTypeId])
|
}, [form.assignmentTypeId])
|
||||||
|
|
||||||
const groupConsultants = consultants.filter(c => c.groupId === form.selectedGroupId)
|
const groupConsultants = consultants.filter(c => c.groupId === form.selectedGroupId)
|
||||||
|
|
@ -54,10 +60,10 @@ export default function AssignmentCreate() {
|
||||||
let groupId = form.selectedGroupId
|
let groupId = form.selectedGroupId
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
// Erstelle eine Standardgruppe falls keine vorhanden
|
// 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 }))
|
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()
|
await reload()
|
||||||
setForm(p => ({ ...p, instituteeIds: [...p.instituteeIds, id] }))
|
setForm(p => ({ ...p, instituteeIds: [...p.instituteeIds, id] }))
|
||||||
setNewFirst('')
|
setNewFirst('')
|
||||||
|
|
@ -66,11 +72,12 @@ export default function AssignmentCreate() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
if (!form.title.trim() || !form.client.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return
|
if (!form.title.trim() || !form.client.trim() || !form.assignmentNumber.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return
|
||||||
const id = await db.assignments.add({
|
const id = await addAssignment({
|
||||||
title: form.title,
|
title: form.title,
|
||||||
client: form.client,
|
client: form.client,
|
||||||
leadName: form.leadName,
|
leadName: form.leadName,
|
||||||
|
assignmentNumber: form.assignmentNumber.trim(),
|
||||||
assignmentTypeId: form.assignmentTypeId,
|
assignmentTypeId: form.assignmentTypeId,
|
||||||
instituteeIds: form.instituteeIds,
|
instituteeIds: form.instituteeIds,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|
@ -79,7 +86,7 @@ export default function AssignmentCreate() {
|
||||||
navigate(`/assignment/${id}`)
|
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!))
|
const selectedInstitutees = consultants.filter(c => form.instituteeIds.includes(c.id!))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -88,6 +95,9 @@ export default function AssignmentCreate() {
|
||||||
|
|
||||||
{/* Basisdaten */}
|
{/* Basisdaten */}
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
|
<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"
|
<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}
|
placeholder="Titel des Assignments" value={form.title}
|
||||||
onChange={e => setForm(p => ({ ...p, title: e.target.value }))} />
|
onChange={e => setForm(p => ({ ...p, title: e.target.value }))} />
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
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'
|
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
|
||||||
|
import { exportAssignmentReportPdf, exportInstituteeFeedbackPdf } from '../utils/pdfExport'
|
||||||
const RATING_BG: Record<FeedbackRating, string> = {
|
import { resolveScoreStyle } from '../config/constants'
|
||||||
na: 'bg-gray-400 text-white',
|
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
||||||
not_client_ready: 'bg-red-500 text-white',
|
import { useRatingColorBands } from '../hooks/useRatingColorBands'
|
||||||
partially_client_ready: 'bg-orange-500 text-white',
|
import type { MeetingCategoryRating } from '../db'
|
||||||
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'
|
|
||||||
|
|
||||||
interface PhaseRow {
|
interface PhaseRow {
|
||||||
template: PhaseTemplate
|
template: PhaseTemplate
|
||||||
|
|
@ -37,19 +40,26 @@ export default function AssignmentDetail() {
|
||||||
const [confirmSoftDelete, setConfirmSoftDelete] = useState<number | null>(null)
|
const [confirmSoftDelete, setConfirmSoftDelete] = useState<number | null>(null)
|
||||||
const [confirmHardDelete, setConfirmHardDelete] = useState<number | null>(null)
|
const [confirmHardDelete, setConfirmHardDelete] = useState<number | null>(null)
|
||||||
const [allAssessments, setAllAssessments] = useState<Assessment[]>([])
|
const [allAssessments, setAllAssessments] = useState<Assessment[]>([])
|
||||||
|
const [categoryRatings, setCategoryRatings] = useState<MeetingCategoryRating[]>([])
|
||||||
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
|
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
|
||||||
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
|
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 load = async () => {
|
||||||
const a = await db.assignments.get(assignmentId)
|
const a = await getAssignment(assignmentId)
|
||||||
if (!a) return
|
if (!a) return
|
||||||
setAssignment(a)
|
setAssignment(a)
|
||||||
|
setFeedbackComplete(a.status === 'done' ? await isAssignmentFeedbackComplete(assignmentId) : false)
|
||||||
|
|
||||||
const [aType, templates, instances, cons] = await Promise.all([
|
const [aType, templates, instances, cons] = await Promise.all([
|
||||||
db.assignmentTypes.get(a.assignmentTypeId),
|
getAssignmentType(a.assignmentTypeId),
|
||||||
db.phaseTemplates.where('assignmentTypeId').equals(a.assignmentTypeId).sortBy('order'),
|
listPhaseTemplatesForType(a.assignmentTypeId),
|
||||||
db.meetingInstances.where('assignmentId').equals(assignmentId).toArray(),
|
listMeetingInstancesForAssignment(assignmentId),
|
||||||
db.consultants.where('id').anyOf(a.instituteeIds).toArray(),
|
getConsultantsByIds(a.instituteeIds),
|
||||||
])
|
])
|
||||||
|
|
||||||
setAssignmentType(aType ?? null)
|
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),
|
instances: active.filter(i => i.phaseTemplateId === t.id).sort((a, b) => a.phaseIndex - b.phaseIndex),
|
||||||
})))
|
})))
|
||||||
const instIds = active.map(i => i.id!)
|
const instIds = active.map(i => i.id!)
|
||||||
const [asmts, allCats, allItems, aType2] = await Promise.all([
|
const [asmts, catRatings, structure, aType2] = await Promise.all([
|
||||||
instIds.length > 0 ? db.assessments.where('meetingInstanceId').anyOf(instIds).toArray() : Promise.resolve([]),
|
listAssessmentsForMeetings(instIds),
|
||||||
db.feedbackCategories.toArray(),
|
listMeetingCategoryRatingsForMeetings(instIds),
|
||||||
db.feedbackCriterionItems.toArray(),
|
loadFeedbackStructure(),
|
||||||
db.assignmentTypes.get(a.assignmentTypeId),
|
getAssignmentType(a.assignmentTypeId),
|
||||||
])
|
])
|
||||||
setAllAssessments(asmts)
|
setAllAssessments(asmts)
|
||||||
const itemFilter = aType2?.criteriaIds?.length ? new Set(aType2.criteriaIds) : null
|
setCategoryRatings(catRatings)
|
||||||
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
|
const visible = resolveVisibleStructure(structure.dimensions, structure.categories, structure.items, aType2?.criteriaIds)
|
||||||
setFbItems(filteredItems)
|
setFbItems(visible.items)
|
||||||
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId))
|
setFbCategories(visible.categories)
|
||||||
setFbCategories(itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [assignmentId])
|
useEffect(() => { load() }, [assignmentId])
|
||||||
|
|
||||||
const startMeeting = async (templateId: number, existingCount: number) => {
|
const startMeeting = async (templateId: number, existingCount: number) => {
|
||||||
const mid = await db.meetingInstances.add({
|
const mid = await addMeetingInstance({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
phaseTemplateId: templateId,
|
phaseTemplateId: templateId,
|
||||||
phaseIndex: existingCount + 1,
|
phaseIndex: existingCount + 1,
|
||||||
|
|
@ -113,28 +122,51 @@ export default function AssignmentDetail() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const softDeleteMeeting = async (instId: number) => {
|
const softDeleteMeeting = async (instId: number) => {
|
||||||
await db.meetingInstances.update(instId, { deletedAt: new Date().toISOString() })
|
await softDeleteMeetingInstance(instId)
|
||||||
setConfirmSoftDelete(null)
|
setConfirmSoftDelete(null)
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreMeeting = async (instId: number) => {
|
const restoreMeeting = async (instId: number) => {
|
||||||
await db.meetingInstances.update(instId, { deletedAt: undefined })
|
await restoreMeetingInstance(instId)
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const hardDeleteMeeting = async (instId: number) => {
|
const hardDeleteMeeting = async (instId: number) => {
|
||||||
await db.conversationSkillScores
|
await hardDeleteMeetingInstance(instId)
|
||||||
.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)
|
|
||||||
setConfirmHardDelete(null)
|
setConfirmHardDelete(null)
|
||||||
await load()
|
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 statusBadge = (status: MeetingInstance['status']) => {
|
||||||
const map = {
|
const map = {
|
||||||
planned: 'bg-yellow-100 text-yellow-700',
|
planned: 'bg-yellow-100 text-yellow-700',
|
||||||
|
|
@ -158,18 +190,78 @@ export default function AssignmentDetail() {
|
||||||
)}
|
)}
|
||||||
<h1 className="text-xl font-bold text-gray-800 leading-tight">{assignment.title}</h1>
|
<h1 className="text-xl font-bold text-gray-800 leading-tight">{assignment.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<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
|
<button
|
||||||
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
|
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">
|
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
|
✏️ Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-sm text-gray-500">{assignment.client} · {assignmentType?.name}</div>
|
<div className="text-sm text-gray-500">{assignment.client} · {assignmentType?.name}</div>
|
||||||
<div className="text-xs text-gray-400 mt-1">
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
{institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}
|
{institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Ad-hoc Meeting */}
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<span className="text-sm text-amber-800 font-medium">Feedback-Entwürfe</span>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{institutees.map(c => (
|
{institutees.map(c => (
|
||||||
<button key={c.id}
|
<div key={c.id} className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
onClick={() => navigate(`/assignment/${assignmentId}/feedback/${c.id}`)}
|
onClick={() => navigate(`/assignment/${assignmentId}/feedback/${c.id}`)}
|
||||||
className="text-xs bg-amber-600 text-white px-3 py-1 rounded-lg">
|
className="text-xs bg-amber-600 text-white px-3 py-1 rounded-lg">
|
||||||
{c.firstName}
|
{c.firstName}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -258,28 +359,24 @@ export default function AssignmentDetail() {
|
||||||
{institutees.map(c => {
|
{institutees.map(c => {
|
||||||
const chips = fbCategories.map(cat => {
|
const chips = fbCategories.map(cat => {
|
||||||
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
||||||
const scored = catItems.map(item => {
|
const mode = resolveCategoryChipScore({
|
||||||
const asmt = allAssessments.find(a =>
|
meetingId: inst.id!,
|
||||||
a.meetingInstanceId === inst.id &&
|
instituteeId: c.id!,
|
||||||
a.instituteeId === c.id &&
|
categoryId: cat.id!,
|
||||||
a.criteriaId === item.id &&
|
categoryRatings,
|
||||||
a.score !== null && a.score !== 'na'
|
assessments: allAssessments,
|
||||||
)
|
catItems,
|
||||||
return asmt ? { score: asmt.score as FeedbackRating, w: item.weight ?? 1 } : null
|
})
|
||||||
}).filter(Boolean) as { score: FeedbackRating; w: number }[]
|
return mode ? { cat, mode } : null
|
||||||
if (scored.length === 0) return null
|
}).filter(Boolean) as { cat: FeedbackCategory; mode: RatingScore }[]
|
||||||
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 }[]
|
|
||||||
|
|
||||||
if (chips.length === 0) return null
|
if (chips.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div key={c.id} className="mt-1">
|
<div key={c.id} className="mt-1">
|
||||||
<span className="text-xs text-gray-400 mr-1">{c.firstName}:</span>
|
<span className="text-xs text-gray-400 mr-1">{c.firstName}:</span>
|
||||||
{chips.map(({ cat, mode }) => (
|
{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]}`}>
|
<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}
|
{cat.name} {mode}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
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() {
|
export default function AssignmentEdit() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
|
@ -27,9 +30,9 @@ export default function AssignmentEdit() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
const [a, types, cons] = await Promise.all([
|
const [a, types, cons] = await Promise.all([
|
||||||
db.assignments.get(assignmentId),
|
getAssignment(assignmentId),
|
||||||
db.assignmentTypes.toArray(),
|
listAssignmentTypes(),
|
||||||
db.consultants.toArray(),
|
listConsultants(),
|
||||||
])
|
])
|
||||||
if (!a) return
|
if (!a) return
|
||||||
setAssignment(a)
|
setAssignment(a)
|
||||||
|
|
@ -50,7 +53,7 @@ export default function AssignmentEdit() {
|
||||||
}, [assignmentId])
|
}, [assignmentId])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await db.assignments.update(assignmentId, {
|
await updateAssignment(assignmentId, {
|
||||||
assignmentNumber: assignmentNumber.trim() || undefined,
|
assignmentNumber: assignmentNumber.trim() || undefined,
|
||||||
title,
|
title,
|
||||||
client,
|
client,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
db,
|
|
||||||
type Assignment,
|
type Assignment,
|
||||||
type Consultant,
|
type Consultant,
|
||||||
type FeedbackDimension,
|
type FeedbackDimension,
|
||||||
|
|
@ -10,119 +9,64 @@ import {
|
||||||
type AssignmentFeedback,
|
type AssignmentFeedback,
|
||||||
type FeedbackCategoryRating,
|
type FeedbackCategoryRating,
|
||||||
type FeedbackDimensionText,
|
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'
|
} 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 =>
|
const trendArrow = (trend: TrendResult['trend']) => {
|
||||||
r ? (RATING_NUM_MAP[r] ?? 0) : 0
|
if (trend === 'up') return <span title="Verbesserung über die Laufzeit">↑</span>
|
||||||
|
if (trend === 'down') return <span title="Verschlechterung über die Laufzeit">↓</span>
|
||||||
const ratingColor = (r: FeedbackRating | undefined) =>
|
return null
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveAiSettings(apiKey: string, model: string) {
|
/** Klein gehaltene KI-Vorschlag-Box mit Übernehmen/Verwerfen — gleiche Bedienlogik überall im Feedback. */
|
||||||
localStorage.setItem(AI_CONFIG.localStorageKeyApiKey, apiKey)
|
function AiSuggestionBox({ text, onAccept, onDiscard }: { text: string; onAccept: () => void; onDiscard: () => void }) {
|
||||||
localStorage.setItem(AI_CONFIG.localStorageKeyModel, model)
|
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>
|
||||||
// ── Prompt-Builder ────────────────────────────────────────────────────────────
|
<div className="text-sm text-blue-900 whitespace-pre-wrap">{text}</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
async function buildAiPrompt(
|
<button onClick={onAccept}
|
||||||
institutee: Consultant,
|
className="text-xs bg-blue-600 text-white px-2 py-1 rounded-lg font-medium hover:bg-blue-700">
|
||||||
assignment: Assignment,
|
Übernehmen
|
||||||
dimensions: FeedbackDimension[],
|
</button>
|
||||||
categories: FeedbackCategory[],
|
<button onClick={onDiscard}
|
||||||
_items: FeedbackCriterionItem[],
|
className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-lg font-medium hover:bg-gray-200">
|
||||||
ratings: FeedbackCategoryRating[],
|
Verwerfen
|
||||||
_feedbackId: number,
|
</button>
|
||||||
): Promise<string> {
|
</div>
|
||||||
const meetings = await db.meetingInstances.where('assignmentId').equals(assignment.id!).toArray()
|
</div>
|
||||||
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.`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Komponente ───────────────────────────────────────────────────────────────
|
// ─── Komponente ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -132,6 +76,7 @@ export default function AssignmentFeedbackPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const assignmentId = Number(id)
|
const assignmentId = Number(id)
|
||||||
const instId = Number(instituteeId)
|
const instId = Number(instituteeId)
|
||||||
|
const colorBands = useRatingColorBands()
|
||||||
|
|
||||||
const [assignment, setAssignment] = useState<Assignment | null>(null)
|
const [assignment, setAssignment] = useState<Assignment | null>(null)
|
||||||
const [institutee, setInstitutee] = useState<Consultant | 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 [feedback, setFeedback] = useState<AssignmentFeedback | null>(null)
|
||||||
const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([])
|
const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([])
|
||||||
const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([])
|
const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([])
|
||||||
|
const [categoryTexts, setCategoryTexts] = useState<FeedbackCategoryText[]>([])
|
||||||
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||||
|
const [catTrends, setCatTrends] = useState<Map<number, TrendResult>>(new Map())
|
||||||
const [showAiSettings, setShowAiSettings] = useState(false)
|
const [showAiSettings, setShowAiSettings] = useState(false)
|
||||||
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
|
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
|
||||||
const [aiModel, setAiModel] = useState(getAiSettings().model)
|
const [aiModel, setAiModel] = useState(getAiSettings().model)
|
||||||
const [generating, setGenerating] = useState(false)
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [generationProgress, setGenerationProgress] = useState('')
|
||||||
const [aiError, setAiError] = 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 saveTimer = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [a, cons, dims, cats, itms] = await Promise.all([
|
const [a, cons, structure] = await Promise.all([
|
||||||
db.assignments.get(assignmentId),
|
getAssignment(assignmentId),
|
||||||
db.consultants.get(instId),
|
getConsultant(instId),
|
||||||
db.feedbackDimensions.orderBy('order').toArray(),
|
loadFeedbackStructure(),
|
||||||
db.feedbackCategories.orderBy('order').toArray(),
|
|
||||||
db.feedbackCriterionItems.orderBy('order').toArray(),
|
|
||||||
])
|
])
|
||||||
setAssignment(a ?? null)
|
setAssignment(a ?? null)
|
||||||
setInstitutee(cons ?? null)
|
setInstitutee(cons ?? null)
|
||||||
setDimensions(dims)
|
|
||||||
setCategories(cats)
|
|
||||||
setItems(itms)
|
|
||||||
|
|
||||||
let fb = await db.assignmentFeedbacks
|
const aType = a ? await getAssignmentType(a.assignmentTypeId) : undefined
|
||||||
.where('assignmentId').equals(assignmentId)
|
const visible = resolveVisibleStructure(
|
||||||
.filter(f => f.instituteeId === instId)
|
structure.dimensions,
|
||||||
.first()
|
structure.categories,
|
||||||
if (!fb) {
|
structure.items,
|
||||||
const fbId = await db.assignmentFeedbacks.add({
|
aType?.criteriaIds,
|
||||||
assignmentId, instituteeId: instId,
|
)
|
||||||
status: 'draft', aiModel: '', updatedAt: new Date().toISOString(),
|
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,
|
||||||
})
|
})
|
||||||
fb = await db.assignmentFeedbacks.get(fbId)
|
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)
|
setFeedback(fb ?? null)
|
||||||
|
|
||||||
if (fb?.id) {
|
if (fb?.id) {
|
||||||
const [rs, txts] = await Promise.all([
|
const [rs, txts, catTxts] = await Promise.all([
|
||||||
db.feedbackCategoryRatings.where('assignmentFeedbackId').equals(fb.id).toArray(),
|
listFeedbackCategoryRatings(fb.id),
|
||||||
db.feedbackDimensionTexts.where('assignmentFeedbackId').equals(fb.id).toArray(),
|
listFeedbackDimensionTexts(fb.id),
|
||||||
|
listFeedbackCategoryTexts(fb.id),
|
||||||
])
|
])
|
||||||
setRatings(rs)
|
setRatings(rs)
|
||||||
setDimTexts(txts)
|
setDimTexts(txts)
|
||||||
|
setCategoryTexts(catTxts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle Dimensionen initial expandiert
|
setExpanded(new Set(visible.dimensions.map(d => d.id!)))
|
||||||
setExpanded(new Set(dims.map(d => d.id!)))
|
|
||||||
}, [assignmentId, instId])
|
}, [assignmentId, instId])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
// ── Rating setzen ─────────────────────────────────────────────────────────
|
// ── Rating setzen ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const setRating = async (categoryId: number, rating: FeedbackRating) => {
|
const setRating = async (categoryId: number, rating: RatingScore | null) => {
|
||||||
if (!feedback?.id) return
|
if (!feedback?.id) return
|
||||||
const existing = ratings.find(r => r.feedbackCategoryId === categoryId)
|
const existing = ratings.find(r => r.feedbackCategoryId === categoryId)
|
||||||
|
if (rating === null) {
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
await db.feedbackCategoryRatings.update(existing.id, { rating })
|
await deleteFeedbackCategoryRating(existing.id)
|
||||||
|
setRatings(prev => prev.filter(r => r.feedbackCategoryId !== categoryId))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (existing?.id) {
|
||||||
|
await updateFeedbackCategoryRating(existing.id, { rating })
|
||||||
setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r))
|
setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r))
|
||||||
} else {
|
} else {
|
||||||
const newId = await db.feedbackCategoryRatings.add({
|
const newId = await addFeedbackCategoryRating({
|
||||||
assignmentFeedbackId: feedback.id, feedbackCategoryId: categoryId, rating,
|
assignmentFeedbackId: feedback.id, feedbackCategoryId: categoryId, rating,
|
||||||
})
|
})
|
||||||
setRatings(prev => [...prev, { id: newId, assignmentFeedbackId: feedback.id!, feedbackCategoryId: categoryId, rating }])
|
setRatings(prev => [...prev, { id: newId, assignmentFeedbackId: feedback.id!, feedbackCategoryId: categoryId, rating }])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRating = (categoryId: number): FeedbackRating | undefined =>
|
const getRating = (categoryId: number): RatingScore | null =>
|
||||||
ratings.find(r => r.feedbackCategoryId === categoryId)?.rating
|
ratings.find(r => r.feedbackCategoryId === categoryId)?.rating ?? null
|
||||||
|
|
||||||
// ── Text speichern (debounced) ────────────────────────────────────────────
|
// ── Dimension-Text speichern (debounced) ──────────────────────────────────
|
||||||
|
|
||||||
const saveDimText = (dimId: number, field: 'achievements' | 'developmentNeeds', value: string) => {
|
const saveDimText = (dimId: number, field: 'achievements' | 'developmentNeeds', value: string) => {
|
||||||
setDimTexts(prev => {
|
setDimTexts(prev => {
|
||||||
|
|
@ -218,19 +206,16 @@ export default function AssignmentFeedbackPage() {
|
||||||
if (existing) return prev.map(t => t.feedbackDimensionId === dimId ? { ...t, [field]: value } : t)
|
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 }]
|
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)
|
const prev = saveTimer.current.get(key)
|
||||||
if (prev) clearTimeout(prev)
|
if (prev) clearTimeout(prev)
|
||||||
saveTimer.current.set(key, setTimeout(async () => {
|
saveTimer.current.set(key, setTimeout(async () => {
|
||||||
if (!feedback?.id) return
|
if (!feedback?.id) return
|
||||||
const existing = await db.feedbackDimensionTexts
|
const existing = await getFeedbackDimensionText(feedback.id, dimId)
|
||||||
.where('assignmentFeedbackId').equals(feedback.id)
|
|
||||||
.filter(t => t.feedbackDimensionId === dimId)
|
|
||||||
.first()
|
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
await db.feedbackDimensionTexts.update(existing.id, { [field]: value })
|
await updateFeedbackDimensionText(existing.id, { [field]: value })
|
||||||
} else {
|
} else {
|
||||||
await db.feedbackDimensionTexts.add({
|
await addFeedbackDimensionText({
|
||||||
assignmentFeedbackId: feedback.id,
|
assignmentFeedbackId: feedback.id,
|
||||||
feedbackDimensionId: dimId,
|
feedbackDimensionId: dimId,
|
||||||
achievements: field === 'achievements' ? value : '',
|
achievements: field === 'achievements' ? value : '',
|
||||||
|
|
@ -243,77 +228,333 @@ export default function AssignmentFeedbackPage() {
|
||||||
const getDimText = (dimId: number) =>
|
const getDimText = (dimId: number) =>
|
||||||
dimTexts.find(t => t.feedbackDimensionId === dimId)
|
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
|
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()
|
const settings = getAiSettings()
|
||||||
if (!settings.apiKey) { setShowAiSettings(true); return }
|
if (!settings.apiKey) { setShowAiSettings(true); return }
|
||||||
|
|
||||||
setGenerating(true)
|
setGenerating(true)
|
||||||
setAiError('')
|
setAiError('')
|
||||||
|
setDimensionErrors(prev => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const d of dimsToRun) delete next[d.id!]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setCategoryErrors({})
|
||||||
|
|
||||||
try {
|
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', {
|
for (let i = 0; i < dimsToRun.length; i++) {
|
||||||
method: 'POST',
|
const dim = dimsToRun[i]
|
||||||
headers: {
|
try {
|
||||||
'Authorization': `Bearer ${settings.apiKey}`,
|
await generateOneDimension(
|
||||||
'Content-Type': 'application/json',
|
dim, i, dimsToRun.length, meetings, instAssessments, instEntries, categoryTemplate, settings,
|
||||||
'HTTP-Referer': window.location.origin,
|
)
|
||||||
'X-Title': 'Assignment Monitor',
|
} catch (e) {
|
||||||
},
|
setDimensionErrors(prev => ({
|
||||||
body: JSON.stringify({
|
...prev,
|
||||||
model: settings.model,
|
[dim.id!]: e instanceof Error ? e.message : 'Unbekannter Fehler',
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false)
|
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 () => {
|
const toggleFinal = async () => {
|
||||||
if (!feedback?.id) return
|
if (!feedback?.id) return
|
||||||
const next = feedback.status === 'final' ? 'draft' : 'final'
|
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)
|
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>
|
if (!assignment || !institutee) return <div className="text-center py-10 text-gray-400">Lädt…</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -341,6 +582,10 @@ export default function AssignmentFeedbackPage() {
|
||||||
className="text-xs text-gray-400 hover:text-gray-600">
|
className="text-xs text-gray-400 hover:text-gray-600">
|
||||||
⚙ KI-Einstellungen
|
⚙ KI-Einstellungen
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -384,14 +629,14 @@ export default function AssignmentFeedbackPage() {
|
||||||
{/* KI-Generieren Button */}
|
{/* KI-Generieren Button */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={generateWithAi}
|
onClick={generateAllDimensions}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
className={`flex-1 py-3 rounded-xl text-sm font-medium transition ${
|
className={`flex-1 py-3 rounded-xl text-sm font-medium transition ${
|
||||||
generating
|
generating
|
||||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||||
: 'bg-brand text-white hover:bg-brand-dark'
|
: 'bg-brand text-white hover:bg-brand-dark'
|
||||||
}`}>
|
}`}>
|
||||||
{generating ? 'Generiere Entwurf…' : '✦ KI-Feedback-Entwurf generieren'}
|
{generating ? (generationProgress || 'Generiere…') : '✦ KI-Feedback-Entwurf generieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{aiError && (
|
{aiError && (
|
||||||
|
|
@ -399,6 +644,30 @@ export default function AssignmentFeedbackPage() {
|
||||||
{aiError}
|
{aiError}
|
||||||
</div>
|
</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 */}
|
{/* Dimensionen */}
|
||||||
{dimensions.map(dim => {
|
{dimensions.map(dim => {
|
||||||
|
|
@ -406,14 +675,9 @@ export default function AssignmentFeedbackPage() {
|
||||||
const isOpen = expanded.has(dim.id!)
|
const isOpen = expanded.has(dim.id!)
|
||||||
const text = getDimText(dim.id!)
|
const text = getDimText(dim.id!)
|
||||||
|
|
||||||
// Zusammenfassung der Bewertungen
|
const catRatings = dimCats.map(c => getRating(c.id!)).filter(isValidRatingScore)
|
||||||
const catRatings = dimCats.map(c => getRating(c.id!)).filter(Boolean) as FeedbackRating[]
|
const worstRating = catRatings.length > 0 ? Math.min(...catRatings) : null
|
||||||
const worstRating = catRatings.includes('not_client_ready') ? 'not_client_ready'
|
const summaryStyle = resolveScoreStyle(worstRating, colorBands)
|
||||||
: 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)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={dim.id} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
<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">
|
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-gray-800 text-sm">{dim.name}</span>
|
<span className="font-semibold text-gray-800 text-sm">{dim.name}</span>
|
||||||
{worstRating && (
|
{worstRating !== null && (
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${summaryColor}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium tabular-nums ${summaryStyle.color}`}>
|
||||||
{RATING_OPTIONS.find(r => r.value === worstRating)?.short}
|
{summaryStyle.short}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -439,28 +703,72 @@ export default function AssignmentFeedbackPage() {
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="border-t border-gray-100 p-4 space-y-4">
|
<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 */}
|
{/* Kategorien */}
|
||||||
{dimCats.map(cat => {
|
{dimCats.map(cat => {
|
||||||
const catItems = items.filter(i => i.categoryId === cat.id)
|
const catItems = items.filter(i => i.categoryId === cat.id)
|
||||||
const currentRating = getRating(cat.id!)
|
const currentRating = getRating(cat.id!)
|
||||||
|
const catText = getCategoryText(cat.id!)
|
||||||
return (
|
return (
|
||||||
<div key={cat.id} className="space-y-2">
|
<div key={cat.id} className="space-y-2">
|
||||||
<div className="font-medium text-sm text-gray-700">{cat.name}</div>
|
<div className="font-medium text-sm text-gray-700">{cat.name}</div>
|
||||||
|
|
||||||
{/* Rating-Buttons */}
|
<ScorePicker
|
||||||
<div className="flex gap-1 flex-wrap">
|
value={currentRating ?? null}
|
||||||
{RATING_OPTIONS.map(r => (
|
onChange={score => setRating(cat.id!, score)}
|
||||||
<button key={r.value}
|
bands={colorBands}
|
||||||
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'
|
const trend = catTrends.get(cat.id!)
|
||||||
: 'bg-gray-50 text-gray-400 border-gray-200 hover:bg-gray-100'
|
if (!trend || trend.suggestion === null) return null
|
||||||
}`}>
|
const suggestedRating = numberToRating(trend.suggestion)
|
||||||
{r.short}
|
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>
|
</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>
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Kriterium-Items als Referenz */}
|
{/* Kriterium-Items als Referenz */}
|
||||||
{catItems.length > 0 && (
|
{catItems.length > 0 && (
|
||||||
|
|
@ -473,33 +781,83 @@ export default function AssignmentFeedbackPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Textfelder */}
|
{/* Textfelder */}
|
||||||
<div className="space-y-3 pt-2 border-t border-gray-50">
|
<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">
|
<label className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-1.5">
|
||||||
Achievements
|
Benefits (Dimension)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<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]"
|
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="3–5 Hauptstärken dieser Dimension…"
|
||||||
value={text?.achievements ?? ''}
|
value={text?.achievements ?? ''}
|
||||||
onChange={e => saveDimText(dim.id!, 'achievements', e.target.value)}
|
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>
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5">
|
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5">
|
||||||
Development Needs
|
Concerns (Dimension)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<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]"
|
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="3–5 zentrale Entwicklungsfelder dieser Dimension…"
|
||||||
value={text?.developmentNeeds ?? ''}
|
value={text?.developmentNeeds ?? ''}
|
||||||
onChange={e => saveDimText(dim.id!, 'developmentNeeds', e.target.value)}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
97
src/pages/AssignmentTrash.tsx
Normal file
97
src/pages/AssignmentTrash.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
import { useEffect, useState } from 'react'
|
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() {
|
export default function AssignmentTypeConfig() {
|
||||||
|
const [exportingPdf, setExportingPdf] = useState(false)
|
||||||
const [types, setTypes] = useState<AssignmentType[]>([])
|
const [types, setTypes] = useState<AssignmentType[]>([])
|
||||||
const [templates, setTemplates] = useState<PhaseTemplate[]>([])
|
const [templates, setTemplates] = useState<PhaseTemplate[]>([])
|
||||||
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
||||||
|
|
@ -14,22 +25,27 @@ export default function AssignmentTypeConfig() {
|
||||||
const [typeError, setTypeError] = useState<Record<number, string>>({})
|
const [typeError, setTypeError] = useState<Record<number, string>>({})
|
||||||
const [newPhase, setNewPhase] = useState<Record<number, {
|
const [newPhase, setNewPhase] = useState<Record<number, {
|
||||||
label: string; withWhom: 'lead' | 'client'
|
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 load = async () => {
|
||||||
const [t, tmpl, dims, cats, itms] = await Promise.all([
|
const [t, tmpl, structure] = await Promise.all([
|
||||||
db.assignmentTypes.toArray(),
|
listAssignmentTypes(),
|
||||||
db.phaseTemplates.toArray(),
|
listPhaseTemplates(),
|
||||||
db.feedbackDimensions.orderBy('order').toArray(),
|
loadFeedbackStructure(),
|
||||||
db.feedbackCategories.orderBy('order').toArray(),
|
|
||||||
db.feedbackCriterionItems.orderBy('order').toArray(),
|
|
||||||
])
|
])
|
||||||
setTypes(t)
|
setTypes(t)
|
||||||
setTemplates(tmpl)
|
setTemplates(tmpl)
|
||||||
setDimensions(dims)
|
setDimensions(structure.dimensions)
|
||||||
setCategories(cats)
|
setCategories(structure.categories)
|
||||||
setItems(itms)
|
setItems(structure.items)
|
||||||
|
const checklistLists = await Promise.all(tmpl.map(p => listChecklistItemDefsForPhaseTemplate(p.id!)))
|
||||||
|
setChecklistItems(checklistLists.flat())
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
@ -39,25 +55,23 @@ export default function AssignmentTypeConfig() {
|
||||||
const addType = async () => {
|
const addType = async () => {
|
||||||
const name = newTypeName.trim()
|
const name = newTypeName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const id = await db.assignmentTypes.add({ name, defaultPhaseKeys: [], criteriaIds: [] })
|
const id = await addAssignmentType({ name, defaultPhaseKeys: [], criteriaIds: [] })
|
||||||
setNewTypeName('')
|
setNewTypeName('')
|
||||||
await load()
|
await load()
|
||||||
setExpanded(id)
|
setExpanded(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateTypeName = async (id: number, name: string) => {
|
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))
|
setTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t))
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteType = async (id: number) => {
|
const deleteType = async (id: number) => {
|
||||||
const count = await db.assignments.where('assignmentTypeId').equals(id).count()
|
const result = await deleteAssignmentType(id)
|
||||||
if (count > 0) {
|
if (!result.ok) {
|
||||||
setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden — ${count} Assignment(s) verwenden diesen Typ.` }))
|
setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden — ${result.assignmentCount} Assignment(s) verwenden diesen Typ.` }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await db.phaseTemplates.where('assignmentTypeId').equals(id).delete()
|
|
||||||
await db.assignmentTypes.delete(id)
|
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,28 +80,45 @@ export default function AssignmentTypeConfig() {
|
||||||
const toggleItem = async (type: AssignmentType, itemId: number) => {
|
const toggleItem = async (type: AssignmentType, itemId: number) => {
|
||||||
const current = type.criteriaIds ?? []
|
const current = type.criteriaIds ?? []
|
||||||
const next = current.includes(itemId) ? current.filter(id => id !== itemId) : [...current, itemId]
|
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))
|
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleAllInCategory = async (type: AssignmentType, catId: number) => {
|
const toggleAllInCategory = async (type: AssignmentType, catId: number) => {
|
||||||
const catItemIds = items.filter(i => i.categoryId === catId).map(i => i.id!)
|
const catItemIds = items.filter(i => i.categoryId === catId).map(i => i.id!)
|
||||||
const current = type.criteriaIds ?? []
|
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
|
const next = allSelected
|
||||||
? current.filter(id => !catItemIds.includes(id))
|
? current.filter(id => !catItemIds.includes(id))
|
||||||
: [...new Set([...current, ...catItemIds])]
|
: [...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))
|
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 ───────────────────────────────────────────────────────
|
// ── Phasen-Templates ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const addPhase = async (typeId: number) => {
|
const addPhase = async (typeId: number) => {
|
||||||
const np = newPhase[typeId]
|
const np = newPhase[typeId]
|
||||||
if (!np?.label.trim()) return
|
if (!np?.label.trim()) return
|
||||||
const existing = templates.filter(t => t.assignmentTypeId === typeId)
|
const existing = templates.filter(t => t.assignmentTypeId === typeId)
|
||||||
await db.phaseTemplates.add({
|
await addPhaseTemplate({
|
||||||
assignmentTypeId: typeId,
|
assignmentTypeId: typeId,
|
||||||
phaseKey: np.label.trim().toLowerCase().replace(/\s+/g, '_'),
|
phaseKey: np.label.trim().toLowerCase().replace(/\s+/g, '_'),
|
||||||
label: np.label.trim(),
|
label: np.label.trim(),
|
||||||
|
|
@ -95,25 +126,69 @@ export default function AssignmentTypeConfig() {
|
||||||
isRepeatable: np.isRepeatable,
|
isRepeatable: np.isRepeatable,
|
||||||
hasConversation: np.hasConversation,
|
hasConversation: np.hasConversation,
|
||||||
hasAssessment: np.hasAssessment,
|
hasAssessment: np.hasAssessment,
|
||||||
|
hasChecklist: np.hasChecklist,
|
||||||
criteriaIds: [],
|
criteriaIds: [],
|
||||||
order: existing.length + 1,
|
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()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePhase = async (id: number, patch: Partial<PhaseTemplate>) => {
|
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))
|
setTemplates(prev => prev.map(t => t.id === id ? { ...t, ...patch } : t))
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletePhase = async (id: number) => {
|
const deletePhase = async (id: number) => {
|
||||||
await db.phaseTemplates.delete(id)
|
await deletePhaseTemplate(id)
|
||||||
await load()
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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 */}
|
{/* Neuer Typ */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
|
|
@ -137,7 +212,7 @@ export default function AssignmentTypeConfig() {
|
||||||
const isOpen = expanded === type.id
|
const isOpen = expanded === type.id
|
||||||
const typPhases = templates.filter(t => t.assignmentTypeId === type.id).sort((a, b) => a.order - b.order)
|
const typPhases = templates.filter(t => t.assignmentTypeId === type.id).sort((a, b) => a.order - b.order)
|
||||||
const tab = activeTab[type.id!] ?? 'phases'
|
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 ?? []
|
const selected = type.criteriaIds ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -149,7 +224,7 @@ export default function AssignmentTypeConfig() {
|
||||||
value={type.name}
|
value={type.name}
|
||||||
onChange={e => updateTypeName(type.id!, e.target.value)}
|
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!)}
|
<button onClick={() => setExpanded(isOpen ? null : type.id!)}
|
||||||
className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
|
className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
|
||||||
<button onClick={() => deleteType(type.id!)}
|
<button onClick={() => deleteType(type.id!)}
|
||||||
|
|
@ -172,7 +247,7 @@ export default function AssignmentTypeConfig() {
|
||||||
className={`flex-1 py-2 text-xs font-medium transition ${
|
className={`flex-1 py-2 text-xs font-medium transition ${
|
||||||
tab === t ? 'text-brand border-b-2 border-brand' : 'text-gray-400'
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -215,7 +290,90 @@ export default function AssignmentTypeConfig() {
|
||||||
onChange={e => updatePhase(phase.id!, { hasAssessment: e.target.checked })} />
|
onChange={e => updatePhase(phase.id!, { hasAssessment: e.target.checked })} />
|
||||||
Bewertung
|
Bewertung
|
||||||
</label>
|
</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>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -254,6 +412,11 @@ export default function AssignmentTypeConfig() {
|
||||||
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, hasAssessment: e.target.checked } }))} />
|
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, hasAssessment: e.target.checked } }))} />
|
||||||
Bewertung
|
Bewertung
|
||||||
</label>
|
</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>
|
</div>
|
||||||
<button onClick={() => addPhase(type.id!)}
|
<button onClick={() => addPhase(type.id!)}
|
||||||
className="px-4 py-1.5 bg-brand text-white text-xs rounded-lg font-medium">
|
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 */}
|
{/* Kriterien-Tab */}
|
||||||
{tab === 'criteria' && (
|
{tab === 'criteria' && (
|
||||||
<div className="p-3 space-y-1">
|
<div className="p-3 space-y-1">
|
||||||
<p className="text-xs text-gray-400 pb-2">
|
<div className="flex items-start justify-between gap-2 pb-2">
|
||||||
Kein Haken = alle Kriterien werden angezeigt.
|
<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>
|
</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 && (
|
{items.length === 0 && (
|
||||||
<div className="text-xs text-gray-400">Keine Kriterien konfiguriert. Erst unter "Feedback" anlegen.</div>
|
<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
|
if (dimItems.length === 0) return null
|
||||||
const isDimOpen = expandedDim.has(dim.id!)
|
const isDimOpen = expandedDim.has(dim.id!)
|
||||||
const dimSelectedCount = dimItems.filter(i => selected.includes(i.id!)).length
|
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 (
|
return (
|
||||||
<div key={dim.id} className="border border-gray-100 rounded-lg overflow-hidden">
|
<div key={dim.id} className="border border-gray-100 rounded-lg overflow-hidden">
|
||||||
|
<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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setExpandedDim(prev => {
|
onClick={() => setExpandedDim(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
|
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
|
||||||
return next
|
return next
|
||||||
})}
|
})}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 bg-gray-50 hover:bg-gray-100 text-left">
|
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 font-semibold text-gray-700">{dim.name}</span>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{dimSelectedCount > 0 ? `${dimSelectedCount}/${dimItems.length}` : 'alle'} · {isDimOpen ? '▲' : '▼'}
|
{selected.length === 0 ? 'alle' : `${dimSelectedCount}/${dimItems.length}`} · {isDimOpen ? '▲' : '▼'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{isDimOpen && (
|
{isDimOpen && (
|
||||||
<div className="divide-y divide-gray-50">
|
<div className="divide-y divide-gray-50">
|
||||||
{dimCats.map(cat => {
|
{dimCats.map(cat => {
|
||||||
|
|
@ -310,6 +495,7 @@ export default function AssignmentTypeConfig() {
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-semibold text-gray-600">{cat.name}</span>
|
<span className="text-xs font-semibold text-gray-600">{cat.name}</span>
|
||||||
|
<span className="text-xs text-gray-300">{catItems.length} Krit.</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="pl-5 space-y-1">
|
<div className="pl-5 space-y-1">
|
||||||
{catItems.map(item => (
|
{catItems.map(item => (
|
||||||
|
|
@ -321,7 +507,7 @@ export default function AssignmentTypeConfig() {
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-600">{item.name}</span>
|
<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>
|
<span className="text-xs text-gray-300">×{item.weight}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import FeedbackStructureConfig from './FeedbackStructureConfig'
|
import FeedbackStructureConfig from './FeedbackStructureConfig'
|
||||||
import AssignmentTypeConfig from './AssignmentTypeConfig'
|
import AssignmentTypeConfig from './AssignmentTypeConfig'
|
||||||
|
import RatingWeightConfig from './RatingWeightConfig'
|
||||||
|
import RatingScaleConfig from './RatingScaleConfig'
|
||||||
|
import AiPromptConfig from './AiPromptConfig'
|
||||||
import { exportFullBackup, importFullBackup } from '../utils/dbBackup'
|
import { exportFullBackup, importFullBackup } from '../utils/dbBackup'
|
||||||
|
|
||||||
export default function Configuration() {
|
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 [backupStatus, setBackupStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
|
||||||
const [restoreConfirm, setRestoreConfirm] = useState(false)
|
const [restoreConfirm, setRestoreConfirm] = useState(false)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
|
|
@ -38,18 +41,29 @@ export default function Configuration() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-bold text-gray-800">Konfiguration</h1>
|
<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 flex-wrap">
|
||||||
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5">
|
|
||||||
<button onClick={() => setTab('feedback')}
|
<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
|
Feedback
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setTab('types')}
|
<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
|
Ass.-Typen
|
||||||
</button>
|
</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')}
|
<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
|
Backup
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -65,6 +79,9 @@ export default function Configuration() {
|
||||||
|
|
||||||
{tab === 'feedback' && <FeedbackStructureConfig />}
|
{tab === 'feedback' && <FeedbackStructureConfig />}
|
||||||
{tab === 'types' && <AssignmentTypeConfig />}
|
{tab === 'types' && <AssignmentTypeConfig />}
|
||||||
|
{tab === 'weighting' && <RatingWeightConfig />}
|
||||||
|
{tab === 'scale' && <RatingScaleConfig />}
|
||||||
|
{tab === 'aiPrompts' && <AiPromptConfig />}
|
||||||
|
|
||||||
{tab === 'backup' && (
|
{tab === 'backup' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,55 @@
|
||||||
import { useEffect, useState } from 'react'
|
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() {
|
export default function Consultants() {
|
||||||
const [groups, setGroups] = useState<Group[]>([])
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
const [consultants, setConsultants] = useState<Consultant[]>([])
|
const [consultants, setConsultants] = useState<Consultant[]>([])
|
||||||
const [newGroup, setNewGroup] = useState({ name: '', cohortYear: new Date().getFullYear() })
|
const [newGroup, setNewGroup] = useState({ name: '', cohortYear: new Date().getFullYear() })
|
||||||
const [newCons, setNewCons] = useState({ groupId: 0, firstName: '', lastName: '' })
|
const [newCons, setNewCons] = useState({ groupId: 0, firstName: '', lastName: '' })
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
|
const [editValues, setEditValues] = useState({ firstName: '', lastName: '' })
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setGroups(await db.groups.toArray())
|
setGroups(await listGroups())
|
||||||
setConsultants(await db.consultants.toArray())
|
setConsultants(await listConsultants())
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
const addGroup = async () => {
|
const addGroup = async () => {
|
||||||
if (!newGroup.name.trim()) return
|
if (!newGroup.name.trim()) return
|
||||||
await db.groups.add({ ...newGroup })
|
await addGroupQuery({ ...newGroup })
|
||||||
setNewGroup({ name: '', cohortYear: new Date().getFullYear() })
|
setNewGroup({ name: '', cohortYear: new Date().getFullYear() })
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addConsultant = async () => {
|
const addConsultant = async () => {
|
||||||
if (!newCons.firstName.trim() || !newCons.lastName.trim() || !newCons.groupId) return
|
if (!newCons.firstName.trim() || !newCons.lastName.trim() || !newCons.groupId) return
|
||||||
await db.consultants.add({ ...newCons })
|
await addConsultantQuery({ ...newCons })
|
||||||
setNewCons(p => ({ ...p, firstName: '', lastName: '' }))
|
setNewCons(p => ({ ...p, firstName: '', lastName: '' }))
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteConsultant = async (id: number) => {
|
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()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,10 +97,37 @@ export default function Consultants() {
|
||||||
<div key={g.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
<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>
|
<h3 className="font-semibold text-gray-700 mb-2">{g.name}</h3>
|
||||||
{members.map(c => (
|
{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">
|
<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>
|
<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>
|
<button onClick={() => deleteConsultant(c.id!)} className="text-red-400 text-xs hover:text-red-600">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,37 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { db, type Assignment, type AssignmentType, type Consultant } from '../db'
|
import { listAssignmentsWithDetails, softDeleteAssignment, archiveAssignment, type AssignmentWithDetails } from '../db'
|
||||||
|
|
||||||
interface AssignmentRow extends Assignment {
|
|
||||||
typeName: string
|
|
||||||
instituteeNames: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [assignments, setAssignments] = useState<AssignmentRow[]>([])
|
const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
|
||||||
const [query, setQuery] = useState('')
|
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()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
const load = () => { listAssignmentsWithDetails().then(setAssignments) }
|
||||||
const load = async () => {
|
useEffect(() => { load() }, [])
|
||||||
const raw = await db.assignments.orderBy('createdAt').reverse().toArray()
|
|
||||||
const types = await db.assignmentTypes.toArray()
|
const trashAssignment = async (id: number) => {
|
||||||
const consultants = await db.consultants.toArray()
|
await softDeleteAssignment(id)
|
||||||
const typeMap = new Map<number, AssignmentType>(types.map(t => [t.id!, t]))
|
setConfirmTrashId(null)
|
||||||
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}` : '?'
|
|
||||||
}),
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
load()
|
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 filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
return assignments.filter(a => {
|
return assignments.filter(a => {
|
||||||
|
if (a.deletedAt || a.archivedAt) return false
|
||||||
if (statusFilter !== 'all' && a.status !== statusFilter) return false
|
if (statusFilter !== 'all' && a.status !== statusFilter) return false
|
||||||
if (!q) return true
|
if (!q) return true
|
||||||
return (
|
return (
|
||||||
|
|
@ -51,6 +48,19 @@ export default function Dashboard() {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-gray-800">Assignments</h1>
|
<h1 className="text-xl font-bold text-gray-800">Assignments</h1>
|
||||||
|
<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
|
<button
|
||||||
onClick={() => navigate('/assignment/new')}
|
onClick={() => navigate('/assignment/new')}
|
||||||
className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium"
|
className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
|
|
@ -58,6 +68,7 @@ export default function Dashboard() {
|
||||||
+ Neues
|
+ Neues
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Suche + Status-Filter */}
|
{/* Suche + Status-Filter */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -112,6 +123,45 @@ export default function Dashboard() {
|
||||||
{a.status === 'active' ? 'Aktiv' : 'Fertig'}
|
{a.status === 'active' ? 'Aktiv' : 'Fertig'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,111 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 => {
|
interface CatScore {
|
||||||
const m: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
|
category: FeedbackCategory
|
||||||
return r ? (m[r] ?? 0) : 0
|
avg: number | null
|
||||||
|
trend: TrendResult['trend']
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InstScore {
|
interface InstScore {
|
||||||
institutee: Consultant
|
institutee: Consultant
|
||||||
assignment: Assignment
|
assignment: Assignment
|
||||||
scores: { criterion: Criterion; avg: number | null }[]
|
catScores: CatScore[]
|
||||||
weightedTotal: number | null
|
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() {
|
export default function Evaluation() {
|
||||||
const [data, setData] = useState<InstScore[]>([])
|
const [data, setData] = useState<InstScore[]>([])
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
||||||
|
const [categories, setCategories] = useState<FeedbackCategory[]>([])
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [assignments, consultants, criteria, cats, assessments] = await Promise.all([
|
const [allAssignments, consultants, structure, assessments, weightStep, feedbacks] = await Promise.all([
|
||||||
db.assignments.toArray(),
|
listAssignments(),
|
||||||
db.consultants.toArray(),
|
listConsultants(),
|
||||||
db.criteria.orderBy('order').toArray(),
|
loadFeedbackStructure(),
|
||||||
db.categories.orderBy('order').toArray(),
|
listAssessmentsAll(),
|
||||||
db.assessments.toArray(),
|
getRatingTrendWeightStep(),
|
||||||
|
listAssignmentFeedbacksAll(),
|
||||||
])
|
])
|
||||||
|
const { dimensions: dims, categories: cats, items } = structure
|
||||||
|
setDimensions(dims)
|
||||||
setCategories(cats)
|
setCategories(cats)
|
||||||
|
|
||||||
|
// Papierkorb/Archiv ausgeschlossen — analog zum Dashboard-Filter, siehe CLAUDE.md.
|
||||||
|
const assignments = allAssignments.filter(a => !a.deletedAt && !a.archivedAt)
|
||||||
|
|
||||||
const rows: InstScore[] = []
|
const rows: InstScore[] = []
|
||||||
for (const a of assignments) {
|
for (const a of assignments) {
|
||||||
|
const meetings = await listDoneMeetingsChronological(a.id!)
|
||||||
|
const meetingIds = meetings.map(m => m.id!)
|
||||||
|
|
||||||
for (const instId of a.instituteeIds) {
|
for (const instId of a.instituteeIds) {
|
||||||
const inst = consultants.find(c => c.id === instId)
|
const inst = consultants.find(c => c.id === instId)
|
||||||
if (!inst) continue
|
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(
|
const instAssessments = assessments.filter(
|
||||||
as => as.instituteeId === instId && meetingIds.includes(as.meetingInstanceId)
|
as => as.instituteeId === instId && meetingIds.includes(as.meetingInstanceId)
|
||||||
)
|
)
|
||||||
if (instAssessments.length === 0) continue
|
if (instAssessments.length === 0) continue
|
||||||
|
|
||||||
const usedCritIds = [...new Set(instAssessments.map(a => a.criteriaId))]
|
const getScore = (meetingId: number, itemId: number): RatingScore | null =>
|
||||||
const usedCrit = criteria.filter(c => usedCritIds.includes(c.id!))
|
instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null
|
||||||
|
|
||||||
const scores = usedCrit.map(crit => {
|
const catScores: CatScore[] = cats.map(cat => {
|
||||||
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null)
|
const history = buildGroupMeetingScores(items.filter(i => i.categoryId === cat.id), meetings, getScore)
|
||||||
const avg = relevant.length > 0
|
const { suggestion, trend } = computeTrend(history, weightStep)
|
||||||
? relevant.reduce((s, a) => s + ratingToNum(a.score), 0) / relevant.length
|
return { category: cat, avg: suggestion, trend }
|
||||||
: null
|
|
||||||
return { criterion: crit, avg }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const rated = scores.filter(s => s.avg !== null)
|
const overallHistory = buildGroupMeetingScores(items, meetings, getScore)
|
||||||
const weightedTotal = rated.length > 0
|
const { suggestion: overall, trend: overallTrend } = computeTrend(overallHistory, weightStep)
|
||||||
? rated.reduce((s, x) => s + x.avg! * x.criterion.weight, 0) /
|
if (overall === null && catScores.every(c => c.avg === null)) continue
|
||||||
rated.reduce((s, x) => s + x.criterion.weight, 0)
|
|
||||||
: null
|
|
||||||
|
|
||||||
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)
|
setData(rows)
|
||||||
|
|
@ -69,37 +113,48 @@ export default function Evaluation() {
|
||||||
load()
|
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 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<h1 className="text-xl font-bold text-gray-800">Auswertung</h1>
|
<h1 className="text-xl font-bold text-gray-800">Auswertung</h1>
|
||||||
{data.length === 0 && (
|
{data.length === 0 && (
|
||||||
<p className="text-gray-500 text-sm">Noch keine Bewertungen vorhanden.</p>
|
<p className="text-gray-500 text-sm">Noch keine Bewertungen vorhanden.</p>
|
||||||
)}
|
)}
|
||||||
{data.map(row => (
|
{[...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>
|
||||||
|
|
||||||
|
{rows.map(row => (
|
||||||
<div key={rowKey(row)} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
<div key={rowKey(row)} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
||||||
<div
|
<div
|
||||||
className="flex items-start justify-between cursor-pointer"
|
className="flex items-start justify-between cursor-pointer"
|
||||||
onClick={() => setSelected(selected === rowKey(row) ? null : rowKey(row))}
|
onClick={() => setSelected(selected === rowKey(row) ? null : rowKey(row))}>
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-gray-800">
|
<div className="font-semibold text-gray-800">
|
||||||
{row.institutee.firstName} {row.institutee.lastName}
|
{row.institutee.firstName} {row.institutee.lastName}
|
||||||
</div>
|
</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="flex items-center gap-2">
|
||||||
<div className={`px-3 py-1 rounded-full text-sm font-bold ${scoreColor(row.weightedTotal)}`}>
|
<div className={`px-3 py-1 rounded-full text-sm font-bold flex items-center gap-1 ${ratingColor(row.overall)}`}>
|
||||||
{row.weightedTotal !== null ? row.weightedTotal.toFixed(1) : '—'} / 5
|
{ratingLabel(row.overall)} {trendArrow(row.overallTrend)}
|
||||||
</div>
|
</div>
|
||||||
|
{feedbackStatusBadge(row.feedbackStatus)}
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }}
|
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">
|
className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-lg">
|
||||||
|
|
@ -109,18 +164,24 @@ export default function Evaluation() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected === rowKey(row) && (
|
{selected === rowKey(row) && (
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-4">
|
||||||
{categories.map(cat => {
|
{dimensions.map(dim => {
|
||||||
const catScores = row.scores.filter(s => s.criterion.categoryId === cat.id)
|
const dimCats = categories.filter(c => c.dimensionId === dim.id)
|
||||||
if (catScores.every(s => s.avg === null)) return null
|
const dimScores = row.catScores.filter(cs =>
|
||||||
|
dimCats.some(c => c.id === cs.category.id) && cs.avg !== null
|
||||||
|
)
|
||||||
|
if (dimScores.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div key={cat.id}>
|
<div key={dim.id}>
|
||||||
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">{cat.name}</div>
|
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">
|
||||||
{catScores.map(({ criterion, avg }) => (
|
{dim.name}
|
||||||
<div key={criterion.id} className="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0">
|
</div>
|
||||||
<span className="text-sm text-gray-700">{criterion.name}</span>
|
{dimScores.map(({ category, avg, trend }) => (
|
||||||
<span className={`text-sm font-semibold px-2 py-0.5 rounded min-w-[36px] text-center ${scoreColor(avg)}`}>
|
<div key={category.id}
|
||||||
{avg !== null ? avg.toFixed(1) : '—'}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -132,5 +193,7 @@ export default function Evaluation() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,35 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
db,
|
|
||||||
type Assignment,
|
type Assignment,
|
||||||
type Consultant,
|
type Consultant,
|
||||||
type Category,
|
type FeedbackCategory,
|
||||||
type Criterion,
|
type FeedbackCriterionItem,
|
||||||
type FeedbackDraft,
|
type FeedbackDraft,
|
||||||
type FeedbackRating,
|
getAssignment, getConsultant,
|
||||||
|
listFeedbackCategoriesOrdered, getFeedbackCriterionItemsByIds,
|
||||||
|
listMeetingInstancesForAssignment,
|
||||||
|
listAssessmentsForMeetings,
|
||||||
|
listConversationEntriesForMeetings,
|
||||||
|
getFeedbackDraft, addFeedbackDraft, updateFeedbackDraft,
|
||||||
} from '../db'
|
} 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 ratingLabel = (avg: number | null): string => {
|
||||||
const m: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
|
if (avg === null) return '—'
|
||||||
return r ? (m[r] ?? 0) : 0
|
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 {
|
interface CriterionSummary {
|
||||||
criterion: Criterion
|
criterion: FeedbackCriterionItem
|
||||||
avg: number | null
|
avg: number | null
|
||||||
notes: string[]
|
notes: string[]
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +42,7 @@ export default function FeedbackDraftPage() {
|
||||||
|
|
||||||
const [assignment, setAssignment] = useState<Assignment | null>(null)
|
const [assignment, setAssignment] = useState<Assignment | null>(null)
|
||||||
const [institutee, setInstitutee] = useState<Consultant | 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 [summary, setSummary] = useState<CriterionSummary[]>([])
|
||||||
const [draft, setDraft] = useState<FeedbackDraft | null>(null)
|
const [draft, setDraft] = useState<FeedbackDraft | null>(null)
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
|
|
@ -38,47 +51,50 @@ export default function FeedbackDraftPage() {
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [a, cons, cats] = await Promise.all([
|
const [a, cons, cats] = await Promise.all([
|
||||||
db.assignments.get(assignmentId),
|
getAssignment(assignmentId),
|
||||||
db.consultants.get(instId),
|
getConsultant(instId),
|
||||||
db.categories.orderBy('order').toArray(),
|
listFeedbackCategoriesOrdered(),
|
||||||
])
|
])
|
||||||
setAssignment(a ?? null)
|
setAssignment(a ?? null)
|
||||||
setInstitutee(cons ?? null)
|
setInstitutee(cons ?? null)
|
||||||
setCategories(cats)
|
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 meetingIds = meetings.map(m => m.id!)
|
||||||
|
|
||||||
const [allAssessments, allEntries] = await Promise.all([
|
const [allAssessments, allEntries] = await Promise.all([
|
||||||
db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray(),
|
listAssessmentsForMeetings(meetingIds),
|
||||||
db.conversationEntries.where('meetingInstanceId').anyOf(meetingIds).toArray(),
|
listConversationEntriesForMeetings(meetingIds),
|
||||||
])
|
])
|
||||||
|
|
||||||
const instAssessments = allAssessments.filter(a => a.instituteeId === instId)
|
const instAssessments = allAssessments.filter(a => a.instituteeId === instId)
|
||||||
const instEntries = allEntries.filter(e => e.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 criteriaIds = [...new Set([
|
||||||
const crits = criteriaIds.length > 0
|
...instAssessments.map(a => a.criteriaId),
|
||||||
? await db.criteria.where('id').anyOf(criteriaIds).toArray()
|
...allLineTags.map(t => t.criterionItemId),
|
||||||
: []
|
...generalNoteTags.map(t => t.criterionItemId),
|
||||||
|
])]
|
||||||
|
const crits = await getFeedbackCriterionItemsByIds(criteriaIds)
|
||||||
|
|
||||||
const sum: CriterionSummary[] = crits.map(crit => {
|
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
|
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
|
: null
|
||||||
const notes = [
|
const notes = [
|
||||||
...instAssessments.filter(a => a.criteriaId === crit.id && a.note).map(a => a.note),
|
...instAssessments.filter(a => a.criteriaId === crit.id && a.note).map(a => a.note),
|
||||||
...instEntries.filter(e => e.note).map(e => e.note),
|
...allLineTags.filter(t => t.criterionItemId === crit.id).map(t => t.text),
|
||||||
].filter(Boolean)
|
...generalNoteTags.filter(t => t.criterionItemId === crit.id).map(t => t.text),
|
||||||
|
]
|
||||||
return { criterion: crit, avg, notes }
|
return { criterion: crit, avg, notes }
|
||||||
})
|
})
|
||||||
setSummary(sum)
|
setSummary(sum)
|
||||||
|
|
||||||
const existing = await db.feedbackDrafts
|
const existing = await getFeedbackDraft(assignmentId, instId)
|
||||||
.where('assignmentId').equals(assignmentId)
|
|
||||||
.filter(d => d.instituteeId === instId)
|
|
||||||
.first()
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setDraft(existing)
|
setDraft(existing)
|
||||||
setContent(existing.content)
|
setContent(existing.content)
|
||||||
|
|
@ -104,8 +120,7 @@ export default function FeedbackDraftPage() {
|
||||||
for (const [catId, items] of grouped) {
|
for (const [catId, items] of grouped) {
|
||||||
text += `## ${catMap.get(catId) ?? 'Sonstige'}\n`
|
text += `## ${catMap.get(catId) ?? 'Sonstige'}\n`
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const avgStr = item.avg !== null ? item.avg.toFixed(1) : '—'
|
text += `${item.criterion.name}: ${ratingLabel(item.avg)}\n`
|
||||||
text += `${item.criterion.name}: ${avgStr}/5\n`
|
|
||||||
if (item.notes.length > 0) {
|
if (item.notes.length > 0) {
|
||||||
const uniqueNotes = [...new Set(item.notes)]
|
const uniqueNotes = [...new Set(item.notes)]
|
||||||
text += uniqueNotes.map(n => ` • ${n}`).join('\n') + '\n'
|
text += uniqueNotes.map(n => ` • ${n}`).join('\n') + '\n'
|
||||||
|
|
@ -120,10 +135,10 @@ export default function FeedbackDraftPage() {
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
if (draft?.id) {
|
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)
|
setDraft(prev => prev ? { ...prev, content: text, updatedAt: now } : null)
|
||||||
} else {
|
} else {
|
||||||
const newId = await db.feedbackDrafts.add({
|
const newId = await addFeedbackDraft({
|
||||||
assignmentId, instituteeId: instId, content: text, updatedAt: now,
|
assignmentId, instituteeId: instId, content: text, updatedAt: now,
|
||||||
})
|
})
|
||||||
setDraft({ id: newId, 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 () => {
|
saveTimer.current = setTimeout(async () => {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
if (draft?.id) {
|
if (draft?.id) {
|
||||||
await db.feedbackDrafts.update(draft.id, { content: val, updatedAt: now })
|
await updateFeedbackDraft(draft.id, { content: val, updatedAt: now })
|
||||||
} else {
|
} else {
|
||||||
const newId = await db.feedbackDrafts.add({
|
const newId = await addFeedbackDraft({
|
||||||
assignmentId, instituteeId: instId, content: val, updatedAt: now,
|
assignmentId, instituteeId: instId, content: val, updatedAt: now,
|
||||||
})
|
})
|
||||||
setDraft({ id: newId, 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)
|
}, 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>
|
if (!assignment || !institutee) return <div className="text-center py-10 text-gray-400">Lädt…</div>
|
||||||
|
|
||||||
return (
|
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">
|
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 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)}`}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,87 @@
|
||||||
import { useEffect, useState } from 'react'
|
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() {
|
export default function FeedbackStructureConfig() {
|
||||||
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
||||||
const [categories, setCategories] = useState<FeedbackCategory[]>([])
|
const [categories, setCategories] = useState<FeedbackCategory[]>([])
|
||||||
const [items, setItems] = useState<FeedbackCriterionItem[]>([])
|
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 [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||||
const [expandedCat, setExpandedCat] = useState<Set<number>>(new Set())
|
const [expandedCat, setExpandedCat] = useState<Set<number>>(new Set())
|
||||||
const [showCatalogs, setShowCatalogs] = useState(false)
|
const [expandedUsage, setExpandedUsage] = useState<Set<number>>(new Set())
|
||||||
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>>({})
|
|
||||||
|
|
||||||
// Neue Einträge
|
// Neue Einträge
|
||||||
const [newDimName, setNewDimName] = useState('')
|
const [newDimName, setNewDimName] = useState('')
|
||||||
const [newCatName, setNewCatName] = useState<Record<number, string>>({})
|
const [newCatName, setNewCatName] = useState<Record<number, string>>({})
|
||||||
const [newItemName, setNewItemName] = 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 load = async () => {
|
||||||
const [dims, cats, itms, types, phases] = await Promise.all([
|
const [structure, usage] = await Promise.all([loadFeedbackStructure(), getCriterionUsageMap()])
|
||||||
db.feedbackDimensions.orderBy('order').toArray(),
|
setDimensions(structure.dimensions)
|
||||||
db.feedbackCategories.orderBy('order').toArray(),
|
setCategories(structure.categories)
|
||||||
db.feedbackCriterionItems.orderBy('order').toArray(),
|
setItems(structure.items)
|
||||||
db.assignmentTypes.toArray(),
|
setUsageMap(usage)
|
||||||
db.phaseTemplates.toArray(),
|
|
||||||
])
|
|
||||||
setDimensions(dims)
|
|
||||||
setCategories(cats)
|
|
||||||
setItems(itms)
|
|
||||||
setAssignmentTypes(types)
|
|
||||||
setPhaseTemplates(phases)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
@ -43,24 +91,29 @@ export default function FeedbackStructureConfig() {
|
||||||
const addDimension = async () => {
|
const addDimension = async () => {
|
||||||
if (!newDimName.trim()) return
|
if (!newDimName.trim()) return
|
||||||
const maxOrder = dimensions.reduce((m, d) => Math.max(m, d.order), 0)
|
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('')
|
setNewDimName('')
|
||||||
await load()
|
await load()
|
||||||
setExpanded(prev => new Set([...prev, id]))
|
setExpanded(prev => new Set([...prev, id]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateDimension = async (id: number, name: string) => {
|
const updateDimension = (id: number, name: string) => {
|
||||||
await db.feedbackDimensions.update(id, { name })
|
|
||||||
setDimensions(prev => prev.map(d => d.id === id ? { ...d, name } : d))
|
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 deleteDimension = async (id: number) => {
|
||||||
const cats = await db.feedbackCategories.where('dimensionId').equals(id).toArray()
|
await deleteFeedbackDimension(id)
|
||||||
for (const cat of cats) {
|
setConfirmDeleteDimId(null)
|
||||||
await db.feedbackCriterionItems.where('categoryId').equals(cat.id!).delete()
|
|
||||||
}
|
|
||||||
await db.feedbackCategories.where('dimensionId').equals(id).delete()
|
|
||||||
await db.feedbackDimensions.delete(id)
|
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,8 +122,8 @@ export default function FeedbackStructureConfig() {
|
||||||
const idx = sorted.findIndex(d => d.id === dim.id)
|
const idx = sorted.findIndex(d => d.id === dim.id)
|
||||||
const other = sorted[idx + dir]
|
const other = sorted[idx + dir]
|
||||||
if (!other) return
|
if (!other) return
|
||||||
await db.feedbackDimensions.update(dim.id!, { order: other.order })
|
await updateFeedbackDimension(dim.id!, { order: other.order })
|
||||||
await db.feedbackDimensions.update(other.id!, { order: dim.order })
|
await updateFeedbackDimension(other.id!, { order: dim.order })
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,20 +134,33 @@ export default function FeedbackStructureConfig() {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const dimCats = categories.filter(c => c.dimensionId === dimId)
|
const dimCats = categories.filter(c => c.dimensionId === dimId)
|
||||||
const maxOrder = dimCats.reduce((m, c) => Math.max(m, c.order), 0)
|
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]: '' }))
|
setNewCatName(prev => ({ ...prev, [dimId]: '' }))
|
||||||
await load()
|
await load()
|
||||||
setExpandedCat(prev => new Set([...prev, id]))
|
setExpandedCat(prev => new Set([...prev, id]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateCategory = async (id: number, name: string) => {
|
const updateCategory = (id: number, name: string) => {
|
||||||
await db.feedbackCategories.update(id, { name })
|
|
||||||
setCategories(prev => prev.map(c => c.id === id ? { ...c, name } : c))
|
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) => {
|
const deleteCategory = async (id: number) => {
|
||||||
await db.feedbackCriterionItems.where('categoryId').equals(id).delete()
|
await deleteFeedbackCategory(id)
|
||||||
await db.feedbackCategories.delete(id)
|
setConfirmDeleteCatId(null)
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,95 +171,225 @@ export default function FeedbackStructureConfig() {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const catItems = items.filter(i => i.categoryId === catId)
|
const catItems = items.filter(i => i.categoryId === catId)
|
||||||
const maxOrder = catItems.reduce((m, i) => Math.max(m, i.order), 0)
|
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]: '' }))
|
setNewItemName(prev => ({ ...prev, [catId]: '' }))
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItem = async (id: number, name: string) => {
|
const updateItem = (id: number, name: string) => {
|
||||||
await db.feedbackCriterionItems.update(id, { name })
|
|
||||||
setItems(prev => prev.map(i => i.id === id ? { ...i, name } : i))
|
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) => {
|
const deleteItem = async (id: number) => {
|
||||||
await db.feedbackCriterionItems.delete(id)
|
await deleteFeedbackCriterionItem(id)
|
||||||
setItems(prev => prev.filter(i => i.id !== id))
|
setItems(prev => prev.filter(i => i.id !== id))
|
||||||
|
setConfirmDeleteItemId(null)
|
||||||
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItemWeight = async (id: number, weight: number) => {
|
const reassignAndDeleteItem = async (fromId: number) => {
|
||||||
await db.feedbackCriterionItems.update(id, { weight })
|
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))
|
setItems(prev => prev.map(i => i.id === id ? { ...i, weight } : i))
|
||||||
|
updateFeedbackCriterionItem(id, { weight })
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleCatalogCategory = async (type: AssignmentType, catId: number) => {
|
const updateItemDescription = (id: number, description: string) => {
|
||||||
const current = type.criteriaIds ?? []
|
setItems(prev => prev.map(i => i.id === id ? { ...i, description } : i))
|
||||||
const next = current.includes(catId) ? current.filter(id => id !== catId) : [...current, catId]
|
updateFeedbackCriterionItem(id, { description })
|
||||||
await db.assignmentTypes.update(type.id!, { criteriaIds: next })
|
|
||||||
setAssignmentTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AssignmentType CRUD ───────────────────────────────────────────────────
|
const moveItemToCategory = (itemId: number, newCategoryId: number) => {
|
||||||
|
setItems(prev => prev.map(i => i.id === itemId ? { ...i, categoryId: newCategoryId } : i))
|
||||||
const addAssignmentType = async () => {
|
updateFeedbackCriterionItem(itemId, { categoryId: newCategoryId })
|
||||||
if (!newTypeName.trim()) return
|
|
||||||
await db.assignmentTypes.add({ name: newTypeName.trim(), defaultPhaseKeys: [] })
|
|
||||||
setNewTypeName('')
|
|
||||||
await load()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateAssignmentTypeName = async (id: number, name: string) => {
|
const toggleUsageExpanded = async (itemId: number) => {
|
||||||
await db.assignmentTypes.update(id, { name })
|
const willOpen = !expandedUsage.has(itemId)
|
||||||
setAssignmentTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t))
|
setExpandedUsage(prev => {
|
||||||
}
|
const next = new Set(prev)
|
||||||
|
if (next.has(itemId)) next.delete(itemId); else next.add(itemId)
|
||||||
const deleteAssignmentType = async (id: number) => {
|
return next
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
setNewPhase(prev => ({ ...prev, [typeId]: { label: '', withWhom: 'lead', isRepeatable: false, hasConversation: false, hasAssessment: false } }))
|
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 refreshUsageAfterInstanceChange = async (itemId: number) => {
|
||||||
|
const [details, usage] = await Promise.all([getCriterionUsageDetails(itemId), getCriterionUsageMap()])
|
||||||
|
setUsageDetails(prev => ({ ...prev, [itemId]: details }))
|
||||||
|
setUsageMap(usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setImportNotice(`Import fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
} finally {
|
||||||
|
setImportingStructure(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePhaseTemplate = async (id: number, fields: Partial<PhaseTemplate>) => {
|
const importMarkdownFile = async (file: File) => {
|
||||||
await db.phaseTemplates.update(id, fields)
|
setImportingStructure(true)
|
||||||
setPhaseTemplates(prev => prev.map(t => t.id === id ? { ...t, ...fields } : t))
|
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 deletePhaseTemplate = async (id: number) => {
|
|
||||||
await db.phaseTemplates.delete(id)
|
|
||||||
setPhaseTemplates(prev => prev.filter(t => t.id !== id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
|
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<h2 className="text-lg font-bold text-gray-800">Feedback-Dimensionen & Kategorien</h2>
|
<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 */}
|
{/* Neue Dimension */}
|
||||||
<div className="flex gap-2">
|
<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!)
|
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
|
||||||
return next
|
return next
|
||||||
})} className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
|
})} 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>
|
className="text-xs text-red-300 hover:text-red-500 px-1">🗑</button>
|
||||||
</div>
|
</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 && (
|
{isOpen && (
|
||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
{dimCats.map(cat => {
|
{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!)
|
if (next.has(cat.id!)) next.delete(cat.id!); else next.add(cat.id!)
|
||||||
return next
|
return next
|
||||||
})} className="text-xs text-gray-400">{isCatOpen ? '▲' : '▼'}</button>
|
})} 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>
|
className="text-xs text-red-300 hover:text-red-500">🗑</button>
|
||||||
</div>
|
</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 && (
|
{isCatOpen && (
|
||||||
<div className="p-2 space-y-1.5">
|
<div className="p-2 space-y-1.5">
|
||||||
{catItems.map(item => (
|
{catItems.map(item => {
|
||||||
<div key={item.id} className="flex items-center gap-2">
|
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>
|
<span className="text-gray-300 text-xs">·</span>
|
||||||
<input
|
<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"
|
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}
|
value={item.name}
|
||||||
onChange={e => updateItem(item.id!, e.target.value)}
|
onChange={e => updateItem(item.id!, e.target.value)}
|
||||||
/>
|
/>
|
||||||
<select
|
<input
|
||||||
|
type="number" min={0} max={2} step={0.1}
|
||||||
value={item.weight ?? 1}
|
value={item.weight ?? 1}
|
||||||
onChange={e => updateItemWeight(item.id!, Number(e.target.value))}
|
onChange={e => updateItemWeight(item.id!, Number(e.target.value))}
|
||||||
title="Gewichtung"
|
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">
|
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"
|
||||||
{[1,2,3,4,5].map(w => <option key={w} value={w}>×{w}</option>)}
|
/>
|
||||||
|
<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>
|
</select>
|
||||||
<button onClick={() => deleteItem(item.id!)}
|
<button onClick={() => requestDeleteItem(item.id!)}
|
||||||
className="text-xs text-red-200 hover:text-red-400">×</button>
|
className="text-xs text-red-200 hover:text-red-400">×</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<input
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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">
|
<div className="flex gap-1.5 mt-1">
|
||||||
<input
|
<input
|
||||||
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-brand"
|
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-brand"
|
||||||
|
|
|
||||||
266
src/pages/MeetingHistory.tsx
Normal file
266
src/pages/MeetingHistory.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
144
src/pages/RatingScaleConfig.tsx
Normal file
144
src/pages/RatingScaleConfig.tsx
Normal 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 1–10 · 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/pages/RatingWeightConfig.tsx
Normal file
71
src/pages/RatingWeightConfig.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
197
src/pages/meeting/AssessmentTab.tsx
Normal file
197
src/pages/meeting/AssessmentTab.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/pages/meeting/ChecklistTab.tsx
Normal file
157
src/pages/meeting/ChecklistTab.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
335
src/pages/meeting/ConversationTab.tsx
Normal file
335
src/pages/meeting/ConversationTab.tsx
Normal 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… (1–10 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
src/pages/meeting/CriterionTagPicker.tsx
Normal file
111
src/pages/meeting/CriterionTagPicker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
898
src/pages/meeting/MeetingView.tsx
Normal file
898
src/pages/meeting/MeetingView.tsx
Normal 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
44
src/utils/aiClient.ts
Normal 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
604
src/utils/aiPrompt.ts
Normal 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 1–10 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 }
|
||||||
|
}
|
||||||
27
src/utils/criterionOptions.tsx
Normal file
27
src/utils/criterionOptions.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
61
src/utils/criterionSuggestion.ts
Normal file
61
src/utils/criterionSuggestion.ts
Normal 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 1–10 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)
|
||||||
|
}
|
||||||
160
src/utils/criterionSuggestionPrompt.ts
Normal file
160
src/utils/criterionSuggestionPrompt.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -1,29 +1,6 @@
|
||||||
import { db } from '../db'
|
import { rpc } from '../db/rpcClient'
|
||||||
import { downloadFile } from './meetingExport'
|
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 {
|
export interface DbBackup {
|
||||||
version: number
|
version: number
|
||||||
exportedAt: string
|
exportedAt: string
|
||||||
|
|
@ -31,16 +8,7 @@ export interface DbBackup {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportFullBackup(): Promise<void> {
|
export async function exportFullBackup(): Promise<void> {
|
||||||
const tables: Record<string, unknown[]> = {}
|
const backup = await rpc<DbBackup>('backup', 'exportAll')
|
||||||
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 date = new Date().toLocaleDateString('de-DE').replace(/\./g, '-')
|
const date = new Date().toLocaleDateString('de-DE').replace(/\./g, '-')
|
||||||
downloadFile(JSON.stringify(backup, null, 2), `assignment-monitor-backup-${date}.json`, 'application/json')
|
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' }
|
return { ok: false, error: 'Ungültiges Backup-Format' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all tables and reimport in dependency order
|
await rpc('backup', 'importAll', backup.tables)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||||
|
|
|
||||||
99
src/utils/feedbackStructureImport.ts
Normal file
99
src/utils/feedbackStructureImport.ts
Normal 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 }
|
||||||
|
}
|
||||||
36
src/utils/meetingCategoryScore.ts
Normal file
36
src/utils/meetingCategoryScore.ts
Normal 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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> {
|
export async function exportMeetingAsMarkdown(meetingId: number): Promise<string> {
|
||||||
const meeting = await db.meetingInstances.get(meetingId)
|
const {
|
||||||
if (!meeting) throw new Error('Meeting nicht gefunden')
|
meeting, template, assignment, consultants, entries, allScores,
|
||||||
|
assessments, categoryRatings, checklistItems, checklistResponses,
|
||||||
const [template, assignment] = await Promise.all([
|
} = await getMeetingExportData(meetingId)
|
||||||
db.phaseTemplates.get(meeting.phaseTemplateId),
|
|
||||||
db.assignments.get(meeting.assignmentId),
|
|
||||||
])
|
|
||||||
|
|
||||||
const instituteeIds = assignment?.instituteeIds ?? []
|
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 = [
|
const critIds = [
|
||||||
...new Set([
|
...new Set([
|
||||||
...(template?.criteriaIds ?? []),
|
...(template?.criteriaIds ?? []),
|
||||||
...assessments.map(a => a.criteriaId),
|
...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
|
const criteria = await getFeedbackCriterionItemsByIds(critIds)
|
||||||
? await db.criteria.where('id').anyOf(critIds).toArray()
|
const categories = await listFeedbackCategoriesAll()
|
||||||
: []
|
|
||||||
|
|
||||||
const institutees = instituteeIds
|
const institutees = instituteeIds
|
||||||
.map(id => consultants.find(c => c.id === id))
|
.map(id => consultants.find(c => c.id === id))
|
||||||
|
|
@ -48,7 +40,11 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
|
|
||||||
if (meeting.generalNotes?.trim()) {
|
if (meeting.generalNotes?.trim()) {
|
||||||
lines.push('## Allgemeine Notizen')
|
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('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +59,9 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
|
|
||||||
if (entry.note?.trim()) {
|
if (entry.note?.trim()) {
|
||||||
for (const noteLine of entry.note.trim().split('\n')) {
|
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) {
|
if (assessments.length > 0) {
|
||||||
lines.push('## Gesamtbewertung')
|
lines.push('## Kriterien-Beobachtungen')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
for (const inst of institutees) {
|
for (const inst of institutees) {
|
||||||
|
|
@ -100,7 +142,7 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
for (const a of instAssessments) {
|
for (const a of instAssessments) {
|
||||||
const crit = criteria.find(c => c.id === a.criteriaId)
|
const crit = criteria.find(c => c.id === a.criteriaId)
|
||||||
if (!crit) continue
|
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()}` : ''
|
const noteStr = a.note?.trim() ? ` — ${a.note.trim()}` : ''
|
||||||
lines.push(`- **${crit.name}:** ${scoreStr}${noteStr}`)
|
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> {
|
export async function exportMeetingAsJson(meetingId: number): Promise<string> {
|
||||||
const meeting = await db.meetingInstances.get(meetingId)
|
const {
|
||||||
if (!meeting) throw new Error('Meeting nicht gefunden')
|
meeting, template, assignment, consultants, entries, allScores,
|
||||||
|
assessments, categoryRatings, checklistItems, checklistResponses,
|
||||||
const [template, assignment] = await Promise.all([
|
} = await getMeetingExportData(meetingId)
|
||||||
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 entryIds = entries.map(e => e.id!)
|
const entryIds = entries.map(e => e.id!)
|
||||||
const entryScores = allScores.filter(s => entryIds.includes(s.conversationEntryId))
|
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),
|
scores: entryScores.filter(s => s.conversationEntryId === e.id),
|
||||||
})),
|
})),
|
||||||
assessments,
|
assessments,
|
||||||
|
categoryRatings,
|
||||||
|
checklistItems,
|
||||||
|
checklistResponses,
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify(payload, null, 2)
|
return JSON.stringify(payload, null, 2)
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,52 @@
|
||||||
import React from 'react'
|
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)
|
export function detectNotationRating(line: string): RatingScore | null {
|
||||||
const NOTATION_RULES = [
|
return parseNotationPrefix(line).rating
|
||||||
{ 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 },
|
export interface NotationPrefixMatch {
|
||||||
{ prefix: '!', className: 'text-red-600 font-semibold', icon: '✗', bg: 'bg-red-50', indent: false },
|
rating: RatingScore | null
|
||||||
{ prefix: '>', className: 'text-blue-600 italic', icon: '›', bg: 'bg-blue-50', indent: true },
|
/** `>`-Präfix — Zitat/Kundeninput, keine Bewertung. */
|
||||||
] as const
|
isQuote: boolean
|
||||||
|
/** Zeile ohne das erkannte Präfix. */
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führende Score-Zahl 1–10 (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 = /^\[([^\]]*)\]/
|
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()
|
const trimmed = line.trim()
|
||||||
if (!trimmed) return <div key={key} className="h-1" />
|
if (!trimmed) return <div key={key} className="h-1" />
|
||||||
|
|
||||||
// Slide-Referenz [Titel…]
|
|
||||||
const slideMatch = trimmed.match(SLIDE_RE)
|
const slideMatch = trimmed.match(SLIDE_RE)
|
||||||
if (slideMatch) {
|
if (slideMatch) {
|
||||||
const rest = trimmed.slice(slideMatch[0].length).trim()
|
const rest = trimmed.slice(slideMatch[0].length).trim()
|
||||||
|
|
@ -30,23 +60,29 @@ function parseLine(line: string, key: number): React.ReactElement {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notations-Präfixe
|
if (trimmed.startsWith('>')) {
|
||||||
for (const rule of NOTATION_RULES) {
|
const rest = trimmed.slice(1).trim()
|
||||||
if (trimmed.startsWith(rule.prefix)) {
|
|
||||||
const rest = trimmed.slice(rule.prefix.length).trim()
|
|
||||||
return (
|
return (
|
||||||
<div key={key}
|
<div key={key} className="flex items-start gap-2 py-0.5 px-2 rounded bg-blue-50 ml-2">
|
||||||
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 text-blue-600 italic">›</span>
|
||||||
<span className={`flex-shrink-0 text-xs font-bold mt-0.5 ${rule.className}`}>
|
<span className="text-sm text-blue-600 italic">{rest}</span>
|
||||||
{rule.icon}
|
|
||||||
</span>
|
|
||||||
<span className={`text-sm ${rule.className}`}>{rest}</span>
|
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div key={key} className="py-0.5 text-sm text-gray-800">
|
<div key={key} className="py-0.5 text-sm text-gray-800">
|
||||||
{trimmed}
|
{trimmed}
|
||||||
|
|
@ -57,14 +93,29 @@ function parseLine(line: string, key: number): React.ReactElement {
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string
|
text: string
|
||||||
className?: 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
|
if (!text?.trim()) return null
|
||||||
|
const bands = colorBands ?? DEFAULT_RATING_COLOR_BANDS
|
||||||
const lines = text.split('\n')
|
const lines = text.split('\n')
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function splitNoteLines(note: string): string[] {
|
||||||
|
return note.split('\n').map(l => l.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
|
||||||
613
src/utils/pdfExport.tsx
Normal file
613
src/utils/pdfExport.tsx
Normal 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
89
src/utils/ratingTrend.ts
Normal 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 (1–10) ü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 1–10 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 1–10-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 1–10; 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)
|
||||||
|
}
|
||||||
80
src/utils/structureExport.ts
Normal file
80
src/utils/structureExport.ts
Normal 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')
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "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
Loading…
Reference in New Issue
Block a user