20260703_tagging von Zeilen und Verbindung
This commit is contained in:
parent
f57db9d161
commit
f47353dbe1
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
57
CLAUDE.md
57
CLAUDE.md
|
|
@ -9,10 +9,30 @@ Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) wäh
|
|||
## Stack
|
||||
|
||||
- React 18 + TypeScript + Vite (PWA)
|
||||
- Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js`
|
||||
- Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js`; Brandfarbe als `@theme { --color-brand }` in `src/index.css`
|
||||
- Dexie.js v5 — IndexedDB-Wrapper, kein Backend
|
||||
- React Router v6
|
||||
- Capgemini-Brandfarbe: `#0070AD`
|
||||
- Capgemini-Brandfarbe: `#0070AD` (Tailwind-Klasse `brand`, JS-Konstante `BRAND_COLOR` in `src/config/constants.ts`)
|
||||
|
||||
## Projektstruktur (nach Architektur-Refactoring, Stand 2026-07-02)
|
||||
|
||||
```
|
||||
src/
|
||||
config/constants.ts ← RATING_OPTIONS, INST_COLORS, AI_CONFIG, RATING_NUM_MAP, BRAND_COLOR, DEFAULT_RATING_TREND_WEIGHT_STEP
|
||||
db/
|
||||
index.ts ← reiner Re-Export (types + schema + seeds)
|
||||
types.ts ← alle Interfaces, FeedbackRating, AppSettings
|
||||
schema.ts ← Dexie-Klasse + Migrationsversionen
|
||||
seeds/ ← assignmentTypes.ts, feedbackStructure.ts, migrations.ts
|
||||
pages/
|
||||
meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx
|
||||
RatingWeightConfig.tsx ← Settings-Tab für Zeitgewichtungs-Faktor (in Configuration.tsx eingebunden)
|
||||
...übrige Pages unverändert am alten Ort
|
||||
utils/
|
||||
ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung (genutzt von Evaluation.tsx & AssignmentFeedbackPage.tsx)
|
||||
```
|
||||
|
||||
Bestehende Imports aus `'../db'` funktionieren weiterhin unverändert (Re-Export).
|
||||
|
||||
## Entwicklung
|
||||
|
||||
|
|
@ -28,8 +48,9 @@ npx tsc --noEmit # TypeScript-Check — muss vor jeder Abgabe fehlerfrei sei
|
|||
### Doppelte `criteriaId`-Semantik
|
||||
|
||||
```
|
||||
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
|
||||
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
|
||||
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
|
||||
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung pro Protokoll-Eintrag)
|
||||
ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Kriterium-Zuordnung einer einzelnen Zeile innerhalb eines ConversationEntry.note-Blocks, seit 2026-07-02 — bewusst anders benannt als "criteriaId" um obige Verwechslungsgefahr nicht fortzuschreiben)
|
||||
```
|
||||
|
||||
### AssignmentType.criteriaIds
|
||||
|
|
@ -54,6 +75,10 @@ type FeedbackRating = 'na' | 'not_client_ready' | 'partially_client_ready' | 'ne
|
|||
|
||||
Aggregation: gewichteter Durchschnitt → `avg < 1.5` Not · `< 2.5` Partially · `< 3.5` Nearly · `≥ 3.5` Fully
|
||||
|
||||
**Bug-Klasse, auf die immer prüfen:** Filter der Form `a.score !== null` schließen N/A NICHT aus (N/A ist der String `'na'`, nicht `null`!). Richtig ist immer `a.score !== null && a.score !== 'na'`. Gefunden und gefixt in `Evaluation.tsx` (2026-07-02); dieselbe Prüfung nötig überall, wo `Assessment.score` oder `ConversationSkillScore.score` gemittelt wird.
|
||||
|
||||
**Zweite Bug-Klasse, auf die immer prüfen (Kriterium-Gewichtung):** `FeedbackCriterionItem.weight` geht seit 2026-07-03 von `0,0` bis `2,0` (Config → Feedback, vorher grobe `×1`–`×5`-Stufen). Jede gewichtete Mittelung MUSS normieren — `Σ(score×weight) / Σ(weight)`, niemals `score×weight` direkt als Endwert interpretieren (würde die Bewertungsstufe verzerren, z.B. Score 3 „Nearly" × Gewicht 0,5 = 1,5, was fälschlich zwischen Not/Partially läge). Zusätzlich: wenn `Σweight === 0` (z.B. alle beteiligten Kriterien einer Kategorie auf Gewicht 0 gesetzt), **nicht** dividieren — `NaN < 1.5/2.5/3.5` ist überall `false` und fällt sonst still auf den letzten Bucket „Fully" durch. Immer `if (wSum === 0) return null` (bzw. `continue`) vor der Division. Umgesetzt in `catMode()` (`AssessmentTab.tsx`) und `buildGroupMeetingScores()` (`ratingTrend.ts`) — beim nächsten neuen gewichteten Durchschnitt genauso prüfen.
|
||||
|
||||
### Routing
|
||||
|
||||
```
|
||||
|
|
@ -66,7 +91,15 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
|
|||
|
||||
- `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
|
||||
- Tailwind v4: Brandfarbe ist `brand` (Tailwind-Klasse, via `@theme` in `index.css`) — **kein** `bg-[#0070AD]` mehr neu schreiben
|
||||
- Auswertungslogik greift auf **zwei komplett unabhängige Bewertungs-Strukturen** zu, die nicht automatisch synchron sind:
|
||||
- `Assessment` (Tabelle `assessments`) — pro Meeting erfasste Kriterium-Bewertungen, Basis für `/evaluation`
|
||||
- `FeedbackCategoryRating` — ausschließlich manuell auf `AssignmentFeedbackPage` gesetzt, unabhängig von Meeting-Daten, Basis für das finale Abschluss-Feedback
|
||||
- Seit 2026-07-02: `src/utils/ratingTrend.ts` berechnet aus den `Assessment`-Daten einen **zeitgewichteten Vorschlag** pro Kategorie (spätere Meetings zählen stärker, Gewichtungsfaktor konfigurierbar unter Config → Gewichtung) inkl. Trend-Erkennung (Verbesserung/Verschlechterung). Der Vorschlag wird auf `AssignmentFeedbackPage` als Badge angezeigt und bleibt frei überschreibbar — die zwei Strukturen bleiben bewusst getrennt.
|
||||
- Meeting-Filter bei Aggregationen über mehrere Meetings: immer `!m.deletedAt && m.status === 'done'` — sonst fließen Papierkorb-/unfertige Meetings mit ein
|
||||
- **Weichmacher/Füllwörter in Formulierungen** (z.B. "ein bisschen") werden bewusst **nicht** über eine eigene Text-Erkennungslogik behandelt — stattdessen legt man dafür ein eigenes, niedrig gewichtetes Kriterium an (z.B. "Sprachliche Präzision", Gewicht < 1) und taggt/notiert entsprechende Zeilen ganz normal (meist mit `(!)`). Die bestehende Tagging- + Notation-Vorschlag-Mechanik deckt das automatisch ab.
|
||||
- Der `äh`-Zähler (`ConversationEntry.fillerCount`) bleibt bewusst **rein informativ** — keine automatische Schwellenwert-Bewertung. Würde eine eigene Design-Entscheidung brauchen (ab wann gilt die Zahl als "zu hoch"), aktuell reicht die reine Anzeige als Grundlage für die manuelle Bewertung.
|
||||
- `ConversationEntry` entspricht weiterhin **einem ganzen Sprecher-Turn** (ein zusammenhängender, mehrzeiliger Text-Block bis "✓ Schließen") — bewusst so belassen, ein Zwischenstand mit "ein Entry pro Zeile" wurde am 2026-07-02 wieder verworfen, weil er die Lesbarkeit des Protokolls zerstört hat. Kriterium-Zuordnung passiert stattdessen **pro Zeile innerhalb** eines Entry über `ConversationEntry.lineTags` (Matching per exaktem Zeilentext, nicht per Index — robust gegenüber nachträglichem Einfügen/Löschen von Zeilen). Zeilen splitten immer über `splitNoteLines()` in `src/utils/notationParser.tsx`, nicht erneut `note.split('\n')` inline schreiben (sonst laufen Trim-/Filter-Verhalten auseinander).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -83,13 +116,17 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
|
|||
|
||||
| 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` |
|
||||
| H | N/A-Aggregationsbug fixen (siehe Bug-Klasse oben) | `src/pages/AssignmentFeedbackPage.tsx`, `src/pages/FeedbackDraftPage.tsx` |
|
||||
| H | **KI-Generierung derzeit funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich blockiert das Capgemini-Netzwerk/Firewall den Call zu `openrouter.ai`. Root Cause noch nicht untersucht — ggf. mit internem IT-Support klären. | `src/pages/AssignmentFeedbackPage.tsx` |
|
||||
| H | **Größeres Vorhaben (noch nicht gescoped):** Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Spezialwissen je Dimension + Platzhaltersystem — Ablösung des aktuellen Einzel-Prompts in `buildAiPrompt`. Braucht eigene Design-Session. | `src/pages/AssignmentFeedbackPage.tsx` |
|
||||
| M | `FeedbackDraftPage.tsx` auf neue Datenstruktur migrieren (nutzt noch `categories`/`criteria`) | `src/pages/FeedbackDraftPage.tsx` |
|
||||
| M | `db/queries/`-Layer einführen (Vorbereitung SQLite-Migration, DB-Calls aus Komponenten extrahieren) | `src/db/queries/` (neu) |
|
||||
| M | **Selbstlernendes Tagging (Idee, noch nicht gescoped):** Zeile-zu-Kriterium-Zuordnungen sammeln und daraus beim Tippen automatische Tag-Vorschläge ableiten (ähnliche Formulierungen → gleiches Kriterium). Braucht eigene Design-Session (Ähnlichkeits-/Lernmechanik, Datenbasis, UI). | `src/pages/meeting/CriterionTagPicker.tsx` |
|
||||
| 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` |
|
||||
| L | SQLite WASM + OPFS Migration (kein festes Datum) | `src/db/schema.ts` |
|
||||
|
||||
**Erledigt (2026-07-02):** Konfiguration zentralisiert (`config/constants.ts`), `db/index.ts` in types/schema/seeds aufgeteilt, `MeetingView.tsx` in 3 Dateien aufgeteilt (`pages/meeting/`), `Evaluation.tsx` auf `feedbackCriterionItems` migriert + nach Assignment gruppiert + Papierkorb/Status-Filter + N/A-Bug gefixt, `zustand` entfernt (war ungenutzt). Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung umgesetzt (`src/utils/ratingTrend.ts`, neue Tabelle `appSettings`, neuer Config-Tab „Gewichtung“, Vorschlags-Badge + Übernehmen/Trend-in-Text auf `AssignmentFeedbackPage`, `buildAiPrompt` um Trend-Abschnitt erweitert und Legacy-`db.criteria`-Bug dabei gefixt). Kriterium-Zuordnung für Freitext-Notizen umgesetzt: `ConversationEntry.lineTags` (Zeilentext → Kriterium, nach einem verworfenen Zwischenstand mit "ein Entry pro Zeile" — siehe Sonstige Fallstricke), `CriterionTagPicker.tsx` (Chip+Popover mit Suche, unverändert seit erster Version) wird pro Zeile direkt an der Zeile via `NotationText`s neuer optionaler `renderLineAddon`-Prop gerendert; Zuordnung passiert bewusst nur nachträglich (nicht live während des Tippens), um den Schreibfluss nicht zu stören. `buildAiPrompt` führt Assessment-Notizen und getaggte Zeilen jetzt pro Kriterium zusammen, unzugeordnete Zeilen bleiben im allgemeinen Protokoll-Block. **(2026-07-03)** Vier Folge-Fixes: `meetingExport.ts` löste Kriterien über die Legacy-Tabelle `db.criteria` auf (identischer Bug wie der frühere `buildAiPrompt`-Fix) — dadurch war die Gesamtbewertung im Export faktisch leer; jetzt auf `feedbackCriterionItems` umgestellt, Markdown-Export zeigt zusätzlich das Kriterium pro getaggter Protokollzeile. `dbBackup.ts`s `TABLE_NAMES` enthielt `appSettings` nicht — Voll-Backups verloren die Gewichtungs-Config stillschweigend; jetzt ergänzt samt Kommentar, dass neue Tabellen dort manuell nachgezogen werden müssen. Notation → Bewertungs-Vorschlag: `detectNotationRating()` in `notationParser.tsx` (`!`→Not, `(!)`→Partially, `(+)`→Nearly, `+`→Fully) kombiniert mit `lineTags` liefert in `AssessmentTab.tsx` einen "Vorschlag aus Protokoll"-Badge pro Kriterium-Item, unabhängig vom bestehenden "Aus Protokoll"-Button (andere Datenquelle). `AssignmentCreate.tsx`: Assignment-Nummer ist jetzt Pflichtfeld (nur auf Formular-Ebene, Typ bleibt optional wegen Altdaten). Kriterium-Gewichtung auf `0,0–2,0` in `0,1`-Schritten verfeinert (`FeedbackStructureConfig.tsx`, vorher `×1`–`×5`), dabei einen latenten Division-durch-Null-Bug gefixt, der durch das neu erlaubte Gewicht `0` real wurde (`catMode()` in `AssessmentTab.tsx`, `buildGroupMeetingScores()` in `ratingTrend.ts` — siehe zweite Bug-Klasse oben). Sprecher eines bereits erfassten Protokoll-Beitrags kann nachträglich geändert werden (`ConversationTab.tsx`, im Bearbeiten-Modus neben Füllwörtern — ändert sofort `ConversationEntry.instituteeId`, kein separater Speichern-Schritt nötig).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
51
docs/EVALUATION_FEEDBACK_REDESIGN.md
Normal file
51
docs/EVALUATION_FEEDBACK_REDESIGN.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Design-Brief: Zeitgewichteter Bewertungs-Vorschlag (Auswertung & Abschluss-Feedback)
|
||||
|
||||
Status: **Umgesetzt (2026-07-02).** Design-Fragen mit Lars geklärt und implementiert — siehe `CLAUDE.md` (Abschnitt "zwei parallele Bewertungs-Strukturen", Erledigt-Liste) für den aktuellen Stand. Dieses Dokument bleibt als Kontext/Historie der Design-Entscheidungen erhalten.
|
||||
|
||||
---
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Am 2026-07-02 wurde bei der Migration von `Evaluation.tsx` auf die neue Datenstruktur (`feedbackCriterionItems`) sichtbar, dass die App **zwei komplett unabhängige, nicht synchronisierte Bewertungs-Strukturen** hat:
|
||||
|
||||
| Struktur | Tabelle | Wo gesetzt | Zweck |
|
||||
|---|---|---|---|
|
||||
| Meeting-Assessment | `assessments` | `MeetingView` (`pages/meeting/AssessmentTab.tsx`), pro Meeting, pro Kriterium | Live-Bewertung während/nach jedem Kundentermin |
|
||||
| Abschluss-Feedback | `feedbackCategoryRatings` | `AssignmentFeedbackPage.tsx`, manuell pro Kategorie | Finales, für den Institutee bestimmtes Feedback am Assignment-Ende |
|
||||
|
||||
Aktuell berechnet `/evaluation` (`Evaluation.tsx`) einen gleichgewichteten Durchschnitt aller `assessments` je Kriterium/Kategorie, gruppiert nach Assignment → Institutee. Das Abschluss-Feedback auf `AssignmentFeedbackPage` ist davon komplett entkoppelt — die Kategorie-Ratings dort werden per Klick frei gesetzt, nicht aus den Meeting-Daten abgeleitet. Der einzige bestehende Brückenschlag ist `buildAiPrompt()` in `AssignmentFeedbackPage.tsx`, der Assessment-Rohdaten in einen KI-Prompt packt (dieser Prompt hat aktuell noch einen offenen Bug: nutzt `db.criteria`, Legacy-Tabelle, sollte `feedbackCriterionItems` nutzen — siehe `docs/STATUS_UND_OFFENE_PUNKTE.md`, Punkt M2).
|
||||
|
||||
## Fachlicher Wunsch (Lars, 2026-07-02, wörtlich sinngemäß)
|
||||
|
||||
> Die Werte sollen aus den einzelnen Meetings berechnet werden — als **Vorschlag**. Dieser Vorschlag kann durch den Feedbackgeber überarbeitet werden. Eine parallele Ansicht ist daher möglicherweise sinnvoll. Bei der Berechnung der Vorschläge auf Kriteriumslevel über alle Meetings sollte auch die **zeitliche Reihenfolge** berücksichtigt werden. Es sollte honoriert werden, wenn sich der Berater über die Laufzeit des Assignments in bestimmten Kriterien **verbessert** hat. **Verschlechterung** über die Zeit sollte ebenfalls berücksichtigt werden (negative Lernkurve). Diese Entwicklung sollte dann auch **im Kommentar an der richtigen Stelle** berücksichtigt werden.
|
||||
|
||||
Kernpunkte:
|
||||
1. **Zeitgewichtung** — spätere Meetings sollen stärker in den Kriterium-Durchschnitt einfließen als frühere (nicht der aktuelle gleichgewichtete Ansatz).
|
||||
2. **Trend-Erkennung** — sowohl positive als auch negative Entwicklung über die Assignment-Laufzeit soll erkannt werden.
|
||||
3. **Automatische Kommentar-Integration** — die erkannte Entwicklung soll an der "richtigen Stelle" im Feedback-Text auftauchen (vermutlich `FeedbackDimensionText.achievements`/`developmentNeeds`, evtl. über den bestehenden KI-Prompt-Mechanismus).
|
||||
4. **Vorschlag bleibt überschreibbar** — die zwei Strukturen (`Assessment` vs. `FeedbackCategoryRating`) sollen bewusst getrennt bleiben; der berechnete Vorschlag ersetzt nicht die manuelle Entscheidung des Feedbackgebers.
|
||||
|
||||
## Bereits vorhandene Bausteine (wiederverwendbar)
|
||||
|
||||
- `RATING_NUM_MAP` in `src/config/constants.ts` — numerische Skala für Aggregation
|
||||
- `weightedAvg()` in `src/pages/Evaluation.tsx` — aktueller (ungewichteter nach Zeit) Aggregationsansatz auf Item-/Kategorie-Ebene, als Ausgangspunkt für die zeitgewichtete Variante
|
||||
- `MeetingInstance.date` — Zeitstempel pro Meeting, Basis für Zeitgewichtung
|
||||
- `buildAiPrompt()` in `AssignmentFeedbackPage.tsx` — bestehender Mechanismus, der Assessment-Daten in strukturierten Text für die KI übersetzt; möglicher Ort, um Trend-Informationen einzuspeisen
|
||||
- Bekannter Bug-Pattern, der bei jeder neuen Aggregationslogik zu beachten ist: `score !== null` schließt `'na'` nicht aus — immer `score !== null && score !== 'na'` prüfen (siehe `CLAUDE.md`)
|
||||
|
||||
## Offene Design-Fragen (in der Planungssession zu klären)
|
||||
|
||||
1. **Gewichtungsformel**: Linear nach Meeting-Reihenfolge? Exponentiell abfallend nach Alter? Feste Gewichte (z.B. letztes Meeting zählt doppelt)? Muss robust für Assignments mit 2 bis 7+ Meetings funktionieren.
|
||||
2. **Trend-Schwellenwert**: Ab wann gilt eine Entwicklung als "signifikant" (z.B. mind. 1 Rating-Stufe Unterschied zwischen erstem und letztem gewerteten Meeting)? Wie viele Meetings mit Bewertung sind mindestens nötig, um überhaupt einen Trend zu behaupten?
|
||||
3. **Granularität**: Wird der Trend auf Kriterium-Ebene, Kategorie-Ebene oder beidem berechnet?
|
||||
4. **UI-Darstellung des Vorschlags**: Nebeneinander-Ansicht (Vorschlag vs. aktueller manueller Wert) mit "Übernehmen"-Button? Oder Vorschlag pre-fillt die Rating-Buttons direkt, überschreibbar wie jeder andere Wert?
|
||||
5. **Kommentar-Generierung**: Deterministisch (Template-Satz wie "Zeigt über die Laufzeit eine Verbesserung in X") oder über den bestehenden KI-Prompt-Mechanismus? Wie wird das mit dem offenen AI-Prompt-Bug (M2) koordiniert?
|
||||
6. **Code-Ort**: Soll die Trend-Berechnung eine gemeinsame Funktion sein, die sowohl `Evaluation.tsx` als auch `AssignmentFeedbackPage.tsx` nutzen (Vermeidung von Doppel-Logik)? Falls ja, wohin (`src/db/queries/`? neues `src/utils/ratingTrend.ts`?).
|
||||
7. **Naming/Labeling**: Wie wird in der UI klar unterschieden zwischen "berechneter Vorschlag" und "final vom Feedbackgeber bestätigter Wert"?
|
||||
|
||||
## Betroffene Dateien (voraussichtlich)
|
||||
|
||||
- `src/pages/Evaluation.tsx` — zeigt den Vorschlag an
|
||||
- `src/pages/AssignmentFeedbackPage.tsx` — übernimmt/überschreibt den Vorschlag, generiert Kommentar
|
||||
- `src/config/constants.ts` — evtl. neue Konstanten für Gewichtungsparameter
|
||||
- Neue Datei denkbar: `src/utils/ratingTrend.ts` oder `src/db/queries/ratingSuggestion.ts` für die gemeinsame Berechnungslogik
|
||||
|
|
@ -31,8 +31,9 @@ MeetingInstance ← Ein konkreter Termin (z.B. "Briefing C
|
|||
└── ConversationEntry[] ← Zeitleiste: wer spricht wann
|
||||
└── Assessment[] ← Gesamtbewertung pro Institutee am Ende
|
||||
|
||||
ConversationEntry ← Ein Gesprächsbeitrag eines Institutees
|
||||
ConversationEntry ← Ein zusammenhängender Sprecher-Beitrag (mehrzeilig) eines Institutees
|
||||
└── ConversationSkillScore[] ← Kategorie-Schnellbewertung (während des Beitrags)
|
||||
└── lineTags[] ← Kriterium-Zuordnung einzelner Zeilen (nachträglich, per Zeilentext)
|
||||
|
||||
FeedbackDimension ← Übergeordnete Dimension für das Assignment-Feedback
|
||||
└── FeedbackCategory[] ← Kategorie (z.B. "Kommunikationsfähigkeit")
|
||||
|
|
@ -79,7 +80,7 @@ Wiederholbare Phasen (z.B. Alignment Calls) werden mit fortlaufendem `phaseIndex
|
|||
|
||||
### Aggregationslogik
|
||||
|
||||
**Gewichteter Durchschnitt** über alle Kriterien mit `weight`-Faktor (×1–×5):
|
||||
**Gewichteter Durchschnitt** über alle Kriterien mit `weight`-Faktor (konfigurierbar `0,0`–`2,0` in `0,1`-Schritten — `0` blendet ein Kriterium komplett aus, z.B. für sprachliche Weichmacher/Füllwörter mit reduziertem Einfluss):
|
||||
|
||||
```
|
||||
avg = Σ(score_i × weight_i) / Σ(weight_i)
|
||||
|
|
@ -99,6 +100,26 @@ Mapping auf Rating:
|
|||
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.
|
||||
|
||||
Verbindendes drittes Element: **Kriterium-Zuordnung einzelner Protokollzeilen** (`ConversationEntry.lineTags`). Der Lead schreibt während des Gesprächs einen zusammenhängenden, mehrzeiligen Beitrag pro Sprecher-Block; nachträglich (nicht während des Tippens, um den Schreibfluss nicht zu stören) kann jede einzelne Zeile über ein kleines Tag-Symbol direkt einem Kriterium zugeordnet werden. Diese Zuordnung ist die Grundlage für den Notation-Vorschlag (siehe unten) und fließt auch strukturiert in den KI-Prompt ein.
|
||||
|
||||
---
|
||||
|
||||
## Bewertungs-Vorschläge
|
||||
|
||||
Die App leitet an zwei Stellen automatisch einen Bewertungs-Vorschlag ab — beide bleiben reine Vorschläge, die der Lead frei übernehmen oder überschreiben kann.
|
||||
|
||||
### 1. Notation-Vorschlag in der Gesamtbewertung (pro Meeting)
|
||||
|
||||
Die Schnellnotations-Symbole (siehe unten) entsprechen einer Bewertungsstufe: `!` → Not, `(!)` → Partially, `(+)` → Nearly, `+` → Fully. Ist eine notierte Protokollzeile einem Kriterium zugeordnet (siehe oben), errechnet die App daraus einen Vorschlag für die Gesamtbewertung dieses Kriteriums ("Vorschlag aus Protokoll" mit Übernehmen-Button). Bei mehreren getaggten Zeilen zum selben Kriterium wird gemittelt. Jede Kategorie-Kopfzeile zeigt zusätzlich — auch zugeklappt — wie viele ihrer Kriterien einen offenen Vorschlag haben.
|
||||
|
||||
Füllwörter oder sprachliche Weichmacher ("ein bisschen") werden nicht automatisch erkannt, sondern über ein eigenes, niedrig gewichtetes Kriterium abgebildet, das ganz normal getaggt und notiert wird.
|
||||
|
||||
### 2. Zeitgewichteter Vorschlag über die Assignment-Laufzeit (`/evaluation`, Abschluss-Feedback)
|
||||
|
||||
Über mehrere Meetings hinweg gewichtet die App spätere Bewertungen stärker als frühere (Gewichtungsfaktor konfigurierbar unter Config → Gewichtung) und erkennt eine Verbesserung oder Verschlechterung eines Institutees über die Zeit. Der Vorschlag erscheint als Badge auf der Abschluss-Feedback-Seite, mit „Übernehmen" für den Bewertungswert und „Trend in Text übernehmen" für einen automatisch formulierten Hinweis auf die Entwicklung in Achievements/Development Needs.
|
||||
|
||||
Beide Vorschlags-Mechanismen greifen nicht in die manuell gepflegten Endergebnisse ein (`Assessment.score`, `FeedbackCategoryRating`) — sie liefern nur einen Ausgangspunkt.
|
||||
|
||||
---
|
||||
|
||||
## Feedback-Struktur (Abschluss-Feedback)
|
||||
|
|
@ -142,8 +163,8 @@ Im Gesprächsprotokoll unterstützt die App eine Schnellnotations-Syntax, die fa
|
|||
**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
|
||||
- **Markdown:** Lesbare Dokumentation mit Protokoll, Bewertungen und Notizen — getaggte Protokollzeilen zeigen zusätzlich das zugeordnete Kriterium
|
||||
- **JSON:** Vollständige Datensicherung eines einzelnen Meetings, inklusive Zeilen-Tags
|
||||
|
||||
### 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.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt naht
|
|||
|
||||
---
|
||||
|
||||
## Stand & nächste Schritte
|
||||
|
||||
Zuletzt abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ratingTrend.ts`), Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags` + `CriterionTagPicker.tsx`), Notation → Bewertungs-Vorschlag in der Gesamtbewertung, feinere Kriterium-Gewichtung (0,0–2,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`.
|
||||
|
||||
**Vollständige, priorisierte Liste der offenen Punkte:** siehe `CLAUDE.md` → Abschnitt "Offene Punkte (priorisiert)" — das ist die laufend gepflegte Quelle, hier nicht dupliziert. Größere offene Themen, die eine eigene Design-Session brauchen: mehrstufiges Prompt-System für die KI-Generierung, selbstlernendes Tagging.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
|
@ -34,8 +42,9 @@ Aggregation: **gewichteter Durchschnitt** (avg < 1.5 → Not, < 2.5 → Partiall
|
|||
### Schlüssel-Unterschied: zwei `criteriaId`-Semantiken
|
||||
|
||||
```
|
||||
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting)
|
||||
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung während Gespräch)
|
||||
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting)
|
||||
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung während Gespräch)
|
||||
ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Kriterium-Zuordnung einer Protokoll-Zeile, bewusst anders benannt als "criteriaId")
|
||||
```
|
||||
|
||||
Das klingt inkonsistent — ist aber so gewollt und funktioniert so.
|
||||
|
|
@ -70,12 +79,12 @@ const filteredCats = itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!))
|
|||
/assignment/new Neues Assignment
|
||||
/assignment/:id AssignmentDetail
|
||||
/assignment/:id/edit AssignmentEdit
|
||||
/assignment/:id/meeting/:meetingId MeetingView ← WICHTIG: meetingId != assignmentId
|
||||
/assignment/:id/meeting/:meetingId MeetingView (src/pages/meeting/) ← WICHTIG: meetingId != assignmentId
|
||||
/assignment/:id/feedback/:instituteeId AssignmentFeedbackPage
|
||||
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
|
||||
/evaluation Auswertung (veraltet!)
|
||||
/evaluation Auswertung — zeitgewichteter Bewertungs-Vorschlag + Trend, nach Assignment gruppiert
|
||||
/consultants Berater
|
||||
/config Konfiguration (3 Tabs)
|
||||
/config Konfiguration (4 Tabs: Feedback | Ass.-Typen | Gewichtung | Backup)
|
||||
```
|
||||
|
||||
MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useParams()`
|
||||
|
|
@ -83,7 +92,7 @@ MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useP
|
|||
## Konfigurationsbereich
|
||||
|
||||
**Tab "Feedback"** (`FeedbackStructureConfig.tsx`):
|
||||
- Dimensionen → Kategorien → Kriterium-Items (mit Gewichtung ×1–×5)
|
||||
- Dimensionen → Kategorien → Kriterium-Items (mit Gewichtung `0,0–2,0` in `0,1`-Schritten, seit 2026-07-03 — vorher grobe `×1`–`×5`-Stufen)
|
||||
- Nur Feedback-Struktur, kein AssignmentType-CRUD mehr!
|
||||
|
||||
**Tab "Ass.-Typen"** (`AssignmentTypeConfig.tsx`):
|
||||
|
|
@ -91,6 +100,9 @@ MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useP
|
|||
- Pro Typ: Sub-Tabs "Phasen" und "Kriterien"
|
||||
- Kriterien-Tab: Baumstruktur Dimension → Kategorie → Items (Checkbox, Indeterminate)
|
||||
|
||||
**Tab "Gewichtung"** (`RatingWeightConfig.tsx`, seit 2026-07-02):
|
||||
- Ein Regler für den Zeitgewichtungs-Faktor des Bewertungs-Vorschlags (`AppSettings.ratingTrendWeightStep`, Tabelle `appSettings`)
|
||||
|
||||
**Tab "Backup"** (`Configuration.tsx`):
|
||||
- JSON-Export aller Tabellen via `exportFullBackup()`
|
||||
- JSON-Import via `importFullBackup(file)` (löscht alles, dann bulkAdd)
|
||||
|
|
@ -99,16 +111,22 @@ MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useP
|
|||
|
||||
### 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
|
||||
2. **Toter Code in FeedbackStructureConfig** — State-Variablen `assignmentTypes`, `phaseTemplates`, `showCatalogs`, `showTypeConfig`, `expandedType`, `newTypeName`, `newPhase`, `typeError` + alle Handler `toggleCatalogCategory`, `addAssignmentType`, `updateAssignmentTypeName`, `deleteAssignmentType`, `addPhaseTemplate`, `updatePhaseTemplate`, `deletePhaseTemplate` entfernen
|
||||
3. **N/A-Aggregationsbug**: Filter der Form `a.score !== null` schließen `'na'` nicht aus (N/A ist ein String, kein `null`!) → verfälscht Mittelwerte. Bereits gefixt in `Evaluation.tsx`. Noch offen in `AssignmentFeedbackPage.tsx:63` (buildAiPrompt) und `FeedbackDraftPage.tsx:66`.
|
||||
|
||||
### 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
|
||||
4. **FeedbackDraftPage.tsx** — nutzt noch Legacy `categories`/`criteria`, sollte auf `feedbackCriterionItems` migriert werden (analog zu `Evaluation.tsx`); letzter Konsument der Legacy-Tabellen außer den Seeds
|
||||
5. **`db/queries/`-Layer** — DB-Zugriffe aus Komponenten in domänenspezifische Query-Funktionen extrahieren, bereitet SQLite-Migration vor
|
||||
6. **KI-Generierung funktionsunfähig** — `generateWithAi()` schlägt fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`. Root Cause nicht untersucht.
|
||||
|
||||
### Große Aufgaben
|
||||
### Große Aufgaben (eigene Design-Session nötig)
|
||||
7. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus.
|
||||
8. **Mehrstufiges, pro Dimension konfigurierbares Prompt-System** — löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab, braucht Spezialwissen je Dimension + Platzhaltersystem.
|
||||
9. **Selbstlernendes Tagging** — aus bisherigen Zeile-zu-Kriterium-Zuordnungen automatische Tag-Vorschläge beim Tippen ableiten.
|
||||
|
||||
**Erledigt (2026-07-02):** Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung — `src/utils/ratingTrend.ts` berechnet pro Kategorie einen zeitgewichteten Vorschlag aus den Meeting-Assessments (Gewichtungsfaktor konfigurierbar unter Config → Gewichtung, neue Tabelle `appSettings`), erkennt Verbesserung/Verschlechterung über die Laufzeit und zeigt beides als Badge auf `AssignmentFeedbackPage` (mit „Übernehmen" + „Trend in Text übernehmen"). `buildAiPrompt` wurde um einen Trend-Abschnitt erweitert; dabei wurde auch der alte `db.criteria`-Bug mitgefixt.
|
||||
|
||||
**Erledigt (2026-07-03):** Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags`, `CriterionTagPicker.tsx`, nachträglich pro Zeile innerhalb eines zusammenhängenden Sprecher-Blocks). Notation → Bewertungs-Vorschlag in der Gesamtbewertung (`detectNotationRating()`, „Vorschlag aus Protokoll"-Badge + Kategorie-weite 💡-Zähler). `meetingExport.ts` und `dbBackup.ts` gefixt (Legacy-`db.criteria`-Bug bzw. fehlende `appSettings`-Tabelle im Backup). Assignment-Nummer ist jetzt Pflichtfeld. Kriterium-Gewichtung auf `0,0–2,0` verfeinert, dabei einen Division-durch-Null-Bug bei Gewicht `0` in allen gewichteten Mittelwerten gefunden und gefixt.
|
||||
|
||||
## Stil-Regeln (bitte einhalten)
|
||||
|
||||
|
|
@ -133,7 +151,7 @@ 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.
|
||||
Alle 21 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen dort ergänzen (ein fehlender Eintrag fällt nicht auf, bis jemand ein Backup zurückspielt und Daten fehlen — genau so ist `appSettings` einmal vergessen worden, siehe Kommentar über der Liste).
|
||||
|
||||
## Seed-Daten
|
||||
|
||||
|
|
@ -167,3 +185,5 @@ Vor jeder Abgabe: `npx tsc --noEmit` ausführen und alle Fehler beheben.
|
|||
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
|
||||
7. **Gewichtete Mittelwerte**: immer `Σ(score×weight)/Σ(weight)`, nie `score×weight` roh interpretieren; bei `Σweight === 0` (seit Gewicht `0` erlaubt ist) `null`/`continue` statt Division — sonst `NaN < x` überall `false` → fällt auf „Fully" durch. Siehe `catMode()` (`AssessmentTab.tsx`) und `buildGroupMeetingScores()` (`ratingTrend.ts`).
|
||||
8. **`ConversationEntry`** entspricht einem ganzen Sprecher-Turn (mehrzeiliger Text-Block), nicht einer einzelnen Zeile — ein Zwischenstand mit "ein Entry pro Zeile" wurde verworfen. Kriterium-Zuordnung läuft über `lineTags` (Matching per Zeilentext, nicht Index).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Projektstatus und offene Punkte — Assignment Monitor
|
||||
|
||||
Stand: 2026-07-02
|
||||
Stand: 2026-07-03
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -27,13 +27,24 @@ Stand: 2026-07-02
|
|||
| 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` |
|
||||
| Feedback-Dimensionsstruktur CRUD mit Gewichtung (0,0–2,0 in 0,1-Schritten) | `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 |
|
||||
| Konfiguration zentralisiert (Farben, Ratings, AI-Settings) | `src/config/constants.ts` |
|
||||
| `db/index.ts` in types/schema/seeds aufgeteilt (Re-Export bleibt kompatibel) | `src/db/` |
|
||||
| `MeetingView.tsx` in 3 Dateien aufgeteilt | `src/pages/meeting/` |
|
||||
| Auswertung auf `feedbackCriterionItems` migriert, nach Assignment gruppiert, Papierkorb/Status gefiltert, N/A-Bug gefixt | `Evaluation.tsx` |
|
||||
| Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung (konfigurierbarer Gewichtungsfaktor, Vorschlags-Badge auf Feedback-Seite, Trend-Satz-Insertion, KI-Prompt-Erweiterung) | `Evaluation.tsx`, `AssignmentFeedbackPage.tsx`, `src/utils/ratingTrend.ts`, `RatingWeightConfig.tsx` |
|
||||
| Kriterium-Zuordnung für Freitext-Notizen — pro Zeile innerhalb eines zusammenhängenden Sprecher-Blocks (`ConversationEntry.lineTags`, Matching per Zeilentext), Chip+Popover mit Suche direkt an der Zeile, nachträglich im Protokoll; `buildAiPrompt` führt Assessment- und Konversations-Notizen pro Kriterium zusammen | `ConversationTab.tsx`, `CriterionTagPicker.tsx`, `notationParser.tsx`, `MeetingView.tsx`, `AssignmentFeedbackPage.tsx` |
|
||||
| Notation → Bewertungs-Vorschlag in der Gesamtbewertung: `!`/`(!)`/`(+)`/`+` an getaggten Zeilen ergeben einen Rating-Vorschlag je Kriterium ("Vorschlag aus Protokoll" + Übernehmen), plus Kategorie-Kopfzeile zeigt Anzahl offener Vorschläge (💡 N) auch zugeklappt | `AssessmentTab.tsx`, `notationParser.tsx` (`detectNotationRating`) |
|
||||
| Meeting-Export (Markdown) zeigt Kriterium-Namen korrekt (`db.criteria`-Legacy-Bug gefixt) + Kriterium pro getaggter Zeile; Voll-Backup deckt wieder alle Tabellen ab (`appSettings` ergänzt) | `meetingExport.ts`, `dbBackup.ts` |
|
||||
| Assignment-Nummer ist Pflichtfeld beim Anlegen | `AssignmentCreate.tsx` |
|
||||
| Kriterium-Gewichtung feiner (0,0–2,0 in 0,1-Schritten statt `×1`–`×5`), inkl. Division-durch-Null-Fix bei Gewicht 0 in allen gewichteten Mittelwerten | `FeedbackStructureConfig.tsx`, `AssessmentTab.tsx`, `ratingTrend.ts` |
|
||||
| Sprecher eines Protokoll-Beitrags nachträglich änderbar (im Bearbeiten-Modus) | `ConversationTab.tsx`, `MeetingView.tsx` |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -43,25 +54,26 @@ Stand: 2026-07-02
|
|||
|
||||
| # | 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 |
|
||||
| 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/schema.ts` | L (Wochen) |
|
||||
| H2 | **Backup-Reminder fehlt**: Es gibt keine automatische Erinnerung / keinen Hinweis nach abgeschlossenem Meeting, dass ein Backup erstellt werden sollte. | `pages/meeting/MeetingView.tsx` | S |
|
||||
| H3 | **N/A-Aggregationsbug**: Filter `a.score !== null` schließt den String `'na'` nicht aus, verfälscht Mittelwerte ("Fantasiewerte"). Gefixt in `Evaluation.tsx`, noch offen in `AssignmentFeedbackPage.tsx:63` und `FeedbackDraftPage.tsx:66`. | `AssignmentFeedbackPage.tsx`, `FeedbackDraftPage.tsx` | XS |
|
||||
| H6 | **KI-Generierung funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert den Call zu `openrouter.ai`. Root Cause noch nicht untersucht. | `AssignmentFeedbackPage.tsx` | ? |
|
||||
| H7 | **Prompt-System-Neubau (noch nicht gescoped)**: Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Spezialwissen je Dimension + Platzhaltersystem, löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab. Braucht eigene Design-Session. | `AssignmentFeedbackPage.tsx` | L |
|
||||
|
||||
### 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 |
|
||||
| M4 | **FeedbackDraftPage.tsx veraltet**: Nutzt noch Legacy-`Category`/`Criterion`-Tabellen, analog zum ehemaligen Zustand von `Evaluation.tsx`. Letzter verbleibender Konsument der Legacy-Tabellen außer den Seeds. | `FeedbackDraftPage.tsx` | M |
|
||||
| M5 | **`db/queries/`-Layer fehlt**: DB-Zugriffe direkt aus Komponenten (`db.*`), keine Abstraktionsschicht. Erschwert die geplante SQLite-Migration (H1). | `src/db/queries/` (neu) | M |
|
||||
| M6 | **Selbstlernendes Tagging (Idee, noch nicht gescoped)**: Zeile-zu-Kriterium-Zuordnungen sammeln und daraus beim Tippen automatische Tag-Vorschläge ableiten. Braucht eigene Design-Session. | `CriterionTagPicker.tsx` | ? |
|
||||
|
||||
### 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 |
|
||||
|
||||
|
|
@ -75,11 +87,19 @@ Stand: 2026-07-02
|
|||
**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
|
||||
### Evaluation — Zeitgewichteter Vorschlag (erledigt, 2026-07-02)
|
||||
Vollständig umgesetzt, siehe `docs/EVALUATION_FEEDBACK_REDESIGN.md` für die Design-Entscheidungen:
|
||||
- Kategorie-Scores werden über `src/utils/ratingTrend.ts` zeitgewichtet gemittelt (Rang = Reihenfolge der tatsächlich erfolgten Bewertungen dieser Kategorie, nicht fester Meeting-Index); Gewichtungsfaktor konfigurierbar unter Config → Gewichtung (`RatingWeightConfig.tsx`, Tabelle `appSettings`)
|
||||
- Trend (Verbesserung/Verschlechterung) wird ab 2 bewerteten Meetings pro Kategorie erkannt (Delta ≥ 0,5 Punkte)
|
||||
- Vorschlags-Badge auf `AssignmentFeedbackPage` dauerhaft sichtbar neben dem manuellen Rating, mit „Übernehmen“ und „Trend in Text übernehmen" (fügt deterministischen Satz in Achievements/Development Needs ein)
|
||||
- `buildAiPrompt` um Trend-Abschnitt erweitert; dabei auch den Legacy-`db.criteria`-Bug (ehem. M2) gefixt
|
||||
- `Assessment`- und `FeedbackCategoryRating`-Daten bleiben bewusst getrennte, unabhängige Strukturen — der Vorschlag ist rein additiv
|
||||
|
||||
### Kriterium-Tagging + Notation-Vorschlag (erledigt, 2026-07-03)
|
||||
- `ConversationEntry.lineTags: { text, criterionItemId }[]` ordnet einzelne Zeilen innerhalb eines Sprecher-Blocks einem Kriterium zu (Matching per exaktem Zeilentext, nicht Index — überlebt nachträgliches Einfügen/Löschen von Zeilen). Ein früherer Zwischenstand mit "ein Entry pro Zeile" wurde verworfen, weil er die Protokoll-Lesbarkeit zerstört hat.
|
||||
- `detectNotationRating()` (`notationParser.tsx`) mappt `!`/`(!)`/`(+)`/`+` auf eine Bewertungsstufe; kombiniert mit getaggten Zeilen liefert `AssessmentTab.tsx` einen Rating-Vorschlag pro Kriterium-Item ("Vorschlag aus Protokoll"), unabhängig von der bestehenden `ConversationSkillScore`-Kategorie-Schnellbewertung.
|
||||
- Füllwörter/Weichmacher-Formulierungen werden bewusst nicht über eigene Text-Erkennung behandelt, sondern über ein niedrig gewichtetes Kriterium + normales Tagging abgedeckt.
|
||||
- Kriterium-Gewichtung dabei auf `0,0–2,0` (0,1-Schritte) verfeinert; ein latenter Division-durch-Null-Bug bei Gewicht `0` in allen gewichteten Mittelwerten (`catMode`, `ratingTrend.ts`) wurde dabei gefunden und gefixt.
|
||||
|
||||
### Dashboard-Verbesserungen (N1 oben)
|
||||
- Assignment-Nummer anzeigen
|
||||
|
|
@ -95,4 +115,5 @@ Auswertungsseite auf neue Datenstruktur migrieren. Soll zeigen:
|
|||
| `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 |
|
||||
| `ConversationEntry.lineTags[].criterionItemId` | Referenziert `FeedbackCriterionItem.id`. Kein FK-Constraint — beim Löschen eines Kriterium-Items bleiben Zeilen-Tags verwaist (Anzeige zeigt dann kein Kriterium mehr, aber Datensatz bleibt). | Beim Löschen von Items: `lineTags` in betroffenen `conversationEntries` 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 |
|
||||
|
|
|
|||
|
|
@ -17,29 +17,44 @@
|
|||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
## Projektstruktur (aktualisiert nach Architektur-Refactoring, 2026-07-02)
|
||||
|
||||
```
|
||||
src/
|
||||
config/
|
||||
constants.ts ← RATING_OPTIONS, INST_COLORS, AI_CONFIG, RATING_NUM_MAP, BRAND_COLOR, DEFAULT_RATING_TREND_WEIGHT_STEP, RATING_TREND_DELTA_THRESHOLD
|
||||
db/
|
||||
index.ts ← Dexie DB-Klasse, alle Interfaces, Seed-Funktionen, Migrations
|
||||
index.ts ← reiner Re-Export von types/schema/seeds (bestehende Imports bleiben kompatibel)
|
||||
types.ts ← alle Interfaces, FeedbackRating-Typ
|
||||
schema.ts ← Dexie DB-Klasse + alle Migrationsversionen
|
||||
seeds/
|
||||
assignmentTypes.ts ← seedIfEmpty() — 3 AssignmentTypen + Legacy-Kriterien
|
||||
feedbackStructure.ts ← seedFeedbackStructureIfEmpty() — Dimensionen/Kategorien/Items
|
||||
migrations.ts ← seedCriterionMappingsIfEmpty(), migrateToUnifiedCriteriaIfNeeded()
|
||||
index.ts ← Re-Export der Seed-Funktionen
|
||||
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)
|
||||
meeting/
|
||||
MeetingView.tsx ← Route-Komponente: State, DB-Handler, Header, Tabs
|
||||
ConversationTab.tsx ← Gesprächsprotokoll-UI
|
||||
AssessmentTab.tsx ← Gesamtbewertungs-UI (Accordion je Kategorie), Notation-Vorschlag
|
||||
CriterionTagPicker.tsx ← Chip+Popover mit Suche, Kriterium-Zuordnung pro Protokoll-Zeile
|
||||
AssignmentFeedbackPage.tsx ← Strukturiertes Abschluss-Feedback + KI-Generierung + Trend-Vorschlag
|
||||
FeedbackDraftPage.tsx ← Einfacher Feedback-Entwurf (Legacy, nutzt noch alte Criterion-Tabelle)
|
||||
Evaluation.tsx ← Auswertung — zeitgewichteter Bewertungs-Vorschlag + Trend, gruppiert nach Assignment
|
||||
Configuration.tsx ← Tab-Container für alle Konfigurationsseiten
|
||||
FeedbackStructureConfig.tsx ← Dimensionen/Kategorien/Kriterien CRUD + Gewichtung
|
||||
AssignmentTypeConfig.tsx ← AssignmentTypen + Phasen + Kriterien-Auswahl (Baum)
|
||||
RatingWeightConfig.tsx ← Config-Tab "Gewichtung": Zeitgewichtungs-Faktor für Bewertungs-Vorschlag
|
||||
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
|
||||
notationParser.tsx ← +/!/>/[…] Notation → farbiges JSX, splitNoteLines(), detectNotationRating()
|
||||
ratingTrend.ts ← Zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung
|
||||
App.tsx ← Route-Definitionen, Navigation
|
||||
main.tsx ← React-Einstiegspunkt, DB-Seed-Aufruf
|
||||
```
|
||||
|
|
@ -48,10 +63,10 @@ src/
|
|||
|
||||
## Datenmodell (IndexedDB via Dexie v5)
|
||||
|
||||
### DB-Version: 5
|
||||
### DB-Version: 6
|
||||
|
||||
```typescript
|
||||
// Konfiguration (Legacy, nur noch für Evaluation.tsx)
|
||||
// Konfiguration (Legacy, nur noch für FeedbackDraftPage.tsx + Seeds)
|
||||
categories: '++id, order'
|
||||
criteria: '++id, categoryId, order'
|
||||
|
||||
|
|
@ -88,8 +103,13 @@ feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId'
|
|||
// Mappings (Legacy, kaum genutzt)
|
||||
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId'
|
||||
criterionLevelDescriptions: '++id, criterionItemId, rating'
|
||||
|
||||
// App-Einstellungen (Singleton, id immer 1), seit Version 6
|
||||
appSettings: '++id'
|
||||
```
|
||||
|
||||
`ConversationEntry.lineTags` (Kriterium-Zuordnung pro Zeile) ist ein reines Objektfeld ohne eigenen Index — kein Versions-Bump nötig, da Dexies `.stores()` nur Indizes definiert, nicht das volle Objekt-Schema.
|
||||
|
||||
### Wichtige Interface-Details
|
||||
|
||||
```typescript
|
||||
|
|
@ -135,12 +155,34 @@ interface FeedbackCriterionItem {
|
|||
id?: number
|
||||
categoryId: number
|
||||
name: string
|
||||
weight?: number // Gewichtung ×1–×5, default 1
|
||||
weight?: number // 0,0–2,0 in 0,1-Schritten, default 1 (seit 2026-07-03; vorher grobe ×1–×5-Stufen)
|
||||
order: number
|
||||
}
|
||||
|
||||
// Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
|
||||
// ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
|
||||
interface ConversationLineTag {
|
||||
text: string // exakter Zeilentext, 1:1 wie in ConversationEntry.note
|
||||
criterionItemId: number // → FeedbackCriterionItem.id
|
||||
}
|
||||
|
||||
interface ConversationEntry {
|
||||
id?: number
|
||||
meetingInstanceId: number
|
||||
instituteeId: number
|
||||
sequenceIndex: number
|
||||
note: string // ganzer Sprecher-Turn, mehrzeilig
|
||||
fillerCount: number
|
||||
updatedAt: string
|
||||
lineTags?: ConversationLineTag[] // Kriterium-Zuordnung pro Zeile, gematcht per Zeilentext
|
||||
}
|
||||
|
||||
interface AppSettings {
|
||||
id?: number // Singleton, immer 1
|
||||
ratingTrendWeightStep: number // Zeitgewichtungs-Faktor für den Bewertungs-Vorschlag
|
||||
}
|
||||
|
||||
// Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
|
||||
// ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
|
||||
// ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Zeilen-Zuordnung, bewusst anders benannt)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -157,7 +199,7 @@ interface FeedbackCriterionItem {
|
|||
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
|
||||
/evaluation Auswertungsübersicht
|
||||
/consultants Berater-Verwaltung
|
||||
/config Konfiguration (3 Tabs: Feedback | Ass.-Typen | Backup)
|
||||
/config Konfiguration (4 Tabs: Feedback | Ass.-Typen | Gewichtung | Backup)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -168,7 +210,7 @@ interface FeedbackCriterionItem {
|
|||
CRUD für:
|
||||
- Feedback-Dimensionen (mit Reihenfolge ↑/↓)
|
||||
- Feedback-Kategorien je Dimension
|
||||
- Feedback-Kriterium-Items je Kategorie (mit Gewichtung ×1–×5)
|
||||
- Feedback-Kriterium-Items je Kategorie (mit Gewichtung `0,0–2,0` in `0,1`-Schritten)
|
||||
|
||||
### Tab "Ass.-Typen" → `AssignmentTypeConfig`
|
||||
CRUD für:
|
||||
|
|
@ -177,6 +219,9 @@ CRUD für:
|
|||
- Kriterien-Auswahl je Typ: Baumstruktur Dimension → Kategorie → Item (mit Checkbox, Indeterminate-State)
|
||||
- `criteriaIds = []` → alle Kriterien werden angezeigt
|
||||
|
||||
### Tab "Gewichtung" → `RatingWeightConfig`
|
||||
- Ein Regler für `AppSettings.ratingTrendWeightStep` (Zeitgewichtungs-Faktor des Bewertungs-Vorschlags), mit Live-Vorschau der resultierenden Gewichte
|
||||
|
||||
### Tab "Backup" → in `Configuration.tsx`
|
||||
- JSON-Export aller Tabellen
|
||||
- Zweistufiger JSON-Import mit Überschreib-Warnung
|
||||
|
|
@ -197,6 +242,7 @@ function catMode(cat: FeedbackCategory, fbItems: FeedbackCriterionItem[], getSco
|
|||
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
||||
if (scored.length === 0) return null
|
||||
const wSum = scored.reduce((s, x) => s + x.w, 0)
|
||||
if (wSum === 0) return null // alle beteiligten Kriterien auf Gewicht 0 → sonst NaN < x überall false, fällt auf "Fully" durch
|
||||
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'
|
||||
|
|
@ -205,6 +251,8 @@ function catMode(cat: FeedbackCategory, fbItems: FeedbackCriterionItem[], getSco
|
|||
}
|
||||
```
|
||||
|
||||
Dieselbe `Σweight === 0`-Absicherung gilt für jede gewichtete Mittelung in der App (auch `buildGroupMeetingScores()` in `ratingTrend.ts`) — seit Gewicht `0` ein gültiger Wert ist, ist das kein theoretischer Randfall mehr.
|
||||
|
||||
---
|
||||
|
||||
## Export-Dateiname-Schema
|
||||
|
|
@ -227,8 +275,9 @@ const buildFilename = (ext: string) => {
|
|||
- **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
|
||||
- **Datenweitergabe:** Kriterien-Scores + Notizen (Assessment und getaggte Protokoll-Zeilen zusammengeführt pro Kriterium), Kategorie-Bewertungen, zeitgewichteter Trend pro Kategorie werden im Prompt strukturiert übermittelt (`buildAiPrompt`, nutzt korrekt `feedbackCriterionItems`, alter `db.criteria`-Bug ist gefixt)
|
||||
- **Bekanntes Problem:** Der Call schlägt aktuell fehl (vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`), Root Cause nicht untersucht
|
||||
- **Geplanter Umbau (nicht gescoped):** Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Platzhaltersystem soll den aktuellen Einzel-Prompt ablösen
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -237,7 +286,7 @@ const buildFilename = (ext: string) => {
|
|||
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)
|
||||
2. **Legacy-Kriterien** in `categories` + `criteria` (nur noch von `FeedbackDraftPage.tsx` genutzt, `Evaluation.tsx` ist längst auf `feedbackCriterionItems` migriert)
|
||||
3. **Feedback-Dimensionsstruktur** mit 5 Dimensionen, 14 Kategorien, ~70 Kriterium-Items (Capgemini-Kompetenzmodell)
|
||||
4. **Criterion-Mappings** (Legacy, nicht mehr aktiv genutzt)
|
||||
|
||||
|
|
@ -247,10 +296,13 @@ Die App legt beim ersten Start automatisch an:
|
|||
|
||||
| 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 |
|
||||
| `FeedbackDraftPage.tsx` | Nutzt alte `Category`/`Criterion`-Tabellen statt neue `feedbackCriterionItems`; letzter Konsument der Legacy-Tabellen außer den Seeds | Mittel |
|
||||
| `meetingExport.ts` | Bewertungs-Labels im Markdown-Export zeigen `FeedbackRating`-Keys (z.B. `client_ready`) statt sprechender 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 |
|
||||
| AI-Prompt & `FeedbackDraftPage` | N/A-Aggregationsbug: `score !== null` schließt `'na'` nicht aus, verfälscht Mittelwerte | Hoch |
|
||||
| KI-Generierung | `generateWithAi()` schlägt fehl (vermutlich Netzwerk/Firewall), Root Cause offen | Hoch |
|
||||
| Prompt-System | Aktueller Einzel-Prompt soll mehrstufig/pro Dimension konfigurierbar werden — eigene Design-Session nötig | Hoch (nicht gescoped) |
|
||||
| Selbstlernendes Tagging | Idee, automatische Tag-Vorschläge aus bisherigen Zuordnungen abzuleiten — eigene Design-Session nötig | Mittel (nicht gescoped) |
|
||||
| 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 |
|
||||
| `db/queries/`-Layer | Fehlt — DB-Zugriffe direkt aus Komponenten, erschwert SQLite-Migration | Mittel |
|
||||
|
|
|
|||
|
|
@ -44,3 +44,9 @@ export const RATING_NUM_MAP: Record<FeedbackRating, number> = {
|
|||
nearly_client_ready: 3,
|
||||
client_ready: 4,
|
||||
}
|
||||
|
||||
/** Default-Zeitgewichtung für den Bewertungs-Vorschlag, überschreibbar über Config > Gewichtung. */
|
||||
export const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5
|
||||
|
||||
/** Mindest-Punktedelta zwischen erstem und letztem bewerteten Meeting einer Kategorie, ab dem ein Trend gilt. */
|
||||
export const RATING_TREND_DELTA_THRESHOLD = 0.5
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
|
||||
AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText,
|
||||
CriterionCategoryMapping, CriterionLevelDescription,
|
||||
AppSettings,
|
||||
} from './types'
|
||||
|
||||
class AssignmentMonitorDB extends Dexie {
|
||||
|
|
@ -32,6 +33,7 @@ class AssignmentMonitorDB extends Dexie {
|
|||
feedbackDimensionTexts!: Table<FeedbackDimensionText>
|
||||
criterionCategoryMappings!: Table<CriterionCategoryMapping>
|
||||
criterionLevelDescriptions!: Table<CriterionLevelDescription>
|
||||
appSettings!: Table<AppSettings>
|
||||
|
||||
constructor() {
|
||||
super('AssignmentMonitorDB')
|
||||
|
|
@ -129,6 +131,31 @@ class AssignmentMonitorDB extends Dexie {
|
|||
meetings: null,
|
||||
consultantNotes: null,
|
||||
})
|
||||
this.version(6).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',
|
||||
appSettings: '++id',
|
||||
meetings: null,
|
||||
consultantNotes: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,11 @@ export interface MeetingInstance {
|
|||
|
||||
// ─── Gesprächs-Zeitleiste ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ConversationLineTag {
|
||||
text: string
|
||||
criterionItemId: number // → FeedbackCriterionItem.id
|
||||
}
|
||||
|
||||
export interface ConversationEntry {
|
||||
id?: number
|
||||
meetingInstanceId: number
|
||||
|
|
@ -106,6 +111,7 @@ export interface ConversationEntry {
|
|||
note: string
|
||||
fillerCount: number
|
||||
updatedAt: string
|
||||
lineTags?: ConversationLineTag[] // Kriterium-Zuordnung pro Zeile, gematcht per exaktem Zeilentext
|
||||
}
|
||||
|
||||
// criteriaId → FeedbackCategory.id (Schnellbewertung während Gesprächsbeitrag)
|
||||
|
|
@ -200,3 +206,10 @@ export interface CriterionLevelDescription {
|
|||
rating: FeedbackRating
|
||||
text: string
|
||||
}
|
||||
|
||||
// ─── App-Einstellungen (Singleton, id immer 1) ───────────────────────────────
|
||||
|
||||
export interface AppSettings {
|
||||
id?: number
|
||||
ratingTrendWeightStep: number // 0 = keine Zeitgewichtung, höher = stärkere Gewichtung späterer Meetings
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export default function AssignmentCreate() {
|
|||
title: '',
|
||||
client: '',
|
||||
leadName: '',
|
||||
assignmentNumber: '',
|
||||
assignmentTypeId: 0,
|
||||
selectedGroupId: 0,
|
||||
instituteeIds: [] as number[],
|
||||
|
|
@ -66,11 +67,12 @@ export default function AssignmentCreate() {
|
|||
}
|
||||
|
||||
const create = async () => {
|
||||
if (!form.title.trim() || !form.client.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return
|
||||
if (!form.title.trim() || !form.client.trim() || !form.assignmentNumber.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return
|
||||
const id = await db.assignments.add({
|
||||
title: form.title,
|
||||
client: form.client,
|
||||
leadName: form.leadName,
|
||||
assignmentNumber: form.assignmentNumber.trim(),
|
||||
assignmentTypeId: form.assignmentTypeId,
|
||||
instituteeIds: form.instituteeIds,
|
||||
status: 'active',
|
||||
|
|
@ -79,7 +81,7 @@ export default function AssignmentCreate() {
|
|||
navigate(`/assignment/${id}`)
|
||||
}
|
||||
|
||||
const canCreate = form.title.trim() && form.client.trim() && form.assignmentTypeId && form.instituteeIds.length > 0
|
||||
const canCreate = form.title.trim() && form.client.trim() && form.assignmentNumber.trim() && form.assignmentTypeId && form.instituteeIds.length > 0
|
||||
const selectedInstitutees = consultants.filter(c => form.instituteeIds.includes(c.id!))
|
||||
|
||||
return (
|
||||
|
|
@ -88,6 +90,9 @@ export default function AssignmentCreate() {
|
|||
|
||||
{/* 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-brand"
|
||||
placeholder="Assignment-Nummer (Pflichtfeld)" value={form.assignmentNumber}
|
||||
onChange={e => setForm(p => ({ ...p, assignmentNumber: e.target.value }))} />
|
||||
<input className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
|
||||
placeholder="Titel des Assignments" value={form.title}
|
||||
onChange={e => setForm(p => ({ ...p, title: e.target.value }))} />
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import {
|
|||
type FeedbackDimensionText,
|
||||
type FeedbackRating,
|
||||
} from '../db'
|
||||
import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG } from '../config/constants'
|
||||
import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG, DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants'
|
||||
import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend'
|
||||
import { splitNoteLines } from '../utils/notationParser'
|
||||
|
||||
const ratingToNum = (r: FeedbackRating | null | undefined): number =>
|
||||
r ? (RATING_NUM_MAP[r] ?? 0) : 0
|
||||
|
|
@ -20,6 +22,12 @@ const ratingToNum = (r: FeedbackRating | null | undefined): number =>
|
|||
const ratingColor = (r: FeedbackRating | undefined) =>
|
||||
RATING_OPTIONS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200'
|
||||
|
||||
const trendArrow = (trend: TrendResult['trend']) => {
|
||||
if (trend === 'up') return <span title="Verbesserung über die Laufzeit">↑</span>
|
||||
if (trend === 'down') return <span title="Verschlechterung über die Laufzeit">↓</span>
|
||||
return null
|
||||
}
|
||||
|
||||
function getAiSettings() {
|
||||
return {
|
||||
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
|
||||
|
|
@ -39,9 +47,9 @@ async function buildAiPrompt(
|
|||
assignment: Assignment,
|
||||
dimensions: FeedbackDimension[],
|
||||
categories: FeedbackCategory[],
|
||||
_items: FeedbackCriterionItem[],
|
||||
items: FeedbackCriterionItem[],
|
||||
ratings: FeedbackCategoryRating[],
|
||||
_feedbackId: number,
|
||||
catTrends: Map<number, TrendResult>,
|
||||
): Promise<string> {
|
||||
const meetings = await db.meetingInstances.where('assignmentId').equals(assignment.id!).toArray()
|
||||
const meetingIds = meetings.map(m => m.id!)
|
||||
|
|
@ -54,24 +62,33 @@ async function buildAiPrompt(
|
|||
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 allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
|
||||
|
||||
const critIds = [...new Set([
|
||||
...instAssessments.map(a => a.criteriaId),
|
||||
...allLineTags.map(t => t.criterionItemId),
|
||||
])]
|
||||
const crits = items.filter(i => critIds.includes(i.id!))
|
||||
|
||||
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)
|
||||
const notes = [
|
||||
...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note),
|
||||
...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text),
|
||||
]
|
||||
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}]` : ''}`)
|
||||
.flatMap(e => splitNoteLines(e.note).map(line => ({
|
||||
line, tagged: (e.lineTags ?? []).some(t => t.text === line),
|
||||
})))
|
||||
.filter(x => !x.tagged)
|
||||
.map(x => ` - ${x.line}`)
|
||||
.join('\n')
|
||||
|
||||
// Kategorie-Ratings
|
||||
|
|
@ -85,6 +102,22 @@ async function buildAiPrompt(
|
|||
return ` Dimension "${dim.name}":\n${lines}`
|
||||
}).join('\n')
|
||||
|
||||
// Entwicklung über die Laufzeit (zeitgewichteter Trend pro Kategorie)
|
||||
const trendLines = dimensions.map(dim => {
|
||||
const dimCats = categories.filter(c => c.dimensionId === dim.id)
|
||||
const lines = dimCats.map(cat => {
|
||||
const t = catTrends.get(cat.id!)
|
||||
if (!t || t.history.length === 0) return ` ${cat.name}: keine Verlaufsdaten`
|
||||
const seq = t.history.map(h => h.score.toFixed(1)).join(' → ')
|
||||
const trendLabel = t.trend === 'up' ? 'Verbesserung über die Laufzeit'
|
||||
: t.trend === 'down' ? 'Verschlechterung über die Laufzeit'
|
||||
: t.trend === 'stable' ? 'stabil'
|
||||
: 'nur ein Meeting, kein Trend ableitbar'
|
||||
return ` ${cat.name}: Verlauf ${seq} (${trendLabel})`
|
||||
}).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}`
|
||||
|
|
@ -100,13 +133,16 @@ 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)'}
|
||||
PROTOKOLL-NOTIZEN, KEINEM KRITERIUM ZUGEORDNET (Notation: + gut, ! negativ, > Kundeninput):
|
||||
${protocolLines || '(keine nicht zugeordneten 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".
|
||||
ENTWICKLUNG ÜBER DIE LAUFZEIT (chronologische Kategorie-Scores aus den Meetings, Skala 0-4):
|
||||
${trendLines}
|
||||
|
||||
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". Erwähne eine erkennbare Verbesserung im Achievements-Absatz und eine erkennbare Verschlechterung im Development-Needs-Absatz der jeweiligen Dimension.
|
||||
|
||||
Ausgabe als JSON:
|
||||
{
|
||||
|
|
@ -142,6 +178,7 @@ export default function AssignmentFeedbackPage() {
|
|||
const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([])
|
||||
const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([])
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||
const [catTrends, setCatTrends] = useState<Map<number, TrendResult>>(new Map())
|
||||
const [showAiSettings, setShowAiSettings] = useState(false)
|
||||
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
|
||||
const [aiModel, setAiModel] = useState(getAiSettings().model)
|
||||
|
|
@ -163,6 +200,27 @@ export default function AssignmentFeedbackPage() {
|
|||
setCategories(cats)
|
||||
setItems(itms)
|
||||
|
||||
const meetings = (await db.meetingInstances.where('assignmentId').equals(assignmentId).toArray())
|
||||
.filter(m => !m.deletedAt && m.status === 'done')
|
||||
.sort((x, y) => new Date(x.date).getTime() - new Date(y.date).getTime())
|
||||
const meetingIds = meetings.map(m => m.id!)
|
||||
const [meetingAssessments, settings] = await Promise.all([
|
||||
meetingIds.length > 0 ? db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray() : Promise.resolve([]),
|
||||
db.appSettings.get(1),
|
||||
])
|
||||
const weightStep = settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP
|
||||
const instAssessments = meetingAssessments.filter(as => as.instituteeId === instId)
|
||||
const getScore = (meetingId: number, itemId: number): FeedbackRating | null =>
|
||||
instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null
|
||||
|
||||
const trends = new Map<number, TrendResult>()
|
||||
for (const cat of cats) {
|
||||
const catItems = itms.filter(i => i.categoryId === cat.id)
|
||||
const history = buildGroupMeetingScores(catItems, meetings, getScore)
|
||||
trends.set(cat.id!, computeTrend(history, weightStep))
|
||||
}
|
||||
setCatTrends(trends)
|
||||
|
||||
let fb = await db.assignmentFeedbacks
|
||||
.where('assignmentId').equals(assignmentId)
|
||||
.filter(f => f.instituteeId === instId)
|
||||
|
|
@ -243,6 +301,17 @@ export default function AssignmentFeedbackPage() {
|
|||
const getDimText = (dimId: number) =>
|
||||
dimTexts.find(t => t.feedbackDimensionId === dimId)
|
||||
|
||||
const appendTrendSentence = (dimId: number, cat: FeedbackCategory, trend: TrendResult) => {
|
||||
if (trend.trend !== 'up' && trend.trend !== 'down') return
|
||||
const field = trend.trend === 'up' ? 'achievements' : 'developmentNeeds'
|
||||
const firstLabel = RATING_OPTIONS.find(o => o.value === numberToRating(trend.history[0].score))?.short
|
||||
const lastLabel = RATING_OPTIONS.find(o => o.value === numberToRating(trend.history[trend.history.length - 1].score))?.short
|
||||
const verb = trend.trend === 'up' ? 'Verbesserung' : 'Verschlechterung'
|
||||
const sentence = `Zeigt im Verlauf des Assignments in ${cat.name} eine ${verb} von ${firstLabel} zu ${lastLabel}.`
|
||||
const existing = getDimText(dimId)?.[field] ?? ''
|
||||
saveDimText(dimId, field, existing ? `${existing}\n${sentence}` : sentence)
|
||||
}
|
||||
|
||||
// ── KI-Generierung ────────────────────────────────────────────────────────
|
||||
|
||||
const generateWithAi = async () => {
|
||||
|
|
@ -253,7 +322,7 @@ export default function AssignmentFeedbackPage() {
|
|||
setGenerating(true)
|
||||
setAiError('')
|
||||
try {
|
||||
const prompt = await buildAiPrompt(institutee, assignment, dimensions, categories, items, ratings, feedback.id)
|
||||
const prompt = await buildAiPrompt(institutee, assignment, dimensions, categories, items, ratings, catTrends)
|
||||
|
||||
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
|
|
@ -462,6 +531,33 @@ export default function AssignmentFeedbackPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Bewertungs-Vorschlag aus Meeting-Assessments */}
|
||||
{(() => {
|
||||
const trend = catTrends.get(cat.id!)
|
||||
if (!trend || trend.suggestion === null) return null
|
||||
const suggestedRating = numberToRating(trend.suggestion)
|
||||
const shortLabel = RATING_OPTIONS.find(o => o.value === suggestedRating)?.short
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-1 rounded-lg font-medium">
|
||||
Vorschlag: {shortLabel} ({trend.suggestion.toFixed(1)}) {trendArrow(trend.trend)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setRating(cat.id!, suggestedRating)}
|
||||
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-lg font-medium hover:bg-blue-200">
|
||||
Übernehmen
|
||||
</button>
|
||||
{(trend.trend === 'up' || trend.trend === 'down') && (
|
||||
<button
|
||||
onClick={() => appendTrendSentence(dim.id!, cat, trend)}
|
||||
className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-lg font-medium hover:bg-gray-200">
|
||||
Trend in Text übernehmen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Kriterium-Items als Referenz */}
|
||||
{catItems.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import FeedbackStructureConfig from './FeedbackStructureConfig'
|
||||
import AssignmentTypeConfig from './AssignmentTypeConfig'
|
||||
import RatingWeightConfig from './RatingWeightConfig'
|
||||
import { exportFullBackup, importFullBackup } from '../utils/dbBackup'
|
||||
|
||||
export default function Configuration() {
|
||||
const [tab, setTab] = useState<'feedback' | 'types' | 'backup'>('feedback')
|
||||
const [tab, setTab] = useState<'feedback' | 'types' | 'weighting' | 'backup'>('feedback')
|
||||
const [backupStatus, setBackupStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
|
||||
const [restoreConfirm, setRestoreConfirm] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
|
@ -48,6 +49,10 @@ export default function Configuration() {
|
|||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'types' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
||||
Ass.-Typen
|
||||
</button>
|
||||
<button onClick={() => setTab('weighting')}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'weighting' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
||||
Gewichtung
|
||||
</button>
|
||||
<button onClick={() => setTab('backup')}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'backup' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
||||
Backup
|
||||
|
|
@ -63,8 +68,9 @@ export default function Configuration() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'feedback' && <FeedbackStructureConfig />}
|
||||
{tab === 'types' && <AssignmentTypeConfig />}
|
||||
{tab === 'feedback' && <FeedbackStructureConfig />}
|
||||
{tab === 'types' && <AssignmentTypeConfig />}
|
||||
{tab === 'weighting' && <RatingWeightConfig />}
|
||||
|
||||
{tab === 'backup' && (
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -3,33 +3,24 @@ import { useNavigate } from 'react-router-dom'
|
|||
import {
|
||||
db,
|
||||
type Assignment, type Consultant,
|
||||
type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
|
||||
type FeedbackDimension, type FeedbackCategory,
|
||||
type FeedbackRating,
|
||||
} from '../db'
|
||||
import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants'
|
||||
import { RATING_OPTIONS, DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants'
|
||||
import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend'
|
||||
|
||||
interface CatScore {
|
||||
category: FeedbackCategory
|
||||
avg: number | null
|
||||
category: FeedbackCategory
|
||||
avg: number | null
|
||||
trend: TrendResult['trend']
|
||||
}
|
||||
|
||||
interface InstScore {
|
||||
institutee: Consultant
|
||||
assignment: Assignment
|
||||
catScores: CatScore[]
|
||||
overall: number | null
|
||||
}
|
||||
|
||||
function weightedAvg(
|
||||
items: FeedbackCriterionItem[],
|
||||
getScore: (itemId: number) => FeedbackRating | null,
|
||||
): number | null {
|
||||
const rated = items
|
||||
.map(i => ({ score: getScore(i.id!), w: i.weight ?? 1 }))
|
||||
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
||||
if (rated.length === 0) return null
|
||||
const wSum = rated.reduce((s, x) => s + x.w, 0)
|
||||
return rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum
|
||||
institutee: Consultant
|
||||
assignment: Assignment
|
||||
catScores: CatScore[]
|
||||
overall: number | null
|
||||
overallTrend: TrendResult['trend']
|
||||
}
|
||||
|
||||
function ratingColor(avg: number | null): string {
|
||||
|
|
@ -51,6 +42,12 @@ function ratingLabel(avg: number | null): string {
|
|||
return r ? `${r.short} (${avg.toFixed(1)})` : avg.toFixed(1)
|
||||
}
|
||||
|
||||
function trendArrow(trend: TrendResult['trend']) {
|
||||
if (trend === 'up') return <span className="text-green-600" title="Verbesserung über die Laufzeit">↑</span>
|
||||
if (trend === 'down') return <span className="text-red-600" title="Verschlechterung über die Laufzeit">↓</span>
|
||||
return null
|
||||
}
|
||||
|
||||
export default function Evaluation() {
|
||||
const [data, setData] = useState<InstScore[]>([])
|
||||
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
||||
|
|
@ -60,22 +57,26 @@ export default function Evaluation() {
|
|||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const [assignments, consultants, dims, cats, items, assessments] = await Promise.all([
|
||||
const [assignments, consultants, dims, cats, items, assessments, settings] = await Promise.all([
|
||||
db.assignments.toArray(),
|
||||
db.consultants.toArray(),
|
||||
db.feedbackDimensions.orderBy('order').toArray(),
|
||||
db.feedbackCategories.orderBy('order').toArray(),
|
||||
db.feedbackCriterionItems.orderBy('order').toArray(),
|
||||
db.assessments.toArray(),
|
||||
db.appSettings.get(1),
|
||||
])
|
||||
setDimensions(dims)
|
||||
setCategories(cats)
|
||||
const weightStep = settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP
|
||||
|
||||
const rows: InstScore[] = []
|
||||
for (const a of assignments) {
|
||||
const meetingIds = (await db.meetingInstances
|
||||
const meetings = (await db.meetingInstances
|
||||
.where('assignmentId').equals(a.id!).toArray()
|
||||
).filter(m => !m.deletedAt && m.status === 'done').map(m => m.id!)
|
||||
).filter(m => !m.deletedAt && m.status === 'done')
|
||||
.sort((x, y) => new Date(x.date).getTime() - new Date(y.date).getTime())
|
||||
const meetingIds = meetings.map(m => m.id!)
|
||||
|
||||
for (const instId of a.instituteeIds) {
|
||||
const inst = consultants.find(c => c.id === instId)
|
||||
|
|
@ -86,25 +87,20 @@ export default function Evaluation() {
|
|||
)
|
||||
if (instAssessments.length === 0) continue
|
||||
|
||||
const getScore = (itemId: number): FeedbackRating | null => {
|
||||
const relevant = instAssessments.filter(a => a.criteriaId === itemId && a.score !== null && a.score !== 'na')
|
||||
if (relevant.length === 0) return null
|
||||
const avg = relevant.reduce((s, a) => s + RATING_NUM_MAP[a.score!], 0) / relevant.length
|
||||
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'
|
||||
}
|
||||
const getScore = (meetingId: number, itemId: number): FeedbackRating | null =>
|
||||
instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null
|
||||
|
||||
const catScores: CatScore[] = cats.map(cat => ({
|
||||
category: cat,
|
||||
avg: weightedAvg(items.filter(i => i.categoryId === cat.id), getScore),
|
||||
}))
|
||||
const catScores: CatScore[] = cats.map(cat => {
|
||||
const history = buildGroupMeetingScores(items.filter(i => i.categoryId === cat.id), meetings, getScore)
|
||||
const { suggestion, trend } = computeTrend(history, weightStep)
|
||||
return { category: cat, avg: suggestion, trend }
|
||||
})
|
||||
|
||||
const overall = weightedAvg(items, getScore)
|
||||
const overallHistory = buildGroupMeetingScores(items, meetings, getScore)
|
||||
const { suggestion: overall, trend: overallTrend } = computeTrend(overallHistory, weightStep)
|
||||
if (overall === null && catScores.every(c => c.avg === null)) continue
|
||||
|
||||
rows.push({ institutee: inst, assignment: a, catScores, overall })
|
||||
rows.push({ institutee: inst, assignment: a, catScores, overall, overallTrend })
|
||||
}
|
||||
}
|
||||
setData(rows)
|
||||
|
|
@ -150,8 +146,8 @@ export default function Evaluation() {
|
|||
{row.institutee.firstName} {row.institutee.lastName}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-bold ${ratingColor(row.overall)}`}>
|
||||
{ratingLabel(row.overall)}
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-bold flex items-center gap-1 ${ratingColor(row.overall)}`}>
|
||||
{ratingLabel(row.overall)} {trendArrow(row.overallTrend)}
|
||||
</div>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }}
|
||||
|
|
@ -174,12 +170,12 @@ export default function Evaluation() {
|
|||
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">
|
||||
{dim.name}
|
||||
</div>
|
||||
{dimScores.map(({ category, avg }) => (
|
||||
{dimScores.map(({ category, avg, trend }) => (
|
||||
<div key={category.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">{category.name}</span>
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${ratingColor(avg)}`}>
|
||||
{ratingLabel(avg)}
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded flex items-center gap-1 ${ratingColor(avg)}`}>
|
||||
{ratingLabel(avg)} {trendArrow(trend)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -271,13 +271,13 @@ export default function FeedbackStructureConfig() {
|
|||
value={item.name}
|
||||
onChange={e => updateItem(item.id!, e.target.value)}
|
||||
/>
|
||||
<select
|
||||
<input
|
||||
type="number" min={0} max={2} step={0.1}
|
||||
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-brand">
|
||||
{[1,2,3,4,5].map(w => <option key={w} value={w}>×{w}</option>)}
|
||||
</select>
|
||||
className="w-14 text-xs border border-gray-200 rounded px-1 py-0.5 text-gray-500 bg-white focus:outline-none focus:border-brand"
|
||||
/>
|
||||
<button onClick={() => deleteItem(item.id!)}
|
||||
className="text-xs text-red-200 hover:text-red-400">×</button>
|
||||
</div>
|
||||
|
|
|
|||
79
src/pages/RatingWeightConfig.tsx
Normal file
79
src/pages/RatingWeightConfig.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { db } from '../db'
|
||||
import { DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants'
|
||||
import { previewWeights } from '../utils/ratingTrend'
|
||||
|
||||
export default function RatingWeightConfig() {
|
||||
const [weightStep, setWeightStep] = useState(DEFAULT_RATING_TREND_WEIGHT_STEP)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
let settings = await db.appSettings.get(1)
|
||||
if (!settings) {
|
||||
await db.appSettings.add({ id: 1, ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP })
|
||||
settings = await db.appSettings.get(1)
|
||||
}
|
||||
setWeightStep(settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP)
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const save = async () => {
|
||||
await db.appSettings.put({ id: 1, ratingTrendWeightStep: weightStep })
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2500)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-800">Zeitgewichtung des Bewertungs-Vorschlags</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Steuert, wie stark spätere Meetings in den Bewertungs-Vorschlag auf der Auswertungs- und
|
||||
Feedback-Seite einfließen. 0 = alle Meetings gleich gewichtet, höher = stärkere Gewichtung
|
||||
späterer Bewertungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Gewichtungsfaktor</label>
|
||||
<span className="text-sm font-semibold text-brand">{weightStep.toFixed(1)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={weightStep}
|
||||
onChange={e => setWeightStep(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-100 p-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">
|
||||
Beispiel bei 3 bewerteten Meetings
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm text-gray-600">
|
||||
{previewWeights(3, weightStep).map((w, i) => (
|
||||
<span key={i} className="bg-white border border-gray-200 rounded px-2 py-1">
|
||||
Meeting {i + 1}: ×{w.toFixed(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={save}
|
||||
className="w-full py-2.5 bg-brand text-white rounded-xl text-sm font-semibold">
|
||||
Speichern
|
||||
</button>
|
||||
{saved && (
|
||||
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded-lg px-3 py-2 text-center">
|
||||
Gespeichert.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
import { useState } from 'react'
|
||||
import type {
|
||||
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
||||
Assessment, FeedbackRating,
|
||||
Assessment, ConversationEntry, FeedbackRating,
|
||||
} from '../../db'
|
||||
import { INST_COLORS, RATING_OPTIONS, RATING_NUM_MAP } from '../../config/constants'
|
||||
import { detectNotationRating } from '../../utils/notationParser'
|
||||
import { numberToRating } from '../../utils/ratingTrend'
|
||||
|
||||
interface Props {
|
||||
institutees: Consultant[]
|
||||
fbCategories: FeedbackCategory[]
|
||||
fbItems: FeedbackCriterionItem[]
|
||||
assessments: Assessment[]
|
||||
entries: ConversationEntry[]
|
||||
hasConv: boolean
|
||||
tagLabel: (inst: Consultant) => string
|
||||
fillerTotal: (instId: number) => number
|
||||
|
|
@ -30,6 +33,7 @@ function catMode(
|
|||
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
||||
if (scored.length === 0) return null
|
||||
const wSum = scored.reduce((s, x) => s + x.w, 0)
|
||||
if (wSum === 0) return null // alle beteiligten Kriterien auf Gewicht 0 → keine Aussage möglich, sonst NaN < x ist überall false und fällt auf "Fully" durch
|
||||
const avg = scored.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum
|
||||
if (avg < 1.5) return 'not_client_ready'
|
||||
if (avg < 2.5) return 'partially_client_ready'
|
||||
|
|
@ -37,8 +41,20 @@ function catMode(
|
|||
return 'client_ready'
|
||||
}
|
||||
|
||||
function suggestedRating(entries: ConversationEntry[], instituteeId: number, itemId: number): FeedbackRating | null {
|
||||
const ratings = entries
|
||||
.filter(e => e.instituteeId === instituteeId)
|
||||
.flatMap(e => e.lineTags ?? [])
|
||||
.filter(t => t.criterionItemId === itemId)
|
||||
.map(t => detectNotationRating(t.text))
|
||||
.filter((r): r is FeedbackRating => r !== null)
|
||||
if (ratings.length === 0) return null
|
||||
const avg = ratings.reduce((s, r) => s + RATING_NUM_MAP[r], 0) / ratings.length
|
||||
return numberToRating(avg)
|
||||
}
|
||||
|
||||
export function AssessmentTab({
|
||||
institutees, fbCategories, fbItems, assessments, hasConv,
|
||||
institutees, fbCategories, fbItems, assessments, entries, hasConv,
|
||||
tagLabel, fillerTotal,
|
||||
getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc,
|
||||
}: Props) {
|
||||
|
|
@ -82,6 +98,11 @@ export function AssessmentTab({
|
|||
const isOpen = expandedCat[inst.id!] === cat.id
|
||||
const mode = catMode(cat, fbItems, itemId => getAssessmentScore(inst.id!, itemId))
|
||||
const headerRo = mode ? RATING_OPTIONS.find(r => r.value === mode) : null
|
||||
const suggestionCount = catItems.filter(item => {
|
||||
const current = getAssessmentScore(inst.id!, item.id!)
|
||||
const suggestion = suggestedRating(entries, inst.id!, item.id!)
|
||||
return suggestion && suggestion !== current
|
||||
}).length
|
||||
|
||||
return (
|
||||
<div key={cat.id} className="rounded-xl overflow-hidden border border-gray-100">
|
||||
|
|
@ -95,6 +116,14 @@ export function AssessmentTab({
|
|||
}`}>
|
||||
<span className={`text-sm font-medium ${headerRo ? 'text-white' : 'text-gray-700'}`}>{cat.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{suggestionCount > 0 && (
|
||||
<span title="Vorschläge aus dem Protokoll verfügbar"
|
||||
className={`text-xs px-1.5 py-0.5 rounded-full font-semibold ${
|
||||
headerRo ? 'bg-white/20 text-white' : 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
💡 {suggestionCount}
|
||||
</span>
|
||||
)}
|
||||
{!headerRo && ratedCount > 0 && (
|
||||
<span className="text-xs text-brand font-medium">{ratedCount}/{catItems.length}</span>
|
||||
)}
|
||||
|
|
@ -105,7 +134,8 @@ export function AssessmentTab({
|
|||
{isOpen && (
|
||||
<div className="border-t border-gray-100 divide-y divide-gray-50">
|
||||
{catItems.map(item => {
|
||||
const current = getAssessmentScore(inst.id!, item.id!)
|
||||
const current = getAssessmentScore(inst.id!, item.id!)
|
||||
const suggestion = suggestedRating(entries, 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>
|
||||
|
|
@ -120,6 +150,17 @@ export function AssessmentTab({
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
{suggestion && suggestion !== current && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-0.5 rounded-lg font-medium">
|
||||
Vorschlag aus Protokoll: {RATING_OPTIONS.find(r => r.value === suggestion)?.short}
|
||||
</span>
|
||||
<button onClick={() => onSetAssessmentScore(inst.id!, item.id!, suggestion)}
|
||||
className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-lg font-medium hover:bg-blue-200">
|
||||
Übernehmen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
} from '../../db'
|
||||
import { NotationText } from '../../utils/notationParser'
|
||||
import { INST_COLORS, RATING_OPTIONS } from '../../config/constants'
|
||||
import { CriterionTagPicker } from './CriterionTagPicker'
|
||||
|
||||
const NOTATIONS = [
|
||||
{ sym: '+', tip: 'Gut' },
|
||||
|
|
@ -37,6 +38,8 @@ interface Props {
|
|||
onSaveEntryNote: () => void
|
||||
onIncrementFiller: () => void
|
||||
onSetEntryScore: (entryId: number, categoryId: number, score: FeedbackRating) => void
|
||||
onSetLineCriterion: (entryId: number, lineText: string, criterionItemId: number | null) => void
|
||||
onChangeSpeaker: (entryId: number, instituteeId: number) => void
|
||||
onStartEdit: (entry: ConversationEntry) => void
|
||||
onSaveEdit: (entryId: number) => void
|
||||
onCancelEdit: () => void
|
||||
|
|
@ -50,7 +53,7 @@ export function ConversationTab({
|
|||
activeInstId, activeEntryId, entryNote, editingId, editNote, noteRef,
|
||||
displayName, tagLabel, fillerTotal, getEntryScore,
|
||||
onActivateInstitutee, onCloseActiveEntry, onInsertNotation,
|
||||
onEntryNoteChange, onSaveEntryNote, onIncrementFiller, onSetEntryScore,
|
||||
onEntryNoteChange, onSaveEntryNote, onIncrementFiller, onSetEntryScore, onSetLineCriterion, onChangeSpeaker,
|
||||
onStartEdit, onSaveEdit, onCancelEdit, onEditNoteChange, onDeleteEntry, onAdjustFiller,
|
||||
}: Props) {
|
||||
const activeInstIdx = institutees.findIndex(c => c.id === activeInstId)
|
||||
|
|
@ -206,6 +209,17 @@ export function ConversationTab({
|
|||
onChange={e => onEditNoteChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Sprecher:</span>
|
||||
<select
|
||||
value={entry.instituteeId}
|
||||
onChange={e => onChangeSpeaker(entry.id!, Number(e.target.value))}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:border-brand">
|
||||
{institutees.map(c => (
|
||||
<option key={c.id} value={c.id}>{displayName(c)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">Füllwörter:</span>
|
||||
<button onClick={() => onAdjustFiller(entry, -1)}
|
||||
|
|
@ -223,8 +237,20 @@ export function ConversationTab({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{entry.note && <NotationText text={entry.note} />}
|
||||
<div className="flex gap-1 flex-wrap mt-1">
|
||||
{entry.note && (
|
||||
<NotationText
|
||||
text={entry.note}
|
||||
renderLineAddon={line => (
|
||||
<CriterionTagPicker
|
||||
items={fbItems}
|
||||
categories={fbCategories}
|
||||
value={(entry.lineTags ?? []).find(t => t.text === line)?.criterionItemId ?? null}
|
||||
onChange={itemId => onSetLineCriterion(entry.id!, line, itemId)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center 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}
|
||||
|
|
|
|||
74
src/pages/meeting/CriterionTagPicker.tsx
Normal file
74
src/pages/meeting/CriterionTagPicker.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useState } from 'react'
|
||||
import type { FeedbackCategory, FeedbackCriterionItem } from '../../db'
|
||||
|
||||
interface Props {
|
||||
items: FeedbackCriterionItem[]
|
||||
categories: FeedbackCategory[]
|
||||
value: number | null | undefined
|
||||
onChange: (itemId: number | null) => void
|
||||
}
|
||||
|
||||
export function CriterionTagPicker({ items, categories, value, onChange }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const current = items.find(i => i.id === value)
|
||||
const categoryName = (categoryId: number) => categories.find(c => c.id === categoryId)?.name ?? ''
|
||||
|
||||
const filtered = items.filter(i => {
|
||||
const q = filter.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return i.name.toLowerCase().includes(q) || categoryName(i.categoryId).toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
const select = (itemId: number) => {
|
||||
onChange(itemId)
|
||||
setOpen(false)
|
||||
setFilter('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
{current ? (
|
||||
<div className="inline-flex items-center gap-0.5 bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-full pl-2 pr-1 py-0.5">
|
||||
<button onClick={() => setOpen(p => !p)} className="text-xs font-medium max-w-[120px] truncate">
|
||||
🏷 {current.name}
|
||||
</button>
|
||||
<button onClick={() => onChange(null)} className="text-indigo-400 hover:text-indigo-700 text-xs leading-none px-1">✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setOpen(p => !p)}
|
||||
title="Kriterium zuordnen"
|
||||
className="w-6 h-6 rounded-full border border-dashed border-gray-300 text-gray-400 text-xs flex items-center justify-center hover:bg-gray-50 hover:text-gray-600">
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-64 bg-white border border-gray-200 rounded-xl shadow-lg p-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="Kriterium suchen…"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg px-2 py-1.5 mb-2 focus:outline-none focus:border-brand"
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5">
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-xs text-gray-400 px-2 py-1.5">Kein Treffer.</div>
|
||||
)}
|
||||
{filtered.map(item => (
|
||||
<button key={item.id}
|
||||
onClick={() => select(item.id!)}
|
||||
className="w-full text-left px-2 py-1.5 rounded-lg hover:bg-gray-50">
|
||||
<div className="text-xs font-medium text-gray-700">{item.name}</div>
|
||||
<div className="text-[10px] text-gray-400">{categoryName(item.categoryId)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
type Consultant, type ConversationEntry, type ConversationSkillScore, type Assessment,
|
||||
} from '../../db'
|
||||
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport'
|
||||
import { NotationText } from '../../utils/notationParser'
|
||||
import { NotationText, splitNoteLines } from '../../utils/notationParser'
|
||||
import { RATING_NUM_MAP } from '../../config/constants'
|
||||
import { ConversationTab } from './ConversationTab'
|
||||
import { AssessmentTab } from './AssessmentTab'
|
||||
|
|
@ -96,7 +96,11 @@ export default function MeetingView() {
|
|||
|
||||
const activateInstitutee = async (instId: number) => {
|
||||
if (activeEntryId !== null) {
|
||||
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
|
||||
if (entryNote.trim() === '') {
|
||||
await db.conversationEntries.delete(activeEntryId)
|
||||
} else {
|
||||
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({
|
||||
|
|
@ -135,7 +139,11 @@ export default function MeetingView() {
|
|||
}
|
||||
|
||||
const closeActiveEntry = async () => {
|
||||
await saveEntryNote()
|
||||
if (activeEntryId !== null && entryNote.trim() === '') {
|
||||
await db.conversationEntries.delete(activeEntryId)
|
||||
} else {
|
||||
await saveEntryNote()
|
||||
}
|
||||
setActiveInstId(null)
|
||||
setActiveEntryId(null)
|
||||
setEntryNote('')
|
||||
|
|
@ -145,11 +153,24 @@ export default function MeetingView() {
|
|||
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))
|
||||
const entry = entries.find(e => e.id === entryId)
|
||||
const currentLines = splitNoteLines(editNote)
|
||||
const prunedTags = (entry?.lineTags ?? []).filter(t => currentLines.includes(t.text))
|
||||
await db.conversationEntries.update(entryId, { note: editNote, lineTags: prunedTags, updatedAt: new Date().toISOString() })
|
||||
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote, lineTags: prunedTags } : e))
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const setLineCriterion = async (entryId: number, lineText: string, criterionItemId: number | null) => {
|
||||
const entry = entries.find(e => e.id === entryId)
|
||||
if (!entry) return
|
||||
const currentLines = splitNoteLines(entry.note)
|
||||
const kept = (entry.lineTags ?? []).filter(t => t.text !== lineText && currentLines.includes(t.text))
|
||||
const nextTags = criterionItemId === null ? kept : [...kept, { text: lineText, criterionItemId }]
|
||||
await db.conversationEntries.update(entryId, { lineTags: nextTags })
|
||||
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, lineTags: nextTags } : e))
|
||||
}
|
||||
|
||||
const deleteEntry = async (entryId: number) => {
|
||||
await db.conversationSkillScores.where('conversationEntryId').equals(entryId).delete()
|
||||
await db.conversationEntries.delete(entryId)
|
||||
|
|
@ -162,6 +183,11 @@ export default function MeetingView() {
|
|||
setEntries(prev => prev.map(e => e.id === entry.id ? { ...e, fillerCount: newCount } : e))
|
||||
}
|
||||
|
||||
const changeEntrySpeaker = async (entryId: number, instituteeId: number) => {
|
||||
await db.conversationEntries.update(entryId, { instituteeId })
|
||||
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, instituteeId } : e))
|
||||
}
|
||||
|
||||
const getEntryScore = (entryId: number, categoryId: number): FeedbackRating | null =>
|
||||
scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score ?? null
|
||||
|
||||
|
|
@ -393,6 +419,8 @@ export default function MeetingView() {
|
|||
onSaveEntryNote={saveEntryNote}
|
||||
onIncrementFiller={incrementFiller}
|
||||
onSetEntryScore={setEntryScore}
|
||||
onSetLineCriterion={setLineCriterion}
|
||||
onChangeSpeaker={changeEntrySpeaker}
|
||||
onStartEdit={startEdit}
|
||||
onSaveEdit={saveEdit}
|
||||
onCancelEdit={() => setEditingId(null)}
|
||||
|
|
@ -408,6 +436,7 @@ export default function MeetingView() {
|
|||
fbCategories={fbCategories}
|
||||
fbItems={fbItems}
|
||||
assessments={assessments}
|
||||
entries={entries}
|
||||
hasConv={hasConv}
|
||||
tagLabel={tagLabel}
|
||||
fillerTotal={fillerTotal}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { db } from '../db'
|
||||
import { downloadFile } from './meetingExport'
|
||||
|
||||
// Muss mit den Tabellen in db/schema.ts synchron gehalten werden — eine neue Tabelle dort
|
||||
// landet nicht automatisch im Backup, sondern muss hier manuell ergänzt werden.
|
||||
const TABLE_NAMES = [
|
||||
'categories',
|
||||
'criteria',
|
||||
|
|
@ -22,6 +24,7 @@ const TABLE_NAMES = [
|
|||
'feedbackDimensionTexts',
|
||||
'criterionCategoryMappings',
|
||||
'criterionLevelDescriptions',
|
||||
'appSettings',
|
||||
] as const
|
||||
|
||||
export interface DbBackup {
|
||||
|
|
|
|||
|
|
@ -23,10 +23,11 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
|||
...new Set([
|
||||
...(template?.criteriaIds ?? []),
|
||||
...assessments.map(a => a.criteriaId),
|
||||
...entries.flatMap(e => (e.lineTags ?? []).map(t => t.criterionItemId)),
|
||||
]),
|
||||
]
|
||||
const criteria = critIds.length > 0
|
||||
? await db.criteria.where('id').anyOf(critIds).toArray()
|
||||
? await db.feedbackCriterionItems.where('id').anyOf(critIds).toArray()
|
||||
: []
|
||||
|
||||
const institutees = instituteeIds
|
||||
|
|
@ -63,7 +64,9 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
|||
|
||||
if (entry.note?.trim()) {
|
||||
for (const noteLine of entry.note.trim().split('\n')) {
|
||||
lines.push(`> ${noteLine}`)
|
||||
const lineTag = entry.lineTags?.find(t => t.text === noteLine.trim())
|
||||
const critName = lineTag ? criteria.find(c => c.id === lineTag.criterionItemId)?.name : null
|
||||
lines.push(`> ${noteLine}${critName ? ` _(→ ${critName})_` : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,22 @@
|
|||
import React from 'react'
|
||||
import type { FeedbackRating } from '../db'
|
||||
|
||||
// Notation → Bewertungsstufe (Reihenfolge wichtig: Klammer-Varianten vor den nackten Zeichen prüfen,
|
||||
// sonst matcht "+" bereits innerhalb von "(+)")
|
||||
const NOTATION_RATING_ORDER: [string, FeedbackRating][] = [
|
||||
['(+)', 'nearly_client_ready'],
|
||||
['(!)', 'partially_client_ready'],
|
||||
['+', 'client_ready'],
|
||||
['!', 'not_client_ready'],
|
||||
]
|
||||
|
||||
export function detectNotationRating(line: string): FeedbackRating | null {
|
||||
const trimmed = line.trim()
|
||||
for (const [prefix, rating] of NOTATION_RATING_ORDER) {
|
||||
if (trimmed.startsWith(prefix)) return rating
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Notations-Regeln (Reihenfolge wichtig: längere Symbole zuerst)
|
||||
const NOTATION_RULES = [
|
||||
|
|
@ -57,14 +75,27 @@ function parseLine(line: string, key: number): React.ReactElement {
|
|||
interface Props {
|
||||
text: string
|
||||
className?: string
|
||||
renderLineAddon?: (line: string, index: number) => React.ReactNode
|
||||
}
|
||||
|
||||
export function NotationText({ text, className }: Props) {
|
||||
export function NotationText({ text, className, renderLineAddon }: Props) {
|
||||
if (!text?.trim()) return null
|
||||
const lines = text.split('\n')
|
||||
return (
|
||||
<div className={className}>
|
||||
{lines.map((line, i) => parseLine(line, i))}
|
||||
{lines.map((line, i) => renderLineAddon
|
||||
? (
|
||||
<div key={i} className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">{parseLine(line, i)}</div>
|
||||
{line.trim() && renderLineAddon(line.trim(), i)}
|
||||
</div>
|
||||
)
|
||||
: parseLine(line, i)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function splitNoteLines(note: string): string[] {
|
||||
return note.split('\n').map(l => l.trim()).filter(l => l !== '')
|
||||
}
|
||||
|
|
|
|||
73
src/utils/ratingTrend.ts
Normal file
73
src/utils/ratingTrend.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { FeedbackCriterionItem, FeedbackRating } from '../db'
|
||||
import { RATING_NUM_MAP, RATING_TREND_DELTA_THRESHOLD } from '../config/constants'
|
||||
|
||||
export interface MeetingScorePoint {
|
||||
meetingId: number
|
||||
date: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface TrendResult {
|
||||
suggestion: number | null
|
||||
history: MeetingScorePoint[]
|
||||
trend: 'up' | 'down' | 'stable' | null
|
||||
delta: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Wert pro Meeting für eine Gruppe von Kriterien (eine Kategorie oder alle Items für "overall").
|
||||
* Meetings ohne bewertetes Item aus der Gruppe werden übersprungen — ein Delivery-Call, der eine
|
||||
* Kategorie nicht abfragt, darf deren Zeitreihe nicht verfälschen.
|
||||
*/
|
||||
export function buildGroupMeetingScores(
|
||||
items: FeedbackCriterionItem[],
|
||||
meetingsChronological: { id: number; date: string }[],
|
||||
getScore: (meetingId: number, itemId: number) => FeedbackRating | null,
|
||||
): MeetingScorePoint[] {
|
||||
const points: MeetingScorePoint[] = []
|
||||
for (const meeting of meetingsChronological) {
|
||||
const rated = items
|
||||
.map(i => ({ score: getScore(meeting.id, i.id!), w: i.weight ?? 1 }))
|
||||
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
||||
if (rated.length === 0) continue
|
||||
const wSum = rated.reduce((s, x) => s + x.w, 0)
|
||||
if (wSum === 0) continue // alle beteiligten Kriterien auf Gewicht 0 → keine Aussage möglich, sonst NaN < x ist überall false und fällt auf "Fully" durch
|
||||
const score = rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum
|
||||
points.push({ meetingId: meeting.id, date: meeting.date, score })
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/**
|
||||
* Lineare Zeitgewichtung: rank r (1-basiert, 1 = ältester Datenpunkt) → weight = 1 + (r-1) * weightStep.
|
||||
* Trend wird aus dem Delta zwischen erstem und letztem Rohwert abgeleitet (nicht dem gewichteten Wert),
|
||||
* da das ohnehin nur ab 2 Datenpunkten sinnvoll ist und der gewichtete Wert selbst schon Teil des
|
||||
* Vorschlags ist.
|
||||
*/
|
||||
export function computeTrend(history: MeetingScorePoint[], weightStep: number): TrendResult {
|
||||
if (history.length === 0) return { suggestion: null, history, trend: null, delta: null }
|
||||
|
||||
const weights = previewWeights(history.length, weightStep)
|
||||
const wSum = weights.reduce((s, w) => s + w, 0)
|
||||
const suggestion = history.reduce((s, p, i) => s + p.score * weights[i], 0) / wSum
|
||||
|
||||
if (history.length < 2) return { suggestion, history, trend: null, delta: null }
|
||||
|
||||
const delta = history[history.length - 1].score - history[0].score
|
||||
const trend = delta >= RATING_TREND_DELTA_THRESHOLD ? 'up'
|
||||
: delta <= -RATING_TREND_DELTA_THRESHOLD ? 'down'
|
||||
: 'stable'
|
||||
return { suggestion, history, trend, delta }
|
||||
}
|
||||
|
||||
export function numberToRating(avg: number): FeedbackRating {
|
||||
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'
|
||||
}
|
||||
|
||||
/** Resultierende Gewichte für k chronologische Datenpunkte, z.B. previewWeights(3, 0.5) => [1, 1.5, 2]. */
|
||||
export function previewWeights(k: number, weightStep: number): number[] {
|
||||
return Array.from({ length: k }, (_, i) => 1 + i * weightStep)
|
||||
}
|
||||
|
|
@ -4,6 +4,10 @@ import tailwindcss from '@tailwindcss/vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: process.env.PORT ? Number(process.env.PORT) : 5173,
|
||||
strictPort: !!process.env.PORT,
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user