Update
This commit is contained in:
parent
911dcace72
commit
9c68c414d8
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -11,6 +11,9 @@ dist-ssr/
|
||||||
# TypeScript build info
|
# TypeScript build info
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Lokale SQLite-Datenbank (enthält personenbezogene Daten, siehe CLAUDE.md → Datenschutz)
|
||||||
|
server/data/
|
||||||
|
|
||||||
# Local environment / secrets
|
# Local environment / secrets
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
|
||||||
71
CLAUDE.md
71
CLAUDE.md
|
|
@ -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,0–2,0` in `0,1`-Schritten verfeinert (`FeedbackStructureConfig.tsx`, vorher `×1`–`×5`), dabei einen latenten Division-durch-Null-Bug gefixt, der durch das neu erlaubte Gewicht `0` real wurde (`catMode()` in `AssessmentTab.tsx`, `buildGroupMeetingScores()` in `ratingTrend.ts` — siehe zweite Bug-Klasse oben). Sprecher eines bereits erfassten Protokoll-Beitrags kann nachträglich geändert werden (`ConversationTab.tsx`, im Bearbeiten-Modus neben Füllwörtern — ändert sofort `ConversationEntry.instituteeId`, kein separater Speichern-Schritt nötig). N/A-Aggregationsbug in `buildAiPrompt` (`AssignmentFeedbackPage.tsx`) gefixt — war die letzte offene Stelle dieser Bug-Klasse außer der jetzt migrierten `FeedbackDraftPage.tsx`. `FeedbackDraftPage.tsx` vollständig auf `feedbackCriterionItems`/`feedbackCategories` migriert (letzter Konsument der Legacy-Tabellen ist jetzt nur noch der Seed-Code), Notizen jetzt korrekt pro Kriterium über `lineTags` statt aller Protokoll-Notizen ungefiltert an jedes Kriterium gehängt, Score-Anzeige auf `RATING_OPTIONS`-Labels umgestellt (statt `/5`-Rohwert). Toter Code (verwaiste AssignmentType/PhaseTemplate-CRUD-Reste) aus `FeedbackStructureConfig.tsx` entfernt. „Dashboard zeigt keine Assignment-Nummer" war bereits gelöst, nur die Doku war veraltet — richtiggestellt.
|
**Erledigt (2026-07-02):** Konfiguration zentralisiert (`config/constants.ts`), `db/index.ts` in types/schema/seeds aufgeteilt, `MeetingView.tsx` in 3 Dateien aufgeteilt (`pages/meeting/`), `Evaluation.tsx` auf `feedbackCriterionItems` migriert + nach Assignment gruppiert + Papierkorb/Status-Filter + N/A-Bug gefixt, `zustand` entfernt (war ungenutzt). Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung umgesetzt (`src/utils/ratingTrend.ts`, neue Tabelle `appSettings`, neuer Config-Tab „Gewichtung“, Vorschlags-Badge + Übernehmen/Trend-in-Text auf `AssignmentFeedbackPage`, `buildAiPrompt` um Trend-Abschnitt erweitert und Legacy-`db.criteria`-Bug dabei gefixt). Kriterium-Zuordnung für Freitext-Notizen umgesetzt: `ConversationEntry.lineTags` (Zeilentext → Kriterium, nach einem verworfenen Zwischenstand mit "ein Entry pro Zeile" — siehe Sonstige Fallstricke), `CriterionTagPicker.tsx` (Chip+Popover mit Suche, unverändert seit erster Version) wird pro Zeile direkt an der Zeile via `NotationText`s neuer optionaler `renderLineAddon`-Prop gerendert; Zuordnung passiert bewusst nur nachträglich (nicht live während des Tippens), um den Schreibfluss nicht zu stören. `buildAiPrompt` führt Assessment-Notizen und getaggte Zeilen jetzt pro Kriterium zusammen, unzugeordnete Zeilen bleiben im allgemeinen Protokoll-Block. **(2026-07-03)** Vier Folge-Fixes: `meetingExport.ts` löste Kriterien über die Legacy-Tabelle `db.criteria` auf (identischer Bug wie der frühere `buildAiPrompt`-Fix) — dadurch war die Gesamtbewertung im Export faktisch leer; jetzt auf `feedbackCriterionItems` umgestellt, Markdown-Export zeigt zusätzlich das Kriterium pro getaggter Protokollzeile. `dbBackup.ts`s `TABLE_NAMES` enthielt `appSettings` nicht — Voll-Backups verloren die Gewichtungs-Config stillschweigend; jetzt ergänzt samt Kommentar, dass neue Tabellen dort manuell nachgezogen werden müssen. Notation → Bewertungs-Vorschlag: `detectNotationRating()` in `notationParser.tsx` (`!`→Not, `(!)`→Partially, `(+)`→Nearly, `+`→Fully) kombiniert mit `lineTags` liefert in `AssessmentTab.tsx` einen "Vorschlag aus Protokoll"-Badge pro Kriterium-Item, unabhängig vom bestehenden "Aus Protokoll"-Button (andere Datenquelle). `AssignmentCreate.tsx`: Assignment-Nummer ist jetzt Pflichtfeld (nur auf Formular-Ebene, Typ bleibt optional wegen Altdaten). Kriterium-Gewichtung auf `0,0–2,0` in `0,1`-Schritten verfeinert (`FeedbackStructureConfig.tsx`, vorher `×1`–`×5`), dabei einen latenten Division-durch-Null-Bug gefixt, der durch das neu erlaubte Gewicht `0` real wurde (`catMode()` in `AssessmentTab.tsx`, `buildGroupMeetingScores()` in `ratingTrend.ts` — siehe zweite Bug-Klasse oben). Sprecher eines bereits erfassten Protokoll-Beitrags kann nachträglich geändert werden (`ConversationTab.tsx`, im Bearbeiten-Modus neben Füllwörtern — ändert sofort `ConversationEntry.instituteeId`, kein separater Speichern-Schritt nötig). N/A-Aggregationsbug in `buildAiPrompt` (`AssignmentFeedbackPage.tsx`) gefixt — war die letzte offene Stelle dieser Bug-Klasse außer der jetzt migrierten `FeedbackDraftPage.tsx`. `FeedbackDraftPage.tsx` vollständig auf `feedbackCriterionItems`/`feedbackCategories` migriert (letzter Konsument der Legacy-Tabellen ist jetzt nur noch der Seed-Code), Notizen jetzt korrekt pro Kriterium über `lineTags` statt aller Protokoll-Notizen ungefiltert an jedes Kriterium gehängt, Score-Anzeige auf `RATING_OPTIONS`-Labels umgestellt (statt `/5`-Rohwert). Toter Code (verwaiste AssignmentType/PhaseTemplate-CRUD-Reste) aus `FeedbackStructureConfig.tsx` entfernt. „Dashboard zeigt keine Assignment-Nummer" war bereits gelöst, nur die Doku war veraltet — richtiggestellt.
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,0–2,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,0–2,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`.
|
||||||
|
|
||||||
**Vollständige, priorisierte Liste der offenen Punkte:** siehe `CLAUDE.md` → Abschnitt "Offene Punkte (priorisiert)" — das ist die laufend gepflegte Quelle, hier nicht dupliziert. Größere offene Themen, die eine eigene Design-Session brauchen: mehrstufiges Prompt-System für die KI-Generierung, selbstlernendes Tagging.
|
**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
1518
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -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
18
server/database.ts
Normal 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
125
server/db/crud.ts
Normal 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
|
||||||
|
}
|
||||||
26
server/db/queries/appSettings.ts
Normal file
26
server/db/queries/appSettings.ts
Normal 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
|
||||||
|
}
|
||||||
12
server/db/queries/assessments.ts
Normal file
12
server/db/queries/assessments.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { getAllRows, getRowsWhereEquals, getRowsWhereIn, insertRow, updateRow, deleteRowsWhereEquals } from '../crud'
|
||||||
|
import type { Assessment } from '../../../src/db/types'
|
||||||
|
|
||||||
|
export const listAssessmentsAll = (): Assessment[] => getAllRows<Assessment>('assessments')
|
||||||
|
export const listAssessmentsForMeeting = (meetingInstanceId: number): Assessment[] =>
|
||||||
|
getRowsWhereEquals<Assessment>('assessments', 'meetingInstanceId', meetingInstanceId)
|
||||||
|
export const listAssessmentsForMeetings = (meetingInstanceIds: number[]): Assessment[] =>
|
||||||
|
getRowsWhereIn<Assessment>('assessments', 'meetingInstanceId', meetingInstanceIds)
|
||||||
|
export const addAssessment = (a: Omit<Assessment, 'id'>): number => insertRow('assessments', a)
|
||||||
|
export const updateAssessment = (id: number, patch: Partial<Assessment>): number => updateRow('assessments', id, patch)
|
||||||
|
export const deleteAssessmentsForMeeting = (meetingInstanceId: number): number =>
|
||||||
|
deleteRowsWhereEquals('assessments', 'meetingInstanceId', meetingInstanceId)
|
||||||
44
server/db/queries/assignmentFeedback.ts
Normal file
44
server/db/queries/assignmentFeedback.ts
Normal 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)
|
||||||
31
server/db/queries/assignmentTypes.ts
Normal file
31
server/db/queries/assignmentTypes.ts
Normal 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)
|
||||||
31
server/db/queries/assignments.ts
Normal file
31
server/db/queries/assignments.ts
Normal 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}` : '?'
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
45
server/db/queries/backup.ts
Normal file
45
server/db/queries/backup.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Generischer Dump/Restore aller Tabellen — Server-Pendant zum bisherigen client-seitigen
|
||||||
|
* dbBackup.ts (das direkt gegen Dexie/IndexedDB lief). Iteriert exakt TABLE_DEFINITIONS aus
|
||||||
|
* tableRegistry.ts, derselben Liste, die auch das SQL-Schema erzeugt (siehe Vollständigkeits-
|
||||||
|
* Garantie im Plan) — eine neue Tabelle kann dadurch nicht unbemerkt aus dem Backup fallen.
|
||||||
|
*/
|
||||||
|
import { db } from '../../database'
|
||||||
|
import { TABLE_DEFINITIONS } from '../tableRegistry'
|
||||||
|
import { getAllRows, insertRowWithId } from '../crud'
|
||||||
|
|
||||||
|
export interface BackupPayload {
|
||||||
|
version: number
|
||||||
|
exportedAt: string
|
||||||
|
tables: Record<string, Array<Record<string, unknown>>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert jede Tabelle mit dekodierten Spalten (echte Arrays/Booleans statt JSON-/Bit-Rohwerten). */
|
||||||
|
export function exportAll(): BackupPayload {
|
||||||
|
const tables: Record<string, Array<Record<string, unknown>>> = {}
|
||||||
|
for (const table of TABLE_DEFINITIONS) {
|
||||||
|
tables[table.name] = getAllRows<Record<string, unknown>>(table.name)
|
||||||
|
}
|
||||||
|
return { version: 1, exportedAt: new Date().toISOString(), tables }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht alle Tabellen und fügt die übergebenen Zeilen mit ihrer ursprünglichen id wieder ein (erhält Fremdschlüssel-Referenzen). */
|
||||||
|
export function importAll(tables: Record<string, Array<Record<string, unknown>>>): void {
|
||||||
|
db.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
for (const table of TABLE_DEFINITIONS) {
|
||||||
|
db.prepare(`DELETE FROM "${table.name}"`).run()
|
||||||
|
}
|
||||||
|
for (const table of TABLE_DEFINITIONS) {
|
||||||
|
for (const row of tables[table.name] ?? []) {
|
||||||
|
const { id, ...rest } = row
|
||||||
|
if (typeof id !== 'number') continue
|
||||||
|
insertRowWithId(table.name, id, rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.exec('COMMIT')
|
||||||
|
} catch (err) {
|
||||||
|
db.exec('ROLLBACK')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/db/queries/consultants.ts
Normal file
12
server/db/queries/consultants.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { getAllRows, getRowById, getRowsWhereIn, insertRow, updateRow, deleteRowById } from '../crud'
|
||||||
|
import type { Group, Consultant } from '../../../src/db/types'
|
||||||
|
|
||||||
|
export const listGroups = (): Group[] => getAllRows<Group>('groups')
|
||||||
|
export const addGroup = (group: Omit<Group, 'id'>): number => insertRow('groups', group)
|
||||||
|
|
||||||
|
export const listConsultants = (): Consultant[] => getAllRows<Consultant>('consultants')
|
||||||
|
export const getConsultant = (id: number): Consultant | undefined => getRowById<Consultant>('consultants', id)
|
||||||
|
export const getConsultantsByIds = (ids: number[]): Consultant[] => getRowsWhereIn<Consultant>('consultants', 'id', ids)
|
||||||
|
export const addConsultant = (consultant: Omit<Consultant, 'id'>): number => insertRow('consultants', consultant)
|
||||||
|
export const updateConsultant = (id: number, patch: Partial<Consultant>): number => updateRow('consultants', id, patch)
|
||||||
|
export const deleteConsultant = (id: number): void => deleteRowById('consultants', id)
|
||||||
19
server/db/queries/conversationEntries.ts
Normal file
19
server/db/queries/conversationEntries.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { getAllRows, getRowsWhereEquals, getRowsWhereIn, insertRow, updateRow, deleteRowById, deleteRowsWhereEquals } from '../crud'
|
||||||
|
import type { ConversationEntry, ConversationSkillScore } from '../../../src/db/types'
|
||||||
|
|
||||||
|
export const listConversationEntriesForMeeting = (meetingInstanceId: number): ConversationEntry[] =>
|
||||||
|
getRowsWhereEquals<ConversationEntry>('conversationEntries', 'meetingInstanceId', meetingInstanceId)
|
||||||
|
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
|
||||||
|
export const listConversationEntriesForMeetings = (meetingInstanceIds: number[]): ConversationEntry[] =>
|
||||||
|
getRowsWhereIn<ConversationEntry>('conversationEntries', 'meetingInstanceId', meetingInstanceIds)
|
||||||
|
export const addConversationEntry = (e: Omit<ConversationEntry, 'id'>): number => insertRow('conversationEntries', e)
|
||||||
|
export const updateConversationEntry = (id: number, patch: Partial<ConversationEntry>): number =>
|
||||||
|
updateRow('conversationEntries', id, patch)
|
||||||
|
export const deleteConversationEntry = (id: number): void => deleteRowById('conversationEntries', id)
|
||||||
|
|
||||||
|
export const listConversationSkillScoresAll = (): ConversationSkillScore[] => getAllRows<ConversationSkillScore>('conversationSkillScores')
|
||||||
|
export const addConversationSkillScore = (s: Omit<ConversationSkillScore, 'id'>): number => insertRow('conversationSkillScores', s)
|
||||||
|
export const updateConversationSkillScore = (id: number, patch: Partial<ConversationSkillScore>): number =>
|
||||||
|
updateRow('conversationSkillScores', id, patch)
|
||||||
|
export const deleteConversationSkillScoresForEntry = (conversationEntryId: number): number =>
|
||||||
|
deleteRowsWhereEquals('conversationSkillScores', 'conversationEntryId', conversationEntryId)
|
||||||
57
server/db/queries/feedbackStructure.ts
Normal file
57
server/db/queries/feedbackStructure.ts
Normal 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)
|
||||||
69
server/db/queries/meetings.ts
Normal file
69
server/db/queries/meetings.ts
Normal 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
160
server/db/tableRegistry.ts
Normal 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
29
server/index.ts
Normal 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
33
server/rpcModules.ts
Normal 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>
|
||||||
66
server/scripts/verifyBackupRoundtrip.ts
Normal file
66
server/scripts/verifyBackupRoundtrip.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
/**
|
||||||
|
* Harter Vollständigkeits-Beweis für Backup-Export/-Import (siehe Plan "Vollständigkeits-Garantie").
|
||||||
|
* Nimmt eine zuvor exportierte Backup-JSON (egal ob aus der alten Dexie-Version oder aus einem
|
||||||
|
* früheren exportAll()), importiert sie in die aktuelle SQLite-DB, exportiert sofort wieder und
|
||||||
|
* vergleicht pro Tabelle Zeilenzahl + Inhalt. ACHTUNG: überschreibt den aktuellen Inhalt der
|
||||||
|
* lokalen SQLite-Datenbank mit dem Inhalt der Backup-Datei.
|
||||||
|
*
|
||||||
|
* Aufruf: npm run verify-backup -- <pfad-zur-backup.json>
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import '../database'
|
||||||
|
import { TABLE_DEFINITIONS } from '../db/tableRegistry'
|
||||||
|
import { exportAll, importAll } from '../db/queries/backup'
|
||||||
|
|
||||||
|
const filePath = process.argv[2]
|
||||||
|
if (!filePath) {
|
||||||
|
console.error('Nutzung: npm run verify-backup -- <pfad-zur-backup.json>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = JSON.parse(readFileSync(filePath, 'utf8')) as { tables?: Record<string, Array<Record<string, unknown>>> }
|
||||||
|
if (!backup.tables || typeof backup.tables !== 'object') {
|
||||||
|
console.error('Ungültiges Backup-Format: kein "tables"-Feld gefunden.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const sourceTables = backup.tables
|
||||||
|
|
||||||
|
console.log(`Importiere ${filePath} in die SQLite-DB ...`)
|
||||||
|
importAll(sourceTables)
|
||||||
|
|
||||||
|
console.log('Exportiere sofort wieder zum Vergleich ...\n')
|
||||||
|
const reexported = exportAll()
|
||||||
|
|
||||||
|
const byId = (rows: Array<Record<string, unknown>>) =>
|
||||||
|
[...rows].sort((a, b) => (a.id as number) - (b.id as number))
|
||||||
|
|
||||||
|
let allPass = true
|
||||||
|
|
||||||
|
for (const table of TABLE_DEFINITIONS) {
|
||||||
|
const name = table.name
|
||||||
|
const sourceRows = byId(sourceTables[name] ?? [])
|
||||||
|
const targetRows = byId(reexported.tables[name] ?? [])
|
||||||
|
|
||||||
|
if (sourceRows.length !== targetRows.length) {
|
||||||
|
console.log(`❌ ${name}: FAIL — Zeilenzahl weicht ab (Quelle: ${sourceRows.length}, Reexport: ${targetRows.length})`)
|
||||||
|
allPass = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceJson = JSON.stringify(sourceRows)
|
||||||
|
const targetJson = JSON.stringify(targetRows)
|
||||||
|
if (sourceJson !== targetJson) {
|
||||||
|
console.log(`❌ ${name}: FAIL — Inhalt weicht ab`)
|
||||||
|
console.log(` Quelle (Ausschnitt): ${sourceJson.slice(0, 300)}`)
|
||||||
|
console.log(` Reexport (Ausschnitt): ${targetJson.slice(0, 300)}`)
|
||||||
|
allPass = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ ${name}: PASS (${sourceRows.length} Zeile${sourceRows.length === 1 ? '' : 'n'}, identisch)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(allPass
|
||||||
|
? '\n✅ Alle 21 Tabellen vollständig und inhaltlich identisch importiert/exportiert.'
|
||||||
|
: '\n❌ Mindestens eine Tabelle weicht ab — siehe oben.')
|
||||||
|
process.exit(allPass ? 0 : 1)
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
6
src/db/queries/appSettings.ts
Normal file
6
src/db/queries/appSettings.ts
Normal 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)
|
||||||
13
src/db/queries/assessments.ts
Normal file
13
src/db/queries/assessments.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { rpc } from '../rpcClient'
|
||||||
|
import type { Assessment } from '../types'
|
||||||
|
|
||||||
|
export const listAssessmentsAll = (): Promise<Assessment[]> => rpc('assessments', 'listAssessmentsAll')
|
||||||
|
export const listAssessmentsForMeeting = (meetingInstanceId: number): Promise<Assessment[]> =>
|
||||||
|
rpc('assessments', 'listAssessmentsForMeeting', meetingInstanceId)
|
||||||
|
export const listAssessmentsForMeetings = (meetingInstanceIds: number[]): Promise<Assessment[]> =>
|
||||||
|
rpc('assessments', 'listAssessmentsForMeetings', meetingInstanceIds)
|
||||||
|
export const addAssessment = (a: Omit<Assessment, 'id'>): Promise<number> => rpc('assessments', 'addAssessment', a)
|
||||||
|
export const updateAssessment = (id: number, patch: Partial<Assessment>): Promise<number> =>
|
||||||
|
rpc('assessments', 'updateAssessment', id, patch)
|
||||||
|
export const deleteAssessmentsForMeeting = (meetingInstanceId: number): Promise<number> =>
|
||||||
|
rpc('assessments', 'deleteAssessmentsForMeeting', meetingInstanceId)
|
||||||
41
src/db/queries/assignmentFeedback.ts
Normal file
41
src/db/queries/assignmentFeedback.ts
Normal 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)
|
||||||
23
src/db/queries/assignmentTypes.ts
Normal file
23
src/db/queries/assignmentTypes.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { rpc } from '../rpcClient'
|
||||||
|
import type { AssignmentType, PhaseTemplate } from '../types'
|
||||||
|
|
||||||
|
export const listAssignmentTypes = (): Promise<AssignmentType[]> => rpc('assignmentTypes', 'listAssignmentTypes')
|
||||||
|
export const getAssignmentType = (id: number): Promise<AssignmentType | undefined> => rpc('assignmentTypes', 'getAssignmentType', id)
|
||||||
|
export const addAssignmentType = (type: Omit<AssignmentType, 'id'>): Promise<number> =>
|
||||||
|
rpc('assignmentTypes', 'addAssignmentType', type)
|
||||||
|
export const updateAssignmentType = (id: number, patch: Partial<AssignmentType>): Promise<number> =>
|
||||||
|
rpc('assignmentTypes', 'updateAssignmentType', id, patch)
|
||||||
|
|
||||||
|
/** Löscht nur, wenn keine Assignments mehr diesen Typ referenzieren (Cascade auf phaseTemplates sonst). */
|
||||||
|
export const deleteAssignmentType = (id: number): Promise<{ ok: true } | { ok: false; assignmentCount: number }> =>
|
||||||
|
rpc('assignmentTypes', 'deleteAssignmentType', id)
|
||||||
|
|
||||||
|
export const listPhaseTemplates = (): Promise<PhaseTemplate[]> => rpc('assignmentTypes', 'listPhaseTemplates')
|
||||||
|
export const getPhaseTemplate = (id: number): Promise<PhaseTemplate | undefined> => rpc('assignmentTypes', 'getPhaseTemplate', id)
|
||||||
|
export const listPhaseTemplatesForType = (assignmentTypeId: number): Promise<PhaseTemplate[]> =>
|
||||||
|
rpc('assignmentTypes', 'listPhaseTemplatesForType', assignmentTypeId)
|
||||||
|
export const addPhaseTemplate = (tmpl: Omit<PhaseTemplate, 'id'>): Promise<number> =>
|
||||||
|
rpc('assignmentTypes', 'addPhaseTemplate', tmpl)
|
||||||
|
export const updatePhaseTemplate = (id: number, patch: Partial<PhaseTemplate>): Promise<number> =>
|
||||||
|
rpc('assignmentTypes', 'updatePhaseTemplate', id, patch)
|
||||||
|
export const deletePhaseTemplate = (id: number): Promise<void> => rpc('assignmentTypes', 'deletePhaseTemplate', id)
|
||||||
17
src/db/queries/assignments.ts
Normal file
17
src/db/queries/assignments.ts
Normal 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')
|
||||||
12
src/db/queries/consultants.ts
Normal file
12
src/db/queries/consultants.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { rpc } from '../rpcClient'
|
||||||
|
import type { Group, Consultant } from '../types'
|
||||||
|
|
||||||
|
export const listGroups = (): Promise<Group[]> => rpc('consultants', 'listGroups')
|
||||||
|
export const addGroup = (group: Omit<Group, 'id'>): Promise<number> => rpc('consultants', 'addGroup', group)
|
||||||
|
|
||||||
|
export const listConsultants = (): Promise<Consultant[]> => rpc('consultants', 'listConsultants')
|
||||||
|
export const getConsultant = (id: number): Promise<Consultant | undefined> => rpc('consultants', 'getConsultant', id)
|
||||||
|
export const getConsultantsByIds = (ids: number[]): Promise<Consultant[]> => rpc('consultants', 'getConsultantsByIds', ids)
|
||||||
|
export const addConsultant = (consultant: Omit<Consultant, 'id'>): Promise<number> => rpc('consultants', 'addConsultant', consultant)
|
||||||
|
export const updateConsultant = (id: number, patch: Partial<Consultant>): Promise<number> => rpc('consultants', 'updateConsultant', id, patch)
|
||||||
|
export const deleteConsultant = (id: number): Promise<void> => rpc('consultants', 'deleteConsultant', id)
|
||||||
21
src/db/queries/conversationEntries.ts
Normal file
21
src/db/queries/conversationEntries.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { rpc } from '../rpcClient'
|
||||||
|
import type { ConversationEntry, ConversationSkillScore } from '../types'
|
||||||
|
|
||||||
|
export const listConversationEntriesForMeeting = (meetingInstanceId: number): Promise<ConversationEntry[]> =>
|
||||||
|
rpc('conversationEntries', 'listConversationEntriesForMeeting', meetingInstanceId)
|
||||||
|
export const listConversationEntriesForMeetings = (meetingInstanceIds: number[]): Promise<ConversationEntry[]> =>
|
||||||
|
rpc('conversationEntries', 'listConversationEntriesForMeetings', meetingInstanceIds)
|
||||||
|
export const addConversationEntry = (e: Omit<ConversationEntry, 'id'>): Promise<number> =>
|
||||||
|
rpc('conversationEntries', 'addConversationEntry', e)
|
||||||
|
export const updateConversationEntry = (id: number, patch: Partial<ConversationEntry>): Promise<number> =>
|
||||||
|
rpc('conversationEntries', 'updateConversationEntry', id, patch)
|
||||||
|
export const deleteConversationEntry = (id: number): Promise<void> => rpc('conversationEntries', 'deleteConversationEntry', id)
|
||||||
|
|
||||||
|
export const listConversationSkillScoresAll = (): Promise<ConversationSkillScore[]> =>
|
||||||
|
rpc('conversationEntries', 'listConversationSkillScoresAll')
|
||||||
|
export const addConversationSkillScore = (s: Omit<ConversationSkillScore, 'id'>): Promise<number> =>
|
||||||
|
rpc('conversationEntries', 'addConversationSkillScore', s)
|
||||||
|
export const updateConversationSkillScore = (id: number, patch: Partial<ConversationSkillScore>): Promise<number> =>
|
||||||
|
rpc('conversationEntries', 'updateConversationSkillScore', id, patch)
|
||||||
|
export const deleteConversationSkillScoresForEntry = (conversationEntryId: number): Promise<number> =>
|
||||||
|
rpc('conversationEntries', 'deleteConversationSkillScoresForEntry', conversationEntryId)
|
||||||
56
src/db/queries/feedbackStructure.ts
Normal file
56
src/db/queries/feedbackStructure.ts
Normal 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
9
src/db/queries/index.ts
Normal 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'
|
||||||
39
src/db/queries/meetings.ts
Normal file
39
src/db/queries/meetings.ts
Normal 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
17
src/db/rpcClient.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { db, type Assignment, type AssignmentType, type Consultant } from '../db'
|
import {
|
||||||
|
type Assignment, type AssignmentType, type Consultant,
|
||||||
|
getAssignment, updateAssignment, listAssignmentTypes, listConsultants,
|
||||||
|
} from '../db'
|
||||||
|
|
||||||
export default function AssignmentEdit() {
|
export default function AssignmentEdit() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
|
@ -27,9 +30,9 @@ export default function AssignmentEdit() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
const [a, types, cons] = await Promise.all([
|
const [a, types, cons] = await Promise.all([
|
||||||
db.assignments.get(assignmentId),
|
getAssignment(assignmentId),
|
||||||
db.assignmentTypes.toArray(),
|
listAssignmentTypes(),
|
||||||
db.consultants.toArray(),
|
listConsultants(),
|
||||||
])
|
])
|
||||||
if (!a) return
|
if (!a) return
|
||||||
setAssignment(a)
|
setAssignment(a)
|
||||||
|
|
@ -50,7 +53,7 @@ export default function AssignmentEdit() {
|
||||||
}, [assignmentId])
|
}, [assignmentId])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await db.assignments.update(assignmentId, {
|
await updateAssignment(assignmentId, {
|
||||||
assignmentNumber: assignmentNumber.trim() || undefined,
|
assignmentNumber: assignmentNumber.trim() || undefined,
|
||||||
title,
|
title,
|
||||||
client,
|
client,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
db,
|
|
||||||
type Assignment,
|
type Assignment,
|
||||||
type Consultant,
|
type Consultant,
|
||||||
type FeedbackDimension,
|
type FeedbackDimension,
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,9 +97,36 @@ 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">
|
||||||
<span>{c.firstName} {c.lastName}</span>
|
{editingId === c.id ? (
|
||||||
<button onClick={() => deleteConsultant(c.id!)} className="text-red-400 text-xs hover:text-red-600">×</button>
|
<>
|
||||||
|
<div className="flex gap-2 flex-1">
|
||||||
|
<input
|
||||||
|
className="flex-1 border border-gray-200 rounded-lg px-2 py-1 text-sm"
|
||||||
|
value={editValues.firstName}
|
||||||
|
onChange={e => setEditValues(p => ({ ...p, firstName: e.target.value }))}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && saveEdit()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="flex-1 border border-gray-200 rounded-lg px-2 py-1 text-sm"
|
||||||
|
value={editValues.lastName}
|
||||||
|
onChange={e => setEditValues(p => ({ ...p, lastName: e.target.value }))}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && saveEdit()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={saveEdit} className="text-brand text-xs hover:underline">Speichern</button>
|
||||||
|
<button onClick={cancelEdit} className="text-gray-400 text-xs hover:text-gray-600">Abbrechen</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{c.firstName} {c.lastName}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => startEdit(c)} className="text-gray-400 text-xs hover:text-brand">Bearbeiten</button>
|
||||||
|
<button onClick={() => deleteConsultant(c.id!)} className="text-red-400 text-xs hover:text-red-600">×</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,33 @@ export interface TrendResult {
|
||||||
delta: number | null
|
delta: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gewichteter Rohwert (0–4) ü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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
20
tsconfig.server.json
Normal 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"]
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user