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