This commit is contained in:
Lars 2026-07-06 19:08:24 +02:00
parent 911dcace72
commit 9c68c414d8
53 changed files with 2919 additions and 469 deletions

3
.gitignore vendored
View File

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

View File

@ -10,37 +10,65 @@ Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) wäh
- React 18 + TypeScript + Vite (PWA) - React 18 + 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`
- Dexie.js v5 — IndexedDB-Wrapper, kein Backend - **Lokaler Node/Express-Server + SQLite** (seit 2026-07-04) — löst Dexie/IndexedDB als primären Datenspeicher ab, siehe eigener Abschnitt unten
- React Router v6 - React Router v6
- Capgemini-Brandfarbe: `#0070AD` (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`)
## Projektstruktur (nach Architektur-Refactoring, Stand 2026-07-02) ## Projektstruktur (Stand 2026-07-04)
``` ```
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_OPTIONS, INST_COLORS, AI_CONFIG, RATING_NUM_MAP, BRAND_COLOR, DEFAULT_RATING_TREND_WEIGHT_STEP
db/ db/
index.ts ← reiner Re-Export (types + schema + seeds) index.ts ← Re-Export (types + queries), rpcClient.ts eingebunden
types.ts ← alle Interfaces, FeedbackRating, AppSettings types.ts ← alle Interfaces, FeedbackRating, AppSettings — von Client UND Server importiert
schema.ts ← Dexie-Klasse + Migrationsversionen rpcClient.ts ← rpc(module, fn, ...args) — POST /api/rpc, einziger Netzwerk-Kontaktpunkt des Clients
seeds/ ← assignmentTypes.ts, feedbackStructure.ts, migrations.ts 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)
...übrige Pages unverändert am alten Ort ...übrige Pages unverändert am alten Ort
utils/ utils/
ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung (genutzt von Evaluation.tsx & AssignmentFeedbackPage.tsx) ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung + weightedAverageScore/weightedAverageRating (genutzt von Evaluation.tsx, AssignmentFeedbackPage.tsx, AssessmentTab.tsx, AssignmentDetail.tsx)
server/
index.ts ← Express-App, ein Endpunkt POST /api/rpc
database.ts ← node:sqlite-Verbindung (server/data/assignment-monitor.sqlite3), Schema aus tableRegistry.ts
rpcModules.ts ← Dispatch-Registry { appSettings, consultants, ..., backup } → server/db/queries/*.ts
db/
tableRegistry.ts ← EINE Quelle der Wahrheit: alle 21 Tabellen + Spalten + JSON-/Bool-Codecs (treibt Schema UND Backup)
crud.ts ← generische SQL-CRUD-Bausteine über die Registry
queries/ ← Server-Pendant zu src/db/queries/*.ts, gleiche Funktionsnamen, echtes SQL statt Dexie
scripts/verifyBackupRoundtrip.ts ← automatisierter Vollständigkeits-Beweis für Backup-Export/-Import
``` ```
Bestehende Imports aus `'../db'` funktionieren weiterhin unverändert (Re-Export). ### Datenbank: lokaler Server statt IndexedDB (seit 2026-07-04)
Grund: Mehrere Browser (Chrome, Edge, ...) auf demselben Laptop sollen dieselben Daten sehen, unabhängig vom Browser-Cache — als Testschritt vor einem späteren Umzug auf einen Docker-Container auf einem Linux-Server. Browser können keine SQLite-Datei direkt öffnen, daher läuft ein kleiner Node/Express-Server (`server/`, Port 4000) mit **`node:sqlite`** (Node-eingebautes SQLite-Modul, **kein** `better-sqlite3`!). Alle Browser sprechen über `http://localhost:5173` (Vite-Dev-Proxy `/api``http://localhost:4000`) denselben Server an.
**Abweichung vom ursprünglich gewählten Stack:** `better-sqlite3` scheiterte lokal an einer fehlenden/kaputten Python-Installation (node-gyp-Kompilierung). `node:sqlite` bietet dieselbe synchrone API (`DatabaseSync`, `.prepare().run()/.get()/.all()`), braucht keine native Kompilierung und ist ab dieser Node-Version (25.x) ohne Flag nutzbar (Node meldet es noch als "experimental" — rein informativ, funktioniert stabil). Falls auf einem anderen Rechner/Server `node:sqlite` fehlt (ältere Node-Version), wäre `better-sqlite3` dort nachzuholen, sofern eine funktionierende Build-Toolchain (Python + C++-Compiler) vorhanden ist.
**RPC statt REST:** Ein einziger Endpunkt `POST /api/rpc` mit Body `{ module, fn, args }` statt ~80 einzelnen Routen — Client (`src/db/rpcClient.ts`) und Server (`server/rpcModules.ts`) sind darüber 1:1 gekoppelt: jede Funktion aus `src/db/queries/*.ts` hat ein gleichnamiges Pendant in `server/db/queries/*.ts`.
**Dexie/IndexedDB ist toter Code, aber noch nicht entfernt:** `src/db/schema.ts` und `src/db/seeds/*.ts` bleiben unverändert im Repo liegen, werden aber von nichts mehr gelesen (Seed-Aufrufe in `main.tsx` wurden entfernt). Vollständiges Entfernen ist eine spätere, bewusste Entscheidung.
**Backup ist jetzt server-seitig:** `src/utils/dbBackup.ts` ruft `rpc('backup', 'exportAll'/'importAll', ...)` statt direkt gegen Dexie zu laufen. `server/db/queries/backup.ts` iteriert exakt `tableRegistry.ts` — dieselbe Liste, die auch das SQL-Schema erzeugt, damit eine neue Tabelle nicht mehr unbemerkt aus dem Backup fallen kann (das ist genau einmal mit `appSettings` passiert, siehe Erledigt-Historie). `server/scripts/verifyBackupRoundtrip.ts` (`npm run verify-backup -- <pfad>`) beweist automatisiert Zeilenzahl- und Inhaltsgleichheit pro Tabelle nach einem Import→Export-Zyklus.
### DB-Zugriffsschicht (`src/db/queries/`, seit 2026-07-04, intern auf RPC umgestellt)
Alle Komponenten greifen auf Daten ausschließlich über benannte Funktionen aus `src/db/queries/*.ts` zu (re-exportiert über `'../db'`) — nach außen unverändert seit der ursprünglichen Extraktion, nur die Innenseite ruft jetzt `rpc(...)` statt `db.<table>.*` auf. Ein Modul pro Domäne: `appSettings.ts`, `consultants.ts` (Groups+Consultants), `assignmentTypes.ts` (AssignmentTypes+PhaseTemplates), `assignments.ts`, `feedbackStructure.ts` (Dimensions/Categories/CriterionItems — `filterVisibleFeedbackStructure()` bleibt reine Client-Funktion ohne RPC), `meetings.ts` (MeetingInstances inkl. kanonischem `!deletedAt && status==='done'`-Filter als `listDoneMeetingsChronological()`, Cascade-Deletes, Export-Datenaggregation `getMeetingExportData()`), `conversationEntries.ts`, `assessments.ts`, `assignmentFeedback.ts`.
## Entwicklung ## Entwicklung
```bash ```bash
npm run dev # Dev-Server starten npm run dev:all # Frontend (Vite, :5173) + lokaler Server (Express, :4000) zusammen starten
npx tsc --noEmit # TypeScript-Check — muss vor jeder Abgabe fehlerfrei sein npm run dev # Nur Frontend (Server muss separat laufen, sonst laufen alle DB-Zugriffe ins Leere)
npm run server # Nur den lokalen Server
npx tsc -b # TypeScript-Check über alle 3 Projekte (App/Node/Server) — muss vor jeder Abgabe fehlerfrei sein
``` ```
**Wichtig:** `npx tsc --noEmit` am Root prüft **nichts** (0 Dateien, `tsc --noEmit --listFiles` bestätigt das) — der Root-`tsconfig.json` hat `"files": []` und nur `references`. Ohne `-b` (Build-Modus) werden referenzierte Projekte nicht ausgewertet. Der bisher in diesem Dokument dokumentierte Workflow `npx tsc --noEmit` war dadurch vermutlich seit Einführung der Project References ein stiller No-Op — **immer `npx tsc -b` verwenden.**
--- ---
## Kritische Eigenheiten ## Kritische Eigenheiten
@ -57,13 +85,7 @@ ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Kriter
Referenziert `FeedbackCriterionItem.id[]`. Leer = alle Kriterien anzeigen. Referenziert `FeedbackCriterionItem.id[]`. Leer = alle Kriterien anzeigen.
Filterlogik (immer so anwenden): Filterlogik: `filterVisibleFeedbackStructure(allCategories, allItems, criteriaIds)` in `src/db/queries/feedbackStructure.ts` — seit dem `db/queries/`-Refactoring (2026-07-04) die einzige Implementierung, vorher identisch dupliziert in `MeetingView.tsx` und `AssignmentDetail.tsx`. Nicht erneut inline schreiben.
```typescript
const itemFilter = aType?.criteriaIds?.length ? new Set(aType.criteriaIds) : null
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId))
const filteredCats = itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats
```
### Rating-Skala ### Rating-Skala
@ -77,7 +99,7 @@ Aggregation: gewichteter Durchschnitt → `avg < 1.5` Not · `< 2.5` Partially
**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. **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. Umgesetzt in `catMode()` (`AssessmentTab.tsx`) und `buildGroupMeetingScores()` (`ratingTrend.ts`) — beim nächsten neuen gewichteten Durchschnitt genauso prüfen. **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
@ -118,9 +140,15 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
|---|---|---| |---|---|---|
| H | **KI-Generierung derzeit funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich blockiert das Capgemini-Netzwerk/Firewall den Call zu `openrouter.ai`. Root Cause noch nicht untersucht — ggf. mit internem IT-Support klären. | `src/pages/AssignmentFeedbackPage.tsx` | | H | **KI-Generierung derzeit funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich blockiert das Capgemini-Netzwerk/Firewall den Call zu `openrouter.ai`. Root Cause noch nicht untersucht — ggf. mit internem IT-Support klären. | `src/pages/AssignmentFeedbackPage.tsx` |
| H | **Größeres Vorhaben (noch nicht gescoped):** Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Spezialwissen je Dimension + Platzhaltersystem — Ablösung des aktuellen Einzel-Prompts in `buildAiPrompt`. Braucht eigene Design-Session. | `src/pages/AssignmentFeedbackPage.tsx` | | H | **Größeres Vorhaben (noch nicht gescoped):** Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Spezialwissen je Dimension + Platzhaltersystem — Ablösung des aktuellen Einzel-Prompts in `buildAiPrompt`. Braucht eigene Design-Session. | `src/pages/AssignmentFeedbackPage.tsx` |
| M | `db/queries/`-Layer einführen (Vorbereitung SQLite-Migration, DB-Calls aus Komponenten extrahieren) | `src/db/queries/` (neu) |
| M | **Selbstlernendes Tagging (Idee, noch nicht gescoped):** Zeile-zu-Kriterium-Zuordnungen sammeln und daraus beim Tippen automatische Tag-Vorschläge ableiten (ähnliche Formulierungen → gleiches Kriterium). Braucht eigene Design-Session (Ähnlichkeits-/Lernmechanik, Datenbasis, UI). | `src/pages/meeting/CriterionTagPicker.tsx` | | M | **Selbstlernendes Tagging (Idee, noch nicht gescoped):** Zeile-zu-Kriterium-Zuordnungen sammeln und daraus beim Tippen automatische Tag-Vorschläge ableiten (ähnliche Formulierungen → gleiches Kriterium). Braucht eigene Design-Session (Ähnlichkeits-/Lernmechanik, Datenbasis, UI). | `src/pages/meeting/CriterionTagPicker.tsx` |
| L | SQLite WASM + OPFS Migration (kein festes Datum) | `src/db/schema.ts` | | M | **Migration der echten Daten aussteht:** Aktuelle IndexedDB-Daten müssen noch einmalig über Config → Backup → Export (alte Version) exportiert und über Config → Backup → Import (neue, serverbasierte Version) in die SQLite-DB eingespielt werden. Siehe Datenbank-Abschnitt oben. | — |
| L | Dexie/IndexedDB-Legacy-Code vollständig entfernen (`src/db/schema.ts`, `src/db/seeds/`) — aktuell bewusst nur stillgelegt, nicht gelöscht | `src/db/schema.ts`, `src/db/seeds/` |
| L | Server-seitiges "Seed-if-empty" für Assignment-Typen/Feedback-Struktur (aktuell nur clientseitig vorhanden und nicht mehr wirksam) — nicht dringend, da der Backup-Import denselben Zweck erfüllt | `server/db/queries/` (neu) |
| L | SQLite WASM + OPFS Migration — durch den lokalen Server-Ansatz (2026-07-04) überholt/hinfällig | `src/db/schema.ts` |
**Erledigt (2026-07-04, zweite Runde):** Lokaler Node/Express-Server mit `node:sqlite` (statt IndexedDB) eingeführt, RPC-Bridge (`POST /api/rpc`) zwischen `src/db/queries/*.ts` (Client) und neuem `server/db/queries/*.ts` (Server) — siehe eigener Abschnitt "Datenbank: lokaler Server statt IndexedDB" oben für Details, Abweichungen (node:sqlite statt better-sqlite3) und die wichtige `npx tsc -b`-statt-`--noEmit`-Korrektur. Ziel: mehrere Browser auf demselben Laptop teilen sich dieselben Daten, unabhängig vom Browser-Cache, als Zwischenschritt vor einem Docker/Linux-Deployment.
**Erledigt (2026-07-04):** `db/queries/`-Layer eingeführt — alle 20 Komponenten/Utils mit vorherigem direktem `db.<table>.*`-Zugriff nutzen jetzt benannte Funktionen aus `src/db/queries/*.ts` (siehe eigener Abschnitt oben), reine Extraktion ohne beabsichtigte Verhaltensänderung. Dabei bewusst mitkonsolidiert: die drei unabhängig implementierten Varianten der gewichteten-Durchschnitt-Logik (`ratingTrend.ts`, `AssessmentTab.tsx`, `AssignmentDetail.tsx`) laufen jetzt über eine einzige Funktion `weightedAverageScore()`/`weightedAverageRating()` in `ratingTrend.ts` — dabei einen fehlenden `Σweight===0`-Guard in `AssignmentDetail.tsx` gefixt (identische Bug-Klasse wie die zweite Bug-Klasse oben, dort war er noch nicht behoben). Ebenfalls konsolidiert: die "AssignmentType.criteriaIds schränkt sichtbare Kriterien ein"-Filterlogik (vorher dupliziert in `MeetingView.tsx`/`AssignmentDetail.tsx`, jetzt `filterVisibleFeedbackStructure()`) und die doppelten Datenabfragen in `meetingExport.ts`s Markdown-/JSON-Export (jetzt `getMeetingExportData()` in `meetings.ts`). `dbBackup.ts` und Seed-Code bleiben bewusst bei direktem `db`-Zugriff (siehe Abschnitt oben). `npx tsc --noEmit` läuft fehlerfrei.
**Erledigt (2026-07-02):** Konfiguration zentralisiert (`config/constants.ts`), `db/index.ts` in types/schema/seeds aufgeteilt, `MeetingView.tsx` in 3 Dateien aufgeteilt (`pages/meeting/`), `Evaluation.tsx` auf `feedbackCriterionItems` migriert + nach Assignment gruppiert + Papierkorb/Status-Filter + N/A-Bug gefixt, `zustand` entfernt (war ungenutzt). Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung umgesetzt (`src/utils/ratingTrend.ts`, neue Tabelle `appSettings`, neuer Config-Tab „Gewichtung“, Vorschlags-Badge + Übernehmen/Trend-in-Text auf `AssignmentFeedbackPage`, `buildAiPrompt` um Trend-Abschnitt erweitert und Legacy-`db.criteria`-Bug dabei gefixt). Kriterium-Zuordnung für Freitext-Notizen umgesetzt: `ConversationEntry.lineTags` (Zeilentext → Kriterium, nach einem verworfenen Zwischenstand mit "ein Entry pro Zeile" — siehe Sonstige Fallstricke), `CriterionTagPicker.tsx` (Chip+Popover mit Suche, unverändert seit erster Version) wird pro Zeile direkt an der Zeile via `NotationText`s neuer optionaler `renderLineAddon`-Prop gerendert; Zuordnung passiert bewusst nur nachträglich (nicht live während des Tippens), um den Schreibfluss nicht zu stören. `buildAiPrompt` führt Assessment-Notizen und getaggte Zeilen jetzt pro Kriterium zusammen, unzugeordnete Zeilen bleiben im allgemeinen Protokoll-Block. **(2026-07-03)** Vier Folge-Fixes: `meetingExport.ts` löste Kriterien über die Legacy-Tabelle `db.criteria` auf (identischer Bug wie der frühere `buildAiPrompt`-Fix) — dadurch war die Gesamtbewertung im Export faktisch leer; jetzt auf `feedbackCriterionItems` umgestellt, Markdown-Export zeigt zusätzlich das Kriterium pro getaggter Protokollzeile. `dbBackup.ts`s `TABLE_NAMES` enthielt `appSettings` nicht — Voll-Backups verloren die Gewichtungs-Config stillschweigend; jetzt ergänzt samt Kommentar, dass neue Tabellen dort manuell nachgezogen werden müssen. Notation → Bewertungs-Vorschlag: `detectNotationRating()` in `notationParser.tsx` (`!`→Not, `(!)`→Partially, `(+)`→Nearly, `+`→Fully) kombiniert mit `lineTags` liefert in `AssessmentTab.tsx` einen "Vorschlag aus Protokoll"-Badge pro Kriterium-Item, unabhängig vom bestehenden "Aus Protokoll"-Button (andere Datenquelle). `AssignmentCreate.tsx`: Assignment-Nummer ist jetzt Pflichtfeld (nur auf Formular-Ebene, Typ bleibt optional wegen Altdaten). Kriterium-Gewichtung auf `0,02,0` in `0,1`-Schritten verfeinert (`FeedbackStructureConfig.tsx`, vorher `×1``×5`), dabei einen latenten Division-durch-Null-Bug gefixt, der durch das neu erlaubte Gewicht `0` real wurde (`catMode()` in `AssessmentTab.tsx`, `buildGroupMeetingScores()` in `ratingTrend.ts` — siehe zweite Bug-Klasse oben). Sprecher eines bereits erfassten Protokoll-Beitrags kann nachträglich geändert werden (`ConversationTab.tsx`, im Bearbeiten-Modus neben Füllwörtern — ändert sofort `ConversationEntry.instituteeId`, kein separater Speichern-Schritt nötig). N/A-Aggregationsbug in `buildAiPrompt` (`AssignmentFeedbackPage.tsx`) gefixt — war die letzte offene Stelle dieser Bug-Klasse außer der jetzt migrierten `FeedbackDraftPage.tsx`. `FeedbackDraftPage.tsx` vollständig auf `feedbackCriterionItems`/`feedbackCategories` migriert (letzter Konsument der Legacy-Tabellen ist jetzt nur noch der Seed-Code), Notizen jetzt korrekt pro Kriterium über `lineTags` statt aller Protokoll-Notizen ungefiltert an jedes Kriterium gehängt, Score-Anzeige auf `RATING_OPTIONS`-Labels umgestellt (statt `/5`-Rohwert). Toter Code (verwaiste AssignmentType/PhaseTemplate-CRUD-Reste) aus `FeedbackStructureConfig.tsx` entfernt. „Dashboard zeigt keine Assignment-Nummer" war bereits gelöst, nur die Doku war veraltet — richtiggestellt. **Erledigt (2026-07-02):** Konfiguration zentralisiert (`config/constants.ts`), `db/index.ts` in types/schema/seeds aufgeteilt, `MeetingView.tsx` in 3 Dateien aufgeteilt (`pages/meeting/`), `Evaluation.tsx` auf `feedbackCriterionItems` migriert + nach Assignment gruppiert + Papierkorb/Status-Filter + N/A-Bug gefixt, `zustand` entfernt (war ungenutzt). Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung umgesetzt (`src/utils/ratingTrend.ts`, neue Tabelle `appSettings`, neuer Config-Tab „Gewichtung“, Vorschlags-Badge + Übernehmen/Trend-in-Text auf `AssignmentFeedbackPage`, `buildAiPrompt` um Trend-Abschnitt erweitert und Legacy-`db.criteria`-Bug dabei gefixt). Kriterium-Zuordnung für Freitext-Notizen umgesetzt: `ConversationEntry.lineTags` (Zeilentext → Kriterium, nach einem verworfenen Zwischenstand mit "ein Entry pro Zeile" — siehe Sonstige Fallstricke), `CriterionTagPicker.tsx` (Chip+Popover mit Suche, unverändert seit erster Version) wird pro Zeile direkt an der Zeile via `NotationText`s neuer optionaler `renderLineAddon`-Prop gerendert; Zuordnung passiert bewusst nur nachträglich (nicht live während des Tippens), um den Schreibfluss nicht zu stören. `buildAiPrompt` führt Assessment-Notizen und getaggte Zeilen jetzt pro Kriterium zusammen, unzugeordnete Zeilen bleiben im allgemeinen Protokoll-Block. **(2026-07-03)** Vier Folge-Fixes: `meetingExport.ts` löste Kriterien über die Legacy-Tabelle `db.criteria` auf (identischer Bug wie der frühere `buildAiPrompt`-Fix) — dadurch war die Gesamtbewertung im Export faktisch leer; jetzt auf `feedbackCriterionItems` umgestellt, Markdown-Export zeigt zusätzlich das Kriterium pro getaggter Protokollzeile. `dbBackup.ts`s `TABLE_NAMES` enthielt `appSettings` nicht — Voll-Backups verloren die Gewichtungs-Config stillschweigend; jetzt ergänzt samt Kommentar, dass neue Tabellen dort manuell nachgezogen werden müssen. Notation → Bewertungs-Vorschlag: `detectNotationRating()` in `notationParser.tsx` (`!`→Not, `(!)`→Partially, `(+)`→Nearly, `+`→Fully) kombiniert mit `lineTags` liefert in `AssessmentTab.tsx` einen "Vorschlag aus Protokoll"-Badge pro Kriterium-Item, unabhängig vom bestehenden "Aus Protokoll"-Button (andere Datenquelle). `AssignmentCreate.tsx`: Assignment-Nummer ist jetzt Pflichtfeld (nur auf Formular-Ebene, Typ bleibt optional wegen Altdaten). Kriterium-Gewichtung auf `0,02,0` in `0,1`-Schritten verfeinert (`FeedbackStructureConfig.tsx`, vorher `×1``×5`), dabei einen latenten Division-durch-Null-Bug gefixt, der durch das neu erlaubte Gewicht `0` real wurde (`catMode()` in `AssessmentTab.tsx`, `buildGroupMeetingScores()` in `ratingTrend.ts` — siehe zweite Bug-Klasse oben). Sprecher eines bereits erfassten Protokoll-Beitrags kann nachträglich geändert werden (`ConversationTab.tsx`, im Bearbeiten-Modus neben Füllwörtern — ändert sofort `ConversationEntry.instituteeId`, kein separater Speichern-Schritt nötig). N/A-Aggregationsbug in `buildAiPrompt` (`AssignmentFeedbackPage.tsx`) gefixt — war die letzte offene Stelle dieser Bug-Klasse außer der jetzt migrierten `FeedbackDraftPage.tsx`. `FeedbackDraftPage.tsx` vollständig auf `feedbackCriterionItems`/`feedbackCategories` migriert (letzter Konsument der Legacy-Tabellen ist jetzt nur noch der Seed-Code), Notizen jetzt korrekt pro Kriterium über `lineTags` statt aller Protokoll-Notizen ungefiltert an jedes Kriterium gehängt, Score-Anzeige auf `RATING_OPTIONS`-Labels umgestellt (statt `/5`-Rohwert). Toter Code (verwaiste AssignmentType/PhaseTemplate-CRUD-Reste) aus `FeedbackStructureConfig.tsx` entfernt. „Dashboard zeigt keine Assignment-Nummer" war bereits gelöst, nur die Doku war veraltet — richtiggestellt.
@ -128,7 +156,8 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
## Datenschutz ## Datenschutz
- Alle Daten lokal (IndexedDB) — **kein Public Cloud Upload** - Alle Daten lokal — seit 2026-07-04 in `server/data/assignment-monitor.sqlite3` (lokaler Server auf demselben Rechner) statt im Browser-IndexedDB; weiterhin **kein Cloud-Upload**, Server ist nur unter `localhost` erreichbar (kein Auth-Schutz nötig, solange das so bleibt — siehe Offene Punkte, falls sich das ändert, z.B. bei Docker/Linux-Deployment mit Netzwerkzugriff)
- `server/data/` ist per `.gitignore` ausgeschlossen — Datenbankdatei nie committen
- GDPR-relevant: Institutee-Namen, Kundennamen, Gesprächsnotizen - GDPR-relevant: Institutee-Namen, Kundennamen, Gesprächsnotizen
- KI-API-Key in `localStorage` — nie loggen oder ausgeben - KI-API-Key in `localStorage` — nie loggen oder ausgeben
- Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln - Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln

View File

@ -6,7 +6,9 @@ Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt naht
## Stand & nächste Schritte ## Stand & nächste Schritte
Zuletzt abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ratingTrend.ts`), Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags` + `CriterionTagPicker.tsx`), Notation → Bewertungs-Vorschlag in der Gesamtbewertung, feinere Kriterium-Gewichtung (0,02,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`. Zuletzt abgeschlossen (2026-07-04): Umstellung von Dexie/IndexedDB auf einen lokalen Node/Express-Server mit `node:sqlite` (RPC-Bridge `POST /api/rpc`), damit mehrere Browser auf demselben Laptop dieselben Daten sehen. Details: `CLAUDE.md` → Abschnitt "Datenbank: lokaler Server statt IndexedDB". **Sofort nächster Schritt: die echten IndexedDB-Daten müssen noch per Backup-Export/-Import in die neue SQLite-DB migriert werden** — siehe CLAUDE.md-Offene-Punkte-Tabelle, Prio M.
Davor abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ratingTrend.ts`), Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags` + `CriterionTagPicker.tsx`), Notation → Bewertungs-Vorschlag in der Gesamtbewertung, feinere Kriterium-Gewichtung (0,02,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`.
**Vollständige, priorisierte Liste der offenen Punkte:** siehe `CLAUDE.md` → Abschnitt "Offene Punkte (priorisiert)" — das ist die laufend gepflegte Quelle, hier nicht dupliziert. Größere offene Themen, die eine eigene Design-Session brauchen: mehrstufiges Prompt-System für die KI-Generierung, selbstlernendes Tagging. **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.
@ -18,13 +20,15 @@ Zuletzt abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ra
## Tech-Stack ## Tech-Stack
- **React 18 + TypeScript + Vite** (PWA) - **React 18 + TypeScript + Vite** (PWA-Frontend)
- **Tailwind CSS v4** (`@import "tailwindcss"` — kein `tailwind.config.js`!) - **Tailwind CSS v4** (`@import "tailwindcss"` — kein `tailwind.config.js`!)
- **Dexie.js v5** als IndexedDB-Wrapper (lokale DB im Browser, kein Backend) - **Lokaler Node/Express-Server + `node:sqlite`** (seit 2026-07-04) — löst Dexie/IndexedDB als Datenspeicher ab, siehe CLAUDE.md. `npm run dev:all` startet Frontend + Server zusammen.
- **React Router v6** - **React Router v6**
- **Capgemini-Brandfarbe:** `#0070AD` - **Capgemini-Brandfarbe:** `#0070AD`
- Arbeitsverzeichnis: `C:\dev\AssigmentMonitor` - Arbeitsverzeichnis: `C:\dev\AssigmentMonitor`
**Wichtig für den TypeScript-Check:** `npx tsc --noEmit` prüft am Root **nichts** (Project-References-Setup, `files: []`) — immer `npx tsc -b` verwenden.
## Bewertungsskala (kritisch für alle Berechnungen) ## Bewertungsskala (kritisch für alle Berechnungen)
```typescript ```typescript
@ -110,13 +114,14 @@ MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useP
## Bekannte offene Punkte (Priorität nach Dringlichkeit) ## Bekannte offene Punkte (Priorität nach Dringlichkeit)
### Mittlere Aufgaben ### Mittlere Aufgaben
1. **`db/queries/`-Layer** — DB-Zugriffe aus Komponenten in domänenspezifische Query-Funktionen extrahieren, bereitet SQLite-Migration vor 1. **KI-Generierung funktionsunfähig**`generateWithAi()` schlägt fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`. Root Cause nicht untersucht.
2. **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) ### Große Aufgaben (eigene Design-Session nötig)
3. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus. 2. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus.
4. **Mehrstufiges, pro Dimension konfigurierbares Prompt-System** — löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab, braucht Spezialwissen je Dimension + Platzhaltersystem. 3. **Mehrstufiges, pro Dimension konfigurierbares Prompt-System** — löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab, braucht Spezialwissen je Dimension + Platzhaltersystem.
5. **Selbstlernendes Tagging** — aus bisherigen Zeile-zu-Kriterium-Zuordnungen automatische Tag-Vorschläge beim Tippen ableiten. 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-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.
@ -159,7 +164,7 @@ Alle 21 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen
## Datenschutz-Hinweise ## Datenschutz-Hinweise
- Alle Daten lokal, kein Cloud-Backend - Alle Daten lokal — seit 2026-07-04 in `server/data/assignment-monitor.sqlite3` (lokaler Server, kein Cloud-Backend), `server/data/` ist gitignored
- GDPR-relevant: Institutee-Namen + Kundennamen in der DB - GDPR-relevant: Institutee-Namen + Kundennamen in der DB
- KI-API: OpenRouter, API-Key in localStorage (nicht im Klartext loggen!) - KI-API: OpenRouter, API-Key in localStorage (nicht im Klartext loggen!)
- Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln - Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln
@ -167,11 +172,11 @@ Alle 21 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen
## TypeScript-Build ## TypeScript-Build
```bash ```bash
npx tsc --noEmit # Muss ohne Fehler durchlaufen npx tsc -b # Muss ohne Fehler durchlaufen — NICHT --noEmit, das prüft am Root nichts (siehe Tech-Stack-Abschnitt)
npm run dev # Dev-Server starten npm run dev:all # Frontend + lokaler Server zusammen starten
``` ```
Vor jeder Abgabe: `npx tsc --noEmit` ausführen und alle Fehler beheben. Vor jeder Abgabe: `npx tsc -b` ausführen und alle Fehler beheben.
## Wichtige Eigenheiten / Fallstricke ## Wichtige Eigenheiten / Fallstricke
@ -180,6 +185,6 @@ Vor jeder Abgabe: `npx tsc --noEmit` ausführen und alle Fehler beheben.
3. **`PhaseKey`**: Ist `type PhaseKey = string` (kein Union-Typ mehr) 3. **`PhaseKey`**: Ist `type PhaseKey = string` (kein Union-Typ mehr)
4. **`aType2`** in `AssignmentDetail.tsx`: Doppelte Variable aus Refactoring — `aType` wird zweimal deklariert, zweite heißt `aType2`. Technische Schuld. 4. **`aType2`** in `AssignmentDetail.tsx`: Doppelte Variable aus Refactoring — `aType` wird zweimal deklariert, zweite heißt `aType2`. Technische Schuld.
5. **`expandedCat`** in `MeetingView`: `Record<instituteeId, categoryId | null>` — öffnet pro Institutee genau eine Kategorie gleichzeitig 5. **`expandedCat`** in `MeetingView`: `Record<instituteeId, categoryId | null>` — öffnet pro Institutee genau eine Kategorie gleichzeitig
6. **Dexie v5**: `bulkAdd` schlägt fehl wenn IDs schon vorhanden → Backup-Import leert erst alle Tabellen 6. **Backup-Import** (`server/db/queries/backup.ts`, seit 2026-07-04 SQL-basiert): leert alle Tabellen und fügt Zeilen mit expliziter, ursprünglicher `id` wieder ein (`insertRowWithId`), damit Fremdschlüssel-Referenzen im importierten Datensatz stabil bleiben — kein Dexie/`bulkAdd` mehr, siehe CLAUDE.md-Datenbank-Abschnitt
7. **Gewichtete Mittelwerte**: immer `Σ(score×weight)/Σ(weight)`, nie `score×weight` roh interpretieren; bei `Σweight === 0` (seit Gewicht `0` erlaubt ist) `null`/`continue` statt Division — sonst `NaN < x` überall `false` → fällt auf „Fully" durch. Siehe `catMode()` (`AssessmentTab.tsx`) und `buildGroupMeetingScores()` (`ratingTrend.ts`). 7. **Gewichtete Mittelwerte**: immer `Σ(score×weight)/Σ(weight)`, nie `score×weight` roh interpretieren; bei `Σweight === 0` (seit Gewicht `0` erlaubt ist) `null`/`continue` statt Division — sonst `NaN < x` überall `false` → fällt auf „Fully" durch. Siehe `catMode()` (`AssessmentTab.tsx`) und `buildGroupMeetingScores()` (`ratingTrend.ts`).
8. **`ConversationEntry`** entspricht einem ganzen Sprecher-Turn (mehrzeiliger Text-Block), nicht einer einzelnen Zeile — ein Zwischenstand mit "ein Entry pro Zeile" wurde verworfen. Kriterium-Zuordnung läuft über `lineTags` (Matching per Zeilentext, nicht Index). 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).

1518
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"server": "tsx watch server/index.ts",
"dev:all": "concurrently -n web,api -c blue,green \"npm:dev\" \"npm:server\"",
"verify-backup": "tsx server/scripts/verifyBackupRoundtrip.ts",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
@ -14,19 +17,24 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.6.2", "react-router-dom": "^7.6.2",
"dexie": "^4.0.11", "dexie": "^4.0.11",
"recharts": "^2.15.3" "recharts": "^2.15.3",
"express": "^4.21.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@tailwindcss/vite": "^4.1.10", "@tailwindcss/vite": "^4.1.10",
"@types/express": "^4.17.21",
"@types/node": "^22.10.5",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"concurrently": "^9.1.2",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"tailwindcss": "^4.1.10", "tailwindcss": "^4.1.10",
"tsx": "^4.19.2",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0" "vite-plugin-pwa": "^1.0.0"

18
server/database.ts Normal file
View File

@ -0,0 +1,18 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TABLE_DEFINITIONS } from './db/tableRegistry'
const __dirname = dirname(fileURLToPath(import.meta.url))
const dbPath = join(__dirname, 'data', 'assignment-monitor.sqlite3')
mkdirSync(dirname(dbPath), { recursive: true })
export const db = new DatabaseSync(dbPath)
for (const table of TABLE_DEFINITIONS) {
const columnsSql = table.columns.map(c => `"${c.name}" ${c.sqlType}`).join(', ')
db.exec(`CREATE TABLE IF NOT EXISTS "${table.name}" (id INTEGER PRIMARY KEY AUTOINCREMENT, ${columnsSql})`)
}
console.log(`[db] SQLite bereit unter ${dbPath}`)

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

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

View File

@ -0,0 +1,26 @@
import { getRowById, insertRowWithId, updateRow } from '../crud'
import type { AppSettings } from '../../../src/db/types'
/** Muss mit DEFAULT_RATING_TREND_WEIGHT_STEP in src/config/constants.ts synchron gehalten werden. */
const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5
export function getRatingTrendWeightStep(): number {
const settings = getRowById<AppSettings>('appSettings', 1)
return settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP
}
export function getOrSeedRatingTrendWeightStep(): number {
let settings = getRowById<AppSettings>('appSettings', 1)
if (!settings) {
insertRowWithId('appSettings', 1, { ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP })
settings = getRowById<AppSettings>('appSettings', 1)
}
return settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP
}
export function saveRatingTrendWeightStep(weightStep: number): number {
const existing = getRowById('appSettings', 1)
if (existing) updateRow('appSettings', 1, { ratingTrendWeightStep: weightStep })
else insertRowWithId('appSettings', 1, { ratingTrendWeightStep: weightStep })
return 1
}

View File

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

View File

@ -0,0 +1,44 @@
import { getRowById, getRowsWhereEquals, insertRow, updateRow } from '../crud'
import type { FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText } from '../../../src/db/types'
export const getFeedbackDraft = (assignmentId: number, instituteeId: number): FeedbackDraft | undefined =>
getRowsWhereEquals<FeedbackDraft>('feedbackDrafts', 'assignmentId', assignmentId).find(d => d.instituteeId === instituteeId)
export const addFeedbackDraft = (d: Omit<FeedbackDraft, 'id'>): number => insertRow('feedbackDrafts', d)
export const updateFeedbackDraft = (id: number, patch: Partial<FeedbackDraft>): number => updateRow('feedbackDrafts', id, patch)
export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): AssignmentFeedback | undefined =>
getRowsWhereEquals<AssignmentFeedback>('assignmentFeedbacks', 'assignmentId', assignmentId).find(f => f.instituteeId === instituteeId)
export const getAssignmentFeedbackById = (id: number): AssignmentFeedback | undefined =>
getRowById<AssignmentFeedback>('assignmentFeedbacks', id)
export const addAssignmentFeedback = (f: Omit<AssignmentFeedback, 'id'>): number => insertRow('assignmentFeedbacks', f)
export const updateAssignmentFeedback = (id: number, patch: Partial<AssignmentFeedback>): number =>
updateRow('assignmentFeedbacks', id, patch)
export function getOrCreateAssignmentFeedback(assignmentId: number, instituteeId: number): AssignmentFeedback | undefined {
let fb = getAssignmentFeedback(assignmentId, instituteeId)
if (!fb) {
const fbId = addAssignmentFeedback({
assignmentId, instituteeId, status: 'draft', aiModel: '', updatedAt: new Date().toISOString(),
})
fb = getAssignmentFeedbackById(fbId)
}
return fb
}
export const listFeedbackCategoryRatings = (assignmentFeedbackId: number): FeedbackCategoryRating[] =>
getRowsWhereEquals<FeedbackCategoryRating>('feedbackCategoryRatings', 'assignmentFeedbackId', assignmentFeedbackId)
export const addFeedbackCategoryRating = (r: Omit<FeedbackCategoryRating, 'id'>): number => insertRow('feedbackCategoryRatings', r)
export const updateFeedbackCategoryRating = (id: number, patch: Partial<FeedbackCategoryRating>): number =>
updateRow('feedbackCategoryRatings', id, patch)
export const listFeedbackDimensionTexts = (assignmentFeedbackId: number): FeedbackDimensionText[] =>
getRowsWhereEquals<FeedbackDimensionText>('feedbackDimensionTexts', 'assignmentFeedbackId', assignmentFeedbackId)
export const getFeedbackDimensionText = (
assignmentFeedbackId: number,
feedbackDimensionId: number,
): FeedbackDimensionText | undefined =>
getRowsWhereEquals<FeedbackDimensionText>('feedbackDimensionTexts', 'assignmentFeedbackId', assignmentFeedbackId)
.find(t => t.feedbackDimensionId === feedbackDimensionId)
export const addFeedbackDimensionText = (t: Omit<FeedbackDimensionText, 'id'>): number => insertRow('feedbackDimensionTexts', t)
export const updateFeedbackDimensionText = (id: number, patch: Partial<FeedbackDimensionText>): number =>
updateRow('feedbackDimensionTexts', id, patch)

View File

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

View File

@ -0,0 +1,31 @@
import { getAllRows, getRowById, insertRow, updateRow } from '../crud'
import type { Assignment, AssignmentType, Consultant } from '../../../src/db/types'
export const listAssignments = (): Assignment[] => getAllRows<Assignment>('assignments')
export const listAssignmentsNewestFirst = (): Assignment[] =>
getAllRows<Assignment>('assignments').sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
export const getAssignment = (id: number): Assignment | undefined => getRowById<Assignment>('assignments', id)
export const addAssignment = (a: Omit<Assignment, 'id'>): number => insertRow('assignments', a)
export const updateAssignment = (id: number, patch: Partial<Assignment>): number => updateRow('assignments', id, patch)
export interface AssignmentWithDetails extends Assignment {
typeName: string
instituteeNames: string[]
}
/** Für die Dashboard-Liste: Assignments mit aufgelöstem Typ- und Institutee-Namen, neueste zuerst. */
export function listAssignmentsWithDetails(): AssignmentWithDetails[] {
const raw = listAssignmentsNewestFirst()
const types = getAllRows<AssignmentType>('assignmentTypes')
const consultants = getAllRows<Consultant>('consultants')
const typeMap = new Map<number, AssignmentType>(types.map(t => [t.id!, t]))
const consMap = new Map<number, Consultant>(consultants.map(c => [c.id!, c]))
return raw.map(a => ({
...a,
typeName: typeMap.get(a.assignmentTypeId)?.name ?? '—',
instituteeNames: a.instituteeIds.map(id => {
const c = consMap.get(id)
return c ? `${c.firstName} ${c.lastName}` : '?'
}),
}))
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,57 @@
import {
getAllRows, getRowsWhereIn, insertRow, updateRow,
getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById,
} from '../crud'
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem } from '../../../src/db/types'
export interface FeedbackStructure {
dimensions: FeedbackDimension[]
categories: FeedbackCategory[]
items: FeedbackCriterionItem[]
}
const byOrder = <T extends { order: number }>(rows: T[]) => [...rows].sort((a, b) => a.order - b.order)
/** Vollständige, nach `order` sortierte Feedback-Taxonomie. */
export function loadFeedbackStructure(): FeedbackStructure {
return {
dimensions: byOrder(getAllRows<FeedbackDimension>('feedbackDimensions')),
categories: byOrder(getAllRows<FeedbackCategory>('feedbackCategories')),
items: byOrder(getAllRows<FeedbackCriterionItem>('feedbackCriterionItems')),
}
}
export const listFeedbackCategoriesOrdered = (): FeedbackCategory[] => byOrder(getAllRows<FeedbackCategory>('feedbackCategories'))
export const listFeedbackCategoriesAll = (): FeedbackCategory[] => getAllRows<FeedbackCategory>('feedbackCategories')
export const listFeedbackCriterionItemsAll = (): FeedbackCriterionItem[] => getAllRows<FeedbackCriterionItem>('feedbackCriterionItems')
export const getFeedbackCriterionItemsByIds = (ids: number[]): FeedbackCriterionItem[] =>
getRowsWhereIn<FeedbackCriterionItem>('feedbackCriterionItems', 'id', ids)
export const addFeedbackDimension = (d: Omit<FeedbackDimension, 'id'>): number => insertRow('feedbackDimensions', d)
export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): number =>
updateRow('feedbackDimensions', id, patch)
/** Cascade: Items der Kategorien → Kategorien → Dimension. */
export function deleteFeedbackDimension(id: number): void {
const cats = getRowsWhereEquals<FeedbackCategory>('feedbackCategories', 'dimensionId', id)
for (const cat of cats) {
deleteRowsWhereEquals('feedbackCriterionItems', 'categoryId', cat.id!)
}
deleteRowsWhereEquals('feedbackCategories', 'dimensionId', id)
deleteRowById('feedbackDimensions', id)
}
export const addFeedbackCategory = (c: Omit<FeedbackCategory, 'id'>): number => insertRow('feedbackCategories', c)
export const updateFeedbackCategory = (id: number, patch: Partial<FeedbackCategory>): number =>
updateRow('feedbackCategories', id, patch)
/** Cascade: Items der Kategorie → Kategorie. */
export function deleteFeedbackCategory(id: number): void {
deleteRowsWhereEquals('feedbackCriterionItems', 'categoryId', id)
deleteRowById('feedbackCategories', id)
}
export const addFeedbackCriterionItem = (i: Omit<FeedbackCriterionItem, 'id'>): number => insertRow('feedbackCriterionItems', i)
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): number =>
updateRow('feedbackCriterionItems', id, patch)
export const deleteFeedbackCriterionItem = (id: number): void => deleteRowById('feedbackCriterionItems', id)

View File

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

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

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

29
server/index.ts Normal file
View File

@ -0,0 +1,29 @@
import express from 'express'
import './database'
import { rpcModules } from './rpcModules'
const app = express()
app.use(express.json({ limit: '50mb' })) // Backup-Import kann alle Tabellen als ein JSON-Body enthalten
app.post('/api/rpc', (req, res) => {
const { module, fn, args } = req.body as { module?: string; fn?: string; args?: unknown[] }
const mod = module ? rpcModules[module] : undefined
const handler = fn ? mod?.[fn] : undefined
if (!mod || typeof handler !== 'function') {
res.json({ ok: false, error: `Unbekannte RPC-Zielfunktion: ${module}.${fn}` })
return
}
try {
const result = handler(...(args ?? []))
res.json({ ok: true, result: result ?? null })
} catch (err) {
res.json({ ok: false, error: err instanceof Error ? err.message : 'Unbekannter Server-Fehler' })
}
})
const port = process.env.API_PORT ? Number(process.env.API_PORT) : 4000
app.listen(port, () => {
console.log(`[server] RPC-API läuft auf http://localhost:${port}/api/rpc`)
})

33
server/rpcModules.ts Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import { rpc } from '../rpcClient'
export const getRatingTrendWeightStep = (): Promise<number> => rpc('appSettings', 'getRatingTrendWeightStep')
export const getOrSeedRatingTrendWeightStep = (): Promise<number> => rpc('appSettings', 'getOrSeedRatingTrendWeightStep')
export const saveRatingTrendWeightStep = (weightStep: number): Promise<number> =>
rpc('appSettings', 'saveRatingTrendWeightStep', weightStep)

View File

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

View File

@ -0,0 +1,41 @@
import { rpc } from '../rpcClient'
import type { FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText } from '../types'
export const getFeedbackDraft = (assignmentId: number, instituteeId: number): Promise<FeedbackDraft | undefined> =>
rpc('assignmentFeedback', 'getFeedbackDraft', assignmentId, instituteeId)
export const addFeedbackDraft = (d: Omit<FeedbackDraft, 'id'>): Promise<number> => rpc('assignmentFeedback', 'addFeedbackDraft', d)
export const updateFeedbackDraft = (id: number, patch: Partial<FeedbackDraft>): Promise<number> =>
rpc('assignmentFeedback', 'updateFeedbackDraft', id, patch)
export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): Promise<AssignmentFeedback | undefined> =>
rpc('assignmentFeedback', 'getAssignmentFeedback', assignmentId, instituteeId)
export const getAssignmentFeedbackById = (id: number): Promise<AssignmentFeedback | undefined> =>
rpc('assignmentFeedback', 'getAssignmentFeedbackById', id)
export const addAssignmentFeedback = (f: Omit<AssignmentFeedback, 'id'>): Promise<number> =>
rpc('assignmentFeedback', 'addAssignmentFeedback', f)
export const updateAssignmentFeedback = (id: number, patch: Partial<AssignmentFeedback>): Promise<number> =>
rpc('assignmentFeedback', 'updateAssignmentFeedback', id, patch)
export const getOrCreateAssignmentFeedback = (
assignmentId: number,
instituteeId: number,
): Promise<AssignmentFeedback | undefined> => rpc('assignmentFeedback', 'getOrCreateAssignmentFeedback', assignmentId, instituteeId)
export const listFeedbackCategoryRatings = (assignmentFeedbackId: number): Promise<FeedbackCategoryRating[]> =>
rpc('assignmentFeedback', 'listFeedbackCategoryRatings', assignmentFeedbackId)
export const addFeedbackCategoryRating = (r: Omit<FeedbackCategoryRating, 'id'>): Promise<number> =>
rpc('assignmentFeedback', 'addFeedbackCategoryRating', r)
export const updateFeedbackCategoryRating = (id: number, patch: Partial<FeedbackCategoryRating>): Promise<number> =>
rpc('assignmentFeedback', 'updateFeedbackCategoryRating', id, patch)
export const listFeedbackDimensionTexts = (assignmentFeedbackId: number): Promise<FeedbackDimensionText[]> =>
rpc('assignmentFeedback', 'listFeedbackDimensionTexts', assignmentFeedbackId)
export const getFeedbackDimensionText = (
assignmentFeedbackId: number,
feedbackDimensionId: number,
): Promise<FeedbackDimensionText | undefined> =>
rpc('assignmentFeedback', 'getFeedbackDimensionText', assignmentFeedbackId, feedbackDimensionId)
export const addFeedbackDimensionText = (t: Omit<FeedbackDimensionText, 'id'>): Promise<number> =>
rpc('assignmentFeedback', 'addFeedbackDimensionText', t)
export const updateFeedbackDimensionText = (id: number, patch: Partial<FeedbackDimensionText>): Promise<number> =>
rpc('assignmentFeedback', 'updateFeedbackDimensionText', id, patch)

View File

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

View File

@ -0,0 +1,17 @@
import { rpc } from '../rpcClient'
import type { Assignment } from '../types'
export const listAssignments = (): Promise<Assignment[]> => rpc('assignments', 'listAssignments')
export const listAssignmentsNewestFirst = (): Promise<Assignment[]> => rpc('assignments', 'listAssignmentsNewestFirst')
export const getAssignment = (id: number): Promise<Assignment | undefined> => rpc('assignments', 'getAssignment', id)
export const addAssignment = (a: Omit<Assignment, 'id'>): Promise<number> => rpc('assignments', 'addAssignment', a)
export const updateAssignment = (id: number, patch: Partial<Assignment>): Promise<number> =>
rpc('assignments', 'updateAssignment', id, patch)
export interface AssignmentWithDetails extends Assignment {
typeName: string
instituteeNames: string[]
}
/** Für die Dashboard-Liste: Assignments mit aufgelöstem Typ- und Institutee-Namen, neueste zuerst. */
export const listAssignmentsWithDetails = (): Promise<AssignmentWithDetails[]> => rpc('assignments', 'listAssignmentsWithDetails')

View File

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

View File

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

View File

@ -0,0 +1,56 @@
import { rpc } from '../rpcClient'
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem } from '../types'
export interface FeedbackStructure {
dimensions: FeedbackDimension[]
categories: FeedbackCategory[]
items: FeedbackCriterionItem[]
}
/** Vollständige, nach `order` sortierte Feedback-Taxonomie. */
export const loadFeedbackStructure = (): Promise<FeedbackStructure> => rpc('feedbackStructure', 'loadFeedbackStructure')
/** Unsortierte Rohtabellen — für Stellen, die selbst filtern/sortieren (z.B. MeetingView, AssignmentDetail). */
export const listFeedbackCategoriesOrdered = (): Promise<FeedbackCategory[]> => rpc('feedbackStructure', 'listFeedbackCategoriesOrdered')
export const listFeedbackCategoriesAll = (): Promise<FeedbackCategory[]> => rpc('feedbackStructure', 'listFeedbackCategoriesAll')
export const listFeedbackCriterionItemsAll = (): Promise<FeedbackCriterionItem[]> => rpc('feedbackStructure', 'listFeedbackCriterionItemsAll')
export const getFeedbackCriterionItemsByIds = (ids: number[]): Promise<FeedbackCriterionItem[]> =>
rpc('feedbackStructure', 'getFeedbackCriterionItemsByIds', ids)
/**
* "AssignmentType.criteriaIds restrict die sichtbaren Kriterien" identische Filterlogik, die zuvor
* in MeetingView.tsx und AssignmentDetail.tsx dupliziert war. Leere/undefined criteriaIds = alles sichtbar.
* Reine Client-Berechnung, kein Server-Roundtrip nötig.
*/
export function filterVisibleFeedbackStructure<
C extends { id?: number },
I extends { id?: number; categoryId: number },
>(allCategories: C[], allItems: I[], criteriaIds: number[] | undefined): { categories: C[]; items: I[] } {
const itemFilter = criteriaIds?.length ? new Set(criteriaIds) : null
const items = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
const visibleCatIds = new Set(items.map(i => i.categoryId))
const categories = itemFilter ? allCategories.filter(c => visibleCatIds.has(c.id!)) : allCategories
return { categories, items }
}
export const addFeedbackDimension = (d: Omit<FeedbackDimension, 'id'>): Promise<number> =>
rpc('feedbackStructure', 'addFeedbackDimension', d)
export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): Promise<number> =>
rpc('feedbackStructure', 'updateFeedbackDimension', id, patch)
/** Cascade: Items der Kategorien → Kategorien → Dimension. */
export const deleteFeedbackDimension = (id: number): Promise<void> => rpc('feedbackStructure', 'deleteFeedbackDimension', id)
export const addFeedbackCategory = (c: Omit<FeedbackCategory, 'id'>): Promise<number> =>
rpc('feedbackStructure', 'addFeedbackCategory', c)
export const updateFeedbackCategory = (id: number, patch: Partial<FeedbackCategory>): Promise<number> =>
rpc('feedbackStructure', 'updateFeedbackCategory', id, patch)
/** Cascade: Items der Kategorie → Kategorie. */
export const deleteFeedbackCategory = (id: number): Promise<void> => rpc('feedbackStructure', 'deleteFeedbackCategory', id)
export const addFeedbackCriterionItem = (i: Omit<FeedbackCriterionItem, 'id'>): Promise<number> =>
rpc('feedbackStructure', 'addFeedbackCriterionItem', i)
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): Promise<number> =>
rpc('feedbackStructure', 'updateFeedbackCriterionItem', id, patch)
export const deleteFeedbackCriterionItem = (id: number): Promise<void> => rpc('feedbackStructure', 'deleteFeedbackCriterionItem', id)

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

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

View File

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

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

@ -0,0 +1,17 @@
/**
* Bridge zum lokalen Server (server/index.ts) ersetzt den bisherigen direkten Dexie-Zugriff
* in src/db/queries/*.ts. Ein Endpunkt statt ~80 REST-Routen: `{ module, fn, args }` Server
* ruft `rpcModules[module][fn](...args)` auf. Relativer Pfad `/api/rpc`, damit derselbe Code
* unverändert über den Vite-Dev-Proxy UND später hinter einem Reverse Proxy/Docker funktioniert.
*/
export async function rpc<T>(module: string, fn: string, ...args: unknown[]): Promise<T> {
const res = await fetch('/api/rpc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module, fn, args }),
})
if (!res.ok) throw new Error(`RPC-Fehler: ${res.status} ${res.statusText}`)
const body = await res.json() as { ok: boolean; result?: T; error?: string }
if (!body.ok) throw new Error(body.error ?? 'RPC-Fehler')
return body.result as T
}

View File

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

View File

@ -1,6 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { db, type AssignmentType, type PhaseTemplate, type Consultant, type Group } from '../db' import {
type AssignmentType, type PhaseTemplate, type Consultant, type Group,
listAssignmentTypes, listPhaseTemplatesForType,
listGroups, listConsultants, addGroup, addConsultant,
addAssignment,
} from '../db'
export default function AssignmentCreate() { export default function AssignmentCreate() {
const navigate = useNavigate() const navigate = useNavigate()
@ -24,9 +29,9 @@ export default function AssignmentCreate() {
const reload = async () => { const reload = async () => {
const [t, g, c] = await Promise.all([ const [t, g, c] = await Promise.all([
db.assignmentTypes.toArray(), listAssignmentTypes(),
db.groups.toArray(), listGroups(),
db.consultants.toArray(), listConsultants(),
]) ])
setTypes(t) setTypes(t)
setGroups(g) setGroups(g)
@ -37,7 +42,7 @@ export default function AssignmentCreate() {
useEffect(() => { useEffect(() => {
if (!form.assignmentTypeId) { setPhases([]); return } if (!form.assignmentTypeId) { setPhases([]); return }
db.phaseTemplates.where('assignmentTypeId').equals(form.assignmentTypeId).sortBy('order').then(setPhases) listPhaseTemplatesForType(form.assignmentTypeId).then(setPhases)
}, [form.assignmentTypeId]) }, [form.assignmentTypeId])
const groupConsultants = consultants.filter(c => c.groupId === form.selectedGroupId) const groupConsultants = consultants.filter(c => c.groupId === form.selectedGroupId)
@ -55,10 +60,10 @@ export default function AssignmentCreate() {
let groupId = form.selectedGroupId let groupId = form.selectedGroupId
if (!groupId) { if (!groupId) {
// Erstelle eine Standardgruppe falls keine vorhanden // Erstelle eine Standardgruppe falls keine vorhanden
groupId = await db.groups.add({ name: 'Neue Gruppe', cohortYear: new Date().getFullYear() }) groupId = await addGroup({ name: 'Neue Gruppe', cohortYear: new Date().getFullYear() })
setForm(p => ({ ...p, selectedGroupId: groupId })) setForm(p => ({ ...p, selectedGroupId: groupId }))
} }
const id = await db.consultants.add({ groupId, firstName: newFirst.trim(), lastName: newLast.trim() }) const id = await addConsultant({ groupId, firstName: newFirst.trim(), lastName: newLast.trim() })
await reload() await reload()
setForm(p => ({ ...p, instituteeIds: [...p.instituteeIds, id] })) setForm(p => ({ ...p, instituteeIds: [...p.instituteeIds, id] }))
setNewFirst('') setNewFirst('')
@ -68,7 +73,7 @@ export default function AssignmentCreate() {
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
const id = await db.assignments.add({ const id = await addAssignment({
title: form.title, title: form.title,
client: form.client, client: form.client,
leadName: form.leadName, leadName: form.leadName,

View File

@ -1,7 +1,16 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { db, type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type FeedbackRating, type FeedbackCategory, type FeedbackCriterionItem } from '../db' import {
type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type FeedbackRating, type FeedbackCategory, type FeedbackCriterionItem,
getAssignment, getAssignmentType,
listPhaseTemplatesForType,
listMeetingInstancesForAssignment, addMeetingInstance, softDeleteMeetingInstance, restoreMeetingInstance, hardDeleteMeetingInstance,
getConsultantsByIds,
listAssessmentsForMeetings,
listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure,
} from '../db'
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport' import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
import { weightedAverageRating } from '../utils/ratingTrend'
const RATING_BG: Record<FeedbackRating, string> = { const RATING_BG: Record<FeedbackRating, string> = {
na: 'bg-gray-400 text-white', na: 'bg-gray-400 text-white',
@ -11,10 +20,6 @@ const RATING_BG: Record<FeedbackRating, string> = {
client_ready: 'bg-green-700 text-white', client_ready: 'bg-green-700 text-white',
} }
const NUM_MAP: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
const avgToRating = (avg: number): FeedbackRating =>
avg < 1.5 ? 'not_client_ready' : avg < 2.5 ? 'partially_client_ready' : avg < 3.5 ? 'nearly_client_ready' : 'client_ready'
interface PhaseRow { interface PhaseRow {
template: PhaseTemplate template: PhaseTemplate
instances: MeetingInstance[] instances: MeetingInstance[]
@ -41,15 +46,15 @@ export default function AssignmentDetail() {
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([]) const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const load = async () => { const load = async () => {
const a = await db.assignments.get(assignmentId) const a = await getAssignment(assignmentId)
if (!a) return if (!a) return
setAssignment(a) setAssignment(a)
const [aType, templates, instances, cons] = await Promise.all([ const [aType, templates, instances, cons] = await Promise.all([
db.assignmentTypes.get(a.assignmentTypeId), getAssignmentType(a.assignmentTypeId),
db.phaseTemplates.where('assignmentTypeId').equals(a.assignmentTypeId).sortBy('order'), listPhaseTemplatesForType(a.assignmentTypeId),
db.meetingInstances.where('assignmentId').equals(assignmentId).toArray(), listMeetingInstancesForAssignment(assignmentId),
db.consultants.where('id').anyOf(a.instituteeIds).toArray(), getConsultantsByIds(a.instituteeIds),
]) ])
setAssignmentType(aType ?? null) setAssignmentType(aType ?? null)
@ -64,23 +69,21 @@ export default function AssignmentDetail() {
}))) })))
const instIds = active.map(i => i.id!) const instIds = active.map(i => i.id!)
const [asmts, allCats, allItems, aType2] = await Promise.all([ const [asmts, allCats, allItems, aType2] = await Promise.all([
instIds.length > 0 ? db.assessments.where('meetingInstanceId').anyOf(instIds).toArray() : Promise.resolve([]), listAssessmentsForMeetings(instIds),
db.feedbackCategories.toArray(), listFeedbackCategoriesAll(),
db.feedbackCriterionItems.toArray(), listFeedbackCriterionItemsAll(),
db.assignmentTypes.get(a.assignmentTypeId), getAssignmentType(a.assignmentTypeId),
]) ])
setAllAssessments(asmts) setAllAssessments(asmts)
const itemFilter = aType2?.criteriaIds?.length ? new Set(aType2.criteriaIds) : null const { categories: visibleCats, items: visibleItems } = filterVisibleFeedbackStructure(allCats, allItems, aType2?.criteriaIds)
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems setFbItems(visibleItems)
setFbItems(filteredItems) setFbCategories(visibleCats)
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId))
setFbCategories(itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats)
} }
useEffect(() => { load() }, [assignmentId]) useEffect(() => { load() }, [assignmentId])
const startMeeting = async (templateId: number, existingCount: number) => { const startMeeting = async (templateId: number, existingCount: number) => {
const mid = await db.meetingInstances.add({ const mid = await addMeetingInstance({
assignmentId, assignmentId,
phaseTemplateId: templateId, phaseTemplateId: templateId,
phaseIndex: existingCount + 1, phaseIndex: existingCount + 1,
@ -113,24 +116,18 @@ export default function AssignmentDetail() {
} }
const softDeleteMeeting = async (instId: number) => { const softDeleteMeeting = async (instId: number) => {
await db.meetingInstances.update(instId, { deletedAt: new Date().toISOString() }) await softDeleteMeetingInstance(instId)
setConfirmSoftDelete(null) setConfirmSoftDelete(null)
await load() await load()
} }
const restoreMeeting = async (instId: number) => { const restoreMeeting = async (instId: number) => {
await db.meetingInstances.update(instId, { deletedAt: undefined }) await restoreMeetingInstance(instId)
await load() await load()
} }
const hardDeleteMeeting = async (instId: number) => { const hardDeleteMeeting = async (instId: number) => {
await db.conversationSkillScores await hardDeleteMeetingInstance(instId)
.where('conversationEntryId')
.anyOf((await db.conversationEntries.where('meetingInstanceId').equals(instId).primaryKeys()))
.delete()
await db.conversationEntries.where('meetingInstanceId').equals(instId).delete()
await db.assessments.where('meetingInstanceId').equals(instId).delete()
await db.meetingInstances.delete(instId)
setConfirmHardDelete(null) setConfirmHardDelete(null)
await load() await load()
} }
@ -258,19 +255,15 @@ export default function AssignmentDetail() {
{institutees.map(c => { {institutees.map(c => {
const chips = fbCategories.map(cat => { const chips = fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id) const catItems = fbItems.filter(i => i.categoryId === cat.id)
const scored = catItems.map(item => { const mode = weightedAverageRating(catItems, itemId => {
const asmt = allAssessments.find(a => const asmt = allAssessments.find(a =>
a.meetingInstanceId === inst.id && a.meetingInstanceId === inst.id &&
a.instituteeId === c.id && a.instituteeId === c.id &&
a.criteriaId === item.id && a.criteriaId === itemId
a.score !== null && a.score !== 'na'
) )
return asmt ? { score: asmt.score as FeedbackRating, w: item.weight ?? 1 } : null return asmt?.score ?? null
}).filter(Boolean) as { score: FeedbackRating; w: number }[] })
if (scored.length === 0) return null return mode ? { cat, mode } : null
const wSum = scored.reduce((s, x) => s + x.w, 0)
const avg = scored.reduce((s, x) => s + NUM_MAP[x.score] * x.w, 0) / wSum
return { cat, mode: avgToRating(avg) }
}).filter(Boolean) as { cat: FeedbackCategory; mode: FeedbackRating }[] }).filter(Boolean) as { cat: FeedbackCategory; mode: FeedbackRating }[]
if (chips.length === 0) return null if (chips.length === 0) return null

View File

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

View File

@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { import {
db,
type Assignment, type Assignment,
type Consultant, type Consultant,
type FeedbackDimension, type FeedbackDimension,
@ -11,8 +10,16 @@ import {
type FeedbackCategoryRating, type FeedbackCategoryRating,
type FeedbackDimensionText, type FeedbackDimensionText,
type FeedbackRating, type FeedbackRating,
getAssignment, getConsultant,
loadFeedbackStructure,
listMeetingInstancesForAssignment, listDoneMeetingsChronological,
listAssessmentsForMeetings, listConversationEntriesForMeetings,
getRatingTrendWeightStep,
getOrCreateAssignmentFeedback, updateAssignmentFeedback,
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating,
listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText,
} from '../db' } from '../db'
import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG, DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants' import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG } from '../config/constants'
import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend' import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend'
import { splitNoteLines } from '../utils/notationParser' import { splitNoteLines } from '../utils/notationParser'
@ -51,12 +58,12 @@ async function buildAiPrompt(
ratings: FeedbackCategoryRating[], ratings: FeedbackCategoryRating[],
catTrends: Map<number, TrendResult>, catTrends: Map<number, TrendResult>,
): Promise<string> { ): Promise<string> {
const meetings = await db.meetingInstances.where('assignmentId').equals(assignment.id!).toArray() const meetings = await listMeetingInstancesForAssignment(assignment.id!)
const meetingIds = meetings.map(m => m.id!) const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([ const [allAssessments, allEntries] = await Promise.all([
db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray(), listAssessmentsForMeetings(meetingIds),
db.conversationEntries.where('meetingInstanceId').anyOf(meetingIds).toArray(), listConversationEntriesForMeetings(meetingIds),
]) ])
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id) const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
@ -187,28 +194,24 @@ export default function AssignmentFeedbackPage() {
const saveTimer = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map()) const saveTimer = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const load = useCallback(async () => { const load = useCallback(async () => {
const [a, cons, dims, cats, itms] = await Promise.all([ const [a, cons, structure] = await Promise.all([
db.assignments.get(assignmentId), getAssignment(assignmentId),
db.consultants.get(instId), getConsultant(instId),
db.feedbackDimensions.orderBy('order').toArray(), loadFeedbackStructure(),
db.feedbackCategories.orderBy('order').toArray(),
db.feedbackCriterionItems.orderBy('order').toArray(),
]) ])
setAssignment(a ?? null) setAssignment(a ?? null)
setInstitutee(cons ?? null) setInstitutee(cons ?? null)
setDimensions(dims) setDimensions(structure.dimensions)
setCategories(cats) setCategories(structure.categories)
setItems(itms) setItems(structure.items)
const { dimensions: dims, categories: cats, items: itms } = structure
const meetings = (await db.meetingInstances.where('assignmentId').equals(assignmentId).toArray()) const meetings = await listDoneMeetingsChronological(assignmentId)
.filter(m => !m.deletedAt && m.status === 'done')
.sort((x, y) => new Date(x.date).getTime() - new Date(y.date).getTime())
const meetingIds = meetings.map(m => m.id!) const meetingIds = meetings.map(m => m.id!)
const [meetingAssessments, settings] = await Promise.all([ const [meetingAssessments, weightStep] = await Promise.all([
meetingIds.length > 0 ? db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray() : Promise.resolve([]), listAssessmentsForMeetings(meetingIds),
db.appSettings.get(1), getRatingTrendWeightStep(),
]) ])
const weightStep = settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP
const instAssessments = meetingAssessments.filter(as => as.instituteeId === instId) const instAssessments = meetingAssessments.filter(as => as.instituteeId === instId)
const getScore = (meetingId: number, itemId: number): FeedbackRating | null => const getScore = (meetingId: number, itemId: number): FeedbackRating | null =>
instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null
@ -221,23 +224,13 @@ export default function AssignmentFeedbackPage() {
} }
setCatTrends(trends) setCatTrends(trends)
let fb = await db.assignmentFeedbacks const fb = await getOrCreateAssignmentFeedback(assignmentId, instId)
.where('assignmentId').equals(assignmentId)
.filter(f => f.instituteeId === instId)
.first()
if (!fb) {
const fbId = await db.assignmentFeedbacks.add({
assignmentId, instituteeId: instId,
status: 'draft', aiModel: '', updatedAt: new Date().toISOString(),
})
fb = await db.assignmentFeedbacks.get(fbId)
}
setFeedback(fb ?? null) setFeedback(fb ?? null)
if (fb?.id) { if (fb?.id) {
const [rs, txts] = await Promise.all([ const [rs, txts] = await Promise.all([
db.feedbackCategoryRatings.where('assignmentFeedbackId').equals(fb.id).toArray(), listFeedbackCategoryRatings(fb.id),
db.feedbackDimensionTexts.where('assignmentFeedbackId').equals(fb.id).toArray(), listFeedbackDimensionTexts(fb.id),
]) ])
setRatings(rs) setRatings(rs)
setDimTexts(txts) setDimTexts(txts)
@ -255,10 +248,10 @@ export default function AssignmentFeedbackPage() {
if (!feedback?.id) return if (!feedback?.id) return
const existing = ratings.find(r => r.feedbackCategoryId === categoryId) const existing = ratings.find(r => r.feedbackCategoryId === categoryId)
if (existing?.id) { if (existing?.id) {
await db.feedbackCategoryRatings.update(existing.id, { rating }) await updateFeedbackCategoryRating(existing.id, { rating })
setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r)) setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r))
} else { } else {
const newId = await db.feedbackCategoryRatings.add({ const newId = await addFeedbackCategoryRating({
assignmentFeedbackId: feedback.id, feedbackCategoryId: categoryId, rating, assignmentFeedbackId: feedback.id, feedbackCategoryId: categoryId, rating,
}) })
setRatings(prev => [...prev, { id: newId, assignmentFeedbackId: feedback.id!, feedbackCategoryId: categoryId, rating }]) setRatings(prev => [...prev, { id: newId, assignmentFeedbackId: feedback.id!, feedbackCategoryId: categoryId, rating }])
@ -281,14 +274,11 @@ export default function AssignmentFeedbackPage() {
if (prev) clearTimeout(prev) if (prev) clearTimeout(prev)
saveTimer.current.set(key, setTimeout(async () => { saveTimer.current.set(key, setTimeout(async () => {
if (!feedback?.id) return if (!feedback?.id) return
const existing = await db.feedbackDimensionTexts const existing = await getFeedbackDimensionText(feedback.id, dimId)
.where('assignmentFeedbackId').equals(feedback.id)
.filter(t => t.feedbackDimensionId === dimId)
.first()
if (existing?.id) { if (existing?.id) {
await db.feedbackDimensionTexts.update(existing.id, { [field]: value }) await updateFeedbackDimensionText(existing.id, { [field]: value })
} else { } else {
await db.feedbackDimensionTexts.add({ await addFeedbackDimensionText({
assignmentFeedbackId: feedback.id, assignmentFeedbackId: feedback.id,
feedbackDimensionId: dimId, feedbackDimensionId: dimId,
achievements: field === 'achievements' ? value : '', achievements: field === 'achievements' ? value : '',
@ -349,17 +339,14 @@ export default function AssignmentFeedbackPage() {
for (const item of parsed.dimensions) { for (const item of parsed.dimensions) {
const dim = dimensions.find(d => d.name === item.name || item.name.includes(d.name.split(',')[0])) const dim = dimensions.find(d => d.name === item.name || item.name.includes(d.name.split(',')[0]))
if (!dim?.id) continue if (!dim?.id) continue
const existing = await db.feedbackDimensionTexts const existing = await getFeedbackDimensionText(feedback.id, dim.id)
.where('assignmentFeedbackId').equals(feedback.id)
.filter(t => t.feedbackDimensionId === dim.id)
.first()
if (existing?.id) { if (existing?.id) {
await db.feedbackDimensionTexts.update(existing.id, { await updateFeedbackDimensionText(existing.id, {
achievements: item.achievements, achievements: item.achievements,
developmentNeeds: item.developmentNeeds, developmentNeeds: item.developmentNeeds,
}) })
} else { } else {
await db.feedbackDimensionTexts.add({ await addFeedbackDimensionText({
assignmentFeedbackId: feedback.id, assignmentFeedbackId: feedback.id,
feedbackDimensionId: dim.id, feedbackDimensionId: dim.id,
achievements: item.achievements, achievements: item.achievements,
@ -367,7 +354,7 @@ export default function AssignmentFeedbackPage() {
}) })
} }
} }
await db.assignmentFeedbacks.update(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() }) await updateAssignmentFeedback(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() })
await load() await load()
} catch (e) { } catch (e) {
setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler') setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler')
@ -379,7 +366,7 @@ export default function AssignmentFeedbackPage() {
const toggleFinal = async () => { const toggleFinal = async () => {
if (!feedback?.id) return if (!feedback?.id) return
const next = feedback.status === 'final' ? 'draft' : 'final' const next = feedback.status === 'final' ? 'draft' : 'final'
await db.assignmentFeedbacks.update(feedback.id, { status: next, updatedAt: new Date().toISOString() }) await updateAssignmentFeedback(feedback.id, { status: next, updatedAt: new Date().toISOString() })
setFeedback(prev => prev ? { ...prev, status: next } : null) setFeedback(prev => prev ? { ...prev, status: next } : null)
} }

View File

@ -1,5 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { db, type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem } from '../db' import {
type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
listAssignmentTypes, listPhaseTemplates, loadFeedbackStructure,
addAssignmentType, updateAssignmentType, deleteAssignmentType,
addPhaseTemplate, updatePhaseTemplate, deletePhaseTemplate,
} from '../db'
export default function AssignmentTypeConfig() { export default function AssignmentTypeConfig() {
const [types, setTypes] = useState<AssignmentType[]>([]) const [types, setTypes] = useState<AssignmentType[]>([])
@ -18,18 +23,16 @@ export default function AssignmentTypeConfig() {
}>>({}) }>>({})
const load = async () => { const load = async () => {
const [t, tmpl, dims, cats, itms] = await Promise.all([ const [t, tmpl, structure] = await Promise.all([
db.assignmentTypes.toArray(), listAssignmentTypes(),
db.phaseTemplates.toArray(), listPhaseTemplates(),
db.feedbackDimensions.orderBy('order').toArray(), loadFeedbackStructure(),
db.feedbackCategories.orderBy('order').toArray(),
db.feedbackCriterionItems.orderBy('order').toArray(),
]) ])
setTypes(t) setTypes(t)
setTemplates(tmpl) setTemplates(tmpl)
setDimensions(dims) setDimensions(structure.dimensions)
setCategories(cats) setCategories(structure.categories)
setItems(itms) setItems(structure.items)
} }
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
@ -39,25 +42,23 @@ export default function AssignmentTypeConfig() {
const addType = async () => { const addType = async () => {
const name = newTypeName.trim() const name = newTypeName.trim()
if (!name) return if (!name) return
const id = await db.assignmentTypes.add({ name, defaultPhaseKeys: [], criteriaIds: [] }) const id = await addAssignmentType({ name, defaultPhaseKeys: [], criteriaIds: [] })
setNewTypeName('') setNewTypeName('')
await load() await load()
setExpanded(id) setExpanded(id)
} }
const updateTypeName = async (id: number, name: string) => { const updateTypeName = async (id: number, name: string) => {
await db.assignmentTypes.update(id, { name }) await updateAssignmentType(id, { name })
setTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t)) setTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t))
} }
const deleteType = async (id: number) => { const deleteType = async (id: number) => {
const count = await db.assignments.where('assignmentTypeId').equals(id).count() const result = await deleteAssignmentType(id)
if (count > 0) { if (!result.ok) {
setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden — ${count} Assignment(s) verwenden diesen Typ.` })) setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden — ${result.assignmentCount} Assignment(s) verwenden diesen Typ.` }))
return return
} }
await db.phaseTemplates.where('assignmentTypeId').equals(id).delete()
await db.assignmentTypes.delete(id)
await load() await load()
} }
@ -66,7 +67,7 @@ export default function AssignmentTypeConfig() {
const toggleItem = async (type: AssignmentType, itemId: number) => { const toggleItem = async (type: AssignmentType, itemId: number) => {
const current = type.criteriaIds ?? [] const current = type.criteriaIds ?? []
const next = current.includes(itemId) ? current.filter(id => id !== itemId) : [...current, itemId] const next = current.includes(itemId) ? current.filter(id => id !== itemId) : [...current, itemId]
await db.assignmentTypes.update(type.id!, { criteriaIds: next }) await updateAssignmentType(type.id!, { criteriaIds: next })
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t)) setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
} }
@ -77,7 +78,7 @@ export default function AssignmentTypeConfig() {
const next = allSelected const next = allSelected
? current.filter(id => !catItemIds.includes(id)) ? current.filter(id => !catItemIds.includes(id))
: [...new Set([...current, ...catItemIds])] : [...new Set([...current, ...catItemIds])]
await db.assignmentTypes.update(type.id!, { criteriaIds: next }) await updateAssignmentType(type.id!, { criteriaIds: next })
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t)) setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
} }
@ -87,7 +88,7 @@ export default function AssignmentTypeConfig() {
const np = newPhase[typeId] const np = newPhase[typeId]
if (!np?.label.trim()) return if (!np?.label.trim()) return
const existing = templates.filter(t => t.assignmentTypeId === typeId) const existing = templates.filter(t => t.assignmentTypeId === typeId)
await db.phaseTemplates.add({ await addPhaseTemplate({
assignmentTypeId: typeId, assignmentTypeId: typeId,
phaseKey: np.label.trim().toLowerCase().replace(/\s+/g, '_'), phaseKey: np.label.trim().toLowerCase().replace(/\s+/g, '_'),
label: np.label.trim(), label: np.label.trim(),
@ -103,12 +104,12 @@ export default function AssignmentTypeConfig() {
} }
const updatePhase = async (id: number, patch: Partial<PhaseTemplate>) => { const updatePhase = async (id: number, patch: Partial<PhaseTemplate>) => {
await db.phaseTemplates.update(id, patch) await updatePhaseTemplate(id, patch)
setTemplates(prev => prev.map(t => t.id === id ? { ...t, ...patch } : t)) setTemplates(prev => prev.map(t => t.id === id ? { ...t, ...patch } : t))
} }
const deletePhase = async (id: number) => { const deletePhase = async (id: number) => {
await db.phaseTemplates.delete(id) await deletePhaseTemplate(id)
await load() await load()
} }

View File

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

View File

@ -1,35 +1,15 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { db, type Assignment, type AssignmentType, type Consultant } from '../db' import { listAssignmentsWithDetails, type AssignmentWithDetails } from '../db'
interface AssignmentRow extends Assignment {
typeName: string
instituteeNames: string[]
}
export default function Dashboard() { export default function Dashboard() {
const [assignments, setAssignments] = useState<AssignmentRow[]>([]) const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'done'>('all') const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'done'>('all')
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
const load = async () => { listAssignmentsWithDetails().then(setAssignments)
const raw = await db.assignments.orderBy('createdAt').reverse().toArray()
const types = await db.assignmentTypes.toArray()
const consultants = await db.consultants.toArray()
const typeMap = new Map<number, AssignmentType>(types.map(t => [t.id!, t]))
const consMap = new Map<number, Consultant>(consultants.map(c => [c.id!, c]))
setAssignments(raw.map(a => ({
...a,
typeName: typeMap.get(a.assignmentTypeId)?.name ?? '—',
instituteeNames: a.instituteeIds.map(id => {
const c = consMap.get(id)
return c ? `${c.firstName} ${c.lastName}` : '?'
}),
})))
}
load()
}, []) }, [])
const filtered = useMemo(() => { const filtered = useMemo(() => {

View File

@ -1,12 +1,16 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
db,
type Assignment, type Consultant, type Assignment, type Consultant,
type FeedbackDimension, type FeedbackCategory, type FeedbackDimension, type FeedbackCategory,
type FeedbackRating, type FeedbackRating,
listAssignments, listConsultants,
loadFeedbackStructure,
listAssessmentsAll,
getRatingTrendWeightStep,
listDoneMeetingsChronological,
} from '../db' } from '../db'
import { RATING_OPTIONS, DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants' import { RATING_OPTIONS } from '../config/constants'
import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend' import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend'
interface CatScore { interface CatScore {
@ -57,25 +61,20 @@ export default function Evaluation() {
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
const [assignments, consultants, dims, cats, items, assessments, settings] = await Promise.all([ const [assignments, consultants, structure, assessments, weightStep] = await Promise.all([
db.assignments.toArray(), listAssignments(),
db.consultants.toArray(), listConsultants(),
db.feedbackDimensions.orderBy('order').toArray(), loadFeedbackStructure(),
db.feedbackCategories.orderBy('order').toArray(), listAssessmentsAll(),
db.feedbackCriterionItems.orderBy('order').toArray(), getRatingTrendWeightStep(),
db.assessments.toArray(),
db.appSettings.get(1),
]) ])
const { dimensions: dims, categories: cats, items } = structure
setDimensions(dims) setDimensions(dims)
setCategories(cats) setCategories(cats)
const weightStep = settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP
const rows: InstScore[] = [] const rows: InstScore[] = []
for (const a of assignments) { for (const a of assignments) {
const meetings = (await db.meetingInstances const meetings = await listDoneMeetingsChronological(a.id!)
.where('assignmentId').equals(a.id!).toArray()
).filter(m => !m.deletedAt && m.status === 'done')
.sort((x, y) => new Date(x.date).getTime() - new Date(y.date).getTime())
const meetingIds = meetings.map(m => m.id!) const meetingIds = meetings.map(m => m.id!)
for (const instId of a.instituteeIds) { for (const instId of a.instituteeIds) {

View File

@ -1,13 +1,18 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { import {
db,
type Assignment, type Assignment,
type Consultant, type Consultant,
type FeedbackCategory, type FeedbackCategory,
type FeedbackCriterionItem, type FeedbackCriterionItem,
type FeedbackDraft, type FeedbackDraft,
type FeedbackRating, type FeedbackRating,
getAssignment, getConsultant,
listFeedbackCategoriesOrdered, getFeedbackCriterionItemsByIds,
listMeetingInstancesForAssignment,
listAssessmentsForMeetings,
listConversationEntriesForMeetings,
getFeedbackDraft, addFeedbackDraft, updateFeedbackDraft,
} from '../db' } from '../db'
import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants' import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants'
import { numberToRating } from '../utils/ratingTrend' import { numberToRating } from '../utils/ratingTrend'
@ -49,20 +54,20 @@ export default function FeedbackDraftPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
const [a, cons, cats] = await Promise.all([ const [a, cons, cats] = await Promise.all([
db.assignments.get(assignmentId), getAssignment(assignmentId),
db.consultants.get(instId), getConsultant(instId),
db.feedbackCategories.orderBy('order').toArray(), listFeedbackCategoriesOrdered(),
]) ])
setAssignment(a ?? null) setAssignment(a ?? null)
setInstitutee(cons ?? null) setInstitutee(cons ?? null)
setCategories(cats) setCategories(cats)
const meetings = await db.meetingInstances.where('assignmentId').equals(assignmentId).toArray() const meetings = await listMeetingInstancesForAssignment(assignmentId)
const meetingIds = meetings.map(m => m.id!) const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([ const [allAssessments, allEntries] = await Promise.all([
db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray(), listAssessmentsForMeetings(meetingIds),
db.conversationEntries.where('meetingInstanceId').anyOf(meetingIds).toArray(), listConversationEntriesForMeetings(meetingIds),
]) ])
const instAssessments = allAssessments.filter(a => a.instituteeId === instId) const instAssessments = allAssessments.filter(a => a.instituteeId === instId)
@ -73,9 +78,7 @@ export default function FeedbackDraftPage() {
...instAssessments.map(a => a.criteriaId), ...instAssessments.map(a => a.criteriaId),
...allLineTags.map(t => t.criterionItemId), ...allLineTags.map(t => t.criterionItemId),
])] ])]
const crits = criteriaIds.length > 0 const crits = await getFeedbackCriterionItemsByIds(criteriaIds)
? await db.feedbackCriterionItems.where('id').anyOf(criteriaIds).toArray()
: []
const sum: CriterionSummary[] = crits.map(crit => { const sum: CriterionSummary[] = crits.map(crit => {
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null && a.score !== 'na') const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null && a.score !== 'na')
@ -90,10 +93,7 @@ export default function FeedbackDraftPage() {
}) })
setSummary(sum) setSummary(sum)
const existing = await db.feedbackDrafts const existing = await getFeedbackDraft(assignmentId, instId)
.where('assignmentId').equals(assignmentId)
.filter(d => d.instituteeId === instId)
.first()
if (existing) { if (existing) {
setDraft(existing) setDraft(existing)
setContent(existing.content) setContent(existing.content)
@ -134,10 +134,10 @@ export default function FeedbackDraftPage() {
const now = new Date().toISOString() const now = new Date().toISOString()
if (draft?.id) { if (draft?.id) {
await db.feedbackDrafts.update(draft.id, { content: text, updatedAt: now }) await updateFeedbackDraft(draft.id, { content: text, updatedAt: now })
setDraft(prev => prev ? { ...prev, content: text, updatedAt: now } : null) setDraft(prev => prev ? { ...prev, content: text, updatedAt: now } : null)
} else { } else {
const newId = await db.feedbackDrafts.add({ const newId = await addFeedbackDraft({
assignmentId, instituteeId: instId, content: text, updatedAt: now, assignmentId, instituteeId: instId, content: text, updatedAt: now,
}) })
setDraft({ id: newId, assignmentId, instituteeId: instId, content: text, updatedAt: now }) setDraft({ id: newId, assignmentId, instituteeId: instId, content: text, updatedAt: now })
@ -150,9 +150,9 @@ export default function FeedbackDraftPage() {
saveTimer.current = setTimeout(async () => { saveTimer.current = setTimeout(async () => {
const now = new Date().toISOString() const now = new Date().toISOString()
if (draft?.id) { if (draft?.id) {
await db.feedbackDrafts.update(draft.id, { content: val, updatedAt: now }) await updateFeedbackDraft(draft.id, { content: val, updatedAt: now })
} else { } else {
const newId = await db.feedbackDrafts.add({ const newId = await addFeedbackDraft({
assignmentId, instituteeId: instId, content: val, updatedAt: now, assignmentId, instituteeId: instId, content: val, updatedAt: now,
}) })
setDraft({ id: newId, assignmentId, instituteeId: instId, content: val, updatedAt: now }) setDraft({ id: newId, assignmentId, instituteeId: instId, content: val, updatedAt: now })

View File

@ -1,5 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { db, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem } from '../db' import {
type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
loadFeedbackStructure,
addFeedbackDimension, updateFeedbackDimension, deleteFeedbackDimension,
addFeedbackCategory, updateFeedbackCategory, deleteFeedbackCategory,
addFeedbackCriterionItem, updateFeedbackCriterionItem, deleteFeedbackCriterionItem,
} from '../db'
export default function FeedbackStructureConfig() { export default function FeedbackStructureConfig() {
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([]) const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
@ -14,14 +20,10 @@ export default function FeedbackStructureConfig() {
const [newItemName, setNewItemName] = useState<Record<number, string>>({}) const [newItemName, setNewItemName] = useState<Record<number, string>>({})
const load = async () => { const load = async () => {
const [dims, cats, itms] = await Promise.all([ const structure = await loadFeedbackStructure()
db.feedbackDimensions.orderBy('order').toArray(), setDimensions(structure.dimensions)
db.feedbackCategories.orderBy('order').toArray(), setCategories(structure.categories)
db.feedbackCriterionItems.orderBy('order').toArray(), setItems(structure.items)
])
setDimensions(dims)
setCategories(cats)
setItems(itms)
} }
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
@ -31,24 +33,19 @@ export default function FeedbackStructureConfig() {
const addDimension = async () => { const addDimension = async () => {
if (!newDimName.trim()) return if (!newDimName.trim()) return
const maxOrder = dimensions.reduce((m, d) => Math.max(m, d.order), 0) const maxOrder = dimensions.reduce((m, d) => Math.max(m, d.order), 0)
const id = await db.feedbackDimensions.add({ name: newDimName.trim(), order: maxOrder + 1 }) const id = await addFeedbackDimension({ name: newDimName.trim(), order: maxOrder + 1 })
setNewDimName('') setNewDimName('')
await load() await load()
setExpanded(prev => new Set([...prev, id])) setExpanded(prev => new Set([...prev, id]))
} }
const updateDimension = async (id: number, name: string) => { const updateDimension = async (id: number, name: string) => {
await db.feedbackDimensions.update(id, { name }) await updateFeedbackDimension(id, { name })
setDimensions(prev => prev.map(d => d.id === id ? { ...d, name } : d)) setDimensions(prev => prev.map(d => d.id === id ? { ...d, name } : d))
} }
const deleteDimension = async (id: number) => { const deleteDimension = async (id: number) => {
const cats = await db.feedbackCategories.where('dimensionId').equals(id).toArray() await deleteFeedbackDimension(id)
for (const cat of cats) {
await db.feedbackCriterionItems.where('categoryId').equals(cat.id!).delete()
}
await db.feedbackCategories.where('dimensionId').equals(id).delete()
await db.feedbackDimensions.delete(id)
await load() await load()
} }
@ -57,8 +54,8 @@ export default function FeedbackStructureConfig() {
const idx = sorted.findIndex(d => d.id === dim.id) const idx = sorted.findIndex(d => d.id === dim.id)
const other = sorted[idx + dir] const other = sorted[idx + dir]
if (!other) return if (!other) return
await db.feedbackDimensions.update(dim.id!, { order: other.order }) await updateFeedbackDimension(dim.id!, { order: other.order })
await db.feedbackDimensions.update(other.id!, { order: dim.order }) await updateFeedbackDimension(other.id!, { order: dim.order })
await load() await load()
} }
@ -69,20 +66,19 @@ export default function FeedbackStructureConfig() {
if (!name) return if (!name) return
const dimCats = categories.filter(c => c.dimensionId === dimId) const dimCats = categories.filter(c => c.dimensionId === dimId)
const maxOrder = dimCats.reduce((m, c) => Math.max(m, c.order), 0) const maxOrder = dimCats.reduce((m, c) => Math.max(m, c.order), 0)
const id = await db.feedbackCategories.add({ dimensionId: dimId, name, order: maxOrder + 1 }) const id = await addFeedbackCategory({ dimensionId: dimId, name, order: maxOrder + 1 })
setNewCatName(prev => ({ ...prev, [dimId]: '' })) setNewCatName(prev => ({ ...prev, [dimId]: '' }))
await load() await load()
setExpandedCat(prev => new Set([...prev, id])) setExpandedCat(prev => new Set([...prev, id]))
} }
const updateCategory = async (id: number, name: string) => { const updateCategory = async (id: number, name: string) => {
await db.feedbackCategories.update(id, { name }) await updateFeedbackCategory(id, { name })
setCategories(prev => prev.map(c => c.id === id ? { ...c, name } : c)) setCategories(prev => prev.map(c => c.id === id ? { ...c, name } : c))
} }
const deleteCategory = async (id: number) => { const deleteCategory = async (id: number) => {
await db.feedbackCriterionItems.where('categoryId').equals(id).delete() await deleteFeedbackCategory(id)
await db.feedbackCategories.delete(id)
await load() await load()
} }
@ -93,23 +89,23 @@ export default function FeedbackStructureConfig() {
if (!name) return if (!name) return
const catItems = items.filter(i => i.categoryId === catId) const catItems = items.filter(i => i.categoryId === catId)
const maxOrder = catItems.reduce((m, i) => Math.max(m, i.order), 0) const maxOrder = catItems.reduce((m, i) => Math.max(m, i.order), 0)
await db.feedbackCriterionItems.add({ categoryId: catId, name, order: maxOrder + 1 }) await addFeedbackCriterionItem({ categoryId: catId, name, order: maxOrder + 1 })
setNewItemName(prev => ({ ...prev, [catId]: '' })) setNewItemName(prev => ({ ...prev, [catId]: '' }))
await load() await load()
} }
const updateItem = async (id: number, name: string) => { const updateItem = async (id: number, name: string) => {
await db.feedbackCriterionItems.update(id, { name }) await updateFeedbackCriterionItem(id, { name })
setItems(prev => prev.map(i => i.id === id ? { ...i, name } : i)) setItems(prev => prev.map(i => i.id === id ? { ...i, name } : i))
} }
const deleteItem = async (id: number) => { const deleteItem = async (id: number) => {
await db.feedbackCriterionItems.delete(id) await deleteFeedbackCriterionItem(id)
setItems(prev => prev.filter(i => i.id !== id)) setItems(prev => prev.filter(i => i.id !== id))
} }
const updateItemWeight = async (id: number, weight: number) => { const updateItemWeight = async (id: number, weight: number) => {
await db.feedbackCriterionItems.update(id, { weight }) await updateFeedbackCriterionItem(id, { weight })
setItems(prev => prev.map(i => i.id === id ? { ...i, weight } : i)) setItems(prev => prev.map(i => i.id === id ? { ...i, weight } : i))
} }

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { db } from '../db' import { getOrSeedRatingTrendWeightStep, saveRatingTrendWeightStep } from '../db'
import { DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants' import { DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants'
import { previewWeights } from '../utils/ratingTrend' import { previewWeights } from '../utils/ratingTrend'
@ -8,19 +8,11 @@ export default function RatingWeightConfig() {
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
useEffect(() => { useEffect(() => {
const load = async () => { getOrSeedRatingTrendWeightStep().then(setWeightStep)
let settings = await db.appSettings.get(1)
if (!settings) {
await db.appSettings.add({ id: 1, ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP })
settings = await db.appSettings.get(1)
}
setWeightStep(settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP)
}
load()
}, []) }, [])
const save = async () => { const save = async () => {
await db.appSettings.put({ id: 1, ratingTrendWeightStep: weightStep }) await saveRatingTrendWeightStep(weightStep)
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 2500) setTimeout(() => setSaved(false), 2500)
} }

View File

@ -5,7 +5,7 @@ import type {
} from '../../db' } from '../../db'
import { INST_COLORS, RATING_OPTIONS, RATING_NUM_MAP } from '../../config/constants' import { INST_COLORS, RATING_OPTIONS, RATING_NUM_MAP } from '../../config/constants'
import { detectNotationRating } from '../../utils/notationParser' import { detectNotationRating } from '../../utils/notationParser'
import { numberToRating } from '../../utils/ratingTrend' import { numberToRating, weightedAverageRating } from '../../utils/ratingTrend'
interface Props { interface Props {
institutees: Consultant[] institutees: Consultant[]
@ -27,18 +27,7 @@ function catMode(
fbItems: FeedbackCriterionItem[], fbItems: FeedbackCriterionItem[],
getScore: (itemId: number) => FeedbackRating | null, getScore: (itemId: number) => FeedbackRating | null,
): FeedbackRating | null { ): FeedbackRating | null {
const scored = fbItems return weightedAverageRating(fbItems.filter(i => i.categoryId === cat.id), getScore)
.filter(i => i.categoryId === cat.id)
.map(i => ({ score: getScore(i.id!), w: i.weight ?? 1 }))
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
if (scored.length === 0) return null
const wSum = scored.reduce((s, x) => s + x.w, 0)
if (wSum === 0) return null // alle beteiligten Kriterien auf Gewicht 0 → keine Aussage möglich, sonst NaN < x ist überall false und fällt auf "Fully" durch
const avg = scored.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum
if (avg < 1.5) return 'not_client_ready'
if (avg < 2.5) return 'partially_client_ready'
if (avg < 3.5) return 'nearly_client_ready'
return 'client_ready'
} }
function suggestedRating(entries: ConversationEntry[], instituteeId: number, itemId: number): FeedbackRating | null { function suggestedRating(entries: ConversationEntry[], instituteeId: number, itemId: number): FeedbackRating | null {

View File

@ -1,10 +1,18 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { import {
db,
type Assignment, type MeetingInstance, type PhaseTemplate, type Assignment, type MeetingInstance, type PhaseTemplate,
type FeedbackCategory, type FeedbackCriterionItem, type FeedbackRating, type FeedbackCategory, type FeedbackCriterionItem, type FeedbackRating,
type Consultant, type ConversationEntry, type ConversationSkillScore, type Assessment, type Consultant, type ConversationEntry, type ConversationSkillScore, type Assessment,
getMeetingInstance, updateMeetingInstance, softDeleteMeetingInstance,
getAssignmentType,
getAssignment,
getPhaseTemplate,
getConsultantsByIds,
listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure,
listConversationEntriesForMeeting, addConversationEntry, updateConversationEntry, deleteConversationEntry,
listConversationSkillScoresAll, addConversationSkillScore, updateConversationSkillScore, deleteConversationSkillScoresForEntry,
listAssessmentsForMeeting, addAssessment, updateAssessment,
} from '../../db' } from '../../db'
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport' import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport'
import { NotationText, splitNoteLines } from '../../utils/notationParser' import { NotationText, splitNoteLines } from '../../utils/notationParser'
@ -52,38 +60,36 @@ export default function MeetingView() {
entries.filter(e => e.instituteeId === instId).reduce((s, e) => s + (e.fillerCount ?? 0), 0) entries.filter(e => e.instituteeId === instId).reduce((s, e) => s + (e.fillerCount ?? 0), 0)
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
const m = await db.meetingInstances.get(meetingId) const m = await getMeetingInstance(meetingId)
if (!m) return if (!m) return
setMeeting(m) setMeeting(m)
setGeneralNotes(m.generalNotes ?? '') setGeneralNotes(m.generalNotes ?? '')
const [tmpl, a] = await Promise.all([ const [tmpl, a] = await Promise.all([
db.phaseTemplates.get(m.phaseTemplateId), getPhaseTemplate(m.phaseTemplateId),
db.assignments.get(m.assignmentId), getAssignment(m.assignmentId),
]) ])
setTemplate(tmpl ?? null) setTemplate(tmpl ?? null)
setAssignment(a ?? null) setAssignment(a ?? null)
if (a) { if (a) {
const cons = await db.consultants.where('id').anyOf(a.instituteeIds).toArray() const cons = await getConsultantsByIds(a.instituteeIds)
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean)) setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
} }
const [allCats, allItems, aType] = await Promise.all([ const [allCats, allItems, aType] = await Promise.all([
db.feedbackCategories.toArray(), listFeedbackCategoriesAll(),
db.feedbackCriterionItems.toArray(), listFeedbackCriterionItemsAll(),
a ? db.assignmentTypes.get(a.assignmentTypeId) : Promise.resolve(undefined), a ? getAssignmentType(a.assignmentTypeId) : Promise.resolve(undefined),
]) ])
const itemFilter = aType?.criteriaIds?.length ? new Set(aType.criteriaIds) : null const { categories: visibleCats, items: visibleItems } = filterVisibleFeedbackStructure(allCats, allItems, aType?.criteriaIds)
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems setFbItems(visibleItems)
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId)) setFbCategories(visibleCats)
setFbItems(filteredItems)
setFbCategories(itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats)
const [convEntries, convScores, asmts] = await Promise.all([ const [convEntries, convScores, asmts] = await Promise.all([
db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'), listConversationEntriesForMeeting(meetingId),
db.conversationSkillScores.toArray(), listConversationSkillScoresAll(),
db.assessments.where('meetingInstanceId').equals(meetingId).toArray(), listAssessmentsForMeeting(meetingId),
]) ])
setEntries(convEntries) setEntries(convEntries)
setScores(convScores) setScores(convScores)
@ -97,13 +103,13 @@ export default function MeetingView() {
const activateInstitutee = async (instId: number) => { const activateInstitutee = async (instId: number) => {
if (activeEntryId !== null) { if (activeEntryId !== null) {
if (entryNote.trim() === '') { if (entryNote.trim() === '') {
await db.conversationEntries.delete(activeEntryId) await deleteConversationEntry(activeEntryId)
} else { } else {
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() }) await updateConversationEntry(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
} }
} }
const maxSeq = entries.reduce((m, e) => Math.max(m, e.sequenceIndex), 0) const maxSeq = entries.reduce((m, e) => Math.max(m, e.sequenceIndex), 0)
const eid = await db.conversationEntries.add({ const eid = await addConversationEntry({
meetingInstanceId: meetingId, instituteeId: instId, meetingInstanceId: meetingId, instituteeId: instId,
sequenceIndex: maxSeq + 1, note: '', fillerCount: 0, updatedAt: new Date().toISOString(), sequenceIndex: maxSeq + 1, note: '', fillerCount: 0, updatedAt: new Date().toISOString(),
}) })
@ -126,7 +132,7 @@ export default function MeetingView() {
const saveEntryNote = async () => { const saveEntryNote = async () => {
if (activeEntryId === null) return if (activeEntryId === null) return
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() }) await updateConversationEntry(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, note: entryNote } : e)) setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, note: entryNote } : e))
} }
@ -134,13 +140,13 @@ export default function MeetingView() {
if (activeEntryId === null) return if (activeEntryId === null) return
const entry = entries.find(e => e.id === activeEntryId) const entry = entries.find(e => e.id === activeEntryId)
const newCount = (entry?.fillerCount ?? 0) + 1 const newCount = (entry?.fillerCount ?? 0) + 1
await db.conversationEntries.update(activeEntryId, { fillerCount: newCount }) await updateConversationEntry(activeEntryId, { fillerCount: newCount })
setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, fillerCount: newCount } : e)) setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, fillerCount: newCount } : e))
} }
const closeActiveEntry = async () => { const closeActiveEntry = async () => {
if (activeEntryId !== null && entryNote.trim() === '') { if (activeEntryId !== null && entryNote.trim() === '') {
await db.conversationEntries.delete(activeEntryId) await deleteConversationEntry(activeEntryId)
} else { } else {
await saveEntryNote() await saveEntryNote()
} }
@ -156,7 +162,7 @@ export default function MeetingView() {
const entry = entries.find(e => e.id === entryId) const entry = entries.find(e => e.id === entryId)
const currentLines = splitNoteLines(editNote) const currentLines = splitNoteLines(editNote)
const prunedTags = (entry?.lineTags ?? []).filter(t => currentLines.includes(t.text)) const prunedTags = (entry?.lineTags ?? []).filter(t => currentLines.includes(t.text))
await db.conversationEntries.update(entryId, { note: editNote, lineTags: prunedTags, updatedAt: new Date().toISOString() }) await updateConversationEntry(entryId, { note: editNote, lineTags: prunedTags, updatedAt: new Date().toISOString() })
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote, lineTags: prunedTags } : e)) setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote, lineTags: prunedTags } : e))
setEditingId(null) setEditingId(null)
} }
@ -167,24 +173,24 @@ export default function MeetingView() {
const currentLines = splitNoteLines(entry.note) const currentLines = splitNoteLines(entry.note)
const kept = (entry.lineTags ?? []).filter(t => t.text !== lineText && currentLines.includes(t.text)) const kept = (entry.lineTags ?? []).filter(t => t.text !== lineText && currentLines.includes(t.text))
const nextTags = criterionItemId === null ? kept : [...kept, { text: lineText, criterionItemId }] const nextTags = criterionItemId === null ? kept : [...kept, { text: lineText, criterionItemId }]
await db.conversationEntries.update(entryId, { lineTags: nextTags }) await updateConversationEntry(entryId, { lineTags: nextTags })
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, lineTags: nextTags } : e)) setEntries(prev => prev.map(e => e.id === entryId ? { ...e, lineTags: nextTags } : e))
} }
const deleteEntry = async (entryId: number) => { const deleteEntry = async (entryId: number) => {
await db.conversationSkillScores.where('conversationEntryId').equals(entryId).delete() await deleteConversationSkillScoresForEntry(entryId)
await db.conversationEntries.delete(entryId) await deleteConversationEntry(entryId)
setEntries(prev => prev.filter(e => e.id !== entryId)) setEntries(prev => prev.filter(e => e.id !== entryId))
} }
const adjustFiller = async (entry: ConversationEntry, delta: number) => { const adjustFiller = async (entry: ConversationEntry, delta: number) => {
const newCount = Math.max(0, (entry.fillerCount ?? 0) + delta) const newCount = Math.max(0, (entry.fillerCount ?? 0) + delta)
await db.conversationEntries.update(entry.id!, { fillerCount: newCount }) await updateConversationEntry(entry.id!, { fillerCount: newCount })
setEntries(prev => prev.map(e => e.id === entry.id ? { ...e, fillerCount: newCount } : e)) setEntries(prev => prev.map(e => e.id === entry.id ? { ...e, fillerCount: newCount } : e))
} }
const changeEntrySpeaker = async (entryId: number, instituteeId: number) => { const changeEntrySpeaker = async (entryId: number, instituteeId: number) => {
await db.conversationEntries.update(entryId, { instituteeId }) await updateConversationEntry(entryId, { instituteeId })
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, instituteeId } : e)) setEntries(prev => prev.map(e => e.id === entryId ? { ...e, instituteeId } : e))
} }
@ -194,11 +200,11 @@ export default function MeetingView() {
const setEntryScore = async (entryId: number, categoryId: number, score: FeedbackRating) => { const setEntryScore = async (entryId: number, categoryId: number, score: FeedbackRating) => {
const existing = scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId) const existing = scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)
if (existing?.id) { if (existing?.id) {
await db.conversationSkillScores.update(existing.id, { score }) await updateConversationSkillScore(existing.id, { score })
} else { } else {
await db.conversationSkillScores.add({ conversationEntryId: entryId, criteriaId: categoryId, score }) await addConversationSkillScore({ conversationEntryId: entryId, criteriaId: categoryId, score })
} }
setScores(await db.conversationSkillScores.toArray()) setScores(await listConversationSkillScoresAll())
} }
// ── Assessment handlers ─────────────────────────────────────────────────── // ── Assessment handlers ───────────────────────────────────────────────────
@ -210,22 +216,22 @@ export default function MeetingView() {
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()
if (existing?.id) { if (existing?.id) {
await db.assessments.update(existing.id, { score, updatedAt: now }) await updateAssessment(existing.id, { score, updatedAt: now })
} else { } else {
await db.assessments.add({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now }) await addAssessment({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now })
} }
setAssessments(await db.assessments.where('meetingInstanceId').equals(meetingId).toArray()) setAssessments(await listAssessmentsForMeeting(meetingId))
} }
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()
if (existing?.id) { if (existing?.id) {
await db.assessments.update(existing.id, { note, updatedAt: now }) await updateAssessment(existing.id, { note, updatedAt: now })
} else { } else {
await db.assessments.add({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score: null, note, updatedAt: now }) await addAssessment({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score: null, note, updatedAt: now })
} }
setAssessments(await db.assessments.where('meetingInstanceId').equals(meetingId).toArray()) setAssessments(await listAssessmentsForMeeting(meetingId))
} }
const autoCalcAssessment = async (instituteeId: number) => { const autoCalcAssessment = async (instituteeId: number) => {
@ -248,7 +254,7 @@ export default function MeetingView() {
const saveGeneralNotes = (val: string) => { const saveGeneralNotes = (val: string) => {
setGeneralNotes(val) setGeneralNotes(val)
if (notesTimer.current) clearTimeout(notesTimer.current) if (notesTimer.current) clearTimeout(notesTimer.current)
notesTimer.current = setTimeout(() => db.meetingInstances.update(meetingId, { generalNotes: val }), 600) notesTimer.current = setTimeout(() => updateMeetingInstance(meetingId, { generalNotes: val }), 600)
} }
const buildFilename = (ext: string) => { const buildFilename = (ext: string) => {
@ -273,13 +279,13 @@ export default function MeetingView() {
const handleDeleteMeeting = async () => { const handleDeleteMeeting = async () => {
await saveEntryNote() await saveEntryNote()
await db.meetingInstances.update(meetingId, { deletedAt: new Date().toISOString() }) await softDeleteMeetingInstance(meetingId)
navigate(`/assignment/${assignment?.id}`) navigate(`/assignment/${assignment?.id}`)
} }
const finishMeeting = async () => { const finishMeeting = async () => {
await saveEntryNote() await saveEntryNote()
await db.meetingInstances.update(meetingId, { status: 'done' }) await updateMeetingInstance(meetingId, { status: 'done' })
navigate(`/assignment/${assignment?.id}`) navigate(`/assignment/${assignment?.id}`)
} }

View File

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

View File

@ -1,24 +1,9 @@
import { db } from '../db' import { getMeetingExportData, getFeedbackCriterionItemsByIds } from '../db'
export async function exportMeetingAsMarkdown(meetingId: number): Promise<string> { export async function exportMeetingAsMarkdown(meetingId: number): Promise<string> {
const meeting = await db.meetingInstances.get(meetingId) const { meeting, template, assignment, consultants, entries, allScores, assessments } = await getMeetingExportData(meetingId)
if (!meeting) throw new Error('Meeting nicht gefunden')
const [template, assignment] = await Promise.all([
db.phaseTemplates.get(meeting.phaseTemplateId),
db.assignments.get(meeting.assignmentId),
])
const instituteeIds = assignment?.instituteeIds ?? [] const instituteeIds = assignment?.instituteeIds ?? []
const [consultants, entries, allScores, assessments] = await Promise.all([
instituteeIds.length > 0
? db.consultants.where('id').anyOf(instituteeIds).toArray()
: Promise.resolve([]),
db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'),
db.conversationSkillScores.toArray(),
db.assessments.where('meetingInstanceId').equals(meetingId).toArray(),
])
const critIds = [ const critIds = [
...new Set([ ...new Set([
...(template?.criteriaIds ?? []), ...(template?.criteriaIds ?? []),
@ -26,9 +11,7 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
...entries.flatMap(e => (e.lineTags ?? []).map(t => t.criterionItemId)), ...entries.flatMap(e => (e.lineTags ?? []).map(t => t.criterionItemId)),
]), ]),
] ]
const criteria = critIds.length > 0 const criteria = await getFeedbackCriterionItemsByIds(critIds)
? await db.feedbackCriterionItems.where('id').anyOf(critIds).toArray()
: []
const institutees = instituteeIds const institutees = instituteeIds
.map(id => consultants.find(c => c.id === id)) .map(id => consultants.find(c => c.id === id))
@ -124,23 +107,7 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
} }
export async function exportMeetingAsJson(meetingId: number): Promise<string> { export async function exportMeetingAsJson(meetingId: number): Promise<string> {
const meeting = await db.meetingInstances.get(meetingId) const { meeting, template, assignment, consultants, entries, allScores, assessments } = await getMeetingExportData(meetingId)
if (!meeting) throw new Error('Meeting nicht gefunden')
const [template, assignment] = await Promise.all([
db.phaseTemplates.get(meeting.phaseTemplateId),
db.assignments.get(meeting.assignmentId),
])
const instituteeIds = assignment?.instituteeIds ?? []
const [consultants, entries, allScores, assessments] = await Promise.all([
instituteeIds.length > 0
? db.consultants.where('id').anyOf(instituteeIds).toArray()
: Promise.resolve([]),
db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'),
db.conversationSkillScores.toArray(),
db.assessments.where('meetingInstanceId').equals(meetingId).toArray(),
])
const entryIds = entries.map(e => e.id!) const entryIds = entries.map(e => e.id!)
const entryScores = allScores.filter(s => entryIds.includes(s.conversationEntryId)) const entryScores = allScores.filter(s => entryIds.includes(s.conversationEntryId))

View File

@ -14,6 +14,33 @@ export interface TrendResult {
delta: number | null delta: number | null
} }
/**
* Gewichteter Rohwert (04) über eine Menge von Kriterien, N/A ausgeschlossen.
* `null` wenn nichts bewertet ist oder Σweight===0 (sonst NaN < x überall false fällt auf "Fully" durch).
* Einzige Implementierung dieser Regel nicht pro Callsite neu schreiben, siehe CLAUDE.md.
*/
export function weightedAverageScore(
items: { id?: number; weight?: number }[],
getScore: (itemId: number) => FeedbackRating | null,
): number | null {
const rated = items
.map(i => ({ score: getScore(i.id!), w: i.weight ?? 1 }))
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
if (rated.length === 0) return null
const wSum = rated.reduce((s, x) => s + x.w, 0)
if (wSum === 0) return null
return rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum
}
/** Wie weightedAverageScore, aber als FeedbackRating-Bucket statt Rohwert. */
export function weightedAverageRating(
items: { id?: number; weight?: number }[],
getScore: (itemId: number) => FeedbackRating | null,
): FeedbackRating | null {
const avg = weightedAverageScore(items, getScore)
return avg === null ? null : numberToRating(avg)
}
/** /**
* Ein Wert pro Meeting für eine Gruppe von Kriterien (eine Kategorie oder alle Items für "overall"). * Ein Wert pro Meeting für eine Gruppe von Kriterien (eine Kategorie oder alle Items für "overall").
* Meetings ohne bewertetes Item aus der Gruppe werden übersprungen ein Delivery-Call, der eine * Meetings ohne bewertetes Item aus der Gruppe werden übersprungen ein Delivery-Call, der eine
@ -21,19 +48,14 @@ export interface TrendResult {
*/ */
export function buildGroupMeetingScores( export function buildGroupMeetingScores(
items: FeedbackCriterionItem[], items: FeedbackCriterionItem[],
meetingsChronological: { id: number; date: string }[], meetingsChronological: { id?: number; date: string }[],
getScore: (meetingId: number, itemId: number) => FeedbackRating | null, getScore: (meetingId: number, itemId: number) => FeedbackRating | null,
): MeetingScorePoint[] { ): MeetingScorePoint[] {
const points: MeetingScorePoint[] = [] const points: MeetingScorePoint[] = []
for (const meeting of meetingsChronological) { for (const meeting of meetingsChronological) {
const rated = items const score = weightedAverageScore(items, itemId => getScore(meeting.id!, itemId))
.map(i => ({ score: getScore(meeting.id, i.id!), w: i.weight ?? 1 })) if (score === null) continue
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na') points.push({ meetingId: meeting.id!, date: meeting.date, score })
if (rated.length === 0) continue
const wSum = rated.reduce((s, x) => s + x.w, 0)
if (wSum === 0) continue // alle beteiligten Kriterien auf Gewicht 0 → keine Aussage möglich, sonst NaN < x ist überall false und fällt auf "Fully" durch
const score = rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum
points.push({ meetingId: meeting.id, date: meeting.date, score })
} }
return points return points
} }

View File

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

20
tsconfig.server.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["server", "src/db/types.ts"]
}

View File

@ -7,6 +7,9 @@ export default defineConfig({
server: { server: {
port: process.env.PORT ? Number(process.env.PORT) : 5173, port: process.env.PORT ? Number(process.env.PORT) : 5173,
strictPort: !!process.env.PORT, strictPort: !!process.env.PORT,
proxy: {
'/api': process.env.API_URL ?? 'http://localhost:4000',
},
}, },
plugins: [ plugins: [
react(), react(),