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:
parent
234f87d408
commit
d894c3c8e4
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"version": "0.0.1",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "dev",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["run", "dev"],
|
|
||||||
"port": 5173,
|
|
||||||
"autoPort": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -14,6 +14,9 @@ dist-ssr/
|
||||||
# Lokale SQLite-Datenbank (enthält personenbezogene Daten, siehe CLAUDE.md → Datenschutz)
|
# Lokale SQLite-Datenbank (enthält personenbezogene Daten, siehe CLAUDE.md → Datenschutz)
|
||||||
server/data/
|
server/data/
|
||||||
|
|
||||||
|
# Versand-ZIPs (lokal erzeugt, nicht committen)
|
||||||
|
dist-release/
|
||||||
|
|
||||||
# Local environment / secrets
|
# Local environment / secrets
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
|
||||||
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"syncfusioninc.document-viewer-vscode-extensions"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
75
CLAUDE.md
75
CLAUDE.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) während Kunden-Assignments.
|
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.
|
**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
|
## 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`
|
- 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
|
- **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`)
|
- 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/
|
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/
|
db/
|
||||||
index.ts ← Re-Export (types + queries), rpcClient.ts eingebunden
|
index.ts ← Re-Export (types + queries)
|
||||||
types.ts ← alle Interfaces, FeedbackRating, AppSettings — von Client UND Server importiert
|
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
|
rpcClient.ts ← rpc(module, fn, ...args) — POST /api/rpc, einziger Netzwerk-Kontaktpunkt des Clients
|
||||||
queries/ ← Client-DB-Zugriffsschicht (siehe eigener Abschnitt unten)
|
queries/ ← Client-DB-Zugriffsschicht (siehe eigener Abschnitt unten)
|
||||||
schema.ts, seeds/ ← Dexie/IndexedDB-Legacy-Code, wird nicht mehr gelesen (siehe Datenbank-Abschnitt)
|
|
||||||
pages/
|
pages/
|
||||||
meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx
|
meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx
|
||||||
RatingWeightConfig.tsx ← Settings-Tab für Zeitgewichtungs-Faktor (in Configuration.tsx eingebunden)
|
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
|
pdfExport.tsx ← PDF-Export (seit 2026-07-14, `@react-pdf/renderer`): Assignment-Report (Gesamtprotokoll + Feedback aller Institutees) und Einzel-Feedback-PDF pro Institutee
|
||||||
server/
|
server/
|
||||||
index.ts ← Express-App, ein Endpunkt POST /api/rpc
|
index.ts ← Express-App, ein Endpunkt POST /api/rpc
|
||||||
database.ts ← node:sqlite-Verbindung (server/data/assignment-monitor.sqlite3), Schema aus tableRegistry.ts
|
database.ts ← node:sqlite-Verbindung (V2: assignment-monitor-v2-dev.sqlite3), Schema aus tableRegistry.ts, Pfad-Guards
|
||||||
rpcModules.ts ← Dispatch-Registry { appSettings, consultants, ..., backup } → server/db/queries/*.ts
|
rpcModules.ts ← Dispatch-Registry inkl. checklists, meetingCategoryRatings, assignmentTransfer, backup, …
|
||||||
db/
|
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
|
crud.ts ← generische SQL-CRUD-Bausteine über die Registry
|
||||||
queries/ ← Server-Pendant zu src/db/queries/*.ts, gleiche Funktionsnamen, echtes SQL statt Dexie
|
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
|
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)
|
### 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.
|
**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`.
|
**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)
|
### 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
|
## Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev:all # Frontend (Vite, :5173) + lokaler Server (Express, :4000) zusammen starten
|
npm run dev:all # Frontend :5174 + Server :4001
|
||||||
npm run dev # Nur Frontend (Server muss separat laufen, sonst laufen alle DB-Zugriffe ins Leere)
|
npm run setup # Starter-Struktur einspielen (Feedback/Typen/Prompts/Checklisten-Defs)
|
||||||
npm run server # Nur den lokalen Server
|
npm run pack:zip # Team-ZIP → dist-release/ (ohne DB)
|
||||||
npx tsc -b # TypeScript-Check über alle 3 Projekte (App/Node/Server) — muss vor jeder Abgabe fehlerfrei sein
|
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.**
|
**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
|
### Rating-Skala
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type FeedbackRating = 'na' | 'not_client_ready' | 'partially_client_ready' | 'nearly_client_ready' | 'client_ready'
|
type RatingScore = number // 1–10; unset = null — siehe docs/SCALE_1_TO_10.md
|
||||||
// Numerisch: na=0, not=1, partially=2, nearly=3, fully=4
|
// Farbbänder in appSettings.ratingColorBands / Config → Skala
|
||||||
// N/A gilt als "nicht gesetzt" — aus allen Berechnungen ausschließen!
|
// 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
|
Aggregation: gewichteter Durchschnitt über `weightedAverageScore`/`weightedAverageRating` in `ratingTrend.ts` — bei `Σweight === 0` null.
|
||||||
|
|
||||||
**Bug-Klasse, auf die immer prüfen:** Filter der Form `a.score !== null` schließen N/A NICHT aus (N/A ist der String `'na'`, nicht `null`!). Richtig ist immer `a.score !== null && a.score !== 'na'`. Gefunden und gefixt in `Evaluation.tsx` (2026-07-02); dieselbe Prüfung nötig überall, wo `Assessment.score` oder `ConversationSkillScore.score` gemittelt wird.
|
|
||||||
|
|
||||||
**Zweite Bug-Klasse, auf die immer prüfen (Kriterium-Gewichtung):** `FeedbackCriterionItem.weight` geht seit 2026-07-03 von `0,0` bis `2,0` (Config → Feedback, vorher grobe `×1`–`×5`-Stufen). Jede gewichtete Mittelung MUSS normieren — `Σ(score×weight) / Σ(weight)`, niemals `score×weight` direkt als Endwert interpretieren (würde die Bewertungsstufe verzerren, z.B. Score 3 „Nearly" × Gewicht 0,5 = 1,5, was fälschlich zwischen Not/Partially läge). Zusätzlich: wenn `Σweight === 0` (z.B. alle beteiligten Kriterien einer Kategorie auf Gewicht 0 gesetzt), **nicht** dividieren — `NaN < 1.5/2.5/3.5` ist überall `false` und fällt sonst still auf den letzten Bucket „Fully" durch. Immer `if (wSum === 0) return null` (bzw. `continue`) vor der Division. Seit dem `db/queries/`-Refactoring (2026-07-04) **eine** Implementierung: `weightedAverageScore()`/`weightedAverageRating()` in `src/utils/ratingTrend.ts`, genutzt von `buildGroupMeetingScores()`, `catMode()` (`AssessmentTab.tsx`) und der Meeting-Chip-Anzeige in `AssignmentDetail.tsx` (dort fehlte der `Σweight===0`-Guard vorher — beim Konsolidieren mitgefixt). Bei jedem neuen gewichteten Durchschnitt diese Funktion wiederverwenden, nicht neu implementieren.
|
|
||||||
|
|
||||||
### Routing
|
### Routing
|
||||||
|
|
||||||
|
|
@ -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
|
- 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.
|
- **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.
|
- 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).
|
- `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 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
|
- **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
|
- **Kein `useEffect` für DB-Calls** außer bei initialem Load — lieber direkte `async`-Funktionen
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Offene Punkte (priorisiert)
|
## Offene Punkte (priorisiert)
|
||||||
|
|
||||||
| Prio | Aufgabe | Datei |
|
**Kanonisch und aktuell:** `docs/STATUS_UND_OFFENE_PUNKTE.md` (2026-08-16). Kurz:
|
||||||
|---|---|---|
|
|
||||||
| 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. | — |
|
| Prio | Aufgabe |
|
||||||
| 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) |
|
| H | **UX.3** Dashboard · **UX.4** Meeting-Live-UX |
|
||||||
| L | SQLite WASM + OPFS Migration — durch den lokalen Server-Ansatz (2026-07-04) überholt/hinfällig | `src/db/schema.ts` |
|
| 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.
|
**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
|
## 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
|
- `server/data/` ist per `.gitignore` ausgeschlossen — Datenbankdatei nie committen
|
||||||
- GDPR-relevant: Institutee-Namen, Kundennamen, Gesprächsnotizen
|
- GDPR-relevant: Institutee-Namen, Kundennamen, Gesprächsnotizen
|
||||||
- KI-API-Key in `localStorage` — nie loggen oder ausgeben
|
- KI-API-Key in `localStorage` — nie loggen oder ausgeben
|
||||||
|
|
|
||||||
87
EINRICHTUNG.md
Normal file
87
EINRICHTUNG.md
Normal 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
68
UMZUG.md
Normal 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\`.
|
||||||
|
|
@ -43,3 +43,18 @@ npm run dev:all
|
||||||
- Niemals Skripte aus V2 gegen den Produktiv-Ordner ausführen.
|
- 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).
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
# Fachliche Architektur — Assignment Monitor
|
# 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
|
## 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:
|
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)
|
**Nutzer:** Lars Stommer (Capgemini Management Consultant Trainee Program Lead)
|
||||||
**Sprache der App:** Deutsch (Bedienoberfläche), Englisch (Rating-Labels)
|
**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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
# Plan: Feedback-Neukonzeption, KI-Kaskadierung & UX
|
# Plan: Feedback-Neukonzeption, KI-Kaskadierung & UX
|
||||||
|
|
||||||
Stand: 2026-08-06 · nur DEV (`AssigmentMonitorV2`)
|
Stand: 2026-08-16 (Abgleich) · nur DEV (`AssigmentMonitorV2`)
|
||||||
Voraussetzung: Bewertungsskala 1–10 weitgehend umgesetzt (`docs/SCALE_1_TO_10.md`).
|
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 |
|
| # | Aufgabe | Done |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 0.1 | Farbbänder speichern/laden (`getOrSeed`/`saveRatingColorBands` Server) — Hell-/Dunkelgrün unterscheidbar | ☐ prüfen |
|
| 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) | ☐ prüfen |
|
| 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.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.4 | Kurz-Smoke: Speichern Skala → Meeting-Einschätzung → Farben greifen nach Reload | ☑ |
|
||||||
|
|
||||||
**Exit:** Skala und Farben verhalten sich in Config, Meeting und Feedback konsistent.
|
**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 |
|
| # | Aufgabe | Done |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 4.1 | Pro Kategorie: ScorePicker + Trend/KI-Score-Vorschlag („Übernehmen“) + Benefits/Concerns (editierbar + suggested-Boxen) | ☐ |
|
| 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 | ☐ |
|
| 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 | ☐ |
|
| 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.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 | ☑ |
|
| 4.5 | Fortschrittsanzeige Generierung: Kategorie X/Y, dann Dimension Z | ☑ |
|
||||||
|
|
||||||
|
|
@ -124,8 +126,8 @@ Bereits weitgehend erledigt; nur nachziehen, was noch hinkt.
|
||||||
|
|
||||||
| # | Aufgabe | Done |
|
| # | Aufgabe | Done |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 5.1 | `InstituteeFeedbackDocument` / Assignment-Report: pro Kategorie Score + Benefits + Concerns; pro Dimension Benefits + Concerns | ☐ |
|
| 5.1 | `InstituteeFeedbackDocument` / Assignment-Report: pro Kategorie Score + Benefits + Concerns; pro Dimension Benefits + Concerns | ☑ Code · Smoke ☐ |
|
||||||
| 5.2 | Nur finale Felder, nie `suggested*` | ☐ |
|
| 5.2 | Nur finale Felder, nie `suggested*` | ☑ Code · Smoke ☐ |
|
||||||
| 5.3 | Markdown/JSON Assignment-Feedback falls vorhanden nachziehen | ☐ |
|
| 5.3 | Markdown/JSON Assignment-Feedback falls vorhanden nachziehen | ☐ |
|
||||||
|
|
||||||
**Exit:** Internes PDF für Manager-Gespräch brauchbar.
|
**Exit:** Internes PDF für Manager-Gespräch brauchbar.
|
||||||
|
|
@ -136,12 +138,12 @@ Bereits weitgehend erledigt; nur nachziehen, was noch hinkt.
|
||||||
|
|
||||||
| # | Aufgabe | Done |
|
| # | Aufgabe | Done |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 6.1 | Hilfsfunktion: offene Zeilen sammeln (unverändert: ohne bereits Getaggte, ohne Kundenzitate wie bisher) | ☐ |
|
| 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) | ☐ |
|
| 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.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.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 | ☐ |
|
| 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) | ☐ |
|
| 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.
|
**Exit:** Lange Protokolle brechen nicht mehr am Einzelcall; getaggte Zeilen bleiben draußen.
|
||||||
|
|
||||||
|
|
@ -153,10 +155,10 @@ Betrifft Feedback- und Tagging-Calls.
|
||||||
|
|
||||||
| # | Aufgabe | Done |
|
| # | Aufgabe | Done |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 7.1 | Timeouts / Abbruch klar melden (nicht still) | ☐ |
|
| 7.1 | Timeouts / Abbruch klar melden (nicht still) | ☑ `callOpenRouter` 90s + Abort-Meldung |
|
||||||
| 7.2 | Retry mit Backoff für transient failures (einmalig) | ☐ |
|
| 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 | ☐ |
|
| 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 | ☐ |
|
| 7.4 | Gleiche `aiClient`-Hilfen für Feedback und Tagging | ☑ |
|
||||||
|
|
||||||
**Exit:** Abbrüche sind erklärbar und oft automatisch reparierbar.
|
**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 |
|
| # | 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.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.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.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.4 | **Meeting-Live-UX:** Einschätzung + Protokoll dichter und ruhiger; ScorePicker/Chips konsistent; Fokus auf Tippen + schnelle Scores | ☐ |
|
||||||
| UX.5 | **Feedback-Seite:** nach Paket 4 — klare Hierarchie Dimension → Kategorie; Benefits/Concerns optisch getrennt; KI-Vorschläge als „Entwurf“, nicht als Lärm | ☐ |
|
| UX.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.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.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.3 | Dexie/IndexedDB-Legacy entfernen | STATUS T2 | ☑ |
|
||||||
| 8.4 | Server Seed-if-empty Assignment-Typen/Struktur | CLAUDE L | ☐ |
|
| 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 | | ☐ |
|
| 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 | CLAUDE | ☐ |
|
| 8.6 | Datenschutz: Hinweis Tagging+Feedback (externe API) vor Prod | STATUS T6 | ☐ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Empfohlene Abarbeitungsreihenfolge
|
## Empfohlene Abarbeitungsreihenfolge
|
||||||
|
|
||||||
```text
|
```text
|
||||||
0 (Smoke Skala)
|
0 / 1 / 1b / 2 / 3 / 6 / 7 / 8.3 ← erledigt
|
||||||
→ 1 (Schema Benefits/Concerns) ← erledigt
|
→ UX.1–UX.2 Tokens + Shell ← erledigt
|
||||||
→ 1b (Typ → sichtbare Struktur) ← VOR Feedback-KI: gleiche Teilmenge in Meeting + Feedback + Kaskade
|
→ UX.3 Dashboard · UX.4 Meeting ← nächster UX-Schritt
|
||||||
→ 6 + 7 parallel möglich ← Tagging-Abbrüche lindern
|
→ UX.5 Feedback · UX.6 Config · UX.7–8
|
||||||
→ 2 → 3 → 4 → 5 ← Feedback-Neukonzeption Ende-zu-Ende
|
→ T4 Smoke / 5.3 parallel möglich
|
||||||
→ 8 Rest nach Bedarf
|
→ T1 / T5 / T6 nach Bedarf
|
||||||
→ UX später
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
323
docs/HANDOVER.md
323
docs/HANDOVER.md
|
|
@ -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`.
|
**Kanonischer Status + offene Punkte:** [`STATUS_UND_OFFENE_PUNKTE.md`](./STATUS_UND_OFFENE_PUNKTE.md) — als Nächstes **T4**.
|
||||||
|
|
||||||
**Vollständige, priorisierte Liste der offenen Punkte:** siehe `CLAUDE.md` → Abschnitt "Offene Punkte (priorisiert)" — das ist die laufend gepflegte Quelle, hier nicht dupliziert. Größere offene Themen, die eine eigene Design-Session brauchen: mehrstufiges Prompt-System für die KI-Generierung, selbstlernendes Tagging.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Worum geht es?
|
## Worum geht es?
|
||||||
|
|
||||||
**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
|
## 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.
|
| Frontend | **React 19** + TypeScript + Vite (PWA) |
|
||||||
- **React Router v6**
|
| Routing | **React Router 7** (`react-router-dom`) |
|
||||||
- **Capgemini-Brandfarbe:** `#0070AD`
|
| CSS | Tailwind v4 — `@import "tailwindcss"`, Brand `brand` / `#0070AD` |
|
||||||
- Arbeitsverzeichnis: `C:\dev\AssigmentMonitor`
|
| Daten | Lokaler **Express** + **`node:sqlite`** (kein better-sqlite3), RPC `POST /api/rpc` |
|
||||||
|
| Ports (V2) | Frontend **5174**, API **4001** |
|
||||||
**Wichtig für den TypeScript-Check:** `npx tsc --noEmit` prüft am Root **nichts** (Project-References-Setup, `files: []`) — immer `npx tsc -b` verwenden.
|
| DB-Datei | `server/data/assignment-monitor-v2-dev.sqlite3` (gitignored) |
|
||||||
|
| Tabellen | **26** — einzige Quelle: `server/db/tableRegistry.ts` |
|
||||||
## 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx tsc -b # Muss ohne Fehler durchlaufen — NICHT --noEmit, das prüft am Root nichts (siehe Tech-Stack-Abschnitt)
|
npm run dev:all # Vite :5174 + API :4001
|
||||||
npm run dev:all # Frontend + lokaler Server zusammen starten
|
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
|
```typescript
|
||||||
2. **`Assessment.criteriaId`**: Zeigt auf `FeedbackCriterionItem.id`, NICHT auf `FeedbackCategory.id` — das verwirrt, weil der Feldname `criteriaId` ist
|
type RatingScore = number // 1–10 ganzzahlig; unset = null (kein N/A-String mehr)
|
||||||
3. **`PhaseKey`**: Ist `type PhaseKey = string` (kein Union-Typ mehr)
|
```
|
||||||
4. **`aType2`** in `AssignmentDetail.tsx`: Doppelte Variable aus Refactoring — `aType` wird zweimal deklariert, zweite heißt `aType2`. Technische Schuld.
|
|
||||||
5. **`expandedCat`** in `MeetingView`: `Record<instituteeId, categoryId | null>` — öffnet pro Institutee genau eine Kategorie gleichzeitig
|
- Farbbänder konfigurierbar: Config → **Skala** (`ratingColorBands` in `appSettings`)
|
||||||
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
|
- Notation in Protokollen: **führende Zahl** `1`–`10` am Zeilenanfang (`parseNotationPrefix`); `>` Zitat, `[…]` Abschnitte bleiben
|
||||||
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`).
|
- Meeting-Pflichtscore oft auf **Kategorie**-Ebene (`meetingCategoryRatings`); Kriterien bleiben Beobachtungs-/Detail-Ebene
|
||||||
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).
|
- 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`
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ Stand: 2026-08-06 · nur DEV (`AssigmentMonitorV2`), Produktiv unberührt.
|
||||||
| Farben | 4 Bänder, **konfigurierbar** unter Config (Defaults unten) |
|
| Farben | 4 Bänder, **konfigurierbar** unter Config (Defaults unten) |
|
||||||
| Labels | Nur Zahlen, keine Ankertexte |
|
| Labels | Nur Zahlen, keine Ankertexte |
|
||||||
| Altdaten | Keine Migration; frische Dev-DB (`npm run db:reset` bei Bedarf) |
|
| 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 |
|
| Struktur | In V2 über Starter/`npm run setup` und Config pflegbar (Stand 2026-08) |
|
||||||
| Typ-Strukturen | Prio 2 (später) |
|
| Typ-Strukturen | Sichtbarkeit über `criteriaIds` + `resolveVisibleStructure` (Cascade 1b) |
|
||||||
| Schnelle Zuordnung | `lineTags` / Tag-Picker / Vorschläge bleiben |
|
| Schnelle Zuordnung | `lineTags` / Tag-Picker / Vorschläge bleiben |
|
||||||
|
|
||||||
### Default-Farbbänder
|
### Default-Farbbänder
|
||||||
|
|
|
||||||
62
docs/SMOKE_TESTS.md
Normal file
62
docs/SMOKE_TESTS.md
Normal 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*`.
|
||||||
|
|
@ -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
|
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).
|
||||||
| Feature | Seite / Datei |
|
**Als Nächstes:** Meeting-Polish (UX.4); parallel optional T4.
|
||||||
|---|---|
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ Offene Punkte / Bugs
|
## Implementierungsstatus (Ist)
|
||||||
|
|
||||||
### Priorität Hoch
|
### Kernplattform
|
||||||
|
|
||||||
| # | Beschreibung | Datei | Aufwand |
|
| Feature | Status | Ort |
|
||||||
|---|---|---|---|
|
|
||||||
| 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 |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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 |
|
| React 19 + Vite PWA, Router 7, Tailwind v4 | ☑ | `package.json`, `src/` |
|
||||||
| `Assessment.criteriaId` | Referenziert `FeedbackCriterionItem.id`. Kein FK-Constraint in IndexedDB. | Beim Löschen von Items: zugehörige Assessments löschen |
|
| Express + `node:sqlite`, RPC `POST /api/rpc` | ☑ | `server/` |
|
||||||
| `ConversationSkillScore.criteriaId` | Referenziert `FeedbackCategory.id`. Gleiche Gefahr. | Beim Löschen von Kategorien: ConversationSkillScores bereinigen |
|
| 26 Tabellen via `tableRegistry` (Schema + Backup) | ☑ | `server/db/tableRegistry.ts` |
|
||||||
| `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 |
|
| Client-Queries 1:1 Server-Queries | ☑ | `src/db/queries/`, `server/db/queries/` |
|
||||||
| 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 |
|
| 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
114
docs/TEAM_SETUP.md
Normal 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 |
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
# Technische Architektur — Assignment Monitor
|
# 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
|
## Stack
|
||||||
|
|
||||||
| Schicht | Technologie | Version | Zweck |
|
| Schicht | Technologie | Version | Zweck |
|
||||||
|
|
|
||||||
8
docs/starter/README.md
Normal file
8
docs/starter/README.md
Normal 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).
|
||||||
1407
docs/starter/starter-structure.json
Normal file
1407
docs/starter/starter-structure.json
Normal file
File diff suppressed because it is too large
Load Diff
15
index.html
15
index.html
|
|
@ -2,9 +2,20 @@
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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, viewport-fit=cover" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Assignment Monitor</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
127
package-lock.json
generated
127
package-lock.json
generated
|
|
@ -1,20 +1,20 @@
|
||||||
{
|
{
|
||||||
"name": "assignment-monitor",
|
"name": "assignment-monitor-v2",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "assignment-monitor",
|
"name": "assignment-monitor-v2",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"dexie": "^4.0.11",
|
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
"recharts": "^2.15.3"
|
"recharts": "^2.15.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
|
@ -67,7 +67,6 @@
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
|
|
@ -3534,7 +3533,6 @@
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -3642,7 +3640,6 @@
|
||||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -3660,6 +3657,15 @@
|
||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
|
|
@ -3977,7 +3983,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.38",
|
"baseline-browser-mapping": "^2.10.38",
|
||||||
"caniuse-lite": "^1.0.30001799",
|
"caniuse-lite": "^1.0.30001799",
|
||||||
|
|
@ -4087,6 +4092,19 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -4137,6 +4155,15 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -4304,6 +4331,18 @@
|
||||||
"url": "https://opencollective.com/core-js"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -4616,12 +4655,6 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/dfa": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
|
@ -4949,7 +4982,6 @@
|
||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -5481,6 +5513,15 @@
|
||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -7616,7 +7656,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -8354,6 +8393,18 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|
@ -8628,7 +8679,6 @@
|
||||||
"integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==",
|
"integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.15.0",
|
"acorn": "^8.15.0",
|
||||||
|
|
@ -9572,7 +9622,6 @@
|
||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
@ -9811,6 +9860,24 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"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==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
|
|
@ -10101,6 +10167,27 @@
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"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": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -8,7 +8,13 @@
|
||||||
"server": "tsx watch --env-file=.env server/index.ts",
|
"server": "tsx watch --env-file=.env server/index.ts",
|
||||||
"dev:all": "concurrently -n web,api -c blue,green \"npm:dev\" \"npm:server\"",
|
"dev:all": "concurrently -n web,api -c blue,green \"npm:dev\" \"npm:server\"",
|
||||||
"verify-backup": "tsx --env-file=.env server/scripts/verifyBackupRoundtrip.ts",
|
"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",
|
"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",
|
"preload:feedback-structure": "tsx --env-file=.env server/scripts/preloadFeedbackStructure.ts",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|
@ -16,12 +22,12 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"dexie": "^4.0.11",
|
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
"recharts": "^2.15.3"
|
"recharts": "^2.15.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
|
|
||||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal 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
BIN
public/pwa-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
public/pwa-512.png
Normal file
BIN
public/pwa-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 828 KiB |
|
|
@ -88,24 +88,28 @@ export function saveDefaultDimensionPromptTemplate(template: string): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt-Template für „🏷 Vorschläge analysieren" (selbstlernendes Kriterien-Tagging) — ein
|
* Prompt-Template für Protokoll-Tagging („🏷 Vorschläge analysieren", Freitext-Zeilen).
|
||||||
* einziges, protokollweites Template statt eines pro Assignment-Typ, siehe CLAUDE.md. Gleiches
|
* Checklisten haben seit der Trennung ein eigenes Template (`checklistTaggingPromptTemplate`).
|
||||||
* Erstbefüllungs-/Persistenz-Muster wie `getOrSeedDefaultDimensionPromptTemplate`.
|
* 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 {
|
export function getOrSeedCriterionTaggingPromptTemplate(): string {
|
||||||
|
const fresh = AI_CONFIG.defaultCriterionTaggingPromptTemplate
|
||||||
let settings = getRowById<AppSettings>('appSettings', 1)
|
let settings = getRowById<AppSettings>('appSettings', 1)
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
insertRowWithId('appSettings', 1, {
|
insertRowWithId('appSettings', 1, {
|
||||||
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
|
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
|
||||||
criterionTaggingPromptTemplate: AI_CONFIG.defaultCriterionTaggingPromptTemplate,
|
criterionTaggingPromptTemplate: fresh,
|
||||||
})
|
})
|
||||||
return AI_CONFIG.defaultCriterionTaggingPromptTemplate
|
return fresh
|
||||||
}
|
}
|
||||||
if (!settings.criterionTaggingPromptTemplate) {
|
const stored = settings.criterionTaggingPromptTemplate
|
||||||
updateRow('appSettings', 1, { criterionTaggingPromptTemplate: AI_CONFIG.defaultCriterionTaggingPromptTemplate })
|
if (!stored || stored.includes('{{CHECKLISTEN_PUNKTE}}')) {
|
||||||
return AI_CONFIG.defaultCriterionTaggingPromptTemplate
|
updateRow('appSettings', 1, { criterionTaggingPromptTemplate: fresh })
|
||||||
|
return fresh
|
||||||
}
|
}
|
||||||
return settings.criterionTaggingPromptTemplate
|
return stored
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveCriterionTaggingPromptTemplate(template: string): number {
|
export function saveCriterionTaggingPromptTemplate(template: string): number {
|
||||||
|
|
@ -115,6 +119,32 @@ export function saveCriterionTaggingPromptTemplate(template: string): number {
|
||||||
return 1
|
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 {
|
export function getOrSeedDefaultCategoryPromptTemplate(): string {
|
||||||
let settings = getRowById<AppSettings>('appSettings', 1)
|
let settings = getRowById<AppSettings>('appSettings', 1)
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
|
|
@ -140,3 +170,29 @@ export function saveDefaultCategoryPromptTemplate(template: string): number {
|
||||||
})
|
})
|
||||||
return 1
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
527
server/db/queries/assignmentTransfer.ts
Normal file
527
server/db/queries/assignmentTransfer.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,8 @@ export function purgeExpiredAssignmentTrash(): void {
|
||||||
|
|
||||||
export interface AssignmentWithDetails extends Assignment {
|
export interface AssignmentWithDetails extends Assignment {
|
||||||
typeName: string
|
typeName: string
|
||||||
|
/** Hex-Farbe des Assignment-Typs (Fallback Brand). */
|
||||||
|
typeColor: string
|
||||||
instituteeNames: string[]
|
instituteeNames: string[]
|
||||||
/** Nur für status==='done' relevant: jedes Institutee hat ein finales Feedback — steuert den Löschen-Button in der Übersicht. */
|
/** Nur für status==='done' relevant: jedes Institutee hat ein finales Feedback — steuert den Löschen-Button in der Übersicht. */
|
||||||
feedbackComplete: boolean
|
feedbackComplete: boolean
|
||||||
|
|
@ -82,15 +84,19 @@ export function listAssignmentsWithDetails(): AssignmentWithDetails[] {
|
||||||
const allFeedbacks = getAllRows<AssignmentFeedback>('assignmentFeedbacks')
|
const allFeedbacks = getAllRows<AssignmentFeedback>('assignmentFeedbacks')
|
||||||
const typeMap = new Map<number, AssignmentType>(types.map(t => [t.id!, t]))
|
const typeMap = new Map<number, AssignmentType>(types.map(t => [t.id!, t]))
|
||||||
const consMap = new Map<number, Consultant>(consultants.map(c => [c.id!, c]))
|
const consMap = new Map<number, Consultant>(consultants.map(c => [c.id!, c]))
|
||||||
return raw.map(a => ({
|
return raw.map(a => {
|
||||||
...a,
|
const t = typeMap.get(a.assignmentTypeId)
|
||||||
typeName: typeMap.get(a.assignmentTypeId)?.name ?? '—',
|
return {
|
||||||
instituteeNames: a.instituteeIds.map(id => {
|
...a,
|
||||||
const c = consMap.get(id)
|
typeName: t?.name ?? '—',
|
||||||
return c ? `${c.firstName} ${c.lastName}` : '?'
|
typeColor: t?.color?.trim() || '#0070AD',
|
||||||
}),
|
instituteeNames: a.instituteeIds.map(id => {
|
||||||
feedbackComplete: a.instituteeIds.every(iid =>
|
const c = consMap.get(id)
|
||||||
allFeedbacks.some(f => f.assignmentId === a.id && f.instituteeId === iid && f.status === 'final'),
|
return c ? `${c.firstName} ${c.lastName}` : '?'
|
||||||
),
|
}),
|
||||||
}))
|
feedbackComplete: a.instituteeIds.every(iid =>
|
||||||
|
allFeedbacks.some(f => f.assignmentId === a.id && f.instituteeId === iid && f.status === 'final'),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,16 +48,23 @@ export function upsertChecklistResponse(input: {
|
||||||
checklistItemId: number
|
checklistItemId: number
|
||||||
rating: ChecklistResponse['rating']
|
rating: ChecklistResponse['rating']
|
||||||
note: string
|
note: string
|
||||||
criterionItemId?: number
|
criterionItemId?: number | null
|
||||||
|
criterionOverride?: boolean
|
||||||
}): number {
|
}): number {
|
||||||
const existing = getAllRows<ChecklistResponse>('checklistResponses').find(r =>
|
const existing = getAllRows<ChecklistResponse>('checklistResponses').find(r =>
|
||||||
r.meetingInstanceId === input.meetingInstanceId &&
|
r.meetingInstanceId === input.meetingInstanceId &&
|
||||||
r.checklistItemId === input.checklistItemId &&
|
r.checklistItemId === input.checklistItemId &&
|
||||||
(r.instituteeId ?? null) === (input.instituteeId ?? null),
|
(r.instituteeId ?? null) === (input.instituteeId ?? null),
|
||||||
)
|
)
|
||||||
|
const row = {
|
||||||
|
rating: input.rating,
|
||||||
|
note: input.note,
|
||||||
|
criterionItemId: input.criterionItemId,
|
||||||
|
criterionOverride: input.criterionOverride,
|
||||||
|
}
|
||||||
if (existing?.id) {
|
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 existing.id
|
||||||
}
|
}
|
||||||
return insertRow('checklistResponses', input)
|
return insertRow('checklistResponses', { ...input, ...row })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,266 @@
|
||||||
import { getAllRows, getRowById, getRowsWhereIn, insertRow, updateRow, deleteRowById } from '../crud'
|
import {
|
||||||
import type { Group, Consultant } from '../../../src/db/types'
|
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 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 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 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 getConsultantsByIds = (ids: number[]): Consultant[] =>
|
||||||
export const updateConsultant = (id: number, patch: Partial<Consultant>): number => updateRow('consultants', id, patch)
|
getRowsWhereIn<Consultant>('consultants', 'id', ids)
|
||||||
export const deleteConsultant = (id: number): void => deleteRowById('consultants', id)
|
|
||||||
|
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
165
server/db/starterPackage.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('name', 'TEXT'),
|
col('name', 'TEXT'),
|
||||||
col('defaultPhaseKeys', 'TEXT', 'json'),
|
col('defaultPhaseKeys', 'TEXT', 'json'),
|
||||||
col('criteriaIds', 'TEXT', 'json'),
|
col('criteriaIds', 'TEXT', 'json'),
|
||||||
|
col('color', 'TEXT'),
|
||||||
]},
|
]},
|
||||||
{ name: 'phaseTemplates', columns: [
|
{ name: 'phaseTemplates', columns: [
|
||||||
col('assignmentTypeId', 'INTEGER'),
|
col('assignmentTypeId', 'INTEGER'),
|
||||||
|
|
@ -61,6 +62,8 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('groupId', 'INTEGER'),
|
col('groupId', 'INTEGER'),
|
||||||
col('firstName', 'TEXT'),
|
col('firstName', 'TEXT'),
|
||||||
col('lastName', 'TEXT'),
|
col('lastName', 'TEXT'),
|
||||||
|
col('email', 'TEXT'),
|
||||||
|
col('archivedAt', 'TEXT'),
|
||||||
]},
|
]},
|
||||||
{ name: 'assignments', columns: [
|
{ name: 'assignments', columns: [
|
||||||
col('title', 'TEXT'),
|
col('title', 'TEXT'),
|
||||||
|
|
@ -75,6 +78,8 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('instituteeEnrollments', 'TEXT', 'json'),
|
col('instituteeEnrollments', 'TEXT', 'json'),
|
||||||
col('deletedAt', 'TEXT'),
|
col('deletedAt', 'TEXT'),
|
||||||
col('archivedAt', 'TEXT'),
|
col('archivedAt', 'TEXT'),
|
||||||
|
col('plannedStart', 'TEXT'),
|
||||||
|
col('plannedEnd', 'TEXT'),
|
||||||
]},
|
]},
|
||||||
{ name: 'meetingInstances', columns: [
|
{ name: 'meetingInstances', columns: [
|
||||||
col('assignmentId', 'INTEGER'),
|
col('assignmentId', 'INTEGER'),
|
||||||
|
|
@ -107,6 +112,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('score', 'INTEGER'),
|
col('score', 'INTEGER'),
|
||||||
col('note', 'TEXT'),
|
col('note', 'TEXT'),
|
||||||
col('updatedAt', 'TEXT'),
|
col('updatedAt', 'TEXT'),
|
||||||
|
col('scoreNeedsReview', 'INTEGER', 'bool'),
|
||||||
]},
|
]},
|
||||||
{ name: 'meetingCategoryRatings', columns: [
|
{ name: 'meetingCategoryRatings', columns: [
|
||||||
col('meetingInstanceId', 'INTEGER'),
|
col('meetingInstanceId', 'INTEGER'),
|
||||||
|
|
@ -185,7 +191,9 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('defaultDimensionPromptTemplate', 'TEXT'),
|
col('defaultDimensionPromptTemplate', 'TEXT'),
|
||||||
col('defaultCategoryPromptTemplate', 'TEXT'),
|
col('defaultCategoryPromptTemplate', 'TEXT'),
|
||||||
col('criterionTaggingPromptTemplate', 'TEXT'),
|
col('criterionTaggingPromptTemplate', 'TEXT'),
|
||||||
|
col('checklistTaggingPromptTemplate', 'TEXT'),
|
||||||
col('ratingColorBands', 'TEXT', 'json'),
|
col('ratingColorBands', 'TEXT', 'json'),
|
||||||
|
col('requirePlannedDates', 'INTEGER', 'bool'),
|
||||||
]},
|
]},
|
||||||
{ name: 'checklistItemDefs', columns: [
|
{ name: 'checklistItemDefs', columns: [
|
||||||
col('phaseTemplateId', 'INTEGER'),
|
col('phaseTemplateId', 'INTEGER'),
|
||||||
|
|
@ -193,6 +201,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('linkedCriterionItemId', 'INTEGER'),
|
col('linkedCriterionItemId', 'INTEGER'),
|
||||||
col('scope', 'TEXT'),
|
col('scope', 'TEXT'),
|
||||||
col('order', 'INTEGER'),
|
col('order', 'INTEGER'),
|
||||||
|
col('scoreContribution', 'TEXT'),
|
||||||
]},
|
]},
|
||||||
{ name: 'checklistResponses', columns: [
|
{ name: 'checklistResponses', columns: [
|
||||||
col('meetingInstanceId', 'INTEGER'),
|
col('meetingInstanceId', 'INTEGER'),
|
||||||
|
|
@ -201,6 +210,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('rating', 'INTEGER'),
|
col('rating', 'INTEGER'),
|
||||||
col('note', 'TEXT'),
|
col('note', 'TEXT'),
|
||||||
col('criterionItemId', 'INTEGER'),
|
col('criterionItemId', 'INTEGER'),
|
||||||
|
col('criterionOverride', 'INTEGER', 'bool'),
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
import './database'
|
import './database'
|
||||||
|
import { getAllRows } from './db/crud'
|
||||||
import { rpcModules } from './rpcModules'
|
import { rpcModules } from './rpcModules'
|
||||||
|
import {
|
||||||
|
STARTER_FILE,
|
||||||
|
readStarterPackage,
|
||||||
|
importStarterPackage,
|
||||||
|
summarizeStarter,
|
||||||
|
} from './db/starterPackage'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
app.use(express.json({ limit: '50mb' })) // Backup-Import kann alle Tabellen als ein JSON-Body enthalten
|
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, () => {
|
app.listen(port, () => {
|
||||||
console.log(`[server] DEV-RPC-API (AssignmentMonitorV2) auf http://localhost:${port}/api/rpc`)
|
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.`)
|
console.log(`[server] Produktiv (AssigmentMonitor) nutzt Port 4000 — diese Instanz ist davon getrennt.`)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import * as assessments from './db/queries/assessments'
|
||||||
import * as assignmentFeedback from './db/queries/assignmentFeedback'
|
import * as assignmentFeedback from './db/queries/assignmentFeedback'
|
||||||
import * as meetingCategoryRatings from './db/queries/meetingCategoryRatings'
|
import * as meetingCategoryRatings from './db/queries/meetingCategoryRatings'
|
||||||
import * as backup from './db/queries/backup'
|
import * as backup from './db/queries/backup'
|
||||||
|
import * as assignmentTransfer from './db/queries/assignmentTransfer'
|
||||||
|
|
||||||
const modules = {
|
const modules = {
|
||||||
appSettings,
|
appSettings,
|
||||||
|
|
@ -26,6 +27,7 @@ const modules = {
|
||||||
assignmentFeedback,
|
assignmentFeedback,
|
||||||
meetingCategoryRatings,
|
meetingCategoryRatings,
|
||||||
backup,
|
backup,
|
||||||
|
assignmentTransfer,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RpcModuleName = keyof typeof modules
|
export type RpcModuleName = keyof typeof modules
|
||||||
|
|
|
||||||
34
server/scripts/bootstrapFromStarter.ts
Normal file
34
server/scripts/bootstrapFromStarter.ts
Normal 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')
|
||||||
22
server/scripts/exportStarterPackage.ts
Normal file
22
server/scripts/exportStarterPackage.ts
Normal 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}`)
|
||||||
158
server/scripts/packLaptopZip.ts
Normal file
158
server/scripts/packLaptopZip.ts
Normal 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.')
|
||||||
120
server/scripts/packTeamZip.ts
Normal file
120
server/scripts/packTeamZip.ts
Normal 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.')
|
||||||
124
server/scripts/smokeScaleAndTagging.ts
Normal file
124
server/scripts/smokeScaleAndTagging.ts
Normal 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')
|
||||||
18
server/scripts/teamSetup.ts
Normal file
18
server/scripts/teamSetup.ts
Normal 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')
|
||||||
109
src/App.tsx
109
src/App.tsx
|
|
@ -1,5 +1,5 @@
|
||||||
import { Routes, Route, NavLink } from 'react-router-dom'
|
import { useEffect } from 'react'
|
||||||
import { BRAND_COLOR } from './config/constants'
|
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import Configuration from './pages/Configuration'
|
import Configuration from './pages/Configuration'
|
||||||
import Consultants from './pages/Consultants'
|
import Consultants from './pages/Consultants'
|
||||||
|
|
@ -13,14 +13,60 @@ import MeetingView from './pages/meeting/MeetingView'
|
||||||
import FeedbackDraftPage from './pages/FeedbackDraftPage'
|
import FeedbackDraftPage from './pages/FeedbackDraftPage'
|
||||||
import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage'
|
import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage'
|
||||||
import Evaluation from './pages/Evaluation'
|
import Evaluation from './pages/Evaluation'
|
||||||
|
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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
<div className="min-h-dvh bg-app flex flex-col font-sans text-ink">
|
||||||
<header className="text-white px-4 py-3 flex items-center gap-3 shadow" style={{ backgroundColor: BRAND_COLOR }}>
|
<header className="bg-brand text-white px-4 py-3 flex items-center gap-3 shrink-0 shadow-[var(--shadow-header)]">
|
||||||
<span className="font-bold text-lg tracking-tight">Assignment Monitor</span>
|
<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>
|
</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>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/assignment/new" element={<AssignmentCreate />} />
|
<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/meeting/:meetingId" element={<MeetingView />} />
|
||||||
<Route path="/assignment/:id/feedback/:instituteeId" element={<AssignmentFeedbackPage />} />
|
<Route path="/assignment/:id/feedback/:instituteeId" element={<AssignmentFeedbackPage />} />
|
||||||
<Route path="/assignment/:id/feedback-draft/:instituteeId" element={<FeedbackDraftPage />} />
|
<Route path="/assignment/:id/feedback-draft/:instituteeId" element={<FeedbackDraftPage />} />
|
||||||
<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" element={<Consultants />} />
|
||||||
|
<Route path="/consultants/:id" element={<ConsultantDetailPage />} />
|
||||||
<Route path="/config" element={<Configuration />} />
|
<Route path="/config" element={<Configuration />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<nav className="bg-white border-t border-gray-200 flex justify-around py-2 sticky bottom-0">
|
|
||||||
{[
|
<nav
|
||||||
{ to: '/', label: 'Home' },
|
className="fixed bottom-0 inset-x-0 z-20 bg-surface border-t border-line shadow-[var(--shadow-nav)]"
|
||||||
{ to: '/consultants', label: 'Berater' },
|
aria-label="Hauptnavigation"
|
||||||
{ to: '/evaluation', label: 'Auswertung' },
|
>
|
||||||
{ to: '/config', label: 'Config' },
|
<div className={`mx-auto flex justify-around px-1 pt-1 pb-[max(0.35rem,env(safe-area-inset-bottom))] ${
|
||||||
].map(({ to, label }) => (
|
wideLayout ? 'max-w-6xl' : 'max-w-2xl'
|
||||||
<NavLink
|
}`}>
|
||||||
key={to}
|
{NAV.map(({ to, label, end }) => (
|
||||||
to={to}
|
<NavLink
|
||||||
end
|
key={to}
|
||||||
className={({ isActive }) =>
|
to={to}
|
||||||
`text-xs px-3 py-1 rounded ${isActive ? 'font-semibold' : 'text-gray-500'}`
|
end={end}
|
||||||
}
|
className={({ isActive }) =>
|
||||||
style={({ isActive }) => isActive ? { color: BRAND_COLOR } : undefined}
|
[
|
||||||
>
|
'flex-1 min-h-11 max-w-[7.5rem] mx-0.5 flex items-center justify-center',
|
||||||
{label}
|
'rounded-[var(--radius-control)] px-2 text-[11px] sm:text-xs font-medium leading-tight text-center',
|
||||||
</NavLink>
|
'transition-colors',
|
||||||
))}
|
isActive
|
||||||
|
? 'bg-brand-soft text-brand font-semibold'
|
||||||
|
: 'text-ink-muted hover:text-ink hover:bg-app',
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,20 @@ import type { RatingBandKey, RatingColorBand, RatingScore } from '../db/types'
|
||||||
|
|
||||||
export const BRAND_COLOR = '#0070AD'
|
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_MIN = 1
|
||||||
export const RATING_MAX = 10
|
export const RATING_MAX = 10
|
||||||
|
|
||||||
|
|
@ -109,6 +123,19 @@ export const AI_CONFIG = {
|
||||||
localStorageKeyModel: 'ai_model',
|
localStorageKeyModel: 'ai_model',
|
||||||
defaultModel: 'openai/gpt-4o-mini',
|
defaultModel: 'openai/gpt-4o-mini',
|
||||||
openRouterBaseUrl: 'https://openrouter.ai/api/v1',
|
openRouterBaseUrl: 'https://openrouter.ai/api/v1',
|
||||||
|
/** 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`
|
* 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
|
* (siehe `getOrSeedDefaultDimensionPromptTemplate()` in server/db/queries/appSettings.ts) — der tatsächlich
|
||||||
|
|
@ -122,9 +149,13 @@ DIMENSION: {{DIMENSION_NAME}}
|
||||||
KATEGORIE: {{KATEGORIE_NAME}}
|
KATEGORIE: {{KATEGORIE_NAME}}
|
||||||
{{KATEGORIE_ERLAEUTERUNG}}
|
{{KATEGORIE_ERLAEUTERUNG}}
|
||||||
|
|
||||||
KRITERIEN & BEOBACHTUNGEN (nur diese Kategorie):
|
KRITERIEN & BEOBACHTUNGEN (nur diese Kategorie — chronologisch, mit Einzelbelegen):
|
||||||
{{KRITERIEN_SCORES}}
|
{{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}}
|
MANUELLE KATEGORIE-BEWERTUNG: {{KATEGORIE_BEWERTUNG}}
|
||||||
ENTWICKLUNG ÜBER MEETINGS: {{ENTWICKLUNG_VERLAUF}}
|
ENTWICKLUNG ÜBER MEETINGS: {{ENTWICKLUNG_VERLAUF}}
|
||||||
{{TREND_VORSCHLAG}}
|
{{TREND_VORSCHLAG}}
|
||||||
|
|
@ -172,7 +203,7 @@ DEVELOPMENT_NEEDS:
|
||||||
{ token: '{{DIMENSION_NAME}}', description: 'Name der übergeordneten Dimension' },
|
{ token: '{{DIMENSION_NAME}}', description: 'Name der übergeordneten Dimension' },
|
||||||
{ token: '{{KATEGORIE_NAME}}', description: 'Name der aktuellen Kategorie' },
|
{ token: '{{KATEGORIE_NAME}}', description: 'Name der aktuellen Kategorie' },
|
||||||
{ token: '{{KATEGORIE_ERLAEUTERUNG}}', description: 'Optional: Erläuterung der 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: '{{KATEGORIE_BEWERTUNG}}', description: 'Manuelle Kategorie-Bewertung (1–10)' },
|
||||||
{ token: '{{ENTWICKLUNG_VERLAUF}}', description: 'Meeting-Verlauf dieser Kategorie' },
|
{ token: '{{ENTWICKLUNG_VERLAUF}}', description: 'Meeting-Verlauf dieser Kategorie' },
|
||||||
{ token: '{{TREND_VORSCHLAG}}', description: 'Vorgeschlagener Score aus Meeting-Trend (±1 Regel)' },
|
{ 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: '{{DIMENSION_NAME}}', description: 'Name der aktuellen Dimension' },
|
||||||
{ token: '{{KATEGORIE_ERGEBNISSE}}', description: 'Scores + Benefits/Concerns aller Kategorien (Stufe A)' },
|
{ 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}}
|
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.
|
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>: <Kriterium-ID>, <Kriterium-ID>, ...
|
||||||
ZEILE <Nummer>: -
|
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: '{{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: '{{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: '{{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
|
} as const
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,2 @@
|
||||||
export * from './types'
|
export * from './types'
|
||||||
export { db } from './schema'
|
|
||||||
export * from './queries'
|
export * from './queries'
|
||||||
export {
|
|
||||||
seedIfEmpty,
|
|
||||||
seedFeedbackStructureIfEmpty,
|
|
||||||
seedCriterionMappingsIfEmpty,
|
|
||||||
migrateToUnifiedCriteriaIfNeeded,
|
|
||||||
} from './seeds'
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,17 @@ export const getOrSeedCriterionTaggingPromptTemplate = (): Promise<string> =>
|
||||||
export const saveCriterionTaggingPromptTemplate = (template: string): Promise<number> =>
|
export const saveCriterionTaggingPromptTemplate = (template: string): Promise<number> =>
|
||||||
rpc('appSettings', 'saveCriterionTaggingPromptTemplate', template)
|
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> =>
|
export const getOrSeedDefaultCategoryPromptTemplate = (): Promise<string> =>
|
||||||
rpc('appSettings', 'getOrSeedDefaultCategoryPromptTemplate')
|
rpc('appSettings', 'getOrSeedDefaultCategoryPromptTemplate')
|
||||||
export const saveDefaultCategoryPromptTemplate = (template: string): Promise<number> =>
|
export const saveDefaultCategoryPromptTemplate = (template: string): Promise<number> =>
|
||||||
rpc('appSettings', 'saveDefaultCategoryPromptTemplate', template)
|
rpc('appSettings', 'saveDefaultCategoryPromptTemplate', template)
|
||||||
|
|
||||||
|
export const getOrSeedRequirePlannedDates = (): Promise<boolean> =>
|
||||||
|
rpc('appSettings', 'getOrSeedRequirePlannedDates')
|
||||||
|
export const saveRequirePlannedDates = (required: boolean): Promise<number> =>
|
||||||
|
rpc('appSettings', 'saveRequirePlannedDates', required)
|
||||||
|
|
|
||||||
10
src/db/queries/assignmentTransfer.ts
Normal file
10
src/db/queries/assignmentTransfer.ts
Normal 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)
|
||||||
|
|
@ -20,6 +20,7 @@ export const hardDeleteAssignment = (id: number): Promise<void> => rpc('assignme
|
||||||
|
|
||||||
export interface AssignmentWithDetails extends Assignment {
|
export interface AssignmentWithDetails extends Assignment {
|
||||||
typeName: string
|
typeName: string
|
||||||
|
typeColor: string
|
||||||
instituteeNames: string[]
|
instituteeNames: string[]
|
||||||
/** Nur für status==='done' relevant: jedes Institutee hat ein finales Feedback — steuert den Löschen-Button in der Übersicht. */
|
/** Nur für status==='done' relevant: jedes Institutee hat ein finales Feedback — steuert den Löschen-Button in der Übersicht. */
|
||||||
feedbackComplete: boolean
|
feedbackComplete: boolean
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,6 @@ export const upsertChecklistResponse = (input: {
|
||||||
checklistItemId: number
|
checklistItemId: number
|
||||||
rating: ChecklistResponse['rating']
|
rating: ChecklistResponse['rating']
|
||||||
note: string
|
note: string
|
||||||
criterionItemId?: number
|
criterionItemId?: number | null
|
||||||
|
criterionOverride?: boolean
|
||||||
}): Promise<number> => rpc('checklists', 'upsertChecklistResponse', input)
|
}): Promise<number> => rpc('checklists', 'upsertChecklistResponse', input)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,50 @@
|
||||||
import { rpc } from '../rpcClient'
|
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 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 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 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 getConsultant = (id: number): Promise<Consultant | undefined> => rpc('consultants', 'getConsultant', id)
|
||||||
export const getConsultantsByIds = (ids: number[]): Promise<Consultant[]> => rpc('consultants', 'getConsultantsByIds', ids)
|
export const getConsultantsByIds = (ids: number[]): Promise<Consultant[]> =>
|
||||||
export const addConsultant = (consultant: Omit<Consultant, 'id'>): Promise<number> => rpc('consultants', 'addConsultant', consultant)
|
rpc('consultants', 'getConsultantsByIds', ids)
|
||||||
export const updateConsultant = (id: number, patch: Partial<Consultant>): Promise<number> => rpc('consultants', 'updateConsultant', id, patch)
|
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 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)
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,4 @@ export * from './conversationEntries'
|
||||||
export * from './assessments'
|
export * from './assessments'
|
||||||
export * from './assignmentFeedback'
|
export * from './assignmentFeedback'
|
||||||
export * from './meetingCategoryRatings'
|
export * from './meetingCategoryRatings'
|
||||||
|
export * from './assignmentTransfer'
|
||||||
|
|
|
||||||
162
src/db/schema.ts
162
src/db/schema.ts
|
|
@ -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()
|
|
||||||
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export { seedIfEmpty } from './assignmentTypes'
|
|
||||||
export { seedFeedbackStructureIfEmpty } from './feedbackStructure'
|
|
||||||
export { seedCriterionMappingsIfEmpty, migrateToUnifiedCriteriaIfNeeded } from './migrations'
|
|
||||||
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
src/db/types.ts
122
src/db/types.ts
|
|
@ -38,6 +38,8 @@ export interface AssignmentType {
|
||||||
name: string
|
name: string
|
||||||
defaultPhaseKeys: PhaseKey[]
|
defaultPhaseKeys: PhaseKey[]
|
||||||
criteriaIds?: number[] // FeedbackCriterionItem IDs; leer = alle anzeigen
|
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 {
|
export interface PhaseTemplate {
|
||||||
|
|
@ -66,11 +68,18 @@ export interface ChecklistItemDef {
|
||||||
id?: number
|
id?: number
|
||||||
phaseTemplateId: number
|
phaseTemplateId: number
|
||||||
label: string
|
label: string
|
||||||
linkedCriterionItemId?: number
|
linkedCriterionItemId?: number | null
|
||||||
scope: 'perInstitutee' | 'meetingWide'
|
scope: 'perInstitutee' | 'meetingWide'
|
||||||
order: number
|
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 auf einen Checklisten-Punkt für dieses Meeting. `criterionItemId` überschreibt pro
|
||||||
* Antwort individuell die Konfigurations-Vorgabe aus `ChecklistItemDef.linkedCriterionItemId`
|
* Antwort individuell die Konfigurations-Vorgabe aus `ChecklistItemDef.linkedCriterionItemId`
|
||||||
|
|
@ -85,7 +94,9 @@ export interface ChecklistResponse {
|
||||||
checklistItemId: number
|
checklistItemId: number
|
||||||
rating: RatingScore | null
|
rating: RatingScore | null
|
||||||
note: string
|
note: string
|
||||||
criterionItemId?: number
|
/** Gesetzt wenn im Meeting manuell zugeordnet oder explizit entfernt — sonst gilt Config-Standard. */
|
||||||
|
criterionItemId?: number | null
|
||||||
|
criterionOverride?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Stammdaten ───────────────────────────────────────────────────────────────
|
// ─── Stammdaten ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -101,6 +112,56 @@ export interface Consultant {
|
||||||
groupId: number
|
groupId: number
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: 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 ─────────────────────────────────────────────────────────────
|
// ─── Assignments ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -125,6 +186,10 @@ export interface Assignment {
|
||||||
deletedAt?: string
|
deletedAt?: string
|
||||||
/** Archiv-Zeitpunkt — dauerhafte Aufbewahrung (spätere Auswertung/ML), unabhängig vom Papierkorb. */
|
/** Archiv-Zeitpunkt — dauerhafte Aufbewahrung (spätere Auswertung/ML), unabhängig vom Papierkorb. */
|
||||||
archivedAt?: string
|
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 ────────────────────────────────────────────────────────
|
// ─── Meeting-Instanzen ────────────────────────────────────────────────────────
|
||||||
|
|
@ -179,6 +244,8 @@ export interface Assessment {
|
||||||
score: RatingScore | null
|
score: RatingScore | null
|
||||||
note: string
|
note: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Score aus Vorschlag übernommen — Hinweis, dass manuell gegengelesen werden sollte. */
|
||||||
|
scoreNeedsReview?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -303,8 +370,57 @@ export interface AppSettings {
|
||||||
defaultDimensionPromptTemplate?: string
|
defaultDimensionPromptTemplate?: string
|
||||||
/** Kategorie-Prompt Stufe A (1 Call pro Kategorie), editierbar in Config → KI-Prompts. */
|
/** Kategorie-Prompt Stufe A (1 Call pro Kategorie), editierbar in Config → KI-Prompts. */
|
||||||
defaultCategoryPromptTemplate?: string
|
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
|
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. */
|
/** Farbbänder der 1–10-Skala (Defaults in constants.ts), editierbar in Config → Skala. */
|
||||||
ratingColorBands?: RatingColorBand[]
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,44 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Design-Tokens (UX.1) — Capgemini-Fachtool.
|
||||||
|
* Seiten schrittweise auf bg-app / bg-surface / border-line / text-ink* umstellen.
|
||||||
|
*/
|
||||||
@theme {
|
@theme {
|
||||||
--color-brand: #0070AD;
|
--color-brand: #0070AD;
|
||||||
--color-brand-dark: #005a8e;
|
--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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
getOrSeedDefaultDimensionPromptTemplate, saveDefaultDimensionPromptTemplate,
|
getOrSeedDefaultDimensionPromptTemplate, saveDefaultDimensionPromptTemplate,
|
||||||
getOrSeedDefaultCategoryPromptTemplate, saveDefaultCategoryPromptTemplate,
|
getOrSeedDefaultCategoryPromptTemplate, saveDefaultCategoryPromptTemplate,
|
||||||
getOrSeedCriterionTaggingPromptTemplate, saveCriterionTaggingPromptTemplate,
|
getOrSeedCriterionTaggingPromptTemplate, saveCriterionTaggingPromptTemplate,
|
||||||
|
getOrSeedChecklistTaggingPromptTemplate, saveChecklistTaggingPromptTemplate,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
import { AI_CONFIG } from '../config/constants'
|
import { AI_CONFIG } from '../config/constants'
|
||||||
import { getAiSettings, saveAiSettings } from '../utils/aiClient'
|
import { getAiSettings, saveAiSettings } from '../utils/aiClient'
|
||||||
|
|
@ -22,27 +23,32 @@ export default function AiPromptConfig() {
|
||||||
const [taggingTemplate, setTaggingTemplate] = useState('')
|
const [taggingTemplate, setTaggingTemplate] = useState('')
|
||||||
const [taggingExpanded, setTaggingExpanded] = useState(false)
|
const [taggingExpanded, setTaggingExpanded] = useState(false)
|
||||||
const [taggingSaved, setTaggingSaved] = 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 [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||||
const [savedDimId, setSavedDimId] = useState<number | null>(null)
|
const [savedDimId, setSavedDimId] = useState<number | null>(null)
|
||||||
const [bulkResetMsg, setBulkResetMsg] = useState('')
|
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 [aiKey, setAiKey] = useState(getAiSettings().apiKey)
|
||||||
const [aiModel, setAiModel] = useState(getAiSettings().model)
|
const [aiModel, setAiModel] = useState(getAiSettings().model)
|
||||||
const [aiSaved, setAiSaved] = useState(false)
|
const [aiSaved, setAiSaved] = useState(false)
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [structure, promptRows, defTemplate, catTemplate, taggingTpl] = await Promise.all([
|
const [structure, promptRows, defTemplate, catTemplate, taggingTpl, checklistTaggingTpl] = await Promise.all([
|
||||||
loadFeedbackStructure(),
|
loadFeedbackStructure(),
|
||||||
listFeedbackDimensionPrompts(),
|
listFeedbackDimensionPrompts(),
|
||||||
getOrSeedDefaultDimensionPromptTemplate(),
|
getOrSeedDefaultDimensionPromptTemplate(),
|
||||||
getOrSeedDefaultCategoryPromptTemplate(),
|
getOrSeedDefaultCategoryPromptTemplate(),
|
||||||
getOrSeedCriterionTaggingPromptTemplate(),
|
getOrSeedCriterionTaggingPromptTemplate(),
|
||||||
|
getOrSeedChecklistTaggingPromptTemplate(),
|
||||||
])
|
])
|
||||||
setDimensions([...structure.dimensions].sort((a, b) => a.order - b.order))
|
setDimensions([...structure.dimensions].sort((a, b) => a.order - b.order))
|
||||||
setPrompts(promptRows)
|
setPrompts(promptRows)
|
||||||
setDefaultTemplate(defTemplate)
|
setDefaultTemplate(defTemplate)
|
||||||
setCategoryTemplate(catTemplate)
|
setCategoryTemplate(catTemplate)
|
||||||
setTaggingTemplate(taggingTpl)
|
setTaggingTemplate(taggingTpl)
|
||||||
|
setChecklistTaggingTemplate(checklistTaggingTpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
@ -100,6 +106,18 @@ export default function AiPromptConfig() {
|
||||||
}, 600))
|
}, 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) => {
|
const saveTaggingTemplate = (value: string) => {
|
||||||
setTaggingTemplate(value)
|
setTaggingTemplate(value)
|
||||||
const prevTimer = saveTimer.current.get('tagging')
|
const prevTimer = saveTimer.current.get('tagging')
|
||||||
|
|
@ -111,6 +129,17 @@ export default function AiPromptConfig() {
|
||||||
}, 600))
|
}, 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -240,10 +269,11 @@ export default function AiPromptConfig() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<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
|
„🏷 Vorschläge analysieren" im Meeting löst <strong>zwei getrennte KI-Calls</strong> aus: Protokoll-Freitext
|
||||||
Protokoll-Ansicht, nicht pro Zeile. Lernt aus bereits vergebenen Kriterium-Zuordnungen.
|
(Antwortformat <code className="font-mono text-xs">ZEILE n:</code>) und Checklistenpunkte
|
||||||
|
(<code className="font-mono text-xs">PUNKT n:</code>).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -251,23 +281,28 @@ export default function AiPromptConfig() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setTaggingExpanded(p => !p)}
|
onClick={() => setTaggingExpanded(p => !p)}
|
||||||
className="w-full flex items-center justify-between p-3 bg-indigo-50 border-b border-indigo-100 text-left">
|
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>
|
<span className="text-xs text-indigo-400">{taggingExpanded ? '▲' : '▼'}</span>
|
||||||
</button>
|
</button>
|
||||||
{taggingExpanded && (
|
{taggingExpanded && (
|
||||||
<div className="p-3 space-y-2">
|
<div className="p-3 space-y-2">
|
||||||
|
<p className="text-xs text-gray-500">Freitext-Zeilen aus Gesprächsprotokoll und allgemeinen Notizen.</p>
|
||||||
<textarea
|
<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}
|
value={taggingTemplate}
|
||||||
onChange={e => saveTaggingTemplate(e.target.value)}
|
onChange={e => saveTaggingTemplate(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{taggingSaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
|
{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>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-2">
|
<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">
|
<div className="grid gap-1.5">
|
||||||
{AI_CONFIG.criterionTaggingPromptPlaceholders.map(p => (
|
{AI_CONFIG.criterionTaggingPromptPlaceholders.map(p => (
|
||||||
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
|
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
|
||||||
|
|
@ -278,6 +313,45 @@ export default function AiPromptConfig() {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Dimensionen */}
|
||||||
{dimensions.map(dim => {
|
{dimensions.map(dim => {
|
||||||
const isOpen = expanded.has(dim.id!)
|
const isOpen = expanded.has(dim.id!)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
type AssignmentType, type PhaseTemplate, type Consultant, type Group,
|
type AssignmentType, type PhaseTemplate, type Consultant, type Group,
|
||||||
listAssignmentTypes, listPhaseTemplatesForType,
|
listAssignmentTypes, listPhaseTemplatesForType,
|
||||||
listGroups, listConsultants, addGroup, addConsultant,
|
listGroups, listActiveConsultants, addGroup, addConsultant,
|
||||||
addAssignment,
|
addAssignment, getOrSeedRequirePlannedDates,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
|
|
||||||
export default function AssignmentCreate() {
|
export default function AssignmentCreate() {
|
||||||
|
|
@ -21,21 +21,28 @@ export default function AssignmentCreate() {
|
||||||
assignmentTypeId: 0,
|
assignmentTypeId: 0,
|
||||||
selectedGroupId: 0,
|
selectedGroupId: 0,
|
||||||
instituteeIds: [] as number[],
|
instituteeIds: [] as number[],
|
||||||
|
plannedStart: '',
|
||||||
|
plannedEnd: '',
|
||||||
})
|
})
|
||||||
|
const [requirePlannedDates, setRequirePlannedDates] = useState(false)
|
||||||
// Inline Berater-Anlage
|
// Inline Berater-Anlage
|
||||||
const [showAddInstitutee, setShowAddInstitutee] = useState(false)
|
const [showAddInstitutee, setShowAddInstitutee] = useState(false)
|
||||||
const [newFirst, setNewFirst] = useState('')
|
const [newFirst, setNewFirst] = useState('')
|
||||||
const [newLast, setNewLast] = useState('')
|
const [newLast, setNewLast] = useState('')
|
||||||
|
const [newEmail, setNewEmail] = useState('')
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
const [t, g, c] = await Promise.all([
|
const [t, g, c, requireDates] = await Promise.all([
|
||||||
listAssignmentTypes(),
|
listAssignmentTypes(),
|
||||||
listGroups(),
|
listGroups(),
|
||||||
listConsultants(),
|
listActiveConsultants(),
|
||||||
|
getOrSeedRequirePlannedDates(),
|
||||||
])
|
])
|
||||||
setTypes(t)
|
setTypes(t)
|
||||||
setGroups(g)
|
setGroups(g)
|
||||||
setConsultants(c)
|
setConsultants(c)
|
||||||
|
setRequirePlannedDates(requireDates)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { reload() }, [])
|
useEffect(() => { reload() }, [])
|
||||||
|
|
@ -56,23 +63,34 @@ export default function AssignmentCreate() {
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const addInstituteeInline = async () => {
|
const addInstituteeInline = async () => {
|
||||||
if (!newFirst.trim() || !newLast.trim()) return
|
if (!newFirst.trim() || !newLast.trim() || !newEmail.trim()) return
|
||||||
let groupId = form.selectedGroupId
|
let groupId = form.selectedGroupId
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
// Erstelle eine Standardgruppe falls keine vorhanden
|
|
||||||
groupId = await addGroup({ name: 'Neue Gruppe', cohortYear: new Date().getFullYear() })
|
groupId = await addGroup({ name: 'Neue Gruppe', cohortYear: new Date().getFullYear() })
|
||||||
setForm(p => ({ ...p, selectedGroupId: groupId }))
|
setForm(p => ({ ...p, selectedGroupId: groupId }))
|
||||||
}
|
}
|
||||||
const id = await addConsultant({ groupId, firstName: newFirst.trim(), lastName: newLast.trim() })
|
try {
|
||||||
await reload()
|
setCreateError(null)
|
||||||
setForm(p => ({ ...p, instituteeIds: [...p.instituteeIds, id] }))
|
const id = await addConsultant({
|
||||||
setNewFirst('')
|
groupId,
|
||||||
setNewLast('')
|
firstName: newFirst.trim(),
|
||||||
setShowAddInstitutee(false)
|
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 () => {
|
const create = async () => {
|
||||||
if (!form.title.trim() || !form.client.trim() || !form.assignmentNumber.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return
|
if (!form.title.trim() || !form.client.trim() || !form.assignmentNumber.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return
|
||||||
|
if (requirePlannedDates && (!form.plannedStart || !form.plannedEnd)) return
|
||||||
const id = await addAssignment({
|
const id = await addAssignment({
|
||||||
title: form.title,
|
title: form.title,
|
||||||
client: form.client,
|
client: form.client,
|
||||||
|
|
@ -82,11 +100,20 @@ export default function AssignmentCreate() {
|
||||||
instituteeIds: form.instituteeIds,
|
instituteeIds: form.instituteeIds,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
plannedStart: form.plannedStart || undefined,
|
||||||
|
plannedEnd: form.plannedEnd || undefined,
|
||||||
})
|
})
|
||||||
navigate(`/assignment/${id}`)
|
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!))
|
const selectedInstitutees = consultants.filter(c => form.instituteeIds.includes(c.id!))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -113,6 +140,30 @@ export default function AssignmentCreate() {
|
||||||
<option value={0}>Assignment-Typ wählen…</option>
|
<option value={0}>Assignment-Typ wählen…</option>
|
||||||
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Phase-Preview */}
|
{/* Phase-Preview */}
|
||||||
|
|
@ -154,6 +205,11 @@ export default function AssignmentCreate() {
|
||||||
onChange={e => setNewLast(e.target.value)}
|
onChange={e => setNewLast(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && addInstituteeInline()} />
|
onKeyDown={e => e.key === 'Enter' && addInstituteeInline()} />
|
||||||
</div>
|
</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 && (
|
{groups.length > 0 && (
|
||||||
<select className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
|
<select className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
|
||||||
value={form.selectedGroupId || ''}
|
value={form.selectedGroupId || ''}
|
||||||
|
|
@ -164,11 +220,11 @@ export default function AssignmentCreate() {
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={addInstituteeInline}
|
<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">
|
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
|
Hinzufügen
|
||||||
</button>
|
</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">
|
className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
isAssignmentFeedbackComplete, softDeleteAssignment, archiveAssignment,
|
isAssignmentFeedbackComplete, softDeleteAssignment, archiveAssignment,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
|
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
|
||||||
|
import { downloadAssignmentTransfer } from '../utils/assignmentTransfer'
|
||||||
import { exportAssignmentReportPdf, exportInstituteeFeedbackPdf } from '../utils/pdfExport'
|
import { exportAssignmentReportPdf, exportInstituteeFeedbackPdf } from '../utils/pdfExport'
|
||||||
import { resolveScoreStyle } from '../config/constants'
|
import { resolveScoreStyle } from '../config/constants'
|
||||||
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
||||||
|
|
@ -48,6 +49,8 @@ export default function AssignmentDetail() {
|
||||||
const [confirmAssignmentTrash, setConfirmAssignmentTrash] = useState(false)
|
const [confirmAssignmentTrash, setConfirmAssignmentTrash] = useState(false)
|
||||||
const [confirmAssignmentArchive, setConfirmAssignmentArchive] = useState(false)
|
const [confirmAssignmentArchive, setConfirmAssignmentArchive] = useState(false)
|
||||||
const [pdfExportingKey, setPdfExportingKey] = useState<string | null>(null)
|
const [pdfExportingKey, setPdfExportingKey] = useState<string | null>(null)
|
||||||
|
const [transferExporting, setTransferExporting] = useState(false)
|
||||||
|
const [transferMsg, setTransferMsg] = useState('')
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const a = await getAssignment(assignmentId)
|
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 statusBadge = (status: MeetingInstance['status']) => {
|
||||||
const map = {
|
const map = {
|
||||||
planned: 'bg-yellow-100 text-yellow-700',
|
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>
|
<h1 className="text-xl font-bold text-gray-800 leading-tight">{assignment.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 flex gap-1.5">
|
<div className="flex-shrink-0 flex gap-1.5 flex-wrap justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/assignment/${assignmentId}/history`)}
|
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">
|
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">
|
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'}
|
{pdfExportingKey === 'report' ? '⏳' : '⬇ PDF'}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
|
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">
|
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">
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
{institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}
|
{institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}
|
||||||
</div>
|
</div>
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Assignment-Aktionen (nur für abgeschlossene Assignments) */}
|
{/* Assignment-Aktionen (nur für abgeschlossene Assignments) */}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ export default function AssignmentEdit() {
|
||||||
const [status, setStatus] = useState<'active' | 'done'>('active')
|
const [status, setStatus] = useState<'active' | 'done'>('active')
|
||||||
const [instituteeIds, setInstituteeIds] = useState<number[]>([])
|
const [instituteeIds, setInstituteeIds] = useState<number[]>([])
|
||||||
const [instituteeEnrollments, setInstituteeEnrollments] = useState<Array<{ instituteeId: number; from?: string; to?: string }>>([])
|
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 [typeChanged, setTypeChanged] = useState(false)
|
||||||
const [addInstituteeId, setAddInstituteeId] = useState(0)
|
const [addInstituteeId, setAddInstituteeId] = useState(0)
|
||||||
|
|
||||||
|
|
@ -48,6 +50,8 @@ export default function AssignmentEdit() {
|
||||||
setStatus(a.status)
|
setStatus(a.status)
|
||||||
setInstituteeIds(a.instituteeIds ?? [])
|
setInstituteeIds(a.instituteeIds ?? [])
|
||||||
setInstituteeEnrollments(a.instituteeEnrollments ?? [])
|
setInstituteeEnrollments(a.instituteeEnrollments ?? [])
|
||||||
|
setPlannedStart(a.plannedStart?.slice(0, 10) ?? '')
|
||||||
|
setPlannedEnd(a.plannedEnd?.slice(0, 10) ?? '')
|
||||||
}
|
}
|
||||||
loadData()
|
loadData()
|
||||||
}, [assignmentId])
|
}, [assignmentId])
|
||||||
|
|
@ -63,6 +67,8 @@ export default function AssignmentEdit() {
|
||||||
status,
|
status,
|
||||||
instituteeIds,
|
instituteeIds,
|
||||||
instituteeEnrollments,
|
instituteeEnrollments,
|
||||||
|
plannedStart: plannedStart || undefined,
|
||||||
|
plannedEnd: plannedEnd || undefined,
|
||||||
})
|
})
|
||||||
navigate(`/assignment/${assignmentId}`)
|
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>
|
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)}
|
onChange={e => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-gray-500">Assignment-Typ</label>
|
<label className="text-xs font-medium text-gray-500">Assignment-Typ</label>
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,14 @@ import {
|
||||||
type MeetingInstance,
|
type MeetingInstance,
|
||||||
type Assessment,
|
type Assessment,
|
||||||
type ConversationEntry,
|
type ConversationEntry,
|
||||||
|
type ChecklistItemDef,
|
||||||
|
type ChecklistResponse,
|
||||||
getAssignment, getConsultant, getAssignmentType,
|
getAssignment, getConsultant, getAssignmentType,
|
||||||
loadFeedbackStructure, resolveVisibleStructure,
|
loadFeedbackStructure, resolveVisibleStructure,
|
||||||
listDoneMeetingsChronological,
|
listDoneMeetingsChronological,
|
||||||
listAssessmentsForMeetings, listConversationEntriesForMeetings,
|
listAssessmentsForMeetings, listConversationEntriesForMeetings,
|
||||||
listMeetingCategoryRatingsForMeetings,
|
listMeetingCategoryRatingsForMeetings,
|
||||||
|
listChecklistItemDefsForPhaseTemplates, listChecklistResponsesForMeetings,
|
||||||
getRatingTrendWeightStep,
|
getRatingTrendWeightStep,
|
||||||
getOrCreateAssignmentFeedback, updateAssignmentFeedback,
|
getOrCreateAssignmentFeedback, updateAssignmentFeedback,
|
||||||
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, deleteFeedbackCategoryRating,
|
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, deleteFeedbackCategoryRating,
|
||||||
|
|
@ -325,9 +328,12 @@ export default function AssignmentFeedbackPage() {
|
||||||
getOrSeedDefaultDimensionPromptTemplate(),
|
getOrSeedDefaultDimensionPromptTemplate(),
|
||||||
])
|
])
|
||||||
const meetingIds = meetings.map(m => m.id!)
|
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),
|
listAssessmentsForMeetings(meetingIds),
|
||||||
listConversationEntriesForMeetings(meetingIds),
|
listConversationEntriesForMeetings(meetingIds),
|
||||||
|
listChecklistItemDefsForPhaseTemplates(phaseTemplateIds),
|
||||||
|
listChecklistResponsesForMeetings(meetingIds),
|
||||||
])
|
])
|
||||||
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
|
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
|
||||||
const instEntries = allEntries.filter(e => e.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,
|
institutee, assignment, dimension: dim, category: firstCat,
|
||||||
catItems: items.filter(i => i.categoryId === firstCat.id),
|
catItems: items.filter(i => i.categoryId === firstCat.id),
|
||||||
ratings, catTrend: catTrends.get(firstCat.id!), instAssessments, instEntries, meetings,
|
ratings, catTrend: catTrends.get(firstCat.id!), instAssessments, instEntries, meetings,
|
||||||
|
checklistItems, checklistResponses,
|
||||||
}))
|
}))
|
||||||
: '(keine Kategorien)'
|
: '(keine Kategorien)'
|
||||||
|
|
||||||
|
|
@ -389,6 +396,8 @@ export default function AssignmentFeedbackPage() {
|
||||||
meetings: MeetingInstance[],
|
meetings: MeetingInstance[],
|
||||||
instAssessments: Assessment[],
|
instAssessments: Assessment[],
|
||||||
instEntries: ConversationEntry[],
|
instEntries: ConversationEntry[],
|
||||||
|
checklistItems: ChecklistItemDef[],
|
||||||
|
checklistResponses: ChecklistResponse[],
|
||||||
categoryTemplate: string,
|
categoryTemplate: string,
|
||||||
settings: { apiKey: string; model: string },
|
settings: { apiKey: string; model: string },
|
||||||
): Promise<CategoryCondenseEntry> => {
|
): Promise<CategoryCondenseEntry> => {
|
||||||
|
|
@ -399,6 +408,7 @@ export default function AssignmentFeedbackPage() {
|
||||||
const context = buildCategoryPromptContext({
|
const context = buildCategoryPromptContext({
|
||||||
institutee, assignment, dimension: dim, category: cat, catItems,
|
institutee, assignment, dimension: dim, category: cat, catItems,
|
||||||
ratings, catTrend: catTrends.get(cat.id!), instAssessments, instEntries, meetings,
|
ratings, catTrend: catTrends.get(cat.id!), instAssessments, instEntries, meetings,
|
||||||
|
checklistItems, checklistResponses,
|
||||||
})
|
})
|
||||||
const prompt = renderPrompt(categoryTemplate, context)
|
const prompt = renderPrompt(categoryTemplate, context)
|
||||||
const text = await callOpenRouter(prompt, settings)
|
const text = await callOpenRouter(prompt, settings)
|
||||||
|
|
@ -452,6 +462,8 @@ export default function AssignmentFeedbackPage() {
|
||||||
meetings: MeetingInstance[],
|
meetings: MeetingInstance[],
|
||||||
instAssessments: Assessment[],
|
instAssessments: Assessment[],
|
||||||
instEntries: ConversationEntry[],
|
instEntries: ConversationEntry[],
|
||||||
|
checklistItems: ChecklistItemDef[],
|
||||||
|
checklistResponses: ChecklistResponse[],
|
||||||
categoryTemplate: string,
|
categoryTemplate: string,
|
||||||
settings: { apiKey: string; model: string },
|
settings: { apiKey: string; model: string },
|
||||||
) => {
|
) => {
|
||||||
|
|
@ -467,7 +479,7 @@ export default function AssignmentFeedbackPage() {
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const result = await generateOneCategory(
|
const result = await generateOneCategory(
|
||||||
dim, cat, meetings, instAssessments, instEntries, categoryTemplate, settings,
|
dim, cat, meetings, instAssessments, instEntries, checklistItems, checklistResponses, categoryTemplate, settings,
|
||||||
)
|
)
|
||||||
categoryResults.push(result)
|
categoryResults.push(result)
|
||||||
setCategoryErrors(prev => { const next = { ...prev }; delete next[errKey]; return next })
|
setCategoryErrors(prev => { const next = { ...prev }; delete next[errKey]; return next })
|
||||||
|
|
@ -503,9 +515,12 @@ export default function AssignmentFeedbackPage() {
|
||||||
getOrSeedDefaultCategoryPromptTemplate(),
|
getOrSeedDefaultCategoryPromptTemplate(),
|
||||||
])
|
])
|
||||||
const meetingIds = meetings.map(m => m.id!)
|
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),
|
listAssessmentsForMeetings(meetingIds),
|
||||||
listConversationEntriesForMeetings(meetingIds),
|
listConversationEntriesForMeetings(meetingIds),
|
||||||
|
listChecklistItemDefsForPhaseTemplates(phaseTemplateIds),
|
||||||
|
listChecklistResponsesForMeetings(meetingIds),
|
||||||
])
|
])
|
||||||
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
|
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
|
||||||
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
|
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
|
||||||
|
|
@ -514,7 +529,8 @@ export default function AssignmentFeedbackPage() {
|
||||||
const dim = dimsToRun[i]
|
const dim = dimsToRun[i]
|
||||||
try {
|
try {
|
||||||
await generateOneDimension(
|
await generateOneDimension(
|
||||||
dim, i, dimsToRun.length, meetings, instAssessments, instEntries, categoryTemplate, settings,
|
dim, i, dimsToRun.length, meetings, instAssessments, instEntries,
|
||||||
|
checklistItems, checklistResponses, categoryTemplate, settings,
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setDimensionErrors(prev => ({
|
setDimensionErrors(prev => ({
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,33 @@ import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
|
type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
|
||||||
type ChecklistItemDef,
|
type ChecklistItemDef,
|
||||||
listAssignmentTypes, listPhaseTemplates, loadFeedbackStructure,
|
listAssignmentTypes, listPhaseTemplates, loadFeedbackStructure, resolveVisibleStructure,
|
||||||
addAssignmentType, updateAssignmentType, deleteAssignmentType,
|
addAssignmentType, updateAssignmentType, deleteAssignmentType,
|
||||||
addPhaseTemplate, updatePhaseTemplate, deletePhaseTemplate,
|
addPhaseTemplate, updatePhaseTemplate, deletePhaseTemplate,
|
||||||
listChecklistItemDefsForPhaseTemplate, addChecklistItemDef, updateChecklistItemDef, deleteChecklistItemDef,
|
listChecklistItemDefsForPhaseTemplate, addChecklistItemDef, updateChecklistItemDef, deleteChecklistItemDef,
|
||||||
|
getGlobalLineTagCorpus, getOrSeedChecklistTaggingPromptTemplate,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
import { exportAssignmentTypesReportAsMarkdown } from '../utils/structureExport'
|
import { exportAssignmentTypesReportAsMarkdown } from '../utils/structureExport'
|
||||||
import { exportAssignmentTypesReportPdf } from '../utils/pdfExport'
|
import { exportAssignmentTypesReportPdf } from '../utils/pdfExport'
|
||||||
import { criterionOptionGroups } from '../utils/criterionOptions'
|
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() {
|
export default function AssignmentTypeConfig() {
|
||||||
const [exportingPdf, setExportingPdf] = useState(false)
|
const [exportingPdf, setExportingPdf] = useState(false)
|
||||||
|
|
@ -32,18 +51,28 @@ export default function AssignmentTypeConfig() {
|
||||||
const [newChecklistItem, setNewChecklistItem] = useState<Record<number, {
|
const [newChecklistItem, setNewChecklistItem] = useState<Record<number, {
|
||||||
label: string; scope: 'perInstitutee' | 'meetingWide'; linkedCriterionItemId: string
|
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 load = async () => {
|
||||||
const [t, tmpl, structure] = await Promise.all([
|
const [t, tmpl, structure, requireDates] = await Promise.all([
|
||||||
listAssignmentTypes(),
|
listAssignmentTypes(),
|
||||||
listPhaseTemplates(),
|
listPhaseTemplates(),
|
||||||
loadFeedbackStructure(),
|
loadFeedbackStructure(),
|
||||||
|
getOrSeedRequirePlannedDates(),
|
||||||
])
|
])
|
||||||
setTypes(t)
|
setTypes(t)
|
||||||
setTemplates(tmpl)
|
setTemplates(tmpl)
|
||||||
setDimensions(structure.dimensions)
|
setDimensions(structure.dimensions)
|
||||||
setCategories(structure.categories)
|
setCategories(structure.categories)
|
||||||
setItems(structure.items)
|
setItems(structure.items)
|
||||||
|
setRequirePlannedDates(requireDates)
|
||||||
const checklistLists = await Promise.all(tmpl.map(p => listChecklistItemDefsForPhaseTemplate(p.id!)))
|
const checklistLists = await Promise.all(tmpl.map(p => listChecklistItemDefsForPhaseTemplate(p.id!)))
|
||||||
setChecklistItems(checklistLists.flat())
|
setChecklistItems(checklistLists.flat())
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +84,9 @@ export default function AssignmentTypeConfig() {
|
||||||
const addType = async () => {
|
const addType = async () => {
|
||||||
const name = newTypeName.trim()
|
const name = newTypeName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const id = await 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('')
|
setNewTypeName('')
|
||||||
await load()
|
await load()
|
||||||
setExpanded(id)
|
setExpanded(id)
|
||||||
|
|
@ -66,6 +97,18 @@ export default function AssignmentTypeConfig() {
|
||||||
setTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t))
|
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 deleteType = async (id: number) => {
|
||||||
const result = await deleteAssignmentType(id)
|
const result = await deleteAssignmentType(id)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|
@ -163,7 +206,12 @@ export default function AssignmentTypeConfig() {
|
||||||
|
|
||||||
const updateChecklistItem = async (id: number, patch: Partial<ChecklistItemDef>) => {
|
const updateChecklistItem = async (id: number, patch: Partial<ChecklistItemDef>) => {
|
||||||
await updateChecklistItemDef(id, patch)
|
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) => {
|
const deleteChecklistItem = async (id: number) => {
|
||||||
|
|
@ -171,6 +219,79 @@ export default function AssignmentTypeConfig() {
|
||||||
setChecklistItems(prev => prev.filter(c => c.id !== id))
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
|
@ -189,6 +310,26 @@ export default function AssignmentTypeConfig() {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Neuer Typ */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<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">
|
<div key={type.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 px-4 py-3 bg-gray-50 border-b border-gray-100">
|
<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
|
<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"
|
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}
|
value={type.name}
|
||||||
|
|
@ -231,6 +377,33 @@ export default function AssignmentTypeConfig() {
|
||||||
className="text-xs text-red-300 hover:text-red-500 px-1">🗑</button>
|
className="text-xs text-red-300 hover:text-red-500 px-1">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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!] && (
|
{typeError[type.id!] && (
|
||||||
<div className="px-4 py-2 text-xs text-red-600 bg-red-50 border-b border-red-100">
|
<div className="px-4 py-2 text-xs text-red-600 bg-red-50 border-b border-red-100">
|
||||||
{typeError[type.id!]}
|
{typeError[type.id!]}
|
||||||
|
|
@ -317,7 +490,8 @@ export default function AssignmentTypeConfig() {
|
||||||
{isChecklistOpen && (
|
{isChecklistOpen && (
|
||||||
<div className="mt-1.5 space-y-1.5">
|
<div className="mt-1.5 space-y-1.5">
|
||||||
{phaseChecklist.map(ci => (
|
{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
|
<input
|
||||||
className="flex-1 text-xs bg-transparent border-b border-transparent focus:border-brand focus:outline-none"
|
className="flex-1 text-xs bg-transparent border-b border-transparent focus:border-brand focus:outline-none"
|
||||||
value={ci.label}
|
value={ci.label}
|
||||||
|
|
@ -332,13 +506,57 @@ export default function AssignmentTypeConfig() {
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={ci.linkedCriterionItemId ?? ''}
|
value={ci.linkedCriterionItemId ?? ''}
|
||||||
onChange={e => updateChecklistItem(ci.id!, { linkedCriterionItemId: e.target.value ? Number(e.target.value) : undefined })}
|
onChange={e => updateChecklistItem(ci.id!, {
|
||||||
className="text-xs border border-gray-200 rounded px-1 py-0.5 bg-white focus:outline-none max-w-[140px]">
|
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>
|
<option value="">— frei —</option>
|
||||||
{criterionOptionGroups(dimensions, categories, items)}
|
{criterionOptionGroups(dimensions, categories, items)}
|
||||||
</select>
|
</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!)}
|
<button onClick={() => deleteChecklistItem(ci.id!)}
|
||||||
className="text-red-300 hover:text-red-500 text-xs">×</button>
|
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>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|
@ -366,8 +584,35 @@ export default function AssignmentTypeConfig() {
|
||||||
<button onClick={() => addChecklistItem(phase.id!)}
|
<button onClick={() => addChecklistItem(phase.id!)}
|
||||||
className="text-xs bg-brand text-white px-2 py-1 rounded-lg font-medium">+ Punkt</button>
|
className="text-xs bg-brand text-white px-2 py-1 rounded-lg font-medium">+ Punkt</button>
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export default function Configuration() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-bold text-gray-800">Konfiguration</h1>
|
<h1 className="text-xl font-bold text-gray-800">Einstellungen</h1>
|
||||||
|
|
||||||
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5 flex-wrap">
|
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5 flex-wrap">
|
||||||
<button onClick={() => setTab('feedback')}
|
<button onClick={() => setTab('feedback')}
|
||||||
|
|
|
||||||
262
src/pages/ConsultantDetail.tsx
Normal file
262
src/pages/ConsultantDetail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,137 +1,465 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
type Consultant, type Group,
|
type Consultant, type Group, type ConsultantImportResult,
|
||||||
listGroups, listConsultants, addGroup as addGroupQuery, addConsultant as addConsultantQuery,
|
listGroups, listConsultants,
|
||||||
updateConsultant as updateConsultantQuery, deleteConsultant as deleteConsultantQuery,
|
addGroup as addGroupQuery, updateGroup, deleteGroup,
|
||||||
|
addConsultant as addConsultantQuery, updateConsultant,
|
||||||
|
archiveConsultant, hardDeleteConsultant,
|
||||||
|
importConsultantsToGroup,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
|
import { parseConsultantExcelFile } from '../utils/consultantImport'
|
||||||
|
|
||||||
|
type ViewMode = 'active' | 'archived' | 'all'
|
||||||
|
|
||||||
export default function Consultants() {
|
export default function Consultants() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [groups, setGroups] = useState<Group[]>([])
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
const [consultants, setConsultants] = useState<Consultant[]>([])
|
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 [newGroup, setNewGroup] = useState({ name: '', cohortYear: new Date().getFullYear() })
|
||||||
const [newCons, setNewCons] = useState({ groupId: 0, firstName: '', lastName: '' })
|
const [newCons, setNewCons] = useState({
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
groupId: 0, firstName: '', lastName: '', email: '',
|
||||||
const [editValues, setEditValues] = useState({ firstName: '', lastName: '' })
|
})
|
||||||
|
|
||||||
|
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 () => {
|
const load = async () => {
|
||||||
setGroups(await listGroups())
|
const [g, c] = await Promise.all([listGroups(), listConsultants()])
|
||||||
setConsultants(await 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() }, [])
|
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 () => {
|
const addGroup = async () => {
|
||||||
if (!newGroup.name.trim()) return
|
if (!newGroup.name.trim()) return
|
||||||
await addGroupQuery({ ...newGroup })
|
const id = await addGroupQuery({ ...newGroup, name: newGroup.name.trim() })
|
||||||
setNewGroup({ name: '', cohortYear: new Date().getFullYear() })
|
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 () => {
|
const addConsultant = async () => {
|
||||||
if (!newCons.firstName.trim() || !newCons.lastName.trim() || !newCons.groupId) return
|
if (!newCons.firstName.trim() || !newCons.lastName.trim() || !newCons.email.trim() || !newCons.groupId) {
|
||||||
await addConsultantQuery({ ...newCons })
|
flash('Vorname, Nachname, E-Mail und Gruppe sind Pflicht.', true)
|
||||||
setNewCons(p => ({ ...p, firstName: '', lastName: '' }))
|
return
|
||||||
load()
|
}
|
||||||
|
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) => {
|
const movePerson = async (id: number, groupId: number) => {
|
||||||
await deleteConsultantQuery(id)
|
await updateConsultant(id, { groupId })
|
||||||
load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEdit = (c: Consultant) => {
|
const archivePerson = async (id: number) => {
|
||||||
setEditingId(c.id!)
|
await archiveConsultant(id)
|
||||||
setEditValues({ firstName: c.firstName, lastName: c.lastName })
|
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 () => {
|
const ensureImportGroup = async (): Promise<number | null> => {
|
||||||
if (!editValues.firstName.trim() || !editValues.lastName.trim() || editingId === null) return
|
if (importGroupId) return importGroupId
|
||||||
await updateConsultantQuery(editingId, { ...editValues })
|
if (!newImportGroupName.trim()) {
|
||||||
setEditingId(null)
|
flash('Bitte Zielgruppe wählen oder neu anlegen.', true)
|
||||||
load()
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-bold text-gray-800">Berater</h1>
|
<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">
|
{msg && (
|
||||||
<h2 className="font-semibold text-gray-700">Neue Gruppe / Kohorte</h2>
|
<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">
|
<div className="flex gap-2">
|
||||||
<input
|
<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)"
|
placeholder="Gruppenname (z.B. MC Kohorte 2025)"
|
||||||
value={newGroup.name}
|
value={newGroup.name}
|
||||||
onChange={e => setNewGroup(p => ({ ...p, name: e.target.value }))}
|
onChange={e => setNewGroup(p => ({ ...p, name: e.target.value }))}
|
||||||
onKeyDown={e => e.key === 'Enter' && addGroup()}
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
|
{/* Neue Person */}
|
||||||
<h2 className="font-semibold text-gray-700">Neuer Berater</h2>
|
<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
|
<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}
|
value={newCons.groupId}
|
||||||
onChange={e => setNewCons(p => ({ ...p, groupId: +e.target.value }))}
|
onChange={e => setNewCons(p => ({ ...p, groupId: +e.target.value }))}
|
||||||
>
|
>
|
||||||
<option value={0}>Gruppe wählen…</option>
|
<option value={0}>Gruppe wählen…</option>
|
||||||
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<div className="flex gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<input className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm" placeholder="Vorname"
|
<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 }))} />
|
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 }))} />
|
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>
|
</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>
|
</section>
|
||||||
|
|
||||||
|
{/* Gruppen + Mitglieder */}
|
||||||
{groups.map(g => {
|
{groups.map(g => {
|
||||||
const members = consultants.filter(c => c.groupId === g.id)
|
const members = filteredByView.filter(c => c.groupId === g.id && matchesQuery(c))
|
||||||
if (members.length === 0) return null
|
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 (
|
return (
|
||||||
<div key={g.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
<div key={g.id} className="bg-surface rounded-xl border border-line overflow-hidden">
|
||||||
<h3 className="font-semibold text-gray-700 mb-2">{g.name}</h3>
|
<div className="flex flex-wrap items-center gap-2 px-4 py-3 bg-app border-b border-line">
|
||||||
{members.map(c => (
|
{renamingGroupId === g.id ? (
|
||||||
<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 ? (
|
<input
|
||||||
<>
|
className="flex-1 border border-line rounded-lg px-2 py-1 text-sm"
|
||||||
<div className="flex gap-2 flex-1">
|
value={renameValue}
|
||||||
<input
|
onChange={e => setRenameValue(e.target.value)}
|
||||||
className="flex-1 border border-gray-200 rounded-lg px-2 py-1 text-sm"
|
onKeyDown={e => e.key === 'Enter' && saveRename(g.id!)}
|
||||||
value={editValues.firstName}
|
autoFocus
|
||||||
onChange={e => setEditValues(p => ({ ...p, firstName: e.target.value }))}
|
/>
|
||||||
onKeyDown={e => e.key === 'Enter' && saveEdit()}
|
<button type="button" onClick={() => saveRename(g.id!)} className="text-xs text-brand font-medium">OK</button>
|
||||||
autoFocus
|
<button type="button" onClick={() => setRenamingGroupId(null)} className="text-xs text-ink-muted">Abbruch</button>
|
||||||
/>
|
</>
|
||||||
<input
|
) : (
|
||||||
className="flex-1 border border-gray-200 rounded-lg px-2 py-1 text-sm"
|
<>
|
||||||
value={editValues.lastName}
|
<h3 className="font-semibold text-ink flex-1 min-w-0 truncate">{g.name}</h3>
|
||||||
onChange={e => setEditValues(p => ({ ...p, lastName: e.target.value }))}
|
<span className="text-[11px] text-ink-muted">{allInGroup.length} Pers. · {g.cohortYear}</span>
|
||||||
onKeyDown={e => e.key === 'Enter' && saveEdit()}
|
<button
|
||||||
/>
|
type="button"
|
||||||
</div>
|
className="text-xs text-ink-muted hover:text-brand"
|
||||||
<button onClick={saveEdit} className="text-brand text-xs hover:underline">Speichern</button>
|
onClick={() => { setRenamingGroupId(g.id!); setRenameValue(g.name) }}
|
||||||
<button onClick={cancelEdit} className="text-gray-400 text-xs hover:text-gray-600">Abbrechen</button>
|
>
|
||||||
</>
|
Umbenennen
|
||||||
) : (
|
</button>
|
||||||
<>
|
<button
|
||||||
<span>{c.firstName} {c.lastName}</span>
|
type="button"
|
||||||
<div className="flex gap-2">
|
className="text-xs text-red-500 hover:text-red-700"
|
||||||
<button onClick={() => startEdit(c)} className="text-gray-400 text-xs hover:text-brand">Bearbeiten</button>
|
onClick={() => setConfirmDeleteGroupId(confirmDeleteGroupId === g.id ? null : g.id!)}
|
||||||
<button onClick={() => deleteConsultant(c.id!)} className="text-red-400 text-xs hover:text-red-600">×</button>
|
>
|
||||||
</div>
|
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>
|
</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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,93 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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() {
|
export default function Dashboard() {
|
||||||
const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
|
const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
|
||||||
const [query, setQuery] = useState('')
|
const [types, setTypes] = useState<AssignmentType[]>([])
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'done'>('active')
|
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 [confirmTrashId, setConfirmTrashId] = useState<number | null>(null)
|
||||||
const [confirmArchiveId, setConfirmArchiveId] = 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 navigate = useNavigate()
|
||||||
|
|
||||||
const load = () => { listAssignmentsWithDetails().then(setAssignments) }
|
const load = () => {
|
||||||
|
Promise.all([listAssignmentsWithDetails(), listAssignmentTypes()]).then(([a, t]) => {
|
||||||
|
setAssignments(a)
|
||||||
|
setTypes(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
useEffect(() => { load() }, [])
|
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) => {
|
const trashAssignment = async (id: number) => {
|
||||||
await softDeleteAssignment(id)
|
await softDeleteAssignment(id)
|
||||||
setConfirmTrashId(null)
|
setConfirmTrashId(null)
|
||||||
|
|
@ -25,145 +100,312 @@ export default function Dashboard() {
|
||||||
load()
|
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 trashCount = useMemo(() => assignments.filter(a => a.deletedAt).length, [assignments])
|
||||||
const archiveCount = useMemo(() => assignments.filter(a => a.archivedAt).length, [assignments])
|
const archiveCount = useMemo(() => assignments.filter(a => a.archivedAt).length, [assignments])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
|
const dateFilterActive = !!(dateFrom || dateTo)
|
||||||
|
|
||||||
return assignments.filter(a => {
|
return assignments.filter(a => {
|
||||||
if (a.deletedAt || a.archivedAt) return false
|
if (a.deletedAt || a.archivedAt) return false
|
||||||
if (statusFilter !== 'all' && a.status !== statusFilter) 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
|
if (!q) return true
|
||||||
return (
|
return (
|
||||||
a.title.toLowerCase().includes(q) ||
|
a.title.toLowerCase().includes(q) ||
|
||||||
(a.assignmentNumber ?? '').toLowerCase().includes(q) ||
|
(a.assignmentNumber ?? '').toLowerCase().includes(q) ||
|
||||||
a.client.toLowerCase().includes(q) ||
|
a.client.toLowerCase().includes(q) ||
|
||||||
|
a.typeName.toLowerCase().includes(q) ||
|
||||||
a.instituteeNames.some(n => n.toLowerCase().includes(q))
|
a.instituteeNames.some(n => n.toLowerCase().includes(q))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [assignments, query, statusFilter])
|
}, [assignments, query, statusFilter, hiddenTypeIds, dateFrom, dateTo, quickFilter])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<h1 className="text-xl font-bold text-ink">Assignments</h1>
|
||||||
<h1 className="text-xl font-bold text-gray-800">Assignments</h1>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/assignments/trash')}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/assignments/archive')}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/assignment/new')}
|
onClick={() => navigate('/assignment/new')}
|
||||||
className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium"
|
className="bg-brand text-white px-4 py-2 rounded-[var(--radius-control)] text-sm font-medium"
|
||||||
>
|
>
|
||||||
+ Neues
|
+ Neues
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Suche nach Nummer, Titel, Kunde, Berater…"
|
placeholder="Suche nach Nummer, Titel, Kunde, Typ, Berater…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
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]) => (
|
{([['all', 'Alle'], ['active', 'Aktiv'], ['done', 'Abgeschlossen']] as const).map(([val, label]) => (
|
||||||
<button key={val} onClick={() => setStatusFilter(val)}
|
<button key={val} onClick={() => setStatusFilter(val)}
|
||||||
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition ${
|
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}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Liste */}
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="text-gray-400 text-sm text-center py-8">
|
<p className="text-ink-muted text-sm text-center py-8">
|
||||||
{assignments.length === 0 ? 'Noch keine Assignments angelegt.' : 'Keine Treffer.'}
|
{assignments.filter(a => !a.deletedAt && !a.archivedAt).length === 0
|
||||||
|
? 'Noch keine Assignments angelegt.'
|
||||||
|
: 'Keine Treffer.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filtered.map(a => (
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||||
<div
|
{filtered.map(a => {
|
||||||
key={a.id}
|
const color = a.typeColor?.trim() || DEFAULT_ASSIGNMENT_TYPE_COLOR
|
||||||
onClick={() => navigate(`/assignment/${a.id}`)}
|
const plan = formatPlanRange(a.plannedStart, a.plannedEnd)
|
||||||
className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 cursor-pointer hover:shadow-md transition"
|
const signals = assignmentSignals(a)
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
return (
|
||||||
<div className="flex-1 min-w-0">
|
<div
|
||||||
{a.assignmentNumber && (
|
key={a.id}
|
||||||
<div className="text-xs font-semibold text-brand uppercase tracking-wide leading-none mb-0.5">
|
onClick={() => navigate(`/assignment/${a.id}`)}
|
||||||
{a.assignmentNumber}
|
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="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 truncate">
|
||||||
|
{a.assignmentNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="font-semibold text-ink truncate">{a.title}</div>
|
||||||
|
<div className="text-sm text-ink-muted truncate">{a.client}</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
)}
|
|
||||||
<div className="font-semibold text-gray-800 truncate">{a.title}</div>
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<div className="text-sm text-gray-500">{a.client}</div>
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||||
<div className="text-xs text-gray-400 mt-0.5">
|
<span className="text-xs font-medium text-ink truncate">{a.typeName}</span>
|
||||||
{a.typeName} · {a.instituteeNames.join(', ')}
|
</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="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-app text-ink-muted cursor-not-allowed'
|
||||||
|
}`}>
|
||||||
|
Papierkorb
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setConfirmArchiveId(confirmArchiveId === a.id ? null : a.id!); setConfirmTrashId(null) }}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-lg font-medium bg-app text-ink-muted border border-line hover:bg-surface">
|
||||||
|
Archivieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmTrashId === a.id && (
|
||||||
|
<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-line text-ink-muted px-2.5 py-1 rounded-lg">Nein</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmArchiveId === a.id && (
|
||||||
|
<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-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>
|
||||||
<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'
|
})}
|
||||||
}`}>
|
</div>
|
||||||
{a.status === 'active' ? 'Aktiv' : 'Fertig'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{a.status === 'done' && (
|
|
||||||
<div className="mt-2 pt-2 border-t border-gray-100 flex gap-2" onClick={e => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
onClick={() => { setConfirmTrashId(confirmTrashId === a.id ? null : a.id!); setConfirmArchiveId(null) }}
|
|
||||||
disabled={!a.feedbackComplete}
|
|
||||||
title={!a.feedbackComplete ? 'Erst möglich, wenn für alle Institutees ein finales Feedback vorliegt' : undefined}
|
|
||||||
className={`text-xs px-2.5 py-1 rounded-lg font-medium ${
|
|
||||||
a.feedbackComplete ? 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
||||||
}`}>
|
|
||||||
🗑 Papierkorb
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setConfirmArchiveId(confirmArchiveId === a.id ? null : a.id!); setConfirmTrashId(null) }}
|
|
||||||
className="text-xs px-2.5 py-1 rounded-lg font-medium bg-gray-50 text-gray-600 border border-gray-200 hover:bg-gray-100">
|
|
||||||
🗄 Archivieren
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{confirmTrashId === a.id && (
|
|
||||||
<div className="mt-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
|
|
||||||
<span className="text-xs text-red-700">In den Papierkorb verschieben?</span>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<button onClick={() => trashAssignment(a.id!)} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
|
|
||||||
<button onClick={() => setConfirmTrashId(null)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{confirmArchiveId === a.id && (
|
|
||||||
<div className="mt-2 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
|
|
||||||
<span className="text-xs text-gray-600">Archivieren?</span>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<button onClick={() => archiveThisAssignment(a.id!)} className="text-xs bg-gray-700 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
|
|
||||||
<button onClick={() => setConfirmArchiveId(null)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ export default function Evaluation() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 && (
|
{data.length === 0 && (
|
||||||
<p className="text-gray-500 text-sm">Noch keine Bewertungen vorhanden.</p>
|
<p className="text-gray-500 text-sm">Noch keine Bewertungen vorhanden.</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { INST_COLORS, resolveScoreStyle } from '../config/constants'
|
||||||
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
||||||
import { useRatingColorBands } from '../hooks/useRatingColorBands'
|
import { useRatingColorBands } from '../hooks/useRatingColorBands'
|
||||||
import { exportAssignmentReportPdf } from '../utils/pdfExport'
|
import { exportAssignmentReportPdf } from '../utils/pdfExport'
|
||||||
|
import { getEffectiveChecklistCriterionId } from '../utils/checklistCriterion'
|
||||||
import type { MeetingCategoryRating } from '../db'
|
import type { MeetingCategoryRating } from '../db'
|
||||||
|
|
||||||
export default function MeetingHistory() {
|
export default function MeetingHistory() {
|
||||||
|
|
@ -210,7 +211,7 @@ export default function MeetingHistory() {
|
||||||
return targets.map(instituteeId => {
|
return targets.map(instituteeId => {
|
||||||
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
|
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
|
||||||
if (!response) return null
|
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 critName = effectiveCritId ? fbItems.find(i => i.id === effectiveCritId)?.name : null
|
||||||
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
|
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ import { useState } from 'react'
|
||||||
import type {
|
import type {
|
||||||
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
||||||
Assessment, ConversationEntry, ConversationLineTag, RatingScore, RatingColorBand,
|
Assessment, ConversationEntry, ConversationLineTag, RatingScore, RatingColorBand,
|
||||||
|
ChecklistItemDef, ChecklistResponse,
|
||||||
} from '../../db'
|
} from '../../db'
|
||||||
import {
|
import {
|
||||||
INST_COLORS, DEFAULT_RATING_COLOR_BANDS, isValidRatingScore, resolveScoreStyle,
|
INST_COLORS, DEFAULT_RATING_COLOR_BANDS, isValidRatingScore, normalizeRatingScore, resolveScoreStyle,
|
||||||
} from '../../config/constants'
|
} from '../../config/constants'
|
||||||
import { ScorePicker } from '../../components/ScorePicker'
|
import { ScorePicker } from '../../components/ScorePicker'
|
||||||
import { detectNotationRating } from '../../utils/notationParser'
|
import { weightedAverageRating } from '../../utils/ratingTrend'
|
||||||
import { numberToRating, weightedAverageRating } from '../../utils/ratingTrend'
|
import { suggestCriterionScoreForInstitutee } from '../../utils/criterionScoreSuggestion'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
institutees: Consultant[]
|
institutees: Consultant[]
|
||||||
|
|
@ -18,13 +19,22 @@ interface Props {
|
||||||
entries: ConversationEntry[]
|
entries: ConversationEntry[]
|
||||||
/** Kriterium-Zuordnungen aus den allgemeinen Notizen — gelten für ALLE Institutees, nicht gefiltert. */
|
/** Kriterium-Zuordnungen aus den allgemeinen Notizen — gelten für ALLE Institutees, nicht gefiltert. */
|
||||||
generalNoteTags: ConversationLineTag[]
|
generalNoteTags: ConversationLineTag[]
|
||||||
|
checklist: ChecklistItemDef[]
|
||||||
|
checklistResponses: ChecklistResponse[]
|
||||||
hasConv: boolean
|
hasConv: boolean
|
||||||
tagLabel: (inst: Consultant) => string
|
tagLabel: (inst: Consultant) => string
|
||||||
fillerTotal: (instId: number) => number
|
fillerTotal: (instId: number) => number
|
||||||
getAssessmentScore: (instituteeId: number, itemId: number) => RatingScore | null
|
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
|
onSaveAssessmentNote: (instituteeId: number, itemId: number, note: string) => void
|
||||||
onAutoCalc: (instituteeId: number) => void
|
onAutoCalc: (instituteeId: number) => void
|
||||||
|
/** Nur noch leere Kriterien — bereits gesetzte Scores werden nie überschrieben. */
|
||||||
|
onAcceptAllSuggestions: (instituteeId: number) => void
|
||||||
colorBands?: RatingColorBand[]
|
colorBands?: RatingColorBand[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,42 +49,46 @@ function catMode(
|
||||||
function suggestedRating(
|
function suggestedRating(
|
||||||
entries: ConversationEntry[],
|
entries: ConversationEntry[],
|
||||||
generalNoteTags: ConversationLineTag[],
|
generalNoteTags: ConversationLineTag[],
|
||||||
|
checklist: ChecklistItemDef[],
|
||||||
|
checklistResponses: ChecklistResponse[],
|
||||||
instituteeId: number,
|
instituteeId: number,
|
||||||
itemId: number,
|
itemId: number,
|
||||||
): RatingScore | null {
|
): RatingScore | null {
|
||||||
const entryTexts = entries
|
return suggestCriterionScoreForInstitutee(
|
||||||
.filter(e => e.instituteeId === instituteeId)
|
entries, generalNoteTags, checklist, checklistResponses, instituteeId, itemId,
|
||||||
.flatMap(e => e.lineTags ?? [])
|
)
|
||||||
.filter(t => t.criterionItemId === itemId)
|
|
||||||
.map(t => t.text)
|
|
||||||
const generalTexts = generalNoteTags
|
|
||||||
.filter(t => t.criterionItemId === itemId)
|
|
||||||
.map(t => t.text)
|
|
||||||
const ratings = [...entryTexts, ...generalTexts]
|
|
||||||
.map(detectNotationRating)
|
|
||||||
.filter(isValidRatingScore)
|
|
||||||
if (ratings.length === 0) return null
|
|
||||||
const avg = ratings.reduce((s, r) => s + r, 0) / ratings.length
|
|
||||||
return numberToRating(avg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssessmentTab({
|
export function AssessmentTab({
|
||||||
institutees, fbCategories, fbItems, assessments, entries, generalNoteTags, hasConv,
|
institutees, fbCategories, fbItems, assessments, entries, generalNoteTags,
|
||||||
|
checklist, checklistResponses, hasConv,
|
||||||
tagLabel, fillerTotal,
|
tagLabel, fillerTotal,
|
||||||
getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc,
|
getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc,
|
||||||
|
onAcceptAllSuggestions,
|
||||||
colorBands = DEFAULT_RATING_COLOR_BANDS,
|
colorBands = DEFAULT_RATING_COLOR_BANDS,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [expandedCat, setExpandedCat] = useState<Record<number, number | null>>({})
|
const [expandedCat, setExpandedCat] = useState<Record<number, number | null>>({})
|
||||||
|
const [acceptingId, setAcceptingId] = useState<number | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{institutees.map((inst, idx) => {
|
{institutees.map((inst, idx) => {
|
||||||
const color = INST_COLORS[idx % INST_COLORS.length]
|
const color = INST_COLORS[idx % INST_COLORS.length]
|
||||||
const filler = fillerTotal(inst.id!)
|
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 (
|
return (
|
||||||
<div key={inst.id} className={`rounded-xl border-2 ${color.border} p-4 space-y-3 bg-white`}>
|
<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">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className={`text-xs font-bold text-white px-2 py-1 rounded ${color.tag}`}>
|
<span className={`text-xs font-bold text-white px-2 py-1 rounded ${color.tag}`}>
|
||||||
{tagLabel(inst)}
|
{tagLabel(inst)}
|
||||||
|
|
@ -85,13 +99,43 @@ export function AssessmentTab({
|
||||||
äh×{filler}
|
äh×{filler}
|
||||||
</span>
|
</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">
|
||||||
|
Aus Protokoll
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasConv && (
|
|
||||||
<button onClick={() => onAutoCalc(inst.id!)}
|
|
||||||
className="text-xs bg-gray-100 text-gray-600 border border-gray-200 px-3 py-1 rounded-lg">
|
|
||||||
Aus Protokoll
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fbCategories.map(cat => {
|
{fbCategories.map(cat => {
|
||||||
|
|
@ -103,25 +147,45 @@ export function AssessmentTab({
|
||||||
const hasScore = isValidRatingScore(mode)
|
const hasScore = isValidRatingScore(mode)
|
||||||
const suggestionCount = catItems.filter(item => {
|
const suggestionCount = catItems.filter(item => {
|
||||||
const current = getAssessmentScore(inst.id!, item.id!)
|
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
|
return suggestion !== null && suggestion !== current
|
||||||
}).length
|
}).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 (
|
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
|
<button
|
||||||
onClick={() => setExpandedCat(prev => ({
|
onClick={() => setExpandedCat(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[inst.id!]: prev[inst.id!] === cat.id ? null : cat.id!,
|
[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 ${
|
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'}`}>
|
<span className={`text-sm font-medium ${hasScore ? 'text-white' : 'text-gray-700'}`}>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
{hasScore && <span className="ml-2 tabular-nums opacity-90">{headerRo.short}</span>}
|
{hasScore && <span className="ml-2 tabular-nums opacity-90">{headerRo.short}</span>}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{suggestionCount > 0 && (
|
||||||
<span title="Vorschläge aus dem Protokoll verfügbar"
|
<span title="Vorschläge aus dem Protokoll verfügbar"
|
||||||
className={`text-xs px-1.5 py-0.5 rounded-full font-semibold ${
|
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>
|
<div className="px-3 py-2.5 text-xs text-gray-400">Keine Kriterien in dieser Kategorie.</div>
|
||||||
) : catItems.map(item => {
|
) : catItems.map(item => {
|
||||||
const current = getAssessmentScore(inst.id!, item.id!)
|
const current = getAssessmentScore(inst.id!, item.id!)
|
||||||
const suggestion = suggestedRating(entries, generalNoteTags, inst.id!, item.id!)
|
const suggestion = suggestedRating(
|
||||||
const note = assessments.find(a => a.instituteeId === inst.id && a.criteriaId === item.id)?.note ?? ''
|
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 (
|
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="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 && (
|
{(item.weight ?? 1) !== 1 && (
|
||||||
<span className="text-xs text-gray-400 flex-shrink-0">×{item.weight}</span>
|
<span className="text-xs text-gray-400 flex-shrink-0">×{item.weight}</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -162,13 +240,24 @@ export function AssessmentTab({
|
||||||
bands={colorBands}
|
bands={colorBands}
|
||||||
allowClear
|
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 && (
|
{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">
|
<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>
|
</span>
|
||||||
<button onClick={() => onSetAssessmentScore(inst.id!, item.id!, suggestion)}
|
<button
|
||||||
className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-lg font-medium hover:bg-blue-200">
|
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
|
Übernehmen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,16 @@ function ChecklistLine({
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5 py-1">
|
<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>
|
||||||
<CriterionTagPicker
|
<div className="flex-shrink-0 w-[10.5rem] flex justify-end">
|
||||||
items={items}
|
<CriterionTagPicker
|
||||||
categories={categories}
|
items={items}
|
||||||
value={criterionValue}
|
categories={categories}
|
||||||
onChange={onSetCriterion}
|
value={criterionValue}
|
||||||
suggestions={suggestions}
|
onChange={onSetCriterion}
|
||||||
/>
|
suggestions={suggestions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<ScorePicker
|
<ScorePicker
|
||||||
value={rating}
|
value={rating}
|
||||||
onChange={onSelectRating}
|
onChange={onSelectRating}
|
||||||
|
|
@ -80,9 +82,7 @@ function ChecklistLine({
|
||||||
* Gesprächs-Zeitleiste gerendert, kein eigener Meeting-Tab. Zeilenbasiert statt kartenbasiert
|
* 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
|
* (Feedback nach Nutzungstests: nahm zu viel Platz ein). Die Kriterium-Zuordnung ist pro Antwort
|
||||||
* individuell überschreibbar (`CriterionTagPicker`, gleiche Komponente wie im Protokoll) — die
|
* individuell überschreibbar (`CriterionTagPicker`, gleiche Komponente wie im Protokoll) — die
|
||||||
* Konfigurations-Vorgabe (`ChecklistItemDef.linkedCriterionItemId`) ist nur der Startwert.
|
* Config-Standard (`linkedCriterionItemId`) erscheint als feste Zuordnung; Override per Antwort möglich.
|
||||||
* `ChecklistResponse` ist immer die Quelle für die UI selbst; bei effektiv vorhandenem Kriterium
|
|
||||||
* spiegelt MeetingView.tsx die Eingabe zusätzlich einseitig nach `Assessment`.
|
|
||||||
*/
|
*/
|
||||||
export function ChecklistTab({
|
export function ChecklistTab({
|
||||||
institutees, checklist, fbItems, fbCategories, tagLabel,
|
institutees, checklist, fbItems, fbCategories, tagLabel,
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,27 @@ export interface CriterionSuggestion {
|
||||||
rank: number
|
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 {
|
interface Props {
|
||||||
items: FeedbackCriterionItem[]
|
items: FeedbackCriterionItem[]
|
||||||
categories: FeedbackCategory[]
|
categories: FeedbackCategory[]
|
||||||
value: number | null | undefined
|
value: number | null | undefined
|
||||||
onChange: (itemId: number | null) => void
|
onChange: (itemId: number | null) => void
|
||||||
/** Nur relevant solange `value` leer ist — überschreibt den leeren „+"-Button durch einen Vorschlags-Chip. */
|
|
||||||
suggestions?: CriterionSuggestion[]
|
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 [open, setOpen] = useState(false)
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
|
|
||||||
|
|
@ -26,7 +37,9 @@ export function CriterionTagPicker({ items, categories, value, onChange, suggest
|
||||||
const filtered = items.filter(i => {
|
const filtered = items.filter(i => {
|
||||||
const q = filter.trim().toLowerCase()
|
const q = filter.trim().toLowerCase()
|
||||||
if (!q) return true
|
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) => {
|
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 sortedSuggestions = suggestions ? [...suggestions].sort((a, b) => a.rank - b.rank) : []
|
||||||
const topItem = sortedSuggestions[0] ? items.find(i => i.id === sortedSuggestions[0].criterionItemId) : undefined
|
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 (
|
return (
|
||||||
<div className="relative inline-block">
|
<div className={shellClass}>
|
||||||
{current ? (
|
{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">
|
<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)} className="text-xs font-medium max-w-[120px] truncate">
|
<button
|
||||||
|
onClick={() => setOpen(p => !p)}
|
||||||
|
title={criterionHoverTitle(current, categoryName(current.categoryId))}
|
||||||
|
className={chipLabelClass}>
|
||||||
🏷 {current.name}
|
🏷 {current.name}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
) : topItem ? (
|
) : 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">
|
<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="Vorschlag übernehmen"
|
<button
|
||||||
className="text-xs font-medium max-w-[120px] truncate">
|
onClick={() => select(topItem.id!)}
|
||||||
|
title={criterionHoverTitle(topItem, categoryName(topItem.categoryId))}
|
||||||
|
className={chipLabelClass}>
|
||||||
💡 {topItem.name}
|
💡 {topItem.name}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setOpen(p => !p)} title="Weitere Vorschläge / Suche"
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => setOpen(p => !p)}
|
<div className={compact ? 'flex justify-end' : undefined}>
|
||||||
title="Kriterium zuordnen"
|
<button onClick={() => setOpen(p => !p)}
|
||||||
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">
|
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>
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{open && (
|
{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 && (
|
{sortedSuggestions.length > 0 && (
|
||||||
<div className="mb-2 space-y-0.5">
|
<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>
|
<div className="text-[10px] font-semibold text-blue-400 uppercase tracking-wide px-2">Vorschläge</div>
|
||||||
{sortedSuggestions.map(s => {
|
{sortedSuggestions.map(s => {
|
||||||
const item = items.find(i => i.id === s.criterionItemId)
|
const item = items.find(i => i.id === s.criterionItemId)
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
|
const cat = categoryName(item.categoryId)
|
||||||
return (
|
return (
|
||||||
<button key={s.criterionItemId} onClick={() => select(item.id!)}
|
<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">
|
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-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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -95,14 +126,21 @@ export function CriterionTagPicker({ items, categories, value, onChange, suggest
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<div className="text-xs text-gray-400 px-2 py-1.5">Kein Treffer.</div>
|
<div className="text-xs text-gray-400 px-2 py-1.5">Kein Treffer.</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map(item => (
|
{filtered.map(item => {
|
||||||
<button key={item.id}
|
const cat = categoryName(item.categoryId)
|
||||||
onClick={() => select(item.id!)}
|
return (
|
||||||
className="w-full text-left px-2 py-1.5 rounded-lg hover:bg-gray-50">
|
<button key={item.id}
|
||||||
<div className="text-xs font-medium text-gray-700">{item.name}</div>
|
onClick={() => select(item.id!)}
|
||||||
<div className="text-[10px] text-gray-400">{categoryName(item.categoryId)}</div>
|
title={criterionHoverTitle(item, cat)}
|
||||||
</button>
|
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>
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
loadFeedbackStructure, resolveVisibleStructure, updateFeedbackCriterionItem,
|
loadFeedbackStructure, resolveVisibleStructure, updateFeedbackCriterionItem,
|
||||||
getGlobalLineTagCorpus,
|
getGlobalLineTagCorpus,
|
||||||
getOrSeedCriterionTaggingPromptTemplate,
|
getOrSeedCriterionTaggingPromptTemplate,
|
||||||
|
getOrSeedChecklistTaggingPromptTemplate,
|
||||||
listConversationEntriesForMeeting, addConversationEntry, updateConversationEntry, deleteConversationEntry,
|
listConversationEntriesForMeeting, addConversationEntry, updateConversationEntry, deleteConversationEntry,
|
||||||
listConversationSkillScoresAll, addConversationSkillScore, updateConversationSkillScore, deleteConversationSkillScoresForEntry,
|
listConversationSkillScoresAll, addConversationSkillScore, updateConversationSkillScore, deleteConversationSkillScoresForEntry,
|
||||||
listAssessmentsForMeeting, addAssessment, updateAssessment,
|
listAssessmentsForMeeting, addAssessment, updateAssessment,
|
||||||
|
|
@ -21,12 +22,22 @@ import {
|
||||||
} from '../../db'
|
} from '../../db'
|
||||||
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport'
|
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport'
|
||||||
import { NotationText, splitNoteLines, parseNotationPrefix } from '../../utils/notationParser'
|
import { NotationText, splitNoteLines, parseNotationPrefix } from '../../utils/notationParser'
|
||||||
import { clampRatingScore, isValidRatingScore, normalizeRatingScore } from '../../config/constants'
|
import { clampRatingScore, isValidRatingScore, normalizeRatingScore, AI_CONFIG } from '../../config/constants'
|
||||||
import { getAiSettings, callOpenRouter } from '../../utils/aiClient'
|
import { getAiSettings, callOpenRouter, checkPromptSize } from '../../utils/aiClient'
|
||||||
import { weightedAverageRating } from '../../utils/ratingTrend'
|
import { weightedAverageRating } from '../../utils/ratingTrend'
|
||||||
import { renderDimensionPrompt } from '../../utils/aiPrompt'
|
import { renderDimensionPrompt } from '../../utils/aiPrompt'
|
||||||
import { buildCriterionSuggestionPromptContext, parseCriterionSuggestionResponse } from '../../utils/criterionSuggestionPrompt'
|
import { buildCriterionSuggestionPromptContext, parseCriterionSuggestionResponse } from '../../utils/criterionSuggestionPrompt'
|
||||||
|
import { runChecklistTaggingAnalysis } from '../../utils/checklistTagging'
|
||||||
|
import { getEffectiveChecklistCriterionId } from '../../utils/checklistCriterion'
|
||||||
import { findSimilarTaggedLines, type SimilarTaggedLine } from '../../utils/criterionSuggestion'
|
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 { useRatingColorBands } from '../../hooks/useRatingColorBands'
|
||||||
import { ConversationTab } from './ConversationTab'
|
import { ConversationTab } from './ConversationTab'
|
||||||
import { AssessmentTab } from './AssessmentTab'
|
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. */
|
/** 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 [suggestionInfo, setSuggestionInfo] = useState('')
|
||||||
const [lastAnalysisRaw, setLastAnalysisRaw] = useState<string | null>(null)
|
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). */
|
/** Einmal pro Meeting-Load geholt, dient der rein lokalen Live-Formulierungshilfe (kein KI-Call). */
|
||||||
const [corpus, setCorpus] = useState<ConversationLineTag[]>([])
|
const [corpus, setCorpus] = useState<ConversationLineTag[]>([])
|
||||||
const [generalNotesHints, setGeneralNotesHints] = useState<SimilarTaggedLine[]>([])
|
const [generalNotesHints, setGeneralNotesHints] = useState<SimilarTaggedLine[]>([])
|
||||||
|
|
@ -314,13 +326,38 @@ export default function MeetingView() {
|
||||||
await upsertMeetingCategoryRating({ meetingInstanceId: meetingId, instituteeId, categoryId, score: avg })
|
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)
|
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 now = new Date().toISOString()
|
||||||
|
const scoreNeedsReview = score === null ? false : (opts?.needsReview ?? false)
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
await updateAssessment(existing.id, { score, updatedAt: now })
|
await updateAssessment(existing.id, { score, scoreNeedsReview, updatedAt: now })
|
||||||
} else {
|
} 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)
|
const next = await listAssessmentsForMeeting(meetingId)
|
||||||
setAssessments(next)
|
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 saveAssessmentNote = async (instituteeId: number, itemId: number, note: string) => {
|
||||||
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
|
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
@ -362,51 +419,28 @@ export default function MeetingView() {
|
||||||
const getChecklistResponse = (checklistItemId: number, instituteeId: number | undefined): ChecklistResponse | undefined =>
|
const getChecklistResponse = (checklistItemId: number, instituteeId: number | undefined): ChecklistResponse | undefined =>
|
||||||
checklistResponses.find(r => r.checklistItemId === checklistItemId && (r.instituteeId ?? undefined) === instituteeId)
|
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 =>
|
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
|
* `ChecklistResponse` ist die Quelle für die Checkliste-UI. Scores/Notizen fließen NICHT
|
||||||
* Kriterium (individuelle Zuordnung ODER Konfigurations-Vorgabe) wird zusätzlich einseitig nach
|
* automatisch in `Assessment` — Vorschläge erscheinen im Gesamtbewertungs-Tab (Übernehmen).
|
||||||
* `Assessment` gespiegelt (score+note), damit Gesamtbewertung/Evaluation und die KI-Prompt-Auswertung
|
* KI-Feedback liest Checklisten/Protokoll direkt aus Primärquellen (criterionEvidence.ts).
|
||||||
* (aiPrompt.ts::kriterienScores liest Assessment.note bereits) die Checklisten-Eintragung ganz normal
|
|
||||||
* als Protokolleintrag mitverwenden — bei `scope: meetingWide` für JEDEN aktuellen Institutee
|
|
||||||
* identisch (Kriterien sind Personen-Kompetenzen, ein meeting-weiter Checkpunkt gilt für alle
|
|
||||||
* gleichermaßen). Wechselt die individuelle Zuordnung auf ein anderes Kriterium, bleibt ein bereits
|
|
||||||
* gespiegelter Eintrag beim alten Kriterium unangetastet (keine rückwirkende Korrektur) — bewusst
|
|
||||||
* einfach gehalten, gleiches Prinzip wie beim Löschen eines Checklisten-Punkts.
|
|
||||||
*/
|
*/
|
||||||
const saveChecklistResponse = async (
|
const saveChecklistResponse = async (
|
||||||
ci: ChecklistItemDef,
|
ci: ChecklistItemDef,
|
||||||
instituteeId: number | undefined,
|
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 existing = getChecklistResponse(ci.id!, instituteeId)
|
||||||
const rating = 'rating' in patch ? (patch.rating ?? null) : (existing?.rating ?? null)
|
const rating = 'rating' in patch ? (patch.rating ?? null) : (existing?.rating ?? null)
|
||||||
const note = patch.note ?? existing?.note ?? ''
|
const note = patch.note ?? existing?.note ?? ''
|
||||||
const criterionItemId = 'criterionItemId' in patch ? patch.criterionItemId : existing?.criterionItemId
|
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))
|
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) =>
|
const setChecklistRating = (ci: ChecklistItemDef, instituteeId: number | undefined, rating: RatingScore | null) =>
|
||||||
|
|
@ -416,9 +450,9 @@ export default function MeetingView() {
|
||||||
saveChecklistResponse(ci, instituteeId, { note })
|
saveChecklistResponse(ci, instituteeId, { note })
|
||||||
|
|
||||||
const setChecklistCriterion = (ci: ChecklistItemDef, instituteeId: number | undefined, criterionItemId: number | null) =>
|
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 localChecklistSuggestions = useMemo(() => {
|
||||||
const map = new Map<string, CriterionSuggestion[]>()
|
const map = new Map<string, CriterionSuggestion[]>()
|
||||||
if (corpus.length === 0) return map
|
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)
|
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
|
||||||
for (const instituteeId of targets) {
|
for (const instituteeId of targets) {
|
||||||
if (getEffectiveChecklistCriterion(ci, instituteeId) !== null) continue
|
if (getEffectiveChecklistCriterion(ci, instituteeId) !== null) continue
|
||||||
const comment = getChecklistResponse(ci.id!, instituteeId)?.note?.trim()
|
if (!ci.label.trim()) continue
|
||||||
if (!comment) continue
|
const hints = findSimilarTaggedLines(ci.label, corpus, 3).filter(h => h.score >= 0.25)
|
||||||
const hints = findSimilarTaggedLines(comment, corpus, 3).filter(h => h.score >= 0.25)
|
|
||||||
if (hints.length === 0) continue
|
if (hints.length === 0) continue
|
||||||
map.set(`${ci.id}:${instituteeId ?? ''}`, hints.map((h, idx) => ({ criterionItemId: h.criterionItemId, rank: idx + 1 })))
|
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
|
return merged
|
||||||
}, [localChecklistSuggestions, checklistSuggestions])
|
}, [localChecklistSuggestions, checklistSuggestions])
|
||||||
|
|
||||||
const getChecklistSuggestions = (checklistItemId: number, instituteeId: number | undefined): CriterionSuggestion[] | undefined =>
|
const getChecklistSuggestions = (checklistItemId: number, instituteeId: number | undefined): CriterionSuggestion[] | undefined => {
|
||||||
mergedChecklistSuggestions.get(`${checklistItemId}:${instituteeId ?? ''}`)
|
const ci = checklist.find(c => c.id === checklistItemId)
|
||||||
|
if (!ci || getEffectiveChecklistCriterion(ci, instituteeId) !== null) return undefined
|
||||||
|
return mergedChecklistSuggestions.get(`${checklistItemId}:${instituteeId ?? ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Meeting-level handlers ────────────────────────────────────────────────
|
// ── 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
|
* „🏷 Vorschläge analysieren" — zwei getrennte KI-Calls: Protokoll-Freitext (ZEILE n:) und
|
||||||
* (nicht pro Zeile), siehe CLAUDE.md „Selbstlernendes Kriterien-Tagging". Sammelt alle noch
|
* Checklistenpunkte (PUNKT n:), jeweils mit eigenem Prompt und Parser.
|
||||||
* nicht zugeordneten Zeilen (Gesprächsbeiträge + allgemeine Notizen, Kundenzitate ausgenommen —
|
|
||||||
* gleiches Ausschlussprinzip wie in aiPrompt.ts), lässt die KI pro Zeile priorisierte
|
|
||||||
* Kriterium-IDs vorschlagen und persistiert die als Nebenprodukt gelieferten Formulierungsmuster
|
|
||||||
* direkt auf `FeedbackCriterionItem.learnedPatternHints`.
|
|
||||||
*/
|
*/
|
||||||
const analyzeSuggestions = async () => {
|
const analyzeSuggestions = async () => {
|
||||||
if (!assignment) return
|
if (!assignment) return
|
||||||
|
|
@ -509,12 +541,14 @@ export default function MeetingView() {
|
||||||
setSuggestionError('')
|
setSuggestionError('')
|
||||||
setSuggestionInfo('')
|
setSuggestionInfo('')
|
||||||
setLastAnalysisRaw(null)
|
setLastAnalysisRaw(null)
|
||||||
|
setLastChecklistAnalysisRaw(null)
|
||||||
try {
|
try {
|
||||||
const [aType, structure, corpus, template] = await Promise.all([
|
const [aType, structure, corpus, protocolTemplate, checklistTemplate] = await Promise.all([
|
||||||
getAssignmentType(assignment.assignmentTypeId),
|
getAssignmentType(assignment.assignmentTypeId),
|
||||||
loadFeedbackStructure(),
|
loadFeedbackStructure(),
|
||||||
getGlobalLineTagCorpus(),
|
getGlobalLineTagCorpus(),
|
||||||
getOrSeedCriterionTaggingPromptTemplate(),
|
getOrSeedCriterionTaggingPromptTemplate(),
|
||||||
|
getOrSeedChecklistTaggingPromptTemplate(),
|
||||||
])
|
])
|
||||||
const visible = resolveVisibleStructure(
|
const visible = resolveVisibleStructure(
|
||||||
structure.dimensions,
|
structure.dimensions,
|
||||||
|
|
@ -522,82 +556,163 @@ export default function MeetingView() {
|
||||||
structure.items,
|
structure.items,
|
||||||
aType?.criteriaIds,
|
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 promptBase = {
|
||||||
const lineRefs: LineRef[] = []
|
|
||||||
let n = 1
|
|
||||||
for (const entry of entries) {
|
|
||||||
const inst = institutees.find(i => i.id === entry.instituteeId)
|
|
||||||
const taggedTexts = new Set((entry.lineTags ?? []).map(t => t.text))
|
|
||||||
for (const line of splitNoteLines(entry.note)) {
|
|
||||||
if (taggedTexts.has(line) || parseNotationPrefix(line).isQuote) continue
|
|
||||||
lineRefs.push({ lineNumber: n++, text: line, speakerLabel: inst ? tagLabel(inst) : undefined })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const taggedGeneralTexts = new Set(generalNoteTags.map(t => t.text))
|
|
||||||
for (const line of splitNoteLines(generalNotes)) {
|
|
||||||
if (taggedGeneralTexts.has(line) || parseNotationPrefix(line).isQuote) continue
|
|
||||||
lineRefs.push({ lineNumber: n++, text: line })
|
|
||||||
}
|
|
||||||
// Checklisten-Antworten ohne effektives Kriterium (weder individuell noch konfiguriert) — auch
|
|
||||||
// ohne Kommentar einbeziehen (Label allein ist bereits ein brauchbares Signal für die KI),
|
|
||||||
// sonst bleiben reine Bewertungen ohne Freitext fälschlich unanalysiert.
|
|
||||||
for (const ci of checklist) {
|
|
||||||
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
|
|
||||||
for (const instituteeId of targets) {
|
|
||||||
if (getEffectiveChecklistCriterion(ci, instituteeId) !== null) continue
|
|
||||||
const comment = getChecklistResponse(ci.id!, instituteeId)?.note?.trim()
|
|
||||||
const inst = instituteeId ? institutees.find(i => i.id === instituteeId) : undefined
|
|
||||||
lineRefs.push({
|
|
||||||
lineNumber: n++,
|
|
||||||
text: comment ? `${ci.label}: ${comment}` : ci.label,
|
|
||||||
speakerLabel: inst ? tagLabel(inst) : undefined,
|
|
||||||
checklistKey: `${ci.id}:${instituteeId ?? ''}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lineRefs.length === 0) {
|
|
||||||
setSuggestionInfo('Keine offenen Protokollzeilen oder Checklisten-Punkte gefunden — alles bereits zugeordnet oder leer.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = buildCriterionSuggestionPromptContext({
|
|
||||||
assignmentTypeName: aType?.name ?? '—',
|
assignmentTypeName: aType?.name ?? '—',
|
||||||
dimensions: visible.dimensions,
|
dimensions: visible.dimensions,
|
||||||
categories: visible.categories,
|
categories: visible.categories,
|
||||||
items: visible.items,
|
items: visible.items,
|
||||||
visibleCriteriaIds: aType?.criteriaIds,
|
visibleCriteriaIds: aType?.criteriaIds,
|
||||||
lines: lineRefs,
|
|
||||||
corpus,
|
corpus,
|
||||||
})
|
|
||||||
const prompt = renderDimensionPrompt(template, context)
|
|
||||||
const raw = await callOpenRouter(prompt, settings)
|
|
||||||
setLastAnalysisRaw(raw)
|
|
||||||
const { lineSuggestions: parsedLines, patternHints } = parseCriterionSuggestionResponse(raw)
|
|
||||||
|
|
||||||
const nextSuggestions = new Map<string, CriterionSuggestion[]>()
|
|
||||||
const nextChecklistSuggestions = new Map<string, CriterionSuggestion[]>()
|
|
||||||
for (const ref of lineRefs) {
|
|
||||||
const ids = parsedLines.get(ref.lineNumber)
|
|
||||||
if (!ids || ids.length === 0) continue
|
|
||||||
const suggestions = ids.map((criterionItemId, idx) => ({ criterionItemId, rank: idx + 1 }))
|
|
||||||
if (ref.checklistKey) nextChecklistSuggestions.set(ref.checklistKey, suggestions)
|
|
||||||
else nextSuggestions.set(ref.text, suggestions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
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)
|
setLineSuggestions(nextSuggestions)
|
||||||
setChecklistSuggestions(nextChecklistSuggestions)
|
setLastAnalysisRaw(protocolRawParts.length > 0 ? protocolRawParts.join('\n\n') : null)
|
||||||
|
|
||||||
const totalSuggestions = nextSuggestions.size + nextChecklistSuggestions.size
|
if (mergedPatterns.size > 0) {
|
||||||
setSuggestionInfo(totalSuggestions === 0
|
await Promise.all([...mergedPatterns.entries()].map(([criterionItemId, hint]) =>
|
||||||
? `${lineRefs.length} Zeile(n)/Punkt(e) analysiert, aber die KI hat für keine(n) davon einen Vorschlag geliefert — Rohantwort unten einsehbar.`
|
|
||||||
: `${lineRefs.length} Zeile(n)/Punkt(e) analysiert, ${totalSuggestions} Vorschlag/Vorschläge erhalten.`)
|
|
||||||
|
|
||||||
if (patternHints.size > 0) {
|
|
||||||
await Promise.all([...patternHints.entries()].map(([criterionItemId, hint]) =>
|
|
||||||
updateFeedbackCriterionItem(criterionItemId, { learnedPatternHints: hint }),
|
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) {
|
} catch (e) {
|
||||||
setSuggestionError(e instanceof Error ? e.message : 'Unbekannter Fehler bei der Analyse.')
|
setSuggestionError(e instanceof Error ? e.message : 'Unbekannter Fehler bei der Analyse.')
|
||||||
|
|
@ -670,7 +785,7 @@ export default function MeetingView() {
|
||||||
disabled={analyzingSuggestions}
|
disabled={analyzingSuggestions}
|
||||||
title="KI-Vorschläge für noch nicht zugeordnete Protokollzeilen"
|
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">
|
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>
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button onClick={() => setShowExportMenu(p => !p)}
|
<button onClick={() => setShowExportMenu(p => !p)}
|
||||||
|
|
@ -713,10 +828,16 @@ export default function MeetingView() {
|
||||||
<div>{suggestionInfo}</div>
|
<div>{suggestionInfo}</div>
|
||||||
{lastAnalysisRaw && (
|
{lastAnalysisRaw && (
|
||||||
<details>
|
<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>
|
<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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -738,70 +859,15 @@ export default function MeetingView() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Allgemeine Notizen */}
|
|
||||||
{editingGeneralNotes ? (
|
|
||||||
<textarea
|
|
||||||
ref={generalNotesRef}
|
|
||||||
autoFocus
|
|
||||||
className="w-full border border-brand rounded-xl px-3 py-2 text-sm bg-white focus:outline-none resize-none"
|
|
||||||
style={{ minHeight: '4rem', fieldSizing: 'content' } as React.CSSProperties}
|
|
||||||
placeholder="Allgemeine Notizen zum Meeting… (+ gut, ! negativ, > Kundeninput)"
|
|
||||||
value={generalNotes}
|
|
||||||
onChange={e => saveGeneralNotes(e.target.value)}
|
|
||||||
onBlur={finishEditingGeneralNotes}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{editingGeneralNotes && generalNotesHints.length > 0 && (
|
|
||||||
<details className="bg-blue-50 border border-blue-100 rounded-lg px-3 py-1.5" open>
|
|
||||||
<summary className="text-xs font-medium text-blue-700 cursor-pointer select-none">💭 Ähnliche frühere Notizen — klicken zum Übernehmen</summary>
|
|
||||||
<ul className="mt-1 space-y-0.5">
|
|
||||||
{generalNotesHints.map((h, i) => (
|
|
||||||
<li key={i}>
|
|
||||||
<button type="button"
|
|
||||||
onMouseDown={e => e.preventDefault()}
|
|
||||||
onClick={() => applyGeneralHint(h.text)}
|
|
||||||
className="text-xs text-blue-800 hover:text-blue-950 hover:underline truncate text-left w-full">
|
|
||||||
„{h.text}"
|
|
||||||
{fbItems.find(i => i.id === h.criterionItemId)?.name && (
|
|
||||||
<span className="text-blue-400"> → 🏷 {fbItems.find(i => i.id === h.criterionItemId)?.name}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
{!editingGeneralNotes && (
|
|
||||||
<div onClick={() => setEditingGeneralNotes(true)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm bg-white cursor-text min-h-[2.5rem]">
|
|
||||||
{generalNotes.trim()
|
|
||||||
? (
|
|
||||||
<NotationText
|
|
||||||
text={generalNotes}
|
|
||||||
colorBands={colorBands}
|
|
||||||
renderLineAddon={line => (
|
|
||||||
<span onClick={e => e.stopPropagation()}>
|
|
||||||
<CriterionTagPicker
|
|
||||||
items={fbItems}
|
|
||||||
categories={fbCategories}
|
|
||||||
value={generalNoteTags.find(t => t.text === line)?.criterionItemId ?? null}
|
|
||||||
onChange={itemId => setGeneralNoteCriterion(line, itemId)}
|
|
||||||
suggestions={mergedLineSuggestions.get(line)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: <span className="text-gray-300">Allgemeine Notizen zum Meeting…</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
{(hasConv || hasAssess || hasChecklist) && (
|
{(hasConv || hasAssess || hasChecklist) && (
|
||||||
<div className="flex bg-gray-100 rounded-xl p-1">
|
<div className="flex bg-gray-100 rounded-xl p-1">
|
||||||
{(hasConv || hasChecklist) && (
|
{(hasConv || hasChecklist) && (
|
||||||
<button onClick={() => setTab('conversation')}
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (editingGeneralNotes) finishEditingGeneralNotes()
|
||||||
|
setTab('conversation')
|
||||||
|
}}
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
|
||||||
tab === 'conversation' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
|
tab === 'conversation' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
|
||||||
}`}>
|
}`}>
|
||||||
|
|
@ -809,7 +875,11 @@ export default function MeetingView() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{hasAssess && (
|
{hasAssess && (
|
||||||
<button onClick={() => setTab('assessment')}
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (editingGeneralNotes) finishEditingGeneralNotes()
|
||||||
|
setTab('assessment')
|
||||||
|
}}
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
|
||||||
tab === 'assessment' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
|
tab === 'assessment' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
|
||||||
}`}>
|
}`}>
|
||||||
|
|
@ -819,6 +889,73 @@ export default function MeetingView() {
|
||||||
</div>
|
</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="Meeting-weite Beobachtungen… (führende Zahl 1–10, > Kundeninput)"
|
||||||
|
value={generalNotes}
|
||||||
|
onChange={e => saveGeneralNotes(e.target.value)}
|
||||||
|
onBlur={finishEditingGeneralNotes}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{editingGeneralNotes && generalNotesHints.length > 0 && (
|
||||||
|
<details className="bg-blue-50 border border-blue-100 rounded-lg px-3 py-1.5" open>
|
||||||
|
<summary className="text-xs font-medium text-blue-700 cursor-pointer select-none">💭 Ähnliche frühere Notizen — klicken zum Übernehmen</summary>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{generalNotesHints.map((h, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<button type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyGeneralHint(h.text)}
|
||||||
|
className="text-xs text-blue-800 hover:text-blue-950 hover:underline truncate text-left w-full">
|
||||||
|
„{h.text}"
|
||||||
|
{fbItems.find(item => item.id === h.criterionItemId)?.name && (
|
||||||
|
<span className="text-blue-400"> → 🏷 {fbItems.find(item => item.id === h.criterionItemId)?.name}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{!editingGeneralNotes && (
|
||||||
|
<div onClick={() => setEditingGeneralNotes(true)}
|
||||||
|
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm bg-white cursor-text min-h-[2.5rem]">
|
||||||
|
{generalNotes.trim()
|
||||||
|
? (
|
||||||
|
<NotationText
|
||||||
|
text={generalNotes}
|
||||||
|
colorBands={colorBands}
|
||||||
|
renderLineAddon={line => (
|
||||||
|
<span onClick={e => e.stopPropagation()}>
|
||||||
|
<CriterionTagPicker
|
||||||
|
items={fbItems}
|
||||||
|
categories={fbCategories}
|
||||||
|
value={generalNoteTags.find(t => t.text === line)?.criterionItemId ?? null}
|
||||||
|
onChange={itemId => setGeneralNoteCriterion(line, itemId)}
|
||||||
|
suggestions={mergedLineSuggestions.get(line)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: <span className="text-gray-300">Meeting-weite Beobachtungen…</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Checkliste — ganz normal im Gesprächsprotokoll angezeigt, kein eigener Tab (siehe CLAUDE.md) */}
|
{/* Checkliste — ganz normal im Gesprächsprotokoll angezeigt, kein eigener Tab (siehe CLAUDE.md) */}
|
||||||
{tab === 'conversation' && hasChecklist && (
|
{tab === 'conversation' && hasChecklist && (
|
||||||
<ChecklistTab
|
<ChecklistTab
|
||||||
|
|
@ -882,6 +1019,8 @@ export default function MeetingView() {
|
||||||
assessments={assessments}
|
assessments={assessments}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
generalNoteTags={generalNoteTags}
|
generalNoteTags={generalNoteTags}
|
||||||
|
checklist={checklist}
|
||||||
|
checklistResponses={checklistResponses}
|
||||||
hasConv={hasConv}
|
hasConv={hasConv}
|
||||||
tagLabel={tagLabel}
|
tagLabel={tagLabel}
|
||||||
fillerTotal={fillerTotal}
|
fillerTotal={fillerTotal}
|
||||||
|
|
@ -889,6 +1028,7 @@ export default function MeetingView() {
|
||||||
onSetAssessmentScore={setAssessmentScore}
|
onSetAssessmentScore={setAssessmentScore}
|
||||||
onSaveAssessmentNote={saveAssessmentNote}
|
onSaveAssessmentNote={saveAssessmentNote}
|
||||||
onAutoCalc={autoCalcAssessment}
|
onAutoCalc={autoCalcAssessment}
|
||||||
|
onAcceptAllSuggestions={acceptAllUnsetSuggestions}
|
||||||
colorBands={colorBands}
|
colorBands={colorBands}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
import { AI_CONFIG } from '../config/constants'
|
import { AI_CONFIG } from '../config/constants'
|
||||||
|
import { checkPromptSize } from './promptSize'
|
||||||
|
|
||||||
|
export type { PromptSizeCheck } from './promptSize'
|
||||||
|
export { estimatePromptChars, checkPromptSize } from './promptSize'
|
||||||
|
|
||||||
export interface AiSettings {
|
export interface AiSettings {
|
||||||
apiKey: string
|
apiKey: string
|
||||||
model: 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 {
|
export function getAiSettings(): AiSettings {
|
||||||
return {
|
return {
|
||||||
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
|
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
|
||||||
|
|
@ -17,28 +28,103 @@ export function saveAiSettings(apiKey: string, model: string): void {
|
||||||
localStorage.setItem(AI_CONFIG.localStorageKeyModel, model)
|
localStorage.setItem(AI_CONFIG.localStorageKeyModel, model)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function sleep(ms: number): Promise<void> {
|
||||||
* Einziger Netzwerk-Kontaktpunkt zu OpenRouter — läuft bewusst client-seitig direkt gegen die
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
* 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.
|
function isTransientHttpStatus(status: number): boolean {
|
||||||
*/
|
return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504
|
||||||
export async function callOpenRouter(prompt: string, settings: AiSettings): Promise<string> {
|
}
|
||||||
|
|
||||||
|
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`, {
|
const res = await fetch(`${AI_CONFIG.openRouterBaseUrl}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${settings.apiKey}`,
|
'Authorization': `Bearer ${settings.apiKey}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'HTTP-Referer': window.location.origin,
|
'HTTP-Referer': typeof window !== 'undefined' ? window.location.origin : 'http://localhost',
|
||||||
'X-Title': 'Assignment Monitor',
|
'X-Title': 'Assignment Monitor',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: settings.model,
|
model: settings.model,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
}),
|
}),
|
||||||
|
signal,
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`API-Fehler: ${res.status} ${res.statusText}`)
|
if (!res.ok) throw new AiHttpError(res.status, res.statusText)
|
||||||
|
const data = await res.json() as { choices?: Array<{ message?: { content?: string } }> }
|
||||||
const data = await res.json()
|
|
||||||
return data.choices?.[0]?.message?.content ?? ''
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import type {
|
import type {
|
||||||
Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
|
Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
|
||||||
FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, RatingScore,
|
FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, RatingScore,
|
||||||
|
ChecklistItemDef, ChecklistResponse,
|
||||||
} from '../db/types'
|
} from '../db/types'
|
||||||
import { clampRatingScore, isValidRatingScore } from '../config/constants'
|
import { isValidRatingScore } from '../config/constants'
|
||||||
import { splitNoteLines, parseNotationPrefix } from './notationParser'
|
import { splitNoteLines, parseNotationPrefix } from './notationParser'
|
||||||
import type { TrendResult } from './ratingTrend'
|
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.
|
* 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[]
|
instAssessments: Assessment[]
|
||||||
/** Bereits auf diesen Berater gefiltert. */
|
/** Bereits auf diesen Berater gefiltert. */
|
||||||
instEntries: ConversationEntry[]
|
instEntries: ConversationEntry[]
|
||||||
/** Alle `done`-Meetings des Assignments, chronologisch — siehe `listDoneMeetingsChronological`. */
|
|
||||||
meetings: MeetingInstance[]
|
meetings: MeetingInstance[]
|
||||||
|
checklistItems: ChecklistItemDef[]
|
||||||
|
checklistResponses: ChecklistResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,9 +62,9 @@ export function buildDimensionPromptContext(input: DimensionPromptInput): Record
|
||||||
const {
|
const {
|
||||||
institutee, assignment, dimension, dimCategories, dimItems,
|
institutee, assignment, dimension, dimCategories, dimItems,
|
||||||
ratings, catTrends, instAssessments, instEntries, meetings,
|
ratings, catTrends, instAssessments, instEntries, meetings,
|
||||||
|
checklistItems, checklistResponses,
|
||||||
} = input
|
} = input
|
||||||
|
|
||||||
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
|
|
||||||
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
|
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
|
||||||
|
|
||||||
const kategorienStruktur = dimCategories
|
const kategorienStruktur = dimCategories
|
||||||
|
|
@ -68,30 +73,12 @@ export function buildDimensionPromptContext(input: DimensionPromptInput): Record
|
||||||
|
|
||||||
const kriterienScores = dimCategories.map(cat => {
|
const kriterienScores = dimCategories.map(cat => {
|
||||||
const catItems = dimItems.filter(i => i.categoryId === cat.id)
|
const catItems = dimItems.filter(i => i.categoryId === cat.id)
|
||||||
const lines = catItems.map(c => {
|
const block = formatKriterienScoresSection(
|
||||||
const scores = instAssessments.filter(a => a.criteriaId === c.id && isValidRatingScore(a.score))
|
catItems, institutee.id!, meetings, instEntries, checklistItems, checklistResponses, instAssessments,
|
||||||
const generalNotesForCrit = meetings.flatMap(m =>
|
)
|
||||||
(m.generalNoteTags ?? []).filter(t => t.criterionItemId === c.id).map(t => t.text),
|
if (block === '(keine Scores/Belege erfasst)') return null
|
||||||
)
|
return ` ${cat.name}:\n${block}`
|
||||||
const notes = [
|
}).filter((l): l is string => l !== null).join('\n\n') || '(keine Scores erfasst)'
|
||||||
...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note),
|
|
||||||
...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text),
|
|
||||||
...generalNotesForCrit,
|
|
||||||
].map(formatNotationLine)
|
|
||||||
if (scores.length === 0 && notes.length === 0) return null
|
|
||||||
const avgNum = scores.length > 0
|
|
||||||
? scores.reduce((s, a) => s + (a.score as number), 0) / scores.length
|
|
||||||
: null
|
|
||||||
const clamped = avgNum !== null ? clampRatingScore(avgNum) : null
|
|
||||||
const avgLabel = avgNum !== null
|
|
||||||
? `${clamped ?? avgNum.toFixed(1)}/10 (Ø ${avgNum.toFixed(1)})`
|
|
||||||
: 'nur Notiz, keine Bewertung'
|
|
||||||
const label = c.description ? `${c.name} (${c.description})` : c.name
|
|
||||||
return ` ${label}: ${avgLabel}${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
|
|
||||||
}).filter((l): l is string => l !== null)
|
|
||||||
if (lines.length === 0) return null
|
|
||||||
return ` ${cat.name}:\n${lines.join('\n')}`
|
|
||||||
}).filter((l): l is string => l !== null).join('\n') || '(keine Scores erfasst)'
|
|
||||||
|
|
||||||
const kategorieBewertungen = dimCategories.map(cat => {
|
const kategorieBewertungen = dimCategories.map(cat => {
|
||||||
const r = ratings.find(x => x.feedbackCategoryId === cat.id)
|
const r = ratings.find(x => x.feedbackCategoryId === cat.id)
|
||||||
|
|
@ -177,6 +164,8 @@ export interface CategoryPromptInput {
|
||||||
instAssessments: Assessment[]
|
instAssessments: Assessment[]
|
||||||
instEntries: ConversationEntry[]
|
instEntries: ConversationEntry[]
|
||||||
meetings: MeetingInstance[]
|
meetings: MeetingInstance[]
|
||||||
|
checklistItems: ChecklistItemDef[]
|
||||||
|
checklistResponses: ChecklistResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryAiResult {
|
export interface CategoryAiResult {
|
||||||
|
|
@ -209,32 +198,14 @@ export function buildCategoryPromptContext(input: CategoryPromptInput): Record<s
|
||||||
const {
|
const {
|
||||||
institutee, assignment, dimension, category, catItems,
|
institutee, assignment, dimension, category, catItems,
|
||||||
ratings, catTrend, instAssessments, instEntries, meetings,
|
ratings, catTrend, instAssessments, instEntries, meetings,
|
||||||
|
checklistItems, checklistResponses,
|
||||||
} = input
|
} = input
|
||||||
|
|
||||||
const catItemIds = new Set(catItems.map(i => i.id))
|
const catItemIds = new Set(catItems.map(i => i.id!))
|
||||||
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
|
|
||||||
|
|
||||||
const kriterienScores = catItems.map(c => {
|
const kriterienScores = formatKriterienScoresSection(
|
||||||
const scores = instAssessments.filter(a => a.criteriaId === c.id && isValidRatingScore(a.score))
|
catItems, institutee.id!, meetings, instEntries, checklistItems, checklistResponses, instAssessments,
|
||||||
const generalNotesForCrit = meetings.flatMap(m =>
|
)
|
||||||
(m.generalNoteTags ?? []).filter(t => t.criterionItemId === c.id).map(t => t.text),
|
|
||||||
)
|
|
||||||
const notes = [
|
|
||||||
...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note!),
|
|
||||||
...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text),
|
|
||||||
...generalNotesForCrit,
|
|
||||||
].map(formatNotationLine)
|
|
||||||
if (scores.length === 0 && notes.length === 0) return null
|
|
||||||
const avgNum = scores.length > 0
|
|
||||||
? scores.reduce((s, a) => s + (a.score as number), 0) / scores.length
|
|
||||||
: null
|
|
||||||
const clamped = avgNum !== null ? clampRatingScore(avgNum) : null
|
|
||||||
const avgLabel = avgNum !== null
|
|
||||||
? `${clamped ?? avgNum.toFixed(1)}/10 (Ø ${avgNum.toFixed(1)})`
|
|
||||||
: 'nur Notiz, keine Bewertung'
|
|
||||||
const label = c.description ? `${c.name} (${c.description})` : c.name
|
|
||||||
return ` ${label}: ${avgLabel}${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
|
|
||||||
}).filter((l): l is string => l !== null).join('\n') || '(keine Scores/Notizen)'
|
|
||||||
|
|
||||||
const r = ratings.find(x => x.feedbackCategoryId === category.id)
|
const r = ratings.find(x => x.feedbackCategoryId === category.id)
|
||||||
const manualRating = isValidRatingScore(r?.rating) ? `${r!.rating}/10` : 'nicht bewertet'
|
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).`
|
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
|
const protokollGetaggt = formatTaggedProtocolByMeeting(
|
||||||
.map(m => {
|
catItemIds, institutee.id!, meetings, instEntries,
|
||||||
const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id)
|
)
|
||||||
const rawLines = meetingEntries
|
|
||||||
.flatMap(e => splitNoteLines(e.note).map(line => ({
|
|
||||||
line,
|
|
||||||
tags: (e.lineTags ?? []).filter(t => t.text === line && catItemIds.has(t.criterionItemId)),
|
|
||||||
})))
|
|
||||||
.filter(x => x.tags.length > 0)
|
|
||||||
.filter(x => !parseNotationPrefix(x.line).isQuote)
|
|
||||||
.map(x => x.line)
|
|
||||||
const generalTagged = (m.generalNoteTags ?? [])
|
|
||||||
.filter(t => catItemIds.has(t.criterionItemId))
|
|
||||||
.map(t => t.text)
|
|
||||||
const allLines = [...rawLines, ...generalTagged]
|
|
||||||
const lines = dropDanglingMarkers(allLines).map(line => ` - ${formatNotationLine(line)}`)
|
|
||||||
if (lines.length === 0) return null
|
|
||||||
return ` [${m.date}]\n${lines.join('\n')}`
|
|
||||||
})
|
|
||||||
.filter((x): x is string => x !== null)
|
|
||||||
.join('\n') || '(keine getaggten Zeilen)'
|
|
||||||
|
|
||||||
const erlaeuterung = category.description
|
const erlaeuterung = category.description
|
||||||
? `Erläuterung: ${category.description}`
|
? `Erläuterung: ${category.description}`
|
||||||
|
|
|
||||||
67
src/utils/assignmentSignals.ts
Normal file
67
src/utils/assignmentSignals.ts
Normal 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!)}`
|
||||||
|
}
|
||||||
36
src/utils/assignmentTransfer.ts
Normal file
36
src/utils/assignmentTransfer.ts
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/utils/charAwareBatches.ts
Normal file
42
src/utils/charAwareBatches.ts
Normal 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)
|
||||||
|
}
|
||||||
10
src/utils/checklistCriterion.ts
Normal file
10
src/utils/checklistCriterion.ts
Normal 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
|
||||||
|
}
|
||||||
83
src/utils/checklistSuggestionPrompt.ts
Normal file
83
src/utils/checklistSuggestionPrompt.ts
Normal 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 }
|
||||||
|
}
|
||||||
91
src/utils/checklistTagging.ts
Normal file
91
src/utils/checklistTagging.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/utils/consultantImport.ts
Normal file
40
src/utils/consultantImport.ts
Normal 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)
|
||||||
|
}
|
||||||
240
src/utils/criterionEvidence.ts
Normal file
240
src/utils/criterionEvidence.ts
Normal 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)'
|
||||||
|
}
|
||||||
96
src/utils/criterionScoreSuggestion.ts
Normal file
96
src/utils/criterionScoreSuggestion.ts
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -39,10 +39,8 @@ export function buildCriterionStructureText(
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CriterionSuggestionLine {
|
export interface CriterionSuggestionLine {
|
||||||
/** Stabiler, nur innerhalb dieser Analyse gültiger Anker — nicht persistiert. */
|
|
||||||
lineNumber: number
|
lineNumber: number
|
||||||
text: string
|
text: string
|
||||||
/** Institutee-Name bei Gesprächsbeiträgen, undefined bei allgemeinen Notizen. */
|
|
||||||
speakerLabel?: string
|
speakerLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,24 +50,25 @@ export interface CriterionSuggestionPromptInput {
|
||||||
categories: FeedbackCategory[]
|
categories: FeedbackCategory[]
|
||||||
items: FeedbackCriterionItem[]
|
items: FeedbackCriterionItem[]
|
||||||
visibleCriteriaIds: number[] | undefined
|
visibleCriteriaIds: number[] | undefined
|
||||||
|
/** Optional: verengte Kriterienmenge für {{KRITERIEN_STRUKTUR}} (Hybrid-Notnagel bei großen Batches). */
|
||||||
|
structureCriteriaIds?: number[]
|
||||||
lines: CriterionSuggestionLine[]
|
lines: CriterionSuggestionLine[]
|
||||||
/** Globaler Lern-Korpus, siehe `getGlobalLineTagCorpus()`. */
|
|
||||||
corpus: ConversationLineTag[]
|
corpus: ConversationLineTag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const similarityLabel = (score: number): string => score >= 0.5 ? 'hoch' : score >= 0.25 ? 'mittel' : 'niedrig'
|
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> {
|
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 itemName = (id: number) => items.find(i => i.id === id)?.name ?? `#${id}`
|
||||||
|
|
||||||
const kriterienStruktur = buildCriterionStructureText(dimensions, categories, items, visibleCriteriaIds)
|
|
||||||
|
|
||||||
const protokollZeilen = lines
|
const protokollZeilen = lines
|
||||||
.map(l => `${l.lineNumber}. ${l.speakerLabel ? `[${l.speakerLabel}] ` : ''}${l.text}`)
|
.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 aehnlicheAltZuordnungen = lines.map(l => {
|
||||||
const hints = findSimilarTaggedLines(l.text, corpus)
|
const hints = findSimilarTaggedLines(l.text, corpus)
|
||||||
|
|
@ -85,7 +84,7 @@ export function buildCriterionSuggestionPromptContext(input: CriterionSuggestion
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'{{ASSIGNMENT_TYP}}': assignmentTypeName,
|
'{{ASSIGNMENT_TYP}}': assignmentTypeName,
|
||||||
'{{KRITERIEN_STRUKTUR}}': kriterienStruktur,
|
'{{KRITERIEN_STRUKTUR}}': buildCriterionStructureText(dimensions, categories, items, structureIds),
|
||||||
'{{GELERNTE_MUSTER}}': gelernteMuster,
|
'{{GELERNTE_MUSTER}}': gelernteMuster,
|
||||||
'{{AEHNLICHE_ALT_ZUORDNUNGEN}}': aehnlicheAltZuordnungen,
|
'{{AEHNLICHE_ALT_ZUORDNUNGEN}}': aehnlicheAltZuordnungen,
|
||||||
'{{PROTOKOLL_ZEILEN}}': protokollZeilen,
|
'{{PROTOKOLL_ZEILEN}}': protokollZeilen,
|
||||||
|
|
@ -93,16 +92,26 @@ export function buildCriterionSuggestionPromptContext(input: CriterionSuggestion
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedCriterionSuggestionResponse {
|
export interface ParsedCriterionSuggestionResponse {
|
||||||
/** Zeilennummer → priorisierte Kriterium-ID-Liste (leer = kein Vorschlag). */
|
|
||||||
lineSuggestions: Map<number, number[]>
|
lineSuggestions: Map<number, number[]>
|
||||||
/** Kriterium-ID → destilliertes Formulierungsmuster (Nebenprodukt dieses Laufs). */
|
|
||||||
patternHints: Map<number, string>
|
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
|
* Parst `ZEILE 3: 12, 47` / `ZEILE 9: -` (+ optional `MUSTER:`-Block) — gleiche Robustheit
|
||||||
* `MUSTER:`-Block) — bewusst kein JSON, gleiches Robustheitsprinzip wie `parseDimensionAiResponse`
|
* wie der Checklisten-Parser (Bullet-/Markdown-Strip, tolerante ID-Liste).
|
||||||
* (Codefence-Stripping vorab, tolerant gegenüber fehlenden/kaputten Zeilen, kein Absturz).
|
|
||||||
*/
|
*/
|
||||||
export function parseCriterionSuggestionResponse(raw: string): ParsedCriterionSuggestionResponse {
|
export function parseCriterionSuggestionResponse(raw: string): ParsedCriterionSuggestionResponse {
|
||||||
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
|
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) {
|
for (const rawLine of lines) {
|
||||||
const line = rawLine.trim()
|
let line = rawLine.trim()
|
||||||
if (/^MUSTER:\s*$/i.test(line)) {
|
if (/^MUSTER:\s*$/i.test(line)) {
|
||||||
flushPattern()
|
flushPattern()
|
||||||
mode = 'patterns'
|
mode = 'patterns'
|
||||||
|
|
@ -133,16 +142,10 @@ export function parseCriterionSuggestionResponse(raw: string): ParsedCriterionSu
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'lines') {
|
if (mode === 'lines') {
|
||||||
|
line = line.replace(/^[-*•]\s+/, '').replace(/\*\*/g, '')
|
||||||
const m = line.match(/^ZEILE\s+(\d+)\s*:\s*(.*)$/i)
|
const m = line.match(/^ZEILE\s+(\d+)\s*:\s*(.*)$/i)
|
||||||
if (!m) continue
|
if (!m) continue
|
||||||
const lineNumber = Number(m[1])
|
lineSuggestions.set(Number(m[1]), parseCriterionIdList(m[2]))
|
||||||
const rest = m[2].trim()
|
|
||||||
if (rest === '' || rest === '-') {
|
|
||||||
lineSuggestions.set(lineNumber, [])
|
|
||||||
} else {
|
|
||||||
const ids = rest.split(',').map(s => Number(s.trim())).filter(n => Number.isFinite(n))
|
|
||||||
lineSuggestions.set(lineNumber, ids)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const m = line.match(/^(\d+)\s*:\s*(.*)$/)
|
const m = line.match(/^(\d+)\s*:\s*(.*)$/)
|
||||||
if (m) {
|
if (m) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { getMeetingExportData, getFeedbackCriterionItemsByIds, listFeedbackCategoriesAll } from '../db'
|
import { getMeetingExportData, getFeedbackCriterionItemsByIds, listFeedbackCategoriesAll } from '../db'
|
||||||
import { normalizeRatingScore } from '../config/constants'
|
import { normalizeRatingScore } from '../config/constants'
|
||||||
|
import { getEffectiveChecklistCriterionId } from './checklistCriterion'
|
||||||
|
|
||||||
export async function exportMeetingAsMarkdown(meetingId: number): Promise<string> {
|
export async function exportMeetingAsMarkdown(meetingId: number): Promise<string> {
|
||||||
const {
|
const {
|
||||||
|
|
@ -96,7 +97,7 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
for (const instituteeId of targets) {
|
for (const instituteeId of targets) {
|
||||||
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
|
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
|
||||||
if (!response) continue
|
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 critName = effectiveCritId ? criteria.find(c => c.id === effectiveCritId)?.name : null
|
||||||
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
|
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
|
||||||
const who = ci.scope === 'meetingWide' ? '' : ` — ${inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'}`
|
const who = ci.scope === 'meetingWide' ? '' : ` — ${inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'}`
|
||||||
|
|
|
||||||
|
|
@ -104,14 +104,21 @@ export function NotationText({ text, className, renderLineAddon, colorBands }: P
|
||||||
const lines = text.split('\n')
|
const lines = text.split('\n')
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{lines.map((line, i) => renderLineAddon
|
{lines.map((line, i) => {
|
||||||
? (
|
// Addon-Key = trim — gleiche Normalisierung wie `splitNoteLines` / Analyse-Map
|
||||||
<div key={i} className="flex items-start gap-1">
|
const addonKey = line.trim()
|
||||||
<div className="flex-1 min-w-0">{parseLine(line, i, bands)}</div>
|
if (renderLineAddon && addonKey) {
|
||||||
{renderLineAddon(line, i)}
|
return (
|
||||||
</div>
|
<div key={i} className="flex items-start gap-2">
|
||||||
)
|
<div className="flex-1 min-w-0">{parseLine(line, i, bands)}</div>
|
||||||
: parseLine(line, i, bands))}
|
<div className="flex-shrink-0 w-[10.5rem] flex justify-end pt-0.5">
|
||||||
|
{renderLineAddon(addonKey, i)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return parseLine(line, i, bands)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
getAssignmentFeedback, listFeedbackCategoryRatings, listFeedbackDimensionTexts, listFeedbackCategoryTexts,
|
getAssignmentFeedback, listFeedbackCategoryRatings, listFeedbackDimensionTexts, listFeedbackCategoryTexts,
|
||||||
listAssignmentTypes, listPhaseTemplates, getOrSeedRatingColorBands,
|
listAssignmentTypes, listPhaseTemplates, getOrSeedRatingColorBands,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
|
import { getEffectiveChecklistCriterionId } from './checklistCriterion'
|
||||||
import { DEFAULT_RATING_COLOR_BANDS, normalizeRatingColorBands, resolveScoreStyle } from '../config/constants'
|
import { DEFAULT_RATING_COLOR_BANDS, normalizeRatingColorBands, resolveScoreStyle } from '../config/constants'
|
||||||
import { splitNoteLines, parseNotationPrefix } from './notationParser'
|
import { splitNoteLines, parseNotationPrefix } from './notationParser'
|
||||||
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
||||||
|
|
@ -287,7 +288,7 @@ function AssignmentReportDocument({ data }: { data: AssignmentReportData }) {
|
||||||
return targets.map(instituteeId => {
|
return targets.map(instituteeId => {
|
||||||
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
|
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
|
||||||
if (!response) return null
|
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 critName = effectiveCritId ? visibleItems.find(i => i.id === effectiveCritId)?.name : undefined
|
||||||
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
|
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
|
||||||
const who = ci.scope === 'meetingWide' ? '' : ` — ${inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'}`
|
const who = ci.scope === 'meetingWide' ? '' : ` — ${inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'}`
|
||||||
|
|
|
||||||
21
src/utils/promptSize.ts
Normal file
21
src/utils/promptSize.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/utils/taggingBatches.ts
Normal file
69
src/utils/taggingBatches.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -16,5 +16,5 @@
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": 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"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,22 @@ export default defineConfig(({ mode }) => {
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.svg', 'apple-touch-icon.png'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Assignment Monitor DEV',
|
name: 'Assignment Monitor',
|
||||||
short_name: 'AM-DEV',
|
short_name: 'AM Monitor',
|
||||||
description: 'DEV — Trainee-Bewertungs-App (nicht Produktiv)',
|
description: 'Live-Bewertung und Feedback für Capgemini-Trainees während Kunden-Assignments (DEV).',
|
||||||
|
lang: 'de',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
theme_color: '#0070AD',
|
theme_color: '#0070AD',
|
||||||
background_color: '#ffffff',
|
background_color: '#eef2f5',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
icons: [
|
icons: [
|
||||||
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png' },
|
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png' },
|
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user