initial commit
This commit is contained in:
commit
ece111c0a7
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal 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
101
CLAUDE.md
Normal 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
4
build.bat
Normal 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
|
||||||
160
docs/FACHLICHE_ARCHITEKTUR.md
Normal file
160
docs/FACHLICHE_ARCHITEKTUR.md
Normal 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 (1–n)
|
||||||
|
└── 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
169
docs/HANDOVER.md
Normal 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
|
||||||
98
docs/STATUS_UND_OFFENE_PUNKTE.md
Normal file
98
docs/STATUS_UND_OFFENE_PUNKTE.md
Normal 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 |
|
||||||
256
docs/TECHNISCHE_ARCHITEKTUR.md
Normal file
256
docs/TECHNISCHE_ARCHITEKTUR.md
Normal 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
13
index.html
Normal 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
8229
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal 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
54
src/App.tsx
Normal 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
602
src/db/index.ts
Normal 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
1
src/index.css
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "tailwindcss";
|
||||||
20
src/main.tsx
Normal file
20
src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
212
src/pages/AssignmentCreate.tsx
Normal file
212
src/pages/AssignmentCreate.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
398
src/pages/AssignmentDetail.tsx
Normal file
398
src/pages/AssignmentDetail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
256
src/pages/AssignmentEdit.tsx
Normal file
256
src/pages/AssignmentEdit.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
533
src/pages/AssignmentFeedbackPage.tsx
Normal file
533
src/pages/AssignmentFeedbackPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
347
src/pages/AssignmentTypeConfig.tsx
Normal file
347
src/pages/AssignmentTypeConfig.tsx
Normal 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
140
src/pages/Configuration.tsx
Normal 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
90
src/pages/Consultants.tsx
Normal 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
119
src/pages/Dashboard.tsx
Normal 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
136
src/pages/Evaluation.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
213
src/pages/FeedbackDraftPage.tsx
Normal file
213
src/pages/FeedbackDraftPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
324
src/pages/FeedbackStructureConfig.tsx
Normal file
324
src/pages/FeedbackStructureConfig.tsx
Normal 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
759
src/pages/MeetingView.tsx
Normal 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
74
src/utils/dbBackup.ts
Normal 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
171
src/utils/meetingExport.ts
Normal 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)
|
||||||
|
}
|
||||||
70
src/utils/notationParser.tsx
Normal file
70
src/utils/notationParser.tsx
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
22
tsconfig.app.json
Normal file
22
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
20
tsconfig.node.json
Normal file
20
tsconfig.node.json
Normal 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
26
vite.config.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user