initial commit

This commit is contained in:
Lars 2026-07-02 10:10:06 +02:00
commit ece111c0a7
34 changed files with 13702 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
# Vite cache
.vite/
# TypeScript build info
*.tsbuildinfo
# Local environment / secrets
.env
.env.local
.env.*.local
# Editor & OS
.DS_Store
Thumbs.db
*.swp
*.swo
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Claude Code local settings (machine-specific, not project-wide)
.claude/settings.local.json
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# PWA generated files
public/sw.js
public/workbox-*.js

101
CLAUDE.md Normal file
View File

@ -0,0 +1,101 @@
# CLAUDE.md — Assignment Monitor
Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) während Kunden-Assignments.
**Vollständige Dokumentation:** `docs/` — bei neuer Session zuerst `docs/HANDOVER.md` lesen.
---
## Stack
- React 18 + TypeScript + Vite (PWA)
- Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js`
- Dexie.js v5 — IndexedDB-Wrapper, kein Backend
- React Router v6
- Capgemini-Brandfarbe: `#0070AD`
## Entwicklung
```bash
npm run dev # Dev-Server starten
npx tsc --noEmit # TypeScript-Check — muss vor jeder Abgabe fehlerfrei sein
```
---
## Kritische Eigenheiten
### Doppelte `criteriaId`-Semantik
```
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
```
### AssignmentType.criteriaIds
Referenziert `FeedbackCriterionItem.id[]`. Leer = alle Kriterien anzeigen.
Filterlogik (immer so anwenden):
```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
```typescript
type FeedbackRating = 'na' | 'not_client_ready' | 'partially_client_ready' | 'nearly_client_ready' | 'client_ready'
// Numerisch: na=0, not=1, partially=2, nearly=3, fully=4
// N/A gilt als "nicht gesetzt" — aus allen Berechnungen ausschließen!
```
Aggregation: gewichteter Durchschnitt → `avg < 1.5` Not · `< 2.5` Partially · `< 3.5` Nearly · `≥ 3.5` Fully
### Routing
```
/assignment/:id/meeting/:meetingId ← meetingId ist MeetingInstance.id, nicht Assignment-ID
```
MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
### Sonstige Fallstricke
- `MeetingInstance.generalNotes` kann `undefined` sein → immer `?? ''` als Fallback
- `PhaseKey` ist `string`, kein Union-Typ
- Tailwind v4: arbitrary values `bg-[#0070AD]` direkt im className — kein Config nötig
---
## Stil-Regeln
- **Keine Kommentare** außer für nicht-offensichtliche Invarianten (kein „Was", nur „Warum")
- **Keine Auto-Resize-Textarea via `rows={1}` + JS** — stattdessen View/Edit-Toggle oder `fieldSizing: 'content'` als Inline-Style
- **`RATING_OPTIONS`-Array** nicht neu definieren — immer aus dem bestehenden Array in der jeweiligen Datei verwenden
- **Kein `useEffect` für DB-Calls** außer bei initialem Load — lieber direkte `async`-Funktionen
---
## Offene Punkte (priorisiert)
| Prio | Aufgabe | Datei |
|---|---|---|
| M | `Evaluation.tsx` auf neue Datenstruktur migrieren | `src/pages/Evaluation.tsx` |
| M | AI-Prompt auf `feedbackCriterionItems` umstellen | `src/pages/AssignmentFeedbackPage.tsx` |
| M | `meetingExport.ts` — Kriterien-Auflösung anpassen | `src/utils/meetingExport.ts` |
| N | Dashboard: Assignment-Nummer anzeigen | `src/pages/Dashboard.tsx` |
| N | AssignmentCreate: Feld für Assignment-Nummer | `src/pages/AssignmentCreate.tsx` |
| N | Toter Code in FeedbackStructureConfig entfernen | `src/pages/FeedbackStructureConfig.tsx` |
| L | SQLite WASM + OPFS Migration (kein festes Datum) | `src/db/index.ts` |
---
## Datenschutz
- Alle Daten lokal (IndexedDB) — **kein Public Cloud Upload**
- GDPR-relevant: Institutee-Namen, Kundennamen, Gesprächsnotizen
- KI-API-Key in `localStorage` — nie loggen oder ausgeben
- Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln

4
build.bat Normal file
View File

@ -0,0 +1,4 @@
@echo off
cd /d C:\dev\AssigmentMonitor
npm run build > build_log.txt 2>&1
echo Exit code: %ERRORLEVEL% >> build_log.txt

View File

@ -0,0 +1,160 @@
# Fachliche Architektur — Assignment Monitor
## Zweck und Kontext
Assignment Monitor ist eine Progressive Web App (PWA) zur **Live-Begleitung und Bewertung von Capgemini-Trainees** (Institutees) während ihrer Kunden-Assignments. Die App wird vom jeweiligen Lead (Führungskraft / Mentor) auf dem Smartphone genutzt, um:
- Kundentermine strukturiert zu protokollieren
- Beobachtungen und Bewertungen in Echtzeit zu erfassen
- Am Ende eines Assignments ein qualifiziertes Feedback vorzubereiten
**Nutzer:** Lars Stommer (Capgemini Management Consultant Trainee Program Lead)
**Sprache der App:** Deutsch (Bedienoberfläche), Englisch (Rating-Labels)
**Datenschutz:** Alle Daten bleiben lokal im Browser (IndexedDB). Kein Public Cloud Upload.
---
## Fachliche Entitätenhierarchie
```
AssignmentType ← Konfiguration (z.B. "Case Interview")
└── PhaseTemplate[] ← Definierter Meeting-Flow des Typs
└── criteriaIds: FeedbackCriterionItem[] ← Welche Kriterien gelten für diesen Typ
Assignment ← Ein konkretes Kundenprojekt
└── AssignmentType ← Zugeordneter Typ
└── Institutees: Consultant[] ← Begleitete Berater (1n)
└── MeetingInstance[] ← Durchgeführte Meetings je Phase
MeetingInstance ← Ein konkreter Termin (z.B. "Briefing Call #1")
└── PhaseTemplate ← Welche Phase des Assignments
└── ConversationEntry[] ← Zeitleiste: wer spricht wann
└── Assessment[] ← Gesamtbewertung pro Institutee am Ende
ConversationEntry ← Ein Gesprächsbeitrag eines Institutees
└── ConversationSkillScore[] ← Kategorie-Schnellbewertung (während des Beitrags)
FeedbackDimension ← Übergeordnete Dimension für das Assignment-Feedback
└── FeedbackCategory[] ← Kategorie (z.B. "Kommunikationsfähigkeit")
└── FeedbackCriterionItem[] ← Einzelnes Kriterium (z.B. "To the point")
AssignmentFeedback ← Strukturiertes Abschluss-Feedback pro Institutee
└── FeedbackCategoryRating[] ← Bewertung je Kategorie
└── FeedbackDimensionText[] ← Freitext Achievements + Development Needs je Dimension
```
---
## Meeting-Phasen
Jeder AssignmentTyp definiert einen geordneten Flow von PhaseTemplates. Die drei vordefinierten Typen haben folgende Phasen:
| Phase | Mit wem | Wdh. | Protokoll | Bewertung | Generisch | Case | Pitch |
|---|---|:-:|:-:|:-:|:-:|:-:|:-:|
| Pre-Briefing (Lead) | Lead | — | — | — | ✓ | ✓ | ✓ |
| Briefing (Kunde) | Kunde | — | ✓ | ✓ | ✓ | ✓ | ✓ |
| Alignment Call | Kunde | **✓** | ✓ | ✓ | ✓ | — | ✓ |
| Pre-Delivery (Lead) | Lead | — | — | — | ✓ | — | ✓ |
| Delivery / Pitch | Kunde | — | ✓ | ✓ | ✓ | ✓ | ✓ |
| Feedback Call (Kunde) | Kunde | — | ✓ | ✓ | ✓ | ✓ | ✓ |
| Feedback Call (Lead) | Lead | — | — | — | ✓ | ✓ | ✓ |
Wiederholbare Phasen (z.B. Alignment Calls) werden mit fortlaufendem `phaseIndex` und Dateinamen-Suffix `-#2`, `-#3` etc. gekennzeichnet.
---
## Bewertungssystem
### Skala
| Label | Kürzel | Farbe | Numerischer Wert |
|---|---|---|:-:|
| N/A | N/A | grau | 0 |
| Not Client Ready | Not | rot | 1 |
| Partially Client Ready | Partially | orange | 2 |
| Nearly Client Ready | Nearly | hellgrün | 3 |
| Client Ready | Fully | dunkelgrün | 4 |
`N/A` gilt als „nicht gesetzt" und wird aus allen Berechnungen ausgeschlossen.
### Aggregationslogik
**Gewichteter Durchschnitt** über alle Kriterien mit `weight`-Faktor (×1×5):
```
avg = Σ(score_i × weight_i) / Σ(weight_i)
```
Mapping auf Rating:
- `avg < 1.5` → Not
- `avg < 2.5` → Partially
- `avg < 3.5` → Nearly
- `avg ≥ 3.5` → Fully
### Zwei Assessment-Ebenen
1. **Kategorie-Level (live, während Gesprächsbeitrag):** `ConversationSkillScore.criteriaId``FeedbackCategory.id`
Schnelle Bewertung während ein Institutee spricht. Optional, da im Hektik oft nicht machbar.
2. **Kriterium-Level (Gesamtbewertung am Ende des Meetings):** `Assessment.criteriaId``FeedbackCriterionItem.id`
Detaillierte Bewertung je Kriterium nach dem Meeting. Grundlage für die Kategorie-Farbkodierung im Gesamtbewertungs-Tab.
---
## Feedback-Struktur (Abschluss-Feedback)
Die Feedback-Struktur ist vollständig konfigurierbar und von der Meeting-Bewertungsstruktur **getrennt**. Sie spiegelt das Capgemini-Kompetenzmodell wider:
| Dimension | Kategorien (Beispiele) |
|---|---|
| Mindset, Commitment, Collaboration | Persönliche Einstellung, Professionalität, Commitment, Kommunikationsfähigkeit, Zusammenarbeit |
| Running Our Projects | Ergebnisverantwortung, Arbeitsstil, Kundenmanagement, Lieferzeiten |
| Growing our People | Networking, Learning und Development |
| Functional & Technical Skills | Qualität des Inhalts, Inhaltliche Vollständigkeit, Qualität der Unterlage, Prozesstreue |
| Innovation und Asset Development | Innovation |
Das Abschluss-Feedback (`AssignmentFeedbackPage`) erlaubt:
- Manuelle Bewertung je Kategorie (N/A bis Fully)
- KI-gestützten Textentwurf via OpenRouter API (GPT-4o, Claude, Gemini)
- Manuell editierbare Textfelder „Achievements" und „Development Needs" je Dimension
- Status-Toggle: Entwurf → Final
---
## Protokoll-Notation
Im Gesprächsprotokoll unterstützt die App eine Schnellnotations-Syntax, die farbig gerendert wird:
| Symbol | Bedeutung | Darstellung |
|---|---|---|
| `+` | Positiv | grün |
| `(+)` | Positiv mit Abstrichen | grün (gedimmt) |
| `!` | Negativ | rot |
| `(!)` | Negativ mit Abstrichen | rot (gedimmt) |
| `>` | Einwand / Kundeninput | blau |
| `[…]` | Slide-/Dokument-Referenz | grau |
---
## Export-Funktionen
### Meeting-Export
**Format:** `{AssignmentNummer}_{Datum}_{PhasenLabel}{-#Nr}.{md|json}`
**Beispiel:** `CGI-2024-001_02-07-2026_Alignment Call-2.md`
- **Markdown:** Lesbare Dokumentation mit Protokoll, Bewertungen und Notizen
- **JSON:** Vollständige Datensicherung eines einzelnen Meetings
### Datenbank-Backup
Vollständiger Export aller Tabellen als JSON-Datei. Import löscht alle Daten und stellt den gesicherten Stand wieder her. Backup sollte nach jedem Meeting erstellt werden.
---
## Datenschutz und Datenhaltung
- Alle Daten bleiben im **lokalen Browser-IndexedDB** (Dexie.js)
- Kein Backend, kein Cloud-Sync
- Personenbezogene Daten: Namen von Institutees, Kundennamen, Gesprächsnotizen
- GDPR-relevant: Backup-Dateien müssen sicher (OneDrive, lokaler Ordner) aufbewahrt werden
- **Kein Public Cloud Storage** erlaubt (explizite Anforderung)
- Zukünftig geplant: SQLite WASM + OPFS für robustere lokale Speicherung ohne Installation

169
docs/HANDOVER.md Normal file
View File

@ -0,0 +1,169 @@
# Handover-Dokument — Assignment Monitor
Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt nahtlos fortzuführen.
---
## Worum geht es?
**Assignment Monitor** ist eine PWA für Lars Stommer (Capgemini Management Consultant Trainee Program Lead). Die App wird auf dem Smartphone genutzt, um Trainees (intern: „Institutees") während ihrer Kunden-Assignments live zu begleiten und zu bewerten. Lars führt die Meetings, beobachtet die Trainees und möchte sofort Notizen und Bewertungen erfassen, ohne Stift/Papier.
## Tech-Stack
- **React 18 + TypeScript + Vite** (PWA)
- **Tailwind CSS v4** (`@import "tailwindcss"` — kein `tailwind.config.js`!)
- **Dexie.js v5** als IndexedDB-Wrapper (lokale DB im Browser, kein Backend)
- **React Router v6**
- **Capgemini-Brandfarbe:** `#0070AD`
- Arbeitsverzeichnis: `C:\dev\AssigmentMonitor`
## Bewertungsskala (kritisch für alle Berechnungen)
```typescript
type FeedbackRating = 'na' | 'not_client_ready' | 'partially_client_ready' | 'nearly_client_ready' | 'client_ready'
// Numerisch: na=0, not=1, partially=2, nearly=3, fully=4
// Labels: N/A, Not, Partially, Nearly, Fully
// Farben: grau, rot, orange, hellgrün, dunkelgrün
// N/A gilt als "nicht gesetzt" — aus allen Berechnungen ausgeschlossen!
```
Aggregation: **gewichteter Durchschnitt** (avg < 1.5 Not, < 2.5 Partially, < 3.5 Nearly, 3.5 Fully)
## Datenmodell-Kern (was zu wissen ist)
### Schlüssel-Unterschied: zwei `criteriaId`-Semantiken
```
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting)
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung während Gespräch)
```
Das klingt inkonsistent — ist aber so gewollt und funktioniert so.
### AssignmentType.criteriaIds
```typescript
// Leer = alle Kriterien anzeigen
// Befüllt = nur diese FeedbackCriterionItem.id anzeigen
criteriaIds?: number[] // → FeedbackCriterionItem.id
```
Filterlogik (in MeetingView + AssignmentDetail):
```typescript
const itemFilter = aType?.criteriaIds?.length ? new Set(aType.criteriaIds) : null
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId))
const filteredCats = itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats
```
### Dateiname für Meeting-Exporte
```typescript
`${assignment.assignmentNumber ?? assignment.title}_${date}_${template.label}${phaseIndex > 1 ? `-${phaseIndex}` : ''}.${ext}`
// Beispiel: CGI-2024-001_02-07-2026_Alignment Call-2.md
```
## Routing-Übersicht
```
/ Dashboard
/assignment/new Neues Assignment
/assignment/:id AssignmentDetail
/assignment/:id/edit AssignmentEdit
/assignment/:id/meeting/:meetingId MeetingView ← WICHTIG: meetingId != assignmentId
/assignment/:id/feedback/:instituteeId AssignmentFeedbackPage
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
/evaluation Auswertung (veraltet!)
/consultants Berater
/config Konfiguration (3 Tabs)
```
MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useParams()`
## Konfigurationsbereich
**Tab "Feedback"** (`FeedbackStructureConfig.tsx`):
- Dimensionen → Kategorien → Kriterium-Items (mit Gewichtung ×1×5)
- Nur Feedback-Struktur, kein AssignmentType-CRUD mehr!
**Tab "Ass.-Typen"** (`AssignmentTypeConfig.tsx`):
- AssignmentTypen CRUD
- Pro Typ: Sub-Tabs "Phasen" und "Kriterien"
- Kriterien-Tab: Baumstruktur Dimension → Kategorie → Items (Checkbox, Indeterminate)
**Tab "Backup"** (`Configuration.tsx`):
- JSON-Export aller Tabellen via `exportFullBackup()`
- JSON-Import via `importFullBackup(file)` (löscht alles, dann bulkAdd)
## Bekannte offene Punkte (Priorität nach Dringlichkeit)
### Sofort-Fixes (einfach)
1. **Dashboard zeigt keine Assignment-Nummer**`Dashboard.tsx` Zeile ~57: `{a.assignmentNumber && <span>...{a.assignmentNumber}</span>}` hinzufügen
2. **AssignmentCreate hat kein Nummer-Feld** — Inputfeld für `assignmentNumber` hinzufügen
3. **Toter Code in FeedbackStructureConfig** — State-Variablen `assignmentTypes`, `phaseTemplates`, `showCatalogs`, `showTypeConfig`, `expandedType`, `newTypeName`, `newPhase`, `typeError` + alle Handler `toggleCatalogCategory`, `addAssignmentType`, `updateAssignmentTypeName`, `deleteAssignmentType`, `addPhaseTemplate`, `updatePhaseTemplate`, `deletePhaseTemplate` entfernen
### Mittlere Aufgaben
4. **Evaluation.tsx neu schreiben** — nutzt `db.categories` + `db.criteria` (Legacy). Soll `feedbackCriterionItems` + `assessments` nutzen und Rating-Labels zeigen
5. **meetingExport.ts anpassen** — Score-Aufschlüsselung nutzt `db.criteria`, sollte `feedbackCriterionItems` nutzen; Kategorie-Namen im Markdown fehlen
6. **AI-Prompt in AssignmentFeedbackPage** — nutzt `db.criteria` (alt), sollte `feedbackCriterionItems` + neue Assessment-Scores nutzen
### Große Aufgaben
7. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus.
## Stil-Regeln (bitte einhalten)
- **Tailwind v4**: kein `bg-[#0070AD]` via config, direkt als CSS-Variable nicht nötig — `bg-[#0070AD]` im Class-String funktioniert direkt
- **Keine Kommentare** außer für nicht-offensichtliche Invarianten
- **Kein `rows={1}` + JS-Autoresize** — besser View/Edit-Toggle oder `fieldSizing: 'content'` via Inline-Style
- **Rating-Buttons** immer mit `RATING_OPTIONS`-Array (nicht neu definieren):
```typescript
{ value, short, color (inaktiv), active (aktiv) }
```
- **Weighted-Average** immer: N/A excludieren, dann `Σ(score×weight)/Σ(weight)`
## Notations-Parser
`src/utils/notationParser.tsx` exportiert `<NotationText text={string} />` — rendert `+`, `(+)`, `!`, `(!)`, `>`, `[…]` farbig. Überall verwenden wo Protokoll-Text angezeigt wird.
## DB-Backup
`src/utils/dbBackup.ts` exportiert:
```typescript
exportFullBackup(): Promise<void> // Download als JSON
importFullBackup(file: File): Promise<{ ok: boolean; error?: string }> // Clears + bulkAdd
```
Alle 20 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen dort ergänzen.
## Seed-Daten
`src/main.tsx` ruft beim Start auf:
1. `seedIfEmpty()` — 3 AssignmentTypen + Legacy-Kriterien
2. `seedFeedbackStructureIfEmpty()` — 5 Dimensionen, 14 Kategorien, ~70 Items
3. `seedCriterionMappingsIfEmpty()` — Legacy-Mappings
4. `migrateToUnifiedCriteriaIfNeeded()` — Migration auf FeedbackCategory-IDs in PhaseTemplates
## Datenschutz-Hinweise
- Alle Daten lokal, kein Cloud-Backend
- GDPR-relevant: Institutee-Namen + Kundennamen in der DB
- KI-API: OpenRouter, API-Key in localStorage (nicht im Klartext loggen!)
- Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln
## TypeScript-Build
```bash
npx tsc --noEmit # Muss ohne Fehler durchlaufen
npm run dev # Dev-Server starten
```
Vor jeder Abgabe: `npx tsc --noEmit` ausführen und alle Fehler beheben.
## Wichtige Eigenheiten / Fallstricke
1. **`MeetingInstance.generalNotes`**: Kann in alten Datensätzen `undefined` sein → immer `?? ''` fallback
2. **`Assessment.criteriaId`**: Zeigt auf `FeedbackCriterionItem.id`, NICHT auf `FeedbackCategory.id` — das verwirrt, weil der Feldname `criteriaId` ist
3. **`PhaseKey`**: Ist `type PhaseKey = string` (kein Union-Typ mehr)
4. **`aType2`** in `AssignmentDetail.tsx`: Doppelte Variable aus Refactoring — `aType` wird zweimal deklariert, zweite heißt `aType2`. Technische Schuld.
5. **`expandedCat`** in `MeetingView`: `Record<instituteeId, categoryId | null>` — öffnet pro Institutee genau eine Kategorie gleichzeitig
6. **Dexie v5**: `bulkAdd` schlägt fehl wenn IDs schon vorhanden → Backup-Import leert erst alle Tabellen

View File

@ -0,0 +1,98 @@
# Projektstatus und offene Punkte — Assignment Monitor
Stand: 2026-07-02
---
## Implementierungsstatus
### ✅ Vollständig implementiert
| Feature | Seite / Datei |
|---|---|
| Dashboard: Assignment-Liste | `Dashboard.tsx` |
| Assignment anlegen (Typ, Institutees, Phasen-Preview) | `AssignmentCreate.tsx` |
| Assignment nachbearbeiten (Nummer, Typ-Wechsel-Warnung, Institutee-Laufzeiten) | `AssignmentEdit.tsx` |
| Phasen-Flow-Ansicht pro Assignment | `AssignmentDetail.tsx` |
| Soft-Delete + Papierkorb für Meetings | `AssignmentDetail.tsx` |
| Bewertungs-Chips je Institutee + Kategorie in der Übersicht | `AssignmentDetail.tsx` |
| Live-Gesprächsprotokoll mit Sprecher-Zeitlinie | `MeetingView.tsx` |
| Schnellnotation (+, !, >, […]) mit farbigem Rendering | `MeetingView.tsx`, `notationParser.tsx` |
| Äh-Counter pro Institutee (live + nachträgliche Anpassung) | `MeetingView.tsx` |
| Gesamtbewertung auf Kriterium-Ebene (Accordion, farbige Kategorie-Header) | `MeetingView.tsx` |
| Auto-Calc Bewertung aus Protokoll-Kategorie-Scores | `MeetingView.tsx` |
| Allgemeine Notizen mit Auto-Resize + Notation-Rendering | `MeetingView.tsx` |
| Meeting-Export: Markdown + JSON, strukturierter Dateiname | `meetingExport.ts` |
| Assignment-Nummer im Dateinamen | `MeetingView.tsx`, `AssignmentDetail.tsx` |
| Strukturiertes Abschluss-Feedback pro Institutee | `AssignmentFeedbackPage.tsx` |
| KI-Feedback-Generierung via OpenRouter | `AssignmentFeedbackPage.tsx` |
| Feedback-Status: Entwurf → Final | `AssignmentFeedbackPage.tsx` |
| Feedback-Dimensionsstruktur CRUD mit Gewichtung ×1×5 | `FeedbackStructureConfig.tsx` |
| AssignmentTyp CRUD mit Phasen-Templates | `AssignmentTypeConfig.tsx` |
| Kriterien-Auswahl je Typ (Baumstruktur, Indeterminate) | `AssignmentTypeConfig.tsx` |
| Kriterien-Filterung je Typ in Meetings + Übersicht | `MeetingView.tsx`, `AssignmentDetail.tsx` |
| Vollständiges DB-Backup (JSON Export/Import) | `dbBackup.ts`, `Configuration.tsx` |
| Berater/Gruppen-Verwaltung | `Consultants.tsx` |
| PWA (installierbar, Offline-fähig) | Vite-PWA-Plugin |
---
## ⚠️ Offene Punkte / Bugs
### Priorität Hoch
| # | Beschreibung | Datei | Aufwand |
|---|---|---|---|
| H1 | **IndexedDB-Robustheit**: Browser kann IndexedDB unter Speicherdruck löschen. Migration zu SQLite WASM + OPFS geplant. Bis dahin: regelmäßige Backups als einzige Sicherheit. | `db/index.ts` | L (Wochen) |
| H2 | **Backup-Reminder fehlt**: Es gibt keine automatische Erinnerung / keinen Hinweis nach abgeschlossenem Meeting, dass ein Backup erstellt werden sollte. | `MeetingView.tsx` | S |
### Priorität Mittel
| # | Beschreibung | Datei | Aufwand |
|---|---|---|---|
| M1 | **Evaluation.tsx veraltet**: Nutzt die Legacy-`Category`/`Criterion`-Tabellen, nicht die neue `feedbackCriterionItems`-Struktur. Zeigt falsche / keine Daten aus neuen Meetings. | `Evaluation.tsx` | M |
| M2 | **AI-Prompt veraltet**: `AssignmentFeedbackPage` liest `db.criteria` (alt) für Score-Zeilen. Sollte auf `feedbackCriterionItems` + `assessments` umgestellt werden. | `AssignmentFeedbackPage.tsx` | M |
| M3 | **meetingExport.md veraltet**: Kriterien-Scores werden über `db.criteria` aufgelöst, nicht `feedbackCriterionItems`. Kategorie-Namen fehlen im Export. | `meetingExport.ts` | S |
### Priorität Niedrig
| # | Beschreibung | Datei | Aufwand |
|---|---|---|---|
| N1 | **Dashboard**: Zeigt keine Assignment-Nummer. Nur Titel + Kunde + Typ. | `Dashboard.tsx` | XS |
| N2 | **AssignmentCreate**: Kein Feld für Assignment-Nummer. Muss nachträglich über Edit eingegeben werden. | `AssignmentCreate.tsx` | XS |
| N3 | **FeedbackStructureConfig**: Toter Code — Funktionen für AssignmentType-CRUD wurden verschoben, Deklarationen blieben (`assignmentTypes`, `phaseTemplates` State + alle zugehörigen Handler). | `FeedbackStructureConfig.tsx` | XS |
| N4 | **Evaluation-Route**: Zeigt Scores als Dezimalzahlen `/5` mit alter Skala statt neuer Rating-Labels. | `Evaluation.tsx` | S |
| N5 | **AssignmentCreate**: Erstellt keine `instituteeEnrollments`. Von/Bis-Daten können nur über Edit gepflegt werden. | `AssignmentCreate.tsx` | S |
| N6 | **Meeting-Route**: URL ist `/assignment/:id/meeting/:meetingId``:id` ist die Assignment-ID, aber die Assignment-Nummer (z.B. CGI-2024-001) ist nicht in der URL. Akzeptiert als nice-to-have. | `App.tsx` | M |
---
## Geplante Erweiterungen (Roadmap)
### SQLite WASM + OPFS (Datenbankevolution)
**Ziel:** Robuste lokale Datenhaltung ohne Installation, ohne Risiko durch Browser-Speicherdruck.
**Technologie:** SQLite compiled to WebAssembly, persistiert via Origin Private File System (OPFS).
**Vorteil:** Echte Datei auf Gerät, nicht löschbar wie IndexedDB. Kein Server nötig.
**Status:** Konzept beschlossen. Implementierung steht aus. Paralleles Backup-System ist die aktuelle Notlösung.
### Evaluation neu (M1 oben)
Auswertungsseite auf neue Datenstruktur migrieren. Soll zeigen:
- Pro Institutee: aggregierte Kriterien-Scores über alle Meetings
- Trend über mehrere Assignments
- Vergleich zwischen Institutees
### Dashboard-Verbesserungen (N1 oben)
- Assignment-Nummer anzeigen
- Filtern nach Status (aktiv/abgeschlossen)
- Sortierung nach Datum / Assignment-Nummer
---
## Bekannte Datenkonsistenz-Risiken
| Risiko | Beschreibung | Mitigation |
|---|---|---|
| `criteriaIds` auf `AssignmentType` | Referenziert `FeedbackCriterionItem.id`. Wenn Kriterien gelöscht werden, bleiben verwaiste IDs in `criteriaIds`. Filter ignoriert fehlende IDs stillschweigend. | Beim Löschen von Items: `criteriaIds` in allen AssignmentTypes bereinigen |
| `Assessment.criteriaId` | Referenziert `FeedbackCriterionItem.id`. Kein FK-Constraint in IndexedDB. | Beim Löschen von Items: zugehörige Assessments löschen |
| `ConversationSkillScore.criteriaId` | Referenziert `FeedbackCategory.id`. Gleiche Gefahr. | Beim Löschen von Kategorien: ConversationSkillScores bereinigen |
| Typ-Wechsel bei Assignment | Zeigt Warnung, löscht aber keine alten Meeting-Bewertungen. Bewertungen mit IDs aus dem alten Typ-Kriteriensatz bleiben erhalten, werden aber möglicherweise nicht mehr angezeigt. | Dokumentiertes Verhalten, absichtlich so gestaltet |

View File

@ -0,0 +1,256 @@
# Technische Architektur — Assignment Monitor
## Stack
| Schicht | Technologie | Version | Zweck |
|---|---|---|---|
| UI-Framework | React | 18 | Component-basiertes UI |
| Sprache | TypeScript | 5 | Typsicherheit |
| Build | Vite | 6 | Dev-Server, HMR, PWA-Plugin |
| CSS | Tailwind CSS | v4 | Utility-First, `@import "tailwindcss"` |
| Datenbank | Dexie.js | 5 | IndexedDB-Wrapper |
| Routing | React Router | v6 | Client-Side-Routing (SPA) |
| PWA | vite-plugin-pwa | — | Offline-Fähigkeit, installierbar |
| KI | OpenRouter API | — | Feedback-Generierung (extern, opt-in) |
**Capgemini Brand Color:** `#0070AD`
---
## Projektstruktur
```
src/
db/
index.ts ← Dexie DB-Klasse, alle Interfaces, Seed-Funktionen, Migrations
pages/
Dashboard.tsx ← Assignment-Liste (Home)
AssignmentCreate.tsx ← Neues Assignment anlegen
AssignmentDetail.tsx ← Phasen-Flow, Meeting-Übersicht, Bewertungs-Chips
AssignmentEdit.tsx ← Assignment nachträglich bearbeiten
MeetingView.tsx ← Live-Meeting: Protokoll + Gesamtbewertung
AssignmentFeedbackPage.tsx ← Strukturiertes Abschluss-Feedback + KI-Generierung
FeedbackDraftPage.tsx ← Einfacher Feedback-Entwurf (Legacy)
Evaluation.tsx ← Auswertung (teilweise veraltet, nutzt alte Criterion-Tabelle)
Configuration.tsx ← Tab-Container für alle Konfigurationsseiten
FeedbackStructureConfig.tsx ← Dimensionen/Kategorien/Kriterien CRUD + Gewichtung
AssignmentTypeConfig.tsx ← AssignmentTypen + Phasen + Kriterien-Auswahl (Baum)
Consultants.tsx ← Berater/Gruppen-Verwaltung
utils/
meetingExport.ts ← Markdown + JSON Export-Logik
dbBackup.ts ← Vollständiges DB-Backup (JSON Export/Import)
notationParser.tsx ← +/!/>/[…] Notation → farbiges JSX
App.tsx ← Route-Definitionen, Navigation
main.tsx ← React-Einstiegspunkt, DB-Seed-Aufruf
```
---
## Datenmodell (IndexedDB via Dexie v5)
### DB-Version: 5
```typescript
// Konfiguration (Legacy, nur noch für Evaluation.tsx)
categories: '++id, order'
criteria: '++id, categoryId, order'
// Assignment-Typen
assignmentTypes: '++id'
phaseTemplates: '++id, assignmentTypeId, order'
// Stammdaten
groups: '++id'
consultants: '++id, groupId'
// Assignments
assignments: '++id, status, createdAt'
meetingInstances: '++id, assignmentId, phaseTemplateId'
// Gesprächsprotokoll
conversationEntries: '++id, meetingInstanceId, instituteeId, sequenceIndex'
conversationSkillScores: '++id, conversationEntryId, criteriaId'
// Meeting-Assessment
assessments: '++id, meetingInstanceId, instituteeId, criteriaId'
// Feedback-Entwürfe (Legacy)
feedbackDrafts: '++id, assignmentId, instituteeId'
// Strukturiertes Feedback
feedbackDimensions: '++id, order'
feedbackCategories: '++id, dimensionId, order'
feedbackCriterionItems: '++id, categoryId, order'
assignmentFeedbacks: '++id, assignmentId, instituteeId'
feedbackCategoryRatings: '++id, assignmentFeedbackId, feedbackCategoryId'
feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId'
// Mappings (Legacy, kaum genutzt)
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId'
criterionLevelDescriptions: '++id, criterionItemId, rating'
```
### Wichtige Interface-Details
```typescript
type FeedbackRating = 'na' | 'not_client_ready' | 'partially_client_ready' | 'nearly_client_ready' | 'client_ready'
interface AssignmentType {
id?: number
name: string
defaultPhaseKeys: string[]
criteriaIds?: number[] // FeedbackCriterionItem IDs; leer = alle anzeigen
}
interface Assignment {
id?: number
title: string
client: string
assignmentTypeId: number
instituteeIds: number[]
leadName: string
status: 'active' | 'done'
createdAt: string
assignmentNumber?: string // z.B. "CGI-2024-001"
description?: string
instituteeEnrollments?: Array<{
instituteeId: number
from?: string // ISO date
to?: string // ISO date
}>
}
interface MeetingInstance {
id?: number
assignmentId: number
phaseTemplateId: number
phaseIndex: number // 1 = erste Instanz, 2+ = Wiederholungen
date: string
status: 'planned' | 'active' | 'done'
generalNotes: string
deletedAt?: string // Soft-Delete
}
interface FeedbackCriterionItem {
id?: number
categoryId: number
name: string
weight?: number // Gewichtung ×1×5, default 1
order: number
}
// Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
// ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
```
---
## Routing
```
/ Dashboard (Assignment-Liste)
/assignment/new Neues Assignment anlegen
/assignment/:id AssignmentDetail (Phasen-Flow)
/assignment/:id/edit Assignment bearbeiten
/assignment/:id/meeting/:meetingId MeetingView (Protokoll + Bewertung)
/assignment/:id/feedback/:instituteeId AssignmentFeedbackPage (strukturiertes Feedback)
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
/evaluation Auswertungsübersicht
/consultants Berater-Verwaltung
/config Konfiguration (3 Tabs: Feedback | Ass.-Typen | Backup)
```
---
## Konfigurationsbereich
### Tab "Feedback" → `FeedbackStructureConfig`
CRUD für:
- Feedback-Dimensionen (mit Reihenfolge ↑/↓)
- Feedback-Kategorien je Dimension
- Feedback-Kriterium-Items je Kategorie (mit Gewichtung ×1×5)
### Tab "Ass.-Typen" → `AssignmentTypeConfig`
CRUD für:
- Assignment-Typen (Name, Löschschutz wenn Assignments vorhanden)
- Phasen-Templates je Typ (Label, Lead/Kunde, Wiederholbar, Protokoll, Bewertung)
- Kriterien-Auswahl je Typ: Baumstruktur Dimension → Kategorie → Item (mit Checkbox, Indeterminate-State)
- `criteriaIds = []` → alle Kriterien werden angezeigt
### Tab "Backup" → in `Configuration.tsx`
- JSON-Export aller Tabellen
- Zweistufiger JSON-Import mit Überschreib-Warnung
---
## Bewertungs-Aggregation (Implementierungsdetail)
```typescript
const NUM_MAP: Record<FeedbackRating, number> = {
na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4
}
function catMode(cat: FeedbackCategory, fbItems: FeedbackCriterionItem[], getScore: (itemId: number) => FeedbackRating | null): FeedbackRating | null {
const scored = fbItems
.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)
const avg = scored.reduce((s, x) => s + NUM_MAP[x.score] * x.w, 0) / wSum
if (avg < 1.5) return 'not_client_ready'
if (avg < 2.5) return 'partially_client_ready'
if (avg < 3.5) return 'nearly_client_ready'
return 'client_ready'
}
```
---
## Export-Dateiname-Schema
```typescript
const buildFilename = (ext: string) => {
const date = new Date(meeting.date).toLocaleDateString('de-DE').replace(/\./g, '-')
const label = template.label // z.B. "Alignment Call"
const idx = phaseIndex > 1 ? `-${phaseIndex}` : ''
const id = assignment.assignmentNumber ?? assignment.title
return `${id}_${date}_${label}${idx}.${ext}`
// Beispiel: CGI-2024-001_02-07-2026_Alignment Call-2.md
}
```
---
## KI-Integration (AssignmentFeedbackPage)
- **Provider:** OpenRouter (https://openrouter.ai) — ermöglicht Modellwechsel ohne eigenen API-Zugang
- **Modelle:** GPT-4o mini, GPT-4o, Claude Sonnet 4.5, Claude Haiku 4.5, Gemini Flash 1.5
- **API-Key:** Lokal in `localStorage` gespeichert (`ai_api_key`, `ai_model`)
- **Datenweitergabe:** Kriterien-Scores, Protokoll-Notizen, Kategorie-Bewertungen werden im Prompt strukturiert übermittelt
- **Hinweis:** Der AI-Prompt nutzt noch die alte `criteria`-Tabelle (Legacy) — Aktualisierung auf `feedbackCriterionItems` steht aus
---
## Seed-Daten (beim ersten Start)
Die App legt beim ersten Start automatisch an:
1. **3 Assignment-Typen** mit je vollständigem Phasen-Flow (Generisch, Case Interview, Stakeholder Meeting / Pitch)
2. **Legacy-Kriterien** in `categories` + `criteria` (für Evaluation.tsx)
3. **Feedback-Dimensionsstruktur** mit 5 Dimensionen, 14 Kategorien, ~70 Kriterium-Items (Capgemini-Kompetenzmodell)
4. **Criterion-Mappings** (Legacy, nicht mehr aktiv genutzt)
---
## Bekannte technische Schulden
| Bereich | Problem | Priorität |
|---|---|---|
| `Evaluation.tsx` | Nutzt alte `Category`/`Criterion`-Tabellen statt neue `feedbackCriterionItems` | Mittel |
| `meetingExport.ts` | Bewertungs-Labels im Markdown-Export zeigen `FeedbackRating`-Keys statt Labels | Niedrig |
| `FeedbackStructureConfig.tsx` | Enthält toten Code (Funktionen für gelöschte AssignmentType-CRUD) | Niedrig |
| AI-Prompt in `AssignmentFeedbackPage` | Nutzt `db.criteria` (alt), sollte `feedbackCriterionItems` nutzen | Mittel |
| Dashboard | Zeigt keine Assignment-Nummer | Niedrig |
| `AssignmentCreate` | Kein Feld für Assignment-Nummer | Niedrig |
| IndexedDB | Keine robuste Persistenz-Garantie (Browser kann löschen) | Hoch → SQLite WASM geplant |

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Assignment Monitor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8229
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "assignment-monitor",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.6.2",
"dexie": "^4.0.11",
"zustand": "^5.0.5",
"recharts": "^2.15.3"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/vite": "^4.1.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"tailwindcss": "^4.1.10",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0"
}
}

54
src/App.tsx Normal file
View File

@ -0,0 +1,54 @@
import { Routes, Route, NavLink } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import Configuration from './pages/Configuration'
import Consultants from './pages/Consultants'
import AssignmentCreate from './pages/AssignmentCreate'
import AssignmentDetail from './pages/AssignmentDetail'
import AssignmentEdit from './pages/AssignmentEdit'
import MeetingView from './pages/MeetingView'
import FeedbackDraftPage from './pages/FeedbackDraftPage'
import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage'
import Evaluation from './pages/Evaluation'
export default function App() {
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<header className="bg-[#0070AD] text-white px-4 py-3 flex items-center gap-3 shadow">
<span className="font-bold text-lg tracking-tight">Assignment Monitor</span>
</header>
<main className="flex-1 p-4 max-w-2xl mx-auto w-full pb-20">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/assignment/new" element={<AssignmentCreate />} />
<Route path="/assignment/:id" element={<AssignmentDetail />} />
<Route path="/assignment/:id/edit" element={<AssignmentEdit />} />
<Route path="/assignment/:id/meeting/:meetingId" element={<MeetingView />} />
<Route path="/assignment/:id/feedback/:instituteeId" element={<AssignmentFeedbackPage />} />
<Route path="/assignment/:id/feedback-draft/:instituteeId" element={<FeedbackDraftPage />} />
<Route path="/evaluation" element={<Evaluation />} />
<Route path="/consultants" element={<Consultants />} />
<Route path="/config" element={<Configuration />} />
</Routes>
</main>
<nav className="bg-white border-t border-gray-200 flex justify-around py-2 sticky bottom-0">
{[
{ to: '/', label: 'Home' },
{ to: '/consultants', label: 'Berater' },
{ to: '/evaluation', label: 'Auswertung' },
{ to: '/config', label: 'Config' },
].map(({ to, label }) => (
<NavLink
key={to}
to={to}
end
className={({ isActive }) =>
`text-xs px-3 py-1 rounded ${isActive ? 'text-[#0070AD] font-semibold' : 'text-gray-500'}`
}
>
{label}
</NavLink>
))}
</nav>
</div>
)
}

602
src/db/index.ts Normal file
View File

@ -0,0 +1,602 @@
import Dexie, { type Table } from 'dexie'
// ─── Bewertungs-Skala (einheitlich für Meetings und Feedback) ─────────────────
export type FeedbackRating =
| 'na'
| 'not_client_ready'
| 'partially_client_ready'
| 'nearly_client_ready'
| 'client_ready'
// ─── Konfiguration ───────────────────────────────────────────────────────────
export interface Category {
id?: number
name: string
order: number
}
export interface Criterion {
id?: number
categoryId: number
name: string
description: string
weight: number
order: number
}
// ─── Assignment-Typen & Phase-Templates ──────────────────────────────────────
export type PhaseKey = string
export interface AssignmentType {
id?: number
name: string
defaultPhaseKeys: PhaseKey[]
criteriaIds?: number[] // FeedbackCategory IDs; leer = alle Kategorien anzeigen
}
export interface PhaseTemplate {
id?: number
assignmentTypeId: number
phaseKey: PhaseKey
label: string
withWhom: 'lead' | 'client'
isRepeatable: boolean
hasConversation: boolean
hasAssessment: boolean
criteriaIds: number[]
order: number
}
// ─── Stammdaten ───────────────────────────────────────────────────────────────
export interface Group {
id?: number
name: string
cohortYear: number
}
export interface Consultant {
id?: number
groupId: number
firstName: string
lastName: string
}
// ─── Assignments ─────────────────────────────────────────────────────────────
export interface Assignment {
id?: number
title: string
client: string
assignmentTypeId: number
instituteeIds: number[]
leadName: string
status: 'active' | 'done'
createdAt: string
assignmentNumber?: string // Offizielle Assignment-Nummer aus Systemsystemen
description?: string
instituteeEnrollments?: Array<{
instituteeId: number
from?: string // ISO date string
to?: string // ISO date string
}>
}
// ─── Meeting-Instanzen ────────────────────────────────────────────────────────
export interface MeetingInstance {
id?: number
assignmentId: number
phaseTemplateId: number
phaseIndex: number
date: string
status: 'planned' | 'active' | 'done'
generalNotes: string
deletedAt?: string
}
// ─── Gesprächs-Zeitleiste ─────────────────────────────────────────────────────
export interface ConversationEntry {
id?: number
meetingInstanceId: number
instituteeId: number
sequenceIndex: number
note: string
fillerCount: number
updatedAt: string
}
// criteriaId here now holds a FeedbackCategory.id (unified criteria system)
export interface ConversationSkillScore {
id?: number
conversationEntryId: number
criteriaId: number // → FeedbackCategory.id
score: FeedbackRating | null
}
// ─── Gesamtbewertung ─────────────────────────────────────────────────────────
// criteriaId here now holds a FeedbackCategory.id (unified criteria system)
export interface Assessment {
id?: number
meetingInstanceId: number
instituteeId: number
criteriaId: number // → FeedbackCategory.id
score: FeedbackRating | null
note: string
updatedAt: string
}
// ─── Feedback-Entwurf (Legacy, bleibt erhalten) ──────────────────────────────
export interface FeedbackDraft {
id?: number
assignmentId: number
instituteeId: number
content: string
updatedAt: string
}
// ─── Strukturiertes Assignment-Feedback ───────────────────────────────────────
export interface FeedbackDimension {
id?: number
name: string
order: number
}
export interface FeedbackCategory {
id?: number
dimensionId: number
name: string
order: number
}
export interface FeedbackCriterionItem {
id?: number
categoryId: number
name: string
weight?: number // Gewichtung, default 1
order: number
}
export interface AssignmentFeedback {
id?: number
assignmentId: number
instituteeId: number
status: 'draft' | 'final'
aiModel: string
updatedAt: string
}
export interface FeedbackCategoryRating {
id?: number
assignmentFeedbackId: number
feedbackCategoryId: number
rating: FeedbackRating
}
export interface FeedbackDimensionText {
id?: number
assignmentFeedbackId: number
feedbackDimensionId: number
achievements: string
developmentNeeds: string
}
// Mapping: Assessment-Kriterium → Feedback-Kategorie (n:m, Legacy-Kompatibilität)
export interface CriterionCategoryMapping {
id?: number
criterionId: number
feedbackCategoryId: number
}
// Beschreibungstext pro FeedbackCriterionItem und Bewertungsstufe
export interface CriterionLevelDescription {
id?: number
criterionItemId: number // → FeedbackCriterionItem.id
rating: FeedbackRating
text: string
}
// ─── Dexie DB ────────────────────────────────────────────────────────────────
class AssignmentMonitorDB extends Dexie {
categories!: Table<Category>
criteria!: Table<Criterion>
assignmentTypes!: Table<AssignmentType>
phaseTemplates!: Table<PhaseTemplate>
groups!: Table<Group>
consultants!: Table<Consultant>
assignments!: Table<Assignment>
meetingInstances!: Table<MeetingInstance>
conversationEntries!: Table<ConversationEntry>
conversationSkillScores!: Table<ConversationSkillScore>
assessments!: Table<Assessment>
feedbackDrafts!: Table<FeedbackDraft>
feedbackDimensions!: Table<FeedbackDimension>
feedbackCategories!: Table<FeedbackCategory>
feedbackCriterionItems!: Table<FeedbackCriterionItem>
assignmentFeedbacks!: Table<AssignmentFeedback>
feedbackCategoryRatings!: Table<FeedbackCategoryRating>
feedbackDimensionTexts!: Table<FeedbackDimensionText>
criterionCategoryMappings!: Table<CriterionCategoryMapping>
criterionLevelDescriptions!: Table<CriterionLevelDescription>
constructor() {
super('AssignmentMonitorDB')
this.version(1).stores({
categories: '++id, order',
criteria: '++id, categoryId, order',
groups: '++id',
consultants: '++id, groupId',
meetings: '++id, date, status, groupId',
assessments: '++id, meetingId, consultantId, criteriaId',
consultantNotes: '++id, meetingId, consultantId',
})
this.version(2).stores({
categories: '++id, order',
criteria: '++id, categoryId, order',
assignmentTypes: '++id',
phaseTemplates: '++id, assignmentTypeId, order',
groups: '++id',
consultants: '++id, groupId',
assignments: '++id, status, createdAt',
meetingInstances: '++id, assignmentId, phaseTemplateId',
conversationEntries: '++id, meetingInstanceId, instituteeId, sequenceIndex',
conversationSkillScores: '++id, conversationEntryId, criteriaId',
assessments: '++id, meetingInstanceId, instituteeId, criteriaId',
feedbackDrafts: '++id, assignmentId, instituteeId',
// v1 tables no longer used but kept for safe migration
meetings: null,
consultantNotes: null,
})
this.version(3).stores({
categories: '++id, order',
criteria: '++id, categoryId, order',
assignmentTypes: '++id',
phaseTemplates: '++id, assignmentTypeId, order',
groups: '++id',
consultants: '++id, groupId',
assignments: '++id, status, createdAt',
meetingInstances: '++id, assignmentId, phaseTemplateId',
conversationEntries: '++id, meetingInstanceId, instituteeId, sequenceIndex',
conversationSkillScores: '++id, conversationEntryId, criteriaId',
assessments: '++id, meetingInstanceId, instituteeId, criteriaId',
feedbackDrafts: '++id, assignmentId, instituteeId',
feedbackDimensions: '++id, order',
feedbackCategories: '++id, dimensionId, order',
feedbackCriterionItems: '++id, categoryId, order',
assignmentFeedbacks: '++id, assignmentId, instituteeId',
feedbackCategoryRatings: '++id, assignmentFeedbackId, feedbackCategoryId',
feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId',
meetings: null,
consultantNotes: null,
})
this.version(4).stores({
categories: '++id, order',
criteria: '++id, categoryId, order',
assignmentTypes: '++id',
phaseTemplates: '++id, assignmentTypeId, order',
groups: '++id',
consultants: '++id, groupId',
assignments: '++id, status, createdAt',
meetingInstances: '++id, assignmentId, phaseTemplateId',
conversationEntries: '++id, meetingInstanceId, instituteeId, sequenceIndex',
conversationSkillScores: '++id, conversationEntryId, criteriaId',
assessments: '++id, meetingInstanceId, instituteeId, criteriaId',
feedbackDrafts: '++id, assignmentId, instituteeId',
feedbackDimensions: '++id, order',
feedbackCategories: '++id, dimensionId, order',
feedbackCriterionItems: '++id, categoryId, order',
assignmentFeedbacks: '++id, assignmentId, instituteeId',
feedbackCategoryRatings: '++id, assignmentFeedbackId, feedbackCategoryId',
feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId',
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId',
meetings: null,
consultantNotes: null,
})
this.version(5).stores({
categories: '++id, order',
criteria: '++id, categoryId, order',
assignmentTypes: '++id',
phaseTemplates: '++id, assignmentTypeId, order',
groups: '++id',
consultants: '++id, groupId',
assignments: '++id, status, createdAt',
meetingInstances: '++id, assignmentId, phaseTemplateId',
conversationEntries: '++id, meetingInstanceId, instituteeId, sequenceIndex',
conversationSkillScores: '++id, conversationEntryId, criteriaId',
assessments: '++id, meetingInstanceId, instituteeId, criteriaId',
feedbackDrafts: '++id, assignmentId, instituteeId',
feedbackDimensions: '++id, order',
feedbackCategories: '++id, dimensionId, order',
feedbackCriterionItems: '++id, categoryId, order',
assignmentFeedbacks: '++id, assignmentId, instituteeId',
feedbackCategoryRatings: '++id, assignmentFeedbackId, feedbackCategoryId',
feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId',
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId',
criterionLevelDescriptions: '++id, criterionItemId, rating',
meetings: null,
consultantNotes: null,
})
}
}
export const db = new AssignmentMonitorDB()
// ─── Seed ─────────────────────────────────────────────────────────────────────
export async function seedIfEmpty() {
const count = await db.assignmentTypes.count()
if (count > 0) return
// ── Gemeinsame Kategorien & Kriterien ────────────────────────────────────
const catKomm = await db.categories.add({ name: 'Kommunikation', order: 1 })
const catFach = await db.categories.add({ name: 'Fachkompetenz', order: 2 })
const catKunde = await db.categories.add({ name: 'Kundenorientierung', order: 3 })
const catSelf = await db.categories.add({ name: 'Selbstorganisation', order: 4 })
const catCase = await db.categories.add({ name: 'Case-Kompetenz', order: 5 })
const kritKlarheit = await db.criteria.add({ categoryId: catKomm, name: 'Klarheit & Struktur', description: 'Klare, strukturierte Aussagen', weight: 2, order: 1 })
const kritUeberzeug = await db.criteria.add({ categoryId: catKomm, name: 'Überzeugungskraft', description: 'Argumente überzeugend präsentieren', weight: 2, order: 2 })
const kritZuhoeren = await db.criteria.add({ categoryId: catKomm, name: 'Aktives Zuhören', description: 'Eingehen auf Gesprächspartner', weight: 1, order: 3 })
const kritFragen = await db.criteria.add({ categoryId: catKomm, name: 'Umgang mit Fragen', description: 'Reaktion auf Kunden-Fragen', weight: 2, order: 4 })
const kritTiefe = await db.criteria.add({ categoryId: catFach, name: 'Fachliche Tiefe', description: 'Inhaltliche Korrektheit und Tiefe', weight: 3, order: 1 })
const kritProblem = await db.criteria.add({ categoryId: catFach, name: 'Problemlösung', description: 'Analytisches Vorgehen, Hypothesen', weight: 3, order: 2 })
const kritStakeholder = await db.criteria.add({ categoryId: catKunde, name: 'Stakeholder-Verständnis', description: 'Erkennt Bedürfnisse und Erwartungen', weight: 2, order: 1 })
const kritEmpathie = await db.criteria.add({ categoryId: catKunde, name: 'Empathie', description: 'Einfühlungsvermögen im Gespräch', weight: 1, order: 2 })
const kritVorb = await db.criteria.add({ categoryId: catSelf, name: 'Vorbereitung', description: 'Zeigt gute Meeting-Vorbereitung', weight: 2, order: 1 })
const kritInitiative = await db.criteria.add({ categoryId: catSelf, name: 'Eigeninitiative', description: 'Handelt proaktiv ohne Aufforderung', weight: 2, order: 2 })
const kritStruktur = await db.criteria.add({ categoryId: catCase, name: 'Problemstrukturierung', description: 'Systematische Zerlegung des Problems', weight: 3, order: 1 })
const kritHypo = await db.criteria.add({ categoryId: catCase, name: 'Hypothesenbildung', description: 'Klare, testbare Hypothesen', weight: 3, order: 2 })
const kritAnalyse = await db.criteria.add({ categoryId: catCase, name: 'Rechnen & Analyse', description: 'Datenbasiertes Vorgehen, Schätzfähigkeit', weight: 2, order: 3 })
const kritZeitmanage = await db.criteria.add({ categoryId: catCase, name: 'Zeitmanagement', description: 'Effizienter Umgang mit der Case-Zeit', weight: 2, order: 4 })
// ── Hilfsfunktion: PhaseTemplate anlegen ────────────────────────────────
const addPhase = (
assignmentTypeId: number,
phaseKey: PhaseKey,
label: string,
withWhom: 'lead' | 'client',
opts: { repeatable?: boolean; conv?: boolean; assess?: boolean; criteriaIds?: number[]; order: number }
) => db.phaseTemplates.add({
assignmentTypeId,
phaseKey,
label,
withWhom,
isRepeatable: opts.repeatable ?? false,
hasConversation: opts.conv ?? false,
hasAssessment: opts.assess ?? false,
criteriaIds: opts.criteriaIds ?? [],
order: opts.order,
})
// ── AssignmentType 1: Generisch ──────────────────────────────────────────
const genericId = await db.assignmentTypes.add({
name: 'Generisch',
defaultPhaseKeys: ['preBriefing', 'briefing', 'alignmentCall', 'preDelivery', 'delivery', 'feedbackCallClient', 'feedbackCallLead'],
})
const genericCriteria = [kritKlarheit, kritUeberzeug, kritZuhoeren, kritTiefe, kritProblem, kritStakeholder, kritEmpathie, kritVorb, kritInitiative]
await addPhase(genericId, 'preBriefing', 'Pre-Briefing (Lead)', 'lead', { order: 1 })
await addPhase(genericId, 'briefing', 'Briefing (Kunde)', 'client', { conv: true, assess: true, criteriaIds: genericCriteria, order: 2 })
await addPhase(genericId, 'alignmentCall', 'Alignment Call (Kunde)', 'client', { repeatable: true, conv: true, assess: true, criteriaIds: genericCriteria, order: 3 })
await addPhase(genericId, 'preDelivery', 'Pre-Delivery (Lead)', 'lead', { order: 4 })
await addPhase(genericId, 'delivery', 'Delivery (Kunde)', 'client', { conv: true, assess: true, criteriaIds: genericCriteria, order: 5 })
await addPhase(genericId, 'feedbackCallClient','Feedback Call (Kunde)', 'client', { conv: true, assess: true, criteriaIds: genericCriteria, order: 6 })
await addPhase(genericId, 'feedbackCallLead', 'Feedback Call (Lead)', 'lead', { order: 7 })
// ── AssignmentType 2: Case Interview ────────────────────────────────────
const caseId = await db.assignmentTypes.add({
name: 'Case Interview',
defaultPhaseKeys: ['preBriefing', 'briefing', 'delivery', 'feedbackCallClient', 'feedbackCallLead'],
})
const caseCriteria = [kritStruktur, kritHypo, kritAnalyse, kritKlarheit, kritZeitmanage, kritUeberzeug, kritFragen]
await addPhase(caseId, 'preBriefing', 'Pre-Briefing (Lead)', 'lead', { order: 1 })
await addPhase(caseId, 'briefing', 'Case Briefing (Kunde)', 'client', { conv: true, assess: true, criteriaIds: caseCriteria, order: 2 })
await addPhase(caseId, 'delivery', 'Case Delivery (Kunde)', 'client', { conv: true, assess: true, criteriaIds: caseCriteria, order: 3 })
await addPhase(caseId, 'feedbackCallClient','Feedback Call (Kunde)', 'client', { conv: true, assess: true, criteriaIds: caseCriteria, order: 4 })
await addPhase(caseId, 'feedbackCallLead', 'Feedback Call (Lead)', 'lead', { order: 5 })
// ── AssignmentType 3: Stakeholder Meeting / Pitch ────────────────────────
const pitchId = await db.assignmentTypes.add({
name: 'Stakeholder Meeting / Pitch',
defaultPhaseKeys: ['preBriefing', 'briefing', 'alignmentCall', 'preDelivery', 'delivery', 'feedbackCallClient', 'feedbackCallLead'],
})
const pitchCriteria = [kritVorb, kritKlarheit, kritUeberzeug, kritStakeholder, kritFragen, kritEmpathie, kritInitiative]
await addPhase(pitchId, 'preBriefing', 'Pre-Briefing (Lead)', 'lead', { order: 1 })
await addPhase(pitchId, 'briefing', 'Briefing (Kunde)', 'client', { conv: true, assess: true, criteriaIds: pitchCriteria, order: 2 })
await addPhase(pitchId, 'alignmentCall', 'Alignment Call (Kunde)', 'client', { repeatable: true, conv: true, assess: true, criteriaIds: pitchCriteria, order: 3 })
await addPhase(pitchId, 'preDelivery', 'Pre-Delivery (Lead)', 'lead', { order: 4 })
await addPhase(pitchId, 'delivery', 'Pitch / Delivery (Kunde)', 'client', { conv: true, assess: true, criteriaIds: pitchCriteria, order: 5 })
await addPhase(pitchId, 'feedbackCallClient','Feedback Call (Kunde)', 'client', { conv: true, assess: true, criteriaIds: pitchCriteria, order: 6 })
await addPhase(pitchId, 'feedbackCallLead', 'Feedback Call (Lead)', 'lead', { order: 7 })
}
// ─── Seed: Feedback-Dimensionsstruktur ───────────────────────────────────────
export async function seedFeedbackStructureIfEmpty() {
const count = await db.feedbackDimensions.count()
if (count > 0) return
type CatSeed = { name: string; items: string[] }
type DimSeed = { name: string; cats: CatSeed[] }
const dimensions: DimSeed[] = [
{
name: 'Mindset, Commitment, Collaboration',
cats: [
{ name: 'Persönliche Einstellung', items: ['Ruhig bei Stress', 'Offen für neue Themen', 'Auftreten / Ausstrahlung', 'Pro aktiv', 'Lösungsorientiert'] },
{ name: 'Professionalität', items: ['Pünktlichkeit', 'Professionell bei informellen Gesprächen', 'Adressatengerecht', 'Hohes Maß an Selbststeuerung', 'Klarheit', 'Zuverlässigkeit'] },
{ name: 'Commitment', items: ['Offen für spontane Änderungen', 'Selbständiger Lösungsansatz', 'Pro aktive Ideen', 'Verantwortungsbewusst', 'Hilfsbereit', 'Teamorientiert'] },
{ name: 'Kommunikationsfähigkeit', items: ['To the point', 'Recap-Fähigkeit', 'Active Listening', 'Souveränität'] },
{ name: 'Zusammenarbeit', items: ['Regelmäßige Abstimmung', 'Unterstützung bei Bedarf', 'Know-How Sharing', 'Starkes Teamverhalten', 'Verantwortung und Vertrauen', 'Inhaltlich unterstützend'] },
],
},
{
name: 'Running Our Projects',
cats: [
{ name: 'Ergebnisverantwortung', items: ['Fokussierung', 'Priorisierung (Impact Driven)', 'Machbarkeit / Umsetzbarkeit', 'Alternativvorschläge', 'Interne Abstimmung und Feedback'] },
{ name: 'Arbeitsstil', items: ['Komplexitätsmanagement', 'Eigenverantwortung', 'Feedbackaffinität', 'Verantwortungsbewusst / vorausschauend', 'Selbstreflektiert, lösungsorientiert', 'Offen, initiativ, kollegial', 'Organisiert, kommunikationsfähig', 'Lernfähig, anpassungsbereit, flexibel'] },
{ name: 'Kundenmanagement', items: ['Aktives Erwartungsmanagement', 'Selbstsicherheit und fachliche Überzeugung', 'Aktive Advisory', 'Realistisch', 'Kundenfokussiert'] },
{ name: 'Lieferzeiten', items: ['Verlässlichkeit', 'Fortschrittskontrolle und Planadaption', 'Aktives Scoping', 'Meetingsvorbereitung', 'Transparent'] },
],
},
{
name: 'Growing our People',
cats: [
{ name: 'Networking', items: ['Cross-Collaboration', 'Network Growth', 'Network Utilization'] },
{ name: 'Learning und Development', items: ['Kritikfähigkeit', 'Wissensaufbau (Kompetenzausstrahlung)', 'Umsetzungsbereitschaft Feedback'] },
],
},
{
name: 'Functional & Technical Skills',
cats: [
{ name: 'Qualität des Inhalts', items: ['Belastbarkeit', 'Nachvollziehbarkeit', 'Logische Storyline'] },
{ name: 'Inhaltliche Vollständigkeit', items: ['Wesentliche Elemente vorhanden', 'Realitätsnähe', 'Nutzenfokus'] },
{ name: 'Qualität der Unterlage', items: ['Design nach Standardvorgaben', 'Sprachliche Präzision', 'Visualisierungen unterstützen Kernbotschaften'] },
{ name: 'Prozesstreue', items: ['Kenne und befolge Prozesse und Vorgaben', 'Nachvollziehbarkeit und Transparenz', 'Qualitätschecks'] },
],
},
{
name: 'Innovation und Asset Development',
cats: [
{ name: 'Innovation', items: ['Aktiver Vorschlag für Verbesserungen', 'Neue Ideen zur Verbesserung', 'Nutze aktuellen Wissensstand', 'Liefere über den Kundenerwartungen'] },
],
},
]
for (let di = 0; di < dimensions.length; di++) {
const dim = dimensions[di]
const dimId = await db.feedbackDimensions.add({ name: dim.name, order: di + 1 })
for (let ci = 0; ci < dim.cats.length; ci++) {
const cat = dim.cats[ci]
const catId = await db.feedbackCategories.add({ dimensionId: dimId, name: cat.name, order: ci + 1 })
for (let ii = 0; ii < cat.items.length; ii++) {
await db.feedbackCriterionItems.add({ categoryId: catId, name: cat.items[ii], order: ii + 1 })
}
}
}
}
// ─── Seed: Kriterien-Mapping ──────────────────────────────────────────────────
export async function seedCriterionMappingsIfEmpty() {
const count = await db.criterionCategoryMappings.count()
if (count > 0) return
// Warte bis Kriterien und Feedback-Kategorien vorhanden sind
const [crits, cats] = await Promise.all([
db.criteria.toArray(),
db.feedbackCategories.toArray(),
])
if (crits.length === 0 || cats.length === 0) return
const c = (name: string) => crits.find(x => x.name === name)?.id
const f = (name: string) => cats.find(x => x.name === name)?.id
const mappings: [string, string][] = [
// Kriterium → Feedback-Kategorie
['Klarheit & Struktur', 'Kommunikationsfähigkeit'],
['Klarheit & Struktur', 'Qualität des Inhalts'],
['Überzeugungskraft', 'Kommunikationsfähigkeit'],
['Überzeugungskraft', 'Kundenmanagement'],
['Aktives Zuhören', 'Kommunikationsfähigkeit'],
['Umgang mit Fragen', 'Kommunikationsfähigkeit'],
['Umgang mit Fragen', 'Kundenmanagement'],
['Fachliche Tiefe', 'Qualität des Inhalts'],
['Fachliche Tiefe', 'Inhaltliche Vollständigkeit'],
['Problemlösung', 'Ergebnisverantwortung'],
['Problemlösung', 'Arbeitsstil'],
['Stakeholder-Verständnis', 'Kundenmanagement'],
['Stakeholder-Verständnis', 'Persönliche Einstellung'],
['Empathie', 'Persönliche Einstellung'],
['Empathie', 'Kommunikationsfähigkeit'],
['Vorbereitung', 'Professionalität'],
['Vorbereitung', 'Lieferzeiten'],
['Eigeninitiative', 'Commitment'],
['Eigeninitiative', 'Persönliche Einstellung'],
['Problemstrukturierung', 'Ergebnisverantwortung'],
['Problemstrukturierung', 'Qualität des Inhalts'],
['Hypothesenbildung', 'Ergebnisverantwortung'],
['Hypothesenbildung', 'Arbeitsstil'],
['Rechnen & Analyse', 'Qualität des Inhalts'],
['Rechnen & Analyse', 'Inhaltliche Vollständigkeit'],
['Zeitmanagement', 'Lieferzeiten'],
['Zeitmanagement', 'Arbeitsstil'],
]
for (const [critName, catName] of mappings) {
const criterionId = c(critName)
const feedbackCategoryId = f(catName)
if (criterionId && feedbackCategoryId) {
await db.criterionCategoryMappings.add({ criterionId, feedbackCategoryId })
}
}
}
// ─── Migration: Unified Criteria System ──────────────────────────────────────
// Löscht nur Bewertungs-Scores (noch nicht befüllt), nie Protokoll-Texte.
// Verknüpft PhaseTemplates mit FeedbackCategory-IDs statt alten Criterion-IDs.
export async function migrateToUnifiedCriteriaIfNeeded() {
// Prüfen ob Migration schon gelaufen: erste PhaseTemplate mit criteriaIds die
// auf eine FeedbackCategory zeigen (vs. alte Criterion-IDs die niedrig sein werden)
const cats = await db.feedbackCategories.toArray()
if (cats.length === 0) return // Feedback-Struktur noch nicht geseeded
const templates = await db.phaseTemplates.toArray()
if (templates.length === 0) return
// Prüfen ob irgendeine Template-criteriaId in den FeedbackCategory-IDs liegt
const catIds = new Set(cats.map(c => c.id!))
const firstWithIds = templates.find(t => t.criteriaIds.length > 0)
if (firstWithIds && catIds.has(firstWithIds.criteriaIds[0])) return // schon migriert
// Kategorien nach Name nachschlagen
const cat = (name: string) => cats.find(c => c.name === name)?.id
// Scores löschen (keine Textinhalte, nur Bewertungszahlen)
await db.conversationSkillScores.clear()
await db.assessments.clear()
// Phase-Templates mit neuen Kategorie-IDs befüllen
// Kategorien-Sets pro Assignment-Typ und Phasentyp
const genericCats = [
cat('Kommunikationsfähigkeit'), cat('Persönliche Einstellung'),
cat('Kundenmanagement'), cat('Ergebnisverantwortung'), cat('Qualität des Inhalts'),
].filter(Boolean) as number[]
const caseCats = [
cat('Kommunikationsfähigkeit'), cat('Ergebnisverantwortung'),
cat('Arbeitsstil'), cat('Qualität des Inhalts'), cat('Inhaltliche Vollständigkeit'),
].filter(Boolean) as number[]
const pitchCats = [
cat('Kommunikationsfähigkeit'), cat('Persönliche Einstellung'),
cat('Kundenmanagement'), cat('Ergebnisverantwortung'),
cat('Qualität des Inhalts'), cat('Qualität der Unterlage'),
].filter(Boolean) as number[]
const types = await db.assignmentTypes.toArray()
for (const tmpl of templates) {
if (!tmpl.hasAssessment && !tmpl.hasConversation) {
await db.phaseTemplates.update(tmpl.id!, { criteriaIds: [] })
continue
}
const aType = types.find(t => t.id === tmpl.assignmentTypeId)
let newIds: number[]
if (aType?.name === 'Case Interview') {
newIds = caseCats
} else if (aType?.name === 'Stakeholder Meeting / Pitch') {
newIds = pitchCats
} else {
newIds = genericCats
}
await db.phaseTemplates.update(tmpl.id!, { criteriaIds: newIds })
}
}

1
src/index.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

20
src/main.tsx Normal file
View File

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

View File

@ -0,0 +1,212 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { db, type AssignmentType, type PhaseTemplate, type Consultant, type Group } from '../db'
export default function AssignmentCreate() {
const navigate = useNavigate()
const [types, setTypes] = useState<AssignmentType[]>([])
const [phases, setPhases] = useState<PhaseTemplate[]>([])
const [groups, setGroups] = useState<Group[]>([])
const [consultants, setConsultants] = useState<Consultant[]>([])
const [form, setForm] = useState({
title: '',
client: '',
leadName: '',
assignmentTypeId: 0,
selectedGroupId: 0,
instituteeIds: [] as number[],
})
// Inline Berater-Anlage
const [showAddInstitutee, setShowAddInstitutee] = useState(false)
const [newFirst, setNewFirst] = useState('')
const [newLast, setNewLast] = useState('')
const reload = async () => {
const [t, g, c] = await Promise.all([
db.assignmentTypes.toArray(),
db.groups.toArray(),
db.consultants.toArray(),
])
setTypes(t)
setGroups(g)
setConsultants(c)
}
useEffect(() => { reload() }, [])
useEffect(() => {
if (!form.assignmentTypeId) { setPhases([]); return }
db.phaseTemplates.where('assignmentTypeId').equals(form.assignmentTypeId).sortBy('order').then(setPhases)
}, [form.assignmentTypeId])
const groupConsultants = consultants.filter(c => c.groupId === form.selectedGroupId)
const toggleInstitutee = (id: number) =>
setForm(p => ({
...p,
instituteeIds: p.instituteeIds.includes(id)
? p.instituteeIds.filter(x => x !== id)
: [...p.instituteeIds, id],
}))
const addInstituteeInline = async () => {
if (!newFirst.trim() || !newLast.trim()) return
let groupId = form.selectedGroupId
if (!groupId) {
// Erstelle eine Standardgruppe falls keine vorhanden
groupId = await db.groups.add({ name: 'Neue Gruppe', cohortYear: new Date().getFullYear() })
setForm(p => ({ ...p, selectedGroupId: groupId }))
}
const id = await db.consultants.add({ groupId, firstName: newFirst.trim(), lastName: newLast.trim() })
await reload()
setForm(p => ({ ...p, instituteeIds: [...p.instituteeIds, id] }))
setNewFirst('')
setNewLast('')
setShowAddInstitutee(false)
}
const create = async () => {
if (!form.title.trim() || !form.client.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return
const id = await db.assignments.add({
title: form.title,
client: form.client,
leadName: form.leadName,
assignmentTypeId: form.assignmentTypeId,
instituteeIds: form.instituteeIds,
status: 'active',
createdAt: new Date().toISOString(),
})
navigate(`/assignment/${id}`)
}
const canCreate = form.title.trim() && form.client.trim() && form.assignmentTypeId && form.instituteeIds.length > 0
const selectedInstitutees = consultants.filter(c => form.instituteeIds.includes(c.id!))
return (
<div className="space-y-5">
<h1 className="text-xl font-bold text-gray-800">Neues Assignment</h1>
{/* Basisdaten */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
<input className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
placeholder="Titel des Assignments" value={form.title}
onChange={e => setForm(p => ({ ...p, title: e.target.value }))} />
<input className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
placeholder="Kunde / Unternehmen" value={form.client}
onChange={e => setForm(p => ({ ...p, client: e.target.value }))} />
<input className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
placeholder="Lead-Name (dein Name)" value={form.leadName}
onChange={e => setForm(p => ({ ...p, leadName: e.target.value }))} />
<select className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
value={form.assignmentTypeId}
onChange={e => setForm(p => ({ ...p, assignmentTypeId: +e.target.value }))}>
<option value={0}>Assignment-Typ wählen</option>
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
{/* Phase-Preview */}
{phases.length > 0 && (
<div className="bg-blue-50 border border-blue-100 rounded-xl p-4">
<div className="text-xs font-semibold text-blue-600 uppercase tracking-wide mb-2">Meeting-Flow</div>
<div className="flex flex-wrap gap-2">
{phases.map((p, i) => (
<span key={p.id} className="text-xs bg-white border border-blue-200 text-blue-700 px-2 py-1 rounded-full">
{i + 1}. {p.label}{p.isRepeatable ? ' *' : ''}
</span>
))}
</div>
<div className="text-xs text-blue-400 mt-2">* = wiederholbar</div>
</div>
)}
{/* Institutees */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-700 text-sm">Institutees (Berater)</h2>
<button onClick={() => setShowAddInstitutee(p => !p)}
className="text-xs text-[#0070AD] border border-[#0070AD] px-2.5 py-1 rounded-lg">
+ Neu anlegen
</button>
</div>
{/* Inline Berater-Formular */}
{showAddInstitutee && (
<div className="bg-blue-50 border border-blue-100 rounded-lg p-3 space-y-2">
<div className="text-xs font-semibold text-blue-600 mb-1">Neuen Berater anlegen</div>
<div className="flex gap-2">
<input className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
placeholder="Vorname" value={newFirst}
onChange={e => setNewFirst(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addInstituteeInline()} />
<input className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
placeholder="Nachname" value={newLast}
onChange={e => setNewLast(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addInstituteeInline()} />
</div>
{groups.length > 0 && (
<select className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
value={form.selectedGroupId || ''}
onChange={e => setForm(p => ({ ...p, selectedGroupId: +e.target.value }))}>
<option value="">Gruppe wählen (optional)</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
)}
<div className="flex gap-2">
<button onClick={addInstituteeInline}
disabled={!newFirst.trim() || !newLast.trim()}
className="flex-1 py-2 rounded-lg text-sm font-medium bg-[#0070AD] text-white disabled:bg-gray-200 disabled:text-gray-400">
Hinzufügen
</button>
<button onClick={() => { setShowAddInstitutee(false); setNewFirst(''); setNewLast('') }}
className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
Abbrechen
</button>
</div>
</div>
)}
{/* Gruppe wählen */}
<select className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
value={form.selectedGroupId}
onChange={e => setForm(p => ({ ...p, selectedGroupId: +e.target.value, instituteeIds: [] }))}>
<option value={0}>Gruppe wählen</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
{form.selectedGroupId > 0 && (
<div className="space-y-1">
{groupConsultants.length === 0 && (
<p className="text-sm text-gray-400">Keine Berater in dieser Gruppe. Nutze "+ Neu anlegen".</p>
)}
{groupConsultants.map(c => (
<label key={c.id} className="flex items-center gap-3 py-1.5 cursor-pointer">
<input type="checkbox" checked={form.instituteeIds.includes(c.id!)}
onChange={() => toggleInstitutee(c.id!)} className="w-4 h-4 accent-[#0070AD]" />
<span className="text-sm">{c.firstName} {c.lastName}</span>
</label>
))}
</div>
)}
{/* Gewählte Berater aus anderen Gruppen */}
{selectedInstitutees.filter(c => c.groupId !== form.selectedGroupId).map(c => (
<div key={c.id} className="flex items-center gap-2 py-1">
<span className="w-4 h-4 rounded bg-[#0070AD] flex items-center justify-center">
<span className="text-white text-xs"></span>
</span>
<span className="text-sm">{c.firstName} {c.lastName}</span>
<button onClick={() => toggleInstitutee(c.id!)} className="text-xs text-red-400 ml-auto">×</button>
</div>
))}
</div>
<button onClick={create} disabled={!canCreate}
className={`w-full py-3 rounded-xl text-sm font-medium shadow transition ${
canCreate ? 'bg-[#0070AD] text-white hover:bg-[#005a8e]' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}>
Assignment anlegen
</button>
</div>
)
}

View File

@ -0,0 +1,398 @@
import { useEffect, useState } from 'react'
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 { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
const RATING_BG: Record<FeedbackRating, string> = {
na: 'bg-gray-400 text-white',
not_client_ready: 'bg-red-500 text-white',
partially_client_ready: 'bg-orange-500 text-white',
nearly_client_ready: 'bg-green-400 text-white',
client_ready: 'bg-green-700 text-white',
}
const NUM_MAP: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
const avgToRating = (avg: number): FeedbackRating =>
avg < 1.5 ? 'not_client_ready' : avg < 2.5 ? 'partially_client_ready' : avg < 3.5 ? 'nearly_client_ready' : 'client_ready'
interface PhaseRow {
template: PhaseTemplate
instances: MeetingInstance[]
}
export default function AssignmentDetail() {
const { id } = useParams()
const navigate = useNavigate()
const assignmentId = Number(id)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [assignmentType, setAssignmentType] = useState<AssignmentType | null>(null)
const [phaseRows, setPhaseRows] = useState<PhaseRow[]>([])
const [allTemplates, setAllTemplates] = useState<PhaseTemplate[]>([])
const [institutees, setInstitutees] = useState<Consultant[]>([])
const [deletedInstances, setDeletedInstances] = useState<MeetingInstance[]>([])
const [showAdHoc, setShowAdHoc] = useState(false)
const [showTrash, setShowTrash] = useState(false)
const [exportMenuId, setExportMenuId] = useState<number | null>(null)
const [confirmSoftDelete, setConfirmSoftDelete] = useState<number | null>(null)
const [confirmHardDelete, setConfirmHardDelete] = useState<number | null>(null)
const [allAssessments, setAllAssessments] = useState<Assessment[]>([])
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const load = async () => {
const a = await db.assignments.get(assignmentId)
if (!a) return
setAssignment(a)
const [aType, templates, instances, cons] = await Promise.all([
db.assignmentTypes.get(a.assignmentTypeId),
db.phaseTemplates.where('assignmentTypeId').equals(a.assignmentTypeId).sortBy('order'),
db.meetingInstances.where('assignmentId').equals(assignmentId).toArray(),
db.consultants.where('id').anyOf(a.instituteeIds).toArray(),
])
setAssignmentType(aType ?? null)
setAllTemplates(templates)
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
const active = instances.filter(i => !i.deletedAt)
const deleted = instances.filter(i => !!i.deletedAt)
setDeletedInstances(deleted)
setPhaseRows(templates.map(t => ({
template: t,
instances: active.filter(i => i.phaseTemplateId === t.id).sort((a, b) => a.phaseIndex - b.phaseIndex),
})))
const instIds = active.map(i => i.id!)
const [asmts, allCats, allItems, aType2] = await Promise.all([
instIds.length > 0 ? db.assessments.where('meetingInstanceId').anyOf(instIds).toArray() : Promise.resolve([]),
db.feedbackCategories.toArray(),
db.feedbackCriterionItems.toArray(),
db.assignmentTypes.get(a.assignmentTypeId),
])
setAllAssessments(asmts)
const itemFilter = aType2?.criteriaIds?.length ? new Set(aType2.criteriaIds) : null
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
setFbItems(filteredItems)
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId))
setFbCategories(itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats)
}
useEffect(() => { load() }, [assignmentId])
const startMeeting = async (templateId: number, existingCount: number) => {
const mid = await db.meetingInstances.add({
assignmentId,
phaseTemplateId: templateId,
phaseIndex: existingCount + 1,
date: new Date().toISOString().slice(0, 10),
status: 'active',
generalNotes: '',
})
navigate(`/assignment/${assignmentId}/meeting/${mid}`)
}
const buildFilename = (inst: MeetingInstance, ext: string) => {
const tmpl = allTemplates.find(t => t.id === inst.phaseTemplateId)
const date = new Date(inst.date).toLocaleDateString('de-DE').replace(/\./g, '-')
const label = tmpl?.label ?? 'Meeting'
const idx = inst.phaseIndex > 1 ? `-${inst.phaseIndex}` : ''
const id = assignment?.assignmentNumber ?? assignment?.title ?? 'export'
return `${id}_${date}_${label}${idx}.${ext}`
}
const handleExportMd = async (inst: MeetingInstance) => {
const md = await exportMeetingAsMarkdown(inst.id!)
downloadFile(md, buildFilename(inst, 'md'), 'text/markdown')
setExportMenuId(null)
}
const handleExportJson = async (inst: MeetingInstance) => {
const json = await exportMeetingAsJson(inst.id!)
downloadFile(json, buildFilename(inst, 'json'), 'application/json')
setExportMenuId(null)
}
const softDeleteMeeting = async (instId: number) => {
await db.meetingInstances.update(instId, { deletedAt: new Date().toISOString() })
setConfirmSoftDelete(null)
await load()
}
const restoreMeeting = async (instId: number) => {
await db.meetingInstances.update(instId, { deletedAt: undefined })
await load()
}
const hardDeleteMeeting = async (instId: number) => {
await db.conversationSkillScores
.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)
await load()
}
const statusBadge = (status: MeetingInstance['status']) => {
const map = {
planned: 'bg-yellow-100 text-yellow-700',
active: 'bg-green-100 text-green-700',
done: 'bg-gray-100 text-gray-500',
}
const label = { planned: 'Geplant', active: 'Aktiv', done: 'Fertig' }
return <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${map[status]}`}>{label[status]}</span>
}
if (!assignment) return <div className="text-center py-10 text-gray-400">Lädt</div>
return (
<div className="space-y-4">
<div>
<button onClick={() => navigate('/')} className="text-sm text-[#0070AD] mb-2 block"> Zurück</button>
<div className="flex items-start justify-between gap-2">
<div>
{assignment.assignmentNumber && (
<div className="text-xs font-semibold text-[#0070AD] uppercase tracking-wide">{assignment.assignmentNumber}</div>
)}
<h1 className="text-xl font-bold text-gray-800 leading-tight">{assignment.title}</h1>
</div>
<button
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
className="flex-shrink-0 text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-[#0070AD] hover:text-[#0070AD] transition">
Bearbeiten
</button>
</div>
<div className="text-sm text-gray-500">{assignment.client} · {assignmentType?.name}</div>
<div className="text-xs text-gray-400 mt-1">
{institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}
</div>
</div>
{/* Ad-hoc Meeting */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">Ad-hoc Meeting starten</span>
<button onClick={() => setShowAdHoc(p => !p)}
className="text-xs bg-[#0070AD] text-white px-3 py-1.5 rounded-lg font-medium">
{showAdHoc ? 'Abbrechen' : '+ Ad-hoc'}
</button>
</div>
{showAdHoc && (
<div className="mt-3 grid grid-cols-2 gap-2">
{allTemplates.map(t => (
<button key={t.id}
onClick={() => {
const count = phaseRows.find(r => r.template.id === t.id)?.instances.length ?? 0
startMeeting(t.id!, count)
}}
className="py-2.5 px-3 text-left rounded-lg border border-gray-200 hover:border-[#0070AD] hover:bg-blue-50 transition">
<div className="text-sm font-medium text-gray-700">{t.label}</div>
<div className={`text-xs mt-0.5 ${t.withWhom === 'client' ? 'text-blue-500' : 'text-purple-500'}`}>
{t.withWhom === 'client' ? 'Kundentermin' : 'Lead-intern'}
</div>
</button>
))}
</div>
)}
</div>
{/* Feedback-Entwürfe */}
{institutees.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 flex items-center justify-between">
<span className="text-sm text-amber-800 font-medium">Feedback-Entwürfe</span>
<div className="flex gap-2 flex-wrap">
{institutees.map(c => (
<button key={c.id}
onClick={() => navigate(`/assignment/${assignmentId}/feedback/${c.id}`)}
className="text-xs bg-amber-600 text-white px-3 py-1 rounded-lg">
{c.firstName}
</button>
))}
</div>
</div>
)}
{/* Phasen-Flow */}
<div className="space-y-3" onClick={() => setExportMenuId(null)}>
{phaseRows.map(({ template, instances }) => {
const canStart = instances.length === 0 || template.isRepeatable
return (
<div key={template.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-w-0">
<span className="font-semibold text-gray-800 text-sm">{template.label}</span>
<span className={`ml-2 text-xs px-2 py-0.5 rounded-full ${
template.withWhom === 'client' ? 'bg-blue-100 text-blue-600' : 'bg-purple-100 text-purple-600'
}`}>
{template.withWhom === 'client' ? 'Kunde' : 'Lead'}
</span>
{template.hasConversation && (
<span className="ml-1 text-xs bg-orange-100 text-orange-600 px-2 py-0.5 rounded-full">
Protokoll
</span>
)}
</div>
<button
onClick={() => canStart && startMeeting(template.id!, instances.length)}
className={`ml-2 flex-shrink-0 text-xs px-3 py-1.5 rounded-lg font-medium ${
canStart ? 'bg-[#0070AD] text-white' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
disabled={!canStart}>
{instances.length === 0 ? 'Starten' : template.isRepeatable ? '+ Wiederholen' : 'Erledigt'}
</button>
</div>
{instances.map(inst => (
<div key={inst.id} className="mt-2 relative">
<div
onClick={() => { if (confirmSoftDelete !== inst.id) navigate(`/assignment/${assignmentId}/meeting/${inst.id}`) }}
className="flex items-start justify-between py-2 px-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-700">
{template.isRepeatable ? `#${inst.phaseIndex} · ` : ''}
{new Date(inst.date).toLocaleDateString('de-DE')}
</div>
{/* Per-institutee per-category chips */}
{institutees.map(c => {
const chips = fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
const scored = catItems.map(item => {
const asmt = allAssessments.find(a =>
a.meetingInstanceId === inst.id &&
a.instituteeId === c.id &&
a.criteriaId === item.id &&
a.score !== null && a.score !== 'na'
)
return asmt ? { score: asmt.score as FeedbackRating, w: item.weight ?? 1 } : null
}).filter(Boolean) as { score: FeedbackRating; w: number }[]
if (scored.length === 0) return 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 }[]
if (chips.length === 0) return null
return (
<div key={c.id} className="mt-1">
<span className="text-xs text-gray-400 mr-1">{c.firstName}:</span>
{chips.map(({ cat, mode }) => (
<span key={cat.id} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${RATING_BG[mode]}`}>
{cat.name}
</span>
))}
</div>
)
})}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{statusBadge(inst.status)}
<button
onClick={e => { e.stopPropagation(); setExportMenuId(exportMenuId === inst.id ? null : inst.id!); setConfirmSoftDelete(null) }}
className="text-xs text-gray-400 hover:text-gray-600 px-1.5 py-1">
</button>
<button
onClick={e => { e.stopPropagation(); setConfirmSoftDelete(confirmSoftDelete === inst.id ? null : inst.id!); setExportMenuId(null) }}
className="text-xs text-red-300 hover:text-red-500 px-1.5 py-1">
🗑
</button>
</div>
</div>
{exportMenuId === inst.id && (
<div className="absolute right-8 top-full mt-0.5 bg-white border border-gray-200 rounded-xl shadow-lg z-10 min-w-[150px] overflow-hidden">
<button onClick={() => handleExportMd(inst)}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 border-b border-gray-100">
📄 Markdown
</button>
<button onClick={() => handleExportJson(inst)}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50">
🗂 JSON-Backup
</button>
</div>
)}
{confirmSoftDelete === inst.id && (
<div className="mt-1 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2">
<span className="text-xs text-red-700">In Papierkorb verschieben?</span>
<div className="flex gap-1.5">
<button onClick={() => softDeleteMeeting(inst.id!)}
className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">
Ja
</button>
<button onClick={() => setConfirmSoftDelete(null)}
className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">
Nein
</button>
</div>
</div>
)}
</div>
))}
</div>
)
})}
</div>
{/* Papierkorb */}
{deletedInstances.length > 0 && (
<div className="border border-dashed border-gray-200 rounded-xl overflow-hidden">
<button
onClick={() => setShowTrash(p => !p)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 transition">
<span className="text-sm text-gray-400 font-medium">
🗑 Papierkorb ({deletedInstances.length})
</span>
<span className="text-gray-300 text-xs">{showTrash ? '▲' : '▼'}</span>
</button>
{showTrash && (
<div className="border-t border-dashed border-gray-200 divide-y divide-gray-100">
{deletedInstances.map(inst => {
const tmpl = allTemplates.find(t => t.id === inst.phaseTemplateId)
return (
<div key={inst.id} className="px-4 py-3 bg-gray-50">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-500">{tmpl?.label ?? '—'}</div>
<div className="text-xs text-gray-400">
{new Date(inst.date).toLocaleDateString('de-DE')}
{inst.deletedAt && ` · gelöscht ${new Date(inst.deletedAt).toLocaleDateString('de-DE')}`}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => restoreMeeting(inst.id!)}
className="text-xs bg-white border border-gray-200 text-gray-600 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Wiederherstellen
</button>
<button onClick={() => setConfirmHardDelete(inst.id!)}
className="text-xs text-red-400 hover:text-red-600 px-2 py-1">
</button>
</div>
</div>
{confirmHardDelete === inst.id && (
<div className="mt-2 bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
<div className="text-xs text-red-700 font-medium">Endgültig löschen? Alle Daten dieses Meetings werden unwiderruflich entfernt.</div>
<div className="flex gap-2">
<button onClick={() => hardDeleteMeeting(inst.id!)}
className="flex-1 py-1.5 rounded-lg text-xs font-medium bg-red-600 text-white">
Endgültig löschen
</button>
<button onClick={() => setConfirmHardDelete(null)}
className="px-3 py-1.5 rounded-lg text-xs border border-gray-200 text-gray-500">
Abbrechen
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,256 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { db, type Assignment, type AssignmentType, type Consultant } from '../db'
export default function AssignmentEdit() {
const { id } = useParams()
const navigate = useNavigate()
const assignmentId = Number(id)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [assignmentTypes, setAssignmentTypes] = useState<AssignmentType[]>([])
const [allConsultants, setAllConsultants] = useState<Consultant[]>([])
// Form fields
const [assignmentNumber, setAssignmentNumber] = useState('')
const [title, setTitle] = useState('')
const [client, setClient] = useState('')
const [description, setDescription] = useState('')
const [assignmentTypeId, setAssignmentTypeId] = useState(0)
const [leadName, setLeadName] = useState('')
const [status, setStatus] = useState<'active' | 'done'>('active')
const [instituteeIds, setInstituteeIds] = useState<number[]>([])
const [instituteeEnrollments, setInstituteeEnrollments] = useState<Array<{ instituteeId: number; from?: string; to?: string }>>([])
const [typeChanged, setTypeChanged] = useState(false)
const [addInstituteeId, setAddInstituteeId] = useState(0)
useEffect(() => {
const loadData = async () => {
const [a, types, cons] = await Promise.all([
db.assignments.get(assignmentId),
db.assignmentTypes.toArray(),
db.consultants.toArray(),
])
if (!a) return
setAssignment(a)
setAssignmentTypes(types)
setAllConsultants(cons)
setAssignmentNumber(a.assignmentNumber ?? '')
setTitle(a.title)
setClient(a.client)
setDescription(a.description ?? '')
setAssignmentTypeId(a.assignmentTypeId)
setLeadName(a.leadName)
setStatus(a.status)
setInstituteeIds(a.instituteeIds ?? [])
setInstituteeEnrollments(a.instituteeEnrollments ?? [])
}
loadData()
}, [assignmentId])
const handleSave = async () => {
await db.assignments.update(assignmentId, {
assignmentNumber: assignmentNumber.trim() || undefined,
title,
client,
description: description.trim() || undefined,
assignmentTypeId,
leadName,
status,
instituteeIds,
instituteeEnrollments,
})
navigate(`/assignment/${assignmentId}`)
}
const handleTypeChange = (newTypeId: number) => {
if (newTypeId !== assignmentTypeId) setTypeChanged(true)
setAssignmentTypeId(newTypeId)
}
const addInstitutee = () => {
if (!addInstituteeId || instituteeIds.includes(addInstituteeId)) return
setInstituteeIds(prev => [...prev, addInstituteeId])
setInstituteeEnrollments(prev => [...prev, { instituteeId: addInstituteeId, from: '', to: '' }])
setAddInstituteeId(0)
}
const removeInstitutee = (cid: number) => {
setInstituteeIds(prev => prev.filter(id => id !== cid))
setInstituteeEnrollments(prev => prev.filter(e => e.instituteeId !== cid))
}
const updateEnrollment = (cid: number, field: 'from' | 'to', value: string) => {
setInstituteeEnrollments(prev =>
prev.map(e => e.instituteeId === cid ? { ...e, [field]: value } : e)
)
}
const availableConsultants = allConsultants.filter(c => !instituteeIds.includes(c.id!))
if (!assignment) return <div className="text-center py-10 text-gray-400">Lädt</div>
return (
<div className="space-y-4">
{/* Header */}
<div>
<button onClick={() => navigate(`/assignment/${assignmentId}`)}
className="text-sm text-[#0070AD] mb-2 block"> Zurück</button>
<h1 className="text-xl font-bold text-gray-800">Assignment bearbeiten</h1>
</div>
{/* Formular */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-4 space-y-3">
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">Assignment-Nummer</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
placeholder="z. B. CGI-2024-001"
value={assignmentNumber}
onChange={e => setAssignmentNumber(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">Titel</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">Kunde</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
value={client}
onChange={e => setClient(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">Beschreibung</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
rows={3}
value={description}
onChange={e => setDescription(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">Assignment-Typ</label>
<select
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
value={assignmentTypeId}
onChange={e => handleTypeChange(Number(e.target.value))}>
{assignmentTypes.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{typeChanged && (
<div className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mt-1">
Bestehende Meetings bleiben erhalten
</div>
)}
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">Lead</label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
value={leadName}
onChange={e => setLeadName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500">Status</label>
<select
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
value={status}
onChange={e => setStatus(e.target.value as 'active' | 'done')}>
<option value="active">Aktiv</option>
<option value="done">Abgeschlossen</option>
</select>
</div>
</div>
{/* Institutees */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-4 space-y-3">
<h2 className="font-semibold text-gray-800">Institutees</h2>
{instituteeIds.length === 0 && (
<p className="text-sm text-gray-400">Noch keine Institutees zugewiesen.</p>
)}
{instituteeIds.map(cid => {
const consultant = allConsultants.find(c => c.id === cid)
const enrollment = instituteeEnrollments.find(e => e.instituteeId === cid)
if (!consultant) return null
return (
<div key={cid} className="flex items-center gap-2 flex-wrap border border-gray-100 rounded-lg px-3 py-2">
<span className="text-sm font-medium text-gray-700 flex-1 min-w-[100px]">
{consultant.firstName} {consultant.lastName}
</span>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-400">Von</span>
<input
type="date"
className="border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-[#0070AD]"
value={enrollment?.from ?? ''}
onChange={e => updateEnrollment(cid, 'from', e.target.value)}
/>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-400">Bis</span>
<input
type="date"
className="border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-[#0070AD]"
value={enrollment?.to ?? ''}
onChange={e => updateEnrollment(cid, 'to', e.target.value)}
/>
</div>
<button
onClick={() => removeInstitutee(cid)}
className="text-red-300 hover:text-red-500 text-sm px-1">
×
</button>
</div>
)
})}
{/* Institutee hinzufügen */}
{availableConsultants.length > 0 && (
<div className="flex gap-2">
<select
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
value={addInstituteeId}
onChange={e => setAddInstituteeId(Number(e.target.value))}>
<option value={0}>Institutee auswählen</option>
{availableConsultants.map(c => (
<option key={c.id} value={c.id}>{c.firstName} {c.lastName}</option>
))}
</select>
<button
onClick={addInstitutee}
disabled={!addInstituteeId}
className="px-4 py-2 bg-[#0070AD] text-white text-sm rounded-lg font-medium disabled:opacity-40">
Hinzufügen
</button>
</div>
)}
</div>
{/* Aktionen */}
<div className="flex gap-3">
<button
onClick={handleSave}
className="flex-1 py-3 bg-[#0070AD] text-white rounded-xl text-sm font-semibold">
Speichern
</button>
<button
onClick={() => navigate(`/assignment/${assignmentId}`)}
className="px-5 py-3 border border-gray-200 text-gray-500 rounded-xl text-sm">
Abbrechen
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,533 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
db,
type Assignment,
type Consultant,
type FeedbackDimension,
type FeedbackCategory,
type FeedbackCriterionItem,
type AssignmentFeedback,
type FeedbackCategoryRating,
type FeedbackDimensionText,
type FeedbackRating,
} from '../db'
const ratingToNum = (r: FeedbackRating | null | undefined): number => {
const m: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
return r ? (m[r] ?? 0) : 0
}
// ── Rating-Skala ─────────────────────────────────────────────────────────────
const RATINGS: { value: FeedbackRating; short: string; label: string; color: string }[] = [
{ value: 'na', short: 'N/A', label: 'N/A (0)', color: 'bg-gray-100 text-gray-500 border-gray-300' },
{ value: 'not_client_ready', short: 'Not', label: 'Not (1)', color: 'bg-red-100 text-red-700 border-red-300' },
{ value: 'partially_client_ready', short: 'Partially', label: 'Partially (2)', color: 'bg-orange-100 text-orange-700 border-orange-300' },
{ value: 'nearly_client_ready', short: 'Nearly', label: 'Nearly (3)', color: 'bg-green-100 text-green-600 border-green-300' },
{ value: 'client_ready', short: 'Fully', label: 'Fully (4)', color: 'bg-green-100 text-green-800 border-green-400' },
]
const ratingColor = (r: FeedbackRating | undefined) =>
RATINGS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200'
// ── KI-Einstellungen aus localStorage ────────────────────────────────────────
const AI_KEY = 'ai_api_key'
const AI_MODEL = 'ai_model'
function getAiSettings() {
return {
apiKey: localStorage.getItem(AI_KEY) ?? '',
model: localStorage.getItem(AI_MODEL) ?? 'openai/gpt-4o-mini',
}
}
function saveAiSettings(apiKey: string, model: string) {
localStorage.setItem(AI_KEY, apiKey)
localStorage.setItem(AI_MODEL, model)
}
// ── Prompt-Builder ────────────────────────────────────────────────────────────
async function buildAiPrompt(
institutee: Consultant,
assignment: Assignment,
dimensions: FeedbackDimension[],
categories: FeedbackCategory[],
_items: FeedbackCriterionItem[],
ratings: FeedbackCategoryRating[],
_feedbackId: number,
): Promise<string> {
const meetings = await db.meetingInstances.where('assignmentId').equals(assignment.id!).toArray()
const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([
db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray(),
db.conversationEntries.where('meetingInstanceId').anyOf(meetingIds).toArray(),
])
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
const critIds = [...new Set(instAssessments.map(a => a.criteriaId))]
const crits = critIds.length > 0
? await db.criteria.where('id').anyOf(critIds).toArray()
: []
const scoreLines = crits.map(c => {
const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null)
const avg = scores.length > 0
? (scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length).toFixed(1)
: '—'
const notes = instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note)
return ` ${c.name}: ${avg}/5${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
}).join('\n')
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
const protocolLines = instEntries
.filter(e => e.note)
.map(e => ` - ${e.note}${(e.fillerCount ?? 0) > 0 ? ` [äh×${e.fillerCount}]` : ''}`)
.join('\n')
// Kategorie-Ratings
const ratingLines = dimensions.map(dim => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
const lines = dimCats.map(cat => {
const r = ratings.find(r => r.feedbackCategoryId === cat.id)
const rating = RATINGS.find(x => x.value === r?.rating)?.label ?? 'nicht bewertet'
return ` ${cat.name}: ${rating}`
}).join('\n')
return ` Dimension "${dim.name}":\n${lines}`
}).join('\n')
// Feedback-Struktur für Output
const dimStructure = dimensions.map((dim, i) =>
`${i + 1}. ${dim.name}`
).join('\n')
return `Du bist Führungskraft und Mentor bei Capgemini. Erstelle ein professionelles, konstruktives Performance-Feedback für einen Berater/Trainee auf Basis der folgenden Beobachtungsdaten aus echten Kundenterminen.
BERATER: ${institutee.firstName} ${institutee.lastName}
ASSIGNMENT: ${assignment.title} | Kunde: ${assignment.client}
MEETINGS: ${meetings.length} Kundentermin(e)
FÜLLWÖRTER GESAMT: ${fillerTotal}
BEOBACHTETE KRITERIEN UND SCORES (1-5):
${scoreLines || '(keine Scores erfasst)'}
PROTOKOLL-NOTIZEN (Kurznotizen aus dem Gespräch, Notation: + gut, ! negativ, > Kundeninput):
${protocolLines || '(keine Protokolleinträge)'}
KATEGORIE-BEWERTUNGEN:
${ratingLines}
Generiere für jede der folgenden ${dimensions.length} Dimensionen je einen kurzen Absatz "Achievements" (2-4 Sätze, konkret, anerkennend) und "Development Needs" (2-4 Sätze, konstruktiv, handlungsorientiert). Falls eine Dimension aus den Daten nicht beurteilbar ist, schreibe "n/a".
Ausgabe als JSON:
{
"dimensions": [
{
"name": "<Dimensionsname>",
"achievements": "<Text>",
"developmentNeeds": "<Text>"
}
]
}
DIMENSIONEN:
${dimStructure}
Wichtig: Beziehe dich auf konkrete Beobachtungen aus den Daten. Schreibe direkt und persönlich (du-Form). Vermeide generische Phrasen.`
}
// ─── Komponente ───────────────────────────────────────────────────────────────
export default function AssignmentFeedbackPage() {
const { id, instituteeId } = useParams()
const navigate = useNavigate()
const assignmentId = Number(id)
const instId = Number(instituteeId)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [institutee, setInstitutee] = useState<Consultant | null>(null)
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
const [categories, setCategories] = useState<FeedbackCategory[]>([])
const [items, setItems] = useState<FeedbackCriterionItem[]>([])
const [feedback, setFeedback] = useState<AssignmentFeedback | null>(null)
const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([])
const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([])
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [showAiSettings, setShowAiSettings] = useState(false)
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
const [aiModel, setAiModel] = useState(getAiSettings().model)
const [generating, setGenerating] = useState(false)
const [aiError, setAiError] = useState('')
const saveTimer = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const load = useCallback(async () => {
const [a, cons, dims, cats, itms] = await Promise.all([
db.assignments.get(assignmentId),
db.consultants.get(instId),
db.feedbackDimensions.orderBy('order').toArray(),
db.feedbackCategories.orderBy('order').toArray(),
db.feedbackCriterionItems.orderBy('order').toArray(),
])
setAssignment(a ?? null)
setInstitutee(cons ?? null)
setDimensions(dims)
setCategories(cats)
setItems(itms)
let fb = await db.assignmentFeedbacks
.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)
if (fb?.id) {
const [rs, txts] = await Promise.all([
db.feedbackCategoryRatings.where('assignmentFeedbackId').equals(fb.id).toArray(),
db.feedbackDimensionTexts.where('assignmentFeedbackId').equals(fb.id).toArray(),
])
setRatings(rs)
setDimTexts(txts)
}
// Alle Dimensionen initial expandiert
setExpanded(new Set(dims.map(d => d.id!)))
}, [assignmentId, instId])
useEffect(() => { load() }, [load])
// ── Rating setzen ─────────────────────────────────────────────────────────
const setRating = async (categoryId: number, rating: FeedbackRating) => {
if (!feedback?.id) return
const existing = ratings.find(r => r.feedbackCategoryId === categoryId)
if (existing?.id) {
await db.feedbackCategoryRatings.update(existing.id, { rating })
setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r))
} else {
const newId = await db.feedbackCategoryRatings.add({
assignmentFeedbackId: feedback.id, feedbackCategoryId: categoryId, rating,
})
setRatings(prev => [...prev, { id: newId, assignmentFeedbackId: feedback.id!, feedbackCategoryId: categoryId, rating }])
}
}
const getRating = (categoryId: number): FeedbackRating | undefined =>
ratings.find(r => r.feedbackCategoryId === categoryId)?.rating
// ── Text speichern (debounced) ────────────────────────────────────────────
const saveDimText = (dimId: number, field: 'achievements' | 'developmentNeeds', value: string) => {
setDimTexts(prev => {
const existing = prev.find(t => t.feedbackDimensionId === dimId)
if (existing) return prev.map(t => t.feedbackDimensionId === dimId ? { ...t, [field]: value } : t)
return [...prev, { assignmentFeedbackId: feedback?.id ?? 0, feedbackDimensionId: dimId, achievements: '', developmentNeeds: '', [field]: value }]
})
const key = `${dimId}-${field}`
const prev = saveTimer.current.get(key)
if (prev) clearTimeout(prev)
saveTimer.current.set(key, setTimeout(async () => {
if (!feedback?.id) return
const existing = await db.feedbackDimensionTexts
.where('assignmentFeedbackId').equals(feedback.id)
.filter(t => t.feedbackDimensionId === dimId)
.first()
if (existing?.id) {
await db.feedbackDimensionTexts.update(existing.id, { [field]: value })
} else {
await db.feedbackDimensionTexts.add({
assignmentFeedbackId: feedback.id,
feedbackDimensionId: dimId,
achievements: field === 'achievements' ? value : '',
developmentNeeds: field === 'developmentNeeds' ? value : '',
})
}
}, 600))
}
const getDimText = (dimId: number) =>
dimTexts.find(t => t.feedbackDimensionId === dimId)
// ── KI-Generierung ────────────────────────────────────────────────────────
const generateWithAi = async () => {
if (!institutee || !assignment || !feedback?.id) return
const settings = getAiSettings()
if (!settings.apiKey) { setShowAiSettings(true); return }
setGenerating(true)
setAiError('')
try {
const prompt = await buildAiPrompt(institutee, assignment, dimensions, categories, items, ratings, feedback.id)
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${settings.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'Assignment Monitor',
},
body: JSON.stringify({
model: settings.model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
}),
})
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`)
const data = await res.json()
const text = data.choices?.[0]?.message?.content ?? ''
const parsed = JSON.parse(text) as { dimensions: { name: string; achievements: string; developmentNeeds: string }[] }
// Texte in DB schreiben
for (const item of parsed.dimensions) {
const dim = dimensions.find(d => d.name === item.name || item.name.includes(d.name.split(',')[0]))
if (!dim?.id) continue
const existing = await db.feedbackDimensionTexts
.where('assignmentFeedbackId').equals(feedback.id)
.filter(t => t.feedbackDimensionId === dim.id)
.first()
if (existing?.id) {
await db.feedbackDimensionTexts.update(existing.id, {
achievements: item.achievements,
developmentNeeds: item.developmentNeeds,
})
} else {
await db.feedbackDimensionTexts.add({
assignmentFeedbackId: feedback.id,
feedbackDimensionId: dim.id,
achievements: item.achievements,
developmentNeeds: item.developmentNeeds,
})
}
}
await db.assignmentFeedbacks.update(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() })
await load()
} catch (e) {
setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setGenerating(false)
}
}
const toggleFinal = async () => {
if (!feedback?.id) return
const next = feedback.status === 'final' ? 'draft' : 'final'
await db.assignmentFeedbacks.update(feedback.id, { status: next, updatedAt: new Date().toISOString() })
setFeedback(prev => prev ? { ...prev, status: next } : null)
}
if (!assignment || !institutee) return <div className="text-center py-10 text-gray-400">Lädt</div>
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<button onClick={() => navigate(`/assignment/${assignmentId}`)}
className="text-xs text-[#0070AD] mb-0.5 block"> Assignment</button>
<h1 className="text-xl font-bold text-gray-800">
Feedback: {institutee.firstName} {institutee.lastName}
</h1>
<div className="text-sm text-gray-500">{assignment.title} · {assignment.client}</div>
</div>
<div className="flex flex-col gap-1.5 items-end flex-shrink-0">
<button onClick={toggleFinal}
className={`text-xs px-3 py-1.5 rounded-lg font-medium border ${
feedback?.status === 'final'
? 'bg-green-600 text-white border-green-600'
: 'bg-white text-gray-500 border-gray-200'
}`}>
{feedback?.status === 'final' ? '✓ Final' : 'Entwurf'}
</button>
<button onClick={() => setShowAiSettings(p => !p)}
className="text-xs text-gray-400 hover:text-gray-600">
KI-Einstellungen
</button>
</div>
</div>
{/* KI-Einstellungen */}
{showAiSettings && (
<div className="bg-blue-50 border border-blue-100 rounded-xl p-4 space-y-3">
<div className="text-sm font-semibold text-blue-700">KI-Einstellungen (OpenRouter)</div>
<div className="text-xs text-blue-500">
API-Key von <span className="font-mono">openrouter.ai</span> wird lokal gespeichert, nie übertragen.
</div>
<input
type="password"
className="w-full border border-blue-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD] bg-white"
placeholder="sk-or-..."
value={aiKey}
onChange={e => setAiKey(e.target.value)}
/>
<select
className="w-full border border-blue-200 rounded-lg px-3 py-2 text-sm bg-white"
value={aiModel}
onChange={e => setAiModel(e.target.value)}>
<option value="openai/gpt-4o-mini">GPT-4o mini (günstig)</option>
<option value="openai/gpt-4o">GPT-4o</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="anthropic/claude-haiku-4-5">Claude Haiku 4.5 (schnell)</option>
<option value="google/gemini-flash-1.5">Gemini Flash 1.5</option>
</select>
<div className="flex gap-2">
<button onClick={() => { saveAiSettings(aiKey, aiModel); setShowAiSettings(false) }}
className="flex-1 py-2 rounded-lg text-sm font-medium bg-[#0070AD] text-white">
Speichern
</button>
<button onClick={() => setShowAiSettings(false)}
className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
Abbrechen
</button>
</div>
</div>
)}
{/* KI-Generieren Button */}
<div className="flex items-center gap-3">
<button
onClick={generateWithAi}
disabled={generating}
className={`flex-1 py-3 rounded-xl text-sm font-medium transition ${
generating
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-[#0070AD] text-white hover:bg-[#005a8e]'
}`}>
{generating ? 'Generiere Entwurf…' : '✦ KI-Feedback-Entwurf generieren'}
</button>
</div>
{aiError && (
<div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{aiError}
</div>
)}
{/* Dimensionen */}
{dimensions.map(dim => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
const isOpen = expanded.has(dim.id!)
const text = getDimText(dim.id!)
// Zusammenfassung der Bewertungen
const catRatings = dimCats.map(c => getRating(c.id!)).filter(Boolean) as FeedbackRating[]
const worstRating = catRatings.includes('not_client_ready') ? 'not_client_ready'
: catRatings.includes('partially_client_ready') ? 'partially_client_ready'
: catRatings.includes('nearly_client_ready') ? 'nearly_client_ready'
: catRatings.includes('client_ready') ? 'client_ready'
: catRatings.length > 0 ? 'na' : undefined
const summaryColor = ratingColor(worstRating)
return (
<div key={dim.id} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
{/* Dimension-Header */}
<button
onClick={() => setExpanded(prev => {
const next = new Set(prev)
if (next.has(dim.id!)) next.delete(dim.id!)
else next.add(dim.id!)
return next
})}
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-800 text-sm">{dim.name}</span>
{worstRating && (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${summaryColor}`}>
{RATINGS.find(r => r.value === worstRating)?.short}
</span>
)}
</div>
<span className="text-gray-400 text-sm">{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<div className="border-t border-gray-100 p-4 space-y-4">
{/* Kategorien */}
{dimCats.map(cat => {
const catItems = items.filter(i => i.categoryId === cat.id)
const currentRating = getRating(cat.id!)
return (
<div key={cat.id} className="space-y-2">
<div className="font-medium text-sm text-gray-700">{cat.name}</div>
{/* Rating-Buttons */}
<div className="flex gap-1 flex-wrap">
{RATINGS.map(r => (
<button key={r.value}
onClick={() => setRating(cat.id!, r.value)}
className={`px-2.5 py-1.5 rounded-lg text-xs font-semibold border transition ${
currentRating === r.value
? r.color + ' ring-2 ring-offset-1 ring-current'
: 'bg-gray-50 text-gray-400 border-gray-200 hover:bg-gray-100'
}`}>
{r.short}
</button>
))}
</div>
{/* Kriterium-Items als Referenz */}
{catItems.length > 0 && (
<div className="flex flex-wrap gap-1">
{catItems.map(item => (
<span key={item.id}
className="text-xs bg-gray-50 text-gray-400 border border-gray-100 px-2 py-0.5 rounded-full">
{item.name}
</span>
))}
</div>
)}
</div>
)
})}
{/* Textfelder */}
<div className="space-y-3 pt-2 border-t border-gray-50">
<div>
<label className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-1.5">
Achievements
</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-green-400 min-h-[80px]"
placeholder="Konkrete Stärken und positive Beobachtungen…"
value={text?.achievements ?? ''}
onChange={e => saveDimText(dim.id!, 'achievements', e.target.value)}
/>
</div>
<div>
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5">
Development Needs
</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-amber-400 min-h-[80px]"
placeholder="Entwicklungsfelder und konkrete Handlungsempfehlungen…"
value={text?.developmentNeeds ?? ''}
onChange={e => saveDimText(dim.id!, 'developmentNeeds', e.target.value)}
/>
</div>
</div>
</div>
)}
</div>
)
})}
{/* Hinweis für KI-Nutzung */}
<div className="text-xs text-gray-400 text-center pb-4">
KI-Entwürfe vor dem Einsatz bitte sorgfältig prüfen und anpassen.
</div>
</div>
)
}

View File

@ -0,0 +1,347 @@
import { useEffect, useState } from 'react'
import { db, type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem } from '../db'
export default function AssignmentTypeConfig() {
const [types, setTypes] = useState<AssignmentType[]>([])
const [templates, setTemplates] = useState<PhaseTemplate[]>([])
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
const [categories, setCategories] = useState<FeedbackCategory[]>([])
const [items, setItems] = useState<FeedbackCriterionItem[]>([])
const [expandedDim, setExpandedDim] = useState<Set<number>>(new Set())
const [expanded, setExpanded] = useState<number | null>(null)
const [activeTab, setActiveTab] = useState<Record<number, 'phases' | 'criteria'>>({})
const [newTypeName, setNewTypeName] = useState('')
const [typeError, setTypeError] = useState<Record<number, string>>({})
const [newPhase, setNewPhase] = useState<Record<number, {
label: string; withWhom: 'lead' | 'client'
isRepeatable: boolean; hasConversation: boolean; hasAssessment: boolean
}>>({})
const load = async () => {
const [t, tmpl, dims, cats, itms] = await Promise.all([
db.assignmentTypes.toArray(),
db.phaseTemplates.toArray(),
db.feedbackDimensions.orderBy('order').toArray(),
db.feedbackCategories.orderBy('order').toArray(),
db.feedbackCriterionItems.orderBy('order').toArray(),
])
setTypes(t)
setTemplates(tmpl)
setDimensions(dims)
setCategories(cats)
setItems(itms)
}
useEffect(() => { load() }, [])
// ── Assignment-Typen ───────────────────────────────────────────────────────
const addType = async () => {
const name = newTypeName.trim()
if (!name) return
const id = await db.assignmentTypes.add({ name, defaultPhaseKeys: [], criteriaIds: [] })
setNewTypeName('')
await load()
setExpanded(id)
}
const updateTypeName = async (id: number, name: string) => {
await db.assignmentTypes.update(id, { name })
setTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t))
}
const deleteType = async (id: number) => {
const count = await db.assignments.where('assignmentTypeId').equals(id).count()
if (count > 0) {
setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden — ${count} Assignment(s) verwenden diesen Typ.` }))
return
}
await db.phaseTemplates.where('assignmentTypeId').equals(id).delete()
await db.assignmentTypes.delete(id)
await load()
}
// ── Kriterien (criteriaIds auf AssignmentType = FeedbackCriterionItem IDs) ──
const toggleItem = async (type: AssignmentType, itemId: number) => {
const current = type.criteriaIds ?? []
const next = current.includes(itemId) ? current.filter(id => id !== itemId) : [...current, itemId]
await db.assignmentTypes.update(type.id!, { criteriaIds: next })
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
}
const toggleAllInCategory = async (type: AssignmentType, catId: number) => {
const catItemIds = items.filter(i => i.categoryId === catId).map(i => i.id!)
const current = type.criteriaIds ?? []
const allSelected = catItemIds.every(id => current.includes(id))
const next = allSelected
? current.filter(id => !catItemIds.includes(id))
: [...new Set([...current, ...catItemIds])]
await db.assignmentTypes.update(type.id!, { criteriaIds: next })
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
}
// ── Phasen-Templates ───────────────────────────────────────────────────────
const addPhase = async (typeId: number) => {
const np = newPhase[typeId]
if (!np?.label.trim()) return
const existing = templates.filter(t => t.assignmentTypeId === typeId)
await db.phaseTemplates.add({
assignmentTypeId: typeId,
phaseKey: np.label.trim().toLowerCase().replace(/\s+/g, '_'),
label: np.label.trim(),
withWhom: np.withWhom,
isRepeatable: np.isRepeatable,
hasConversation: np.hasConversation,
hasAssessment: np.hasAssessment,
criteriaIds: [],
order: existing.length + 1,
})
setNewPhase(prev => ({ ...prev, [typeId]: { label: '', withWhom: 'lead', isRepeatable: false, hasConversation: false, hasAssessment: false } }))
await load()
}
const updatePhase = async (id: number, patch: Partial<PhaseTemplate>) => {
await db.phaseTemplates.update(id, patch)
setTemplates(prev => prev.map(t => t.id === id ? { ...t, ...patch } : t))
}
const deletePhase = async (id: number) => {
await db.phaseTemplates.delete(id)
await load()
}
return (
<div className="space-y-4">
{/* Neuer Typ */}
<div className="flex gap-2">
<input
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
placeholder="Neuer Assignment-Typ…"
value={newTypeName}
onChange={e => setNewTypeName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addType()}
/>
<button onClick={addType}
className="px-4 py-2 bg-[#0070AD] text-white text-sm rounded-lg font-medium">
+ Typ
</button>
</div>
{types.length === 0 && (
<div className="text-center py-8 text-gray-400 text-sm">Noch keine Assignment-Typen definiert.</div>
)}
{types.map(type => {
const isOpen = expanded === type.id
const typPhases = templates.filter(t => t.assignmentTypeId === type.id).sort((a, b) => a.order - b.order)
const tab = activeTab[type.id!] ?? 'phases'
const np = newPhase[type.id!] ?? { label: '', withWhom: 'lead' as const, isRepeatable: false, hasConversation: false, hasAssessment: false }
const selected = type.criteriaIds ?? []
return (
<div key={type.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 bg-gray-50 border-b border-gray-100">
<input
className="flex-1 bg-transparent text-sm font-semibold text-gray-800 focus:outline-none border-b border-transparent focus:border-[#0070AD] py-0.5"
value={type.name}
onChange={e => updateTypeName(type.id!, e.target.value)}
/>
<span className="text-xs text-gray-400">{typPhases.length} Phasen · {selected.length ? `${selected.length} Kriterien` : 'alle Kriterien'}</span>
<button onClick={() => setExpanded(isOpen ? null : type.id!)}
className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
<button onClick={() => deleteType(type.id!)}
className="text-xs text-red-300 hover:text-red-500 px-1">🗑</button>
</div>
{typeError[type.id!] && (
<div className="px-4 py-2 text-xs text-red-600 bg-red-50 border-b border-red-100">
{typeError[type.id!]}
</div>
)}
{isOpen && (
<div>
{/* Sub-Tab */}
<div className="flex border-b border-gray-100">
{(['phases', 'criteria'] as const).map(t => (
<button key={t}
onClick={() => setActiveTab(prev => ({ ...prev, [type.id!]: t }))}
className={`flex-1 py-2 text-xs font-medium transition ${
tab === t ? 'text-[#0070AD] border-b-2 border-[#0070AD]' : 'text-gray-400'
}`}>
{t === 'phases' ? `Phasen (${typPhases.length})` : `Kriterien (${selected.length || 'alle'})`}
</button>
))}
</div>
{/* Phasen-Tab */}
{tab === 'phases' && (
<div className="p-3 space-y-2">
{typPhases.map(phase => (
<div key={phase.id} className="border border-gray-100 rounded-lg px-3 py-2 space-y-1.5">
<div className="flex items-center gap-2">
<input
className="flex-1 text-sm text-gray-700 border-b border-transparent focus:border-[#0070AD] focus:outline-none bg-transparent"
value={phase.label}
onChange={e => updatePhase(phase.id!, { label: e.target.value })}
/>
<select
value={phase.withWhom}
onChange={e => updatePhase(phase.id!, { withWhom: e.target.value as 'lead' | 'client' })}
className="text-xs border border-gray-200 rounded px-1.5 py-1 focus:outline-none focus:border-[#0070AD]">
<option value="lead">Lead</option>
<option value="client">Kunde</option>
</select>
<button onClick={() => deletePhase(phase.id!)}
className="text-red-300 hover:text-red-500 text-sm">×</button>
</div>
<div className="flex gap-4 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={phase.isRepeatable}
onChange={e => updatePhase(phase.id!, { isRepeatable: e.target.checked })} />
Wiederholbar
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={phase.hasConversation}
onChange={e => updatePhase(phase.id!, { hasConversation: e.target.checked })} />
Protokoll
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={phase.hasAssessment}
onChange={e => updatePhase(phase.id!, { hasAssessment: e.target.checked })} />
Bewertung
</label>
</div>
</div>
))}
{/* Neue Phase */}
<div className="border border-dashed border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
<div className="text-xs font-medium text-gray-500">Neue Phase</div>
<div className="flex gap-2">
<input
className="flex-1 border border-gray-200 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-[#0070AD] bg-white"
placeholder="Phasenname…"
value={np.label}
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, label: e.target.value } }))}
onKeyDown={e => e.key === 'Enter' && addPhase(type.id!)}
/>
<select
value={np.withWhom}
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, withWhom: e.target.value as 'lead' | 'client' } }))}
className="border border-gray-200 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-[#0070AD] bg-white">
<option value="lead">Lead</option>
<option value="client">Kunde</option>
</select>
</div>
<div className="flex gap-4 text-xs text-gray-600">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={np.isRepeatable}
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, isRepeatable: e.target.checked } }))} />
Wiederholbar
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={np.hasConversation}
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, hasConversation: e.target.checked } }))} />
Protokoll
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={np.hasAssessment}
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, hasAssessment: e.target.checked } }))} />
Bewertung
</label>
</div>
<button onClick={() => addPhase(type.id!)}
className="px-4 py-1.5 bg-[#0070AD] text-white text-xs rounded-lg font-medium">
+ Hinzufügen
</button>
</div>
</div>
)}
{/* Kriterien-Tab */}
{tab === 'criteria' && (
<div className="p-3 space-y-1">
<p className="text-xs text-gray-400 pb-2">
Kein Haken = alle Kriterien werden angezeigt.
</p>
{items.length === 0 && (
<div className="text-xs text-gray-400">Keine Kriterien konfiguriert. Erst unter "Feedback" anlegen.</div>
)}
{dimensions.map(dim => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
const dimItems = items.filter(i => dimCats.some(c => c.id === i.categoryId))
if (dimItems.length === 0) return null
const isDimOpen = expandedDim.has(dim.id!)
const dimSelectedCount = dimItems.filter(i => selected.includes(i.id!)).length
return (
<div key={dim.id} className="border border-gray-100 rounded-lg overflow-hidden">
<button
onClick={() => setExpandedDim(prev => {
const next = new Set(prev)
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
return next
})}
className="w-full flex items-center justify-between px-3 py-2 bg-gray-50 hover:bg-gray-100 text-left">
<span className="text-xs font-semibold text-gray-700">{dim.name}</span>
<span className="text-xs text-gray-400">
{dimSelectedCount > 0 ? `${dimSelectedCount}/${dimItems.length}` : 'alle'} · {isDimOpen ? '▲' : '▼'}
</span>
</button>
{isDimOpen && (
<div className="divide-y divide-gray-50">
{dimCats.map(cat => {
const catItems = items.filter(i => i.categoryId === cat.id)
if (catItems.length === 0) return null
const allCatSelected = catItems.every(i => selected.includes(i.id!))
const someCatSelected = catItems.some(i => selected.includes(i.id!))
return (
<div key={cat.id} className="px-3 py-2 space-y-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={allCatSelected}
ref={el => { if (el) el.indeterminate = someCatSelected && !allCatSelected }}
onChange={() => toggleAllInCategory(type, cat.id!)}
className="rounded border-gray-300"
/>
<span className="text-xs font-semibold text-gray-600">{cat.name}</span>
</label>
<div className="pl-5 space-y-1">
{catItems.map(item => (
<label key={item.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selected.includes(item.id!)}
onChange={() => toggleItem(type, item.id!)}
className="rounded border-gray-300"
/>
<span className="text-xs text-gray-600">{item.name}</span>
{(item.weight ?? 1) > 1 && (
<span className="text-xs text-gray-300">×{item.weight}</span>
)}
</label>
))}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
})}
</div>
)
}

140
src/pages/Configuration.tsx Normal file
View File

@ -0,0 +1,140 @@
import { useRef, useState } from 'react'
import FeedbackStructureConfig from './FeedbackStructureConfig'
import AssignmentTypeConfig from './AssignmentTypeConfig'
import { exportFullBackup, importFullBackup } from '../utils/dbBackup'
export default function Configuration() {
const [tab, setTab] = useState<'feedback' | 'types' | 'backup'>('feedback')
const [backupStatus, setBackupStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
const [restoreConfirm, setRestoreConfirm] = useState(false)
const [importing, setImporting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleExport = async () => {
try {
await exportFullBackup()
setBackupStatus({ type: 'success', msg: 'Backup erfolgreich heruntergeladen.' })
} catch (e) {
setBackupStatus({ type: 'error', msg: `Fehler: ${e instanceof Error ? e.message : String(e)}` })
}
setTimeout(() => setBackupStatus(null), 4000)
}
const handleImportFile = async (file: File) => {
setImporting(true)
setRestoreConfirm(false)
const result = await importFullBackup(file)
setImporting(false)
if (result.ok) {
setBackupStatus({ type: 'success', msg: 'Datenbank erfolgreich wiederhergestellt. Seite wird neu geladen…' })
setTimeout(() => window.location.reload(), 1500)
} else {
setBackupStatus({ type: 'error', msg: `Fehler beim Import: ${result.error}` })
}
setTimeout(() => setBackupStatus(null), 6000)
}
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">Konfiguration</h1>
{/* Tab-Switcher */}
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5">
<button onClick={() => setTab('feedback')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'feedback' ? 'bg-white text-[#0070AD] shadow-sm' : 'text-gray-500'}`}>
Feedback
</button>
<button onClick={() => setTab('types')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'types' ? 'bg-white text-[#0070AD] shadow-sm' : 'text-gray-500'}`}>
Ass.-Typen
</button>
<button onClick={() => setTab('backup')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'backup' ? 'bg-white text-[#0070AD] shadow-sm' : 'text-gray-500'}`}>
Backup
</button>
</div>
{/* Status-Banner */}
{backupStatus && (
<div className={`rounded-xl px-4 py-3 text-sm font-medium ${
backupStatus.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
}`}>
{backupStatus.msg}
</div>
)}
{tab === 'feedback' && <FeedbackStructureConfig />}
{tab === 'types' && <AssignmentTypeConfig />}
{tab === 'backup' && (
<div className="space-y-4">
{/* Export */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-3">
<h2 className="font-semibold text-gray-800">Datenbank exportieren</h2>
<p className="text-sm text-gray-500">
Exportiert alle Daten (Assignments, Meetings, Protokolle, Bewertungen, Konfiguration) als eine JSON-Datei.
Diese Datei kannst du sicher aufbewahren und bei Bedarf wiederherstellen.
</p>
<button onClick={handleExport}
className="w-full py-3 bg-[#0070AD] text-white rounded-xl text-sm font-semibold">
Vollständiges Backup herunterladen
</button>
</div>
{/* Import */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-3">
<h2 className="font-semibold text-gray-800">Datenbank wiederherstellen</h2>
<p className="text-sm text-gray-500">
Stellt alle Daten aus einer zuvor exportierten Backup-Datei wieder her.
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg px-3 py-2.5 text-xs text-amber-700">
Alle aktuellen Daten werden dabei überschrieben. Erstelle vorher ein Backup.
</div>
{!restoreConfirm ? (
<button onClick={() => setRestoreConfirm(true)}
className="w-full py-3 border-2 border-red-200 text-red-600 rounded-xl text-sm font-semibold hover:bg-red-50 transition">
Backup wiederherstellen
</button>
) : (
<div className="space-y-2">
<p className="text-sm font-medium text-red-700">Backup-Datei auswählen um zu bestätigen:</p>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (file) handleImportFile(file)
e.target.value = ''
}}
/>
<div className="flex gap-2">
<button onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="flex-1 py-2.5 bg-red-600 text-white rounded-xl text-sm font-semibold disabled:opacity-50">
{importing ? 'Wird importiert…' : 'Datei auswählen & importieren'}
</button>
<button onClick={() => setRestoreConfirm(false)}
className="px-4 py-2.5 border border-gray-200 text-gray-500 rounded-xl text-sm">
Abbrechen
</button>
</div>
</div>
)}
</div>
{/* Info */}
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-1.5">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Hinweise</div>
<div className="text-xs text-gray-500"> Backup-Dateien regelmäßig (z.B. nach jedem Meeting) erstellen</div>
<div className="text-xs text-gray-500"> Dateien an einem sicheren Ort speichern (OneDrive, lokaler Ordner)</div>
<div className="text-xs text-gray-500"> Backup enthält alle personenbezogenen Daten vertraulich behandeln</div>
<div className="text-xs text-gray-500"> Dateiname: <code className="bg-gray-100 px-1 rounded">assignment-monitor-backup-TT-MM-JJJJ.json</code></div>
</div>
</div>
)}
</div>
)
}

90
src/pages/Consultants.tsx Normal file
View File

@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { db, type Consultant, type Group } from '../db'
export default function Consultants() {
const [groups, setGroups] = useState<Group[]>([])
const [consultants, setConsultants] = useState<Consultant[]>([])
const [newGroup, setNewGroup] = useState({ name: '', cohortYear: new Date().getFullYear() })
const [newCons, setNewCons] = useState({ groupId: 0, firstName: '', lastName: '' })
const load = async () => {
setGroups(await db.groups.toArray())
setConsultants(await db.consultants.toArray())
}
useEffect(() => { load() }, [])
const addGroup = async () => {
if (!newGroup.name.trim()) return
await db.groups.add({ ...newGroup })
setNewGroup({ name: '', cohortYear: new Date().getFullYear() })
load()
}
const addConsultant = async () => {
if (!newCons.firstName.trim() || !newCons.lastName.trim() || !newCons.groupId) return
await db.consultants.add({ ...newCons })
setNewCons(p => ({ ...p, firstName: '', lastName: '' }))
load()
}
const deleteConsultant = async (id: number) => {
await db.consultants.delete(id)
load()
}
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-gray-800">Berater</h1>
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
<h2 className="font-semibold text-gray-700">Neue Gruppe / Kohorte</h2>
<div className="flex gap-2">
<input
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm"
placeholder="Gruppenname (z.B. MC Kohorte 2025)"
value={newGroup.name}
onChange={e => setNewGroup(p => ({ ...p, name: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && addGroup()}
/>
<button onClick={addGroup} className="bg-[#0070AD] text-white px-4 py-2 rounded-lg text-sm">+</button>
</div>
</section>
<section className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
<h2 className="font-semibold text-gray-700">Neuer Berater</h2>
<select
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
value={newCons.groupId}
onChange={e => setNewCons(p => ({ ...p, groupId: +e.target.value }))}
>
<option value={0}>Gruppe wählen</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
<div className="flex gap-2">
<input className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm" placeholder="Vorname"
value={newCons.firstName} onChange={e => setNewCons(p => ({ ...p, firstName: e.target.value }))} />
<input className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm" placeholder="Nachname"
value={newCons.lastName} onChange={e => setNewCons(p => ({ ...p, lastName: e.target.value }))} />
</div>
<button onClick={addConsultant} className="w-full bg-[#0070AD] text-white py-2 rounded-lg text-sm font-medium">Berater hinzufügen</button>
</section>
{groups.map(g => {
const members = consultants.filter(c => c.groupId === g.id)
if (members.length === 0) return null
return (
<div key={g.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<h3 className="font-semibold text-gray-700 mb-2">{g.name}</h3>
{members.map(c => (
<div key={c.id} className="flex items-center justify-between py-1 text-sm text-gray-700 border-b border-gray-50 last:border-0">
<span>{c.firstName} {c.lastName}</span>
<button onClick={() => deleteConsultant(c.id!)} className="text-red-400 text-xs hover:text-red-600">×</button>
</div>
))}
</div>
)
})}
</div>
)
}

119
src/pages/Dashboard.tsx Normal file
View File

@ -0,0 +1,119 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { db, type Assignment, type AssignmentType, type Consultant } from '../db'
interface AssignmentRow extends Assignment {
typeName: string
instituteeNames: string[]
}
export default function Dashboard() {
const [assignments, setAssignments] = useState<AssignmentRow[]>([])
const [query, setQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'done'>('all')
const navigate = useNavigate()
useEffect(() => {
const load = async () => {
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 q = query.trim().toLowerCase()
return assignments.filter(a => {
if (statusFilter !== 'all' && a.status !== statusFilter) return false
if (!q) return true
return (
a.title.toLowerCase().includes(q) ||
(a.assignmentNumber ?? '').toLowerCase().includes(q) ||
a.client.toLowerCase().includes(q) ||
a.instituteeNames.some(n => n.toLowerCase().includes(q))
)
})
}, [assignments, query, statusFilter])
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800">Assignments</h1>
<button
onClick={() => navigate('/assignment/new')}
className="bg-[#0070AD] text-white px-4 py-2 rounded-lg text-sm font-medium"
>
+ Neues
</button>
</div>
{/* Suche + Status-Filter */}
<div className="space-y-2">
<input
type="search"
placeholder="Suche nach Nummer, Titel, Kunde, Berater…"
value={query}
onChange={e => setQuery(e.target.value)}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD] bg-white"
/>
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5">
{([['all', 'Alle'], ['active', 'Aktiv'], ['done', 'Abgeschlossen']] as const).map(([val, label]) => (
<button key={val} onClick={() => setStatusFilter(val)}
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition ${
statusFilter === val ? 'bg-white text-[#0070AD] shadow-sm' : 'text-gray-500'
}`}>
{label}
</button>
))}
</div>
</div>
{/* Liste */}
{filtered.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">
{assignments.length === 0 ? 'Noch keine Assignments angelegt.' : 'Keine Treffer.'}
</p>
)}
{filtered.map(a => (
<div
key={a.id}
onClick={() => navigate(`/assignment/${a.id}`)}
className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 cursor-pointer hover:shadow-md transition"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{a.assignmentNumber && (
<div className="text-xs font-semibold text-[#0070AD] uppercase tracking-wide leading-none mb-0.5">
{a.assignmentNumber}
</div>
)}
<div className="font-semibold text-gray-800 truncate">{a.title}</div>
<div className="text-sm text-gray-500">{a.client}</div>
<div className="text-xs text-gray-400 mt-0.5">
{a.typeName} · {a.instituteeNames.join(', ')}
</div>
</div>
<span className={`flex-shrink-0 text-xs px-2 py-1 rounded-full font-medium ${
a.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}>
{a.status === 'active' ? 'Aktiv' : 'Fertig'}
</span>
</div>
</div>
))}
</div>
)
}

136
src/pages/Evaluation.tsx Normal file
View File

@ -0,0 +1,136 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { db, type Assignment, type Consultant, type Category, type Criterion, type FeedbackRating } from '../db'
const ratingToNum = (r: FeedbackRating | null | undefined): number => {
const m: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
return r ? (m[r] ?? 0) : 0
}
interface InstScore {
institutee: Consultant
assignment: Assignment
scores: { criterion: Criterion; avg: number | null }[]
weightedTotal: number | null
}
export default function Evaluation() {
const [data, setData] = useState<InstScore[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [selected, setSelected] = useState<string | null>(null)
const navigate = useNavigate()
useEffect(() => {
const load = async () => {
const [assignments, consultants, criteria, cats, assessments] = await Promise.all([
db.assignments.toArray(),
db.consultants.toArray(),
db.criteria.orderBy('order').toArray(),
db.categories.orderBy('order').toArray(),
db.assessments.toArray(),
])
setCategories(cats)
const rows: InstScore[] = []
for (const a of assignments) {
for (const instId of a.instituteeIds) {
const inst = consultants.find(c => c.id === instId)
if (!inst) continue
// Nur Meetings dieses Assignments
const meetingIds = (await db.meetingInstances.where('assignmentId').equals(a.id!).toArray()).map(m => m.id!)
const instAssessments = assessments.filter(
as => as.instituteeId === instId && meetingIds.includes(as.meetingInstanceId)
)
if (instAssessments.length === 0) continue
const usedCritIds = [...new Set(instAssessments.map(a => a.criteriaId))]
const usedCrit = criteria.filter(c => usedCritIds.includes(c.id!))
const scores = usedCrit.map(crit => {
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null)
const avg = relevant.length > 0
? relevant.reduce((s, a) => s + ratingToNum(a.score), 0) / relevant.length
: null
return { criterion: crit, avg }
})
const rated = scores.filter(s => s.avg !== null)
const weightedTotal = rated.length > 0
? rated.reduce((s, x) => s + x.avg! * x.criterion.weight, 0) /
rated.reduce((s, x) => s + x.criterion.weight, 0)
: null
rows.push({ institutee: inst, assignment: a, scores, weightedTotal })
}
}
setData(rows)
}
load()
}, [])
const scoreColor = (avg: number | null) => {
if (avg === null) return 'bg-gray-100 text-gray-400'
if (avg >= 4) return 'bg-green-100 text-green-700'
if (avg >= 3) return 'bg-yellow-100 text-yellow-700'
return 'bg-red-100 text-red-700'
}
const rowKey = (r: InstScore) => `${r.assignment.id}-${r.institutee.id}`
return (
<div className="space-y-4">
<h1 className="text-xl font-bold text-gray-800">Auswertung</h1>
{data.length === 0 && (
<p className="text-gray-500 text-sm">Noch keine Bewertungen vorhanden.</p>
)}
{data.map(row => (
<div key={rowKey(row)} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div
className="flex items-start justify-between cursor-pointer"
onClick={() => setSelected(selected === rowKey(row) ? null : rowKey(row))}
>
<div>
<div className="font-semibold text-gray-800">
{row.institutee.firstName} {row.institutee.lastName}
</div>
<div className="text-xs text-gray-400">{row.assignment.title} · {row.assignment.client}</div>
</div>
<div className="flex items-center gap-2">
<div className={`px-3 py-1 rounded-full text-sm font-bold ${scoreColor(row.weightedTotal)}`}>
{row.weightedTotal !== null ? row.weightedTotal.toFixed(1) : '—'} / 5
</div>
<button
onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }}
className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-lg">
Feedback
</button>
</div>
</div>
{selected === rowKey(row) && (
<div className="mt-4 space-y-3">
{categories.map(cat => {
const catScores = row.scores.filter(s => s.criterion.categoryId === cat.id)
if (catScores.every(s => s.avg === null)) return null
return (
<div key={cat.id}>
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">{cat.name}</div>
{catScores.map(({ criterion, avg }) => (
<div key={criterion.id} className="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0">
<span className="text-sm text-gray-700">{criterion.name}</span>
<span className={`text-sm font-semibold px-2 py-0.5 rounded min-w-[36px] text-center ${scoreColor(avg)}`}>
{avg !== null ? avg.toFixed(1) : '—'}
</span>
</div>
))}
</div>
)
})}
</div>
)}
</div>
))}
</div>
)
}

View File

@ -0,0 +1,213 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
db,
type Assignment,
type Consultant,
type Category,
type Criterion,
type FeedbackDraft,
type FeedbackRating,
} from '../db'
const ratingToNum = (r: FeedbackRating | null | undefined): number => {
const m: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
return r ? (m[r] ?? 0) : 0
}
interface CriterionSummary {
criterion: Criterion
avg: number | null
notes: string[]
}
export default function FeedbackDraftPage() {
const { id, instituteeId } = useParams()
const navigate = useNavigate()
const assignmentId = Number(id)
const instId = Number(instituteeId)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [institutee, setInstitutee] = useState<Consultant | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [summary, setSummary] = useState<CriterionSummary[]>([])
const [draft, setDraft] = useState<FeedbackDraft | null>(null)
const [content, setContent] = useState('')
const [generated, setGenerated] = useState(false)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const load = useCallback(async () => {
const [a, cons, cats] = await Promise.all([
db.assignments.get(assignmentId),
db.consultants.get(instId),
db.categories.orderBy('order').toArray(),
])
setAssignment(a ?? null)
setInstitutee(cons ?? null)
setCategories(cats)
const meetings = await db.meetingInstances.where('assignmentId').equals(assignmentId).toArray()
const meetingIds = meetings.map(m => m.id!)
const [allAssessments, allEntries] = await Promise.all([
db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray(),
db.conversationEntries.where('meetingInstanceId').anyOf(meetingIds).toArray(),
])
const instAssessments = allAssessments.filter(a => a.instituteeId === instId)
const instEntries = allEntries.filter(e => e.instituteeId === instId)
const criteriaIds = [...new Set(instAssessments.map(a => a.criteriaId))]
const crits = criteriaIds.length > 0
? await db.criteria.where('id').anyOf(criteriaIds).toArray()
: []
const sum: CriterionSummary[] = crits.map(crit => {
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null)
const avg = relevant.length > 0
? relevant.reduce((s, a) => s + ratingToNum(a.score), 0) / relevant.length
: null
const notes = [
...instAssessments.filter(a => a.criteriaId === crit.id && a.note).map(a => a.note),
...instEntries.filter(e => e.note).map(e => e.note),
].filter(Boolean)
return { criterion: crit, avg, notes }
})
setSummary(sum)
const existing = await db.feedbackDrafts
.where('assignmentId').equals(assignmentId)
.filter(d => d.instituteeId === instId)
.first()
if (existing) {
setDraft(existing)
setContent(existing.content)
setGenerated(true)
}
}, [assignmentId, instId])
useEffect(() => { load() }, [load])
const generateDraft = async () => {
if (!institutee || !assignment) return
const catMap = new Map(categories.map(c => [c.id!, c.name]))
let text = `Feedback: ${institutee.firstName} ${institutee.lastName}\n`
text += `Assignment: ${assignment.title} | Kunde: ${assignment.client}\n\n`
const grouped = new Map<number, CriterionSummary[]>()
for (const s of summary) {
const cid = s.criterion.categoryId
if (!grouped.has(cid)) grouped.set(cid, [])
grouped.get(cid)!.push(s)
}
for (const [catId, items] of grouped) {
text += `## ${catMap.get(catId) ?? 'Sonstige'}\n`
for (const item of items) {
const avgStr = item.avg !== null ? item.avg.toFixed(1) : '—'
text += `${item.criterion.name}: ${avgStr}/5\n`
if (item.notes.length > 0) {
const uniqueNotes = [...new Set(item.notes)]
text += uniqueNotes.map(n => `${n}`).join('\n') + '\n'
}
}
text += '\n'
}
text += '---\nZusätzliche Anmerkungen:\n'
setContent(text)
setGenerated(true)
const now = new Date().toISOString()
if (draft?.id) {
await db.feedbackDrafts.update(draft.id, { content: text, updatedAt: now })
setDraft(prev => prev ? { ...prev, content: text, updatedAt: now } : null)
} else {
const newId = await db.feedbackDrafts.add({
assignmentId, instituteeId: instId, content: text, updatedAt: now,
})
setDraft({ id: newId, assignmentId, instituteeId: instId, content: text, updatedAt: now })
}
}
const saveContent = (val: string) => {
setContent(val)
if (saveTimer.current) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(async () => {
const now = new Date().toISOString()
if (draft?.id) {
await db.feedbackDrafts.update(draft.id, { content: val, updatedAt: now })
} else {
const newId = await db.feedbackDrafts.add({
assignmentId, instituteeId: instId, content: val, updatedAt: now,
})
setDraft({ id: newId, assignmentId, instituteeId: instId, content: val, updatedAt: now })
}
}, 600)
}
const scoreColor = (avg: number | null) => {
if (avg === null) return 'bg-gray-100 text-gray-400'
if (avg >= 4) return 'bg-green-100 text-green-700'
if (avg >= 3) return 'bg-yellow-100 text-yellow-700'
return 'bg-red-100 text-red-700'
}
if (!assignment || !institutee) return <div className="text-center py-10 text-gray-400">Lädt</div>
return (
<div className="space-y-4">
<div>
<button onClick={() => navigate(`/assignment/${assignmentId}`)}
className="text-sm text-[#0070AD] mb-2 block">
Assignment
</button>
<h1 className="text-xl font-bold text-gray-800">
Feedback: {institutee.firstName} {institutee.lastName}
</h1>
<div className="text-sm text-gray-500">{assignment.title} · {assignment.client}</div>
</div>
{/* Bewertungsübersicht */}
{summary.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-1">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">
Bewertungsübersicht (alle Meetings)
</div>
{summary.map(s => (
<div key={s.criterion.id}
className="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0">
<span className="text-sm text-gray-700">{s.criterion.name}</span>
<span className={`text-sm font-semibold px-2 py-0.5 rounded min-w-[40px] text-center ${scoreColor(s.avg)}`}>
{s.avg !== null ? s.avg.toFixed(1) : '—'}
</span>
</div>
))}
</div>
)}
{/* Feedback-Entwurf */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide">Feedback-Entwurf</div>
<button onClick={generateDraft}
className="text-xs bg-[#0070AD] text-white px-3 py-1.5 rounded-lg font-medium">
{generated ? 'Neu generieren' : 'Automatisch generieren'}
</button>
</div>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:border-[#0070AD]"
rows={18}
placeholder="Tippe deinen Feedback-Entwurf hier, oder nutze 'Automatisch generieren'…"
value={content}
onChange={e => saveContent(e.target.value)}
/>
{draft && (
<div className="text-xs text-gray-400">
Gespeichert: {new Date(draft.updatedAt).toLocaleString('de-DE')}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,324 @@
import { useEffect, useState } from 'react'
import { db, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem, type AssignmentType, type PhaseTemplate } from '../db'
export default function FeedbackStructureConfig() {
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
const [categories, setCategories] = useState<FeedbackCategory[]>([])
const [items, setItems] = useState<FeedbackCriterionItem[]>([])
const [assignmentTypes, setAssignmentTypes] = useState<AssignmentType[]>([])
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [expandedCat, setExpandedCat] = useState<Set<number>>(new Set())
const [showCatalogs, setShowCatalogs] = useState(false)
const [phaseTemplates, setPhaseTemplates] = useState<PhaseTemplate[]>([])
const [showTypeConfig, setShowTypeConfig] = useState(false)
const [expandedType, setExpandedType] = useState<number | null>(null)
const [newTypeName, setNewTypeName] = useState('')
const [newPhase, setNewPhase] = useState<Record<number, { label: string; withWhom: 'lead'|'client'; isRepeatable: boolean; hasConversation: boolean; hasAssessment: boolean }>>({})
const [typeError, setTypeError] = useState<Record<number, string>>({})
// Neue Einträge
const [newDimName, setNewDimName] = useState('')
const [newCatName, setNewCatName] = useState<Record<number, string>>({})
const [newItemName, setNewItemName] = useState<Record<number, string>>({})
const load = async () => {
const [dims, cats, itms, types, phases] = await Promise.all([
db.feedbackDimensions.orderBy('order').toArray(),
db.feedbackCategories.orderBy('order').toArray(),
db.feedbackCriterionItems.orderBy('order').toArray(),
db.assignmentTypes.toArray(),
db.phaseTemplates.toArray(),
])
setDimensions(dims)
setCategories(cats)
setItems(itms)
setAssignmentTypes(types)
setPhaseTemplates(phases)
}
useEffect(() => { load() }, [])
// ── Dimensionen ───────────────────────────────────────────────────────────
const addDimension = async () => {
if (!newDimName.trim()) return
const maxOrder = dimensions.reduce((m, d) => Math.max(m, d.order), 0)
const id = await db.feedbackDimensions.add({ name: newDimName.trim(), order: maxOrder + 1 })
setNewDimName('')
await load()
setExpanded(prev => new Set([...prev, id]))
}
const updateDimension = async (id: number, name: string) => {
await db.feedbackDimensions.update(id, { name })
setDimensions(prev => prev.map(d => d.id === id ? { ...d, name } : d))
}
const deleteDimension = async (id: number) => {
const cats = await db.feedbackCategories.where('dimensionId').equals(id).toArray()
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()
}
const moveDim = async (dim: FeedbackDimension, dir: -1 | 1) => {
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
const idx = sorted.findIndex(d => d.id === dim.id)
const other = sorted[idx + dir]
if (!other) return
await db.feedbackDimensions.update(dim.id!, { order: other.order })
await db.feedbackDimensions.update(other.id!, { order: dim.order })
await load()
}
// ── Kategorien ────────────────────────────────────────────────────────────
const addCategory = async (dimId: number) => {
const name = newCatName[dimId]?.trim()
if (!name) return
const dimCats = categories.filter(c => c.dimensionId === dimId)
const maxOrder = dimCats.reduce((m, c) => Math.max(m, c.order), 0)
const id = await db.feedbackCategories.add({ dimensionId: dimId, name, order: maxOrder + 1 })
setNewCatName(prev => ({ ...prev, [dimId]: '' }))
await load()
setExpandedCat(prev => new Set([...prev, id]))
}
const updateCategory = async (id: number, name: string) => {
await db.feedbackCategories.update(id, { name })
setCategories(prev => prev.map(c => c.id === id ? { ...c, name } : c))
}
const deleteCategory = async (id: number) => {
await db.feedbackCriterionItems.where('categoryId').equals(id).delete()
await db.feedbackCategories.delete(id)
await load()
}
// ── Kriterium-Items ───────────────────────────────────────────────────────
const addItem = async (catId: number) => {
const name = newItemName[catId]?.trim()
if (!name) return
const catItems = items.filter(i => i.categoryId === catId)
const maxOrder = catItems.reduce((m, i) => Math.max(m, i.order), 0)
await db.feedbackCriterionItems.add({ categoryId: catId, name, order: maxOrder + 1 })
setNewItemName(prev => ({ ...prev, [catId]: '' }))
await load()
}
const updateItem = async (id: number, name: string) => {
await db.feedbackCriterionItems.update(id, { name })
setItems(prev => prev.map(i => i.id === id ? { ...i, name } : i))
}
const deleteItem = async (id: number) => {
await db.feedbackCriterionItems.delete(id)
setItems(prev => prev.filter(i => i.id !== id))
}
const updateItemWeight = async (id: number, weight: number) => {
await db.feedbackCriterionItems.update(id, { weight })
setItems(prev => prev.map(i => i.id === id ? { ...i, weight } : i))
}
const toggleCatalogCategory = async (type: AssignmentType, catId: number) => {
const current = type.criteriaIds ?? []
const next = current.includes(catId) ? current.filter(id => id !== catId) : [...current, catId]
await db.assignmentTypes.update(type.id!, { criteriaIds: next })
setAssignmentTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
}
// ── AssignmentType CRUD ───────────────────────────────────────────────────
const addAssignmentType = async () => {
if (!newTypeName.trim()) return
await db.assignmentTypes.add({ name: newTypeName.trim(), defaultPhaseKeys: [] })
setNewTypeName('')
await load()
}
const updateAssignmentTypeName = async (id: number, name: string) => {
await db.assignmentTypes.update(id, { name })
setAssignmentTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t))
}
const deleteAssignmentType = async (id: number) => {
const count = await db.assignments.where('assignmentTypeId').equals(id).count()
if (count > 0) {
setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden ${count} Assignment(s) verwenden diesen Typ.` }))
return
}
await db.phaseTemplates.where('assignmentTypeId').equals(id).delete()
await db.assignmentTypes.delete(id)
setTypeError(prev => { const n = { ...prev }; delete n[id]; return n })
await load()
}
// ── PhaseTemplate CRUD ────────────────────────────────────────────────────
const addPhaseTemplate = async (typeId: number) => {
const p = newPhase[typeId]
if (!p?.label.trim()) return
const existing = phaseTemplates.filter(t => t.assignmentTypeId === typeId)
await db.phaseTemplates.add({
assignmentTypeId: typeId,
phaseKey: p.label.trim().toLowerCase().replace(/\s+/g, '_'),
label: p.label.trim(),
withWhom: p.withWhom,
isRepeatable: p.isRepeatable,
hasConversation: p.hasConversation,
hasAssessment: p.hasAssessment,
criteriaIds: [],
order: existing.length + 1,
})
setNewPhase(prev => ({ ...prev, [typeId]: { label: '', withWhom: 'lead', isRepeatable: false, hasConversation: false, hasAssessment: false } }))
await load()
}
const updatePhaseTemplate = async (id: number, fields: Partial<PhaseTemplate>) => {
await db.phaseTemplates.update(id, fields)
setPhaseTemplates(prev => prev.map(t => t.id === id ? { ...t, ...fields } : t))
}
const deletePhaseTemplate = async (id: number) => {
await db.phaseTemplates.delete(id)
setPhaseTemplates(prev => prev.filter(t => t.id !== id))
}
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
return (
<div className="space-y-4">
<h2 className="text-lg font-bold text-gray-800">Feedback-Dimensionen & Kategorien</h2>
{/* Neue Dimension */}
<div className="flex gap-2">
<input
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
placeholder="Neue Dimension…"
value={newDimName}
onChange={e => setNewDimName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addDimension()}
/>
<button onClick={addDimension}
className="px-4 py-2 bg-[#0070AD] text-white text-sm rounded-lg font-medium">
+ Dimension
</button>
</div>
{/* Dimensionen */}
{sorted.map((dim, di) => {
const isOpen = expanded.has(dim.id!)
const dimCats = [...categories].filter(c => c.dimensionId === dim.id).sort((a, b) => a.order - b.order)
return (
<div key={dim.id} className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
{/* Dimension-Header */}
<div className="flex items-center gap-2 p-3 bg-gray-50 border-b border-gray-100">
<div className="flex flex-col gap-0.5">
<button onClick={() => moveDim(dim, -1)} disabled={di === 0}
className="text-xs text-gray-400 hover:text-gray-600 leading-none disabled:opacity-30"></button>
<button onClick={() => moveDim(dim, 1)} disabled={di === sorted.length - 1}
className="text-xs text-gray-400 hover:text-gray-600 leading-none disabled:opacity-30"></button>
</div>
<input
className="flex-1 bg-transparent font-semibold text-sm text-gray-800 focus:outline-none border-b border-transparent focus:border-[#0070AD]"
value={dim.name}
onChange={e => updateDimension(dim.id!, e.target.value)}
/>
<button onClick={() => setExpanded(prev => {
const next = new Set(prev)
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
return next
})} className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
<button onClick={() => deleteDimension(dim.id!)}
className="text-xs text-red-300 hover:text-red-500 px-1">🗑</button>
</div>
{isOpen && (
<div className="p-3 space-y-3">
{dimCats.map(cat => {
const isCatOpen = expandedCat.has(cat.id!)
const catItems = [...items].filter(i => i.categoryId === cat.id).sort((a, b) => a.order - b.order)
return (
<div key={cat.id} className="border border-gray-100 rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50">
<input
className="flex-1 bg-transparent text-sm text-gray-700 focus:outline-none"
value={cat.name}
onChange={e => updateCategory(cat.id!, e.target.value)}
/>
<button onClick={() => setExpandedCat(prev => {
const next = new Set(prev)
if (next.has(cat.id!)) next.delete(cat.id!); else next.add(cat.id!)
return next
})} className="text-xs text-gray-400">{isCatOpen ? '▲' : '▼'}</button>
<button onClick={() => deleteCategory(cat.id!)}
className="text-xs text-red-300 hover:text-red-500">🗑</button>
</div>
{isCatOpen && (
<div className="p-2 space-y-1.5">
{catItems.map(item => (
<div key={item.id} className="flex items-center gap-2">
<span className="text-gray-300 text-xs">·</span>
<input
className="flex-1 text-xs text-gray-600 border-b border-transparent focus:border-gray-300 focus:outline-none bg-transparent py-0.5"
value={item.name}
onChange={e => updateItem(item.id!, e.target.value)}
/>
<select
value={item.weight ?? 1}
onChange={e => updateItemWeight(item.id!, Number(e.target.value))}
title="Gewichtung"
className="text-xs border border-gray-200 rounded px-1 py-0.5 text-gray-500 bg-white focus:outline-none focus:border-[#0070AD]">
{[1,2,3,4,5].map(w => <option key={w} value={w}>×{w}</option>)}
</select>
<button onClick={() => deleteItem(item.id!)}
className="text-xs text-red-200 hover:text-red-400">×</button>
</div>
))}
<div className="flex gap-1.5 mt-1">
<input
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-[#0070AD]"
placeholder="Neues Kriterium…"
value={newItemName[cat.id!] ?? ''}
onChange={e => setNewItemName(p => ({ ...p, [cat.id!]: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && addItem(cat.id!)}
/>
<button onClick={() => addItem(cat.id!)}
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-600 text-xs rounded">+</button>
</div>
</div>
)}
</div>
)
})}
{/* Neue Kategorie */}
<div className="flex gap-1.5">
<input
className="flex-1 border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-[#0070AD]"
placeholder="Neue Kategorie…"
value={newCatName[dim.id!] ?? ''}
onChange={e => setNewCatName(p => ({ ...p, [dim.id!]: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && addCategory(dim.id!)}
/>
<button onClick={() => addCategory(dim.id!)}
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-600 text-sm rounded-lg">
+ Kat.
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)
}

759
src/pages/MeetingView.tsx Normal file
View File

@ -0,0 +1,759 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
db,
type Assignment,
type MeetingInstance,
type PhaseTemplate,
type FeedbackCategory,
type FeedbackCriterionItem,
type FeedbackRating,
type Consultant,
type ConversationEntry,
type ConversationSkillScore,
type Assessment,
} from '../db'
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
import { NotationText } from '../utils/notationParser'
const INST_COLORS = [
{ bg: 'bg-blue-50', border: 'border-blue-300', tag: 'bg-blue-500', text: 'text-blue-700' },
{ bg: 'bg-purple-50', border: 'border-purple-300', tag: 'bg-purple-500', text: 'text-purple-700' },
{ bg: 'bg-emerald-50', border: 'border-emerald-300', tag: 'bg-emerald-500', text: 'text-emerald-700' },
{ bg: 'bg-orange-50', border: 'border-orange-300', tag: 'bg-orange-500', text: 'text-orange-700' },
]
const NOTATIONS = [
{ sym: '+', tip: 'Gut' },
{ sym: '(+)', tip: 'Gut mit Abstrichen' },
{ sym: '!', tip: 'Negativ' },
{ sym: '(!)', tip: 'Negativ mit Abstrichen' },
{ sym: '>', tip: 'Einwand / Kundeninput' },
{ sym: '[…]', tip: 'Slide-Referenz' },
]
const RATING_OPTIONS: { value: FeedbackRating; short: string; color: string; active: string }[] = [
{ value: 'na', short: 'N/A', color: 'bg-gray-50 text-gray-400 border-gray-200', active: 'bg-gray-400 text-white border-gray-400' },
{ value: 'not_client_ready', short: 'Not', color: 'bg-red-50 text-red-400 border-red-200', active: 'bg-red-500 text-white border-red-500' },
{ value: 'partially_client_ready', short: 'Partially', color: 'bg-orange-50 text-orange-400 border-orange-200', active: 'bg-orange-500 text-white border-orange-500' },
{ value: 'nearly_client_ready', short: 'Nearly', color: 'bg-green-50 text-green-500 border-green-200', active: 'bg-green-400 text-white border-green-400' },
{ value: 'client_ready', short: 'Fully', color: 'bg-green-50 text-green-800 border-green-300', active: 'bg-green-700 text-white border-green-700' },
]
type Tab = 'conversation' | 'assessment'
export default function MeetingView() {
const { id, meetingId: meetingIdParam } = useParams()
const navigate = useNavigate()
const meetingId = Number(meetingIdParam ?? id)
const [meeting, setMeeting] = useState<MeetingInstance | null>(null)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [template, setTemplate] = useState<PhaseTemplate | null>(null)
const [institutees, setInstitutees] = useState<Consultant[]>([])
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const [entries, setEntries] = useState<ConversationEntry[]>([])
const [scores, setScores] = useState<ConversationSkillScore[]>([])
const [assessments, setAssessments] = useState<Assessment[]>([])
const [tab, setTab] = useState<Tab>('conversation')
const [activeInstId, setActiveInstId] = useState<number | null>(null)
const [activeEntryId, setActiveEntryId] = useState<number | null>(null)
const [entryNote, setEntryNote] = useState('')
const [generalNotes, setGeneralNotes] = useState('')
const [editingGeneralNotes, setEditingGeneralNotes] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const [editNote, setEditNote] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showExportMenu, setShowExportMenu] = useState(false)
// accordion: per instituteeId → expanded categoryId
const [expandedCat, setExpandedCat] = useState<Record<number, number | null>>({})
const noteRef = useRef<HTMLTextAreaElement>(null)
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Vollständiger Vorname, bei Duplikaten + erster Buchstabe Nachname
const displayName = (inst: Consultant) => {
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
return dup ? `${inst.firstName} ${inst.lastName.charAt(0)}.` : inst.firstName
}
// Tag-Label für farbige Badges im Protokoll
const tagLabel = (inst: Consultant) => {
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
return dup ? `${inst.firstName} ${inst.lastName.charAt(0)}.` : inst.firstName
}
const loadData = useCallback(async () => {
const m = await db.meetingInstances.get(meetingId)
if (!m) return
setMeeting(m)
setGeneralNotes(m.generalNotes ?? '')
const [tmpl, a] = await Promise.all([
db.phaseTemplates.get(m.phaseTemplateId),
db.assignments.get(m.assignmentId),
])
setTemplate(tmpl ?? null)
setAssignment(a ?? null)
if (a) {
const cons = await db.consultants.where('id').anyOf(a.instituteeIds).toArray()
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
}
const [allCats, allItems, aType] = await Promise.all([
db.feedbackCategories.toArray(),
db.feedbackCriterionItems.toArray(),
a ? db.assignmentTypes.get(a.assignmentTypeId) : Promise.resolve(undefined),
])
const itemFilter = aType?.criteriaIds?.length ? new Set(aType.criteriaIds) : null
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
setFbItems(filteredItems)
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId))
setFbCategories(itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats)
const [convEntries, convScores, asmts] = await Promise.all([
db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'),
db.conversationSkillScores.toArray(),
db.assessments.where('meetingInstanceId').equals(meetingId).toArray(),
])
setEntries(convEntries)
setScores(convScores)
setAssessments(asmts)
}, [meetingId])
useEffect(() => { loadData() }, [loadData])
// ── Sprecher ──────────────────────────────────────────────────────────────
const activateInstitutee = async (instId: number) => {
if (activeEntryId !== null) {
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
}
const maxSeq = entries.reduce((m, e) => Math.max(m, e.sequenceIndex), 0)
const eid = await db.conversationEntries.add({
meetingInstanceId: meetingId,
instituteeId: instId,
sequenceIndex: maxSeq + 1,
note: '',
fillerCount: 0,
updatedAt: new Date().toISOString(),
})
setActiveInstId(instId)
setActiveEntryId(eid)
setEntryNote('')
await loadData()
setTimeout(() => noteRef.current?.focus(), 50)
}
const insertNotation = (sym: string) => {
const el = noteRef.current
if (!el) { setEntryNote(p => p + sym + ' '); return }
const start = el.selectionStart ?? entryNote.length
const end = el.selectionEnd ?? entryNote.length
const next = entryNote.slice(0, start) + sym + ' ' + entryNote.slice(end)
setEntryNote(next)
setTimeout(() => {
el.selectionStart = el.selectionEnd = start + sym.length + 1
el.focus()
}, 0)
}
const saveEntryNote = async () => {
if (activeEntryId === null) return
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, note: entryNote } : e))
}
const incrementFiller = async () => {
if (activeEntryId === null) return
const entry = entries.find(e => e.id === activeEntryId)
const newCount = (entry?.fillerCount ?? 0) + 1
await db.conversationEntries.update(activeEntryId, { fillerCount: newCount })
setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, fillerCount: newCount } : e))
}
const closeActiveEntry = async () => {
await saveEntryNote()
setActiveInstId(null)
setActiveEntryId(null)
setEntryNote('')
await loadData()
}
// ── Edit / Delete ─────────────────────────────────────────────────────────
const startEdit = (entry: ConversationEntry) => {
setEditingId(entry.id!)
setEditNote(entry.note)
}
const saveEdit = async (entryId: number) => {
await db.conversationEntries.update(entryId, { note: editNote, updatedAt: new Date().toISOString() })
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote } : e))
setEditingId(null)
}
const deleteEntry = async (entryId: number) => {
await db.conversationSkillScores.where('conversationEntryId').equals(entryId).delete()
await db.conversationEntries.delete(entryId)
setEntries(prev => prev.filter(e => e.id !== entryId))
}
const adjustFiller = async (entry: ConversationEntry, delta: number) => {
const newCount = Math.max(0, (entry.fillerCount ?? 0) + delta)
await db.conversationEntries.update(entry.id!, { fillerCount: newCount })
setEntries(prev => prev.map(e => e.id === entry.id ? { ...e, fillerCount: newCount } : e))
}
// ── Scores ────────────────────────────────────────────────────────────────
const setEntryScore = async (entryId: number, categoryId: number, score: FeedbackRating) => {
const existing = scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)
if (existing?.id) {
await db.conversationSkillScores.update(existing.id, { score })
} else {
await db.conversationSkillScores.add({ conversationEntryId: entryId, criteriaId: categoryId, score })
}
setScores(await db.conversationSkillScores.toArray())
}
const getEntryScore = (entryId: number, categoryId: number): FeedbackRating | null =>
scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score ?? null
// ── Gesamtbewertung ───────────────────────────────────────────────────────
// Assessment scored per FeedbackCriterionItem.id
const setAssessmentScore = async (instituteeId: number, itemId: number, score: FeedbackRating) => {
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
const now = new Date().toISOString()
if (existing?.id) {
await db.assessments.update(existing.id, { score, updatedAt: now })
} else {
await db.assessments.add({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now })
}
setAssessments(await db.assessments.where('meetingInstanceId').equals(meetingId).toArray())
}
const getAssessmentScore = (instituteeId: number, itemId: number): FeedbackRating | null =>
assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)?.score ?? null
const saveAssessmentNote = async (instituteeId: number, itemId: number, note: string) => {
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
const now = new Date().toISOString()
if (existing?.id) {
await db.assessments.update(existing.id, { note, updatedAt: now })
} else {
await db.assessments.add({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score: null, note, updatedAt: now })
}
setAssessments(await db.assessments.where('meetingInstanceId').equals(meetingId).toArray())
}
// Aus Protokoll: Kategorie-Durchschnitt → alle Items der Kategorie setzen
const autoCalcAssessment = async (instituteeId: number) => {
const numMap: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
const instEntries = entries.filter(e => e.instituteeId === instituteeId)
for (const cat of fbCategories) {
const vals = instEntries
.map(e => getEntryScore(e.id!, cat.id!))
.filter((s): s is FeedbackRating => s !== null && s !== 'na')
if (vals.length === 0) continue
const avg = vals.reduce((s, v) => s + numMap[v], 0) / vals.length
const rating: FeedbackRating = avg < 1.5 ? 'not_client_ready' : avg < 2.5 ? 'partially_client_ready' : avg < 3.5 ? 'nearly_client_ready' : 'client_ready'
for (const item of fbItems.filter(i => i.categoryId === cat.id)) {
await setAssessmentScore(instituteeId, item.id!, rating)
}
}
}
const buildFilename = (ext: string) => {
const date = new Date(meeting?.date ?? '').toLocaleDateString('de-DE').replace(/\./g, '-')
const label = template?.label ?? 'Meeting'
const idx = (meeting?.phaseIndex ?? 1) > 1 ? `-${meeting!.phaseIndex}` : ''
const id = assignment?.assignmentNumber ?? assignment?.title ?? 'export'
return `${id}_${date}_${label}${idx}.${ext}`
}
const handleExportMd = async () => {
try {
const md = await exportMeetingAsMarkdown(meetingId)
downloadFile(md, buildFilename('md'), 'text/markdown')
} catch (e) { console.error(e) }
setShowExportMenu(false)
}
const handleExportJson = async () => {
try {
const json = await exportMeetingAsJson(meetingId)
downloadFile(json, buildFilename('json'), 'application/json')
} catch (e) { console.error(e) }
setShowExportMenu(false)
}
const handleDeleteMeeting = async () => {
await saveEntryNote()
await db.meetingInstances.update(meetingId, { deletedAt: new Date().toISOString() })
navigate(`/assignment/${assignment?.id}`)
}
const saveGeneralNotes = (val: string) => {
setGeneralNotes(val)
if (notesTimer.current) clearTimeout(notesTimer.current)
notesTimer.current = setTimeout(() => {
db.meetingInstances.update(meetingId, { generalNotes: val })
}, 600)
}
const finishMeeting = async () => {
await saveEntryNote()
await db.meetingInstances.update(meetingId, { status: 'done' })
navigate(`/assignment/${assignment?.id}`)
}
const fillerTotal = (instId: number) =>
entries.filter(e => e.instituteeId === instId).reduce((s, e) => s + (e.fillerCount ?? 0), 0)
if (!meeting || !template) return <div className="text-center py-10 text-gray-400">Lädt</div>
const hasConv = template.hasConversation
const hasAssess = template.hasAssessment
const activeInstIdx = institutees.findIndex(c => c.id === activeInstId)
const activeColor = INST_COLORS[activeInstIdx % INST_COLORS.length]
const pastEntries = [...entries].filter(e => e.id !== activeEntryId).reverse()
const activeEntry = entries.find(e => e.id === activeEntryId)
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<button onClick={() => navigate(`/assignment/${assignment?.id}`)}
className="text-xs text-[#0070AD] mb-0.5 block"> Assignment</button>
{assignment?.assignmentNumber && (
<div className="text-xs font-semibold text-[#0070AD] uppercase tracking-wide leading-none mb-0.5">{assignment.assignmentNumber}</div>
)}
<div className="text-sm font-semibold text-gray-600 truncate">{assignment?.title}</div>
<div className="font-bold text-gray-800 truncate">{template.label}</div>
<div className="text-xs text-gray-400">
{assignment?.client} · {new Date(meeting.date).toLocaleDateString('de-DE')}
</div>
</div>
<div className="flex gap-1.5 flex-shrink-0 items-start">
{/* Export-Menü */}
<div className="relative">
<button onClick={() => setShowExportMenu(p => !p)}
className="text-sm bg-white border border-gray-200 text-gray-600 px-2.5 py-1.5 rounded-lg font-medium">
Export
</button>
{showExportMenu && (
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-xl shadow-lg z-10 min-w-[160px] overflow-hidden">
<button onClick={handleExportMd}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 border-b border-gray-100">
📄 Markdown
</button>
<button onClick={handleExportJson}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50">
🗂 JSON-Backup
</button>
</div>
)}
</div>
<button onClick={() => setShowDeleteConfirm(p => !p)}
title="Meeting löschen"
className="text-sm bg-white border border-red-200 text-red-400 hover:text-red-600 px-2.5 py-1.5 rounded-lg font-medium">
🗑
</button>
<button onClick={finishMeeting}
className="text-sm bg-green-600 text-white px-3 py-1.5 rounded-lg font-medium">
Fertig
</button>
</div>
</div>
{/* Löschen-Bestätigung */}
{showDeleteConfirm && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 space-y-3">
<div className="text-sm font-semibold text-red-700">Meeting in den Papierkorb verschieben?</div>
<div className="text-xs text-red-500">Das Meeting wird in den Papierkorb verschoben und kann dort wiederhergestellt oder endgültig gelöscht werden.</div>
<div className="flex gap-2">
<button onClick={handleDeleteMeeting}
className="flex-1 py-2 rounded-lg text-sm font-medium bg-red-600 text-white">
Ja, in Papierkorb
</button>
<button onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
Abbrechen
</button>
</div>
</div>
)}
{/* Allgemeine Notizen */}
{editingGeneralNotes ? (
<textarea
autoFocus
className="w-full border border-[#0070AD] rounded-xl px-3 py-2 text-sm bg-white focus:outline-none resize-none"
style={{ minHeight: '4rem', fieldSizing: 'content' } as React.CSSProperties}
placeholder="Allgemeine Notizen zum Meeting… (+ gut, ! negativ, > Kundeninput)"
value={generalNotes}
onChange={e => saveGeneralNotes(e.target.value)}
onBlur={() => setEditingGeneralNotes(false)}
/>
) : (
<div
onClick={() => setEditingGeneralNotes(true)}
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm bg-white cursor-text min-h-[2.5rem]">
{generalNotes.trim()
? <NotationText text={generalNotes} />
: <span className="text-gray-300">Allgemeine Notizen zum Meeting</span>
}
</div>
)}
{/* Tabs */}
{(hasConv || hasAssess) && (
<div className="flex bg-gray-100 rounded-xl p-1">
{hasConv && (
<button onClick={() => setTab('conversation')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
tab === 'conversation' ? 'bg-white text-[#0070AD] shadow-sm' : 'text-gray-500'
}`}>
Gesprächsprotokoll
</button>
)}
{hasAssess && (
<button onClick={() => setTab('assessment')}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
tab === 'assessment' ? 'bg-white text-[#0070AD] shadow-sm' : 'text-gray-500'
}`}>
Gesamtbewertung
</button>
)}
</div>
)}
{/* ── GESPRÄCHSPROTOKOLL ───────────────────────────────────────────── */}
{tab === 'conversation' && hasConv && (
<div className="space-y-3">
{/* Aktiver Sprecher-Bereich */}
<div className={`bg-white rounded-xl shadow-sm border-2 p-4 space-y-3 ${
activeInstId !== null ? (activeColor?.border ?? 'border-[#0070AD]') : 'border-gray-200'
}`}>
<div>
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">Wer spricht?</div>
<div className="flex gap-2 flex-wrap">
{institutees.map((c, idx) => {
const color = INST_COLORS[idx % INST_COLORS.length]
const isActive = activeInstId === c.id
const filler = fillerTotal(c.id!)
return (
<button key={c.id}
onClick={() => activateInstitutee(c.id!)}
className={`px-3 py-2 rounded-lg text-sm font-semibold border-2 transition ${
isActive
? `${color.tag} text-white border-transparent`
: `bg-white ${color.text} border-current`
}`}>
{displayName(c)}
{filler > 0 && (
<span className={`ml-1.5 text-xs font-normal ${isActive ? 'text-white/80' : 'text-gray-400'}`}>
äh×{filler}
</span>
)}
</button>
)
})}
{activeInstId !== null && (
<button onClick={closeActiveEntry}
className="px-3 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
Schließen
</button>
)}
</div>
</div>
{activeInstId !== null && (
<>
{/* Notation + Äh-Counter */}
<div className="flex items-start gap-2">
<div className="flex gap-1.5 flex-wrap flex-1">
{NOTATIONS.map(({ sym, tip }) => (
<button key={sym}
onClick={() => insertNotation(sym)}
title={tip}
className="px-2.5 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-mono rounded-lg transition">
{sym}
</button>
))}
</div>
<button
onClick={incrementFiller}
className={`flex-shrink-0 flex flex-col items-center justify-center w-14 h-14 rounded-xl border-2 border-dashed font-bold transition active:scale-95
${activeColor?.text ?? 'text-blue-700'} ${activeColor?.border ?? 'border-blue-300'}
hover:bg-gray-50`}>
<span className="text-lg leading-none">äh</span>
<span className="text-base font-bold leading-none mt-0.5">
{activeEntry?.fillerCount ?? 0}
</span>
</button>
</div>
{/* Notiz */}
<textarea
ref={noteRef}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[#0070AD]"
rows={4}
placeholder="Notizen… (+ gut, (!) negativ mit Abstrichen, > Kundeninput, [Slide 3])"
value={entryNote}
onChange={e => setEntryNote(e.target.value)}
onBlur={saveEntryNote}
/>
{/* Bewertungen per Kategorie (optional) */}
{fbCategories.length > 0 && activeEntryId !== null && (
<details>
<summary className="text-xs text-gray-400 cursor-pointer select-none hover:text-gray-600">
Kategorie-Bewertung für diesen Beitrag (optional)
</summary>
<div className="mt-2 space-y-3">
{fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
const current = getEntryScore(activeEntryId, cat.id!)
return (
<div key={cat.id}>
<div className="text-xs font-medium text-gray-600 mb-1">{cat.name}</div>
{catItems.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1.5">
{catItems.map(i => (
<span key={i.id} className="text-xs text-gray-400 bg-gray-50 border border-gray-100 px-1.5 py-0.5 rounded-full">{i.name}</span>
))}
</div>
)}
<div className="flex gap-1">
{RATING_OPTIONS.map(r => (
<button key={r.value}
onClick={() => setEntryScore(activeEntryId, cat.id!, r.value)}
className={`flex-1 py-1.5 rounded text-xs font-bold border transition ${
current === r.value ? r.active : r.color
}`}>
{r.short}
</button>
))}
</div>
</div>
)
})}
</div>
</details>
)}
</>
)}
</div>
{/* Protokoll-Zeitleiste */}
{pastEntries.length > 0 && (
<div className="space-y-1.5">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1">Protokoll</div>
{pastEntries.map(entry => {
const instIdx = institutees.findIndex(c => c.id === entry.instituteeId)
const inst = institutees[instIdx]
const color = INST_COLORS[instIdx % INST_COLORS.length]
const scoreChips = fbCategories
.map(c => ({ name: c.name, s: getEntryScore(entry.id!, c.id!) }))
.filter(x => x.s !== null)
const isEditing = editingId === entry.id
return (
<div key={entry.id}
className={`rounded-xl border px-3 py-2.5 ${color?.bg ?? 'bg-gray-50'} ${color?.border ?? 'border-gray-200'}`}>
<div className="flex items-start gap-2">
<span className={`flex-shrink-0 text-xs font-bold text-white px-2 py-0.5 rounded mt-0.5 ${color?.tag ?? 'bg-gray-400'}`}>
{inst ? tagLabel(inst) : '?'}
</span>
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="space-y-2">
<textarea
className="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:border-[#0070AD] bg-white"
rows={3}
value={editNote}
onChange={e => setEditNote(e.target.value)}
autoFocus
/>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">Füllwörter:</span>
<button onClick={() => adjustFiller(entry, -1)}
className="w-7 h-7 rounded-full border border-gray-300 text-gray-600 flex items-center justify-center"></button>
<span className="text-sm font-semibold w-6 text-center">{entry.fillerCount ?? 0}</span>
<button onClick={() => adjustFiller(entry, +1)}
className="w-7 h-7 rounded-full border border-gray-300 text-gray-600 flex items-center justify-center">+</button>
</div>
<div className="flex gap-2">
<button onClick={() => saveEdit(entry.id!)}
className="text-xs bg-[#0070AD] text-white px-3 py-1.5 rounded-lg">Speichern</button>
<button onClick={() => setEditingId(null)}
className="text-xs border border-gray-200 text-gray-500 px-3 py-1.5 rounded-lg">Abbrechen</button>
</div>
</div>
) : (
<>
{entry.note && (
<NotationText text={entry.note} />
)}
<div className="flex gap-1 flex-wrap mt-1">
{(entry.fillerCount ?? 0) > 0 && (
<span className="text-xs bg-red-50 text-red-500 border border-red-200 px-1.5 py-0.5 rounded font-medium">
äh×{entry.fillerCount}
</span>
)}
{scoreChips.map(({ name, s }) => {
const ro = RATING_OPTIONS.find(r => r.value === s)
return (
<span key={name} className={`text-xs px-1.5 py-0.5 rounded border font-medium ${ro?.active ?? 'bg-gray-100 text-gray-500 border-gray-200'}`}>
{name.slice(0, 8)}: {ro?.short ?? s}
</span>
)}
)}
</div>
</>
)}
</div>
{!isEditing && (
<>
<div className="flex flex-col gap-1 flex-shrink-0">
<button onClick={() => startEdit(entry)}
className="text-xs text-gray-400 hover:text-gray-600 px-1.5 py-1 rounded leading-none"></button>
<button onClick={() => deleteEntry(entry.id!)}
className="text-xs text-red-300 hover:text-red-500 px-1.5 py-1 rounded leading-none">🗑</button>
</div>
<span className="text-xs text-gray-300 flex-shrink-0 self-start">#{entry.sequenceIndex}</span>
</>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* ── GESAMTBEWERTUNG ────────────────────────────────────────────────── */}
{tab === 'assessment' && hasAssess && (
<div className="space-y-4">
{institutees.map((inst, idx) => {
const color = INST_COLORS[idx % INST_COLORS.length]
const filler = fillerTotal(inst.id!)
// Helper: weighted average rating for a category (N/A excluded, scale 1-4)
const catMode = (cat: FeedbackCategory): FeedbackRating | null => {
const numMap: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
const scored = fbItems
.filter(i => i.categoryId === cat.id)
.map(i => ({ score: getAssessmentScore(inst.id!, i.id!), w: i.weight ?? 1 }))
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
if (scored.length === 0) return null
const wSum = scored.reduce((s, x) => s + x.w, 0)
const avg = scored.reduce((s, x) => s + numMap[x.score] * x.w, 0) / wSum
if (avg < 1.5) return 'not_client_ready'
if (avg < 2.5) return 'partially_client_ready'
if (avg < 3.5) return 'nearly_client_ready'
return 'client_ready'
}
return (
<div key={inst.id} className={`rounded-xl border-2 ${color.border} p-4 space-y-3 bg-white`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-bold text-white px-2 py-1 rounded ${color.tag}`}>
{tagLabel(inst)}
</span>
<h3 className={`font-semibold ${color.text}`}>{inst.firstName} {inst.lastName}</h3>
{filler > 0 && (
<span className="text-xs bg-red-50 text-red-500 border border-red-200 px-2 py-0.5 rounded">
äh×{filler}
</span>
)}
</div>
{hasConv && (
<button onClick={() => autoCalcAssessment(inst.id!)}
className="text-xs bg-gray-100 text-gray-600 border border-gray-200 px-3 py-1 rounded-lg">
Aus Protokoll
</button>
)}
</div>
{fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
if (catItems.length === 0) return null
const ratedCount = catItems.filter(i => { const s = getAssessmentScore(inst.id!, i.id!); return s !== null && s !== 'na' }).length
const isOpen = expandedCat[inst.id!] === cat.id
const mode = catMode(cat)
const headerRo = mode ? RATING_OPTIONS.find(r => r.value === mode) : null
return (
<div key={cat.id} className="rounded-xl overflow-hidden border border-gray-100">
{/* Category header — colored when rated */}
<button
onClick={() => setExpandedCat(prev => ({
...prev,
[inst.id!]: prev[inst.id!] === cat.id ? null : cat.id!,
}))}
className={`w-full flex items-center justify-between px-3 py-2.5 text-left transition ${
headerRo ? headerRo.active : 'bg-white hover:bg-gray-50'
}`}>
<span className={`text-sm font-medium ${headerRo ? 'text-white' : 'text-gray-700'}`}>{cat.name}</span>
<div className="flex items-center gap-2">
{!headerRo && ratedCount > 0 && (
<span className="text-xs text-[#0070AD] font-medium">{ratedCount}/{catItems.length}</span>
)}
<span className={`text-xs ${headerRo ? 'text-white/70' : 'text-gray-300'}`}>{isOpen ? '▲' : '▼'}</span>
</div>
</button>
{/* Criterion items */}
{isOpen && (
<div className="border-t border-gray-100 divide-y divide-gray-50">
{catItems.map(item => {
const current = getAssessmentScore(inst.id!, item.id!)
return (
<div key={item.id} className="px-3 py-2.5 space-y-1.5">
<div className="text-xs text-gray-600 font-medium">{item.name}</div>
<div className="flex gap-1">
{RATING_OPTIONS.map(r => (
<button key={r.value}
onClick={() => setAssessmentScore(inst.id!, item.id!, r.value)}
className={`flex-1 py-1.5 rounded-lg text-xs font-semibold border transition ${
current === r.value ? r.active : r.color
}`}>
{r.short}
</button>
))}
</div>
</div>
)
})}
<div className="px-3 py-2 bg-gray-50">
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-[#0070AD] bg-white"
rows={1}
placeholder="Kommentar zur Kategorie"
defaultValue={assessments.find(a => a.instituteeId === inst.id && a.criteriaId === cat.id)?.note ?? ''}
onBlur={e => saveAssessmentNote(inst.id!, cat.id!, e.target.value)}
/>
</div>
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
)}
</div>
)
}

74
src/utils/dbBackup.ts Normal file
View File

@ -0,0 +1,74 @@
import { db } from '../db'
import { downloadFile } from './meetingExport'
const TABLE_NAMES = [
'categories',
'criteria',
'assignmentTypes',
'phaseTemplates',
'groups',
'consultants',
'assignments',
'meetingInstances',
'conversationEntries',
'conversationSkillScores',
'assessments',
'feedbackDrafts',
'feedbackDimensions',
'feedbackCategories',
'feedbackCriterionItems',
'assignmentFeedbacks',
'feedbackCategoryRatings',
'feedbackDimensionTexts',
'criterionCategoryMappings',
'criterionLevelDescriptions',
] as const
export interface DbBackup {
version: number
exportedAt: string
tables: Record<string, unknown[]>
}
export async function exportFullBackup(): Promise<void> {
const tables: Record<string, unknown[]> = {}
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, '-')
downloadFile(JSON.stringify(backup, null, 2), `assignment-monitor-backup-${date}.json`, 'application/json')
}
export async function importFullBackup(file: File): Promise<{ ok: boolean; error?: string }> {
try {
const text = await file.text()
const backup: DbBackup = JSON.parse(text)
if (!backup.tables || typeof backup.tables !== 'object') {
return { ok: false, error: 'Ungültiges Backup-Format' }
}
// Clear all tables and reimport in dependency order
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 }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}

171
src/utils/meetingExport.ts Normal file
View File

@ -0,0 +1,171 @@
import { db } from '../db'
export async function exportMeetingAsMarkdown(meetingId: number): Promise<string> {
const meeting = await db.meetingInstances.get(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 critIds = [
...new Set([
...(template?.criteriaIds ?? []),
...assessments.map(a => a.criteriaId),
]),
]
const criteria = critIds.length > 0
? await db.criteria.where('id').anyOf(critIds).toArray()
: []
const institutees = instituteeIds
.map(id => consultants.find(c => c.id === id))
.filter(Boolean) as typeof consultants
const date = new Date(meeting.date).toLocaleDateString('de-DE', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
})
const lines: string[] = []
lines.push(`# ${template?.label ?? 'Meeting'}`)
lines.push(`**Assignment:** ${assignment?.title ?? '—'} | **Kunde:** ${assignment?.client ?? '—'}`)
lines.push(`**Datum:** ${date}`)
lines.push(`**Institutees:** ${institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}`)
if (assignment?.leadName) lines.push(`**Lead:** ${assignment.leadName}`)
lines.push('')
if (meeting.generalNotes?.trim()) {
lines.push('## Allgemeine Notizen')
lines.push(meeting.generalNotes.trim())
lines.push('')
}
if (entries.length > 0) {
lines.push('## Gesprächsprotokoll')
lines.push('')
for (const entry of entries) {
const inst = institutees.find(c => c.id === entry.instituteeId)
const name = inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'
lines.push(`**[${entry.sequenceIndex}] ${name}**`)
if (entry.note?.trim()) {
for (const noteLine of entry.note.trim().split('\n')) {
lines.push(`> ${noteLine}`)
}
}
if ((entry.fillerCount ?? 0) > 0) {
lines.push(`> _Füllwörter: ${entry.fillerCount}_`)
}
const entryScores = allScores.filter(s => s.conversationEntryId === entry.id)
if (entryScores.length > 0) {
const scoreStr = entryScores
.map(s => {
const crit = criteria.find(c => c.id === s.criteriaId)
return crit ? `${crit.name}: ${s.score}` : null
})
.filter(Boolean)
.join(' | ')
if (scoreStr) lines.push(`> _Scores: ${scoreStr}_`)
}
lines.push('')
}
}
if (assessments.length > 0) {
lines.push('## Gesamtbewertung')
lines.push('')
for (const inst of institutees) {
const instAssessments = assessments.filter(a => a.instituteeId === inst.id)
if (instAssessments.length === 0) continue
lines.push(`### ${inst.firstName} ${inst.lastName}`)
for (const a of instAssessments) {
const crit = criteria.find(c => c.id === a.criteriaId)
if (!crit) continue
const scoreStr = a.score !== null ? `${a.score}/5` : '—'
const noteStr = a.note?.trim() ? `${a.note.trim()}` : ''
lines.push(`- **${crit.name}:** ${scoreStr}${noteStr}`)
}
const totalFiller = entries
.filter(e => e.instituteeId === inst.id)
.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
if (totalFiller > 0) lines.push(`- _Füllwörter gesamt: ${totalFiller}_`)
lines.push('')
}
}
lines.push('---')
lines.push(`_Export: ${new Date().toLocaleString('de-DE')}_`)
return lines.join('\n')
}
export async function exportMeetingAsJson(meetingId: number): Promise<string> {
const meeting = await db.meetingInstances.get(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 entryScores = allScores.filter(s => entryIds.includes(s.conversationEntryId))
const payload = {
exportedAt: new Date().toISOString(),
meeting,
template,
assignment,
consultants,
entries: entries.map(e => ({
...e,
scores: entryScores.filter(s => s.conversationEntryId === e.id),
})),
assessments,
}
return JSON.stringify(payload, null, 2)
}
export function downloadFile(content: string, filename: string, mime: string) {
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}

View File

@ -0,0 +1,70 @@
import React from 'react'
// Notations-Regeln (Reihenfolge wichtig: längere Symbole zuerst)
const NOTATION_RULES = [
{ prefix: '(+)', className: 'text-green-600', icon: '✓~', bg: 'bg-green-50', indent: false },
{ prefix: '(!)', className: 'text-red-500', icon: '✗~', bg: 'bg-red-50', indent: false },
{ prefix: '+', className: 'text-green-700 font-semibold', icon: '✓', bg: 'bg-green-50', indent: false },
{ prefix: '!', className: 'text-red-600 font-semibold', icon: '✗', bg: 'bg-red-50', indent: false },
{ prefix: '>', className: 'text-blue-600 italic', icon: '', bg: 'bg-blue-50', indent: true },
] as const
// Erkennt [Slide 3] oder […] am Anfang einer Zeile
const SLIDE_RE = /^\[([^\]]*)\]/
function parseLine(line: string, key: number): React.ReactElement {
const trimmed = line.trim()
if (!trimmed) return <div key={key} className="h-1" />
// Slide-Referenz [Titel…]
const slideMatch = trimmed.match(SLIDE_RE)
if (slideMatch) {
const rest = trimmed.slice(slideMatch[0].length).trim()
return (
<div key={key} className="flex items-start gap-2 py-0.5">
<span className="flex-shrink-0 text-xs font-bold text-blue-700 bg-blue-100 border border-blue-200 px-1.5 py-0.5 rounded mt-0.5">
{slideMatch[1] || '…'}
</span>
{rest && <span className="text-sm text-gray-700">{rest}</span>}
</div>
)
}
// Notations-Präfixe
for (const rule of NOTATION_RULES) {
if (trimmed.startsWith(rule.prefix)) {
const rest = trimmed.slice(rule.prefix.length).trim()
return (
<div key={key}
className={`flex items-start gap-2 py-0.5 px-2 rounded ${rule.bg} ${rule.indent ? 'ml-2' : ''}`}>
<span className={`flex-shrink-0 text-xs font-bold mt-0.5 ${rule.className}`}>
{rule.icon}
</span>
<span className={`text-sm ${rule.className}`}>{rest}</span>
</div>
)
}
}
// Normaler Text
return (
<div key={key} className="py-0.5 text-sm text-gray-800">
{trimmed}
</div>
)
}
interface Props {
text: string
className?: string
}
export function NotationText({ text, className }: Props) {
if (!text?.trim()) return null
const lines = text.split('\n')
return (
<div className={className}>
{lines.map((line, i) => parseLine(line, i))}
</div>
)
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

22
tsconfig.app.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

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

20
tsconfig.node.json Normal file
View File

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

26
vite.config.ts Normal file
View File

@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Assignment Monitor',
short_name: 'AssignMonitor',
description: 'Trainee-Bewertungs-App für Capgemini Management Consultants',
theme_color: '#0070AD',
background_color: '#ffffff',
display: 'standalone',
icons: [
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png' },
],
},
}),
],
})