V2-Stand: UX-Dashboard, Personen v2, Starter-Paket und Dexie-Legacy entfernt.

Sichert den lokalen Entwicklungsstand nach Gitea: Nav/Dashboard, Personen-Excel/Archiv, Team-Setup, Assignment-Übergabe und C6/C7/T2.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-16 14:17:25 +02:00
parent 234f87d408
commit d894c3c8e4
91 changed files with 7196 additions and 2134 deletions

View File

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

3
.gitignore vendored
View File

@ -14,6 +14,9 @@ dist-ssr/
# Lokale SQLite-Datenbank (enthält personenbezogene Daten, siehe CLAUDE.md → Datenschutz)
server/data/
# Versand-ZIPs (lokal erzeugt, nicht committen)
dist-release/
# Local environment / secrets
.env
.env.local

View File

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

View File

@ -2,7 +2,7 @@
Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) während Kunden-Assignments.
**Vollständige Dokumentation:** `docs/` — bei neuer Session zuerst `docs/HANDOVER.md` lesen.
**Vollständige Dokumentation:** `docs/` — bei neuer Session zuerst `docs/HANDOVER.md` lesen; offene Punkte kanonisch: `docs/STATUS_UND_OFFENE_PUNKTE.md` (Stand 2026-08-16, inkl. C6/C7/T2 erledigt).
**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.
@ -10,23 +10,23 @@ Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) wäh
## Stack
- React 18 + TypeScript + Vite (PWA)
- **React 19** + TypeScript + Vite (PWA)
- Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js`; Brandfarbe als `@theme { --color-brand }` in `src/index.css`
- **Lokaler Node/Express-Server + SQLite** (seit 2026-07-04) — löst Dexie/IndexedDB als primären Datenspeicher ab, siehe eigener Abschnitt unten
- React Router v6
- **React Router 7** (`react-router-dom`)
- Capgemini-Brandfarbe: `#0070AD` (Tailwind-Klasse `brand`, JS-Konstante `BRAND_COLOR` in `src/config/constants.ts`)
- Bewertungsskala: **1–10** (`RatingScore`) — siehe `docs/SCALE_1_TO_10.md` (nicht mehr Client-Ready-Enum)
## Projektstruktur (Stand 2026-07-04)
## Projektstruktur (Stand 2026-08; Erledigt-Historie unten weiter gültig)
```
src/
config/constants.ts ← RATING_OPTIONS, INST_COLORS, AI_CONFIG, RATING_NUM_MAP, BRAND_COLOR, DEFAULT_RATING_TREND_WEIGHT_STEP
config/constants.ts ← RATING_SCORES, Score-Styles, AI_CONFIG, 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
index.ts ← Re-Export (types + queries)
types.ts ← alle Interfaces, RatingScore, 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)
@ -41,10 +41,10 @@ src/
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
database.ts ← node:sqlite-Verbindung (V2: assignment-monitor-v2-dev.sqlite3), Schema aus tableRegistry.ts, Pfad-Guards
rpcModules.ts ← Dispatch-Registry inkl. checklists, meetingCategoryRatings, assignmentTransfer, backup, …
db/
tableRegistry.ts ← EINE Quelle der Wahrheit: alle 21 Tabellen + Spalten + JSON-/Bool-Codecs (treibt Schema UND Backup)
tableRegistry.ts ← EINE Quelle der Wahrheit: alle 26 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
@ -52,7 +52,7 @@ server/
### 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.
Grund: Mehrere Browser auf demselben Laptop sollen dieselben Daten sehen. Browser können keine SQLite-Datei direkt öffnen, daher lokaler Node/Express-Server mit **`node:sqlite`** (**kein** `better-sqlite3`!). **V2-Defaults:** API Port **4001**, Vite **5174**, Proxy `/api` → `http://localhost:4001`, Datei `assignment-monitor-v2-dev.sqlite3`. (Produktiv bleibt 4000/5173 — nie vermischen, siehe `docs/DEV_ISOLATION.md`.)
**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.
@ -60,21 +60,23 @@ Grund: Mehrere Browser (Chrome, Edge, ...) auf demselben Laptop sollen dieselben
**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.
**Dexie/IndexedDB-Legacy entfernt (2026-08-16):** `dexie`-Dependency, `src/db/schema.ts` und `src/db/seeds/` sind weg. Primärspeicher bleibt Express + `node:sqlite`; Struktur-Seed über Starter/`npm run setup`.
**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.
**Backup ist server-seitig** über `tableRegistry`. Zusätzlich: **Starter-Paket** (nur Struktur, `docs/starter/`, `npm run setup`) und **Assignment-Transfer** (ein Assignment inkl. Berater/Notizen/Checklisten, Merge-Import — `assignmentTransfer`).
### 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`.
Module u.a.: `appSettings`, `consultants`, `assignmentTypes`, `assignments`, `feedbackStructure` (`resolveVisibleStructure`/`filterVisibleFeedbackStructure`), `meetings` (`listDoneMeetingsChronological`, `getMeetingExportData`), `conversationEntries`, `assessments`, `assignmentFeedback`, `meetingCategoryRatings`, `checklists`, `criterionSuggestions`, **`assignmentTransfer`**.
## Entwicklung
```bash
npm run dev:all # Frontend (Vite, :5173) + lokaler Server (Express, :4000) zusammen starten
npm run dev # Nur Frontend (Server muss separat laufen, sonst laufen alle DB-Zugriffe ins Leere)
npm run server # Nur den lokalen Server
npx tsc -b # TypeScript-Check über alle 3 Projekte (App/Node/Server) — muss vor jeder Abgabe fehlerfrei sein
npm run dev:all # Frontend :5174 + Server :4001
npm run setup # Starter-Struktur einspielen (Feedback/Typen/Prompts/Checklisten-Defs)
npm run pack:zip # Team-ZIP → dist-release/ (ohne DB)
npm run pack:laptop # Umzug: Code + volle Dev-DB → dist-release/
npm run starter:export
npx tsc -b # TypeScript — 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.**
@ -100,16 +102,12 @@ Filterlogik: `filterVisibleFeedbackStructure(allCategories, allItems, criteriaId
### Rating-Skala
```typescript
type FeedbackRating = 'na' | 'not_client_ready' | 'partially_client_ready' | 'nearly_client_ready' | 'client_ready'
// Numerisch: na=0, not=1, partially=2, nearly=3, fully=4
// N/A gilt als "nicht gesetzt" — aus allen Berechnungen ausschließen!
type RatingScore = number // 1–10; unset = null — siehe docs/SCALE_1_TO_10.md
// Farbbänder in appSettings.ratingColorBands / Config → Skala
// N/A-String-Enum (not_client_ready …) ist abgelöst — nicht neu einführen
```
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.
Aggregation: gewichteter Durchschnitt über `weightedAverageScore`/`weightedAverageRating` in `ratingTrend.ts` — bei `Σweight === 0` null.
### Routing
@ -131,7 +129,7 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
- 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`.
- **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 `rpcClient` 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).
---
@ -140,19 +138,24 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
- **Keine Kommentare** außer für nicht-offensichtliche Invarianten (kein „Was", nur „Warum")
- **Keine Auto-Resize-Textarea via `rows={1}` + JS** — stattdessen View/Edit-Toggle oder `fieldSizing: 'content'` als Inline-Style
- **`RATING_OPTIONS`-Array** nicht neu definieren — immer aus dem bestehenden Array in der jeweiligen Datei verwenden
- **Score-UI** über `ScorePicker` / `RATING_SCORES` / `resolveScoreStyle` — keine alten Enum-`RATING_OPTIONS` neu erfinden
- Gewichtete Mittel immer über `weightedAverageScore`/`weightedAverageRating` in `ratingTrend.ts`
- **Kein `useEffect` für DB-Calls** außer bei initialem Load — lieber direkte `async`-Funktionen
---
## Offene Punkte (priorisiert)
| Prio | Aufgabe | Datei |
|---|---|---|
| M | **Migration der echten Daten aussteht:** Aktuelle IndexedDB-Daten müssen noch einmalig über Config → Backup → Export (alte Version) exportiert und über Config → Backup → Import (neue, serverbasierte Version) in die SQLite-DB eingespielt werden. Siehe Datenbank-Abschnitt oben. | — |
| L | Dexie/IndexedDB-Legacy-Code vollständig entfernen (`src/db/schema.ts`, `src/db/seeds/`) — aktuell bewusst nur stillgelegt, nicht gelöscht | `src/db/schema.ts`, `src/db/seeds/` |
| L | Server-seitiges "Seed-if-empty" für Assignment-Typen/Feedback-Struktur (aktuell nur clientseitig vorhanden und nicht mehr wirksam) — nicht dringend, da der Backup-Import denselben Zweck erfüllt | `server/db/queries/` (neu) |
| L | SQLite WASM + OPFS Migration — durch den lokalen Server-Ansatz (2026-07-04) überholt/hinfällig | `src/db/schema.ts` |
**Kanonisch und aktuell:** `docs/STATUS_UND_OFFENE_PUNKTE.md` (2026-08-16). Kurz:
| Prio | Aufgabe |
|---|---|
| H | **UX.3** Dashboard · **UX.4** Meeting-Live-UX |
| M | UX.5–UX.8; **T4** Cascade Smoke; **T1** npm-Audit; **T5** Legacy; **T3** Backup-Reminder; **T6** Datenschutz |
| L | Create-Enrollments, Assignment-Nr. in URL |
| — | **C0/C6/C7/T2/UX.1–UX.2** + Cascade 1–3/1b: **erledigt** |
**Erledigt (2026-08-16):** C0 Skala-Smoke, C6 Tagging-Batches, C7 KI-Client, T2 Dexie-Legacy, UX.1 Design-Tokens + UX.2 App-Chrome (`index.css`, `App.tsx`, Source Sans 3).
**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.
@ -236,7 +239,7 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
## Datenschutz
- 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)
- Alle Daten lokal — V2: `server/data/assignment-monitor-v2-dev.sqlite3`; **kein Cloud-Upload**, nur `localhost` (kein Auth, solange das so bleibt)
- `server/data/` ist per `.gitignore` ausgeschlossen — Datenbankdatei nie committen
- GDPR-relevant: Institutee-Namen, Kundennamen, Gesprächsnotizen
- KI-API-Key in `localStorage` — nie loggen oder ausgeben

87
EINRICHTUNG.md Normal file
View File

@ -0,0 +1,87 @@
# Einrichtung — Assignment Monitor (Team-Paket)
Kurz: ZIP entpacken → Node installieren → einrichten → Browser öffnen.
**Wichtig:** Das ist die Team-/DEV-Version (Ports **5174** / **4001**).
Eine vorhandene Produktiv-Installation wird **nicht** verändert.
**Laptop-Umzug** (Code + komplette Datenbank): siehe **`UMZUG.md`**. Dort **kein** `npm run setup`.
---
## Was du bekommst / was nicht
| Enthalten (nach Setup) | Nicht enthalten |
|---|---|
| Feedback-Struktur (Dimensionen, Kategorien, Kriterien) | Institutees / Personen |
| Assignment-Typen + Phasen | Kunden-Assignments |
| Checklisten-Definitionen | Meetings, Protokolle, Beobachtungen |
| KI-Prompts (Feedback, Tagging) + Farbbänder | Bewertungen, Scores, Feedback-Texte |
Die Struktur liegt in `docs\starter\starter-structure.json` und wird beim Setup in die lokale DB geladen.
---
## 1. Voraussetzung
- **Node.js 25 oder neuer** — https://nodejs.org/
Prüfen:
```powershell
node -v
```
---
## 2. Entpacken
z.B. nach `C:\dev\AssigmentMonitorV2`, dann:
```powershell
cd C:\dev\AssigmentMonitorV2
```
---
## 3. Einmalig einrichten
```powershell
copy .env.example .env
npm install
npm run setup
```
`npm run setup` spielt die **Struktur** ein (Feedback, Typen, Prompts, Checklisten) — ohne Bewertungen/Beobachtungen.
Falls du `setup` vergisst: Beim ersten Server-Start wird die Struktur automatisch nachgeladen, sobald die DB noch leer ist.
---
## 4. Starten
```powershell
npm run dev:all
```
Browser: **http://localhost:5174**
Beenden: `Strg+C` im Terminal.
---
## Optional: KI
Unter **Config → KI-Prompts** einen eigenen OpenRouter-API-Key eintragen (bleibt nur lokal im Browser).
---
## Probleme
| Symptom | Lösung |
|---|---|
| `node` nicht gefunden | Node 25+ installieren, neues Terminal |
| Port belegt | Anderes Programm auf 5174/4001 beenden — **nicht** auf 5173/4000 umbiegen |
| Keine Kriterien / leere Config | `npm run setup` nochmal |
| `npm install` fehlerhaft | Im Projektordner ausführen |
Mehr: `docs\TEAM_SETUP.md`

68
UMZUG.md Normal file
View File

@ -0,0 +1,68 @@
# Umzug auf einen neuen Laptop — Assignment Monitor V2
Dieses Paket ist **kein Team-Starter**. Es enthält den aktuellen Code **und die komplette lokale Datenbank** (Personen, Assignments, Meetings, Feedback).
**Nicht** `npm run setup` ausführen — das würde die mitgelieferte Datenbank durch die leere Struktur ersetzen.
---
## 1. Voraussetzung
- **Node.js 25 oder neuer** — https://nodejs.org/
- Prüfen: `node -v`
Eine vorhandene Produktiv-Installation (`AssigmentMonitor` ohne V2, Ports 5173/4000) bleibt unberührt.
---
## 2. Entpacken
Nach z.B. `C:\dev\AssigmentMonitorV2`, dann:
```powershell
cd C:\dev\AssigmentMonitorV2
```
---
## 3. Einmalig einrichten
```powershell
copy .env.example .env
npm install
```
Kein `setup`, kein `bootstrap`, kein `db:reset`.
Die Datei `server\data\assignment-monitor-v2-dev.sqlite3` muss nach dem Entpacken vorhanden sein.
---
## 4. Starten
```powershell
npm run dev:all
```
Browser: **http://localhost:5174**
Beenden: `Strg+C` im Terminal.
---
## 5. KI-Schlüssel
Der OpenRouter-API-Key lag im Browser (`localStorage`) des alten Laptops und ist **nicht** in diesem ZIP. Unter **Einstellungen → KI-Prompts** neu eintragen.
---
## Probleme
| Symptom | Lösung |
|---|---|
| `node` nicht gefunden | Node 25+ installieren, neues Terminal |
| Leere App / keine Assignments | `setup` wurde ausgeführt — ZIP erneut entpacken, DB nicht überschreiben |
| Port belegt | Anderes Programm auf 5174/4001 beenden — **nicht** auf 5173/4000 umbiegen |
| `npm install` fehlerhaft | Im Projektordner ausführen |
Neues Paket erzeugen (alter Laptop): `npm run pack:laptop` → Datei unter `dist-release\`.

View File

@ -43,3 +43,18 @@ npm run dev:all
- 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.
## Team-Verteilung
Struktur an Kollegen weitergeben über das **Starter-Paket** (keine Personen-/Meeting-Daten):
```bash
npm run starter:export # Maintainer: Struktur aus V2-Dev exportieren
npm run setup # Team: frische V2-DB mit Struktur befüllen
npm run pack:zip # Versand-ZIP (ohne DB)
npm run pack:laptop # Umzug: Code + volle Dev-DB (personenbezogen)
```
Assignment-Übergabe Lead→Lead: UI **↔ Übergabe** / **↔ Import** (`assignmentTransfer`).
Details: [`TEAM_SETUP.md`](./TEAM_SETUP.md), [`HANDOVER.md`](./HANDOVER.md). Produktiv bleibt davon unberührt.

View File

@ -1,5 +1,7 @@
# Fachliche Architektur — Assignment Monitor
> **Stand Hinweis (2026-08-16):** Dieses Dokument ist **historisch** (u.a. IndexedDB, ältere Skala). Aktueller Ist-Stand und offene Punkte: [`HANDOVER.md`](./HANDOVER.md), [`STATUS_UND_OFFENE_PUNKTE.md`](./STATUS_UND_OFFENE_PUNKTE.md). Bei Widerspruch gewinnen HANDOVER/STATUS/Code.
## Zweck und Kontext
Assignment Monitor ist eine Progressive Web App (PWA) zur **Live-Begleitung und Bewertung von Capgemini-Trainees** (Institutees) während ihrer Kunden-Assignments. Die App wird vom jeweiligen Lead (Führungskraft / Mentor) auf dem Smartphone genutzt, um:
@ -10,7 +12,7 @@ Assignment Monitor ist eine Progressive Web App (PWA) zur **Live-Begleitung und
**Nutzer:** Lars Stommer (Capgemini Management Consultant Trainee Program Lead)
**Sprache der App:** Deutsch (Bedienoberfläche), Englisch (Rating-Labels)
**Datenschutz:** Alle Daten bleiben lokal im Browser (IndexedDB). Kein Public Cloud Upload.
**Datenschutz:** Alle Daten bleiben lokal (V2: SQLite unter `server/data/`, kein Public-Cloud-Upload). Details: [`HANDOVER.md`](./HANDOVER.md).
---

View File

@ -1,9 +1,11 @@
# 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`).
Stand: 2026-08-16 (Abgleich) · nur DEV (`AssigmentMonitorV2`)
Voraussetzung: Bewertungsskala 1–10 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.
Dieses Dokument ist die **Arbeitsliste**. Status der offenen Punkte gesamt: `docs/STATUS_UND_OFFENE_PUNKTE.md`.
**Fortschritt 2026-08-16:** Pakete **0, 1, 1b, 2, 3, 6, 7**, **8.3** und **UX.1–UX.2** (Tokens + Shell) erledigt; **4/5** Code weitgehend; UX.3–UX.8 offen. **Nächster Fokus:** UX.3 Dashboard / UX.4 Meeting.
---
@ -57,10 +59,10 @@ 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 | ☐ |
| 0.1 | Farbbänder speichern/laden (`getOrSeed`/`saveRatingColorBands` Server) — Hell-/Dunkelgrün unterscheidbar | ☑ `npm run smoke` + manuell |
| 0.2 | Chips Detail/History/PDF/MD aus `meetingCategoryRatings` (+ Fallback) | ☑ geprüft |
| 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.
@ -110,9 +112,9 @@ Bereits weitgehend erledigt; nur nachziehen, was noch hinkt.
| # | 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.1 | Pro Kategorie: ScorePicker + Trend/KI-Score-Vorschlag („Übernehmen“) + Benefits/Concerns (editierbar + suggested-Boxen) | ☑ Code · Smoke ☐ |
| 4.2 | Pro Dimension: nur Benefits/Concerns (3–5), suggested übernehmen/verwerfen | ☑ Code · Smoke ☐ |
| 4.3 | Alten Ein-Text-pro-Kategorie und Achievements/DevNeeds-Labels entfernen bzw. umbenennen | ☑ Code · Smoke ☐ |
| 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 | ☑ |
@ -124,8 +126,8 @@ Bereits weitgehend erledigt; nur nachziehen, was noch hinkt.
| # | Aufgabe | Done |
|---|---|---|
| 5.1 | `InstituteeFeedbackDocument` / Assignment-Report: pro Kategorie Score + Benefits + Concerns; pro Dimension Benefits + Concerns | ☐ |
| 5.2 | Nur finale Felder, nie `suggested*` | ☐ |
| 5.1 | `InstituteeFeedbackDocument` / Assignment-Report: pro Kategorie Score + Benefits + Concerns; pro Dimension Benefits + Concerns | ☑ Code · Smoke ☐ |
| 5.2 | Nur finale Felder, nie `suggested*` | ☑ Code · Smoke ☐ |
| 5.3 | Markdown/JSON Assignment-Feedback falls vorhanden nachziehen | ☐ |
**Exit:** Internes PDF für Manager-Gespräch brauchbar.
@ -136,12 +138,12 @@ Bereits weitgehend erledigt; nur nachziehen, was noch hinkt.
| # | 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) | ☐ |
| 6.1 | Hilfsfunktion: offene Zeilen sammeln (unverändert: ohne bereits Getaggte, ohne Kundenzitate wie bisher) | ☑ `collectOpenProtocolLines` |
| 6.2 | Batch-Builder: max. Zeilen **und** max. Zeichensumme (Konstanten in `constants` oder AI_CONFIG, konfigurierbar später) | ☑ `buildCharAwareBatches` / `AI_CONFIG.taggingBatch*` |
| 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 | ☑ `narrowCriteriaIdsForBatch` |
| 6.5 | `MUSTER:`-Block: nach allen Batches einmal zusammenführen/schreiben oder nur aus Batches mit Treffern — Regel dokumentieren | ☑ Merge, später überschreibt früher; ein DB-Write am Ende |
| 6.6 | Info-Zeile + Rohantwort weiter nutzbar (pro Batch oder letzte) | ☑ Rohantworten mit Batch-Trennern |
**Exit:** Lange Protokolle brechen nicht mehr am Einzelcall; getaggte Zeilen bleiben draußen.
@ -153,10 +155,10 @@ 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 | ☐ |
| 7.1 | Timeouts / Abbruch klar melden (nicht still) | ☑ `callOpenRouter` 90s + Abort-Meldung |
| 7.2 | Retry mit Backoff für transient failures (einmalig) | ☑ 429/5xx/Netz/Timeout |
| 7.3 | Grobe Prompt-Größen-Schätzung vor Call → Warnung oder Auto-Split | ☑ Soft-Warn + Hard-Limit; Tagging auto-split (6) |
| 7.4 | Gleiche `aiClient`-Hilfen für Feedback und Tagging | ☑ |
**Exit:** Abbrüche sind erklärbar und oft automatisch reparierbar.
@ -176,8 +178,8 @@ Bisher wirkt die App funktional, aber wie ein schneller Prototyp (graue Fläche,
| # | 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.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 | ☐ |
@ -199,28 +201,27 @@ Nicht blockierend für Pakete 1–7 / UX; einplanen wenn Kapazität.
|---|---|---|---|
| 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 | ☐ |
| 8.3 | Dexie/IndexedDB-Legacy entfernen | STATUS T2 | ☑ |
| 8.4 | Server Seed-if-empty Assignment-Typen/Struktur | STATUS N5 (weitgehend durch `setup` ersetzt) | ☐ optional |
| 8.5 | Evaluation.tsx / FeedbackDraftPage auf neue Benefits/Concerns + Bänder prüfen oder Legacy deprecaten | STATUS T5 | ☐ |
| 8.6 | Datenschutz: Hinweis Tagging+Feedback (externe API) vor Prod | STATUS T6 | ☐ |
---
## 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
0 / 1 / 1b / 2 / 3 / 6 / 7 / 8.3 ← erledigt
→ UX.1–UX.2 Tokens + Shell ← erledigt
→ UX.3 Dashboard · UX.4 Meeting ← nächster UX-Schritt
→ UX.5 Feedback · UX.6 Config · UX.7–8
→ T4 Smoke / 5.3 parallel möglich
→ T1 / T5 / T6 nach Bedarf
```
**Hinweis:** Paket UX ist spezifiziert, aber **zurückgestellt** — erst Features fertig, dann Optik.
**Hinweis:** Feature-Stabilität vor UX war Absicht; UX.1–UX.2 sind Fundament für die Folgeschritte.
**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.
**Warum 1b vor 2 (historisch):** Stufe A und Feedback-UI müssen nur Kategorien/Dimensionen des Assignment-Typs sehen — erledigt.
---

View File

@ -1,198 +1,173 @@
# Handover-Dokument — Assignment Monitor
# Handover-Dokument — Assignment Monitor V2
Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt nahtlos fortzuführen.
Einstieg für neue Chat-/Dev-Sessions. **Produktiv** (`c:\dev\AssigmentMonitor`) niemals anfassen.
> **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.
> **DEV-Isolation:** Repo `AssigmentMonitorV2` · Ports **5174 / 4001** · SQLite `assignment-monitor-v2-dev.sqlite3`
> Details: [`DEV_ISOLATION.md`](./DEV_ISOLATION.md) · Team-Verteilung: [`TEAM_SETUP.md`](./TEAM_SETUP.md) · Offene Punkte: [`STATUS_UND_OFFENE_PUNKTE.md`](./STATUS_UND_OFFENE_PUNKTE.md)
---
## Stand & nächste Schritte
## Stand (2026-08-16)
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).
**Arbeitsverzeichnis:** `C:\dev\AssigmentMonitorV2`
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).
### Zuletzt umgesetzt (Auswahl)
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).
| Wann | Thema |
|---|---|
| 2026-08-16 | **Personen v2** E-Mail Pflicht · Gruppen umbenennen/löschen · Verschieben · Excel-Import · Archiv · Detail (Assignments/Feedbacks/Entwicklung) · Lösch-Kaskade |
| 2026-08-16 | **UX.3 Teil 1** Nav (Assignments/Feedbacks/Personen/Einstellungen) · Dashboard-Kachel-Grid · Plandaten · Typ-Farben · Filter/Überfällig |
| 2026-08-16 | **UX.1–UX.2** Design-Tokens + App-Chrome · **C0** Smoke · **C6/C7** Tagging/KI · **T2** Dexie entfernt |
| 2026-08 | Assignment-**Übergabe** Export/Import (Lead→Lead, inkl. Berater/Checklisten/Notizen) |
| 2026-08 | Team-**Starter-Paket** (`starter:export` / `bootstrap` / `setup` / `pack:zip`) |
| 2026-08 | Bewertungsskala **1–10**, Farbbänder, `meetingCategoryRatings` |
| 2026-08 | Feedback-Kaskade Stufe A (Kategorie) → B (Dimension), Benefits/Concerns |
| 2026-07 | Checklisten, selbstlernendes Kriterien-Tagging, PDF-Exporte, Papierkorb/Archiv, Meeting-Historie |
| 2026-07-04 | Lokaler Node/Express + `node:sqlite` statt IndexedDB als Primärspeicher |
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.
**Laufende Arbeitsliste Feedback/KI:** [`FEEDBACK_CASCADE_PLAN.md`](./FEEDBACK_CASCADE_PLAN.md) — Pakete **0**, 1–3, 1b, **6, 7**, 8.3 erledigt; 4/5 Code weitgehend (manuelles Smoke/5.3 offen).
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.
**Kanonischer Status + offene Punkte:** [`STATUS_UND_OFFENE_PUNKTE.md`](./STATUS_UND_OFFENE_PUNKTE.md) — als Nächstes **T4**.
---
## Worum geht es?
**Assignment Monitor** ist eine PWA für Lars Stommer (Capgemini Management Consultant Trainee Program Lead). Die App wird auf dem Smartphone genutzt, um Trainees (intern: „Institutees") während ihrer Kunden-Assignments live zu begleiten und zu bewerten. Lars führt die Meetings, beobachtet die Trainees und möchte sofort Notizen und Bewertungen erfassen, ohne Stift/Papier.
PWA für die Live-Bewertung von Capgemini-Trainees („Institutees“) während Kunden-Assignments: Meetings protokollieren, Kriterien taggen, einschätzen, Abschluss-Feedback (Benefits/Concerns) erzeugen.
---
## Tech-Stack
- **React 18 + TypeScript + Vite** (PWA-Frontend)
- **Tailwind CSS v4** (`@import "tailwindcss"` — kein `tailwind.config.js`!)
- **Lokaler Node/Express-Server + `node:sqlite`** (seit 2026-07-04) — löst Dexie/IndexedDB als Datenspeicher ab, siehe CLAUDE.md. `npm run dev:all` startet Frontend + Server zusammen.
- **React Router v6**
- **Capgemini-Brandfarbe:** `#0070AD`
- Arbeitsverzeichnis: `C:\dev\AssigmentMonitor`
**Wichtig für den TypeScript-Check:** `npx tsc --noEmit` prüft am Root **nichts** (Project-References-Setup, `files: []`) — immer `npx tsc -b` verwenden.
## Bewertungsskala (kritisch für alle Berechnungen)
```typescript
type FeedbackRating = 'na' | 'not_client_ready' | 'partially_client_ready' | 'nearly_client_ready' | 'client_ready'
// Numerisch: na=0, not=1, partially=2, nearly=3, fully=4
// Labels: N/A, Not, Partially, Nearly, Fully
// Farben: grau, rot, orange, hellgrün, dunkelgrün
// N/A gilt als "nicht gesetzt" — aus allen Berechnungen ausgeschlossen!
```
Aggregation: **gewichteter Durchschnitt** (avg < 1.5 → Not, < 2.5 → Partially, < 3.5 → Nearly, ≥ 3.5 → Fully)
## Datenmodell-Kern (was zu wissen ist)
### Schlüssel-Unterschied: zwei `criteriaId`-Semantiken
```
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting)
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung während Gespräch)
ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Kriterium-Zuordnung einer Protokoll-Zeile, bewusst anders benannt als "criteriaId")
```
Das klingt inkonsistent — ist aber so gewollt und funktioniert so.
### AssignmentType.criteriaIds
```typescript
// Leer = alle Kriterien anzeigen
// Befüllt = nur diese FeedbackCriterionItem.id anzeigen
criteriaIds?: number[] // → FeedbackCriterionItem.id
```
Filterlogik (in MeetingView + AssignmentDetail):
```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
```
### Dateiname für Meeting-Exporte
```typescript
`${assignment.assignmentNumber ?? assignment.title}_${date}_${template.label}${phaseIndex > 1 ? `-${phaseIndex}` : ''}.${ext}`
// Beispiel: CGI-2024-001_02-07-2026_Alignment Call-2.md
```
## Routing-Übersicht
```
/ Dashboard
/assignment/new Neues Assignment
/assignment/:id AssignmentDetail
/assignment/:id/edit AssignmentEdit
/assignment/:id/meeting/:meetingId MeetingView (src/pages/meeting/) ← WICHTIG: meetingId != assignmentId
/assignment/:id/feedback/:instituteeId AssignmentFeedbackPage
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
/evaluation Auswertung — zeitgewichteter Bewertungs-Vorschlag + Trend, nach Assignment gruppiert
/consultants Berater
/config Konfiguration (4 Tabs: Feedback | Ass.-Typen | Gewichtung | Backup)
```
MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useParams()`
## Konfigurationsbereich
**Tab "Feedback"** (`FeedbackStructureConfig.tsx`):
- 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!
**Tab "Ass.-Typen"** (`AssignmentTypeConfig.tsx`):
- AssignmentTypen CRUD
- Pro Typ: Sub-Tabs "Phasen" und "Kriterien"
- Kriterien-Tab: Baumstruktur Dimension → Kategorie → Items (Checkbox, Indeterminate)
**Tab "Gewichtung"** (`RatingWeightConfig.tsx`, seit 2026-07-02):
- Ein Regler für den Zeitgewichtungs-Faktor des Bewertungs-Vorschlags (`AppSettings.ratingTrendWeightStep`, Tabelle `appSettings`)
**Tab "Backup"** (`Configuration.tsx`):
- JSON-Export aller Tabellen via `exportFullBackup()`
- JSON-Import via `importFullBackup(file)` (löscht alles, dann bulkAdd)
## Bekannte offene Punkte (Priorität nach Dringlichkeit)
### Mittlere Aufgaben
1. **KI-Generierung funktionsunfähig** — `generateWithAi()` schlägt fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`. Root Cause nicht untersucht.
### Große Aufgaben (eigene Design-Session nötig)
2. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus.
3. **Mehrstufiges, pro Dimension konfigurierbares Prompt-System** — löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab, braucht Spezialwissen je Dimension + Platzhaltersystem.
4. **Selbstlernendes Tagging** — aus bisherigen Zeile-zu-Kriterium-Zuordnungen automatische Tag-Vorschläge beim Tippen ableiten.
**Erledigt (2026-07-04):** `db/queries/`-Layer eingeführt — DB-Zugriffe aus allen 20 betroffenen Komponenten/Utils in domänenspezifische Query-Funktionen unter `src/db/queries/` extrahiert (Details siehe CLAUDE.md). Dabei die drei unabhängigen Implementierungen der gewichteten-Durchschnitt-Logik auf eine gemeinsame Funktion in `ratingTrend.ts` konsolidiert (Nebeneffekt: fehlender `Σweight===0`-Guard in `AssignmentDetail.tsx` gefixt) sowie die duplizierte `criteriaIds`-Filterlogik und die doppelten Meeting-Export-Queries zusammengeführt.
**Erledigt (2026-07-03, zweite Runde):** N/A-Aggregationsbug in `buildAiPrompt` gefixt. `FeedbackDraftPage.tsx` vollständig auf `feedbackCriterionItems`/`feedbackCategories` migriert (Notizen jetzt korrekt pro Kriterium über `lineTags`, Score-Anzeige über `RATING_OPTIONS`). Toter Code in `FeedbackStructureConfig.tsx` entfernt. "Dashboard zeigt keine Assignment-Nummer" war bereits gelöst — nur die Doku war veraltet.
**Erledigt (2026-07-02):** Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung — `src/utils/ratingTrend.ts` berechnet pro Kategorie einen zeitgewichteten Vorschlag aus den Meeting-Assessments (Gewichtungsfaktor konfigurierbar unter Config → Gewichtung, neue Tabelle `appSettings`), erkennt Verbesserung/Verschlechterung über die Laufzeit und zeigt beides als Badge auf `AssignmentFeedbackPage` (mit „Übernehmen" + „Trend in Text übernehmen"). `buildAiPrompt` wurde um einen Trend-Abschnitt erweitert; dabei wurde auch der alte `db.criteria`-Bug mitgefixt.
**Erledigt (2026-07-03):** Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags`, `CriterionTagPicker.tsx`, nachträglich pro Zeile innerhalb eines zusammenhängenden Sprecher-Blocks). Notation → Bewertungs-Vorschlag in der Gesamtbewertung (`detectNotationRating()`, „Vorschlag aus Protokoll"-Badge + Kategorie-weite 💡-Zähler). `meetingExport.ts` und `dbBackup.ts` gefixt (Legacy-`db.criteria`-Bug bzw. fehlende `appSettings`-Tabelle im Backup). Assignment-Nummer ist jetzt Pflichtfeld. Kriterium-Gewichtung auf `0,0–2,0` verfeinert, dabei einen Division-durch-Null-Bug bei Gewicht `0` in allen gewichteten Mittelwerten gefunden und gefixt.
## Stil-Regeln (bitte einhalten)
- **Tailwind v4**: kein `bg-[#0070AD]` via config, direkt als CSS-Variable nicht nötig — `bg-[#0070AD]` im Class-String funktioniert direkt
- **Keine Kommentare** außer für nicht-offensichtliche Invarianten
- **Kein `rows={1}` + JS-Autoresize** — besser View/Edit-Toggle oder `fieldSizing: 'content'` via Inline-Style
- **Rating-Buttons** immer mit `RATING_OPTIONS`-Array (nicht neu definieren):
```typescript
{ value, short, color (inaktiv), active (aktiv) }
```
- **Weighted-Average** immer: N/A excludieren, dann `Σ(score×weight)/Σ(weight)`
## Notations-Parser
`src/utils/notationParser.tsx` exportiert `<NotationText text={string} />` — rendert `+`, `(+)`, `!`, `(!)`, `>`, `[…]` farbig. Überall verwenden wo Protokoll-Text angezeigt wird.
## DB-Backup
`src/utils/dbBackup.ts` exportiert:
```typescript
exportFullBackup(): Promise<void> // Download als JSON
importFullBackup(file: File): Promise<{ ok: boolean; error?: string }> // Clears + bulkAdd
```
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
`src/main.tsx` ruft beim Start auf:
1. `seedIfEmpty()` — 3 AssignmentTypen + Legacy-Kriterien
2. `seedFeedbackStructureIfEmpty()` — 5 Dimensionen, 14 Kategorien, ~70 Items
3. `seedCriterionMappingsIfEmpty()` — Legacy-Mappings
4. `migrateToUnifiedCriteriaIfNeeded()` — Migration auf FeedbackCategory-IDs in PhaseTemplates
## Datenschutz-Hinweise
- Alle Daten lokal — seit 2026-07-04 in `server/data/assignment-monitor.sqlite3` (lokaler Server, kein Cloud-Backend), `server/data/` ist gitignored
- GDPR-relevant: Institutee-Namen + Kundennamen in der DB
- KI-API: OpenRouter, API-Key in localStorage (nicht im Klartext loggen!)
- Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln
## TypeScript-Build
| | |
|---|---|
| Frontend | **React 19** + TypeScript + Vite (PWA) |
| Routing | **React Router 7** (`react-router-dom`) |
| CSS | Tailwind v4 — `@import "tailwindcss"`, Brand `brand` / `#0070AD` |
| Daten | Lokaler **Express** + **`node:sqlite`** (kein better-sqlite3), RPC `POST /api/rpc` |
| Ports (V2) | Frontend **5174**, API **4001** |
| DB-Datei | `server/data/assignment-monitor-v2-dev.sqlite3` (gitignored) |
| Tabellen | **26** — einzige Quelle: `server/db/tableRegistry.ts` |
```bash
npx tsc -b # Muss ohne Fehler durchlaufen — NICHT --noEmit, das prüft am Root nichts (siehe Tech-Stack-Abschnitt)
npm run dev:all # Frontend + lokaler Server zusammen starten
npm run dev:all # Vite :5174 + API :4001
npx tsc -b # TypeScript — NICHT tsc --noEmit am Root
npm run setup # Starter-Struktur einspielen
npm run pack:zip # Team-ZIP nach dist-release/ (ohne DB)
npm run pack:laptop # Umzug: Code + volle Dev-DB
```
Vor jeder Abgabe: `npx tsc -b` ausführen und alle Fehler beheben.
---
## Wichtige Eigenheiten / Fallstricke
## Bewertungsskala (1–10)
1. **`MeetingInstance.generalNotes`**: Kann in alten Datensätzen `undefined` sein → immer `?? ''` fallback
2. **`Assessment.criteriaId`**: Zeigt auf `FeedbackCriterionItem.id`, NICHT auf `FeedbackCategory.id` — das verwirrt, weil der Feldname `criteriaId` ist
3. **`PhaseKey`**: Ist `type PhaseKey = string` (kein Union-Typ mehr)
4. **`aType2`** in `AssignmentDetail.tsx`: Doppelte Variable aus Refactoring — `aType` wird zweimal deklariert, zweite heißt `aType2`. Technische Schuld.
5. **`expandedCat`** in `MeetingView`: `Record<instituteeId, categoryId | null>` — öffnet pro Institutee genau eine Kategorie gleichzeitig
6. **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).
```typescript
type RatingScore = number // 1–10 ganzzahlig; unset = null (kein N/A-String mehr)
```
- Farbbänder konfigurierbar: Config → **Skala** (`ratingColorBands` in `appSettings`)
- Notation in Protokollen: **führende Zahl** `1`–`10` am Zeilenanfang (`parseNotationPrefix`); `>` Zitat, `[…]` Abschnitte bleiben
- Meeting-Pflichtscore oft auf **Kategorie**-Ebene (`meetingCategoryRatings`); Kriterien bleiben Beobachtungs-/Detail-Ebene
- Aggregation: gewichtet `Σ(score×weight)/Σ(weight)`; `Σweight===0` → null; Details `docs/SCALE_1_TO_10.md`
**Legacy:** Alte Enum-Skala `not_client_ready` … und Notation `+`/`!` sind abgelöst — nicht neu einbauen.
---
## Datenmodell-Kern
### `criteriaId`-Semantiken (unverändert kritisch)
```
Assessment.criteriaId → FeedbackCriterionItem.id
ConversationSkillScore.criteriaId → FeedbackCategory.id
ConversationLineTag.criterionItemId → FeedbackCriterionItem.id
```
### Sichtbare Struktur je Assignment-Typ
`AssignmentType.criteriaIds` (leer = alle). Kanonisch:
`resolveVisibleStructure()` / `filterVisibleFeedbackStructure()` in `src/db/queries/feedbackStructure.ts` — nicht erneut inline.
### Checkliste
- `PhaseTemplate.hasChecklist` + `checklistItemDefs` / `checklistResponses`
- Effektives Kriterium: Antwort-Override oder Config-`linkedCriterionItemId` (`getEffectiveChecklistCriterionId`)
- UI im Gesprächsprotokoll-Tab (kein eigener Meeting-Tab)
- Tagging: eigener Prompt `checklistTaggingPromptTemplate` (`PUNKT n:`), getrennt vom Protokoll (`ZEILE n:`)
---
## Routing
```
/ Dashboard (+ Import Übergabe)
/assignment/new
/assignments/trash | /assignments/archive
/assignment/:id Detail (+ Export Übergabe, PDF)
/assignment/:id/edit
/assignment/:id/history Gesamtprotokoll
/assignment/:id/meeting/:meetingId MeetingView ← meetingId ≠ Assignment-ID
/assignment/:id/feedback/:instituteeId
/assignment/:id/feedback-draft/:instituteeId Legacy
/evaluation | /consultants | /config
```
---
## Konfiguration (6 Tabs)
| Tab | Inhalt |
|---|---|
| Feedback | Dimensionen → Kategorien → Kriterien (Gewicht 0,0–2,0) |
| Ass.-Typen | Phasen, Checklisten, Kriterien-Sichtbarkeit, Standard-Zuordnung (KI) |
| Gewichtung | Zeitgewichtungs-Faktor Trend |
| Skala | Farbbänder 1–10 |
| KI-Prompts | Kategorie-/Dimension-Templates, Protokoll- + Checklisten-Tagging |
| Backup | Voll-Backup JSON (Wipe-Import) |
---
## Team & Übergabe
| Zweck | Wie |
|---|---|
| Frische Struktur ohne Personen | `docs/starter/starter-structure.json` · `npm run setup` |
| ZIP an Kollegen | `npm run pack:zip` → `dist-release/` · Anleitung `EINRICHTUNG.md` |
| Laptop-Umzug (Code + volle DB) | `npm run pack:laptop` → `dist-release/` · Anleitung `UMZUG.md` |
| Assignment an anderen Lead | Detail **↔ Übergabe** / Dashboard **↔ Import** (`assignmentTransfer`) |
Import prüft Berater per Vor-/Nachname (wiederverwenden oder anlegen); Feedback-Struktur muss auf dem Ziel (Starter) passen.
---
## Stil-Regeln
- Keine Kommentare außer nicht-offensichtlichen Invarianten
- Kein Auto-Resize-Textarea via `rows={1}` + JS
- Scores: `ScorePicker` / `resolveScoreStyle` / `RATING_SCORES` — keine alten `RATING_OPTIONS`-Enums neu erfinden
- Gewichtete Mittel: immer `weightedAverageScore` / `weightedAverageRating` in `ratingTrend.ts`
- Zeilen splitten: `splitNoteLines()` — nicht `note.split('\n')` inline
- Cross-Boundary: Dateien für Client+Server nur aus `src/db/types.ts` / `src/config/constants.ts` (in `tsconfig.server.json` include)
---
## Datenschutz
- Daten nur lokal unter V2-`server/data/` · kein Cloud-Upload
- GDPR: Institutee-/Kundennamen, Gesprächsnotizen
- KI-Key in `localStorage` — nie loggen
- Backup- und Übergabe-JSON = personenbezogen → vertraulich
- Produktiv-Backup nicht in V2-Experimente mischen
---
## Fallstricke
1. `MeetingInstance.generalNotes` → immer `?? ''`
2. `Assessment.criteriaId` ≠ Kategorie-ID
3. `npx tsc --noEmit` am Root prüft nichts — immer `npx tsc -b`
4. V2-Ports 5174/4001 — Start bricht bei 5173/4000 ab
5. Protokoll-Tagging-Prompt in DB darf kein `{{CHECKLISTEN_PUNKTE}}` mehr enthalten (Auto-Migration in `getOrSeedCriterionTaggingPromptTemplate`)
6. Dexie/IndexedDB-Legacy ist entfernt (2026-08-16) — nicht wieder einführen; Struktur über Starter/`npm run setup`

View File

@ -14,8 +14,8 @@ Stand: 2026-08-06 · nur DEV (`AssigmentMonitorV2`), Produktiv unberührt.
| 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) |
| Struktur | In V2 über Starter/`npm run setup` und Config pflegbar (Stand 2026-08) |
| Typ-Strukturen | Sichtbarkeit über `criteriaIds` + `resolveVisibleStructure` (Cascade 1b) |
| Schnelle Zuordnung | `lineTags` / Tag-Picker / Vorschläge bleiben |
### Default-Farbbänder

62
docs/SMOKE_TESTS.md Normal file
View File

@ -0,0 +1,62 @@
# Smoke-Tests — Assignment Monitor V2
Stand: 2026-08-16 · nur DEV
## Automatisiert (C0 Kern + C6/C7-Logik)
```powershell
cd C:\dev\AssigmentMonitorV2
npm run smoke
```
Prüft u.a.: Score-Normalisierung, Default-Farbbänder inkl. Hell-/Dunkelgrün, DB-Roundtrip Farbbänder (stellt vorherigen Stand wieder her), Tagging-Batches, offene Zeilen ohne Tags/Zitate, Prompt Soft/Hard-Limit.
Kein Browser, keine echte OpenRouter-API.
---
## Manuell — C0 Skala (UI, ~10–15 Min)
Voraussetzung: `npm run dev:all` → http://localhost:5174
### 0.1 / 0.4 Farbbänder + Reload
1. **Config → Skala** öffnen.
2. Grenzen leicht ändern (z.B. Rot bis 3 statt 2) → speichern.
3. Seite neu laden (F5) → Werte müssen gleich bleiben.
4. Optional: `npm run smoke` danach — Roundtrip bleibt grün; eigene Werte bleiben erhalten.
### 0.2 / 0.4 Meeting → Chips
1. Assignment mit Institutee öffnen → Meeting mit Einschätzung.
2. Im Tab **Gesamtbewertung** / Kategorie-Scores Werte setzen (z.B. 2, 6, 9).
3. Speichern / Meeting verlassen.
4. Prüfen:
- **Assignment-Detail**: Chips farbig (rot / hellgrün / dunkelgrün unterscheidbar)
- **📜 Gesamtprotokoll**: gleiche Scores/Farben
- Optional: Meeting-**Export MD** enthält Scores
5. Config → Skala wieder auf Default setzen (falls geändert).
### 0.3 KI-Prompts Standard
1. **Config → KI-Prompts**
2. Eine Dimension „Auf Standard zurücksetzen“ (1–10 / Benefits-Concerns-Vorlage).
3. Auf Feedback-Seite Prompt-Vorschau kurz ansehen — Platzhalter/Struktur wirken stimmig.
**C0 abhaken:** erledigt 2026-08-16 (`npm run smoke` + Abnahme).
---
## Manuell — C6 Batches (optional, ~5 Min)
1. Meeting mit **≥15 ungetaggten** Protokollzeilen (kein `>`, keine Tags).
2. **🏷 Vorschläge analysieren** (API-Key nötig).
3. Erwartung: Fortschritt `Protokoll-Batch i/n`, Info mit Batch-Anzahl, Rohantwort mit `--- Protokoll-Batch ---`.
---
## Manuell — T4 Feedback kurz (optional)
1. Assignment → Feedback einer Person.
2. Benefits/Concerns auf Kategorie + Dimension sichtbar; ScorePicker 1–10.
3. PDF Institutee-Feedback: finale Benefits/Concerns, keine `suggested*`.

View File

@ -1,118 +1,169 @@
# Projektstatus und offene Punkte — Assignment Monitor
# Projektstatus und offene Punkte — Assignment Monitor V2
Stand: 2026-07-03
Stand: **2026-08-16** (Abgleich Code + Session) · nur DEV (`AssigmentMonitorV2`) · Produktiv unberührt
Kanonische Einstiege: [`HANDOVER.md`](./HANDOVER.md) · Isolation: [`DEV_ISOLATION.md`](./DEV_ISOLATION.md) · Feedback-Arbeitsliste: [`FEEDBACK_CASCADE_PLAN.md`](./FEEDBACK_CASCADE_PLAN.md)
> Ältere STATUS-Stände (Juli 2026, IndexedDB/WASM/Enum-Skala) sind **ersetzt**. Offene Punkte unten sind gegen den Code abgeglichen.
---
## Implementierungsstatus
## Kurzfazit
### ✅ Vollständig implementiert
| Feature | Seite / Datei |
|---|---|
| Dashboard: Assignment-Liste | `Dashboard.tsx` |
| Assignment anlegen (Typ, Institutees, Phasen-Preview) | `AssignmentCreate.tsx` |
| Assignment nachbearbeiten (Nummer, Typ-Wechsel-Warnung, Institutee-Laufzeiten) | `AssignmentEdit.tsx` |
| Phasen-Flow-Ansicht pro Assignment | `AssignmentDetail.tsx` |
| Soft-Delete + Papierkorb für Meetings | `AssignmentDetail.tsx` |
| Bewertungs-Chips je Institutee + Kategorie in der Übersicht | `AssignmentDetail.tsx` |
| Live-Gesprächsprotokoll mit Sprecher-Zeitlinie | `MeetingView.tsx` |
| Schnellnotation (+, !, >, […]) mit farbigem Rendering | `MeetingView.tsx`, `notationParser.tsx` |
| Äh-Counter pro Institutee (live + nachträgliche Anpassung) | `MeetingView.tsx` |
| Gesamtbewertung auf Kriterium-Ebene (Accordion, farbige Kategorie-Header) | `MeetingView.tsx` |
| Auto-Calc Bewertung aus Protokoll-Kategorie-Scores | `MeetingView.tsx` |
| Allgemeine Notizen mit Auto-Resize + Notation-Rendering | `MeetingView.tsx` |
| Meeting-Export: Markdown + JSON, strukturierter Dateiname | `meetingExport.ts` |
| Assignment-Nummer im Dateinamen | `MeetingView.tsx`, `AssignmentDetail.tsx` |
| Strukturiertes Abschluss-Feedback pro Institutee | `AssignmentFeedbackPage.tsx` |
| KI-Feedback-Generierung via OpenRouter | `AssignmentFeedbackPage.tsx` |
| Feedback-Status: Entwurf → Final | `AssignmentFeedbackPage.tsx` |
| Feedback-Dimensionsstruktur CRUD mit Gewichtung (0,0–2,0 in 0,1-Schritten) | `FeedbackStructureConfig.tsx` |
| AssignmentTyp CRUD mit Phasen-Templates | `AssignmentTypeConfig.tsx` |
| Kriterien-Auswahl je Typ (Baumstruktur, Indeterminate) | `AssignmentTypeConfig.tsx` |
| Kriterien-Filterung je Typ in Meetings + Übersicht | `MeetingView.tsx`, `AssignmentDetail.tsx` |
| Vollständiges DB-Backup (JSON Export/Import) | `dbBackup.ts`, `Configuration.tsx` |
| Berater/Gruppen-Verwaltung | `Consultants.tsx` |
| PWA (installierbar, Offline-fähig) | Vite-PWA-Plugin |
| Konfiguration zentralisiert (Farben, Ratings, AI-Settings) | `src/config/constants.ts` |
| `db/index.ts` in types/schema/seeds aufgeteilt (Re-Export bleibt kompatibel) | `src/db/` |
| `MeetingView.tsx` in 3 Dateien aufgeteilt | `src/pages/meeting/` |
| Auswertung auf `feedbackCriterionItems` migriert, nach Assignment gruppiert, Papierkorb/Status gefiltert, N/A-Bug gefixt | `Evaluation.tsx` |
| Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung (konfigurierbarer Gewichtungsfaktor, Vorschlags-Badge auf Feedback-Seite, Trend-Satz-Insertion, KI-Prompt-Erweiterung) | `Evaluation.tsx`, `AssignmentFeedbackPage.tsx`, `src/utils/ratingTrend.ts`, `RatingWeightConfig.tsx` |
| Kriterium-Zuordnung für Freitext-Notizen — pro Zeile innerhalb eines zusammenhängenden Sprecher-Blocks (`ConversationEntry.lineTags`, Matching per Zeilentext), Chip+Popover mit Suche direkt an der Zeile, nachträglich im Protokoll; `buildAiPrompt` führt Assessment- und Konversations-Notizen pro Kriterium zusammen | `ConversationTab.tsx`, `CriterionTagPicker.tsx`, `notationParser.tsx`, `MeetingView.tsx`, `AssignmentFeedbackPage.tsx` |
| Notation → Bewertungs-Vorschlag in der Gesamtbewertung: `!`/`(!)`/`(+)`/`+` an getaggten Zeilen ergeben einen Rating-Vorschlag je Kriterium ("Vorschlag aus Protokoll" + Übernehmen), plus Kategorie-Kopfzeile zeigt Anzahl offener Vorschläge (💡 N) auch zugeklappt | `AssessmentTab.tsx`, `notationParser.tsx` (`detectNotationRating`) |
| Meeting-Export (Markdown) zeigt Kriterium-Namen korrekt (`db.criteria`-Legacy-Bug gefixt) + Kriterium pro getaggter Zeile; Voll-Backup deckt wieder alle Tabellen ab (`appSettings` ergänzt) | `meetingExport.ts`, `dbBackup.ts` |
| Assignment-Nummer ist Pflichtfeld beim Anlegen | `AssignmentCreate.tsx` |
| Kriterium-Gewichtung feiner (0,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` |
Die V2-App ist für den DEV-/Team-Einsatz **funktional weitgehend fertig**: Skala 1–10, Meetings/Checklisten/Tagging, Feedback-KI-Kaskade A→B, Starter-ZIP, Assignment-Übergabe.
**Heute erledigt:** … **UX.3 Teil 1**; **Personen v2** (E-Mail, Gruppen-CRUD, Verschieben, Excel-Import, Archiv, Detail inkl. Entwicklung, Lösch-Kaskade).
**Als Nächstes:** Meeting-Polish (UX.4); parallel optional T4.
---
## ⚠️ Offene Punkte / Bugs
## Implementierungsstatus (Ist)
### Priorität Hoch
### Kernplattform
| # | 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/schema.ts` | L (Wochen) |
| H2 | **Backup-Reminder fehlt**: Es gibt keine automatische Erinnerung / keinen Hinweis nach abgeschlossenem Meeting, dass ein Backup erstellt werden sollte. | `pages/meeting/MeetingView.tsx` | S |
| H6 | **KI-Generierung funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert den Call zu `openrouter.ai`. Root Cause noch nicht untersucht. | `AssignmentFeedbackPage.tsx` | ? |
| H7 | **Prompt-System-Neubau (noch nicht gescoped)**: Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Spezialwissen je Dimension + Platzhaltersystem, löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab. Braucht eigene Design-Session. | `AssignmentFeedbackPage.tsx` | L |
### Priorität Mittel
| # | Beschreibung | Datei | Aufwand |
|---|---|---|---|
| M5 | **`db/queries/`-Layer fehlt**: DB-Zugriffe direkt aus Komponenten (`db.*`), keine Abstraktionsschicht. Erschwert die geplante SQLite-Migration (H1). | `src/db/queries/` (neu) | M |
| M6 | **Selbstlernendes Tagging (Idee, noch nicht gescoped)**: Zeile-zu-Kriterium-Zuordnungen sammeln und daraus beim Tippen automatische Tag-Vorschläge ableiten. Braucht eigene Design-Session. | `CriterionTagPicker.tsx` | ? |
### Priorität Niedrig
| # | Beschreibung | Datei | Aufwand |
|---|---|---|---|
| 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 |
---
## Geplante Erweiterungen (Roadmap)
### SQLite WASM + OPFS (Datenbankevolution)
**Ziel:** Robuste lokale Datenhaltung ohne Installation, ohne Risiko durch Browser-Speicherdruck.
**Technologie:** SQLite compiled to WebAssembly, persistiert via Origin Private File System (OPFS).
**Vorteil:** Echte Datei auf Gerät, nicht löschbar wie IndexedDB. Kein Server nötig.
**Status:** Konzept beschlossen. Implementierung steht aus. Paralleles Backup-System ist die aktuelle Notlösung.
### Evaluation — Zeitgewichteter Vorschlag (erledigt, 2026-07-02)
Vollständig umgesetzt, siehe `docs/EVALUATION_FEEDBACK_REDESIGN.md` für die Design-Entscheidungen:
- Kategorie-Scores werden über `src/utils/ratingTrend.ts` zeitgewichtet gemittelt (Rang = Reihenfolge der tatsächlich erfolgten Bewertungen dieser Kategorie, nicht fester Meeting-Index); Gewichtungsfaktor konfigurierbar unter Config → Gewichtung (`RatingWeightConfig.tsx`, Tabelle `appSettings`)
- Trend (Verbesserung/Verschlechterung) wird ab 2 bewerteten Meetings pro Kategorie erkannt (Delta ≥ 0,5 Punkte)
- Vorschlags-Badge auf `AssignmentFeedbackPage` dauerhaft sichtbar neben dem manuellen Rating, mit „Übernehmen“ und „Trend in Text übernehmen" (fügt deterministischen Satz in Achievements/Development Needs ein)
- `buildAiPrompt` um Trend-Abschnitt erweitert; dabei auch den Legacy-`db.criteria`-Bug (ehem. M2) gefixt
- `Assessment`- und `FeedbackCategoryRating`-Daten bleiben bewusst getrennte, unabhängige Strukturen — der Vorschlag ist rein additiv
### Kriterium-Tagging + Notation-Vorschlag (erledigt, 2026-07-03)
- `ConversationEntry.lineTags: { text, criterionItemId }[]` ordnet einzelne Zeilen innerhalb eines Sprecher-Blocks einem Kriterium zu (Matching per exaktem Zeilentext, nicht Index — überlebt nachträgliches Einfügen/Löschen von Zeilen). Ein früherer Zwischenstand mit "ein Entry pro Zeile" wurde verworfen, weil er die Protokoll-Lesbarkeit zerstört hat.
- `detectNotationRating()` (`notationParser.tsx`) mappt `!`/`(!)`/`(+)`/`+` auf eine Bewertungsstufe; kombiniert mit getaggten Zeilen liefert `AssessmentTab.tsx` einen Rating-Vorschlag pro Kriterium-Item ("Vorschlag aus Protokoll"), unabhängig von der bestehenden `ConversationSkillScore`-Kategorie-Schnellbewertung.
- Füllwörter/Weichmacher-Formulierungen werden bewusst nicht über eigene Text-Erkennung behandelt, sondern über ein niedrig gewichtetes Kriterium + normales Tagging abgedeckt.
- Kriterium-Gewichtung dabei auf `0,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)
- Assignment-Nummer anzeigen
- Filtern nach Status (aktiv/abgeschlossen)
- Sortierung nach Datum / Assignment-Nummer
---
## Bekannte Datenkonsistenz-Risiken
| Risiko | Beschreibung | Mitigation |
| Feature | Status | Ort |
|---|---|---|
| `criteriaIds` auf `AssignmentType` | Referenziert `FeedbackCriterionItem.id`. Wenn Kriterien gelöscht werden, bleiben verwaiste IDs in `criteriaIds`. Filter ignoriert fehlende IDs stillschweigend. | Beim Löschen von Items: `criteriaIds` in allen AssignmentTypes bereinigen |
| `Assessment.criteriaId` | Referenziert `FeedbackCriterionItem.id`. Kein FK-Constraint in IndexedDB. | Beim Löschen von Items: zugehörige Assessments löschen |
| `ConversationSkillScore.criteriaId` | Referenziert `FeedbackCategory.id`. Gleiche Gefahr. | Beim Löschen von Kategorien: ConversationSkillScores bereinigen |
| `ConversationEntry.lineTags[].criterionItemId` | Referenziert `FeedbackCriterionItem.id`. Kein FK-Constraint — beim Löschen eines Kriterium-Items bleiben Zeilen-Tags verwaist (Anzeige zeigt dann kein Kriterium mehr, aber Datensatz bleibt). | Beim Löschen von Items: `lineTags` in betroffenen `conversationEntries` bereinigen |
| Typ-Wechsel bei Assignment | Zeigt Warnung, löscht aber keine alten Meeting-Bewertungen. Bewertungen mit IDs aus dem alten Typ-Kriteriensatz bleiben erhalten, werden aber möglicherweise nicht mehr angezeigt. | Dokumentiertes Verhalten, absichtlich so gestaltet |
| React 19 + Vite PWA, Router 7, Tailwind v4 | ☑ | `package.json`, `src/` |
| Express + `node:sqlite`, RPC `POST /api/rpc` | ☑ | `server/` |
| 26 Tabellen via `tableRegistry` (Schema + Backup) | ☑ | `server/db/tableRegistry.ts` |
| Client-Queries 1:1 Server-Queries | ☑ | `src/db/queries/`, `server/db/queries/` |
| DEV-Ports 5174/4001, Pfad-Guards | ☑ | `.env.example`, `database.ts` |
| Dexie/IndexedDB-Legacy | ☑ entfernt | kein `dexie`, kein `schema.ts`/`seeds/` |
### Bewertung & Meetings
| Feature | Status | Ort |
|---|---|---|
| Skala 1–10, Farbbänder, ScorePicker | ☑ inkl. Smoke (C0) | Config → Skala, `constants.ts`, `npm run smoke` |
| Meeting-Kategorie-Scores | ☑ | `meetingCategoryRatings` |
| Protokoll + Notation (führende Zahl), Füllwörter | ☑ | `ConversationTab`, `notationParser` |
| Kriterium-Tags Zeile / allgemeine Notizen | ☑ | `lineTags`, `generalNoteTags` |
| Checklisten (Config + Meeting + Export) | ☑ | `ChecklistTab`, `checklists` RPC |
| Papierkorb/Archiv Assignments + Meeting-Trash | ☑ | Dashboard, Detail |
| Gesamtprotokoll Historie | ☑ | `MeetingHistory.tsx` |
| PDF Assignment-Report + Institutee-Feedback | ☑ | `pdfExport.tsx` |
| MD/JSON Meeting-Export | ☑ | `meetingExport.ts` |
### Feedback & KI
| Feature | Status | Ort |
|---|---|---|
| Benefits/Concerns Kategorie + Dimension | ☑ Code · Smoke ☐ (T4) | `AssignmentFeedbackPage`, Schema |
| KI-Kaskade Stufe A (Cat) → B (Dim) | ☑ | `aiPrompt.ts`, Config → KI-Prompts |
| Tagging Protokoll (`ZEILE n:`) inkl. Zeichen-/Zeilen-Batches | ☑ | `taggingBatches.ts`, `MeetingView.analyzeSuggestions` |
| Checklisten-Tagging (`PUNKT n:`) gebatcht | ☑ | `checklistTagging.ts` |
| KI-Client: Timeout, Retry, Prompt-Größen-Guard | ☑ | `aiClient.ts`, `AI_CONFIG` |
| Typ → sichtbare Struktur | ☑ | `resolveVisibleStructure` |
### Verteilung & Übergabe
| Feature | Status | Ort |
|---|---|---|
| Starter-Struktur ohne Personen | ☑ | `docs/starter/`, `npm run setup` |
| Team-ZIP | ☑ | `npm run pack:zip`, `EINRICHTUNG.md` |
| Laptop-Umzug ZIP | ☑ | `npm run pack:laptop`, `UMZUG.md` (Code + volle DB) |
| Assignment-Übergabe Export/Import | ☑ | `assignmentTransfer`, Detail ↔ / Dashboard ↔ |
### Cascade-Plan (Kurz)
| Paket | Status |
|---|---|
| 0 Smoke Skala | ☑ |
| 1 / 1b / 2 / 3 | ☑ |
| 4 / 5 UI+PDF Benefits | ☑ Code · Smoke ☐; 5.3 MD/JSON-Feedback ☐ |
| 6 Tagging-Batches | ☑ |
| 7 KI-Robustheit | ☑ |
| 8.3 Dexie-Legacy | ☑ |
| UX-Paket | UX.1–UX.3 Teil1 ☑ · Personen v2 ☑ · UX.4–UX.8 ☐ |
Details und Checkboxen: [`FEEDBACK_CASCADE_PLAN.md`](./FEEDBACK_CASCADE_PLAN.md).
---
## Offene Punkte (priorisiert)
### Hoch / als Nächstes sinnvoll
| ID | Thema | Hinweis |
|---|---|---|
| **T4** | Cascade 4/5 Feinschliff | Benefits/Concerns UI+PDF smoke; optional 5.3 MD/JSON Assignment-Feedback |
### Mittel
| ID | Thema | Hinweis |
|---|---|---|
| **T1** | npm-Deprecations / Audit | z.B. ungenutztes `recharts`, transitive Warnungen — beim nächsten Release/ZIP |
| **T5** | Evaluation / FeedbackDraft Legacy | an 1–10 + Benefits anpassen oder deprecaten (Cascade 8.5) |
| **T3** | Backup-Reminder nach Meeting | nie gebaut |
| **T6** | Datenschutz Tagging+Feedback (externe API) | vor Produktivnutzung organisatorisch |
### Niedrig
| ID | Thema | Hinweis |
|---|---|---|
| **N1** | `instituteeEnrollments` schon beim Anlegen | Create vs. nur Edit |
| **N2** | Assignment-Nummer in URL | Nice-to-have |
| **N3** | Notation-Schnellbuttons / mobile Hit-Areas | Cascade 8.2 / UX |
| **N4** | UX-Paket (Cascade) | UX.1–UX.3 Teil1 ☑ · Personen v2 ☑ · UX.4 Meeting offen |
| **N5** | Server seed-if-empty | weitgehend durch Starter/`setup` ersetzt — optional |
### Erledigt / obsolet (nicht wieder öffnen)
| ID / Thema | Status |
|---|---|
| **C0** Smoke Skala 1–10 | ☑ 2026-08-16 — `npm run smoke` (31 pass) + Abnahme |
| **C6** Tagging-Batches | ☑ 2026-08-16 — `taggingBatches.ts`; Limits in `AI_CONFIG` |
| **C7** KI-Client Timeout/Retry/Prompt-Guard | ☑ 2026-08-16 — `aiClient.ts` |
| **T2** Dexie/IndexedDB-Legacy | ☑ 2026-08-16 — Dependency + `schema.ts` + `seeds/` entfernt |
| Cascade 1 / 1b / 2 / 3 | ☑ Benefits-Schema, Sichtbarkeit, KI A→B |
| IndexedDB → SQLite WASM + OPFS | Obsolet — lokaler Server + SQLite |
| `db/queries/`-Layer fehlt | ☑ 2026-07-04 |
| Selbstlernendes Tagging „Idee“ | Basis + Batches erledigt |
| Prompt-System Neubau „ungescoped“ | Stufe A/B + Config erledigt |
| Enum-Skala Client-Ready | Abgelöst durch 1–10 |
| KI „komplett kaputt“ (alter Monolith) | Architektur ersetzt |
### Produktiv-Kontext (nicht V2-Dev)
| Thema | Hinweis |
|---|---|
| Echtdaten-Migration Produktiv → ggf. späteres System | V2 eigene DB + Starter; Produktiv-Ordner unangetastet lassen |
---
## Empfohlene Reihenfolge
```text
UX.4 Meeting · UX.5 Feedback · UX.6 Config · UX.7–8
→ T4 Cascade 4/5 Smoke + ggf. 5.3
→ T1 npm-Bereinigung (nächstes Team-ZIP/Release)
→ T5 Legacy Evaluation/FeedbackDraft (oder deprecaten)
→ T6 Datenschutz vor Prod
```
---
## Bekannte Konsistenz-Risiken
| Risiko | Mitigation |
|---|---|
| Verwaiste IDs in `criteriaIds` / Tags nach Kriterium-Löschung | Cascade/`reassign` in Feedback-Struktur-Config |
| Assignment-Übergabe braucht passende Feedback-Struktur auf dem Ziel | Gleicher Starter; Import mappt per Name |
| Checkliste ↔ Assessment | Kein Auto-Spiegel; Evidence über Primärquellen |
| Voll-Backup-Import = Wipe | Nicht mit Assignment-Übergabe verwechseln |
| Sehr lange Einzelzeile im Protokoll | Wird nicht mitten im Text gesplittet — eigener Batch / Struktur-Verengung / Hard-Limit |
---
## Dokumentations-Landkarte
| Doc | Rolle |
|---|---|
| `HANDOVER.md` | Session-Einstieg, Stack, Fallstricke |
| `STATUS_UND_OFFENE_PUNKTE.md` | **Diese Datei** — Ist + Offene Punkte (kanonisch) |
| `SMOKE_TESTS.md` | Automatisierter `npm run smoke` + manuelle UI-Checkliste (C0/C6/T4) |
| `DEV_ISOLATION.md` | Ports, Guards, Produktiv-Trennung |
| `TEAM_SETUP.md` / `EINRICHTUNG.md` | Kollegen-Onboarding / ZIP |
| `SCALE_1_TO_10.md` | Skalen-Konzept |
| `FEEDBACK_CASCADE_PLAN.md` | Feature-Arbeitsliste Feedback/KI |
| `CLAUDE.md` | Agent-Regeln + lange Erledigt-Historie |
| `FACHLICHE_` / `TECHNISCHE_ARCHITEKTUR.md` | Historisch — bei Widerspruch HANDOVER/STATUS/Code gewinnen |

114
docs/TEAM_SETUP.md Normal file
View File

@ -0,0 +1,114 @@
# Team-Setup — Assignment Monitor V2
Verteilung an Teammitglieder **nur über dieses Repo** (`AssigmentMonitorV2`).
Das Produktivsystem (`c:\dev\AssigmentMonitor`, Ports 5173/4000) wird **nicht** angefasst und darf nicht verändert werden.
## Was im Starter-Paket steckt
Datei: [`docs/starter/starter-structure.json`](./starter/starter-structure.json)
| Enthalten | Nicht enthalten |
|---|---|
| Feedback-Struktur (Dimensionen, Kategorien, Kriterien) | Personen / Groups / Consultants |
| Dimension-Prompts, Tagging-Prompts, Farbbänder | Assignments, Meetings, Notizen |
| Assignment-Typen, Phasen, Checklisten-Definitionen | Scores, Feedback-Entwürfe, Backups mit Echtdaten |
`learnedPatternHints` aus echten Protokollen werden beim Export entfernt.
## Neuinstallation per ZIP (ohne Git)
1. ZIP entpacken (z.B. nach `C:\dev\AssigmentMonitorV2`)
2. Anleitung im Paket: **`EINRICHTUNG.md`** (Kurzfassung)
```powershell
cd C:\dev\AssigmentMonitorV2
copy .env.example .env
npm install
npm run setup
npm run dev:all
```
(`npm run setup` = Starter-Struktur; alternativ `npm run bootstrap -- --yes`)
Öffnen: **http://localhost:5174**
ZIP erzeugen (Maintainer, nur V2):
```powershell
npm run pack:zip
```
Die Datei liegt unter `dist-release/AssignmentMonitor-Team-YYYY-MM-DD.zip` (gitignored, enthält keine Dev-DB und kein `.env`).
### Laptop-Umzug (Code + komplette Datenbank)
Für den eigenen Rechnerwechsel, nicht für Kollegen:
```powershell
npm run pack:laptop
```
Datei: `dist-release/AssignmentMonitor-Umzug-YYYY-MM-DD.zip` — enthält die SQLite unter `server/data/` (personenbezogene Daten). Anleitung im Paket: **`UMZUG.md`**. Am Ziel **kein** `npm run setup` (sonst wird die mitgelieferte DB überschrieben).
### Assignment-Übergabe (Lead → Lead)
Nicht das Team-ZIP — sondern ein einzelnes Assignment inkl. Notizen/Checklisten/Beratern:
- **Export:** Assignment öffnen → **↔ Übergabe**
- **Import:** Dashboard → **↔ Import**
Voraussetzung: gleiche Feedback-Struktur (Starter) auf beiden Rechnern. Details: `docs/HANDOVER.md`, RPC `assignmentTransfer`.
## Neuinstallation per Git
Voraussetzungen: **Node.js 25+** (wegen `node:sqlite`), Git.
```powershell
git clone <URL-dieses-Repos>
cd AssigmentMonitorV2
copy .env.example .env
npm install
npm run setup
npm run dev:all
```
Öffnen: **http://localhost:5174** (nicht 5173).
Optional: OpenRouter-API-Key unter **Config → KI-Prompts / Feedback**.
## Starter aktualisieren (Maintainer, nur V2)
Nach Änderungen an Struktur/Typen/Checklisten/Prompts in der **eigenen V2-Dev-DB**:
```powershell
cd C:\dev\AssigmentMonitorV2
npm run starter:export
git add docs/starter/starter-structure.json
git commit -m "Starter-Paket: Struktur aktualisiert"
```
Teammitglieder holen die Datei per `git pull` und spielen bei Bedarf neu ein:
```powershell
npm run bootstrap -- --yes
```
**Achtung:** `bootstrap` löscht die lokale V2-Dev-DB-Inhalte (Meetings etc.) und ersetzt sie durch das Starter-Paket.
## Sicherheitsregeln
- Nie Produktiv-Ordner, -Ports (5173/4000) oder Produktiv-SQLite verwenden — siehe [`DEV_ISOLATION.md`](./DEV_ISOLATION.md).
- Starter-Skripte brechen ab bei `API_PORT=4000`, `PORT=5173` oder Dateiname `assignment-monitor.sqlite3`.
- Kein Produktiv-Backup in V2 importieren, wenn ihr nur Struktur teilen wollt.
## npm-Scripts
| Script | Zweck |
|---|---|
| `npm run starter:export` | Struktur aus V2-Dev-DB → `docs/starter/starter-structure.json` |
| `npm run bootstrap -- --yes` | Starter in V2-Dev-DB einspielen (Wipe) |
| `npm run setup` | Wie bootstrap + legt `.env` an falls fehlend |
| `npm run pack:zip` | Versandfertiges Team-ZIP nach `dist-release/` (ohne DB) |
| `npm run pack:laptop` | Laptop-Umzug: Code + komplette Dev-DB nach `dist-release/` |
| `npm run db:reset` | Nur Dev-DB-Datei löschen (leere DB beim nächsten Start) |
| `npm run dev:all` | Frontend :5174 + API :4001 |

View File

@ -1,5 +1,7 @@
# Technische Architektur — Assignment Monitor
> **Stand Hinweis (2026-08-16):** Dieses Dokument ist **historisch** (React 18, Dexie/IndexedDB, Router 6, Enum-Skala). Aktueller Stack: React 19, Router 7, Express + `node:sqlite`, Skala 1–10 — siehe [`HANDOVER.md`](./HANDOVER.md), [`STATUS_UND_OFFENE_PUNKTE.md`](./STATUS_UND_OFFENE_PUNKTE.md). Bei Widerspruch gewinnen HANDOVER/STATUS/Code.
## Stack
| Schicht | Technologie | Version | Zweck |

8
docs/starter/README.md Normal file
View File

@ -0,0 +1,8 @@
# Starter-Struktur
Maschinell erzeugtes Paket für Team-Onboarding — **ohne personenbezogene Daten**.
- Erzeugen: `npm run starter:export` (nur AssignmentMonitorV2)
- Einspielen: `npm run bootstrap -- --yes`
Siehe [`../TEAM_SETUP.md`](../TEAM_SETUP.md).

File diff suppressed because it is too large Load Diff

View File

@ -2,9 +2,20 @@
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Assignment Monitor</title>
<meta name="description" content="Live-Bewertung und Feedback für Capgemini-Trainees während Kunden-Assignments." />
<meta name="theme-color" content="#0070AD" />
<meta name="application-name" content="Assignment Monitor" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Assignment Monitor" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/pwa-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

127
package-lock.json generated
View File

@ -1,20 +1,20 @@
{
"name": "assignment-monitor",
"name": "assignment-monitor-v2",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "assignment-monitor",
"name": "assignment-monitor-v2",
"version": "0.0.0",
"dependencies": {
"@react-pdf/renderer": "^4.5.1",
"dexie": "^4.0.11",
"express": "^4.21.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.6.2",
"recharts": "^2.15.3"
"recharts": "^2.15.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
@ -67,7 +67,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@ -3534,7 +3533,6 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -3642,7 +3640,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3660,6 +3657,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@ -3977,7 +3983,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
@ -4087,6 +4092,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -4137,6 +4155,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -4304,6 +4331,18 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4616,12 +4655,6 @@
"node": ">=8"
}
},
"node_modules/dexie": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.4.tgz",
"integrity": "sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==",
"license": "Apache-2.0"
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
@ -4949,7 +4982,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -5481,6 +5513,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -7606,7 +7647,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -7616,7 +7656,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -8354,6 +8393,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -8628,7 +8679,6 @@
"integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@ -9572,7 +9622,6 @@
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -9811,6 +9860,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -9914,7 +9981,6 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -10101,6 +10167,27 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -8,7 +8,13 @@
"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",
"smoke": "tsx --env-file=.env server/scripts/smokeScaleAndTagging.ts",
"db:reset": "tsx --env-file=.env server/scripts/resetDevDatabase.ts",
"starter:export": "tsx --env-file=.env server/scripts/exportStarterPackage.ts",
"bootstrap": "tsx --env-file=.env server/scripts/bootstrapFromStarter.ts",
"setup": "tsx --env-file=.env server/scripts/teamSetup.ts",
"pack:zip": "tsx server/scripts/packTeamZip.ts",
"pack:laptop": "tsx --env-file=.env server/scripts/packLaptopZip.ts",
"preload:feedback-structure": "tsx --env-file=.env server/scripts/preloadFeedbackStructure.ts",
"build": "tsc -b && vite build",
"lint": "eslint .",
@ -16,12 +22,12 @@
},
"dependencies": {
"@react-pdf/renderer": "^4.5.1",
"dexie": "^4.0.11",
"express": "^4.21.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.6.2",
"recharts": "^2.15.3"
"recharts": "^2.15.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.25.0",

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

4
public/favicon.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Assignment Monitor">
<rect width="32" height="32" rx="6" fill="#0070AD"/>
<text x="16" y="22" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff">AM</text>
</svg>

After

Width:  |  Height:  |  Size: 309 B

BIN
public/pwa-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/pwa-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 KiB

View File

@ -88,24 +88,28 @@ export function saveDefaultDimensionPromptTemplate(template: string): number {
}
/**
* 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`.
* Prompt-Template für Protokoll-Tagging („🏷 Vorschläge analysieren", Freitext-Zeilen).
* Checklisten haben seit der Trennung ein eigenes Template (`checklistTaggingPromptTemplate`).
* Alte Zwischenstände mit `{{CHECKLISTEN_PUNKTE}}` im Protokoll-Prompt werden einmalig auf den
* aktuellen Protokoll-Standard migriert — sonst bleibt der Platzhalter unersetzt und die KI
* liefert keine brauchbaren ZEILE-Vorschläge mehr.
*/
export function getOrSeedCriterionTaggingPromptTemplate(): string {
const fresh = AI_CONFIG.defaultCriterionTaggingPromptTemplate
let settings = getRowById<AppSettings>('appSettings', 1)
if (!settings) {
insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
criterionTaggingPromptTemplate: AI_CONFIG.defaultCriterionTaggingPromptTemplate,
criterionTaggingPromptTemplate: fresh,
})
return AI_CONFIG.defaultCriterionTaggingPromptTemplate
return fresh
}
if (!settings.criterionTaggingPromptTemplate) {
updateRow('appSettings', 1, { criterionTaggingPromptTemplate: AI_CONFIG.defaultCriterionTaggingPromptTemplate })
return AI_CONFIG.defaultCriterionTaggingPromptTemplate
const stored = settings.criterionTaggingPromptTemplate
if (!stored || stored.includes('{{CHECKLISTEN_PUNKTE}}')) {
updateRow('appSettings', 1, { criterionTaggingPromptTemplate: fresh })
return fresh
}
return settings.criterionTaggingPromptTemplate
return stored
}
export function saveCriterionTaggingPromptTemplate(template: string): number {
@ -115,6 +119,32 @@ export function saveCriterionTaggingPromptTemplate(template: string): number {
return 1
}
export function getOrSeedChecklistTaggingPromptTemplate(): string {
let settings = getRowById<AppSettings>('appSettings', 1)
if (!settings) {
insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
checklistTaggingPromptTemplate: AI_CONFIG.defaultChecklistTaggingPromptTemplate,
})
return AI_CONFIG.defaultChecklistTaggingPromptTemplate
}
if (!settings.checklistTaggingPromptTemplate) {
updateRow('appSettings', 1, { checklistTaggingPromptTemplate: AI_CONFIG.defaultChecklistTaggingPromptTemplate })
return AI_CONFIG.defaultChecklistTaggingPromptTemplate
}
return settings.checklistTaggingPromptTemplate
}
export function saveChecklistTaggingPromptTemplate(template: string): number {
const existing = getRowById('appSettings', 1)
if (existing) updateRow('appSettings', 1, { checklistTaggingPromptTemplate: template })
else insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
checklistTaggingPromptTemplate: template,
})
return 1
}
export function getOrSeedDefaultCategoryPromptTemplate(): string {
let settings = getRowById<AppSettings>('appSettings', 1)
if (!settings) {
@ -140,3 +170,29 @@ export function saveDefaultCategoryPromptTemplate(template: string): number {
})
return 1
}
export function getOrSeedRequirePlannedDates(): boolean {
let settings = getRowById<AppSettings>('appSettings', 1)
if (!settings) {
insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
requirePlannedDates: false,
})
return false
}
if (settings.requirePlannedDates === undefined || settings.requirePlannedDates === null) {
updateRow('appSettings', 1, { requirePlannedDates: false })
return false
}
return !!settings.requirePlannedDates
}
export function saveRequirePlannedDates(required: boolean): number {
const existing = getRowById('appSettings', 1)
if (existing) updateRow('appSettings', 1, { requirePlannedDates: required })
else insertRowWithId('appSettings', 1, {
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
requirePlannedDates: required,
})
return 1
}

View File

@ -0,0 +1,527 @@
/**
* Einzel-Assignment Export/Import für Lead-Übergabe (Urlaub/Krankheit).
* Enthält Runtime-Daten inkl. Checklisten + referenzierte Berater/Gruppen.
* Struktur wird per stabilem Schlüssel gemappt; Berater werden bei Namensgleichheit
* wiederverwendet, sonst neu angelegt.
*/
import { db } from '../../database'
import {
getAllRows, getRowById, getRowsWhereEquals, getRowsWhereIn, insertRow,
} from '../crud'
import type {
Assignment, AssignmentType, PhaseTemplate, ChecklistItemDef, ChecklistResponse,
Group, Consultant, MeetingInstance, ConversationEntry, ConversationSkillScore,
Assessment, FeedbackDraft, AssignmentFeedback,
FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText,
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, ConversationLineTag,
AssignmentTransferPackage, AssignmentImportResult,
} from '../../../src/db/types'
import { ASSIGNMENT_TRANSFER_KIND } from '../../../src/db/types'
import { listMeetingInstancesForAssignment } from './meetings'
import { listChecklistItemDefsForPhaseTemplates, listChecklistResponsesForMeetings } from './checklists'
import { listConversationEntriesForMeetings } from './conversationEntries'
import { listAssessmentsForMeetings } from './assessments'
import { listMeetingCategoryRatingsForMeetings } from './meetingCategoryRatings'
const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' ')
const personKey = (c: Pick<Consultant, 'firstName' | 'lastName'>) =>
`${norm(c.firstName)}|${norm(c.lastName)}`
const emailKey = (c: Pick<Consultant, 'email'>): string | null => {
const e = (c.email ?? '').trim().toLowerCase()
return e || null
}
const groupKey = (g: Pick<Group, 'name' | 'cohortYear'>) =>
`${norm(g.name)}|${g.cohortYear}`
function collectCriterionIds(pkgParts: {
assignmentType: AssignmentType
phaseTemplates: PhaseTemplate[]
checklistItemDefs: ChecklistItemDef[]
assessments: Assessment[]
checklistResponses: ChecklistResponse[]
conversationEntries: ConversationEntry[]
meetingInstances: MeetingInstance[]
}): Set<number> {
const ids = new Set<number>()
for (const id of pkgParts.assignmentType.criteriaIds ?? []) ids.add(id)
for (const p of pkgParts.phaseTemplates) for (const id of p.criteriaIds ?? []) ids.add(id)
for (const c of pkgParts.checklistItemDefs) {
if (c.linkedCriterionItemId != null) ids.add(c.linkedCriterionItemId)
}
for (const a of pkgParts.assessments) ids.add(a.criteriaId)
for (const r of pkgParts.checklistResponses) {
if (r.criterionItemId != null) ids.add(r.criterionItemId)
}
for (const e of pkgParts.conversationEntries) {
for (const t of e.lineTags ?? []) ids.add(t.criterionItemId)
}
for (const m of pkgParts.meetingInstances) {
for (const t of m.generalNoteTags ?? []) ids.add(t.criterionItemId)
}
return ids
}
function remapTags(
tags: ConversationLineTag[] | undefined,
criterionMap: Map<number, number>,
): ConversationLineTag[] | undefined {
if (!tags) return tags
return tags.map(t => ({
text: t.text,
criterionItemId: criterionMap.get(t.criterionItemId) ?? t.criterionItemId,
}))
}
function requireMap(map: Map<number, number>, oldId: number, label: string): number {
const next = map.get(oldId)
if (next == null) throw new Error(`${label}: Quell-ID ${oldId} konnte nicht gemappt werden`)
return next
}
/** Exportiert ein Assignment inkl. aller Meetings/Notizen/Checklisten/Feedbacks und referenzierter Berater. */
export function exportAssignmentTransfer(assignmentId: number): AssignmentTransferPackage {
const assignment = getRowById<Assignment>('assignments', assignmentId)
if (!assignment) throw new Error('Assignment nicht gefunden')
const assignmentType = getRowById<AssignmentType>('assignmentTypes', assignment.assignmentTypeId)
if (!assignmentType) throw new Error('Assignment-Typ nicht gefunden')
const phaseTemplates = getRowsWhereEquals<PhaseTemplate>('phaseTemplates', 'assignmentTypeId', assignment.assignmentTypeId)
.sort((a, b) => a.order - b.order)
const phaseIds = phaseTemplates.map(p => p.id!)
const checklistItemDefs = listChecklistItemDefsForPhaseTemplates(phaseIds)
const meetingInstances = listMeetingInstancesForAssignment(assignmentId)
const meetingIds = meetingInstances.map(m => m.id!)
const conversationEntries = listConversationEntriesForMeetings(meetingIds)
const entryIds = conversationEntries.map(e => e.id!)
const conversationSkillScores = getAllRows<ConversationSkillScore>('conversationSkillScores')
.filter(s => entryIds.includes(s.conversationEntryId))
const assessments = listAssessmentsForMeetings(meetingIds)
const meetingCategoryRatings = listMeetingCategoryRatingsForMeetings(meetingIds)
const checklistResponses = listChecklistResponsesForMeetings(meetingIds)
const feedbackDrafts = getRowsWhereEquals<FeedbackDraft>('feedbackDrafts', 'assignmentId', assignmentId)
const assignmentFeedbacks = getRowsWhereEquals<AssignmentFeedback>('assignmentFeedbacks', 'assignmentId', assignmentId)
const feedbackIds = assignmentFeedbacks.map(f => f.id!)
const feedbackCategoryRatings = getRowsWhereIn<FeedbackCategoryRating>('feedbackCategoryRatings', 'assignmentFeedbackId', feedbackIds)
const feedbackDimensionTexts = getRowsWhereIn<FeedbackDimensionText>('feedbackDimensionTexts', 'assignmentFeedbackId', feedbackIds)
const feedbackCategoryTexts = getRowsWhereIn<FeedbackCategoryText>('feedbackCategoryTexts', 'assignmentFeedbackId', feedbackIds)
const instituteeIds = new Set<number>(assignment.instituteeIds)
for (const e of assignment.instituteeEnrollments ?? []) instituteeIds.add(e.instituteeId)
for (const e of conversationEntries) instituteeIds.add(e.instituteeId)
for (const a of assessments) instituteeIds.add(a.instituteeId)
for (const r of meetingCategoryRatings) instituteeIds.add(r.instituteeId)
for (const r of checklistResponses) if (r.instituteeId != null) instituteeIds.add(r.instituteeId)
for (const f of feedbackDrafts) instituteeIds.add(f.instituteeId)
for (const f of assignmentFeedbacks) instituteeIds.add(f.instituteeId)
const consultants = getRowsWhereIn<Consultant>('consultants', 'id', [...instituteeIds])
const groupIds = [...new Set(consultants.map(c => c.groupId))]
const groups = getRowsWhereIn<Group>('groups', 'id', groupIds)
const criterionIds = collectCriterionIds({
assignmentType, phaseTemplates, checklistItemDefs, assessments,
checklistResponses, conversationEntries, meetingInstances,
})
for (const r of meetingCategoryRatings) {
for (const i of getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', r.categoryId)) {
criterionIds.add(i.id!)
}
}
for (const r of feedbackCategoryRatings) {
for (const i of getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', r.feedbackCategoryId)) {
criterionIds.add(i.id!)
}
}
for (const s of conversationSkillScores) {
for (const i of getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', s.criteriaId)) {
criterionIds.add(i.id!)
}
}
const feedbackCriterionItems = getRowsWhereIn<FeedbackCriterionItem>('feedbackCriterionItems', 'id', [...criterionIds])
.map(i => ({ ...i, learnedPatternHints: undefined }))
const categoryIds = new Set(feedbackCriterionItems.map(i => i.categoryId))
for (const r of meetingCategoryRatings) categoryIds.add(r.categoryId)
for (const r of feedbackCategoryRatings) categoryIds.add(r.feedbackCategoryId)
for (const t of feedbackCategoryTexts) categoryIds.add(t.feedbackCategoryId)
for (const s of conversationSkillScores) categoryIds.add(s.criteriaId)
const feedbackCategories = getRowsWhereIn<FeedbackCategory>('feedbackCategories', 'id', [...categoryIds])
const dimensionIds = new Set(feedbackCategories.map(c => c.dimensionId))
for (const t of feedbackDimensionTexts) dimensionIds.add(t.feedbackDimensionId)
const feedbackDimensions = getRowsWhereIn<FeedbackDimension>('feedbackDimensions', 'id', [...dimensionIds])
return {
kind: ASSIGNMENT_TRANSFER_KIND,
version: 1,
exportedAt: new Date().toISOString(),
source: {
assignmentId,
assignmentNumber: assignment.assignmentNumber,
title: assignment.title,
client: assignment.client,
},
groups,
consultants,
assignmentType,
phaseTemplates,
checklistItemDefs,
feedbackDimensions,
feedbackCategories,
feedbackCriterionItems,
assignment,
meetingInstances,
conversationEntries,
conversationSkillScores,
assessments,
meetingCategoryRatings,
checklistResponses,
feedbackDrafts,
assignmentFeedbacks,
feedbackCategoryRatings,
feedbackDimensionTexts,
feedbackCategoryTexts,
}
}
/** Importiert ein Transfer-Paket als neues Assignment (Merge, kein Wipe). */
export function importAssignmentTransfer(pkg: AssignmentTransferPackage): AssignmentImportResult {
if (!pkg || pkg.kind !== ASSIGNMENT_TRANSFER_KIND || pkg.version !== 1) {
throw new Error('Ungültiges Assignment-Übergabe-Format')
}
if (!pkg.assignment || !pkg.assignmentType) {
throw new Error('Paket unvollständig (Assignment/Typ fehlt)')
}
const groupMap = new Map<number, number>()
const consultantMap = new Map<number, number>()
const dimensionMap = new Map<number, number>()
const categoryMap = new Map<number, number>()
const criterionMap = new Map<number, number>()
const typeMap = new Map<number, number>()
const phaseMap = new Map<number, number>()
const checklistDefMap = new Map<number, number>()
const meetingMap = new Map<number, number>()
const entryMap = new Map<number, number>()
const feedbackMap = new Map<number, number>()
const consultantsMatched: string[] = []
const consultantsCreated: string[] = []
const localDims = getAllRows<FeedbackDimension>('feedbackDimensions')
for (const d of pkg.feedbackDimensions) {
const byId = d.id != null ? getRowById<FeedbackDimension>('feedbackDimensions', d.id) : undefined
if (byId && norm(byId.name) === norm(d.name)) {
dimensionMap.set(d.id!, d.id!)
continue
}
const hit = localDims.find(x => norm(x.name) === norm(d.name))
if (!hit?.id) {
throw new Error(`Feedback-Dimension „${d.name}" fehlt lokal — bitte zuerst npm run setup / Starter laden.`)
}
dimensionMap.set(d.id!, hit.id)
}
const localCats = getAllRows<FeedbackCategory>('feedbackCategories')
for (const c of pkg.feedbackCategories) {
const dimId = requireMap(dimensionMap, c.dimensionId, 'Dimension')
const byId = c.id != null ? getRowById<FeedbackCategory>('feedbackCategories', c.id) : undefined
if (byId && norm(byId.name) === norm(c.name) && byId.dimensionId === dimId) {
categoryMap.set(c.id!, c.id!)
continue
}
const hit = localCats.find(x => x.dimensionId === dimId && norm(x.name) === norm(c.name))
if (!hit?.id) {
throw new Error(`Feedback-Kategorie „${c.name}" fehlt lokal — bitte Starter-Struktur angleichen.`)
}
categoryMap.set(c.id!, hit.id)
}
const localItems = getAllRows<FeedbackCriterionItem>('feedbackCriterionItems')
for (const item of pkg.feedbackCriterionItems) {
const catId = requireMap(categoryMap, item.categoryId, 'Kategorie')
const byId = item.id != null ? getRowById<FeedbackCriterionItem>('feedbackCriterionItems', item.id) : undefined
if (byId && norm(byId.name) === norm(item.name) && byId.categoryId === catId) {
criterionMap.set(item.id!, item.id!)
continue
}
const hit = localItems.find(x => x.categoryId === catId && norm(x.name) === norm(item.name))
if (!hit?.id) {
throw new Error(`Kriterium „${item.name}" fehlt lokal — bitte Starter-Struktur angleichen.`)
}
criterionMap.set(item.id!, hit.id)
}
const localTypes = getAllRows<AssignmentType>('assignmentTypes')
{
const t = pkg.assignmentType
const byId = t.id != null ? getRowById<AssignmentType>('assignmentTypes', t.id) : undefined
let typeId: number
if (byId && norm(byId.name) === norm(t.name)) {
typeId = t.id!
} else {
const hit = localTypes.find(x => norm(x.name) === norm(t.name))
if (hit?.id) {
typeId = hit.id
} else {
typeId = insertRow('assignmentTypes', {
name: t.name,
defaultPhaseKeys: t.defaultPhaseKeys,
criteriaIds: (t.criteriaIds ?? [])
.map(id => criterionMap.get(id))
.filter((id): id is number => id != null),
})
}
}
typeMap.set(t.id!, typeId)
}
const targetTypeId = requireMap(typeMap, pkg.assignmentType.id!, 'Assignment-Typ')
const localPhases = getRowsWhereEquals<PhaseTemplate>('phaseTemplates', 'assignmentTypeId', targetTypeId)
for (const p of pkg.phaseTemplates) {
const byKey = localPhases.find(x => x.phaseKey === p.phaseKey && norm(x.label) === norm(p.label))
?? localPhases.find(x => x.phaseKey === p.phaseKey)
let phaseId: number
if (byKey?.id) {
phaseId = byKey.id
} else {
phaseId = insertRow('phaseTemplates', {
assignmentTypeId: targetTypeId,
phaseKey: p.phaseKey,
label: p.label,
withWhom: p.withWhom,
isRepeatable: p.isRepeatable,
hasConversation: p.hasConversation,
hasAssessment: p.hasAssessment,
hasChecklist: p.hasChecklist,
criteriaIds: (p.criteriaIds ?? [])
.map(id => criterionMap.get(id))
.filter((id): id is number => id != null),
order: p.order,
})
}
phaseMap.set(p.id!, phaseId)
}
for (const def of pkg.checklistItemDefs) {
const phaseId = requireMap(phaseMap, def.phaseTemplateId, 'Phase')
const localDefs = getRowsWhereEquals<ChecklistItemDef>('checklistItemDefs', 'phaseTemplateId', phaseId)
const hit = localDefs.find(x => norm(x.label) === norm(def.label) && x.order === def.order)
?? localDefs.find(x => norm(x.label) === norm(def.label))
let defId: number
if (hit?.id) {
defId = hit.id
} else {
defId = insertRow('checklistItemDefs', {
phaseTemplateId: phaseId,
label: def.label,
linkedCriterionItemId: def.linkedCriterionItemId != null
? (criterionMap.get(def.linkedCriterionItemId) ?? null)
: null,
scope: def.scope,
order: def.order,
scoreContribution: def.scoreContribution,
})
}
checklistDefMap.set(def.id!, defId)
}
const pkgGroupsById = new Map(pkg.groups.map(g => [g.id!, g]))
for (const g of pkg.groups) {
const locals = getAllRows<Group>('groups')
const hit = locals.find(x => groupKey(x) === groupKey(g))
groupMap.set(g.id!, hit?.id ?? insertRow('groups', { name: g.name, cohortYear: g.cohortYear }))
}
for (const c of pkg.consultants) {
const label = `${c.firstName} ${c.lastName}`.trim()
const locals = getAllRows<Consultant>('consultants')
const ek = emailKey(c)
const hit = ek
? locals.find(x => emailKey(x) === ek)
: locals.find(x => personKey(x) === personKey(c))
if (hit?.id) {
consultantMap.set(c.id!, hit.id)
consultantsMatched.push(label)
continue
}
const srcGroup = pkgGroupsById.get(c.groupId)
if (!srcGroup) throw new Error(`Gruppe für Berater „${label}" fehlt im Paket`)
let groupId = groupMap.get(srcGroup.id!)
if (groupId == null) {
const gLocals = getAllRows<Group>('groups')
const gHit = gLocals.find(x => groupKey(x) === groupKey(srcGroup))
groupId = gHit?.id ?? insertRow('groups', { name: srcGroup.name, cohortYear: srcGroup.cohortYear })
groupMap.set(srcGroup.id!, groupId)
}
const newId = insertRow('consultants', {
groupId,
firstName: c.firstName,
lastName: c.lastName,
email: ek ?? '',
})
consultantMap.set(c.id!, newId)
consultantsCreated.push(label)
}
let newAssignmentId = 0
let checklistResponseCount = 0
db.exec('BEGIN')
try {
const a = pkg.assignment
newAssignmentId = insertRow('assignments', {
title: a.title,
client: a.client,
assignmentTypeId: targetTypeId,
instituteeIds: a.instituteeIds.map(id => requireMap(consultantMap, id, 'Berater')),
leadName: a.leadName,
status: a.status,
createdAt: a.createdAt || new Date().toISOString(),
assignmentNumber: a.assignmentNumber,
description: a.description,
instituteeEnrollments: (a.instituteeEnrollments ?? []).map(e => ({
...e,
instituteeId: requireMap(consultantMap, e.instituteeId, 'Berater'),
})),
deletedAt: null,
archivedAt: null,
})
for (const m of pkg.meetingInstances) {
const meetingId = insertRow('meetingInstances', {
assignmentId: newAssignmentId,
phaseTemplateId: requireMap(phaseMap, m.phaseTemplateId, 'Phase'),
phaseIndex: m.phaseIndex,
date: m.date,
status: m.status,
generalNotes: m.generalNotes ?? '',
deletedAt: m.deletedAt ?? null,
generalNoteTags: remapTags(m.generalNoteTags, criterionMap),
})
meetingMap.set(m.id!, meetingId)
}
for (const e of pkg.conversationEntries) {
const entryId = insertRow('conversationEntries', {
meetingInstanceId: requireMap(meetingMap, e.meetingInstanceId, 'Meeting'),
instituteeId: requireMap(consultantMap, e.instituteeId, 'Berater'),
sequenceIndex: e.sequenceIndex,
note: e.note,
fillerCount: e.fillerCount ?? 0,
updatedAt: e.updatedAt || new Date().toISOString(),
lineTags: remapTags(e.lineTags, criterionMap),
})
entryMap.set(e.id!, entryId)
}
for (const s of pkg.conversationSkillScores) {
insertRow('conversationSkillScores', {
conversationEntryId: requireMap(entryMap, s.conversationEntryId, 'Protokoll-Eintrag'),
criteriaId: requireMap(categoryMap, s.criteriaId, 'Kategorie (Skill-Score)'),
score: s.score,
})
}
for (const asmt of pkg.assessments) {
insertRow('assessments', {
meetingInstanceId: requireMap(meetingMap, asmt.meetingInstanceId, 'Meeting'),
instituteeId: requireMap(consultantMap, asmt.instituteeId, 'Berater'),
criteriaId: requireMap(criterionMap, asmt.criteriaId, 'Kriterium'),
score: asmt.score,
note: asmt.note ?? '',
updatedAt: asmt.updatedAt || new Date().toISOString(),
scoreNeedsReview: asmt.scoreNeedsReview ?? false,
})
}
for (const r of pkg.meetingCategoryRatings) {
insertRow('meetingCategoryRatings', {
meetingInstanceId: requireMap(meetingMap, r.meetingInstanceId, 'Meeting'),
instituteeId: requireMap(consultantMap, r.instituteeId, 'Berater'),
categoryId: requireMap(categoryMap, r.categoryId, 'Kategorie'),
score: r.score,
note: r.note,
})
}
for (const r of pkg.checklistResponses) {
insertRow('checklistResponses', {
meetingInstanceId: requireMap(meetingMap, r.meetingInstanceId, 'Meeting'),
instituteeId: r.instituteeId != null ? requireMap(consultantMap, r.instituteeId, 'Berater') : null,
checklistItemId: requireMap(checklistDefMap, r.checklistItemId, 'Checklistenpunkt'),
rating: r.rating,
note: r.note ?? '',
criterionItemId: r.criterionItemId != null ? (criterionMap.get(r.criterionItemId) ?? null) : null,
criterionOverride: r.criterionOverride ?? false,
})
checklistResponseCount++
}
for (const d of pkg.feedbackDrafts) {
insertRow('feedbackDrafts', {
assignmentId: newAssignmentId,
instituteeId: requireMap(consultantMap, d.instituteeId, 'Berater'),
content: d.content,
updatedAt: d.updatedAt || new Date().toISOString(),
})
}
for (const f of pkg.assignmentFeedbacks) {
const fbId = insertRow('assignmentFeedbacks', {
assignmentId: newAssignmentId,
instituteeId: requireMap(consultantMap, f.instituteeId, 'Berater'),
status: f.status,
aiModel: f.aiModel ?? '',
updatedAt: f.updatedAt || new Date().toISOString(),
})
feedbackMap.set(f.id!, fbId)
}
for (const r of pkg.feedbackCategoryRatings) {
insertRow('feedbackCategoryRatings', {
assignmentFeedbackId: requireMap(feedbackMap, r.assignmentFeedbackId, 'Feedback'),
feedbackCategoryId: requireMap(categoryMap, r.feedbackCategoryId, 'Kategorie'),
rating: r.rating,
})
}
for (const t of pkg.feedbackDimensionTexts) {
insertRow('feedbackDimensionTexts', {
assignmentFeedbackId: requireMap(feedbackMap, t.assignmentFeedbackId, 'Feedback'),
feedbackDimensionId: requireMap(dimensionMap, t.feedbackDimensionId, 'Dimension'),
achievements: t.achievements ?? '',
developmentNeeds: t.developmentNeeds ?? '',
suggestedAchievements: t.suggestedAchievements,
suggestedDevelopmentNeeds: t.suggestedDevelopmentNeeds,
})
}
for (const t of pkg.feedbackCategoryTexts) {
insertRow('feedbackCategoryTexts', {
assignmentFeedbackId: requireMap(feedbackMap, t.assignmentFeedbackId, 'Feedback'),
feedbackCategoryId: requireMap(categoryMap, t.feedbackCategoryId, 'Kategorie'),
benefits: t.benefits ?? '',
concerns: t.concerns ?? '',
suggestedBenefits: t.suggestedBenefits,
suggestedConcerns: t.suggestedConcerns,
})
}
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
throw err
}
return {
assignmentId: newAssignmentId,
consultantsMatched,
consultantsCreated,
meetings: pkg.meetingInstances.length,
checklistResponses: checklistResponseCount,
}
}

View File

@ -68,6 +68,8 @@ export function purgeExpiredAssignmentTrash(): void {
export interface AssignmentWithDetails extends Assignment {
typeName: string
/** Hex-Farbe des Assignment-Typs (Fallback Brand). */
typeColor: 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
@ -82,9 +84,12 @@ export function listAssignmentsWithDetails(): AssignmentWithDetails[] {
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 => ({
return raw.map(a => {
const t = typeMap.get(a.assignmentTypeId)
return {
...a,
typeName: typeMap.get(a.assignmentTypeId)?.name ?? '—',
typeName: t?.name ?? '—',
typeColor: t?.color?.trim() || '#0070AD',
instituteeNames: a.instituteeIds.map(id => {
const c = consMap.get(id)
return c ? `${c.firstName} ${c.lastName}` : '?'
@ -92,5 +97,6 @@ export function listAssignmentsWithDetails(): AssignmentWithDetails[] {
feedbackComplete: a.instituteeIds.every(iid =>
allFeedbacks.some(f => f.assignmentId === a.id && f.instituteeId === iid && f.status === 'final'),
),
}))
}
})
}

View File

@ -48,16 +48,23 @@ export function upsertChecklistResponse(input: {
checklistItemId: number
rating: ChecklistResponse['rating']
note: string
criterionItemId?: number
criterionItemId?: number | null
criterionOverride?: boolean
}): number {
const existing = getAllRows<ChecklistResponse>('checklistResponses').find(r =>
r.meetingInstanceId === input.meetingInstanceId &&
r.checklistItemId === input.checklistItemId &&
(r.instituteeId ?? null) === (input.instituteeId ?? null),
)
const row = {
rating: input.rating,
note: input.note,
criterionItemId: input.criterionItemId,
criterionOverride: input.criterionOverride,
}
if (existing?.id) {
updateRow('checklistResponses', existing.id, { rating: input.rating, note: input.note, criterionItemId: input.criterionItemId })
updateRow('checklistResponses', existing.id, row)
return existing.id
}
return insertRow('checklistResponses', input)
return insertRow('checklistResponses', { ...input, ...row })
}

View File

@ -1,12 +1,266 @@
import { getAllRows, getRowById, getRowsWhereIn, insertRow, updateRow, deleteRowById } from '../crud'
import type { Group, Consultant } from '../../../src/db/types'
import {
getAllRows, getRowById, getRowsWhereIn,
insertRow, updateRow, deleteRowById, deleteRowsWhereEquals, deleteRowsWhereIn,
} from '../crud'
import type {
Group, Consultant, Assignment, AssignmentFeedback, FeedbackCategoryRating,
FeedbackCategory, ConversationEntry, RatingScore,
ConsultantImportRow, ConsultantImportResult, HardDeleteConsultantResult,
ConsultantAssignmentSummary, ConsultantDetail,
} from '../../../src/db/types'
export type {
ConsultantImportRow, ConsultantImportResult, HardDeleteConsultantResult,
ConsultantAssignmentSummary, ConsultantDetail,
}
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase()
}
export function listGroups(): Group[] {
return getAllRows<Group>('groups').sort((a, b) =>
a.cohortYear !== b.cohortYear ? b.cohortYear - a.cohortYear : a.name.localeCompare(b.name),
)
}
export const getGroup = (id: number): Group | undefined => getRowById<Group>('groups', id)
export const listGroups = (): Group[] => getAllRows<Group>('groups')
export const addGroup = (group: Omit<Group, 'id'>): number => insertRow('groups', group)
export function updateGroup(id: number, patch: Partial<Group>): number {
return updateRow('groups', id, patch)
}
/** Nur leere Gruppen — sonst Fehlertext. */
export function deleteGroup(id: number): { ok: true } | { ok: false; reason: string; memberCount: number } {
const members = getAllRows<Consultant>('consultants').filter(c => c.groupId === id)
if (members.length > 0) {
return {
ok: false,
reason: 'Gruppe hat noch Personen — zuerst umziehen oder archivieren/löschen.',
memberCount: members.length,
}
}
deleteRowById('groups', id)
return { ok: true }
}
export const listConsultants = (): Consultant[] => getAllRows<Consultant>('consultants')
export function listActiveConsultants(): Consultant[] {
return listConsultants().filter(c => !c.archivedAt)
}
export function listArchivedConsultants(): Consultant[] {
return listConsultants().filter(c => !!c.archivedAt)
}
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)
export const getConsultantsByIds = (ids: number[]): Consultant[] =>
getRowsWhereIn<Consultant>('consultants', 'id', ids)
export function findConsultantByEmail(email: string): Consultant | undefined {
const key = normalizeEmail(email)
if (!key) return undefined
return listConsultants().find(c => normalizeEmail(c.email ?? '') === key)
}
function assertUniqueEmail(email: string, exceptId?: number): void {
const key = normalizeEmail(email)
if (!key) throw new Error('E-Mail ist Pflicht.')
const hit = listConsultants().find(c => normalizeEmail(c.email ?? '') === key && c.id !== exceptId)
if (hit) throw new Error(`E-Mail „${key}" ist bereits vergeben (${hit.firstName} ${hit.lastName}).`)
}
export function addConsultant(consultant: Omit<Consultant, 'id'>): number {
const email = normalizeEmail(consultant.email ?? '')
assertUniqueEmail(email)
return insertRow('consultants', {
...consultant,
email,
firstName: consultant.firstName.trim(),
lastName: consultant.lastName.trim(),
})
}
export function updateConsultant(id: number, patch: Partial<Consultant>): number {
const next: Partial<Consultant> = { ...patch }
if (patch.email !== undefined) {
const email = normalizeEmail(patch.email)
assertUniqueEmail(email, id)
next.email = email
}
if (patch.firstName !== undefined) next.firstName = patch.firstName.trim()
if (patch.lastName !== undefined) next.lastName = patch.lastName.trim()
return updateRow('consultants', id, next)
}
export function archiveConsultant(id: number): number {
return updateRow('consultants', id, { archivedAt: new Date().toISOString() })
}
export function unarchiveConsultant(id: number): number {
return updateRow('consultants', id, { archivedAt: null as unknown as undefined })
}
/**
* Import in eine gewählte Zielgruppe. Abgleich nur über E-Mail.
* moveIfDifferentGroup: bekannte Personen mit anderer Gruppe in die Zielgruppe verschieben.
*/
export function importConsultantsToGroup(
groupId: number,
rows: ConsultantImportRow[],
moveIfDifferentGroup = true,
): ConsultantImportResult {
const group = getGroup(groupId)
if (!group) throw new Error('Zielgruppe nicht gefunden.')
const result: ConsultantImportResult = { created: 0, updated: 0, moved: 0, skipped: 0, errors: [] }
const seenInFile = new Set<string>()
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!
const line = i + 1
const email = normalizeEmail(row.email ?? '')
const firstName = (row.firstName ?? '').trim()
const lastName = (row.lastName ?? '').trim()
if (!email) {
result.errors.push(`Zeile ${line}: E-Mail fehlt`)
result.skipped++
continue
}
if (!firstName || !lastName) {
result.errors.push(`Zeile ${line}: Name unvollständig (${email})`)
result.skipped++
continue
}
if (seenInFile.has(email)) {
result.errors.push(`Zeile ${line}: doppelte E-Mail in Datei (${email})`)
result.skipped++
continue
}
seenInFile.add(email)
const existing = findConsultantByEmail(email)
if (!existing?.id) {
insertRow('consultants', { groupId, firstName, lastName, email })
result.created++
continue
}
const patch: Partial<Consultant> = { firstName, lastName }
if (existing.groupId !== groupId) {
if (moveIfDifferentGroup) {
patch.groupId = groupId
result.moved++
}
}
updateRow('consultants', existing.id, patch)
result.updated++
}
return result
}
/** Feedback + personenbezogene Meeting-Daten weg; Assignment bleibt, Person wird entkoppelt. */
export function hardDeleteConsultant(id: number): HardDeleteConsultantResult {
const person = getConsultant(id)
if (!person) throw new Error('Person nicht gefunden.')
const feedbacks = getAllRows<AssignmentFeedback>('assignmentFeedbacks').filter(f => f.instituteeId === id)
const feedbackIds = feedbacks.map(f => f.id!).filter(Boolean)
if (feedbackIds.length) {
deleteRowsWhereIn('feedbackCategoryRatings', 'assignmentFeedbackId', feedbackIds)
deleteRowsWhereIn('feedbackDimensionTexts', 'assignmentFeedbackId', feedbackIds)
deleteRowsWhereIn('feedbackCategoryTexts', 'assignmentFeedbackId', feedbackIds)
}
deleteRowsWhereEquals('assignmentFeedbacks', 'instituteeId', id)
deleteRowsWhereEquals('feedbackDrafts', 'instituteeId', id)
const entries = getAllRows<ConversationEntry>('conversationEntries').filter(e => e.instituteeId === id)
const entryIds = entries.map(e => e.id!).filter(Boolean)
if (entryIds.length) {
deleteRowsWhereIn('conversationSkillScores', 'conversationEntryId', entryIds)
}
deleteRowsWhereEquals('conversationEntries', 'instituteeId', id)
deleteRowsWhereEquals('assessments', 'instituteeId', id)
deleteRowsWhereEquals('meetingCategoryRatings', 'instituteeId', id)
deleteRowsWhereEquals('checklistResponses', 'instituteeId', id)
const emptiedAssignmentIds: number[] = []
let assignmentsTouched = 0
const assignments = getAllRows<Assignment>('assignments')
for (const a of assignments) {
if (!a.id || !a.instituteeIds.includes(id)) continue
assignmentsTouched++
const instituteeIds = a.instituteeIds.filter(x => x !== id)
const instituteeEnrollments = (a.instituteeEnrollments ?? []).filter(e => e.instituteeId !== id)
updateRow('assignments', a.id, { instituteeIds, instituteeEnrollments })
if (instituteeIds.length === 0) emptiedAssignmentIds.push(a.id)
}
deleteRowById('consultants', id)
return {
emptiedAssignmentIds,
feedbacksDeleted: feedbacks.length,
assignmentsTouched,
}
}
/** @deprecated — nutze hardDeleteConsultant; bleibt als Alias für alte Client-Aufrufe. */
export function deleteConsultant(id: number): void {
hardDeleteConsultant(id)
}
export function getConsultantDetail(id: number): ConsultantDetail | undefined {
const consultant = getConsultant(id)
if (!consultant) return undefined
const group = getGroup(consultant.groupId)
const types = getAllRows<{ id?: number; name: string }>('assignmentTypes')
const typeMap = new Map(types.map(t => [t.id!, t.name]))
const categories = getAllRows<FeedbackCategory>('feedbackCategories')
const catMap = new Map(categories.map(c => [c.id!, c.name]))
const allFeedbacks = getAllRows<AssignmentFeedback>('assignmentFeedbacks')
const allRatings = getAllRows<FeedbackCategoryRating>('feedbackCategoryRatings')
const assignments = getAllRows<Assignment>('assignments')
.filter(a => a.instituteeIds.includes(id))
.map(a => {
const fb = allFeedbacks.find(f => f.assignmentId === a.id && f.instituteeId === id)
const ratings = fb?.id
? allRatings
.filter(r => r.assignmentFeedbackId === fb.id)
.map(r => ({
categoryId: r.feedbackCategoryId,
categoryName: catMap.get(r.feedbackCategoryId) ?? `Kategorie ${r.feedbackCategoryId}`,
rating: (r.rating ?? null) as RatingScore | null,
}))
: []
return {
assignmentId: a.id!,
title: a.title,
assignmentNumber: a.assignmentNumber,
client: a.client,
status: a.status,
deletedAt: a.deletedAt,
archivedAt: a.archivedAt,
plannedStart: a.plannedStart,
plannedEnd: a.plannedEnd,
createdAt: a.createdAt,
typeName: typeMap.get(a.assignmentTypeId) ?? '—',
feedbackStatus: (!fb ? 'none' : fb.status === 'final' ? 'final' : 'draft') as 'none' | 'draft' | 'final',
categoryRatings: ratings,
}
})
.sort((a, b) => {
const ae = a.plannedEnd ?? a.createdAt
const be = b.plannedEnd ?? b.createdAt
return be.localeCompare(ae)
})
return { consultant, group, assignments }
}

165
server/db/starterPackage.ts Normal file
View File

@ -0,0 +1,165 @@
/**
* Starter-Paket: nur Konfigurations-/Strukturdaten für Team-Verteilung.
* Keine Personen-, Assignment- oder Meeting-Daten (GDPR).
* Produktiv (`AssigmentMonitor`) wird nie angefasst — Skripte nutzen dieselben Pfad-Guards.
*/
import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
import { dirname, join, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TABLE_DEFINITIONS, TABLE_NAMES } from './tableRegistry'
import { getAllRows, insertRowWithId } from './crud'
import { db } from '../database'
const __dirname = dirname(fileURLToPath(import.meta.url))
export const PROJECT_ROOT = resolve(join(__dirname, '../..'))
export const STARTER_DIR = join(PROJECT_ROOT, 'docs', 'starter')
export const STARTER_FILE = join(STARTER_DIR, 'starter-structure.json')
/** Tabellen mit Stammdaten / Config — IDs bleiben stabil (FK zwischen Struktur-Tabellen). */
export const STARTER_STRUCTURE_TABLES = [
'feedbackDimensions',
'feedbackCategories',
'feedbackCriterionItems',
'feedbackDimensionPrompts',
'assignmentTypes',
'phaseTemplates',
'checklistItemDefs',
'appSettings',
'criterionLevelDescriptions',
] as const
const STRUCTURE_SET = new Set<string>(STARTER_STRUCTURE_TABLES)
export interface StarterPackage {
kind: 'assignment-monitor-starter'
version: 1
exportedAt: string
note: string
tables: Record<string, Array<Record<string, unknown>>>
}
/** Verweigert jeden Pfad außerhalb V2 und jeden Produktiv-DB-Pfad. */
export function assertV2DevOnly(resolvedPath: string, label: string): void {
const pathNorm = resolvedPath.toLowerCase()
const rootNorm = PROJECT_ROOT.toLowerCase()
const rootPrefix = rootNorm.endsWith(sep) ? rootNorm : rootNorm + sep
if (!pathNorm.startsWith(rootPrefix) && pathNorm !== rootNorm) {
throw new Error(`[${label}] SAFETY: Pfad außerhalb AssignmentMonitorV2:\n ${resolvedPath}`)
}
if (
/[/\\]assigmentmonitor[/\\]/.test(pathNorm) &&
!/[/\\]assigmentmonitorv2[/\\]/.test(pathNorm)
) {
throw new Error(`[${label}] SAFETY: Produktiv-Pfad blockiert:\n ${resolvedPath}`)
}
}
export function assertStarterEnv(): void {
const apiPort = process.env.API_PORT?.trim() || '4001'
const vitePort = process.env.PORT?.trim() || '5174'
const sqliteFilename = process.env.SQLITE_FILENAME?.trim() || 'assignment-monitor-v2-dev.sqlite3'
if (apiPort === '4000') {
throw new Error('[starter] SAFETY: API_PORT=4000 ist der Produktiv-Port — abgebrochen.')
}
if (vitePort === '5173') {
throw new Error('[starter] SAFETY: PORT=5173 ist der Produktiv-Port — abgebrochen.')
}
if (sqliteFilename === 'assignment-monitor.sqlite3') {
throw new Error('[starter] SAFETY: Produktiv-DB-Dateiname blockiert.')
}
if (sqliteFilename.includes('/') || sqliteFilename.includes('\\') || sqliteFilename.includes('..')) {
throw new Error(`[starter] SAFETY: SQLITE_FILENAME ungültig: "${sqliteFilename}"`)
}
}
function sanitizeStructureRow(tableName: string, row: Record<string, unknown>): Record<string, unknown> {
const copy = { ...row }
// Destillierte Muster stammen aus echten Protokollen — nicht mitverteilen.
if (tableName === 'feedbackCriterionItems' && 'learnedPatternHints' in copy) {
copy.learnedPatternHints = null
}
return copy
}
/** Liest Struktur aus der aktuellen V2-Dev-DB (leere Laufzeit-Tabellen im Payload). */
export function buildStarterPackageFromDb(): StarterPackage {
assertStarterEnv()
assertV2DevOnly(PROJECT_ROOT, 'starter:export')
const tables: Record<string, Array<Record<string, unknown>>> = {}
for (const name of TABLE_NAMES) {
if (STRUCTURE_SET.has(name)) {
tables[name] = getAllRows<Record<string, unknown>>(name).map(r => sanitizeStructureRow(name, r))
} else {
tables[name] = []
}
}
return {
kind: 'assignment-monitor-starter',
version: 1,
exportedAt: new Date().toISOString(),
note: 'Nur Struktur/Config — keine Personen, Assignments oder Meetings. Nur für AssignmentMonitorV2.',
tables,
}
}
export function writeStarterPackage(pkg: StarterPackage, targetPath = STARTER_FILE): string {
assertV2DevOnly(targetPath, 'starter:export')
mkdirSync(dirname(targetPath), { recursive: true })
writeFileSync(targetPath, JSON.stringify(pkg, null, 2), 'utf8')
return targetPath
}
export function readStarterPackage(sourcePath = STARTER_FILE): StarterPackage {
assertV2DevOnly(sourcePath, 'starter:import')
if (!existsSync(sourcePath)) {
throw new Error(`[starter] Datei fehlt: ${sourcePath}`)
}
const raw = JSON.parse(readFileSync(sourcePath, 'utf8')) as StarterPackage
if (raw.kind !== 'assignment-monitor-starter' || raw.version !== 1 || !raw.tables) {
throw new Error('[starter] Ungültiges Starter-Paket (kind/version/tables).')
}
return raw
}
/**
* Ersetzt den kompletten DB-Inhalt durch das Starter-Paket (Struktur befüllt, Rest leer).
* Absichtlich voller Wipe — für frische Team-Installationen.
*/
export function importStarterPackage(pkg: StarterPackage): { structureRows: number } {
assertStarterEnv()
assertV2DevOnly(PROJECT_ROOT, 'starter:import')
let structureRows = 0
db.exec('BEGIN')
try {
for (const table of TABLE_DEFINITIONS) {
db.prepare(`DELETE FROM "${table.name}"`).run()
}
for (const table of TABLE_DEFINITIONS) {
const rows = pkg.tables[table.name] ?? []
for (const row of rows) {
const { id, ...rest } = row
if (typeof id !== 'number') continue
insertRowWithId(table.name, id, sanitizeStructureRow(table.name, rest))
if (STRUCTURE_SET.has(table.name)) structureRows++
}
}
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
throw err
}
return { structureRows }
}
export function summarizeStarter(pkg: StarterPackage): Record<string, number> {
const counts: Record<string, number> = {}
for (const name of STARTER_STRUCTURE_TABLES) {
counts[name] = pkg.tables[name]?.length ?? 0
}
return counts
}

View File

@ -40,6 +40,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
col('name', 'TEXT'),
col('defaultPhaseKeys', 'TEXT', 'json'),
col('criteriaIds', 'TEXT', 'json'),
col('color', 'TEXT'),
]},
{ name: 'phaseTemplates', columns: [
col('assignmentTypeId', 'INTEGER'),
@ -61,6 +62,8 @@ export const TABLE_DEFINITIONS: TableDef[] = [
col('groupId', 'INTEGER'),
col('firstName', 'TEXT'),
col('lastName', 'TEXT'),
col('email', 'TEXT'),
col('archivedAt', 'TEXT'),
]},
{ name: 'assignments', columns: [
col('title', 'TEXT'),
@ -75,6 +78,8 @@ export const TABLE_DEFINITIONS: TableDef[] = [
col('instituteeEnrollments', 'TEXT', 'json'),
col('deletedAt', 'TEXT'),
col('archivedAt', 'TEXT'),
col('plannedStart', 'TEXT'),
col('plannedEnd', 'TEXT'),
]},
{ name: 'meetingInstances', columns: [
col('assignmentId', 'INTEGER'),
@ -107,6 +112,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
col('score', 'INTEGER'),
col('note', 'TEXT'),
col('updatedAt', 'TEXT'),
col('scoreNeedsReview', 'INTEGER', 'bool'),
]},
{ name: 'meetingCategoryRatings', columns: [
col('meetingInstanceId', 'INTEGER'),
@ -185,7 +191,9 @@ export const TABLE_DEFINITIONS: TableDef[] = [
col('defaultDimensionPromptTemplate', 'TEXT'),
col('defaultCategoryPromptTemplate', 'TEXT'),
col('criterionTaggingPromptTemplate', 'TEXT'),
col('checklistTaggingPromptTemplate', 'TEXT'),
col('ratingColorBands', 'TEXT', 'json'),
col('requirePlannedDates', 'INTEGER', 'bool'),
]},
{ name: 'checklistItemDefs', columns: [
col('phaseTemplateId', 'INTEGER'),
@ -193,6 +201,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
col('linkedCriterionItemId', 'INTEGER'),
col('scope', 'TEXT'),
col('order', 'INTEGER'),
col('scoreContribution', 'TEXT'),
]},
{ name: 'checklistResponses', columns: [
col('meetingInstanceId', 'INTEGER'),
@ -201,6 +210,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
col('rating', 'INTEGER'),
col('note', 'TEXT'),
col('criterionItemId', 'INTEGER'),
col('criterionOverride', 'INTEGER', 'bool'),
]},
]

View File

@ -1,6 +1,14 @@
import express from 'express'
import { existsSync } from 'node:fs'
import './database'
import { getAllRows } from './db/crud'
import { rpcModules } from './rpcModules'
import {
STARTER_FILE,
readStarterPackage,
importStarterPackage,
summarizeStarter,
} from './db/starterPackage'
const app = express()
app.use(express.json({ limit: '50mb' })) // Backup-Import kann alle Tabellen als ein JSON-Body enthalten
@ -32,6 +40,30 @@ if (!Number.isFinite(port) || port === 4000) {
)
}
/** Team-ZIP: leere DB automatisch mit Struktur befüllen (keine Meetings/Personen). */
function seedStarterIfEmpty(): void {
const dims = getAllRows('feedbackDimensions')
if (dims.length > 0) return
if (!existsSync(STARTER_FILE)) {
console.warn('[server] DB ohne Feedback-Struktur und ohne Starter-Paket — Config bleibt leer.')
return
}
try {
const pkg = readStarterPackage()
const { structureRows } = importStarterPackage(pkg)
const counts = summarizeStarter(pkg)
console.log(`[server] Starter-Struktur automatisch geladen (${structureRows} Zeilen).`)
console.log(
`[server] Dimensionen=${counts.feedbackDimensions} Kategorien=${counts.feedbackCategories} ` +
`Kriterien=${counts.feedbackCriterionItems} Typen=${counts.assignmentTypes}`,
)
} catch (err) {
console.warn('[server] Auto-Seed fehlgeschlagen:', err instanceof Error ? err.message : err)
}
}
seedStarterIfEmpty()
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.`)

View File

@ -11,6 +11,7 @@ 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'
import * as assignmentTransfer from './db/queries/assignmentTransfer'
const modules = {
appSettings,
@ -26,6 +27,7 @@ const modules = {
assignmentFeedback,
meetingCategoryRatings,
backup,
assignmentTransfer,
}
export type RpcModuleName = keyof typeof modules

View File

@ -0,0 +1,34 @@
/**
* Spielt docs/starter/starter-structure.json in die V2-Dev-DB ein (voller Wipe).
* Usage: npm run bootstrap -- --yes
* Ohne --yes / CONFIRM=1: Abbruch (Schutz vor versehentlichem Löschen).
* Produktiv-Ports/DB-Namen werden blockiert.
*/
const args = new Set(process.argv.slice(2))
const confirmed = args.has('--yes') || args.has('-y') || process.env.CONFIRM === '1'
if (!confirmed) {
console.error('[bootstrap] Abbruch: würde die V2-Dev-DB komplett ersetzen.')
console.error('[bootstrap] Zum Bestätigen: npm run bootstrap -- --yes')
console.error('[bootstrap] Quelle: docs/starter/starter-structure.json')
process.exit(1)
}
const {
readStarterPackage,
importStarterPackage,
summarizeStarter,
STARTER_FILE,
} = await import('../db/starterPackage')
const pkg = readStarterPackage()
const counts = summarizeStarter(pkg)
const { structureRows } = importStarterPackage(pkg)
console.log(`[bootstrap] Starter eingespielt (${structureRows} Struktur-Zeilen).`)
console.log(`[bootstrap] Quelle: ${STARTER_FILE} (${pkg.exportedAt})`)
for (const [table, n] of Object.entries(counts)) {
if (n > 0) console.log(` ${table}: ${n}`)
}
console.log('[bootstrap] Keine Personen-/Meeting-Daten. API-Key lokal unter Config → KI setzen.')
console.log('[bootstrap] Start: npm run dev:all → http://localhost:5174')

View File

@ -0,0 +1,22 @@
/**
* Exportiert Struktur/Config aus der V2-Dev-DB nach docs/starter/starter-structure.json.
* Usage: npm run starter:export
* Produktiv wird per Guard blockiert.
*/
import {
buildStarterPackageFromDb,
writeStarterPackage,
summarizeStarter,
STARTER_FILE,
} from '../db/starterPackage'
const pkg = buildStarterPackageFromDb()
const path = writeStarterPackage(pkg)
const counts = summarizeStarter(pkg)
console.log(`[starter:export] geschrieben: ${path}`)
console.log(`[starter:export] Stand: ${pkg.exportedAt}`)
for (const [table, n] of Object.entries(counts)) {
if (n > 0) console.log(` ${table}: ${n}`)
}
console.log(`[starter:export] Ziel: ${STARTER_FILE}`)

View File

@ -0,0 +1,158 @@
/**
* Laptop-Umzug: Code + komplette V2-Dev-SQLite (ohne node_modules, .env).
* Enthält personenbezogene Daten — nur an sich selbst / den Ziel-Laptop schicken.
*
* Usage: npm run pack:laptop
*/
import { mkdirSync, existsSync, rmSync, readFileSync, copyFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFileSync } from 'node:child_process'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = resolve(join(__dirname, '../..'))
const outDir = join(projectRoot, 'dist-release')
const stamp = new Date().toISOString().slice(0, 10)
const zipName = `AssignmentMonitor-Umzug-${stamp}.zip`
const zipPath = join(outDir, zipName)
const stageName = 'AssigmentMonitorV2'
const stageRoot = join(outDir, '_stage-laptop', stageName)
const rootNorm = projectRoot.toLowerCase()
if (!rootNorm.includes('assigmentmonitorv2')) {
throw new Error('[pack:laptop] SAFETY: Skript nur innerhalb AssigmentMonitorV2 ausführen.')
}
const sqliteFilename = process.env.SQLITE_FILENAME?.trim() || 'assignment-monitor-v2-dev.sqlite3'
if (sqliteFilename.includes('/') || sqliteFilename.includes('\\') || sqliteFilename.includes('..')) {
throw new Error(`[pack:laptop] SAFETY: SQLITE_FILENAME ungültig: "${sqliteFilename}"`)
}
if (sqliteFilename === 'assignment-monitor.sqlite3') {
throw new Error('[pack:laptop] SAFETY: Produktiv-DB-Dateiname blockiert.')
}
const dbPath = join(projectRoot, 'server', 'data', sqliteFilename)
const EXCLUDE_DIR_NAMES = new Set([
'node_modules',
'dist',
'dist-release',
'dist-ssr',
'.vite',
'.claude',
'.vscode',
'_quarantine',
'_stage',
'_stage-laptop',
])
const EXCLUDE_FILE_NAMES = new Set([
'.env',
'.env.local',
'dist-release-log.txt',
])
function shouldSkip(absPath: string): boolean {
const rel = relative(projectRoot, absPath).split(/[/\\]/).join('/')
if (!rel || rel.startsWith('..')) return true
if (rel === 'server/data' || rel.startsWith('server/data/')) return true
if (rel === 'dist-release' || rel.startsWith('dist-release/')) return true
const parts = rel.split('/')
if (parts.some(p => EXCLUDE_DIR_NAMES.has(p) || p.startsWith('_quarantine'))) return true
const base = parts[parts.length - 1] ?? ''
if (EXCLUDE_FILE_NAMES.has(base)) return true
if (base.endsWith('.tsbuildinfo')) return true
if (base.startsWith('.env.') && base.endsWith('.local')) return true
if (base.endsWith('.bak')) return true
return false
}
async function copyTree(srcDir: string, destDir: string): Promise<number> {
const { readdirSync, statSync, mkdirSync: mkdir, copyFileSync: cp } = await import('node:fs')
let count = 0
mkdir(destDir, { recursive: true })
for (const name of readdirSync(srcDir)) {
const src = join(srcDir, name)
if (shouldSkip(src)) continue
const dest = join(destDir, name)
const st = statSync(src)
if (st.isDirectory()) {
count += await copyTree(src, dest)
} else if (st.isFile()) {
cp(src, dest)
count++
}
}
return count
}
function zipWithPowerShell(sourceFolder: string, targetZip: string): void {
const parent = dirname(sourceFolder)
const leaf = sourceFolder.split(/[/\\]/).pop()
const ps = `
$ErrorActionPreference = 'Stop'
if (Test-Path -LiteralPath '${targetZip.replace(/'/g, "''")}') { Remove-Item -LiteralPath '${targetZip.replace(/'/g, "''")}' -Force }
Compress-Archive -Path (Join-Path '${parent.replace(/'/g, "''")}' '${leaf}') -DestinationPath '${targetZip.replace(/'/g, "''")}' -CompressionLevel Optimal
`
execFileSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'inherit' })
}
if (!existsSync(dbPath)) {
throw new Error(
`[pack:laptop] Datenbank fehlt: ${dbPath}\n` +
'Ohne SQLite wäre das nur ein Team-Paket — zuerst die App einmal starten oder pack:zip nutzen.',
)
}
mkdirSync(outDir, { recursive: true })
const stageParent = join(outDir, '_stage-laptop')
if (existsSync(stageParent)) rmSync(stageParent, { recursive: true, force: true })
mkdirSync(stageRoot, { recursive: true })
console.log('[pack:laptop] Kopiere Projektdateien (ohne node_modules, .env; inkl. .git) …')
const files = await copyTree(projectRoot, stageRoot)
console.log(`[pack:laptop] ${files} Dateien → Staging`)
const stageDataDir = join(stageRoot, 'server', 'data')
mkdirSync(stageDataDir, { recursive: true })
copyFileSync(dbPath, join(stageDataDir, sqliteFilename))
let extraDbFiles = 0
for (const suffix of ['-wal', '-shm']) {
const extra = `${dbPath}${suffix}`
if (existsSync(extra)) {
copyFileSync(extra, join(stageDataDir, `${sqliteFilename}${suffix}`))
extraDbFiles++
}
}
writeFileSync(
join(stageDataDir, 'README.txt'),
[
'Diese SQLite-Datei ist die vollständige lokale Datenbank (Personen, Assignments, Meetings).',
`Datei: ${sqliteFilename}`,
'Nach dem Entpacken NICHT npm run setup / bootstrap / db:reset ausführen.',
'',
].join('\n'),
'utf8',
)
console.log(`[pack:laptop] DB kopiert: ${sqliteFilename}${extraDbFiles ? ` (+${extraDbFiles} WAL/SHM)` : ''}`)
if (!existsSync(join(stageRoot, 'UMZUG.md'))) {
throw new Error('[pack:laptop] UMZUG.md fehlt im Staging — Abbruch.')
}
if (!existsSync(join(stageRoot, '.env.example'))) {
throw new Error('[pack:laptop] .env.example fehlt')
}
if (!existsSync(join(stageRoot, 'server', 'data', sqliteFilename))) {
throw new Error('[pack:laptop] DB landete nicht im Staging — Abbruch.')
}
console.log(`[pack:laptop] Erzeuge ${zipName} …`)
zipWithPowerShell(stageRoot, zipPath)
rmSync(stageParent, { recursive: true, force: true })
const sizeMb = (readFileSync(zipPath).byteLength / (1024 * 1024)).toFixed(2)
console.log(`[pack:laptop] Fertig: ${zipPath}`)
console.log(`[pack:laptop] Größe: ${sizeMb} MB (E-Mail-tauglich, solange unter dem Postfach-Limit)`)
console.log('[pack:laptop] Enthält personenbezogene Daten — nur an den Ziel-Laptop schicken.')
console.log('[pack:laptop] Am Ziel: UMZUG.md folgen. Nicht npm run setup.')
console.log('[pack:laptop] Produktiv (AssigmentMonitor) wurde nicht angefasst.')

View File

@ -0,0 +1,120 @@
/**
* Erzeugt ein versandfertiges ZIP der V2-Team-Version (ohne node_modules, .env, Dev-DB, .git).
* Schreibt nach dist-release/ — Ordner ist gitignored; Produktiv wird nicht angefasst.
*
* Usage: npm run pack:zip
*/
import { mkdirSync, existsSync, rmSync, readFileSync } from 'node:fs'
import { dirname, join, resolve, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execFileSync } from 'node:child_process'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = resolve(join(__dirname, '../..'))
const outDir = join(projectRoot, 'dist-release')
const stamp = new Date().toISOString().slice(0, 10)
const zipName = `AssignmentMonitor-Team-${stamp}.zip`
const zipPath = join(outDir, zipName)
const stageName = 'AssigmentMonitorV2'
const stageRoot = join(outDir, '_stage', stageName)
const rootNorm = projectRoot.toLowerCase()
if (!rootNorm.includes('assigmentmonitorv2')) {
throw new Error('[pack:zip] SAFETY: Skript nur innerhalb AssigmentMonitorV2 ausführen.')
}
const EXCLUDE_DIR_NAMES = new Set([
'node_modules',
'.git',
'dist',
'dist-release',
'dist-ssr',
'.vite',
'.claude',
'.vscode',
'server\\data',
'server/data',
'_quarantine',
'_stage',
])
const EXCLUDE_FILE_NAMES = new Set([
'.env',
'.env.local',
'dist-release-log.txt',
])
function shouldSkip(absPath: string): boolean {
const rel = relative(projectRoot, absPath).split(/[/\\]/).join('/')
if (!rel || rel.startsWith('..')) return true
if (rel === 'server/data' || rel.startsWith('server/data/')) return true
if (rel === 'dist-release' || rel.startsWith('dist-release/')) return true
const parts = rel.split('/')
if (parts.some(p => EXCLUDE_DIR_NAMES.has(p) || p.startsWith('_quarantine'))) return true
const base = parts[parts.length - 1] ?? ''
if (EXCLUDE_FILE_NAMES.has(base)) return true
if (base.endsWith('.tsbuildinfo')) return true
if (base.startsWith('.env.') && base.endsWith('.local')) return true
return false
}
async function copyTree(srcDir: string, destDir: string): Promise<number> {
const { readdirSync, statSync, mkdirSync: mkdir, copyFileSync: cp } = await import('node:fs')
let count = 0
mkdir(destDir, { recursive: true })
for (const name of readdirSync(srcDir)) {
const src = join(srcDir, name)
if (shouldSkip(src)) continue
const dest = join(destDir, name)
const st = statSync(src)
if (st.isDirectory()) {
count += await copyTree(src, dest)
} else if (st.isFile()) {
cp(src, dest)
count++
}
}
return count
}
function zipWithPowerShell(sourceFolder: string, targetZip: string): void {
// Compress-Archive braucht den Inhalt so, dass der Root-Ordner im ZIP liegt
const parent = dirname(sourceFolder)
const leaf = sourceFolder.split(/[/\\]/).pop()
const ps = `
$ErrorActionPreference = 'Stop'
if (Test-Path -LiteralPath '${targetZip.replace(/'/g, "''")}') { Remove-Item -LiteralPath '${targetZip.replace(/'/g, "''")}' -Force }
Compress-Archive -Path (Join-Path '${parent.replace(/'/g, "''")}' '${leaf}') -DestinationPath '${targetZip.replace(/'/g, "''")}' -CompressionLevel Optimal
`
execFileSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'inherit' })
}
mkdirSync(outDir, { recursive: true })
const stageParent = join(outDir, '_stage')
if (existsSync(stageParent)) rmSync(stageParent, { recursive: true, force: true })
mkdirSync(stageRoot, { recursive: true })
console.log('[pack:zip] Kopiere Projektdateien (ohne DB, .env, node_modules, .git) …')
const files = await copyTree(projectRoot, stageRoot)
console.log(`[pack:zip] ${files} Dateien → Staging`)
// Sicherstellen, dass Einrichtungsdateien im Root der ZIP liegen
if (!existsSync(join(stageRoot, 'EINRICHTUNG.md'))) {
throw new Error('[pack:zip] EINRICHTUNG.md fehlt im Staging — Abbruch.')
}
if (!existsSync(join(stageRoot, 'docs', 'starter', 'starter-structure.json'))) {
throw new Error('[pack:zip] starter-structure.json fehlt — zuerst npm run starter:export')
}
if (!existsSync(join(stageRoot, '.env.example'))) {
throw new Error('[pack:zip] .env.example fehlt')
}
console.log(`[pack:zip] Erzeuge ${zipName} …`)
zipWithPowerShell(stageRoot, zipPath)
rmSync(stageParent, { recursive: true, force: true })
const sizeMb = (readFileSync(zipPath).byteLength / (1024 * 1024)).toFixed(2)
console.log(`[pack:zip] Fertig: ${zipPath}`)
console.log(`[pack:zip] Größe: ${sizeMb} MB`)
console.log('[pack:zip] An Kollegen: ZIP schicken + auf EINRICHTUNG.md verweisen.')
console.log('[pack:zip] Produktiv (AssigmentMonitor) wurde nicht angefasst.')

View File

@ -0,0 +1,124 @@
/**
* Automatisierter Smoke für C0 (Skala/Farbbänder) + C6/C7-Hilfslogik (Batches, Prompt-Größe).
* Schreibt temporär Farbbänder in die V2-Dev-DB und stellt den vorherigen Stand wieder her.
*
* Aufruf: npm run smoke
* Voraussetzung: .env mit V2-Ports/DB (wie npm run setup / server).
*
* Nur server-kompatible Imports (kein DOM/JSX) — siehe tsconfig.server.json.
*/
import '../database'
import {
DEFAULT_RATING_COLOR_BANDS,
BAND_STYLES,
bandForScore,
normalizeRatingColorBands,
normalizeRatingScore,
resolveScoreStyle,
AI_CONFIG,
} from '../../src/config/constants'
import { getOrSeedRatingColorBands, saveRatingColorBands } from '../db/queries/appSettings'
import { buildCharAwareBatches, mergePatternHints } from '../../src/utils/charAwareBatches'
import { checkPromptSize } from '../../src/utils/promptSize'
let failed = 0
let passed = 0
function assert(cond: boolean, label: string, detail?: string) {
if (cond) {
console.log(`✅ ${label}`)
passed++
} else {
console.log(`❌ ${label}${detail ? ` — ${detail}` : ''}`)
failed++
}
}
console.log('=== Smoke: Skala 1–10 (C0) ===\n')
assert(normalizeRatingScore(7) === 7, 'normalizeRatingScore(7) → 7')
assert(normalizeRatingScore(0) === null, 'normalizeRatingScore(0) → null')
assert(normalizeRatingScore(11) === null, 'normalizeRatingScore(11) → null')
assert(normalizeRatingScore('5') === 5, 'normalizeRatingScore("5") → 5')
const bands = normalizeRatingColorBands(DEFAULT_RATING_COLOR_BANDS)
assert(bandForScore(1, bands).key === 'red', 'Score 1 → Band rot')
assert(bandForScore(2, bands).key === 'red', 'Score 2 → Band rot')
assert(bandForScore(3, bands).key === 'orange', 'Score 3 → Band orange')
assert(bandForScore(5, bands).key === 'orange', 'Score 5 → Band orange')
assert(bandForScore(6, bands).key === 'lightGreen', 'Score 6 → Band hellgrün')
assert(bandForScore(8, bands).key === 'lightGreen', 'Score 8 → Band hellgrün')
assert(bandForScore(9, bands).key === 'darkGreen', 'Score 9 → Band dunkelgrün')
assert(bandForScore(10, bands).key === 'darkGreen', 'Score 10 → Band dunkelgrün')
assert(
BAND_STYLES.lightGreen.color !== BAND_STYLES.darkGreen.color,
'Hellgrün und Dunkelgrün haben unterscheidbare CSS-Klassen',
)
assert(
BAND_STYLES.lightGreen.hex !== BAND_STYLES.darkGreen.hex,
'Hellgrün und Dunkelgrün haben unterscheidbare Hex-Farben',
)
const style6 = resolveScoreStyle(6, bands)
const style9 = resolveScoreStyle(9, bands)
assert(style6.color !== style9.color, 'resolveScoreStyle: 6 und 9 unterschiedliche Farben')
const previousBands = getOrSeedRatingColorBands()
const probeBands = [
{ max: 3, key: 'red' as const },
{ max: 6, key: 'orange' as const },
{ max: 8, key: 'lightGreen' as const },
{ max: 10, key: 'darkGreen' as const },
]
saveRatingColorBands(probeBands)
const reloaded = getOrSeedRatingColorBands()
assert(
JSON.stringify(reloaded) === JSON.stringify(normalizeRatingColorBands(probeBands)),
'Farbbänder speichern/laden Roundtrip (DB)',
`got ${JSON.stringify(reloaded)}`,
)
saveRatingColorBands(previousBands)
const restored = getOrSeedRatingColorBands()
assert(
JSON.stringify(restored) === JSON.stringify(normalizeRatingColorBands(previousBands)),
'Vorherige Farbbänder wiederhergestellt',
)
console.log('\n=== Smoke: Tagging-Batches (C6) ===\n')
const manyShort = Array.from({ length: 25 }, (_, i) => `Zeile ${i + 1} kurz`)
const byLines = buildCharAwareBatches(manyShort, s => s, 12, 50_000)
assert(byLines.length === 3, `25 Zeilen / max 12 → 3 Batches (ist ${byLines.length})`)
assert(byLines[0].length === 12 && byLines[1].length === 12 && byLines[2].length === 1, 'Batch-Größen 12+12+1')
const longA = 'a'.repeat(2000)
const longB = 'b'.repeat(2000)
const byChars = buildCharAwareBatches([longA, longB, 'kurz'], s => s, 12, 3500)
assert(byChars.length === 2, `Zeichen-Budget → 2 Batches (ist ${byChars.length})`)
assert(byChars[0].length === 1 && byChars[0][0] === longA, 'Erste lange Zeile allein im Batch')
assert(byChars[1][0] === longB && byChars[1].includes('kurz'), 'Zweite lange Zeile + Kurzzeile im Folgebatch')
const oneHuge = ['x'.repeat(5000)]
const alone = buildCharAwareBatches(oneHuge, s => s, 12, 3500)
assert(alone.length === 1 && alone[0].length === 1, 'Überlanges Einzelstück → 1 Batch mit 1 Zeile')
const patterns = new Map<number, string>()
mergePatternHints(patterns, new Map([[1, 'alt'], [2, 'zwei']]))
mergePatternHints(patterns, new Map([[1, 'neu']]))
assert(patterns.get(1) === 'neu' && patterns.get(2) === 'zwei', 'MUSTER-Merge: später überschreibt früher')
console.log('\n=== Smoke: Prompt-Größe (C7) ===\n')
const small = checkPromptSize('kurz')
assert(!small.softWarn && !small.overHardLimit, 'Kleiner Prompt: kein Soft/Hard')
const soft = checkPromptSize('y'.repeat(AI_CONFIG.promptSoftWarnChars))
assert(soft.softWarn && !soft.overHardLimit, 'Soft-Warn-Schwelle greift')
const hard = checkPromptSize('z'.repeat(AI_CONFIG.promptHardLimitChars))
assert(hard.overHardLimit, 'Hard-Limit-Schwelle greift')
console.log(`\n=== Ergebnis: ${passed} pass, ${failed} fail ===`)
if (failed > 0) process.exit(1)
console.log('\nHinweis: UI-Teile → manuell in docs/SMOKE_TESTS.md')

View File

@ -0,0 +1,18 @@
/**
* Einmal-Setup für Team-ZIP: Starter-Struktur in die V2-Dev-DB laden.
* Usage: npm run setup
*/
import { existsSync, copyFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const rootPath = join(dirname(fileURLToPath(import.meta.url)), '../..')
const envPath = join(rootPath, '.env')
const examplePath = join(rootPath, '.env.example')
if (!existsSync(envPath) && existsSync(examplePath)) {
copyFileSync(examplePath, envPath)
console.log('[setup] .env aus .env.example angelegt.')
}
process.env.CONFIRM = '1'
await import('./bootstrapFromStarter')

View File

@ -1,5 +1,5 @@
import { Routes, Route, NavLink } from 'react-router-dom'
import { BRAND_COLOR } from './config/constants'
import { useEffect } from 'react'
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import Configuration from './pages/Configuration'
import Consultants from './pages/Consultants'
@ -13,14 +13,60 @@ import MeetingView from './pages/meeting/MeetingView'
import FeedbackDraftPage from './pages/FeedbackDraftPage'
import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage'
import Evaluation from './pages/Evaluation'
import ConsultantDetailPage from './pages/ConsultantDetail'
function headerContext(pathname: string): string {
if (pathname.startsWith('/config')) return 'Einstellungen'
if (pathname.startsWith('/consultants')) return 'Personen'
if (pathname.startsWith('/evaluation') || pathname.startsWith('/feedbacks')) return 'Feedbacks'
if (pathname.startsWith('/assignments/trash')) return 'Papierkorb'
if (pathname.startsWith('/assignments/archive')) return 'Archiv'
if (/\/meeting\//.test(pathname)) return 'Meeting'
if (/\/history$/.test(pathname)) return 'Gesamtprotokoll'
if (/\/feedback/.test(pathname)) return 'Feedback'
if (pathname.startsWith('/assignment')) return 'Assignment'
return 'Assignments'
}
const NAV = [
{ to: '/', label: 'Assignments', end: true },
{ to: '/feedbacks', label: 'Feedbacks', end: false },
{ to: '/consultants', label: 'Personen', end: false },
{ to: '/config', label: 'Einstellungen', end: false },
] as const
export default function App() {
const { pathname } = useLocation()
const wideLayout =
pathname === '/' ||
pathname.startsWith('/feedbacks') ||
pathname.startsWith('/evaluation') ||
pathname.startsWith('/consultants') ||
/\/meeting\/|\/history$/.test(pathname)
const context = headerContext(pathname)
useEffect(() => {
document.title = context === 'Assignments'
? 'Assignment Monitor'
: `${context} · Assignment Monitor`
}, [context])
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<header className="text-white px-4 py-3 flex items-center gap-3 shadow" style={{ backgroundColor: BRAND_COLOR }}>
<span className="font-bold text-lg tracking-tight">Assignment Monitor</span>
<div className="min-h-dvh bg-app flex flex-col font-sans text-ink">
<header className="bg-brand text-white px-4 py-3 flex items-center gap-3 shrink-0 shadow-[var(--shadow-header)]">
<div className="min-w-0 flex-1">
<div className="font-bold text-lg tracking-tight leading-tight truncate">
Assignment Monitor
</div>
<div className="text-xs text-white/80 mt-0.5 truncate">{context}</div>
</div>
</header>
<main className="flex-1 p-4 max-w-2xl mx-auto w-full pb-20">
<main
className={`flex-1 w-full mx-auto px-4 pt-4 pb-24 ${
wideLayout ? 'max-w-6xl' : 'max-w-2xl'
}`}
>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/assignment/new" element={<AssignmentCreate />} />
@ -32,30 +78,41 @@ export default function App() {
<Route path="/assignment/:id/meeting/:meetingId" element={<MeetingView />} />
<Route path="/assignment/:id/feedback/:instituteeId" element={<AssignmentFeedbackPage />} />
<Route path="/assignment/:id/feedback-draft/:instituteeId" element={<FeedbackDraftPage />} />
<Route path="/evaluation" element={<Evaluation />} />
<Route path="/feedbacks" element={<Evaluation />} />
<Route path="/evaluation" element={<Navigate to="/feedbacks" replace />} />
<Route path="/consultants" element={<Consultants />} />
<Route path="/consultants/:id" element={<ConsultantDetailPage />} />
<Route path="/config" element={<Configuration />} />
</Routes>
</main>
<nav className="bg-white border-t border-gray-200 flex justify-around py-2 sticky bottom-0">
{[
{ to: '/', label: 'Home' },
{ to: '/consultants', label: 'Berater' },
{ to: '/evaluation', label: 'Auswertung' },
{ to: '/config', label: 'Config' },
].map(({ to, label }) => (
<nav
className="fixed bottom-0 inset-x-0 z-20 bg-surface border-t border-line shadow-[var(--shadow-nav)]"
aria-label="Hauptnavigation"
>
<div className={`mx-auto flex justify-around px-1 pt-1 pb-[max(0.35rem,env(safe-area-inset-bottom))] ${
wideLayout ? 'max-w-6xl' : 'max-w-2xl'
}`}>
{NAV.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end
end={end}
className={({ isActive }) =>
`text-xs px-3 py-1 rounded ${isActive ? 'font-semibold' : 'text-gray-500'}`
[
'flex-1 min-h-11 max-w-[7.5rem] mx-0.5 flex items-center justify-center',
'rounded-[var(--radius-control)] px-2 text-[11px] sm:text-xs font-medium leading-tight text-center',
'transition-colors',
isActive
? 'bg-brand-soft text-brand font-semibold'
: 'text-ink-muted hover:text-ink hover:bg-app',
].join(' ')
}
style={({ isActive }) => isActive ? { color: BRAND_COLOR } : undefined}
>
{label}
</NavLink>
))}
</div>
</nav>
</div>
)

View File

@ -2,6 +2,20 @@ import type { RatingBandKey, RatingColorBand, RatingScore } from '../db/types'
export const BRAND_COLOR = '#0070AD'
/** Voreinstellungen für AssignmentType.color (Einstellungen → Ass.-Typen). */
export const ASSIGNMENT_TYPE_COLOR_PRESETS = [
'#0070AD',
'#2E7D32',
'#C62828',
'#EF6C00',
'#6A1B9A',
'#00838F',
'#4527A0',
'#546E7A',
] as const
export const DEFAULT_ASSIGNMENT_TYPE_COLOR = ASSIGNMENT_TYPE_COLOR_PRESETS[0]
export const RATING_MIN = 1
export const RATING_MAX = 10
@ -109,6 +123,19 @@ export const AI_CONFIG = {
localStorageKeyModel: 'ai_model',
defaultModel: 'openai/gpt-4o-mini',
openRouterBaseUrl: 'https://openrouter.ai/api/v1',
/** Timeout pro OpenRouter-Call (inkl. Retry). */
requestTimeoutMs: 90_000,
/** Einmaliger Retry bei transienten Fehlern (Netz/429/5xx). */
retryTransientOnce: true,
retryBackoffMs: 1_500,
/** Soft-Warnung / Hard-Abbruch nach Prompt-Zeichenzahl (roher Prompt-Text). */
promptSoftWarnChars: 24_000,
promptHardLimitChars: 100_000,
/** Tagging-Batches: max. Zeilen und max. Summe Rohtext-Zeichen pro Call. */
taggingBatchMaxLines: 12,
taggingBatchMaxChars: 3_500,
/** Hybrid-Notnagel: Top-K Kriterien per Jaccard, wenn ein Einzelzeilen-Prompt zu groß ist. */
taggingStructureTopK: 15,
/**
* 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
@ -122,9 +149,13 @@ DIMENSION: {{DIMENSION_NAME}}
KATEGORIE: {{KATEGORIE_NAME}}
{{KATEGORIE_ERLAEUTERUNG}}
KRITERIEN & BEOBACHTUNGEN (nur diese Kategorie):
KRITERIEN & BEOBACHTUNGEN (nur diese Kategorie — chronologisch, mit Einzelbelegen):
{{KRITERIEN_SCORES}}
Jedes Kriterium enthält Gesamt-Ø über Meetings sowie Einzelbelege mit Datum, Quelle, Score und Wichtigkeit.
Priorisiere bei der Auswertung: (1) Einträge mit Wichtigkeit „hoch"/„erhöht", (2) neuere Meetings, (3) konkrete Kommentare mit Score.
Nutze den Gesamt-Ø als Orientierung — wähle aus den Belegen die aussagekräftigsten für BENEFITS/CONCERNS.
MANUELLE KATEGORIE-BEWERTUNG: {{KATEGORIE_BEWERTUNG}}
ENTWICKLUNG ÜBER MEETINGS: {{ENTWICKLUNG_VERLAUF}}
{{TREND_VORSCHLAG}}
@ -172,7 +203,7 @@ DEVELOPMENT_NEEDS:
{ 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: '{{KRITERIEN_SCORES}}', description: 'Pro Kriterium: Gesamt-Ø + chronologische Einzelbelege (Protokoll, Checkliste, Notizen) mit Datum, Score und Wichtigkeit' },
{ 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)' },
@ -185,7 +216,7 @@ DEVELOPMENT_NEEDS:
{ 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.
defaultCriterionTaggingPromptTemplate: `Du bist Assistent bei der Auswertung von Kundentermin-Protokollen für Capgemini-Trainees (Institutees). Analysiere die folgenden noch nicht einem Bewertungskriterium zugeordneten Protokollzeilen (Freitext-Beobachtungen) und schlage pro Zeile passende Kriterien vor.
ASSIGNMENT-TYP: {{ASSIGNMENT_TYP}}
@ -203,7 +234,7 @@ NOCH NICHT ZUGEORDNETE PROTOKOLLZEILEN (nummeriert):
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:
Antworte AUSSCHLIESSLICH in exakt diesem Format, ohne zusätzliche Erklärungen, ohne Markdown-Formatierung (Kriterium-IDs als plain numbers, ohne eckige Klammern):
ZEILE <Nummer>: <Kriterium-ID>, <Kriterium-ID>, ...
ZEILE <Nummer>: -
@ -217,7 +248,36 @@ MUSTER:
{ 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)' },
{ token: '{{PROTOKOLL_ZEILEN}}', description: 'Nummerierte, noch nicht zugeordnete Protokollzeilen (Gesprächsbeiträge + allgemeine Notizen)' },
],
defaultChecklistTaggingPromptTemplate: `Du ordnest Checklistenpunkte aus Kundenterminen Bewertungskriterien zu. Jeder Punkt ist ein strukturierter Prüfpunkt mit festem Namen (Label). Kommentare zu Checklistenpunkten sind absichtlich nicht enthalten — sie dienen nur der späteren Feedback-Text-Erstellung, nicht der Kriterium-Zuordnung.
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 — Suche primär nach Label-Ähnlichkeit):
{{AEHNLICHE_ALT_ZUORDNUNGEN}}
NOCH NICHT ZUGEORDNETE CHECKLISTENPUNKTE (nummeriert — ordne ausschließlich nach Bedeutung des Punkt-Namens/Labels):
{{CHECKLISTEN_PUNKTE}}
Ordne jeden Punkt 0 bis 3 Kriterien zu, priorisiert nach Passung (wahrscheinlichstes zuerst). Nutze ausschließlich die oben gelisteten Kriterium-IDs. Der Punkt-Name ist das einzige Zuordnungskriterium — es gibt keine Scores oder Kommentare. Wenn keine sinnvolle Zuordnung erkennbar ist, gib "-" an.
Antworte AUSSCHLIESSLICH in exakt diesem Format, ohne zusätzliche Erklärungen, ohne Markdown-Formatierung (Kriterium-IDs als plain numbers, ohne eckige Klammern):
PUNKT <Nummer>: <Kriterium-ID>, <Kriterium-ID>, ...
PUNKT <Nummer>: -`,
checklistTaggingPromptPlaceholders: [
{ token: '{{ASSIGNMENT_TYP}}', description: 'Name des Assignment-Typs des aktuellen Meetings' },
{ token: '{{KRITERIEN_STRUKTUR}}', description: 'ID-referenzierte Struktur Dimension → Kategorie → Kriterium (gefiltert)' },
{ token: '{{GELERNTE_MUSTER}}', description: 'Formulierungsmuster aus früheren Protokoll-Analysen' },
{ token: '{{AEHNLICHE_ALT_ZUORDNUNGEN}}', description: 'Ähnlichste Alt-Zuordnungen je Checklistenpunkt (Label-basiert)' },
{ token: '{{CHECKLISTEN_PUNKTE}}', description: 'Nummerierte Checklistenpunkte (nur Label — kein Score, kein Kommentar)' },
],
} as const

View File

@ -1,9 +1,2 @@
export * from './types'
export { db } from './schema'
export * from './queries'
export {
seedIfEmpty,
seedFeedbackStructureIfEmpty,
seedCriterionMappingsIfEmpty,
migrateToUnifiedCriteriaIfNeeded,
} from './seeds'

View File

@ -23,7 +23,17 @@ export const getOrSeedCriterionTaggingPromptTemplate = (): Promise<string> =>
export const saveCriterionTaggingPromptTemplate = (template: string): Promise<number> =>
rpc('appSettings', 'saveCriterionTaggingPromptTemplate', template)
export const getOrSeedChecklistTaggingPromptTemplate = (): Promise<string> =>
rpc('appSettings', 'getOrSeedChecklistTaggingPromptTemplate')
export const saveChecklistTaggingPromptTemplate = (template: string): Promise<number> =>
rpc('appSettings', 'saveChecklistTaggingPromptTemplate', template)
export const getOrSeedDefaultCategoryPromptTemplate = (): Promise<string> =>
rpc('appSettings', 'getOrSeedDefaultCategoryPromptTemplate')
export const saveDefaultCategoryPromptTemplate = (template: string): Promise<number> =>
rpc('appSettings', 'saveDefaultCategoryPromptTemplate', template)
export const getOrSeedRequirePlannedDates = (): Promise<boolean> =>
rpc('appSettings', 'getOrSeedRequirePlannedDates')
export const saveRequirePlannedDates = (required: boolean): Promise<number> =>
rpc('appSettings', 'saveRequirePlannedDates', required)

View File

@ -0,0 +1,10 @@
import { rpc } from '../rpcClient'
import type { AssignmentTransferPackage, AssignmentImportResult } from '../types'
export type { AssignmentTransferPackage, AssignmentImportResult }
export const exportAssignmentTransfer = (assignmentId: number): Promise<AssignmentTransferPackage> =>
rpc('assignmentTransfer', 'exportAssignmentTransfer', assignmentId)
export const importAssignmentTransfer = (pkg: AssignmentTransferPackage): Promise<AssignmentImportResult> =>
rpc('assignmentTransfer', 'importAssignmentTransfer', pkg)

View File

@ -20,6 +20,7 @@ export const hardDeleteAssignment = (id: number): Promise<void> => rpc('assignme
export interface AssignmentWithDetails extends Assignment {
typeName: string
typeColor: 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

View File

@ -23,5 +23,6 @@ export const upsertChecklistResponse = (input: {
checklistItemId: number
rating: ChecklistResponse['rating']
note: string
criterionItemId?: number
criterionItemId?: number | null
criterionOverride?: boolean
}): Promise<number> => rpc('checklists', 'upsertChecklistResponse', input)

View File

@ -1,12 +1,50 @@
import { rpc } from '../rpcClient'
import type { Group, Consultant } from '../types'
import type {
Group, Consultant,
ConsultantImportRow, ConsultantImportResult,
HardDeleteConsultantResult, ConsultantDetail,
} from '../types'
export type {
ConsultantImportRow, ConsultantImportResult,
HardDeleteConsultantResult, ConsultantDetail,
}
export const listGroups = (): Promise<Group[]> => rpc('consultants', 'listGroups')
export const getGroup = (id: number): Promise<Group | undefined> => rpc('consultants', 'getGroup', id)
export const addGroup = (group: Omit<Group, 'id'>): Promise<number> => rpc('consultants', 'addGroup', group)
export const updateGroup = (id: number, patch: Partial<Group>): Promise<number> =>
rpc('consultants', 'updateGroup', id, patch)
export const deleteGroup = (
id: number,
): Promise<{ ok: true } | { ok: false; reason: string; memberCount: number }> =>
rpc('consultants', 'deleteGroup', id)
export const listConsultants = (): Promise<Consultant[]> => rpc('consultants', 'listConsultants')
export const listActiveConsultants = (): Promise<Consultant[]> => rpc('consultants', 'listActiveConsultants')
export const listArchivedConsultants = (): Promise<Consultant[]> => rpc('consultants', 'listArchivedConsultants')
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 getConsultantsByIds = (ids: number[]): Promise<Consultant[]> =>
rpc('consultants', 'getConsultantsByIds', ids)
export const findConsultantByEmail = (email: string): Promise<Consultant | undefined> =>
rpc('consultants', 'findConsultantByEmail', email)
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 archiveConsultant = (id: number): Promise<number> => rpc('consultants', 'archiveConsultant', id)
export const unarchiveConsultant = (id: number): Promise<number> => rpc('consultants', 'unarchiveConsultant', id)
export const hardDeleteConsultant = (id: number): Promise<HardDeleteConsultantResult> =>
rpc('consultants', 'hardDeleteConsultant', id)
export const deleteConsultant = (id: number): Promise<void> => rpc('consultants', 'deleteConsultant', id)
export const importConsultantsToGroup = (
groupId: number,
rows: ConsultantImportRow[],
moveIfDifferentGroup?: boolean,
): Promise<ConsultantImportResult> =>
rpc('consultants', 'importConsultantsToGroup', groupId, rows, moveIfDifferentGroup)
export const getConsultantDetail = (id: number): Promise<ConsultantDetail | undefined> =>
rpc('consultants', 'getConsultantDetail', id)

View File

@ -10,3 +10,4 @@ export * from './conversationEntries'
export * from './assessments'
export * from './assignmentFeedback'
export * from './meetingCategoryRatings'
export * from './assignmentTransfer'

View File

@ -1,162 +0,0 @@
import Dexie, { type Table } from 'dexie'
import type {
Category, Criterion,
AssignmentType, PhaseTemplate,
Group, Consultant,
Assignment, MeetingInstance,
ConversationEntry, ConversationSkillScore,
Assessment, FeedbackDraft,
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText,
CriterionCategoryMapping, CriterionLevelDescription,
AppSettings,
} from './types'
class AssignmentMonitorDB extends Dexie {
categories!: Table<Category>
criteria!: Table<Criterion>
assignmentTypes!: Table<AssignmentType>
phaseTemplates!: Table<PhaseTemplate>
groups!: Table<Group>
consultants!: Table<Consultant>
assignments!: Table<Assignment>
meetingInstances!: Table<MeetingInstance>
conversationEntries!: Table<ConversationEntry>
conversationSkillScores!: Table<ConversationSkillScore>
assessments!: Table<Assessment>
feedbackDrafts!: Table<FeedbackDraft>
feedbackDimensions!: Table<FeedbackDimension>
feedbackCategories!: Table<FeedbackCategory>
feedbackCriterionItems!: Table<FeedbackCriterionItem>
assignmentFeedbacks!: Table<AssignmentFeedback>
feedbackCategoryRatings!: Table<FeedbackCategoryRating>
feedbackDimensionTexts!: Table<FeedbackDimensionText>
criterionCategoryMappings!: Table<CriterionCategoryMapping>
criterionLevelDescriptions!: Table<CriterionLevelDescription>
appSettings!: Table<AppSettings>
constructor() {
super('AssignmentMonitorDB')
this.version(1).stores({
categories: '++id, order',
criteria: '++id, categoryId, order',
groups: '++id',
consultants: '++id, groupId',
meetings: '++id, date, status, groupId',
assessments: '++id, meetingId, consultantId, criteriaId',
consultantNotes: '++id, meetingId, consultantId',
})
this.version(2).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',
meetings: null,
consultantNotes: null,
})
this.version(3).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',
meetings: null,
consultantNotes: null,
})
this.version(4).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',
meetings: null,
consultantNotes: null,
})
this.version(5).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',
meetings: null,
consultantNotes: null,
})
this.version(6).stores({
categories: '++id, order',
criteria: '++id, categoryId, order',
assignmentTypes: '++id',
phaseTemplates: '++id, assignmentTypeId, order',
groups: '++id',
consultants: '++id, groupId',
assignments: '++id, status, createdAt',
meetingInstances: '++id, assignmentId, phaseTemplateId',
conversationEntries: '++id, meetingInstanceId, instituteeId, sequenceIndex',
conversationSkillScores: '++id, conversationEntryId, criteriaId',
assessments: '++id, meetingInstanceId, instituteeId, criteriaId',
feedbackDrafts: '++id, assignmentId, instituteeId',
feedbackDimensions: '++id, order',
feedbackCategories: '++id, dimensionId, order',
feedbackCriterionItems: '++id, categoryId, order',
assignmentFeedbacks: '++id, assignmentId, instituteeId',
feedbackCategoryRatings: '++id, assignmentFeedbackId, feedbackCategoryId',
feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId',
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId',
criterionLevelDescriptions: '++id, criterionItemId, rating',
appSettings: '++id',
meetings: null,
consultantNotes: null,
})
}
}
export const db = new AssignmentMonitorDB()

View File

@ -1,88 +0,0 @@
import { db } from '../schema'
import type { PhaseKey } from '../types'
export async function seedIfEmpty() {
const count = await db.assignmentTypes.count()
if (count > 0) return
// ── Gemeinsame Kategorien & Kriterien (Legacy-Tabellen für Evaluation.tsx) ──
const catKomm = await db.categories.add({ name: 'Kommunikation', order: 1 })
const catFach = await db.categories.add({ name: 'Fachkompetenz', order: 2 })
const catKunde = await db.categories.add({ name: 'Kundenorientierung', order: 3 })
const catSelf = await db.categories.add({ name: 'Selbstorganisation', order: 4 })
const catCase = await db.categories.add({ name: 'Case-Kompetenz', order: 5 })
const kritKlarheit = await db.criteria.add({ categoryId: catKomm, name: 'Klarheit & Struktur', description: 'Klare, strukturierte Aussagen', weight: 2, order: 1 })
const kritUeberzeug = await db.criteria.add({ categoryId: catKomm, name: 'Überzeugungskraft', description: 'Argumente überzeugend präsentieren', weight: 2, order: 2 })
const kritZuhoeren = await db.criteria.add({ categoryId: catKomm, name: 'Aktives Zuhören', description: 'Eingehen auf Gesprächspartner', weight: 1, order: 3 })
const kritFragen = await db.criteria.add({ categoryId: catKomm, name: 'Umgang mit Fragen', description: 'Reaktion auf Kunden-Fragen', weight: 2, order: 4 })
const kritTiefe = await db.criteria.add({ categoryId: catFach, name: 'Fachliche Tiefe', description: 'Inhaltliche Korrektheit und Tiefe', weight: 3, order: 1 })
const kritProblem = await db.criteria.add({ categoryId: catFach, name: 'Problemlösung', description: 'Analytisches Vorgehen, Hypothesen', weight: 3, order: 2 })
const kritStakeholder = await db.criteria.add({ categoryId: catKunde, name: 'Stakeholder-Verständnis', description: 'Erkennt Bedürfnisse und Erwartungen', weight: 2, order: 1 })
const kritEmpathie = await db.criteria.add({ categoryId: catKunde, name: 'Empathie', description: 'Einfühlungsvermögen im Gespräch', weight: 1, order: 2 })
const kritVorb = await db.criteria.add({ categoryId: catSelf, name: 'Vorbereitung', description: 'Zeigt gute Meeting-Vorbereitung', weight: 2, order: 1 })
const kritInitiative = await db.criteria.add({ categoryId: catSelf, name: 'Eigeninitiative', description: 'Handelt proaktiv ohne Aufforderung', weight: 2, order: 2 })
const kritStruktur = await db.criteria.add({ categoryId: catCase, name: 'Problemstrukturierung', description: 'Systematische Zerlegung des Problems', weight: 3, order: 1 })
const kritHypo = await db.criteria.add({ categoryId: catCase, name: 'Hypothesenbildung', description: 'Klare, testbare Hypothesen', weight: 3, order: 2 })
const kritAnalyse = await db.criteria.add({ categoryId: catCase, name: 'Rechnen & Analyse', description: 'Datenbasiertes Vorgehen, Schätzfähigkeit', weight: 2, order: 3 })
const kritZeitmanage = await db.criteria.add({ categoryId: catCase, name: 'Zeitmanagement', description: 'Effizienter Umgang mit der Case-Zeit', weight: 2, order: 4 })
const addPhase = (
assignmentTypeId: number,
phaseKey: PhaseKey,
label: string,
withWhom: 'lead' | 'client',
opts: { repeatable?: boolean; conv?: boolean; assess?: boolean; criteriaIds?: number[]; order: number }
) => db.phaseTemplates.add({
assignmentTypeId,
phaseKey,
label,
withWhom,
isRepeatable: opts.repeatable ?? false,
hasConversation: opts.conv ?? false,
hasAssessment: opts.assess ?? false,
hasChecklist: false,
criteriaIds: opts.criteriaIds ?? [],
order: opts.order,
})
// ── AssignmentType 1: Generisch ──────────────────────────────────────────
const genericId = await db.assignmentTypes.add({
name: 'Generisch',
defaultPhaseKeys: ['preBriefing', 'briefing', 'alignmentCall', 'preDelivery', 'delivery', 'feedbackCallClient', 'feedbackCallLead'],
})
const genericCriteria = [kritKlarheit, kritUeberzeug, kritZuhoeren, kritTiefe, kritProblem, kritStakeholder, kritEmpathie, kritVorb, kritInitiative]
await addPhase(genericId, 'preBriefing', 'Pre-Briefing (Lead)', 'lead', { order: 1 })
await addPhase(genericId, 'briefing', 'Briefing (Kunde)', 'client', { conv: true, assess: true, criteriaIds: genericCriteria, order: 2 })
await addPhase(genericId, 'alignmentCall', 'Alignment Call (Kunde)', 'client', { repeatable: true, conv: true, assess: true, criteriaIds: genericCriteria, order: 3 })
await addPhase(genericId, 'preDelivery', 'Pre-Delivery (Lead)', 'lead', { order: 4 })
await addPhase(genericId, 'delivery', 'Delivery (Kunde)', 'client', { conv: true, assess: true, criteriaIds: genericCriteria, order: 5 })
await addPhase(genericId, 'feedbackCallClient', 'Feedback Call (Kunde)', 'client', { conv: true, assess: true, criteriaIds: genericCriteria, order: 6 })
await addPhase(genericId, 'feedbackCallLead', 'Feedback Call (Lead)', 'lead', { order: 7 })
// ── AssignmentType 2: Case Interview ────────────────────────────────────
const caseId = await db.assignmentTypes.add({
name: 'Case Interview',
defaultPhaseKeys: ['preBriefing', 'briefing', 'delivery', 'feedbackCallClient', 'feedbackCallLead'],
})
const caseCriteria = [kritStruktur, kritHypo, kritAnalyse, kritKlarheit, kritZeitmanage, kritUeberzeug, kritFragen]
await addPhase(caseId, 'preBriefing', 'Pre-Briefing (Lead)', 'lead', { order: 1 })
await addPhase(caseId, 'briefing', 'Case Briefing (Kunde)', 'client', { conv: true, assess: true, criteriaIds: caseCriteria, order: 2 })
await addPhase(caseId, 'delivery', 'Case Delivery (Kunde)', 'client', { conv: true, assess: true, criteriaIds: caseCriteria, order: 3 })
await addPhase(caseId, 'feedbackCallClient', 'Feedback Call (Kunde)', 'client', { conv: true, assess: true, criteriaIds: caseCriteria, order: 4 })
await addPhase(caseId, 'feedbackCallLead', 'Feedback Call (Lead)', 'lead', { order: 5 })
// ── AssignmentType 3: Stakeholder Meeting / Pitch ────────────────────────
const pitchId = await db.assignmentTypes.add({
name: 'Stakeholder Meeting / Pitch',
defaultPhaseKeys: ['preBriefing', 'briefing', 'alignmentCall', 'preDelivery', 'delivery', 'feedbackCallClient', 'feedbackCallLead'],
})
const pitchCriteria = [kritVorb, kritKlarheit, kritUeberzeug, kritStakeholder, kritFragen, kritEmpathie, kritInitiative]
await addPhase(pitchId, 'preBriefing', 'Pre-Briefing (Lead)', 'lead', { order: 1 })
await addPhase(pitchId, 'briefing', 'Briefing (Kunde)', 'client', { conv: true, assess: true, criteriaIds: pitchCriteria, order: 2 })
await addPhase(pitchId, 'alignmentCall', 'Alignment Call (Kunde)', 'client', { repeatable: true, conv: true, assess: true, criteriaIds: pitchCriteria, order: 3 })
await addPhase(pitchId, 'preDelivery', 'Pre-Delivery (Lead)', 'lead', { order: 4 })
await addPhase(pitchId, 'delivery', 'Pitch / Delivery (Kunde)', 'client', { conv: true, assess: true, criteriaIds: pitchCriteria, order: 5 })
await addPhase(pitchId, 'feedbackCallClient', 'Feedback Call (Kunde)', 'client', { conv: true, assess: true, criteriaIds: pitchCriteria, order: 6 })
await addPhase(pitchId, 'feedbackCallLead', 'Feedback Call (Lead)', 'lead', { order: 7 })
}

View File

@ -1,65 +0,0 @@
import { db } from '../schema'
export async function seedFeedbackStructureIfEmpty() {
const count = await db.feedbackDimensions.count()
if (count > 0) return
type CatSeed = { name: string; items: string[] }
type DimSeed = { name: string; cats: CatSeed[] }
const dimensions: DimSeed[] = [
{
name: 'Mindset, Commitment, Collaboration',
cats: [
{ name: 'Persönliche Einstellung', items: ['Ruhig bei Stress', 'Offen für neue Themen', 'Auftreten / Ausstrahlung', 'Pro aktiv', 'Lösungsorientiert'] },
{ name: 'Professionalität', items: ['Pünktlichkeit', 'Professionell bei informellen Gesprächen', 'Adressatengerecht', 'Hohes Maß an Selbststeuerung', 'Klarheit', 'Zuverlässigkeit'] },
{ name: 'Commitment', items: ['Offen für spontane Änderungen', 'Selbständiger Lösungsansatz', 'Pro aktive Ideen', 'Verantwortungsbewusst', 'Hilfsbereit', 'Teamorientiert'] },
{ name: 'Kommunikationsfähigkeit', items: ['To the point', 'Recap-Fähigkeit', 'Active Listening', 'Souveränität'] },
{ name: 'Zusammenarbeit', items: ['Regelmäßige Abstimmung', 'Unterstützung bei Bedarf', 'Know-How Sharing', 'Starkes Teamverhalten', 'Verantwortung und Vertrauen', 'Inhaltlich unterstützend'] },
],
},
{
name: 'Running Our Projects',
cats: [
{ name: 'Ergebnisverantwortung', items: ['Fokussierung', 'Priorisierung (Impact Driven)', 'Machbarkeit / Umsetzbarkeit', 'Alternativvorschläge', 'Interne Abstimmung und Feedback'] },
{ name: 'Arbeitsstil', items: ['Komplexitätsmanagement', 'Eigenverantwortung', 'Feedbackaffinität', 'Verantwortungsbewusst / vorausschauend', 'Selbstreflektiert, lösungsorientiert', 'Offen, initiativ, kollegial', 'Organisiert, kommunikationsfähig', 'Lernfähig, anpassungsbereit, flexibel'] },
{ name: 'Kundenmanagement', items: ['Aktives Erwartungsmanagement', 'Selbstsicherheit und fachliche Überzeugung', 'Aktive Advisory', 'Realistisch', 'Kundenfokussiert'] },
{ name: 'Lieferzeiten', items: ['Verlässlichkeit', 'Fortschrittskontrolle und Planadaption', 'Aktives Scoping', 'Meetingsvorbereitung', 'Transparent'] },
],
},
{
name: 'Growing our People',
cats: [
{ name: 'Networking', items: ['Cross-Collaboration', 'Network Growth', 'Network Utilization'] },
{ name: 'Learning und Development', items: ['Kritikfähigkeit', 'Wissensaufbau (Kompetenzausstrahlung)', 'Umsetzungsbereitschaft Feedback'] },
],
},
{
name: 'Functional & Technical Skills',
cats: [
{ name: 'Qualität des Inhalts', items: ['Belastbarkeit', 'Nachvollziehbarkeit', 'Logische Storyline'] },
{ name: 'Inhaltliche Vollständigkeit', items: ['Wesentliche Elemente vorhanden', 'Realitätsnähe', 'Nutzenfokus'] },
{ name: 'Qualität der Unterlage', items: ['Design nach Standardvorgaben', 'Sprachliche Präzision', 'Visualisierungen unterstützen Kernbotschaften'] },
{ name: 'Prozesstreue', items: ['Kenne und befolge Prozesse und Vorgaben', 'Nachvollziehbarkeit und Transparenz', 'Qualitätschecks'] },
],
},
{
name: 'Innovation und Asset Development',
cats: [
{ name: 'Innovation', items: ['Aktiver Vorschlag für Verbesserungen', 'Neue Ideen zur Verbesserung', 'Nutze aktuellen Wissensstand', 'Liefere über den Kundenerwartungen'] },
],
},
]
for (let di = 0; di < dimensions.length; di++) {
const dim = dimensions[di]
const dimId = await db.feedbackDimensions.add({ name: dim.name, order: di + 1 })
for (let ci = 0; ci < dim.cats.length; ci++) {
const cat = dim.cats[ci]
const catId = await db.feedbackCategories.add({ dimensionId: dimId, name: cat.name, order: ci + 1 })
for (let ii = 0; ii < cat.items.length; ii++) {
await db.feedbackCriterionItems.add({ categoryId: catId, name: cat.items[ii], order: ii + 1 })
}
}
}
}

View File

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

View File

@ -1,3 +0,0 @@
export { seedIfEmpty } from './assignmentTypes'
export { seedFeedbackStructureIfEmpty } from './feedbackStructure'
export { seedCriterionMappingsIfEmpty, migrateToUnifiedCriteriaIfNeeded } from './migrations'

View File

@ -1,106 +0,0 @@
import { db } from '../schema'
export async function seedCriterionMappingsIfEmpty() {
const count = await db.criterionCategoryMappings.count()
if (count > 0) return
const [crits, cats] = await Promise.all([
db.criteria.toArray(),
db.feedbackCategories.toArray(),
])
if (crits.length === 0 || cats.length === 0) return
const c = (name: string) => crits.find(x => x.name === name)?.id
const f = (name: string) => cats.find(x => x.name === name)?.id
const mappings: [string, string][] = [
['Klarheit & Struktur', 'Kommunikationsfähigkeit'],
['Klarheit & Struktur', 'Qualität des Inhalts'],
['Überzeugungskraft', 'Kommunikationsfähigkeit'],
['Überzeugungskraft', 'Kundenmanagement'],
['Aktives Zuhören', 'Kommunikationsfähigkeit'],
['Umgang mit Fragen', 'Kommunikationsfähigkeit'],
['Umgang mit Fragen', 'Kundenmanagement'],
['Fachliche Tiefe', 'Qualität des Inhalts'],
['Fachliche Tiefe', 'Inhaltliche Vollständigkeit'],
['Problemlösung', 'Ergebnisverantwortung'],
['Problemlösung', 'Arbeitsstil'],
['Stakeholder-Verständnis', 'Kundenmanagement'],
['Stakeholder-Verständnis', 'Persönliche Einstellung'],
['Empathie', 'Persönliche Einstellung'],
['Empathie', 'Kommunikationsfähigkeit'],
['Vorbereitung', 'Professionalität'],
['Vorbereitung', 'Lieferzeiten'],
['Eigeninitiative', 'Commitment'],
['Eigeninitiative', 'Persönliche Einstellung'],
['Problemstrukturierung', 'Ergebnisverantwortung'],
['Problemstrukturierung', 'Qualität des Inhalts'],
['Hypothesenbildung', 'Ergebnisverantwortung'],
['Hypothesenbildung', 'Arbeitsstil'],
['Rechnen & Analyse', 'Qualität des Inhalts'],
['Rechnen & Analyse', 'Inhaltliche Vollständigkeit'],
['Zeitmanagement', 'Lieferzeiten'],
['Zeitmanagement', 'Arbeitsstil'],
]
for (const [critName, catName] of mappings) {
const criterionId = c(critName)
const feedbackCategoryId = f(catName)
if (criterionId && feedbackCategoryId) {
await db.criterionCategoryMappings.add({ criterionId, feedbackCategoryId })
}
}
}
// Löscht nur Bewertungs-Scores (keine Textinhalte), verknüpft PhaseTemplates
// mit FeedbackCategory-IDs statt veralteten Criterion-IDs.
export async function migrateToUnifiedCriteriaIfNeeded() {
const cats = await db.feedbackCategories.toArray()
if (cats.length === 0) return
const templates = await db.phaseTemplates.toArray()
if (templates.length === 0) return
const catIds = new Set(cats.map(c => c.id!))
const firstWithIds = templates.find(t => t.criteriaIds.length > 0)
if (firstWithIds && catIds.has(firstWithIds.criteriaIds[0])) return
const cat = (name: string) => cats.find(c => c.name === name)?.id
await db.conversationSkillScores.clear()
await db.assessments.clear()
const genericCats = [
cat('Kommunikationsfähigkeit'), cat('Persönliche Einstellung'),
cat('Kundenmanagement'), cat('Ergebnisverantwortung'), cat('Qualität des Inhalts'),
].filter(Boolean) as number[]
const caseCats = [
cat('Kommunikationsfähigkeit'), cat('Ergebnisverantwortung'),
cat('Arbeitsstil'), cat('Qualität des Inhalts'), cat('Inhaltliche Vollständigkeit'),
].filter(Boolean) as number[]
const pitchCats = [
cat('Kommunikationsfähigkeit'), cat('Persönliche Einstellung'),
cat('Kundenmanagement'), cat('Ergebnisverantwortung'),
cat('Qualität des Inhalts'), cat('Qualität der Unterlage'),
].filter(Boolean) as number[]
const types = await db.assignmentTypes.toArray()
for (const tmpl of templates) {
if (!tmpl.hasAssessment && !tmpl.hasConversation) {
await db.phaseTemplates.update(tmpl.id!, { criteriaIds: [] })
continue
}
const aType = types.find(t => t.id === tmpl.assignmentTypeId)
let newIds: number[]
if (aType?.name === 'Case Interview') {
newIds = caseCats
} else if (aType?.name === 'Stakeholder Meeting / Pitch') {
newIds = pitchCats
} else {
newIds = genericCats
}
await db.phaseTemplates.update(tmpl.id!, { criteriaIds: newIds })
}
}

View File

@ -38,6 +38,8 @@ export interface AssignmentType {
name: string
defaultPhaseKeys: PhaseKey[]
criteriaIds?: number[] // FeedbackCriterionItem IDs; leer = alle anzeigen
/** Anzeige-Farbe auf Dashboard-Kacheln (Hex, z.B. #0070AD), editierbar in Einstellungen → Ass.-Typen. */
color?: string
}
export interface PhaseTemplate {
@ -66,11 +68,18 @@ export interface ChecklistItemDef {
id?: number
phaseTemplateId: number
label: string
linkedCriterionItemId?: number
linkedCriterionItemId?: number | null
scope: 'perInstitutee' | 'meetingWide'
order: number
/**
* Wie der Checklisten-Score mit Protokoll-Notation auf dasselbe Kriterium zusammenfließt:
* equal = gleichrangig mitteln; primary = Checkliste dominiert; weighted = Checkliste ×2.
*/
scoreContribution?: ChecklistScoreContribution
}
export type ChecklistScoreContribution = 'equal' | 'primary' | 'weighted'
/**
* Antwort auf einen Checklisten-Punkt für dieses Meeting. `criterionItemId` überschreibt pro
* Antwort individuell die Konfigurations-Vorgabe aus `ChecklistItemDef.linkedCriterionItemId`
@ -85,7 +94,9 @@ export interface ChecklistResponse {
checklistItemId: number
rating: RatingScore | null
note: string
criterionItemId?: number
/** Gesetzt wenn im Meeting manuell zugeordnet oder explizit entfernt — sonst gilt Config-Standard. */
criterionItemId?: number | null
criterionOverride?: boolean
}
// ─── Stammdaten ───────────────────────────────────────────────────────────────
@ -101,6 +112,56 @@ export interface Consultant {
groupId: number
firstName: string
lastName: string
/** Unternehmens-E-Mail, Pflicht und eindeutig (normalisiert klein). */
email: string
/** Archiv — Person nicht mehr für neue Assignments vorschlagen; Historie bleibt. */
archivedAt?: string
}
export interface ConsultantImportRow {
email: string
firstName: string
lastName: string
}
export interface ConsultantImportResult {
created: number
updated: number
moved: number
skipped: number
errors: string[]
}
export interface HardDeleteConsultantResult {
emptiedAssignmentIds: number[]
feedbacksDeleted: number
assignmentsTouched: number
}
export interface ConsultantAssignmentSummary {
assignmentId: number
title: string
assignmentNumber?: string
client: string
status: 'active' | 'done'
deletedAt?: string
archivedAt?: string
plannedStart?: string
plannedEnd?: string
createdAt: string
typeName: string
feedbackStatus: 'none' | 'draft' | 'final'
categoryRatings: Array<{
categoryId: number
categoryName: string
rating: RatingScore | null
}>
}
export interface ConsultantDetail {
consultant: Consultant
group: Group | undefined
assignments: ConsultantAssignmentSummary[]
}
// ─── Assignments ─────────────────────────────────────────────────────────────
@ -125,6 +186,10 @@ export interface Assignment {
deletedAt?: string
/** Archiv-Zeitpunkt — dauerhafte Aufbewahrung (spätere Auswertung/ML), unabhängig vom Papierkorb. */
archivedAt?: string
/** Geplanter Beginn (ISO-Datum YYYY-MM-DD), optional; Pflicht nur wenn AppSettings.requirePlannedDates. */
plannedStart?: string
/** Geplantes Ende (ISO-Datum YYYY-MM-DD), optional. */
plannedEnd?: string
}
// ─── Meeting-Instanzen ────────────────────────────────────────────────────────
@ -179,6 +244,8 @@ export interface Assessment {
score: RatingScore | null
note: string
updatedAt: string
/** Score aus Vorschlag übernommen — Hinweis, dass manuell gegengelesen werden sollte. */
scoreNeedsReview?: boolean
}
/**
@ -303,8 +370,57 @@ export interface AppSettings {
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. */
/** Prompt-Template für „🏷 Vorschläge analysieren" (Protokoll-Freitext), editierbar in Config → KI-Prompts. */
criterionTaggingPromptTemplate?: string
/** Prompt-Template für Checklisten-Kriterium-Vorschläge (separater KI-Call), editierbar in Config → KI-Prompts. */
checklistTaggingPromptTemplate?: string
/** Farbbänder der 1–10-Skala (Defaults in constants.ts), editierbar in Config → Skala. */
ratingColorBands?: RatingColorBand[]
/** Wenn true: plannedStart/plannedEnd beim Anlegen eines Assignments Pflicht. */
requirePlannedDates?: boolean
}
// ─── Assignment-Übergabe (Lead → Lead, Export/Import) ─────────────────────────
export const ASSIGNMENT_TRANSFER_KIND = 'assignment-monitor-assignment-transfer' as const
/** JSON-Paket für die Übergabe eines einzelnen Assignments inkl. Notizen/Checklisten/Berater. */
export interface AssignmentTransferPackage {
kind: typeof ASSIGNMENT_TRANSFER_KIND
version: 1
exportedAt: string
source: {
assignmentId: number
assignmentNumber?: string
title: string
client: string
}
groups: Group[]
consultants: Consultant[]
assignmentType: AssignmentType
phaseTemplates: PhaseTemplate[]
checklistItemDefs: ChecklistItemDef[]
feedbackDimensions: FeedbackDimension[]
feedbackCategories: FeedbackCategory[]
feedbackCriterionItems: FeedbackCriterionItem[]
assignment: Assignment
meetingInstances: MeetingInstance[]
conversationEntries: ConversationEntry[]
conversationSkillScores: ConversationSkillScore[]
assessments: Assessment[]
meetingCategoryRatings: MeetingCategoryRating[]
checklistResponses: ChecklistResponse[]
feedbackDrafts: FeedbackDraft[]
assignmentFeedbacks: AssignmentFeedback[]
feedbackCategoryRatings: FeedbackCategoryRating[]
feedbackDimensionTexts: FeedbackDimensionText[]
feedbackCategoryTexts: FeedbackCategoryText[]
}
export interface AssignmentImportResult {
assignmentId: number
consultantsMatched: string[]
consultantsCreated: string[]
meetings: number
checklistResponses: number
}

View File

@ -1,6 +1,44 @@
@import "tailwindcss";
/*
* Design-Tokens (UX.1) — Capgemini-Fachtool.
* Seiten schrittweise auf bg-app / bg-surface / border-line / text-ink* umstellen.
*/
@theme {
--color-brand: #0070AD;
--color-brand-dark: #005a8e;
--color-brand-soft: #e8f3f9;
--color-app: #eef2f5;
--color-surface: #ffffff;
--color-line: #d8e0e8;
--color-ink: #1a2b3c;
--color-ink-muted: #5a6b7c;
--color-ink-faint: #8a9aab;
--font-sans: "Source Sans 3", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
--radius-control: 0.5rem;
--radius-panel: 0.75rem;
--shadow-header: 0 1px 0 rgb(0 0 0 / 0.06), 0 2px 8px rgb(0 70 120 / 0.12);
--shadow-nav: 0 -1px 0 rgb(0 0 0 / 0.06), 0 -4px 12px rgb(0 0 0 / 0.04);
}
@layer base {
html {
font-family: var(--font-sans);
color: var(--color-ink);
-webkit-font-smoothing: antialiased;
}
body {
margin: 0;
min-height: 100dvh;
background-color: var(--color-app);
}
#root {
min-height: 100dvh;
}
}

View File

@ -6,6 +6,7 @@ import {
getOrSeedDefaultDimensionPromptTemplate, saveDefaultDimensionPromptTemplate,
getOrSeedDefaultCategoryPromptTemplate, saveDefaultCategoryPromptTemplate,
getOrSeedCriterionTaggingPromptTemplate, saveCriterionTaggingPromptTemplate,
getOrSeedChecklistTaggingPromptTemplate, saveChecklistTaggingPromptTemplate,
} from '../db'
import { AI_CONFIG } from '../config/constants'
import { getAiSettings, saveAiSettings } from '../utils/aiClient'
@ -22,27 +23,32 @@ export default function AiPromptConfig() {
const [taggingTemplate, setTaggingTemplate] = useState('')
const [taggingExpanded, setTaggingExpanded] = useState(false)
const [taggingSaved, setTaggingSaved] = useState(false)
const [checklistTaggingTemplate, setChecklistTaggingTemplate] = useState('')
const [checklistTaggingExpanded, setChecklistTaggingExpanded] = useState(false)
const [checklistTaggingSaved, setChecklistTaggingSaved] = 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 saveTimer = useRef<Map<number | 'default' | 'category' | 'tagging' | 'checklistTagging', 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([
const [structure, promptRows, defTemplate, catTemplate, taggingTpl, checklistTaggingTpl] = await Promise.all([
loadFeedbackStructure(),
listFeedbackDimensionPrompts(),
getOrSeedDefaultDimensionPromptTemplate(),
getOrSeedDefaultCategoryPromptTemplate(),
getOrSeedCriterionTaggingPromptTemplate(),
getOrSeedChecklistTaggingPromptTemplate(),
])
setDimensions([...structure.dimensions].sort((a, b) => a.order - b.order))
setPrompts(promptRows)
setDefaultTemplate(defTemplate)
setCategoryTemplate(catTemplate)
setTaggingTemplate(taggingTpl)
setChecklistTaggingTemplate(checklistTaggingTpl)
}
useEffect(() => { load() }, [])
@ -100,6 +106,18 @@ export default function AiPromptConfig() {
}, 600))
}
const resetTaggingToDefault = async () => {
const def = AI_CONFIG.defaultCriterionTaggingPromptTemplate
await saveCriterionTaggingPromptTemplate(def)
setTaggingTemplate(def)
}
const resetChecklistTaggingToDefault = async () => {
const def = AI_CONFIG.defaultChecklistTaggingPromptTemplate
await saveChecklistTaggingPromptTemplate(def)
setChecklistTaggingTemplate(def)
}
const saveTaggingTemplate = (value: string) => {
setTaggingTemplate(value)
const prevTimer = saveTimer.current.get('tagging')
@ -111,6 +129,17 @@ export default function AiPromptConfig() {
}, 600))
}
const saveChecklistTaggingTemplate = (value: string) => {
setChecklistTaggingTemplate(value)
const prevTimer = saveTimer.current.get('checklistTagging')
if (prevTimer) clearTimeout(prevTimer)
saveTimer.current.set('checklistTagging', setTimeout(async () => {
await saveChecklistTaggingPromptTemplate(value)
setChecklistTaggingSaved(true)
setTimeout(() => setChecklistTaggingSaved(false), 2000)
}, 600))
}
return (
<div className="space-y-4">
<div>
@ -240,10 +269,11 @@ export default function AiPromptConfig() {
</div>
<div>
<h2 className="text-lg font-bold text-gray-800">KI-Prompt: Kriterien-Zuordnungsvorschläge</h2>
<h2 className="text-lg font-bold text-gray-800">KI-Prompts: 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.
„🏷 Vorschläge analysieren" im Meeting löst <strong>zwei getrennte KI-Calls</strong> aus: Protokoll-Freitext
(Antwortformat <code className="font-mono text-xs">ZEILE n:</code>) und Checklistenpunkte
(<code className="font-mono text-xs">PUNKT n:</code>).
</p>
</div>
@ -251,23 +281,28 @@ export default function AiPromptConfig() {
<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="font-semibold text-sm text-indigo-800">Protokoll — Tagging-Prompt</span>
<span className="text-xs text-indigo-400">{taggingExpanded ? '▲' : '▼'}</span>
</button>
{taggingExpanded && (
<div className="p-3 space-y-2">
<p className="text-xs text-gray-500">Freitext-Zeilen aus Gesprächsprotokoll und allgemeinen Notizen.</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]"
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={taggingTemplate}
onChange={e => saveTaggingTemplate(e.target.value)}
/>
{taggingSaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
<button onClick={resetTaggingToDefault}
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 px-3 py-1.5 rounded-lg font-medium">
Protokoll-Tagging auf Standard zurücksetzen
</button>
</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="text-xs font-semibold text-gray-500 uppercase tracking-wide">Platzhalter Protokoll-Tagging</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">
@ -278,6 +313,45 @@ export default function AiPromptConfig() {
</div>
</div>
<div className="bg-white rounded-xl border border-teal-100 shadow-sm overflow-hidden">
<button
onClick={() => setChecklistTaggingExpanded(p => !p)}
className="w-full flex items-center justify-between p-3 bg-teal-50 border-b border-teal-100 text-left">
<span className="font-semibold text-sm text-teal-800">Checkliste — Tagging-Prompt</span>
<span className="text-xs text-teal-400">{checklistTaggingExpanded ? '▲' : '▼'}</span>
</button>
{checklistTaggingExpanded && (
<div className="p-3 space-y-2">
<p className="text-xs text-gray-500">
Strukturierte Prüfpunkte — nur Label (kein Score). Antwortformat: <code className="font-mono">PUNKT n:</code>.
In Config → Ass.-Typen als „Standard-Zuordnung (KI)" nutzbar; spart Meeting-Calls.
</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={checklistTaggingTemplate}
onChange={e => saveChecklistTaggingTemplate(e.target.value)}
/>
{checklistTaggingSaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
<button onClick={resetChecklistTaggingToDefault}
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 px-3 py-1.5 rounded-lg font-medium">
Checklisten-Tagging auf Standard zurücksetzen
</button>
</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 Checklisten-Tagging</div>
<div className="grid gap-1.5">
{AI_CONFIG.checklistTaggingPromptPlaceholders.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!)

View File

@ -3,8 +3,8 @@ import { useNavigate } from 'react-router-dom'
import {
type AssignmentType, type PhaseTemplate, type Consultant, type Group,
listAssignmentTypes, listPhaseTemplatesForType,
listGroups, listConsultants, addGroup, addConsultant,
addAssignment,
listGroups, listActiveConsultants, addGroup, addConsultant,
addAssignment, getOrSeedRequirePlannedDates,
} from '../db'
export default function AssignmentCreate() {
@ -21,21 +21,28 @@ export default function AssignmentCreate() {
assignmentTypeId: 0,
selectedGroupId: 0,
instituteeIds: [] as number[],
plannedStart: '',
plannedEnd: '',
})
const [requirePlannedDates, setRequirePlannedDates] = useState(false)
// Inline Berater-Anlage
const [showAddInstitutee, setShowAddInstitutee] = useState(false)
const [newFirst, setNewFirst] = useState('')
const [newLast, setNewLast] = useState('')
const [newEmail, setNewEmail] = useState('')
const [createError, setCreateError] = useState<string | null>(null)
const reload = async () => {
const [t, g, c] = await Promise.all([
const [t, g, c, requireDates] = await Promise.all([
listAssignmentTypes(),
listGroups(),
listConsultants(),
listActiveConsultants(),
getOrSeedRequirePlannedDates(),
])
setTypes(t)
setGroups(g)
setConsultants(c)
setRequirePlannedDates(requireDates)
}
useEffect(() => { reload() }, [])
@ -56,23 +63,34 @@ export default function AssignmentCreate() {
}))
const addInstituteeInline = async () => {
if (!newFirst.trim() || !newLast.trim()) return
if (!newFirst.trim() || !newLast.trim() || !newEmail.trim()) return
let groupId = form.selectedGroupId
if (!groupId) {
// Erstelle eine Standardgruppe falls keine vorhanden
groupId = await addGroup({ name: 'Neue Gruppe', cohortYear: new Date().getFullYear() })
setForm(p => ({ ...p, selectedGroupId: groupId }))
}
const id = await addConsultant({ groupId, firstName: newFirst.trim(), lastName: newLast.trim() })
try {
setCreateError(null)
const id = await addConsultant({
groupId,
firstName: newFirst.trim(),
lastName: newLast.trim(),
email: newEmail.trim(),
})
await reload()
setForm(p => ({ ...p, instituteeIds: [...p.instituteeIds, id] }))
setNewFirst('')
setNewLast('')
setNewEmail('')
setShowAddInstitutee(false)
} catch (e) {
setCreateError(e instanceof Error ? e.message : String(e))
}
}
const create = async () => {
if (!form.title.trim() || !form.client.trim() || !form.assignmentNumber.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return
if (requirePlannedDates && (!form.plannedStart || !form.plannedEnd)) return
const id = await addAssignment({
title: form.title,
client: form.client,
@ -82,11 +100,20 @@ export default function AssignmentCreate() {
instituteeIds: form.instituteeIds,
status: 'active',
createdAt: new Date().toISOString(),
plannedStart: form.plannedStart || undefined,
plannedEnd: form.plannedEnd || undefined,
})
navigate(`/assignment/${id}`)
}
const canCreate = form.title.trim() && form.client.trim() && form.assignmentNumber.trim() && form.assignmentTypeId && form.instituteeIds.length > 0
const canCreate = !!(
form.title.trim() &&
form.client.trim() &&
form.assignmentNumber.trim() &&
form.assignmentTypeId &&
form.instituteeIds.length > 0 &&
(!requirePlannedDates || (form.plannedStart && form.plannedEnd))
)
const selectedInstitutees = consultants.filter(c => form.instituteeIds.includes(c.id!))
return (
@ -113,6 +140,30 @@ export default function AssignmentCreate() {
<option value={0}>Assignment-Typ wählen…</option>
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">
Geplanter Beginn{requirePlannedDates ? ' *' : ''}
</label>
<input
type="date"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
value={form.plannedStart}
onChange={e => setForm(p => ({ ...p, plannedStart: e.target.value }))}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">
Geplantes Ende{requirePlannedDates ? ' *' : ''}
</label>
<input
type="date"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
value={form.plannedEnd}
onChange={e => setForm(p => ({ ...p, plannedEnd: e.target.value }))}
/>
</div>
</div>
</div>
{/* Phase-Preview */}
@ -154,6 +205,11 @@ export default function AssignmentCreate() {
onChange={e => setNewLast(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addInstituteeInline()} />
</div>
<input className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
placeholder="E-Mail *" type="email" value={newEmail}
onChange={e => setNewEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addInstituteeInline()} />
{createError && <p className="text-xs text-red-600">{createError}</p>}
{groups.length > 0 && (
<select className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
value={form.selectedGroupId || ''}
@ -164,11 +220,11 @@ export default function AssignmentCreate() {
)}
<div className="flex gap-2">
<button onClick={addInstituteeInline}
disabled={!newFirst.trim() || !newLast.trim()}
disabled={!newFirst.trim() || !newLast.trim() || !newEmail.trim()}
className="flex-1 py-2 rounded-lg text-sm font-medium bg-brand text-white disabled:bg-gray-200 disabled:text-gray-400">
Hinzufügen
</button>
<button onClick={() => { setShowAddInstitutee(false); setNewFirst(''); setNewLast('') }}
<button onClick={() => { setShowAddInstitutee(false); setNewFirst(''); setNewLast(''); setNewEmail(''); setCreateError(null) }}
className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
Abbrechen
</button>

View File

@ -12,6 +12,7 @@ import {
isAssignmentFeedbackComplete, softDeleteAssignment, archiveAssignment,
} from '../db'
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
import { downloadAssignmentTransfer } from '../utils/assignmentTransfer'
import { exportAssignmentReportPdf, exportInstituteeFeedbackPdf } from '../utils/pdfExport'
import { resolveScoreStyle } from '../config/constants'
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
@ -48,6 +49,8 @@ export default function AssignmentDetail() {
const [confirmAssignmentTrash, setConfirmAssignmentTrash] = useState(false)
const [confirmAssignmentArchive, setConfirmAssignmentArchive] = useState(false)
const [pdfExportingKey, setPdfExportingKey] = useState<string | null>(null)
const [transferExporting, setTransferExporting] = useState(false)
const [transferMsg, setTransferMsg] = useState('')
const load = async () => {
const a = await getAssignment(assignmentId)
@ -167,6 +170,19 @@ export default function AssignmentDetail() {
}
}
const handleExportTransfer = async () => {
setTransferExporting(true)
setTransferMsg('')
try {
await downloadAssignmentTransfer(assignmentId)
setTransferMsg('Übergabe-Datei heruntergeladen (inkl. Berater, Meetings, Checklisten, Notizen).')
} catch (e) {
setTransferMsg(e instanceof Error ? e.message : 'Export fehlgeschlagen')
} finally {
setTransferExporting(false)
}
}
const statusBadge = (status: MeetingInstance['status']) => {
const map = {
planned: 'bg-yellow-100 text-yellow-700',
@ -190,7 +206,7 @@ export default function AssignmentDetail() {
)}
<h1 className="text-xl font-bold text-gray-800 leading-tight">{assignment.title}</h1>
</div>
<div className="flex-shrink-0 flex gap-1.5">
<div className="flex-shrink-0 flex gap-1.5 flex-wrap justify-end">
<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">
@ -203,6 +219,13 @@ export default function AssignmentDetail() {
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition disabled:opacity-50">
{pdfExportingKey === 'report' ? '⏳' : '⬇ PDF'}
</button>
<button
onClick={handleExportTransfer}
disabled={transferExporting}
title="Assignment inkl. Notizen, Checklisten und Beratern für Lead-Übergabe exportieren"
className="text-xs px-3 py-1.5 border border-brand/30 rounded-lg text-brand hover:bg-brand/5 transition disabled:opacity-50">
{transferExporting ? '⏳' : '↔ Übergabe'}
</button>
<button
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
@ -214,6 +237,11 @@ export default function AssignmentDetail() {
<div className="text-xs text-gray-400 mt-1">
{institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}
</div>
{transferMsg && (
<div className="mt-2 text-xs text-brand bg-brand/5 border border-brand/20 rounded-lg px-3 py-2">
{transferMsg}
</div>
)}
</div>
{/* Assignment-Aktionen (nur für abgeschlossene Assignments) */}

View File

@ -24,6 +24,8 @@ export default function AssignmentEdit() {
const [status, setStatus] = useState<'active' | 'done'>('active')
const [instituteeIds, setInstituteeIds] = useState<number[]>([])
const [instituteeEnrollments, setInstituteeEnrollments] = useState<Array<{ instituteeId: number; from?: string; to?: string }>>([])
const [plannedStart, setPlannedStart] = useState('')
const [plannedEnd, setPlannedEnd] = useState('')
const [typeChanged, setTypeChanged] = useState(false)
const [addInstituteeId, setAddInstituteeId] = useState(0)
@ -48,6 +50,8 @@ export default function AssignmentEdit() {
setStatus(a.status)
setInstituteeIds(a.instituteeIds ?? [])
setInstituteeEnrollments(a.instituteeEnrollments ?? [])
setPlannedStart(a.plannedStart?.slice(0, 10) ?? '')
setPlannedEnd(a.plannedEnd?.slice(0, 10) ?? '')
}
loadData()
}, [assignmentId])
@ -63,6 +67,8 @@ export default function AssignmentEdit() {
status,
instituteeIds,
instituteeEnrollments,
plannedStart: plannedStart || undefined,
plannedEnd: plannedEnd || undefined,
})
navigate(`/assignment/${assignmentId}`)
}
@ -90,7 +96,7 @@ export default function AssignmentEdit() {
)
}
const availableConsultants = allConsultants.filter(c => !instituteeIds.includes(c.id!))
const availableConsultants = allConsultants.filter(c => !instituteeIds.includes(c.id!) && !c.archivedAt)
if (!assignment) return <div className="text-center py-10 text-gray-400">Lädt…</div>
@ -139,6 +145,26 @@ export default function AssignmentEdit() {
onChange={e => setDescription(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">Geplanter Beginn</label>
<input
type="date"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
value={plannedStart}
onChange={e => setPlannedStart(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">Geplantes Ende</label>
<input
type="date"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
value={plannedEnd}
onChange={e => setPlannedEnd(e.target.value)}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">Assignment-Typ</label>
<select

View File

@ -14,11 +14,14 @@ import {
type MeetingInstance,
type Assessment,
type ConversationEntry,
type ChecklistItemDef,
type ChecklistResponse,
getAssignment, getConsultant, getAssignmentType,
loadFeedbackStructure, resolveVisibleStructure,
listDoneMeetingsChronological,
listAssessmentsForMeetings, listConversationEntriesForMeetings,
listMeetingCategoryRatingsForMeetings,
listChecklistItemDefsForPhaseTemplates, listChecklistResponsesForMeetings,
getRatingTrendWeightStep,
getOrCreateAssignmentFeedback, updateAssignmentFeedback,
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, deleteFeedbackCategoryRating,
@ -325,9 +328,12 @@ export default function AssignmentFeedbackPage() {
getOrSeedDefaultDimensionPromptTemplate(),
])
const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([
const phaseTemplateIds = [...new Set(meetings.map(m => m.phaseTemplateId))]
const [allAssessments, allEntries, checklistItems, checklistResponses] = await Promise.all([
listAssessmentsForMeetings(meetingIds),
listConversationEntriesForMeetings(meetingIds),
listChecklistItemDefsForPhaseTemplates(phaseTemplateIds),
listChecklistResponsesForMeetings(meetingIds),
])
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
@ -339,6 +345,7 @@ export default function AssignmentFeedbackPage() {
institutee, assignment, dimension: dim, category: firstCat,
catItems: items.filter(i => i.categoryId === firstCat.id),
ratings, catTrend: catTrends.get(firstCat.id!), instAssessments, instEntries, meetings,
checklistItems, checklistResponses,
}))
: '(keine Kategorien)'
@ -389,6 +396,8 @@ export default function AssignmentFeedbackPage() {
meetings: MeetingInstance[],
instAssessments: Assessment[],
instEntries: ConversationEntry[],
checklistItems: ChecklistItemDef[],
checklistResponses: ChecklistResponse[],
categoryTemplate: string,
settings: { apiKey: string; model: string },
): Promise<CategoryCondenseEntry> => {
@ -399,6 +408,7 @@ export default function AssignmentFeedbackPage() {
const context = buildCategoryPromptContext({
institutee, assignment, dimension: dim, category: cat, catItems,
ratings, catTrend: catTrends.get(cat.id!), instAssessments, instEntries, meetings,
checklistItems, checklistResponses,
})
const prompt = renderPrompt(categoryTemplate, context)
const text = await callOpenRouter(prompt, settings)
@ -452,6 +462,8 @@ export default function AssignmentFeedbackPage() {
meetings: MeetingInstance[],
instAssessments: Assessment[],
instEntries: ConversationEntry[],
checklistItems: ChecklistItemDef[],
checklistResponses: ChecklistResponse[],
categoryTemplate: string,
settings: { apiKey: string; model: string },
) => {
@ -467,7 +479,7 @@ export default function AssignmentFeedbackPage() {
)
try {
const result = await generateOneCategory(
dim, cat, meetings, instAssessments, instEntries, categoryTemplate, settings,
dim, cat, meetings, instAssessments, instEntries, checklistItems, checklistResponses, categoryTemplate, settings,
)
categoryResults.push(result)
setCategoryErrors(prev => { const next = { ...prev }; delete next[errKey]; return next })
@ -503,9 +515,12 @@ export default function AssignmentFeedbackPage() {
getOrSeedDefaultCategoryPromptTemplate(),
])
const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([
const phaseTemplateIds = [...new Set(meetings.map(m => m.phaseTemplateId))]
const [allAssessments, allEntries, checklistItems, checklistResponses] = await Promise.all([
listAssessmentsForMeetings(meetingIds),
listConversationEntriesForMeetings(meetingIds),
listChecklistItemDefsForPhaseTemplates(phaseTemplateIds),
listChecklistResponsesForMeetings(meetingIds),
])
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
@ -514,7 +529,8 @@ export default function AssignmentFeedbackPage() {
const dim = dimsToRun[i]
try {
await generateOneDimension(
dim, i, dimsToRun.length, meetings, instAssessments, instEntries, categoryTemplate, settings,
dim, i, dimsToRun.length, meetings, instAssessments, instEntries,
checklistItems, checklistResponses, categoryTemplate, settings,
)
} catch (e) {
setDimensionErrors(prev => ({

View File

@ -2,14 +2,33 @@ import { useEffect, useState } from 'react'
import {
type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
type ChecklistItemDef,
listAssignmentTypes, listPhaseTemplates, loadFeedbackStructure,
listAssignmentTypes, listPhaseTemplates, loadFeedbackStructure, resolveVisibleStructure,
addAssignmentType, updateAssignmentType, deleteAssignmentType,
addPhaseTemplate, updatePhaseTemplate, deletePhaseTemplate,
listChecklistItemDefsForPhaseTemplate, addChecklistItemDef, updateChecklistItemDef, deleteChecklistItemDef,
getGlobalLineTagCorpus, getOrSeedChecklistTaggingPromptTemplate,
} from '../db'
import { exportAssignmentTypesReportAsMarkdown } from '../utils/structureExport'
import { exportAssignmentTypesReportPdf } from '../utils/pdfExport'
import { criterionOptionGroups } from '../utils/criterionOptions'
import { getAiSettings, callOpenRouter } from '../utils/aiClient'
import { runChecklistTaggingAnalysis } from '../utils/checklistTagging'
import { criterionHoverTitle } from './meeting/CriterionTagPicker'
import type { ChecklistScoreContribution } from '../db/types'
import {
ASSIGNMENT_TYPE_COLOR_PRESETS,
DEFAULT_ASSIGNMENT_TYPE_COLOR,
} from '../config/constants'
import {
getOrSeedRequirePlannedDates,
saveRequirePlannedDates,
} from '../db'
const SCORE_CONTRIBUTION_OPTIONS: { value: ChecklistScoreContribution; label: string }[] = [
{ value: 'equal', label: 'Ø gleich' },
{ value: 'primary', label: 'primär' },
{ value: 'weighted', label: '×2' },
]
export default function AssignmentTypeConfig() {
const [exportingPdf, setExportingPdf] = useState(false)
@ -32,18 +51,28 @@ export default function AssignmentTypeConfig() {
const [newChecklistItem, setNewChecklistItem] = useState<Record<number, {
label: string; scope: 'perInstitutee' | 'meetingWide'; linkedCriterionItemId: string
}>>({})
const [analyzingChecklistPhaseId, setAnalyzingChecklistPhaseId] = useState<number | null>(null)
const [checklistConfigMsg, setChecklistConfigMsg] = useState('')
const [checklistConfigRaw, setChecklistConfigRaw] = useState('')
const [checklistConfigRawOpen, setChecklistConfigRawOpen] = useState(false)
/** checklistItemDef.id → priorisierte Kriterium-IDs aus letzter KI-Analyse dieser Phase. */
const [checklistAiSuggestions, setChecklistAiSuggestions] = useState<Record<number, number[]>>({})
const [requirePlannedDates, setRequirePlannedDates] = useState(false)
const [plannedDatesSaved, setPlannedDatesSaved] = useState(false)
const load = async () => {
const [t, tmpl, structure] = await Promise.all([
const [t, tmpl, structure, requireDates] = await Promise.all([
listAssignmentTypes(),
listPhaseTemplates(),
loadFeedbackStructure(),
getOrSeedRequirePlannedDates(),
])
setTypes(t)
setTemplates(tmpl)
setDimensions(structure.dimensions)
setCategories(structure.categories)
setItems(structure.items)
setRequirePlannedDates(requireDates)
const checklistLists = await Promise.all(tmpl.map(p => listChecklistItemDefsForPhaseTemplate(p.id!)))
setChecklistItems(checklistLists.flat())
}
@ -55,7 +84,9 @@ export default function AssignmentTypeConfig() {
const addType = async () => {
const name = newTypeName.trim()
if (!name) return
const id = await addAssignmentType({ name, defaultPhaseKeys: [], criteriaIds: [] })
const color = ASSIGNMENT_TYPE_COLOR_PRESETS[types.length % ASSIGNMENT_TYPE_COLOR_PRESETS.length]
?? DEFAULT_ASSIGNMENT_TYPE_COLOR
const id = await addAssignmentType({ name, defaultPhaseKeys: [], criteriaIds: [], color })
setNewTypeName('')
await load()
setExpanded(id)
@ -66,6 +97,18 @@ export default function AssignmentTypeConfig() {
setTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t))
}
const updateTypeColor = async (id: number, color: string) => {
await updateAssignmentType(id, { color })
setTypes(prev => prev.map(t => t.id === id ? { ...t, color } : t))
}
const savePlannedDatesSetting = async (required: boolean) => {
setRequirePlannedDates(required)
await saveRequirePlannedDates(required)
setPlannedDatesSaved(true)
setTimeout(() => setPlannedDatesSaved(false), 2000)
}
const deleteType = async (id: number) => {
const result = await deleteAssignmentType(id)
if (!result.ok) {
@ -163,7 +206,12 @@ export default function AssignmentTypeConfig() {
const updateChecklistItem = async (id: number, patch: Partial<ChecklistItemDef>) => {
await updateChecklistItemDef(id, patch)
setChecklistItems(prev => prev.map(c => c.id === id ? { ...c, ...patch } : c))
setChecklistItems(prev => prev.map(c => {
if (c.id !== id) return c
const next = { ...c, ...patch }
if (patch.linkedCriterionItemId === null) delete next.linkedCriterionItemId
return next
}))
}
const deleteChecklistItem = async (id: number) => {
@ -171,6 +219,79 @@ export default function AssignmentTypeConfig() {
setChecklistItems(prev => prev.filter(c => c.id !== id))
}
const suggestChecklistStandardMappings = async (phaseId: number, type: AssignmentType) => {
const settings = getAiSettings()
if (!settings.apiKey) {
setChecklistConfigMsg('Kein KI-API-Key — bitte unter Config → KI-Prompts hinterlegen.')
return
}
const unmapped = checklistItems
.filter(c => c.phaseTemplateId === phaseId && !c.linkedCriterionItemId && c.label.trim())
if (unmapped.length === 0) {
setChecklistConfigMsg('Alle Checklisten-Punkte dieser Phase haben bereits eine Standard-Zuordnung.')
setTimeout(() => setChecklistConfigMsg(''), 4000)
return
}
setAnalyzingChecklistPhaseId(phaseId)
setChecklistConfigMsg('')
setChecklistConfigRaw('')
setChecklistConfigRawOpen(false)
try {
const [corpus, template] = await Promise.all([
getGlobalLineTagCorpus(),
getOrSeedChecklistTaggingPromptTemplate(),
])
const visible = resolveVisibleStructure(dimensions, categories, items, type.criteriaIds)
const { raw, suggestionsByKey, batchErrors } = await runChecklistTaggingAnalysis(
{
assignmentTypeName: type.name,
dimensions: visible.dimensions,
categories: visible.categories,
items: visible.items,
visibleCriteriaIds: type.criteriaIds,
corpus,
template,
targets: unmapped.map(ci => ({ key: String(ci.id), label: ci.label })),
},
prompt => callOpenRouter(prompt, settings),
)
if (batchErrors.length > 0) {
setChecklistConfigMsg(`Teilfehler: ${batchErrors.join(' · ')}`)
}
let applied = 0
const nextSuggestions: Record<number, number[]> = { ...checklistAiSuggestions }
for (const ci of unmapped) {
const ids = suggestionsByKey.get(String(ci.id)) ?? []
if (ids.length > 0) nextSuggestions[ci.id!] = ids
const topId = ids[0]
if (topId) {
await updateChecklistItemDef(ci.id!, { linkedCriterionItemId: topId })
applied++
}
}
setChecklistAiSuggestions(nextSuggestions)
const checklistLists = await Promise.all(templates.map(p => listChecklistItemDefsForPhaseTemplate(p.id!)))
setChecklistItems(checklistLists.flat())
setChecklistConfigMsg(
applied > 0
? `${applied} Standard-Zuordnung(en) gesetzt (${unmapped.length} Punkt(e) analysiert).`
: `${unmapped.length} Punkt(e) analysiert, aber die KI hat keine Zuordnung geliefert — Rohantwort unten einsehbar.`,
)
if (applied === 0) {
setChecklistConfigRaw(raw)
setChecklistConfigRawOpen(true)
}
setTimeout(() => setChecklistConfigMsg(''), 6000)
} catch (e) {
setChecklistConfigMsg(e instanceof Error ? e.message : 'KI-Analyse fehlgeschlagen.')
} finally {
setAnalyzingChecklistPhaseId(null)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
@ -189,6 +310,26 @@ export default function AssignmentTypeConfig() {
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-4 space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-1"
checked={requirePlannedDates}
onChange={e => savePlannedDatesSetting(e.target.checked)}
/>
<span>
<span className="text-sm font-medium text-gray-800 block">Plandaten beim Anlegen Pflicht</span>
<span className="text-xs text-gray-500">
Wenn aktiv, müssen Beginn und Ende eines Assignments beim Anlegen gesetzt werden.
</span>
</span>
</label>
{plannedDatesSaved && (
<div className="text-xs text-green-700">Gespeichert.</div>
)}
</div>
{/* Neuer Typ */}
<div className="flex gap-2">
<input
@ -219,6 +360,11 @@ export default function AssignmentTypeConfig() {
<div key={type.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 bg-gray-50 border-b border-gray-100">
<span
className="w-3 h-8 rounded-sm shrink-0"
style={{ backgroundColor: type.color?.trim() || DEFAULT_ASSIGNMENT_TYPE_COLOR }}
title="Typ-Farbe"
/>
<input
className="flex-1 bg-transparent text-sm font-semibold text-gray-800 focus:outline-none border-b border-transparent focus:border-brand py-0.5"
value={type.name}
@ -231,6 +377,33 @@ export default function AssignmentTypeConfig() {
className="text-xs text-red-300 hover:text-red-500 px-1">🗑</button>
</div>
{isOpen && (
<div className="px-4 pt-3 flex flex-wrap items-center gap-2 border-b border-gray-50 pb-3">
<span className="text-xs text-gray-500 shrink-0">Farbe:</span>
{ASSIGNMENT_TYPE_COLOR_PRESETS.map(c => (
<button
key={c}
type="button"
title={c}
onClick={() => updateTypeColor(type.id!, c)}
className={`w-6 h-6 rounded-md border-2 ${
(type.color?.trim() || DEFAULT_ASSIGNMENT_TYPE_COLOR) === c
? 'border-gray-800 scale-110'
: 'border-transparent'
}`}
style={{ backgroundColor: c }}
/>
))}
<input
type="color"
value={type.color?.trim() || DEFAULT_ASSIGNMENT_TYPE_COLOR}
onChange={e => updateTypeColor(type.id!, e.target.value)}
className="w-8 h-6 p-0 border border-gray-200 rounded cursor-pointer bg-transparent"
title="Eigene Farbe"
/>
</div>
)}
{typeError[type.id!] && (
<div className="px-4 py-2 text-xs text-red-600 bg-red-50 border-b border-red-100">
{typeError[type.id!]}
@ -317,7 +490,8 @@ export default function AssignmentTypeConfig() {
{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">
<div key={ci.id} className="bg-gray-50 rounded-lg px-2 py-1.5 space-y-1">
<div className="flex items-center gap-1.5">
<input
className="flex-1 text-xs bg-transparent border-b border-transparent focus:border-brand focus:outline-none"
value={ci.label}
@ -332,14 +506,58 @@ export default function AssignmentTypeConfig() {
</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]">
onChange={e => updateChecklistItem(ci.id!, {
linkedCriterionItemId: e.target.value ? Number(e.target.value) : null,
})}
className="text-xs border border-gray-200 rounded px-1 py-0.5 bg-white focus:outline-none max-w-[180px]"
title={(() => {
if (!ci.linkedCriterionItemId) return undefined
const crit = items.find(i => i.id === ci.linkedCriterionItemId)
if (!crit) return undefined
return criterionHoverTitle(crit, categories.find(c => c.id === crit.categoryId)?.name ?? '')
})()}>
<option value="">— frei —</option>
{criterionOptionGroups(dimensions, categories, items)}
</select>
<select
value={ci.scoreContribution ?? 'equal'}
onChange={e => updateChecklistItem(ci.id!, {
scoreContribution: e.target.value as ChecklistScoreContribution,
})}
title="Wie Checklisten-Score mit Protokoll auf dasselbe Kriterium zusammenfließt"
className="text-xs border border-gray-200 rounded px-1 py-0.5 bg-white focus:outline-none">
{SCORE_CONTRIBUTION_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<button onClick={() => deleteChecklistItem(ci.id!)}
className="text-red-300 hover:text-red-500 text-xs">×</button>
</div>
{checklistAiSuggestions[ci.id!]?.length ? (
<div className="flex flex-wrap gap-1 pl-0.5">
{checklistAiSuggestions[ci.id!]!.map((critId, idx) => {
const crit = items.find(i => i.id === critId)
if (!crit) return null
const cat = categories.find(c => c.id === crit.categoryId)?.name ?? ''
const active = ci.linkedCriterionItemId === critId
return (
<button
key={critId}
type="button"
title={criterionHoverTitle(crit, cat)}
onClick={() => updateChecklistItem(ci.id!, { linkedCriterionItemId: critId })}
className={`text-[10px] px-1.5 py-0.5 rounded-full border max-w-full text-left whitespace-normal leading-snug ${
active
? 'bg-teal-100 border-teal-300 text-teal-900'
: 'bg-white border-gray-200 text-gray-600 hover:border-teal-200 hover:bg-teal-50'
}`}>
{idx === 0 ? '💡 ' : `${idx + 1}. `}{crit.name}
</button>
)
})}
</div>
) : null}
</div>
))}
<div className="flex items-center gap-1.5">
<input
@ -366,8 +584,35 @@ export default function AssignmentTypeConfig() {
<button onClick={() => addChecklistItem(phase.id!)}
className="text-xs bg-brand text-white px-2 py-1 rounded-lg font-medium">+ Punkt</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => suggestChecklistStandardMappings(phase.id!, type)}
disabled={analyzingChecklistPhaseId === phase.id}
className="text-xs bg-teal-50 hover:bg-teal-100 text-teal-800 border border-teal-200 px-2 py-1 rounded-lg font-medium disabled:opacity-50">
{analyzingChecklistPhaseId === phase.id
? '⏳ KI analysiert…'
: '🏷 Standard-Zuordnung (KI)'}
</button>
{checklistConfigMsg && analyzingChecklistPhaseId === null && (
<span className="text-[10px] text-teal-700">{checklistConfigMsg}</span>
)}
</div>
{checklistConfigRaw && checklistConfigRawOpen && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-2 space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-gray-600">🔍 KI-Rohantwort</span>
<button
type="button"
onClick={() => setChecklistConfigRawOpen(false)}
className="text-[10px] text-gray-400 hover:text-gray-600">
Schließen
</button>
</div>
<pre className="text-[10px] text-gray-700 whitespace-pre-wrap max-h-40 overflow-y-auto">{checklistConfigRaw}</pre>
</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.
Standard-Zuordnung: KI-Button + Alternativen-Chips. Score-Modus: gleichrangig (Ø), primär (Checkliste dominiert), ×2 (höher gewichtet).
</p>
</div>
)}

View File

@ -39,7 +39,7 @@ export default function Configuration() {
return (
<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">Einstellungen</h1>
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5 flex-wrap">
<button onClick={() => setTab('feedback')}

View File

@ -0,0 +1,262 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import {
type ConsultantDetail, type Group,
getConsultantDetail, listGroups, updateConsultant,
archiveConsultant, unarchiveConsultant, hardDeleteConsultant,
} from '../db'
import { formatPlanRange } from '../utils/assignmentSignals'
const FB_LABEL = { none: 'Kein Feedback', draft: 'Entwurf', final: 'Final' } as const
const FB_CLASS = {
none: 'bg-app text-ink-muted',
draft: 'bg-amber-50 text-amber-800',
final: 'bg-green-50 text-green-700',
} as const
export default function ConsultantDetailPage() {
const { id } = useParams()
const consultantId = Number(id)
const navigate = useNavigate()
const [detail, setDetail] = useState<ConsultantDetail | null>(null)
const [groups, setGroups] = useState<Group[]>([])
const [edit, setEdit] = useState({ firstName: '', lastName: '', email: '', groupId: 0 })
const [msg, setMsg] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState(false)
const load = async () => {
const [d, g] = await Promise.all([getConsultantDetail(consultantId), listGroups()])
if (!d) {
setDetail(null)
return
}
setDetail(d)
setGroups(g)
setEdit({
firstName: d.consultant.firstName,
lastName: d.consultant.lastName,
email: d.consultant.email ?? '',
groupId: d.consultant.groupId,
})
}
useEffect(() => { load() }, [consultantId])
const developmentRows = useMemo(() => {
if (!detail) return []
return detail.assignments
.filter(a => !a.deletedAt && a.feedbackStatus === 'final' && a.categoryRatings.length > 0)
}, [detail])
const categoryNames = useMemo(() => {
const names = new Set<string>()
for (const a of developmentRows) {
for (const r of a.categoryRatings) names.add(r.categoryName)
}
return [...names].sort()
}, [developmentRows])
const save = async () => {
if (!edit.firstName.trim() || !edit.lastName.trim() || !edit.email.trim()) {
setMsg('Name und E-Mail sind Pflicht.')
return
}
try {
await updateConsultant(consultantId, {
firstName: edit.firstName.trim(),
lastName: edit.lastName.trim(),
email: edit.email.trim(),
groupId: edit.groupId,
})
setMsg('Gespeichert.')
await load()
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
}
}
const archive = async () => {
await archiveConsultant(consultantId)
setMsg('Archiviert.')
await load()
}
const unarchive = async () => {
await unarchiveConsultant(consultantId)
setMsg('Wieder aktiv.')
await load()
}
const hardDelete = async () => {
const res = await hardDeleteConsultant(consultantId)
navigate('/consultants', {
replace: true,
state: {
flash: `Person gelöscht (${res.feedbacksDeleted} Feedbacks, ${res.assignmentsTouched} Assignments).`
+ (res.emptiedAssignmentIds.length
? ` ${res.emptiedAssignmentIds.length} Assignment(s) ohne Berater.`
: ''),
},
})
}
if (!detail) {
return (
<div className="text-center py-10 text-ink-muted text-sm">
Person nicht gefunden.{' '}
<Link to="/consultants" className="text-brand">Zurück</Link>
</div>
)
}
const { consultant, group, assignments } = detail
const visibleAssignments = assignments.filter(a => !a.deletedAt)
return (
<div className="space-y-4">
<div>
<button type="button" onClick={() => navigate('/consultants')} className="text-sm text-brand mb-2">
← Personen
</button>
<h1 className="text-xl font-bold text-ink">
{consultant.firstName} {consultant.lastName}
</h1>
<p className="text-sm text-ink-muted">
{consultant.email || 'keine E-Mail'} · {group?.name ?? 'ohne Gruppe'}
{consultant.archivedAt ? ' · Archiv' : ''}
</p>
</div>
{msg && (
<div className="text-xs rounded-lg px-3 py-2 border border-brand/20 bg-brand-soft text-brand">{msg}</div>
)}
<section className="bg-surface rounded-xl border border-line p-4 space-y-3">
<h2 className="font-semibold text-ink text-sm">Stammdaten</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input className="border border-line rounded-lg px-3 py-2 text-sm" value={edit.firstName}
onChange={e => setEdit(p => ({ ...p, firstName: e.target.value }))} placeholder="Vorname" />
<input className="border border-line rounded-lg px-3 py-2 text-sm" value={edit.lastName}
onChange={e => setEdit(p => ({ ...p, lastName: e.target.value }))} placeholder="Nachname" />
<input className="border border-line rounded-lg px-3 py-2 text-sm sm:col-span-2" type="email"
value={edit.email} onChange={e => setEdit(p => ({ ...p, email: e.target.value }))} placeholder="E-Mail" />
<select className="border border-line rounded-lg px-3 py-2 text-sm sm:col-span-2"
value={edit.groupId} onChange={e => setEdit(p => ({ ...p, groupId: +e.target.value }))}>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={save} className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium">
Speichern
</button>
{consultant.archivedAt ? (
<button type="button" onClick={unarchive} className="border border-line px-4 py-2 rounded-lg text-sm">
Aus Archiv holen
</button>
) : (
<button type="button" onClick={archive} className="border border-line px-4 py-2 rounded-lg text-sm">
Archivieren
</button>
)}
<button type="button" onClick={() => setConfirmDelete(v => !v)}
className="text-red-600 border border-red-200 px-4 py-2 rounded-lg text-sm">
Endgültig löschen
</button>
</div>
{confirmDelete && (
<div className="bg-red-50 border border-red-200 rounded-lg px-3 py-2 space-y-2">
<p className="text-xs text-red-700">
Feedbacks und personenbezogene Meeting-Daten werden gelöscht. Assignments bleiben ohne diese Person.
Besser zuerst archivieren.
</p>
<div className="flex gap-2">
<button type="button" onClick={hardDelete} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg">
Ja, löschen
</button>
<button type="button" onClick={() => setConfirmDelete(false)}
className="text-xs border border-line px-2.5 py-1 rounded-lg">Abbruch</button>
</div>
</div>
)}
</section>
<section className="bg-surface rounded-xl border border-line p-4 space-y-3">
<h2 className="font-semibold text-ink text-sm">Assignments ({visibleAssignments.length})</h2>
{visibleAssignments.length === 0 ? (
<p className="text-xs text-ink-muted">Noch keinem Assignment zugeordnet.</p>
) : (
<ul className="space-y-2">
{visibleAssignments.map(a => {
const plan = formatPlanRange(a.plannedStart, a.plannedEnd)
return (
<li key={a.assignmentId} className="border border-line rounded-lg p-3 flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<Link to={`/assignment/${a.assignmentId}`} className="font-medium text-ink hover:text-brand">
{a.assignmentNumber ? `${a.assignmentNumber} · ` : ''}{a.title}
</Link>
<div className="text-xs text-ink-muted">
{a.client} · {a.typeName}
{plan ? ` · ${plan}` : ''}
{a.archivedAt ? ' · archiviert' : ''}
</div>
</div>
<span className={`text-[11px] px-2 py-0.5 rounded-md font-medium ${FB_CLASS[a.feedbackStatus]}`}>
{FB_LABEL[a.feedbackStatus]}
</span>
<Link
to={`/assignment/${a.assignmentId}/feedback/${consultantId}`}
className="text-xs text-brand hover:underline"
>
Feedback
</Link>
</li>
)
})}
</ul>
)}
</section>
<section className="bg-surface rounded-xl border border-line p-4 space-y-3">
<h2 className="font-semibold text-ink text-sm">Entwicklung</h2>
<p className="text-xs text-ink-muted">
Kategorie-Bewertungen aus finalen Feedbacks über die Assignments (neueste zuerst).
</p>
{developmentRows.length === 0 ? (
<p className="text-xs text-ink-muted">Noch keine finalen Feedbacks mit Kategorie-Scores.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs text-left border-collapse min-w-[20rem]">
<thead>
<tr className="border-b border-line text-ink-muted">
<th className="py-1.5 pr-2 font-medium">Assignment</th>
{categoryNames.map(n => (
<th key={n} className="py-1.5 px-1 font-medium whitespace-nowrap">{n}</th>
))}
</tr>
</thead>
<tbody>
{developmentRows.map(a => {
const byName = new Map(a.categoryRatings.map(r => [r.categoryName, r.rating]))
return (
<tr key={a.assignmentId} className="border-b border-line/60">
<td className="py-1.5 pr-2">
<Link to={`/assignment/${a.assignmentId}`} className="text-brand hover:underline">
{a.assignmentNumber || a.title}
</Link>
</td>
{categoryNames.map(n => (
<td key={n} className="py-1.5 px-1 tabular-nums text-ink">
{byName.get(n) ?? '—'}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
</div>
)
}

View File

@ -1,137 +1,465 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import {
type Consultant, type Group,
listGroups, listConsultants, addGroup as addGroupQuery, addConsultant as addConsultantQuery,
updateConsultant as updateConsultantQuery, deleteConsultant as deleteConsultantQuery,
type Consultant, type Group, type ConsultantImportResult,
listGroups, listConsultants,
addGroup as addGroupQuery, updateGroup, deleteGroup,
addConsultant as addConsultantQuery, updateConsultant,
archiveConsultant, hardDeleteConsultant,
importConsultantsToGroup,
} from '../db'
import { parseConsultantExcelFile } from '../utils/consultantImport'
type ViewMode = 'active' | 'archived' | 'all'
export default function Consultants() {
const navigate = useNavigate()
const [groups, setGroups] = useState<Group[]>([])
const [consultants, setConsultants] = useState<Consultant[]>([])
const [query, setQuery] = useState('')
const [view, setView] = useState<ViewMode>('active')
const [msg, setMsg] = useState<{ text: string; error: boolean } | null>(null)
const [newGroup, setNewGroup] = useState({ name: '', cohortYear: new Date().getFullYear() })
const [newCons, setNewCons] = useState({ groupId: 0, firstName: '', lastName: '' })
const [editingId, setEditingId] = useState<number | null>(null)
const [editValues, setEditValues] = useState({ firstName: '', lastName: '' })
const [newCons, setNewCons] = useState({
groupId: 0, firstName: '', lastName: '', email: '',
})
const [renamingGroupId, setRenamingGroupId] = useState<number | null>(null)
const [renameValue, setRenameValue] = useState('')
const [confirmDeleteGroupId, setConfirmDeleteGroupId] = useState<number | null>(null)
const [confirmHardDeleteId, setConfirmHardDeleteId] = useState<number | null>(null)
const [importGroupId, setImportGroupId] = useState(0)
const [importMove, setImportMove] = useState(true)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState<ConsultantImportResult | null>(null)
const [newImportGroupName, setNewImportGroupName] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
const load = async () => {
setGroups(await listGroups())
setConsultants(await listConsultants())
const [g, c] = await Promise.all([listGroups(), listConsultants()])
setGroups(g)
setConsultants(c)
if (!importGroupId && g[0]?.id) setImportGroupId(g[0].id)
if (!newCons.groupId && g[0]?.id) setNewCons(p => ({ ...p, groupId: g[0]!.id! }))
}
useEffect(() => { load() }, [])
const flash = (text: string, error = false) => {
setMsg({ text, error })
setTimeout(() => setMsg(null), 5000)
}
const filteredByView = useMemo(() => {
return consultants.filter(c => {
if (view === 'active') return !c.archivedAt
if (view === 'archived') return !!c.archivedAt
return true
})
}, [consultants, view])
const q = query.trim().toLowerCase()
const matchesQuery = (c: Consultant) => {
if (!q) return true
return (
c.firstName.toLowerCase().includes(q) ||
c.lastName.toLowerCase().includes(q) ||
(c.email ?? '').toLowerCase().includes(q)
)
}
const addGroup = async () => {
if (!newGroup.name.trim()) return
await addGroupQuery({ ...newGroup })
const id = await addGroupQuery({ ...newGroup, name: newGroup.name.trim() })
setNewGroup({ name: '', cohortYear: new Date().getFullYear() })
load()
await load()
setImportGroupId(id)
flash('Gruppe angelegt.')
}
const saveRename = async (id: number) => {
if (!renameValue.trim()) return
await updateGroup(id, { name: renameValue.trim() })
setRenamingGroupId(null)
await load()
}
const tryDeleteGroup = async (id: number) => {
const res = await deleteGroup(id)
if (!res.ok) {
flash(res.reason, true)
setConfirmDeleteGroupId(null)
return
}
setConfirmDeleteGroupId(null)
await load()
flash('Gruppe gelöscht.')
}
const addConsultant = async () => {
if (!newCons.firstName.trim() || !newCons.lastName.trim() || !newCons.groupId) return
await addConsultantQuery({ ...newCons })
setNewCons(p => ({ ...p, firstName: '', lastName: '' }))
load()
if (!newCons.firstName.trim() || !newCons.lastName.trim() || !newCons.email.trim() || !newCons.groupId) {
flash('Vorname, Nachname, E-Mail und Gruppe sind Pflicht.', true)
return
}
try {
await addConsultantQuery({
groupId: newCons.groupId,
firstName: newCons.firstName.trim(),
lastName: newCons.lastName.trim(),
email: newCons.email.trim(),
})
setNewCons(p => ({ ...p, firstName: '', lastName: '', email: '' }))
await load()
flash('Person angelegt.')
} catch (e) {
flash(e instanceof Error ? e.message : String(e), true)
}
}
const deleteConsultant = async (id: number) => {
await deleteConsultantQuery(id)
load()
const movePerson = async (id: number, groupId: number) => {
await updateConsultant(id, { groupId })
await load()
}
const startEdit = (c: Consultant) => {
setEditingId(c.id!)
setEditValues({ firstName: c.firstName, lastName: c.lastName })
const archivePerson = async (id: number) => {
await archiveConsultant(id)
await load()
flash('Person archiviert (erste Empfehlung vor dem Löschen).')
}
const cancelEdit = () => setEditingId(null)
const hardDeletePerson = async (id: number) => {
try {
const res = await hardDeleteConsultant(id)
setConfirmHardDeleteId(null)
await load()
const emptyHint = res.emptiedAssignmentIds.length
? ` ${res.emptiedAssignmentIds.length} Assignment(s) ohne Berater — bitte archivieren oder löschen.`
: ''
flash(
`Person gelöscht (${res.feedbacksDeleted} Feedback(s), ${res.assignmentsTouched} Assignment(s) entkoppelt).${emptyHint}`,
)
} catch (e) {
flash(e instanceof Error ? e.message : String(e), true)
}
}
const saveEdit = async () => {
if (!editValues.firstName.trim() || !editValues.lastName.trim() || editingId === null) return
await updateConsultantQuery(editingId, { ...editValues })
setEditingId(null)
load()
const ensureImportGroup = async (): Promise<number | null> => {
if (importGroupId) return importGroupId
if (!newImportGroupName.trim()) {
flash('Bitte Zielgruppe wählen oder neu anlegen.', true)
return null
}
const id = await addGroupQuery({
name: newImportGroupName.trim(),
cohortYear: new Date().getFullYear(),
})
setNewImportGroupName('')
await load()
setImportGroupId(id)
return id
}
const handleImportFile = async (file: File | undefined) => {
if (!file) return
setImporting(true)
setImportResult(null)
try {
const groupId = await ensureImportGroup()
if (!groupId) return
const buf = await file.arrayBuffer()
const rows = parseConsultantExcelFile(buf)
if (rows.length === 0) {
flash('Keine Zeilen in der Excel-Datei gefunden.', true)
return
}
const result = await importConsultantsToGroup(groupId, rows, importMove)
setImportResult(result)
await load()
flash(
`Import: ${result.created} neu, ${result.updated} aktualisiert`
+ (result.moved ? `, ${result.moved} verschoben` : '')
+ (result.skipped ? `, ${result.skipped} übersprungen` : ''),
result.errors.length > 0,
)
} catch (e) {
flash(e instanceof Error ? e.message : String(e), true)
} finally {
setImporting(false)
if (fileRef.current) fileRef.current.value = ''
}
}
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-gray-800">Berater</h1>
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-bold text-ink">Personen</h1>
<div className="flex bg-app rounded-xl p-1 gap-0.5">
{([['active', 'Aktiv'], ['archived', 'Archiv'], ['all', 'Alle']] as const).map(([val, label]) => (
<button
key={val}
type="button"
onClick={() => setView(val)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
view === val ? 'bg-surface text-brand shadow-sm' : 'text-ink-muted'
}`}
>
{label}
</button>
))}
</div>
</div>
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
<h2 className="font-semibold text-gray-700">Neue Gruppe / Kohorte</h2>
{msg && (
<div className={`text-xs rounded-lg px-3 py-2 border ${
msg.error ? 'bg-red-50 border-red-200 text-red-700' : 'bg-brand-soft border-brand/20 text-brand'
}`}>
{msg.text}
</div>
)}
<input
type="search"
placeholder="Suche Name oder E-Mail…"
value={query}
onChange={e => setQuery(e.target.value)}
className="w-full border border-line rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-brand bg-surface"
/>
{/* Excel-Import */}
<section className="bg-surface rounded-xl border border-line p-4 space-y-3">
<h2 className="font-semibold text-ink text-sm">Excel-Import</h2>
<p className="text-xs text-ink-muted">
SharePoint-Liste als Excel. Keine Gruppen-Spalte nötig — Zielgruppe hier wählen oder anlegen.
Abgleich über E-Mail.
</p>
<div className="flex flex-wrap gap-2 items-end">
<div className="space-y-0.5 min-w-[12rem] flex-1">
<label className="text-[11px] text-ink-muted">Zielgruppe</label>
<select
className="w-full border border-line rounded-lg px-2 py-1.5 text-sm bg-surface"
value={importGroupId}
onChange={e => setImportGroupId(+e.target.value)}
>
<option value={0}>— wählen oder neu —</option>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
{importGroupId === 0 && (
<div className="space-y-0.5 min-w-[10rem] flex-1">
<label className="text-[11px] text-ink-muted">Neue Gruppe</label>
<input
className="w-full border border-line rounded-lg px-2 py-1.5 text-sm"
placeholder="Gruppenname"
value={newImportGroupName}
onChange={e => setNewImportGroupName(e.target.value)}
/>
</div>
)}
<label className="flex items-center gap-2 text-xs text-ink-muted py-1.5">
<input type="checkbox" checked={importMove} onChange={e => setImportMove(e.target.checked)} />
Bei Treffer in andere Gruppe verschieben
</label>
<input
ref={fileRef}
type="file"
accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
className="hidden"
onChange={e => handleImportFile(e.target.files?.[0])}
/>
<button
type="button"
disabled={importing}
onClick={() => fileRef.current?.click()}
className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{importing ? 'Import…' : 'Excel wählen'}
</button>
</div>
{importResult && importResult.errors.length > 0 && (
<ul className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 space-y-0.5 max-h-32 overflow-auto">
{importResult.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
)}
</section>
{/* Neue Gruppe */}
<section className="bg-surface rounded-xl border border-line p-4 space-y-2">
<h2 className="font-semibold text-ink text-sm">Neue Gruppe</h2>
<div className="flex gap-2">
<input
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm"
className="flex-1 border border-line rounded-lg px-3 py-2 text-sm"
placeholder="Gruppenname (z.B. MC Kohorte 2025)"
value={newGroup.name}
onChange={e => setNewGroup(p => ({ ...p, name: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && addGroup()}
/>
<button onClick={addGroup} className="bg-brand text-white px-4 py-2 rounded-lg text-sm">+</button>
<input
type="number"
className="w-24 border border-line rounded-lg px-2 py-2 text-sm"
value={newGroup.cohortYear}
onChange={e => setNewGroup(p => ({ ...p, cohortYear: +e.target.value }))}
title="Kohortejahr"
/>
<button type="button" onClick={addGroup} className="bg-brand text-white px-4 py-2 rounded-lg text-sm">+</button>
</div>
</section>
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
<h2 className="font-semibold text-gray-700">Neuer Berater</h2>
{/* Neue Person */}
<section className="bg-surface rounded-xl border border-line p-4 space-y-3">
<h2 className="font-semibold text-ink text-sm">Neue Person</h2>
<select
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
className="w-full border border-line rounded-lg px-3 py-2 text-sm"
value={newCons.groupId}
onChange={e => setNewCons(p => ({ ...p, groupId: +e.target.value }))}
>
<option value={0}>Gruppe wählen…</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
<div className="flex gap-2">
<input className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm" placeholder="Vorname"
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input className="border border-line rounded-lg px-3 py-2 text-sm" placeholder="Vorname"
value={newCons.firstName} onChange={e => setNewCons(p => ({ ...p, firstName: e.target.value }))} />
<input className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm" placeholder="Nachname"
<input className="border border-line rounded-lg px-3 py-2 text-sm" placeholder="Nachname"
value={newCons.lastName} onChange={e => setNewCons(p => ({ ...p, lastName: e.target.value }))} />
<input className="border border-line rounded-lg px-3 py-2 text-sm" placeholder="E-Mail *" type="email"
value={newCons.email} onChange={e => setNewCons(p => ({ ...p, email: e.target.value }))} />
</div>
<button onClick={addConsultant} className="w-full bg-brand text-white py-2 rounded-lg text-sm font-medium">Berater hinzufügen</button>
<button type="button" onClick={addConsultant}
className="w-full bg-brand text-white py-2 rounded-lg text-sm font-medium">
Person hinzufügen
</button>
</section>
{/* Gruppen + Mitglieder */}
{groups.map(g => {
const members = consultants.filter(c => c.groupId === g.id)
if (members.length === 0) return null
const members = filteredByView.filter(c => c.groupId === g.id && matchesQuery(c))
const allInGroup = consultants.filter(c => c.groupId === g.id)
if (view !== 'all' && members.length === 0 && q) return null
if (view === 'archived' && members.length === 0) return null
return (
<div key={g.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<h3 className="font-semibold text-gray-700 mb-2">{g.name}</h3>
{members.map(c => (
<div key={c.id} className="flex items-center justify-between py-1 text-sm text-gray-700 border-b border-gray-50 last:border-0 gap-2">
{editingId === c.id ? (
<div key={g.id} className="bg-surface rounded-xl border border-line overflow-hidden">
<div className="flex flex-wrap items-center gap-2 px-4 py-3 bg-app border-b border-line">
{renamingGroupId === g.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()}
className="flex-1 border border-line rounded-lg px-2 py-1 text-sm"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveRename(g.id!)}
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>
<button type="button" onClick={() => saveRename(g.id!)} className="text-xs text-brand font-medium">OK</button>
<button type="button" onClick={() => setRenamingGroupId(null)} className="text-xs text-ink-muted">Abbruch</button>
</>
) : (
<>
<span>{c.firstName} {c.lastName}</span>
<div className="flex gap-2">
<button onClick={() => startEdit(c)} className="text-gray-400 text-xs hover:text-brand">Bearbeiten</button>
<button onClick={() => deleteConsultant(c.id!)} className="text-red-400 text-xs hover:text-red-600">×</button>
</div>
<h3 className="font-semibold text-ink flex-1 min-w-0 truncate">{g.name}</h3>
<span className="text-[11px] text-ink-muted">{allInGroup.length} Pers. · {g.cohortYear}</span>
<button
type="button"
className="text-xs text-ink-muted hover:text-brand"
onClick={() => { setRenamingGroupId(g.id!); setRenameValue(g.name) }}
>
Umbenennen
</button>
<button
type="button"
className="text-xs text-red-500 hover:text-red-700"
onClick={() => setConfirmDeleteGroupId(confirmDeleteGroupId === g.id ? null : g.id!)}
>
Löschen
</button>
</>
)}
</div>
{confirmDeleteGroupId === g.id && (
<div className="px-4 py-2 bg-red-50 border-b border-red-100 flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-red-700">
{allInGroup.length === 0
? 'Leere Gruppe endgültig löschen?'
: `Gruppe hat ${allInGroup.length} Person(en) — Löschen nur möglich wenn leer. Mitglieder zuerst umziehen.`}
</span>
<div className="flex gap-1.5">
{allInGroup.length === 0 && (
<button type="button" onClick={() => tryDeleteGroup(g.id!)}
className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg">Ja</button>
)}
<button type="button" onClick={() => setConfirmDeleteGroupId(null)}
className="text-xs border border-line px-2.5 py-1 rounded-lg">Schließen</button>
</div>
</div>
)}
{members.length === 0 ? (
<p className="px-4 py-3 text-xs text-ink-muted">Keine Personen in dieser Ansicht.</p>
) : (
<ul>
{members.map(c => (
<li key={c.id} className="px-4 py-2.5 border-b border-line last:border-0 flex flex-wrap items-center gap-2 text-sm">
<button
type="button"
className="text-left min-w-0 flex-1 hover:text-brand"
onClick={() => navigate(`/consultants/${c.id}`)}
>
<span className="font-medium text-ink">{c.firstName} {c.lastName}</span>
<span className="block text-xs text-ink-muted truncate">{c.email || 'keine E-Mail'}</span>
{c.archivedAt && (
<span className="inline-block mt-0.5 text-[10px] px-1.5 py-0.5 rounded bg-app text-ink-muted">Archiv</span>
)}
</button>
<select
className="text-xs border border-line rounded-lg px-1.5 py-1 max-w-[9rem]"
value={c.groupId}
onChange={e => movePerson(c.id!, +e.target.value)}
title="Gruppe wechseln"
>
{groups.map(og => (
<option key={og.id} value={og.id}>{og.name}</option>
))}
</select>
<Link to={`/consultants/${c.id}`} className="text-xs text-brand hover:underline">Öffnen</Link>
{!c.archivedAt && (
<button type="button" onClick={() => archivePerson(c.id!)}
className="text-xs text-ink-muted hover:text-brand">Archivieren</button>
)}
<button
type="button"
onClick={() => setConfirmHardDeleteId(confirmHardDeleteId === c.id ? null : c.id!)}
className="text-xs text-red-400 hover:text-red-600"
>
Löschen
</button>
{confirmHardDeleteId === c.id && (
<div className="w-full bg-red-50 border border-red-200 rounded-lg px-3 py-2 space-y-1.5">
<p className="text-xs text-red-700">
Endgültig löschen? Feedbacks und personenbezogene Meeting-Daten werden entfernt.
Assignments bleiben (Person wird nur ausgehängt). Besser zuerst archivieren.
</p>
<div className="flex gap-1.5">
<button type="button" onClick={() => hardDeletePerson(c.id!)}
className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg">Endgültig löschen</button>
<button type="button" onClick={() => setConfirmHardDeleteId(null)}
className="text-xs border border-line px-2.5 py-1 rounded-lg">Abbruch</button>
</div>
</div>
)}
</li>
))}
</ul>
)}
</div>
)
})}
{groups.length === 0 && (
<p className="text-center text-ink-muted text-sm py-8">Noch keine Gruppen — zuerst anlegen oder per Excel-Import erstellen.</p>
)}
</div>
)
}

View File

@ -1,18 +1,93 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { listAssignmentsWithDetails, softDeleteAssignment, archiveAssignment, type AssignmentWithDetails } from '../db'
import {
listAssignmentsWithDetails,
softDeleteAssignment,
archiveAssignment,
listAssignmentTypes,
type AssignmentWithDetails,
type AssignmentType,
} from '../db'
import { importAssignmentTransferFile } from '../utils/assignmentTransfer'
import {
assignmentSignals,
formatPlanRange,
plannedRangeOverlaps,
type AssignmentSignal,
} from '../utils/assignmentSignals'
import { DEFAULT_ASSIGNMENT_TYPE_COLOR } from '../config/constants'
type QuickFilter = 'all' | 'overdue' | 'ending_soon' | 'no_plan'
const SIGNAL_LABEL: Record<AssignmentSignal, string> = {
plan_overdue: 'Plan überfällig',
feedback_overdue: 'Feedback fehlt',
ending_soon: 'Endet bald',
}
const SIGNAL_CLASS: Record<AssignmentSignal, string> = {
plan_overdue: 'bg-red-50 text-red-700 border-red-200',
feedback_overdue: 'bg-amber-50 text-amber-800 border-amber-200',
ending_soon: 'bg-orange-50 text-orange-700 border-orange-200',
}
export default function Dashboard() {
const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
const [types, setTypes] = useState<AssignmentType[]>([])
const [query, setQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'done'>('active')
const [hiddenTypeIds, setHiddenTypeIds] = useState<Set<number>>(() => new Set())
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [quickFilter, setQuickFilter] = useState<QuickFilter>('all')
const [confirmTrashId, setConfirmTrashId] = useState<number | null>(null)
const [confirmArchiveId, setConfirmArchiveId] = useState<number | null>(null)
const [importing, setImporting] = useState(false)
const [importMsg, setImportMsg] = useState<{ text: string; error: boolean } | null>(null)
const importInputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
const load = () => { listAssignmentsWithDetails().then(setAssignments) }
const load = () => {
Promise.all([listAssignmentsWithDetails(), listAssignmentTypes()]).then(([a, t]) => {
setAssignments(a)
setTypes(t)
})
}
useEffect(() => { load() }, [])
const handleImportTransfer = async (file: File | undefined) => {
if (!file) return
setImporting(true)
setImportMsg(null)
try {
const res = await importAssignmentTransferFile(file)
if (!res.ok) {
setImportMsg({ text: res.error, error: true })
return
}
const { result } = res
const matched = result.consultantsMatched.length
? `Berater vorhanden: ${result.consultantsMatched.join(', ')}`
: null
const created = result.consultantsCreated.length
? `Berater neu: ${result.consultantsCreated.join(', ')}`
: null
setImportMsg({
text: [
`Assignment importiert (${result.meetings} Meetings, ${result.checklistResponses} Checklisten-Antworten).`,
matched,
created,
].filter(Boolean).join(' '),
error: false,
})
load()
navigate(`/assignment/${result.assignmentId}`)
} finally {
setImporting(false)
if (importInputRef.current) importInputRef.current.value = ''
}
}
const trashAssignment = async (id: number) => {
await softDeleteAssignment(id)
setConfirmTrashId(null)
@ -25,145 +100,312 @@ export default function Dashboard() {
load()
}
const toggleTypeHidden = (typeId: number) => {
setHiddenTypeIds(prev => {
const next = new Set(prev)
if (next.has(typeId)) next.delete(typeId)
else next.add(typeId)
return next
})
}
const trashCount = useMemo(() => assignments.filter(a => a.deletedAt).length, [assignments])
const archiveCount = useMemo(() => assignments.filter(a => a.archivedAt).length, [assignments])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
const dateFilterActive = !!(dateFrom || dateTo)
return assignments.filter(a => {
if (a.deletedAt || a.archivedAt) return false
if (statusFilter !== 'all' && a.status !== statusFilter) return false
if (hiddenTypeIds.has(a.assignmentTypeId)) return false
if (dateFilterActive) {
if (!a.plannedStart && !a.plannedEnd) return false
if (!plannedRangeOverlaps(a.plannedStart, a.plannedEnd, dateFrom, dateTo)) return false
}
const signals = assignmentSignals(a)
if (quickFilter === 'overdue') {
if (!signals.includes('plan_overdue') && !signals.includes('feedback_overdue')) return false
} else if (quickFilter === 'ending_soon') {
if (!signals.includes('ending_soon')) return false
} else if (quickFilter === 'no_plan') {
if (a.plannedStart || a.plannedEnd) return false
}
if (!q) return true
return (
a.title.toLowerCase().includes(q) ||
(a.assignmentNumber ?? '').toLowerCase().includes(q) ||
a.client.toLowerCase().includes(q) ||
a.typeName.toLowerCase().includes(q) ||
a.instituteeNames.some(n => n.toLowerCase().includes(q))
)
})
}, [assignments, query, statusFilter])
}, [assignments, query, statusFilter, hiddenTypeIds, dateFrom, dateTo, quickFilter])
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800">Assignments</h1>
<div className="flex items-center gap-2">
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-bold text-ink">Assignments</h1>
<div className="flex flex-wrap 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"
className="text-xs px-2.5 py-1.5 border border-line rounded-[var(--radius-control)] text-ink-muted hover:border-brand hover:text-brand transition"
>
🗑 Papierkorb{trashCount > 0 && ` (${trashCount})`}
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"
className="text-xs px-2.5 py-1.5 border border-line rounded-[var(--radius-control)] text-ink-muted hover:border-brand hover:text-brand transition"
>
🗄 Archiv{archiveCount > 0 && ` (${archiveCount})`}
Archiv{archiveCount > 0 && ` (${archiveCount})`}
</button>
<input
ref={importInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={e => handleImportTransfer(e.target.files?.[0])}
/>
<button
onClick={() => importInputRef.current?.click()}
disabled={importing}
title="Assignment-Übergabe importieren (Lead → Lead)"
className="text-xs px-2.5 py-1.5 border border-brand/30 rounded-[var(--radius-control)] text-brand hover:bg-brand-soft transition disabled:opacity-50"
>
{importing ? 'Import…' : 'Import'}
</button>
<button
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-[var(--radius-control)] text-sm font-medium"
>
+ Neues
</button>
</div>
</div>
{/* Suche + Status-Filter */}
{importMsg && (
<div className={`text-xs rounded-[var(--radius-control)] px-3 py-2 border ${
importMsg.error
? 'bg-red-50 border-red-200 text-red-700'
: 'bg-brand-soft border-brand/20 text-brand'
}`}>
{importMsg.text}
</div>
)}
<div className="space-y-2">
<input
type="search"
placeholder="Suche nach Nummer, Titel, Kunde, Berater…"
placeholder="Suche nach Nummer, Titel, Kunde, Typ, Berater…"
value={query}
onChange={e => setQuery(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-brand bg-white"
className="w-full border border-line rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-brand bg-surface"
/>
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5">
<div className="flex bg-app rounded-xl p-1 gap-0.5">
{([['all', 'Alle'], ['active', 'Aktiv'], ['done', 'Abgeschlossen']] as const).map(([val, label]) => (
<button key={val} onClick={() => setStatusFilter(val)}
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition ${
statusFilter === val ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
statusFilter === val ? 'bg-surface text-brand shadow-sm' : 'text-ink-muted'
}`}>
{label}
</button>
))}
</div>
{types.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center">
<span className="text-[11px] text-ink-muted shrink-0">Typen:</span>
{types.map(t => {
const hidden = hiddenTypeIds.has(t.id!)
const color = t.color?.trim() || DEFAULT_ASSIGNMENT_TYPE_COLOR
return (
<button
key={t.id}
type="button"
onClick={() => toggleTypeHidden(t.id!)}
title={hidden ? 'Einblenden' : 'Ausblenden'}
className={`inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-lg border transition ${
hidden
? 'opacity-40 border-line text-ink-muted line-through'
: 'border-line bg-surface text-ink'
}`}
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
{t.name}
</button>
)
})}
</div>
)}
<div className="flex flex-wrap gap-2 items-end">
<div className="space-y-0.5">
<label className="text-[11px] text-ink-muted">Zeitraum von</label>
<input
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="border border-line rounded-lg px-2 py-1.5 text-xs bg-surface"
/>
</div>
<div className="space-y-0.5">
<label className="text-[11px] text-ink-muted">bis</label>
<input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="border border-line rounded-lg px-2 py-1.5 text-xs bg-surface"
/>
</div>
{(dateFrom || dateTo) && (
<button
type="button"
onClick={() => { setDateFrom(''); setDateTo('') }}
className="text-xs text-ink-muted underline py-1.5"
>
Zeitraum zurücksetzen
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{([
['all', 'Alle'],
['overdue', 'Überfällig'],
['ending_soon', 'Endet bald'],
['no_plan', 'Ohne Plandaten'],
] as const).map(([val, label]) => (
<button
key={val}
type="button"
onClick={() => setQuickFilter(val)}
className={`text-xs px-2.5 py-1 rounded-lg border transition ${
quickFilter === val
? 'border-brand bg-brand-soft text-brand font-medium'
: 'border-line text-ink-muted hover:border-brand/40'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Liste */}
{filtered.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">
{assignments.length === 0 ? 'Noch keine Assignments angelegt.' : 'Keine Treffer.'}
<p className="text-ink-muted text-sm text-center py-8">
{assignments.filter(a => !a.deletedAt && !a.archivedAt).length === 0
? 'Noch keine Assignments angelegt.'
: 'Keine Treffer.'}
</p>
)}
{filtered.map(a => (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
{filtered.map(a => {
const color = a.typeColor?.trim() || DEFAULT_ASSIGNMENT_TYPE_COLOR
const plan = formatPlanRange(a.plannedStart, a.plannedEnd)
const signals = assignmentSignals(a)
return (
<div
key={a.id}
onClick={() => navigate(`/assignment/${a.id}`)}
className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 cursor-pointer hover:shadow-md transition"
className="bg-surface rounded-xl border border-line cursor-pointer hover:shadow-md transition overflow-hidden flex flex-col min-h-[8.5rem]"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="h-1.5 shrink-0" style={{ backgroundColor: color }} />
<div className="p-4 flex-1 flex flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{a.assignmentNumber && (
<div className="text-xs font-semibold text-brand uppercase tracking-wide leading-none mb-0.5">
<div className="text-xs font-semibold text-brand uppercase tracking-wide leading-none mb-0.5 truncate">
{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 className="font-semibold text-ink truncate">{a.title}</div>
<div className="text-sm text-ink-muted truncate">{a.client}</div>
</div>
</div>
<span className={`flex-shrink-0 text-xs px-2 py-1 rounded-full font-medium ${
a.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
<span className={`shrink-0 text-[11px] px-2 py-0.5 rounded-md font-medium ${
a.status === 'active' ? 'bg-green-50 text-green-700' : 'bg-app text-ink-muted'
}`}>
{a.status === 'active' ? 'Aktiv' : 'Fertig'}
</span>
</div>
<div className="flex items-center gap-1.5 min-w-0">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-xs font-medium text-ink truncate">{a.typeName}</span>
</div>
<div className="text-xs text-ink-muted truncate">
{a.instituteeNames.join(', ') || '—'}
</div>
{plan && (
<div className="text-xs text-ink-muted">{plan}</div>
)}
{signals.length > 0 && (
<div className="flex flex-wrap gap-1 mt-auto pt-1">
{signals.map(s => (
<span
key={s}
className={`text-[10px] px-1.5 py-0.5 rounded border font-medium ${SIGNAL_CLASS[s]}`}
>
{SIGNAL_LABEL[s]}
</span>
))}
</div>
)}
{a.status === 'done' && (
<div className="mt-2 pt-2 border-t border-gray-100 flex gap-2" onClick={e => e.stopPropagation()}>
<div className="pt-2 border-t border-line 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'
a.feedbackComplete
? 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100'
: 'bg-app text-ink-muted cursor-not-allowed'
}`}>
🗑 Papierkorb
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
className="text-xs px-2.5 py-1 rounded-lg font-medium bg-app text-ink-muted border border-line hover:bg-surface">
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="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?</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>
<button onClick={() => setConfirmTrashId(null)} className="text-xs border border-line text-ink-muted 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="bg-app border border-line rounded-lg px-3 py-2.5 flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
<span className="text-xs text-ink-muted">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>
<button onClick={() => archiveThisAssignment(a.id!)} className="text-xs bg-ink text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmArchiveId(null)} className="text-xs border border-line text-ink-muted px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
</div>
))}
</div>
)
})}
</div>
</div>
)
}

View File

@ -128,7 +128,7 @@ export default function Evaluation() {
return (
<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">Feedbacks</h1>
{data.length === 0 && (
<p className="text-gray-500 text-sm">Noch keine Bewertungen vorhanden.</p>
)}

View File

@ -20,6 +20,7 @@ import { INST_COLORS, resolveScoreStyle } from '../config/constants'
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
import { useRatingColorBands } from '../hooks/useRatingColorBands'
import { exportAssignmentReportPdf } from '../utils/pdfExport'
import { getEffectiveChecklistCriterionId } from '../utils/checklistCriterion'
import type { MeetingCategoryRating } from '../db'
export default function MeetingHistory() {
@ -210,7 +211,7 @@ export default function MeetingHistory() {
return targets.map(instituteeId => {
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
if (!response) return null
const effectiveCritId = response.criterionItemId ?? ci.linkedCriterionItemId
const effectiveCritId = getEffectiveChecklistCriterionId(ci, response)
const critName = effectiveCritId ? fbItems.find(i => i.id === effectiveCritId)?.name : null
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
return (

View File

@ -2,13 +2,14 @@ import { useState } from 'react'
import type {
Consultant, FeedbackCategory, FeedbackCriterionItem,
Assessment, ConversationEntry, ConversationLineTag, RatingScore, RatingColorBand,
ChecklistItemDef, ChecklistResponse,
} from '../../db'
import {
INST_COLORS, DEFAULT_RATING_COLOR_BANDS, isValidRatingScore, resolveScoreStyle,
INST_COLORS, DEFAULT_RATING_COLOR_BANDS, isValidRatingScore, normalizeRatingScore, resolveScoreStyle,
} from '../../config/constants'
import { ScorePicker } from '../../components/ScorePicker'
import { detectNotationRating } from '../../utils/notationParser'
import { numberToRating, weightedAverageRating } from '../../utils/ratingTrend'
import { weightedAverageRating } from '../../utils/ratingTrend'
import { suggestCriterionScoreForInstitutee } from '../../utils/criterionScoreSuggestion'
interface Props {
institutees: Consultant[]
@ -18,13 +19,22 @@ interface Props {
entries: ConversationEntry[]
/** Kriterium-Zuordnungen aus den allgemeinen Notizen — gelten für ALLE Institutees, nicht gefiltert. */
generalNoteTags: ConversationLineTag[]
checklist: ChecklistItemDef[]
checklistResponses: ChecklistResponse[]
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
onSetAssessmentScore: (
instituteeId: number,
itemId: number,
score: RatingScore | null,
opts?: { needsReview?: boolean; onlyIfUnset?: boolean },
) => void
onSaveAssessmentNote: (instituteeId: number, itemId: number, note: string) => void
onAutoCalc: (instituteeId: number) => void
/** Nur noch leere Kriterien — bereits gesetzte Scores werden nie überschrieben. */
onAcceptAllSuggestions: (instituteeId: number) => void
colorBands?: RatingColorBand[]
}
@ -39,42 +49,46 @@ function catMode(
function suggestedRating(
entries: ConversationEntry[],
generalNoteTags: ConversationLineTag[],
checklist: ChecklistItemDef[],
checklistResponses: ChecklistResponse[],
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)
return suggestCriterionScoreForInstitutee(
entries, generalNoteTags, checklist, checklistResponses, instituteeId, itemId,
)
}
export function AssessmentTab({
institutees, fbCategories, fbItems, assessments, entries, generalNoteTags, hasConv,
institutees, fbCategories, fbItems, assessments, entries, generalNoteTags,
checklist, checklistResponses, hasConv,
tagLabel, fillerTotal,
getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc,
onAcceptAllSuggestions,
colorBands = DEFAULT_RATING_COLOR_BANDS,
}: Props) {
const [expandedCat, setExpandedCat] = useState<Record<number, number | null>>({})
const [acceptingId, setAcceptingId] = useState<number | null>(null)
return (
<div className="space-y-4">
{institutees.map((inst, idx) => {
const color = INST_COLORS[idx % INST_COLORS.length]
const filler = fillerTotal(inst.id!)
const unsetSuggestionCount = fbItems.filter(item => {
if (!item.id) return false
if (isValidRatingScore(getAssessmentScore(inst.id!, item.id))) return false
return suggestedRating(
entries, generalNoteTags, checklist, checklistResponses, inst.id!, item.id,
) !== null
}).length
const reviewCount = assessments.filter(
a => a.instituteeId === inst.id && a.scoreNeedsReview && isValidRatingScore(a.score),
).length
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 justify-between gap-2 flex-wrap">
<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)}
@ -85,7 +99,36 @@ export function AssessmentTab({
äh×{filler}
</span>
)}
{reviewCount > 0 && (
<span
title="Automatisch übernommene Scores — bitte gegenlesen"
className="text-xs bg-amber-50 text-amber-800 border border-amber-200 px-2 py-0.5 rounded font-medium"
>
Review {reviewCount}
</span>
)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{unsetSuggestionCount > 0 && (
<button
type="button"
disabled={acceptingId === inst.id}
onClick={async () => {
setAcceptingId(inst.id!)
try {
await onAcceptAllSuggestions(inst.id!)
} finally {
setAcceptingId(null)
}
}}
className="text-xs bg-blue-600 text-white border border-blue-700 px-3 py-1 rounded-lg font-medium disabled:opacity-50"
title="Nur noch leere Kriterien — mit Review-Hinweis"
>
{acceptingId === inst.id
? 'Übernehme…'
: `Alle Vorschläge übernehmen (${unsetSuggestionCount})`}
</button>
)}
{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">
@ -93,6 +136,7 @@ export function AssessmentTab({
</button>
)}
</div>
</div>
{fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
@ -103,25 +147,45 @@ export function AssessmentTab({
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!)
const suggestion = suggestedRating(
entries, generalNoteTags, checklist, checklistResponses, inst.id!, item.id!,
)
return suggestion !== null && suggestion !== current
}).length
const reviewInCat = catItems.filter(item => {
const row = assessments.find(a => a.instituteeId === inst.id && a.criteriaId === item.id)
return !!row?.scoreNeedsReview && isValidRatingScore(normalizeRatingScore(row.score))
}).length
return (
<div key={cat.id} className="rounded-xl overflow-hidden border border-gray-100">
<div key={cat.id} className={`rounded-xl overflow-hidden border ${
reviewInCat > 0 ? 'border-amber-300' : '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'
hasScore ? headerRo.active : reviewInCat > 0 ? 'bg-amber-50 hover:bg-amber-100/80' : '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">
{reviewInCat > 0 && (
<span
title="Automatisch übernommene Scores in dieser Kategorie — bitte gegenlesen"
className={`text-xs px-1.5 py-0.5 rounded-full font-semibold ${
hasScore
? 'bg-amber-200/90 text-amber-950'
: 'bg-amber-100 text-amber-900 border border-amber-300'
}`}
>
Review {reviewInCat}
</span>
)}
{suggestionCount > 0 && (
<span title="Vorschläge aus dem Protokoll verfügbar"
className={`text-xs px-1.5 py-0.5 rounded-full font-semibold ${
@ -143,12 +207,26 @@ export function AssessmentTab({
<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 ?? ''
const suggestion = suggestedRating(
entries, generalNoteTags, checklist, checklistResponses, inst.id!, item.id!,
)
const row = assessments.find(a => a.instituteeId === inst.id && a.criteriaId === item.id)
const note = row?.note ?? ''
const needsReview = !!row?.scoreNeedsReview && isValidRatingScore(current)
return (
<div key={item.id} className="px-3 py-2.5 space-y-1.5">
<div key={item.id} className={`px-3 py-2.5 space-y-1.5 ${needsReview ? 'bg-amber-50/60' : ''}`}>
<div className="flex items-baseline justify-between gap-2">
<div className="text-xs text-gray-700 font-medium">{item.name}</div>
<div className="text-xs text-gray-700 font-medium flex items-center gap-1.5 flex-wrap">
{item.name}
{needsReview && (
<span
title="Automatisch übernommen — bitte gegenlesen oder Score anpassen"
className="text-[10px] font-semibold uppercase tracking-wide bg-amber-100 text-amber-900 border border-amber-300 px-1.5 py-0.5 rounded"
>
Review
</span>
)}
</div>
{(item.weight ?? 1) !== 1 && (
<span className="text-xs text-gray-400 flex-shrink-0">×{item.weight}</span>
)}
@ -162,13 +240,24 @@ export function AssessmentTab({
bands={colorBands}
allowClear
/>
{needsReview && (
<button
type="button"
onClick={() => onSetAssessmentScore(inst.id!, item.id!, current, { needsReview: false })}
className="text-xs bg-amber-100 text-amber-900 border border-amber-300 px-2 py-0.5 rounded-lg font-medium hover:bg-amber-200"
>
Als geprüft markieren
</button>
)}
{suggestion !== null && suggestion !== current && (
<div className="flex items-center gap-2">
<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-0.5 rounded-lg font-medium tabular-nums">
Vorschlag aus Protokoll: {suggestion}
💡 Vorschlag (Protokoll + Checkliste): {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">
<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>

View File

@ -43,7 +43,8 @@ function ChecklistLine({
return (
<div>
<div className="flex items-center gap-1.5 py-1">
<span className="flex-1 truncate text-xs text-gray-700">{label}</span>
<span className="flex-1 min-w-0 truncate text-xs text-gray-700">{label}</span>
<div className="flex-shrink-0 w-[10.5rem] flex justify-end">
<CriterionTagPicker
items={items}
categories={categories}
@ -51,6 +52,7 @@ function ChecklistLine({
onChange={onSetCriterion}
suggestions={suggestions}
/>
</div>
<ScorePicker
value={rating}
onChange={onSelectRating}
@ -80,9 +82,7 @@ function ChecklistLine({
* 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`.
* Config-Standard (`linkedCriterionItemId`) erscheint als feste Zuordnung; Override per Antwort möglich.
*/
export function ChecklistTab({
institutees, checklist, fbItems, fbCategories, tagLabel,

View File

@ -7,16 +7,27 @@ export interface CriterionSuggestion {
rank: number
}
export function criterionHoverTitle(
item: FeedbackCriterionItem,
categoryName: string,
): string {
const parts = [item.name]
if (categoryName) parts.push(`Kategorie: ${categoryName}`)
if (item.description?.trim()) parts.push(item.description.trim())
return parts.join('\n\n')
}
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[]
/** Protokoll: kurzer Chip + Hover — Popover/Suche zeigen weiterhin vollen Text. */
compact?: boolean
}
export function CriterionTagPicker({ items, categories, value, onChange, suggestions }: Props) {
export function CriterionTagPicker({ items, categories, value, onChange, suggestions, compact = true }: Props) {
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
@ -26,7 +37,9 @@ export function CriterionTagPicker({ items, categories, value, onChange, suggest
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)
return i.name.toLowerCase().includes(q)
|| categoryName(i.categoryId).toLowerCase().includes(q)
|| (i.description?.toLowerCase().includes(q) ?? false)
})
const select = (itemId: number) => {
@ -38,45 +51,63 @@ export function CriterionTagPicker({ items, categories, value, onChange, suggest
const sortedSuggestions = suggestions ? [...suggestions].sort((a, b) => a.rank - b.rank) : []
const topItem = sortedSuggestions[0] ? items.find(i => i.id === sortedSuggestions[0].criterionItemId) : undefined
const shellClass = compact ? 'relative w-[10.5rem] flex-shrink-0' : 'relative inline-block max-w-[min(100%,16rem)]'
const chipShell = compact ? 'rounded-full' : 'rounded-lg'
const chipLabelClass = compact
? 'text-xs font-medium truncate max-w-[8.5rem]'
: 'text-xs font-medium text-left max-w-[min(100%,16rem)] whitespace-normal leading-snug'
return (
<div className="relative inline-block">
<div className={shellClass}>
{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">
<div className={`inline-flex items-center gap-0.5 bg-indigo-50 text-indigo-700 border border-indigo-200 ${chipShell} pl-2 pr-1 py-0.5 max-w-full`}>
<button
onClick={() => setOpen(p => !p)}
title={criterionHoverTitle(current, categoryName(current.categoryId))}
className={chipLabelClass}>
🏷 {current.name}
</button>
<button onClick={() => onChange(null)} className="text-indigo-400 hover:text-indigo-700 text-xs leading-none px-1">✕</button>
<button onClick={() => onChange(null)} className="text-indigo-400 hover:text-indigo-700 text-xs leading-none px-0.5 flex-shrink-0">✕</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">
<div className={`inline-flex items-center gap-0.5 bg-blue-50 text-blue-700 border border-blue-200 ${chipShell} pl-2 pr-1 py-0.5 max-w-full`}>
<button
onClick={() => select(topItem.id!)}
title={criterionHoverTitle(topItem, categoryName(topItem.categoryId))}
className={chipLabelClass}>
💡 {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>
className="text-blue-400 hover:text-blue-700 text-xs leading-none px-0.5 flex-shrink-0">▾</button>
</div>
) : (
<div className={compact ? 'flex justify-end' : undefined}>
<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>
</div>
)}
{open && (
<div className="absolute z-20 mt-1 w-64 bg-white border border-gray-200 rounded-xl shadow-lg p-2">
<div className="absolute z-20 mt-1 w-72 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
const cat = categoryName(item.categoryId)
return (
<button key={s.criterionItemId} onClick={() => select(item.id!)}
title={criterionHoverTitle(item, cat)}
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>
{cat && <div className="text-[10px] text-gray-400">{cat}</div>}
{item.description?.trim() && (
<div className="text-[10px] text-gray-500 mt-0.5 line-clamp-2">{item.description}</div>
)}
</button>
)
})}
@ -95,14 +126,21 @@ export function CriterionTagPicker({ items, categories, value, onChange, suggest
{filtered.length === 0 && (
<div className="text-xs text-gray-400 px-2 py-1.5">Kein Treffer.</div>
)}
{filtered.map(item => (
{filtered.map(item => {
const cat = categoryName(item.categoryId)
return (
<button key={item.id}
onClick={() => select(item.id!)}
title={criterionHoverTitle(item, cat)}
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>
{cat && <div className="text-[10px] text-gray-400">{cat}</div>}
{item.description?.trim() && (
<div className="text-[10px] text-gray-500 mt-0.5 line-clamp-2">{item.description}</div>
)}
</button>
))}
)
})}
</div>
</div>
)}

View File

@ -13,6 +13,7 @@ import {
loadFeedbackStructure, resolveVisibleStructure, updateFeedbackCriterionItem,
getGlobalLineTagCorpus,
getOrSeedCriterionTaggingPromptTemplate,
getOrSeedChecklistTaggingPromptTemplate,
listConversationEntriesForMeeting, addConversationEntry, updateConversationEntry, deleteConversationEntry,
listConversationSkillScoresAll, addConversationSkillScore, updateConversationSkillScore, deleteConversationSkillScoresForEntry,
listAssessmentsForMeeting, addAssessment, updateAssessment,
@ -21,12 +22,22 @@ import {
} 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 { clampRatingScore, isValidRatingScore, normalizeRatingScore, AI_CONFIG } from '../../config/constants'
import { getAiSettings, callOpenRouter, checkPromptSize } from '../../utils/aiClient'
import { weightedAverageRating } from '../../utils/ratingTrend'
import { renderDimensionPrompt } from '../../utils/aiPrompt'
import { buildCriterionSuggestionPromptContext, parseCriterionSuggestionResponse } from '../../utils/criterionSuggestionPrompt'
import { runChecklistTaggingAnalysis } from '../../utils/checklistTagging'
import { getEffectiveChecklistCriterionId } from '../../utils/checklistCriterion'
import { findSimilarTaggedLines, type SimilarTaggedLine } from '../../utils/criterionSuggestion'
import { suggestCriterionScoreForInstitutee } from '../../utils/criterionScoreSuggestion'
import {
collectOpenProtocolLines,
buildCharAwareBatches,
narrowCriteriaIdsForBatch,
mergePatternHints,
promptNeedsStructureNarrowing,
} from '../../utils/taggingBatches'
import { useRatingColorBands } from '../../hooks/useRatingColorBands'
import { ConversationTab } from './ConversationTab'
import { AssessmentTab } from './AssessmentTab'
@ -71,6 +82,7 @@ export default function MeetingView() {
/** 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)
const [lastChecklistAnalysisRaw, setLastChecklistAnalysisRaw] = 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[]>([])
@ -314,13 +326,38 @@ export default function MeetingView() {
await upsertMeetingCategoryRating({ meetingInstanceId: meetingId, instituteeId, categoryId, score: avg })
}
const setAssessmentScore = async (instituteeId: number, itemId: number, score: RatingScore | null) => {
const setAssessmentScore = async (
instituteeId: number,
itemId: number,
score: RatingScore | null,
opts?: { needsReview?: boolean; onlyIfUnset?: boolean },
) => {
// onlyIfUnset: nie überschreiben — frisch aus DB, nicht aus ggf. veraltetem React-State
if (opts?.onlyIfUnset) {
const latest = await listAssessmentsForMeeting(meetingId)
const existingScore = normalizeRatingScore(
latest.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)?.score,
)
if (isValidRatingScore(existingScore)) return
}
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
?? (await listAssessmentsForMeeting(meetingId))
.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
const now = new Date().toISOString()
const scoreNeedsReview = score === null ? false : (opts?.needsReview ?? false)
if (existing?.id) {
await updateAssessment(existing.id, { score, updatedAt: now })
await updateAssessment(existing.id, { score, scoreNeedsReview, updatedAt: now })
} else {
await addAssessment({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now })
await addAssessment({
meetingInstanceId: meetingId,
instituteeId,
criteriaId: itemId,
score,
note: existing?.note ?? '',
updatedAt: now,
scoreNeedsReview,
})
}
const next = await listAssessmentsForMeeting(meetingId)
setAssessments(next)
@ -330,6 +367,26 @@ export default function MeetingView() {
}
}
/**
* Nur Kriterien ohne gesetzten Score — bereits gesetzte Werte werden nie überschrieben
* (Filter + onlyIfUnset-Guard beim Schreiben).
*/
const acceptAllUnsetSuggestions = async (instituteeId: number) => {
const snapshot = await listAssessmentsForMeeting(meetingId)
for (const item of fbItems) {
if (!item.id) continue
const current = normalizeRatingScore(
snapshot.find(a => a.instituteeId === instituteeId && a.criteriaId === item.id)?.score,
)
if (isValidRatingScore(current)) continue
const suggestion = suggestCriterionScoreForInstitutee(
entries, generalNoteTags, checklist, checklistResponses, instituteeId, item.id,
)
if (suggestion === null) continue
await setAssessmentScore(instituteeId, item.id, suggestion, { needsReview: true, onlyIfUnset: true })
}
}
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()
@ -362,51 +419,28 @@ export default function MeetingView() {
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
getEffectiveChecklistCriterionId(ci, getChecklistResponse(ci.id!, instituteeId))
/**
* `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.
* `ChecklistResponse` ist die Quelle für die Checkliste-UI. Scores/Notizen fließen NICHT
* automatisch in `Assessment` — Vorschläge erscheinen im Gesamtbewertungs-Tab (Übernehmen).
* KI-Feedback liest Checklisten/Protokoll direkt aus Primärquellen (criterionEvidence.ts).
*/
const saveChecklistResponse = async (
ci: ChecklistItemDef,
instituteeId: number | undefined,
patch: { rating?: RatingScore | null; note?: string; criterionItemId?: number },
patch: { rating?: RatingScore | null; note?: string; criterionItemId?: number | null; criterionOverride?: boolean },
) => {
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 })
const criterionOverride = 'criterionOverride' in patch ? patch.criterionOverride : existing?.criterionOverride
await upsertChecklistResponse({
meetingInstanceId: meetingId, instituteeId, checklistItemId: ci.id!, rating, note, criterionItemId, criterionOverride,
})
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) =>
@ -416,9 +450,9 @@ export default function MeetingView() {
saveChecklistResponse(ci, instituteeId, { note })
const setChecklistCriterion = (ci: ChecklistItemDef, instituteeId: number | undefined, criterionItemId: number | null) =>
saveChecklistResponse(ci, instituteeId, { criterionItemId: criterionItemId ?? undefined })
saveChecklistResponse(ci, instituteeId, { criterionItemId, criterionOverride: true })
/** Lokale (KI-freie) Kriterium-Vorschläge für Checklisten-Antworten ohne effektives Kriterium — analog `localLineSuggestions`. */
/** Lokale (KI-freie) Vorschläge nur wenn weder Config-Standard noch Antwort-Zuordnung gesetzt. */
const localChecklistSuggestions = useMemo(() => {
const map = new Map<string, CriterionSuggestion[]>()
if (corpus.length === 0) return map
@ -426,9 +460,8 @@ export default function MeetingView() {
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 (!ci.label.trim()) continue
const hints = findSimilarTaggedLines(ci.label, 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 })))
}
@ -442,8 +475,11 @@ export default function MeetingView() {
return merged
}, [localChecklistSuggestions, checklistSuggestions])
const getChecklistSuggestions = (checklistItemId: number, instituteeId: number | undefined): CriterionSuggestion[] | undefined =>
mergedChecklistSuggestions.get(`${checklistItemId}:${instituteeId ?? ''}`)
const getChecklistSuggestions = (checklistItemId: number, instituteeId: number | undefined): CriterionSuggestion[] | undefined => {
const ci = checklist.find(c => c.id === checklistItemId)
if (!ci || getEffectiveChecklistCriterion(ci, instituteeId) !== null) return undefined
return mergedChecklistSuggestions.get(`${checklistItemId}:${instituteeId ?? ''}`)
}
// ── Meeting-level handlers ────────────────────────────────────────────────
@ -490,12 +526,8 @@ export default function MeetingView() {
}
/**
* „🏷 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`.
* „🏷 Vorschläge analysieren" — zwei getrennte KI-Calls: Protokoll-Freitext (ZEILE n:) und
* Checklistenpunkte (PUNKT n:), jeweils mit eigenem Prompt und Parser.
*/
const analyzeSuggestions = async () => {
if (!assignment) return
@ -509,12 +541,14 @@ export default function MeetingView() {
setSuggestionError('')
setSuggestionInfo('')
setLastAnalysisRaw(null)
setLastChecklistAnalysisRaw(null)
try {
const [aType, structure, corpus, template] = await Promise.all([
const [aType, structure, corpus, protocolTemplate, checklistTemplate] = await Promise.all([
getAssignmentType(assignment.assignmentTypeId),
loadFeedbackStructure(),
getGlobalLineTagCorpus(),
getOrSeedCriterionTaggingPromptTemplate(),
getOrSeedChecklistTaggingPromptTemplate(),
])
const visible = resolveVisibleStructure(
structure.dimensions,
@ -522,82 +556,163 @@ export default function MeetingView() {
structure.items,
aType?.criteriaIds,
)
const allVisibleItemIds = visible.items.map(i => i.id!).filter((id): id is number => id != null)
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({
const promptBase = {
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)
}
let protocolAnalyzed = 0
let protocolSuggestions = 0
let checklistAnalyzed = 0
let checklistSuggestionCount = 0
const batchErrors: string[] = []
const softWarns: string[] = []
// ── Stufe 1: Protokoll-Freitext (zeichenbewusste Batches) ───────────────
const openLines = collectOpenProtocolLines(entries, institutees, generalNotes, generalNoteTags, tagLabel)
const protocolBatches = buildCharAwareBatches(openLines, l => l.text)
const nextSuggestions = new Map<string, CriterionSuggestion[]>()
const nextChecklistSuggestions = new Map<string, CriterionSuggestion[]>()
for (const ref of lineRefs) {
const mergedPatterns = new Map<number, string>()
const protocolRawParts: string[] = []
for (let bi = 0; bi < protocolBatches.length; bi++) {
const batch = protocolBatches[bi]
setSuggestionInfo(`Protokoll-Batch ${bi + 1}/${protocolBatches.length}…`)
const numbered = batch.map((l, i) => ({ lineNumber: i + 1, text: l.text, speakerLabel: l.speakerLabel }))
let structureCriteriaIds: number[] | undefined
let context = buildCriterionSuggestionPromptContext({ ...promptBase, lines: numbered })
let prompt = renderDimensionPrompt(protocolTemplate, context)
if (promptNeedsStructureNarrowing(prompt)) {
const narrowed = narrowCriteriaIdsForBatch(
batch.map(l => l.text),
corpus,
allVisibleItemIds,
AI_CONFIG.taggingStructureTopK,
)
if (narrowed) {
structureCriteriaIds = narrowed
context = buildCriterionSuggestionPromptContext({
...promptBase,
lines: numbered,
structureCriteriaIds,
})
prompt = renderDimensionPrompt(protocolTemplate, context)
softWarns.push(`Batch ${bi + 1}: Kriterienstruktur auf Top-${narrowed.length} verengt`)
}
}
const size = checkPromptSize(prompt)
if (size.softWarn && !structureCriteriaIds) {
softWarns.push(`Batch ${bi + 1}: großer Prompt (${size.chars.toLocaleString('de-DE')} Zeichen)`)
}
if (size.overHardLimit) {
batchErrors.push(`Protokoll-Batch ${bi + 1}/${protocolBatches.length}: Prompt zu groß (${size.chars} Zeichen)`)
continue
}
try {
const raw = await callOpenRouter(prompt, settings)
protocolRawParts.push(`--- Protokoll-Batch ${bi + 1}/${protocolBatches.length} ---\n${raw}`)
const { lineSuggestions: parsedLines, patternHints } = parseCriterionSuggestionResponse(raw)
mergePatternHints(mergedPatterns, patternHints)
for (const ref of numbered) {
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)
nextSuggestions.set(ref.text, ids.map((criterionItemId, idx) => ({ criterionItemId, rank: idx + 1 })))
protocolSuggestions++
}
protocolAnalyzed += batch.length
} catch (e) {
batchErrors.push(
`Protokoll-Batch ${bi + 1}/${protocolBatches.length}: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`,
)
}
}
setLineSuggestions(nextSuggestions)
setChecklistSuggestions(nextChecklistSuggestions)
setLastAnalysisRaw(protocolRawParts.length > 0 ? protocolRawParts.join('\n\n') : null)
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]) =>
if (mergedPatterns.size > 0) {
await Promise.all([...mergedPatterns.entries()].map(([criterionItemId, hint]) =>
updateFeedbackCriterionItem(criterionItemId, { learnedPatternHints: hint }),
))
setFbItems(prev => prev.map(i => patternHints.has(i.id!) ? { ...i, learnedPatternHints: patternHints.get(i.id!) } : i))
setFbItems(prev => prev.map(i => mergedPatterns.has(i.id!) ? { ...i, learnedPatternHints: mergedPatterns.get(i.id!) } : i))
}
// ── Stufe 2: Checklistenpunkte (Batches in runChecklistTaggingAnalysis) ─
const checklistTargets: { key: string; label: string; speakerLabel?: string }[] = []
for (const ci of checklist) {
if (!ci.label.trim()) continue
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
for (const instituteeId of targets) {
if (getEffectiveChecklistCriterion(ci, instituteeId) !== null) continue
const inst = instituteeId ? institutees.find(i => i.id === instituteeId) : undefined
checklistTargets.push({
key: `${ci.id}:${instituteeId ?? ''}`,
label: ci.label,
speakerLabel: inst ? tagLabel(inst) : undefined,
})
}
}
if (checklistTargets.length > 0) {
const { raw, suggestionsByKey, batchErrors: clErrors } = await runChecklistTaggingAnalysis(
{
...promptBase,
template: checklistTemplate,
targets: checklistTargets,
onBatchProgress: (i, n) => setSuggestionInfo(`Checkliste-Batch ${i}/${n}…`),
},
prompt => callOpenRouter(prompt, settings),
)
setLastChecklistAnalysisRaw(raw || null)
batchErrors.push(...clErrors)
const nextChecklistSuggestions = new Map<string, CriterionSuggestion[]>()
for (const [key, ids] of suggestionsByKey) {
nextChecklistSuggestions.set(key, ids.map((criterionItemId, idx) => ({ criterionItemId, rank: idx + 1 })))
checklistSuggestionCount++
}
setChecklistSuggestions(nextChecklistSuggestions)
checklistAnalyzed = checklistTargets.length
} else {
setChecklistSuggestions(new Map())
}
const totalAnalyzed = protocolAnalyzed + checklistAnalyzed
const totalSuggestions = protocolSuggestions + checklistSuggestionCount
const batchNote = [
protocolBatches.length > 1 ? `${protocolBatches.length} Protokoll-Batches` : null,
softWarns.length > 0 ? softWarns.join('; ') : null,
].filter(Boolean).join(' · ')
if (batchErrors.length > 0) {
setSuggestionError(batchErrors.join(' · ') + ' — erfolgreiche Batches sind übernommen; erneut versuchen für den Rest.')
}
if (totalAnalyzed === 0 && batchErrors.length === 0) {
setSuggestionInfo('Keine offenen Protokollzeilen oder Checklisten-Punkte gefunden — alles bereits zugeordnet oder leer.')
} else if (totalSuggestions === 0 && totalAnalyzed === 0) {
setSuggestionInfo(batchNote || 'Keine Batches erfolgreich.')
} else if (totalSuggestions === 0) {
const parts: string[] = []
if (protocolAnalyzed > 0) parts.push(`${protocolAnalyzed} Protokollzeile(n) ohne Vorschlag`)
if (checklistAnalyzed > 0) parts.push(`${checklistAnalyzed} Checklistenpunkt(e) ohne Vorschlag`)
setSuggestionInfo([parts.join(', ') + ' — Rohantwort(en) unten einsehbar.', batchNote].filter(Boolean).join(' · '))
} else {
const parts: string[] = []
if (protocolAnalyzed > 0) parts.push(`${protocolAnalyzed} Protokollzeile(n), ${protocolSuggestions} Vorschlag/Vorschläge`)
if (checklistAnalyzed > 0) parts.push(`${checklistAnalyzed} Checklistenpunkt(e), ${checklistSuggestionCount} Vorschlag/Vorschläge`)
setSuggestionInfo([parts.join(' · '), batchNote].filter(Boolean).join(' · '))
}
} catch (e) {
setSuggestionError(e instanceof Error ? e.message : 'Unbekannter Fehler bei der Analyse.')
@ -670,7 +785,7 @@ export default function MeetingView() {
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'}
{analyzingSuggestions ? (suggestionInfo.startsWith('Protokoll') || suggestionInfo.startsWith('Checkliste') ? `⏳ ${suggestionInfo}` : '⏳ Analysiere…') : '🏷 Vorschläge analysieren'}
</button>
<div className="relative">
<button onClick={() => setShowExportMenu(p => !p)}
@ -713,10 +828,16 @@ export default function MeetingView() {
<div>{suggestionInfo}</div>
{lastAnalysisRaw && (
<details>
<summary className="cursor-pointer select-none font-medium">🔍 Rohantwort ansehen</summary>
<summary className="cursor-pointer select-none font-medium">Protokoll — Rohantwort</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>
)}
{lastChecklistAnalysisRaw && (
<details>
<summary className="cursor-pointer select-none font-medium">Checkliste — Rohantwort</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">{lastChecklistAnalysisRaw}</pre>
</details>
)}
</div>
)}
@ -738,14 +859,50 @@ export default function MeetingView() {
</div>
)}
{/* Allgemeine Notizen */}
{/* Tabs */}
{(hasConv || hasAssess || hasChecklist) && (
<div className="flex bg-gray-100 rounded-xl p-1">
{(hasConv || hasChecklist) && (
<button
onClick={() => {
if (editingGeneralNotes) finishEditingGeneralNotes()
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={() => {
if (editingGeneralNotes) finishEditingGeneralNotes()
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>
)}
{/* Allgemeine Notizen — nur im Gesprächsprotokoll (gelten typischerweise für alle Berater) */}
{tab === 'conversation' && (hasConv || hasChecklist) && (
<div className="space-y-1.5">
<div className="text-xs font-medium text-ink-muted px-0.5">
Allgemeine Notizen
<span className="font-normal text-ink-faint"> · für alle Berater</span>
</div>
{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)"
placeholder="Meeting-weite Beobachtungen… (führende Zahl 1–10, > Kundeninput)"
value={generalNotes}
onChange={e => saveGeneralNotes(e.target.value)}
onBlur={finishEditingGeneralNotes}
@ -762,8 +919,8 @@ export default function MeetingView() {
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>
{fbItems.find(item => item.id === h.criterionItemId)?.name && (
<span className="text-blue-400"> → 🏷 {fbItems.find(item => item.id === h.criterionItemId)?.name}</span>
)}
</button>
</li>
@ -792,30 +949,10 @@ export default function MeetingView() {
)}
/>
)
: <span className="text-gray-300">Allgemeine Notizen zum Meeting…</span>
: <span className="text-gray-300">Meeting-weite Beobachtungen…</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>
)}
@ -882,6 +1019,8 @@ export default function MeetingView() {
assessments={assessments}
entries={entries}
generalNoteTags={generalNoteTags}
checklist={checklist}
checklistResponses={checklistResponses}
hasConv={hasConv}
tagLabel={tagLabel}
fillerTotal={fillerTotal}
@ -889,6 +1028,7 @@ export default function MeetingView() {
onSetAssessmentScore={setAssessmentScore}
onSaveAssessmentNote={saveAssessmentNote}
onAutoCalc={autoCalcAssessment}
onAcceptAllSuggestions={acceptAllUnsetSuggestions}
colorBands={colorBands}
/>
)}

View File

@ -1,10 +1,21 @@
import { AI_CONFIG } from '../config/constants'
import { checkPromptSize } from './promptSize'
export type { PromptSizeCheck } from './promptSize'
export { estimatePromptChars, checkPromptSize } from './promptSize'
export interface AiSettings {
apiKey: string
model: string
}
export interface CallOpenRouterOptions {
/** Externer Abbruch (z.B. Unmount) — zusätzlich zum Timeout. */
signal?: AbortSignal
/** Kein zweiter Versuch trotz transientem Fehler. */
skipRetry?: boolean
}
export function getAiSettings(): AiSettings {
return {
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
@ -17,28 +28,103 @@ export function saveAiSettings(apiKey: string, model: string): void {
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> {
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function isTransientHttpStatus(status: number): boolean {
return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504
}
function isAbortError(e: unknown): boolean {
return (e instanceof DOMException && e.name === 'AbortError')
|| (typeof e === 'object' && e !== null && 'name' in e && (e as { name: string }).name === 'AbortError')
}
class AiHttpError extends Error {
status: number
constructor(status: number, statusText: string) {
super(`API-Fehler: ${status} ${statusText}`)
this.status = status
}
}
async function fetchOnce(
prompt: string,
settings: AiSettings,
signal: AbortSignal,
): 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,
'HTTP-Referer': typeof window !== 'undefined' ? window.location.origin : 'http://localhost',
'X-Title': 'Assignment Monitor',
},
body: JSON.stringify({
model: settings.model,
messages: [{ role: 'user', content: prompt }],
}),
signal,
})
if (!res.ok) throw new Error(`API-Fehler: ${res.status} ${res.statusText}`)
const data = await res.json()
if (!res.ok) throw new AiHttpError(res.status, res.statusText)
const data = await res.json() as { choices?: Array<{ message?: { content?: string } }> }
return data.choices?.[0]?.message?.content ?? ''
}
/**
* Einziger Netzwerk-Kontaktpunkt zu OpenRouter — client-seitig, Key aus `localStorage`.
* Timeout, einmaliger Retry bei transienten Fehlern, Hard-Limit für Prompt-Größe.
*/
export async function callOpenRouter(
prompt: string,
settings: AiSettings,
options: CallOpenRouterOptions = {},
): Promise<string> {
const size = checkPromptSize(prompt)
if (size.overHardLimit) {
throw new Error(
`Prompt zu groß (${size.chars.toLocaleString('de-DE')} Zeichen, Limit ${AI_CONFIG.promptHardLimitChars.toLocaleString('de-DE')}). ` +
'Bitte Inhalt kürzen oder Tagging-Batches nutzen.',
)
}
const runAttempt = async (): Promise<string> => {
const controller = new AbortController()
let timedOut = false
const onExternalAbort = () => controller.abort()
if (options.signal) {
if (options.signal.aborted) controller.abort()
else options.signal.addEventListener('abort', onExternalAbort, { once: true })
}
const timer = globalThis.setTimeout(() => {
timedOut = true
controller.abort()
}, AI_CONFIG.requestTimeoutMs)
try {
return await fetchOnce(prompt, settings, controller.signal)
} catch (e) {
if (timedOut) throw new Error(`KI-Anfrage abgebrochen: Timeout nach ${AI_CONFIG.requestTimeoutMs / 1000}s.`)
if (isAbortError(e)) throw new Error('KI-Anfrage abgebrochen.')
if (e instanceof TypeError) throw new Error('KI-Netzwerkfehler — Verbindung prüfen und erneut versuchen.')
throw e
} finally {
globalThis.clearTimeout(timer)
options.signal?.removeEventListener('abort', onExternalAbort)
}
}
try {
return await runAttempt()
} catch (e) {
const status = e instanceof AiHttpError ? e.status : NaN
const msg = e instanceof Error ? e.message : ''
const transient = isTransientHttpStatus(status)
|| msg.includes('Netzwerkfehler')
|| msg.includes('Timeout')
if (!(AI_CONFIG.retryTransientOnce && !options.skipRetry && transient)) throw e
await sleep(AI_CONFIG.retryBackoffMs)
return runAttempt()
}
}

View File

@ -1,10 +1,14 @@
import type {
Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, RatingScore,
ChecklistItemDef, ChecklistResponse,
} from '../db/types'
import { clampRatingScore, isValidRatingScore } from '../config/constants'
import { isValidRatingScore } from '../config/constants'
import { splitNoteLines, parseNotationPrefix } from './notationParser'
import type { TrendResult } from './ratingTrend'
import {
formatKriterienScoresSection, formatTaggedProtocolByMeeting,
} from './criterionEvidence'
/**
* Löst ein Notations-Präfix (führende Score-Zahl 1–10 bzw. `>`) am Zeilenanfang auf.
@ -45,8 +49,9 @@ export interface DimensionPromptInput {
instAssessments: Assessment[]
/** Bereits auf diesen Berater gefiltert. */
instEntries: ConversationEntry[]
/** Alle `done`-Meetings des Assignments, chronologisch — siehe `listDoneMeetingsChronological`. */
meetings: MeetingInstance[]
checklistItems: ChecklistItemDef[]
checklistResponses: ChecklistResponse[]
}
/**
@ -57,9 +62,9 @@ export function buildDimensionPromptContext(input: DimensionPromptInput): Record
const {
institutee, assignment, dimension, dimCategories, dimItems,
ratings, catTrends, instAssessments, instEntries, meetings,
checklistItems, checklistResponses,
} = input
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
const kategorienStruktur = dimCategories
@ -68,30 +73,12 @@ export function buildDimensionPromptContext(input: DimensionPromptInput): Record
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 block = formatKriterienScoresSection(
catItems, institutee.id!, meetings, instEntries, checklistItems, checklistResponses, instAssessments,
)
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)'
if (block === '(keine Scores/Belege erfasst)') return null
return ` ${cat.name}:\n${block}`
}).filter((l): l is string => l !== null).join('\n\n') || '(keine Scores erfasst)'
const kategorieBewertungen = dimCategories.map(cat => {
const r = ratings.find(x => x.feedbackCategoryId === cat.id)
@ -177,6 +164,8 @@ export interface CategoryPromptInput {
instAssessments: Assessment[]
instEntries: ConversationEntry[]
meetings: MeetingInstance[]
checklistItems: ChecklistItemDef[]
checklistResponses: ChecklistResponse[]
}
export interface CategoryAiResult {
@ -209,32 +198,14 @@ export function buildCategoryPromptContext(input: CategoryPromptInput): Record<s
const {
institutee, assignment, dimension, category, catItems,
ratings, catTrend, instAssessments, instEntries, meetings,
checklistItems, checklistResponses,
} = input
const catItemIds = new Set(catItems.map(i => i.id))
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
const catItemIds = new Set(catItems.map(i => i.id!))
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 kriterienScores = formatKriterienScoresSection(
catItems, institutee.id!, meetings, instEntries, checklistItems, checklistResponses, instAssessments,
)
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'
@ -255,27 +226,9 @@ export function buildCategoryPromptContext(input: CategoryPromptInput): Record<s
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 protokollGetaggt = formatTaggedProtocolByMeeting(
catItemIds, institutee.id!, meetings, instEntries,
)
const erlaeuterung = category.description
? `Erläuterung: ${category.description}`

View File

@ -0,0 +1,67 @@
/** ISO-Datum YYYY-MM-DD für Vergleiche ohne Uhrzeit. */
export function todayIsoDate(d = new Date()): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export type AssignmentSignal = 'plan_overdue' | 'feedback_overdue' | 'ending_soon'
export interface AssignmentSignalInput {
status: 'active' | 'done'
plannedStart?: string
plannedEnd?: string
feedbackComplete: boolean
}
const ENDING_SOON_DAYS = 7
/** Signale für Dashboard-Badges (beide Überfällig-Regeln + „endet bald"). */
export function assignmentSignals(
a: AssignmentSignalInput,
today = todayIsoDate(),
): AssignmentSignal[] {
const out: AssignmentSignal[] = []
const end = a.plannedEnd?.slice(0, 10)
if (a.status === 'active' && end && end < today) out.push('plan_overdue')
if (a.status === 'done' && !a.feedbackComplete) out.push('feedback_overdue')
if (a.status === 'active' && end && end >= today) {
const endMs = Date.parse(`${end}T12:00:00`)
const todayMs = Date.parse(`${today}T12:00:00`)
const days = Math.round((endMs - todayMs) / 86_400_000)
if (days >= 0 && days <= ENDING_SOON_DAYS) out.push('ending_soon')
}
return out
}
/** Ob [plannedStart, plannedEnd] mit [from, to] überlappt (fehlende Planenden = offen). */
export function plannedRangeOverlaps(
plannedStart: string | undefined,
plannedEnd: string | undefined,
from: string,
to: string,
): boolean {
if (!from && !to) return true
const start = (plannedStart ?? '').slice(0, 10) || '0000-01-01'
const end = (plannedEnd ?? '').slice(0, 10) || '9999-12-31'
const f = from || '0000-01-01'
const t = to || '9999-12-31'
return start <= t && end >= f
}
export function formatPlanRange(plannedStart?: string, plannedEnd?: string): string | null {
const s = plannedStart?.slice(0, 10)
const e = plannedEnd?.slice(0, 10)
if (!s && !e) return null
const fmt = (iso: string) => {
const [y, m, d] = iso.split('-')
return `${d}.${m}.${y}`
}
if (s && e) return `${fmt(s)} – ${fmt(e)}`
if (s) return `ab ${fmt(s)}`
return `bis ${fmt(e!)}`
}

View File

@ -0,0 +1,36 @@
import {
exportAssignmentTransfer,
importAssignmentTransfer,
type AssignmentTransferPackage,
type AssignmentImportResult,
} from '../db/queries/assignmentTransfer'
import { ASSIGNMENT_TRANSFER_KIND } from '../db/types'
import { downloadFile } from './meetingExport'
function safeFilenamePart(s: string): string {
return s.replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '_').slice(0, 60)
}
export async function downloadAssignmentTransfer(assignmentId: number): Promise<void> {
const pkg = await exportAssignmentTransfer(assignmentId)
const date = new Date().toLocaleDateString('de-DE').replace(/\./g, '-')
const id = pkg.source.assignmentNumber || pkg.source.title || `assignment-${assignmentId}`
const name = `assignment-transfer_${safeFilenamePart(id)}_${date}.json`
downloadFile(JSON.stringify(pkg, null, 2), name, 'application/json')
}
export async function importAssignmentTransferFile(file: File): Promise<
{ ok: true; result: AssignmentImportResult } | { ok: false; error: string }
> {
try {
const text = await file.text()
const pkg = JSON.parse(text) as AssignmentTransferPackage
if (pkg.kind !== ASSIGNMENT_TRANSFER_KIND || pkg.version !== 1) {
return { ok: false, error: 'Ungültiges Übergabe-Format (kein Assignment-Transfer-Paket).' }
}
const result = await importAssignmentTransfer(pkg)
return { ok: true, result }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}

View File

@ -0,0 +1,42 @@
import { AI_CONFIG } from '../config/constants'
/**
* Batches mit Budget: max. Zeilen und max. Zeichensumme (Rohtext).
* Eine überlange Einzelzeile bildet allein einen Batch.
*/
export function buildCharAwareBatches<T>(
items: T[],
getText: (item: T) => string,
maxLines: number = AI_CONFIG.taggingBatchMaxLines,
maxChars: number = AI_CONFIG.taggingBatchMaxChars,
): T[][] {
if (items.length === 0) return []
const batches: T[][] = []
let current: T[] = []
let chars = 0
for (const item of items) {
const len = getText(item).length
const wouldExceed =
current.length > 0 &&
(current.length >= maxLines || chars + len > maxChars)
if (wouldExceed) {
batches.push(current)
current = []
chars = 0
}
current.push(item)
chars += len
}
if (current.length > 0) batches.push(current)
return batches
}
/**
* MUSTER-Hinweise mergen: spätere Batches überschreiben frühere für dieselbe Kriterium-ID.
*/
export function mergePatternHints(
into: Map<number, string>,
from: Map<number, string>,
): void {
for (const [id, hint] of from) into.set(id, hint)
}

View File

@ -0,0 +1,10 @@
import type { ChecklistItemDef, ChecklistResponse } from '../db/types'
/** Anzeige- und Spiegel-Logik: Config-Standard, sofern im Meeting nicht überschrieben oder entfernt. */
export function getEffectiveChecklistCriterionId(
ci: ChecklistItemDef,
response: ChecklistResponse | undefined,
): number | null {
if (response?.criterionOverride) return response.criterionItemId ?? null
return ci.linkedCriterionItemId ?? null
}

View File

@ -0,0 +1,83 @@
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, ConversationLineTag } from '../db/types'
import { resolveVisibleStructure } from '../db/queries/feedbackStructure'
import { findSimilarTaggedLines } from './criterionSuggestion'
import { buildCriterionStructureText, parseCriterionIdList } from './criterionSuggestionPrompt'
export interface ChecklistSuggestionPoint {
pointNumber: number
/** `${checklistItemId}:${instituteeId ?? ''}` — stabil für UI-Routing. */
checklistKey: string
label: string
displayText: string
speakerLabel?: string
}
export interface ChecklistSuggestionPromptInput {
assignmentTypeName: string
dimensions: FeedbackDimension[]
categories: FeedbackCategory[]
items: FeedbackCriterionItem[]
visibleCriteriaIds: number[] | undefined
points: ChecklistSuggestionPoint[]
corpus: ConversationLineTag[]
}
const similarityLabel = (score: number): string => score >= 0.5 ? 'hoch' : score >= 0.25 ? 'mittel' : 'niedrig'
export function buildChecklistSuggestionPromptContext(input: ChecklistSuggestionPromptInput): Record<string, string> {
const { assignmentTypeName, dimensions, categories, items, visibleCriteriaIds, points, corpus } = input
const { items: visibleItems } = resolveVisibleStructure(dimensions, categories, items, visibleCriteriaIds)
const itemName = (id: number) => items.find(i => i.id === id)?.name ?? `#${id}`
const checklistenPunkte = points
.map(p => `${p.pointNumber}. ${p.speakerLabel ? `[${p.speakerLabel}] ` : ''}${p.displayText}`)
.join('\n') || '(keine offenen Checklistenpunkte)'
const aehnlicheAltZuordnungen = points.map(p => {
const hints = findSimilarTaggedLines(p.label, corpus)
if (hints.length === 0) return `Punkt ${p.pointNumber}: (keine ähnlichen Alt-Zeilen gefunden)`
const hintLines = hints.map(h =>
` - "${h.text}" → [${h.criterionItemId}] ${itemName(h.criterionItemId)} (Ähnlichkeit ${similarityLabel(h.score)})`,
)
return `Punkt ${p.pointNumber} („${p.label}"):\n${hintLines.join('\n')}`
}).join('\n') || '(keine Punkte)'
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}}': buildCriterionStructureText(dimensions, categories, items, visibleCriteriaIds),
'{{GELERNTE_MUSTER}}': gelernteMuster,
'{{AEHNLICHE_ALT_ZUORDNUNGEN}}': aehnlicheAltZuordnungen,
'{{CHECKLISTEN_PUNKTE}}': checklistenPunkte,
}
}
export interface ParsedChecklistSuggestionResponse {
/** Punktnummer → priorisierte Kriterium-ID-Liste (leer = kein Vorschlag). */
pointSuggestions: Map<number, number[]>
}
export { parseCriterionIdList } from './criterionSuggestionPrompt'
/** Parst `PUNKT 3: 12, 47` / `PUNKT 9: -` / `Punkt 1: [12]` — getrennt vom Protokoll-Parser (`ZEILE n:`). */
export function parseChecklistSuggestionResponse(raw: string): ParsedChecklistSuggestionResponse {
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
const pointSuggestions = new Map<number, number[]>()
for (const rawLine of cleaned.split('\n')) {
let line = rawLine.trim()
if (/^MUSTER:\s*$/i.test(line)) break
line = line.replace(/^[-*•]\s+/, '').replace(/\*\*/g, '')
const m = line.match(/^PUNKT\s+(\d+)\s*:\s*(.*)$/i)
if (!m) continue
const pointNumber = Number(m[1])
pointSuggestions.set(pointNumber, parseCriterionIdList(m[2]))
}
return { pointSuggestions }
}

View File

@ -0,0 +1,91 @@
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, ConversationLineTag } from '../db/types'
import { renderDimensionPrompt } from './aiPrompt'
import { checkPromptSize } from './promptSize'
import { buildCharAwareBatches } from './charAwareBatches'
import {
buildChecklistSuggestionPromptContext,
parseChecklistSuggestionResponse,
type ChecklistSuggestionPoint,
} from './checklistSuggestionPrompt'
export function formatChecklistPointLabel(label: string): string {
return `Checklistenpunkt „${label}"`
}
export interface ChecklistTaggingTarget {
/** Stabiler Schlüssel für Ergebnis-Routing (z.B. checklistItemId oder `${id}:${instituteeId}`). */
key: string
label: string
speakerLabel?: string
}
export interface ChecklistTaggingInput {
assignmentTypeName: string
dimensions: FeedbackDimension[]
categories: FeedbackCategory[]
items: FeedbackCriterionItem[]
visibleCriteriaIds: number[] | undefined
corpus: ConversationLineTag[]
template: string
targets: ChecklistTaggingTarget[]
onBatchProgress?: (batchIndex: number, batchCount: number) => void
}
/** Gebündelte KI-Calls für Checklisten-Labels → Kriterium-IDs (`PUNKT n:`), zeichenbewusst in Batches. */
export async function runChecklistTaggingAnalysis(
input: ChecklistTaggingInput,
callAi: (prompt: string) => Promise<string>,
): Promise<{ raw: string; suggestionsByKey: Map<string, number[]>; batchErrors: string[] }> {
const batches = buildCharAwareBatches(input.targets, t => t.label)
const suggestionsByKey = new Map<string, number[]>()
const rawParts: string[] = []
const batchErrors: string[] = []
for (let bi = 0; bi < batches.length; bi++) {
input.onBatchProgress?.(bi + 1, batches.length)
const batch = batches[bi]
const points: ChecklistSuggestionPoint[] = batch.map((t, i) => ({
pointNumber: i + 1,
checklistKey: t.key,
label: t.label,
displayText: formatChecklistPointLabel(t.label),
speakerLabel: t.speakerLabel,
}))
const context = buildChecklistSuggestionPromptContext({
assignmentTypeName: input.assignmentTypeName,
dimensions: input.dimensions,
categories: input.categories,
items: input.items,
visibleCriteriaIds: input.visibleCriteriaIds,
points,
corpus: input.corpus,
})
const prompt = renderDimensionPrompt(input.template, context)
const size = checkPromptSize(prompt)
if (size.overHardLimit) {
batchErrors.push(`Checkliste-Batch ${bi + 1}/${batches.length}: Prompt zu groß (${size.chars} Zeichen)`)
continue
}
try {
const raw = await callAi(prompt)
rawParts.push(`--- Checkliste-Batch ${bi + 1}/${batches.length} ---\n${raw}`)
const { pointSuggestions } = parseChecklistSuggestionResponse(raw)
for (const pt of points) {
const ids = pointSuggestions.get(pt.pointNumber)
if (ids && ids.length > 0) suggestionsByKey.set(pt.checklistKey, ids)
}
} catch (e) {
batchErrors.push(
`Checkliste-Batch ${bi + 1}/${batches.length}: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`,
)
}
}
return {
raw: rawParts.join('\n\n'),
suggestionsByKey,
batchErrors,
}
}

View File

@ -0,0 +1,40 @@
import * as XLSX from 'xlsx'
import type { ConsultantImportRow } from '../db/types'
function cellStr(v: unknown): string {
if (v == null) return ''
return String(v).trim()
}
function pick(row: Record<string, unknown>, keys: string[]): string {
const lower = new Map<string, unknown>()
for (const [k, v] of Object.entries(row)) lower.set(k.trim().toLowerCase(), v)
for (const key of keys) {
const hit = lower.get(key.toLowerCase())
if (hit != null && cellStr(hit)) return cellStr(hit)
}
return ''
}
/** Liest erste Sheet-Zeilen; erkennt Spalten über gängige SharePoint-/Excel-Köpfe. */
export function parseConsultantExcelFile(buffer: ArrayBuffer): ConsultantImportRow[] {
const wb = XLSX.read(buffer, { type: 'array' })
const sheetName = wb.SheetNames[0]
if (!sheetName) return []
const sheet = wb.Sheets[sheetName]
if (!sheet) return []
const raw = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
return raw.map(row => ({
email: pick(row, [
'email', 'e-mail', 'e_mail', 'mail', 'work email', 'geschäftliche e-mail',
'geschäftliche email', 'emailadresse', 'e-mail-adresse', 'user principal name',
]),
firstName: pick(row, [
'firstname', 'first name', 'vorname', 'given name', 'givenname',
]),
lastName: pick(row, [
'lastname', 'last name', 'nachname', 'surname', 'family name', 'familyname',
]),
})).filter(r => r.email || r.firstName || r.lastName)
}

View File

@ -0,0 +1,240 @@
import type {
FeedbackCriterionItem, ChecklistItemDef, ChecklistResponse, ChecklistScoreContribution,
ConversationEntry, MeetingInstance, RatingScore, Assessment,
} from '../db/types'
import { clampRatingScore, isValidRatingScore } from '../config/constants'
import { splitNoteLines, parseNotationPrefix } from './notationParser'
import { getEffectiveChecklistCriterionId } from './checklistCriterion'
import { suggestCriterionScoreForInstitutee } from './criterionScoreSuggestion'
export type EvidenceImportance = 'hoch' | 'erhöht' | 'normal'
export interface CriterionEvidenceItem {
date: string
source: string
score: RatingScore | null
importance: EvidenceImportance
text: string
}
export interface CriterionEvidenceBundle {
items: CriterionEvidenceItem[]
overallScore: RatingScore | null
meetingCount: number
}
const importanceLabel: Record<EvidenceImportance, string> = {
hoch: 'hoch (Checkliste primär)',
erhöht: 'erhöht (Checkliste ×2)',
normal: 'normal',
}
function checklistImportance(mode: ChecklistScoreContribution | undefined): EvidenceImportance {
if (mode === 'primary') return 'hoch'
if (mode === 'weighted') return 'erhöht'
return 'normal'
}
function formatEvidenceLine(item: CriterionEvidenceItem): string {
const scorePart = isValidRatingScore(item.score) ? `Score ${item.score}/10` : 'ohne Score'
const imp = item.importance !== 'normal' ? ` | Wichtigkeit: ${importanceLabel[item.importance]}` : ''
const textPart = item.text.trim() ? ` | ${item.text.trim()}` : ''
return ` • ${item.source} | ${scorePart}${imp}${textPart}`
}
function isChecklistMirrorNote(note: string): boolean {
return note.trim().startsWith('Checkliste „')
}
/** Sammelt alle Belege für ein Kriterium aus Primärquellen (Protokoll, Checkliste, manuelle Gesamtbewertung). */
export function collectCriterionEvidence(
criterionId: number,
instituteeId: number,
meetings: MeetingInstance[],
entries: ConversationEntry[],
checklistItems: ChecklistItemDef[],
checklistResponses: ChecklistResponse[],
assessments: Assessment[],
): CriterionEvidenceBundle {
const items: CriterionEvidenceItem[] = []
for (const meeting of meetings) {
const date = meeting.date
const meetingEntries = entries.filter(e => e.meetingInstanceId === meeting.id)
const meetingChecklist = checklistItems.filter(ci => ci.phaseTemplateId === meeting.phaseTemplateId)
const meetingResponses = checklistResponses.filter(r => r.meetingInstanceId === meeting.id)
const generalTags = meeting.generalNoteTags ?? []
for (const entry of meetingEntries) {
for (const tag of entry.lineTags ?? []) {
if (tag.criterionItemId !== criterionId) continue
const { rating, text } = parseNotationPrefix(tag.text)
const display = text.trim() || tag.text.trim()
if (!display && !isValidRatingScore(rating)) continue
items.push({
date,
source: 'Protokoll',
score: rating,
importance: 'normal',
text: display,
})
}
}
for (const tag of generalTags) {
if (tag.criterionItemId !== criterionId) continue
const { rating, text } = parseNotationPrefix(tag.text)
const display = text.trim() || tag.text.trim()
if (!display && !isValidRatingScore(rating)) continue
items.push({
date,
source: 'Allgemeine Meeting-Notiz',
score: rating,
importance: 'normal',
text: display,
})
}
for (const ci of meetingChecklist) {
const targets = ci.scope === 'meetingWide' ? [undefined as number | undefined] : [instituteeId]
for (const instId of targets) {
const resp = meetingResponses.find(r =>
r.checklistItemId === ci.id && (r.instituteeId ?? undefined) === instId,
)
if (getEffectiveChecklistCriterionId(ci, resp) !== criterionId) continue
const score = resp?.rating ?? null
const comment = resp?.note?.trim() ?? ''
if (!isValidRatingScore(score) && !comment) continue
items.push({
date,
source: `Checkliste „${ci.label}"`,
score: isValidRatingScore(score) ? score : null,
importance: checklistImportance(ci.scoreContribution),
text: comment,
})
}
}
const manual = assessments.find(a =>
a.meetingInstanceId === meeting.id && a.instituteeId === instituteeId && a.criteriaId === criterionId,
)
if (manual?.note?.trim() && !isChecklistMirrorNote(manual.note)) {
items.push({
date,
source: 'Gesamtbewertung (manuell)',
score: isValidRatingScore(manual.score) ? manual.score : null,
importance: 'normal',
text: manual.note.trim(),
})
} else if (
manual && isValidRatingScore(manual.score)
&& !items.some(i => i.date === date && isValidRatingScore(i.score))
) {
items.push({
date,
source: 'Gesamtbewertung (manuell)',
score: manual.score,
importance: 'normal',
text: '',
})
}
}
const meetingCount = new Set(items.map(i => i.date)).size
const perMeetingScores = meetings.map(m => {
const meetingEntries = entries.filter(e => e.meetingInstanceId === m.id)
const meetingResponses = checklistResponses.filter(r => r.meetingInstanceId === m.id)
const meetingChecklist = checklistItems.filter(ci => ci.phaseTemplateId === m.phaseTemplateId)
return suggestCriterionScoreForInstitutee(
meetingEntries, m.generalNoteTags ?? [], meetingChecklist, meetingResponses, instituteeId, criterionId,
)
}).filter((s): s is RatingScore => s !== null)
const overallScore = perMeetingScores.length > 0
? clampRatingScore(perMeetingScores.reduce((s, v) => s + v, 0) / perMeetingScores.length)
: null
return { items, overallScore, meetingCount }
}
export function formatCriterionEvidenceBlock(
criterion: FeedbackCriterionItem,
bundle: CriterionEvidenceBundle,
): string | null {
if (bundle.items.length === 0 && bundle.overallScore === null) return null
const label = criterion.description ? `${criterion.name} (${criterion.description})` : criterion.name
const overall = bundle.overallScore !== null
? `Gesamt-Ø: ${bundle.overallScore}/10 (${bundle.meetingCount} Meeting(s) mit Belegen)`
: 'Gesamt-Ø: keine Score-Daten (nur Notizen)'
const byDate = new Map<string, CriterionEvidenceItem[]>()
for (const item of bundle.items) {
const list = byDate.get(item.date) ?? []
list.push(item)
byDate.set(item.date, list)
}
const datedBlocks = [...byDate.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, dayItems]) => {
const lines = dayItems.map(formatEvidenceLine).join('\n')
return ` [${date}]\n${lines}`
})
.join('\n')
return ` ${label}:\n ${overall}\n${datedBlocks}`
}
export function formatKriterienScoresSection(
criteria: FeedbackCriterionItem[],
instituteeId: number,
meetings: MeetingInstance[],
entries: ConversationEntry[],
checklistItems: ChecklistItemDef[],
checklistResponses: ChecklistResponse[],
assessments: Assessment[],
): string {
const blocks = criteria
.map(c => formatCriterionEvidenceBlock(
c,
collectCriterionEvidence(c.id!, instituteeId, meetings, entries, checklistItems, checklistResponses, assessments),
))
.filter((b): b is string => b !== null)
return blocks.join('\n\n') || '(keine Scores/Belege erfasst)'
}
/** Flacht getaggte Protokollzeilen einer Kategorie chronologisch (für {{PROTOKOLL_GETAGGT}}). */
export function formatTaggedProtocolByMeeting(
catItemIds: Set<number>,
instituteeId: number,
meetings: MeetingInstance[],
entries: ConversationEntry[],
): string {
return meetings
.map(m => {
const meetingEntries = entries.filter(e => e.meetingInstanceId === m.id && e.instituteeId === instituteeId)
const rawLines = meetingEntries.flatMap(e =>
splitNoteLines(e.note).flatMap(line => {
const tags = (e.lineTags ?? []).filter(t => t.text === line && catItemIds.has(t.criterionItemId))
if (tags.length === 0) return []
if (parseNotationPrefix(line).isQuote) return []
return [line]
}),
)
const generalTagged = (m.generalNoteTags ?? [])
.filter(t => catItemIds.has(t.criterionItemId))
.map(t => t.text)
const allLines = [...rawLines, ...generalTagged]
if (allLines.length === 0) return null
const lines = allLines.map(line => {
const { rating, text } = parseNotationPrefix(line)
const scorePart = isValidRatingScore(rating) ? `Score ${rating}/10 — ` : ''
return ` - ${scorePart}${text.trim() || line.trim()}`
})
return ` [${m.date}]\n${lines.join('\n')}`
})
.filter((x): x is string => x !== null)
.join('\n') || '(keine getaggten Zeilen)'
}

View File

@ -0,0 +1,96 @@
import type {
RatingScore, ChecklistItemDef, ChecklistResponse, ChecklistScoreContribution,
ConversationEntry, ConversationLineTag,
} from '../db/types'
import { isValidRatingScore, clampRatingScore } from '../config/constants'
import { detectNotationRating } from './notationParser'
import { getEffectiveChecklistCriterionId } from './checklistCriterion'
export const CHECKLIST_WEIGHTED_FACTOR = 2
export interface ChecklistScoreSource {
score: RatingScore
contribution: ChecklistScoreContribution
}
export function protocolScoresForCriterion(
entries: ConversationEntry[],
generalNoteTags: ConversationLineTag[],
instituteeId: number,
itemId: number,
): RatingScore[] {
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)
return [...entryTexts, ...generalTexts]
.map(detectNotationRating)
.filter(isValidRatingScore)
}
export function checklistScoresForCriterion(
checklist: ChecklistItemDef[],
responses: ChecklistResponse[],
instituteeId: number,
itemId: number,
): ChecklistScoreSource[] {
const sources: ChecklistScoreSource[] = []
for (const ci of checklist) {
const targets = ci.scope === 'meetingWide' ? [undefined as number | undefined] : [instituteeId]
for (const instId of targets) {
const resp = responses.find(r =>
r.checklistItemId === ci.id && (r.instituteeId ?? undefined) === instId,
)
if (getEffectiveChecklistCriterionId(ci, resp) !== itemId) continue
if (!isValidRatingScore(resp?.rating ?? null)) continue
sources.push({
score: resp!.rating!,
contribution: ci.scoreContribution ?? 'equal',
})
}
}
return sources
}
/**
* Score-Vorschlag aus Protokoll-Notation + Checkliste.
* primary: nur Checklisten mit Modus „primär" (Protokoll-Scores nur Erklärungsebene).
* weighted: Checklisten zählen doppelt gegenüber Protokoll/gleich.
*/
export function suggestCriterionScore(
protocolScores: RatingScore[],
checklistSources: ChecklistScoreSource[],
): RatingScore | null {
const primary = checklistSources.filter(s => s.contribution === 'primary')
if (primary.length > 0) {
const avg = primary.reduce((s, x) => s + x.score, 0) / primary.length
return clampRatingScore(avg)
}
const pool: { score: number; w: number }[] = []
for (const s of protocolScores) pool.push({ score: s, w: 1 })
for (const s of checklistSources) {
pool.push({ score: s.score, w: s.contribution === 'weighted' ? CHECKLIST_WEIGHTED_FACTOR : 1 })
}
if (pool.length === 0) return null
const wSum = pool.reduce((s, x) => s + x.w, 0)
return clampRatingScore(pool.reduce((s, x) => s + x.score * x.w, 0) / wSum)
}
export function suggestCriterionScoreForInstitutee(
entries: ConversationEntry[],
generalNoteTags: ConversationLineTag[],
checklist: ChecklistItemDef[],
checklistResponses: ChecklistResponse[],
instituteeId: number,
itemId: number,
): RatingScore | null {
return suggestCriterionScore(
protocolScoresForCriterion(entries, generalNoteTags, instituteeId, itemId),
checklistScoresForCriterion(checklist, checklistResponses, instituteeId, itemId),
)
}

View File

@ -39,10 +39,8 @@ export function buildCriterionStructureText(
}
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
}
@ -52,24 +50,25 @@ export interface CriterionSuggestionPromptInput {
categories: FeedbackCategory[]
items: FeedbackCriterionItem[]
visibleCriteriaIds: number[] | undefined
/** Optional: verengte Kriterienmenge für {{KRITERIEN_STRUKTUR}} (Hybrid-Notnagel bei großen Batches). */
structureCriteriaIds?: number[]
lines: CriterionSuggestionLine[]
/** Globaler Lern-Korpus, siehe `getGlobalLineTagCorpus()`. */
corpus: ConversationLineTag[]
}
const similarityLabel = (score: number): string => score >= 0.5 ? 'hoch' : score >= 0.25 ? 'mittel' : 'niedrig'
/** Protokoll-Tagging (Freitext-Zeilen) — Checklisten haben eigenen Prompt/Parser. */
export function buildCriterionSuggestionPromptContext(input: CriterionSuggestionPromptInput): Record<string, string> {
const { assignmentTypeName, dimensions, categories, items, visibleCriteriaIds, lines, corpus } = input
const { assignmentTypeName, dimensions, categories, items, lines, corpus } = input
const structureIds = input.structureCriteriaIds ?? input.visibleCriteriaIds
const { items: visibleItems } = resolveVisibleStructure(dimensions, categories, items, visibleCriteriaIds)
const { items: visibleItems } = resolveVisibleStructure(dimensions, categories, items, structureIds)
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)'
.join('\n') || '(keine offenen Protokollzeilen)'
const aehnlicheAltZuordnungen = lines.map(l => {
const hints = findSimilarTaggedLines(l.text, corpus)
@ -85,7 +84,7 @@ export function buildCriterionSuggestionPromptContext(input: CriterionSuggestion
return {
'{{ASSIGNMENT_TYP}}': assignmentTypeName,
'{{KRITERIEN_STRUKTUR}}': kriterienStruktur,
'{{KRITERIEN_STRUKTUR}}': buildCriterionStructureText(dimensions, categories, items, structureIds),
'{{GELERNTE_MUSTER}}': gelernteMuster,
'{{AEHNLICHE_ALT_ZUORDNUNGEN}}': aehnlicheAltZuordnungen,
'{{PROTOKOLL_ZEILEN}}': protokollZeilen,
@ -93,16 +92,26 @@ export function buildCriterionSuggestionPromptContext(input: CriterionSuggestion
}
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>
}
/** Extrahiert Kriterium-IDs aus Klartext — toleriert `[12]`, `12, 47`, `Kriterium 12`. */
export function parseCriterionIdList(rest: string): number[] {
const trimmed = rest.trim()
if (trimmed === '' || trimmed === '-') return []
const bracketIds = [...trimmed.matchAll(/\[(\d+)\]/g)].map(m => Number(m[1]))
if (bracketIds.length > 0) return [...new Set(bracketIds.filter(n => n > 0))]
const ids = trimmed.split(/[,;|/]/).map(s => {
const m = s.trim().match(/\d+/)
return m ? Number(m[0]) : NaN
}).filter(n => Number.isFinite(n) && n > 0)
return [...new Set(ids)]
}
/**
* 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).
* Parst `ZEILE 3: 12, 47` / `ZEILE 9: -` (+ optional `MUSTER:`-Block) — gleiche Robustheit
* wie der Checklisten-Parser (Bullet-/Markdown-Strip, tolerante ID-Liste).
*/
export function parseCriterionSuggestionResponse(raw: string): ParsedCriterionSuggestionResponse {
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
@ -125,7 +134,7 @@ export function parseCriterionSuggestionResponse(raw: string): ParsedCriterionSu
}
for (const rawLine of lines) {
const line = rawLine.trim()
let line = rawLine.trim()
if (/^MUSTER:\s*$/i.test(line)) {
flushPattern()
mode = 'patterns'
@ -133,16 +142,10 @@ export function parseCriterionSuggestionResponse(raw: string): ParsedCriterionSu
}
if (mode === 'lines') {
line = line.replace(/^[-*•]\s+/, '').replace(/\*\*/g, '')
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)
}
lineSuggestions.set(Number(m[1]), parseCriterionIdList(m[2]))
} else {
const m = line.match(/^(\d+)\s*:\s*(.*)$/)
if (m) {

View File

@ -1,5 +1,6 @@
import { getMeetingExportData, getFeedbackCriterionItemsByIds, listFeedbackCategoriesAll } from '../db'
import { normalizeRatingScore } from '../config/constants'
import { getEffectiveChecklistCriterionId } from './checklistCriterion'
export async function exportMeetingAsMarkdown(meetingId: number): Promise<string> {
const {
@ -96,7 +97,7 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
for (const instituteeId of targets) {
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
if (!response) continue
const effectiveCritId = response.criterionItemId ?? ci.linkedCriterionItemId
const effectiveCritId = getEffectiveChecklistCriterionId(ci, response)
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'}`

View File

@ -104,14 +104,21 @@ export function NotationText({ text, className, renderLineAddon, colorBands }: P
const lines = text.split('\n')
return (
<div className={className}>
{lines.map((line, i) => renderLineAddon
? (
<div key={i} className="flex items-start gap-1">
{lines.map((line, i) => {
// Addon-Key = trim — gleiche Normalisierung wie `splitNoteLines` / Analyse-Map
const addonKey = line.trim()
if (renderLineAddon && addonKey) {
return (
<div key={i} className="flex items-start gap-2">
<div className="flex-1 min-w-0">{parseLine(line, i, bands)}</div>
{renderLineAddon(line, i)}
<div className="flex-shrink-0 w-[10.5rem] flex justify-end pt-0.5">
{renderLineAddon(addonKey, i)}
</div>
</div>
)
: parseLine(line, i, bands))}
}
return parseLine(line, i, bands)
})}
</div>
)
}

View File

@ -13,6 +13,7 @@ import {
getAssignmentFeedback, listFeedbackCategoryRatings, listFeedbackDimensionTexts, listFeedbackCategoryTexts,
listAssignmentTypes, listPhaseTemplates, getOrSeedRatingColorBands,
} from '../db'
import { getEffectiveChecklistCriterionId } from './checklistCriterion'
import { DEFAULT_RATING_COLOR_BANDS, normalizeRatingColorBands, resolveScoreStyle } from '../config/constants'
import { splitNoteLines, parseNotationPrefix } from './notationParser'
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
@ -287,7 +288,7 @@ function AssignmentReportDocument({ data }: { data: AssignmentReportData }) {
return targets.map(instituteeId => {
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
if (!response) return null
const effectiveCritId = response.criterionItemId ?? ci.linkedCriterionItemId
const effectiveCritId = getEffectiveChecklistCriterionId(ci, response)
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'}`

21
src/utils/promptSize.ts Normal file
View File

@ -0,0 +1,21 @@
import { AI_CONFIG } from '../config/constants'
/** Grobe Prompt-Größen-Schätzung (Zeichenzahl des fertigen Prompt-Texts). */
export function estimatePromptChars(prompt: string): number {
return prompt.length
}
export interface PromptSizeCheck {
chars: number
softWarn: boolean
overHardLimit: boolean
}
export function checkPromptSize(prompt: string): PromptSizeCheck {
const chars = estimatePromptChars(prompt)
return {
chars,
softWarn: chars >= AI_CONFIG.promptSoftWarnChars,
overHardLimit: chars >= AI_CONFIG.promptHardLimitChars,
}
}

View File

@ -0,0 +1,69 @@
import { AI_CONFIG } from '../config/constants'
import type { ConversationEntry, ConversationLineTag, Consultant } from '../db/types'
import { splitNoteLines, parseNotationPrefix } from './notationParser'
import { findSimilarTaggedLines } from './criterionSuggestion'
import { checkPromptSize } from './promptSize'
export { buildCharAwareBatches, mergePatternHints } from './charAwareBatches'
export interface OpenProtocolLine {
text: string
speakerLabel?: string
}
/**
* Offene Protokollzeilen (Gespräch + allgemeine Notizen): ohne bereits Getaggte, ohne Kundenzitate.
* Reihenfolge chronologisch — gleiche Filterlogik wie zuvor inline in MeetingView.
*/
export function collectOpenProtocolLines(
entries: ConversationEntry[],
institutees: Consultant[],
generalNotes: string,
generalNoteTags: ConversationLineTag[],
labelFor: (inst: Consultant) => string,
): OpenProtocolLine[] {
const lines: OpenProtocolLine[] = []
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
lines.push({ text: line, speakerLabel: inst ? labelFor(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
lines.push({ text: line })
}
return lines
}
/**
* Hybrid-Notnagel: Top-K Kriterien nach lokaler Jaccard-Ähnlichkeit der Batch-Zeilen zum Korpus.
* Leere Trefferliste → null (volle Struktur behalten).
*/
export function narrowCriteriaIdsForBatch(
lineTexts: string[],
corpus: ConversationLineTag[],
visibleCriteriaIds: number[],
topK: number = AI_CONFIG.taggingStructureTopK,
): number[] | null {
const scores = new Map<number, number>()
for (const text of lineTexts) {
for (const h of findSimilarTaggedLines(text, corpus, topK)) {
if (!visibleCriteriaIds.includes(h.criterionItemId)) continue
scores.set(h.criterionItemId, Math.max(scores.get(h.criterionItemId) ?? 0, h.score))
}
}
if (scores.size === 0) return null
return [...scores.entries()]
.sort((a, b) => b[1] - a[1])
.map(([id]) => id)
.slice(0, topK)
}
export function promptNeedsStructureNarrowing(prompt: string): boolean {
const size = checkPromptSize(prompt)
return size.softWarn || size.overHardLimit
}

View File

@ -16,5 +16,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["server", "src/db/types.ts", "src/config/constants.ts", "src/utils/feedbackStructureImport.ts"]
"include": ["server", "src/db/types.ts", "src/config/constants.ts", "src/utils/feedbackStructureImport.ts", "src/utils/promptSize.ts", "src/utils/charAwareBatches.ts"]
}

View File

@ -28,16 +28,22 @@ export default defineConfig(({ mode }) => {
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'apple-touch-icon.png'],
manifest: {
name: 'Assignment Monitor DEV',
short_name: 'AM-DEV',
description: 'DEV — Trainee-Bewertungs-App (nicht Produktiv)',
name: 'Assignment Monitor',
short_name: 'AM Monitor',
description: 'Live-Bewertung und Feedback für Capgemini-Trainees während Kunden-Assignments (DEV).',
lang: 'de',
start_url: '/',
scope: '/',
theme_color: '#0070AD',
background_color: '#ffffff',
background_color: '#eef2f5',
display: 'standalone',
orientation: 'any',
icons: [
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png' },
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),