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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
55
CLAUDE.md
55
CLAUDE.md
|
|
@ -9,10 +9,30 @@ Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) wäh
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- React 18 + TypeScript + Vite (PWA)
|
- 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
|
- Dexie.js v5 — IndexedDB-Wrapper, kein Backend
|
||||||
- React Router v6
|
- 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
|
## Entwicklung
|
||||||
|
|
||||||
|
|
@ -29,7 +49,8 @@ npx tsc --noEmit # TypeScript-Check — muss vor jeder Abgabe fehlerfrei sei
|
||||||
|
|
||||||
```
|
```
|
||||||
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
|
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
|
||||||
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
|
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
|
### 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
|
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
|
### Routing
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -66,7 +91,15 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
|
||||||
|
|
||||||
- `MeetingInstance.generalNotes` kann `undefined` sein → immer `?? ''` als Fallback
|
- `MeetingInstance.generalNotes` kann `undefined` sein → immer `?? ''` als Fallback
|
||||||
- `PhaseKey` ist `string`, kein Union-Typ
|
- `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 |
|
| Prio | Aufgabe | Datei |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| M | `Evaluation.tsx` auf neue Datenstruktur migrieren | `src/pages/Evaluation.tsx` |
|
| H | N/A-Aggregationsbug fixen (siehe Bug-Klasse oben) | `src/pages/AssignmentFeedbackPage.tsx`, `src/pages/FeedbackDraftPage.tsx` |
|
||||||
| M | AI-Prompt auf `feedbackCriterionItems` umstellen | `src/pages/AssignmentFeedbackPage.tsx` |
|
| H | **KI-Generierung derzeit funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich blockiert das Capgemini-Netzwerk/Firewall den Call zu `openrouter.ai`. Root Cause noch nicht untersucht — ggf. mit internem IT-Support klären. | `src/pages/AssignmentFeedbackPage.tsx` |
|
||||||
| M | `meetingExport.ts` — Kriterien-Auflösung anpassen | `src/utils/meetingExport.ts` |
|
| 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 | 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` |
|
| 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
|
└── ConversationEntry[] ← Zeitleiste: wer spricht wann
|
||||||
└── Assessment[] ← Gesamtbewertung pro Institutee am Ende
|
└── 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)
|
└── ConversationSkillScore[] ← Kategorie-Schnellbewertung (während des Beitrags)
|
||||||
|
└── lineTags[] ← Kriterium-Zuordnung einzelner Zeilen (nachträglich, per Zeilentext)
|
||||||
|
|
||||||
FeedbackDimension ← Übergeordnete Dimension für das Assignment-Feedback
|
FeedbackDimension ← Übergeordnete Dimension für das Assignment-Feedback
|
||||||
└── FeedbackCategory[] ← Kategorie (z.B. "Kommunikationsfähigkeit")
|
└── FeedbackCategory[] ← Kategorie (z.B. "Kommunikationsfähigkeit")
|
||||||
|
|
@ -79,7 +80,7 @@ Wiederholbare Phasen (z.B. Alignment Calls) werden mit fortlaufendem `phaseIndex
|
||||||
|
|
||||||
### Aggregationslogik
|
### 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)
|
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`
|
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.
|
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)
|
## 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}`
|
**Format:** `{AssignmentNummer}_{Datum}_{PhasenLabel}{-#Nr}.{md|json}`
|
||||||
**Beispiel:** `CGI-2024-001_02-07-2026_Alignment Call-2.md`
|
**Beispiel:** `CGI-2024-001_02-07-2026_Alignment Call-2.md`
|
||||||
|
|
||||||
- **Markdown:** Lesbare Dokumentation mit Protokoll, Bewertungen und Notizen
|
- **Markdown:** Lesbare Dokumentation mit Protokoll, Bewertungen und Notizen — getaggte Protokollzeilen zeigen zusätzlich das zugeordnete Kriterium
|
||||||
- **JSON:** Vollständige Datensicherung eines einzelnen Meetings
|
- **JSON:** Vollständige Datensicherung eines einzelnen Meetings, inklusive Zeilen-Tags
|
||||||
|
|
||||||
### Datenbank-Backup
|
### 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.
|
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?
|
## 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.
|
**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.
|
||||||
|
|
@ -36,6 +44,7 @@ Aggregation: **gewichteter Durchschnitt** (avg < 1.5 → Not, < 2.5 → Partiall
|
||||||
```
|
```
|
||||||
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting)
|
Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung nach Meeting)
|
||||||
ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung während Gespräch)
|
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.
|
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/new Neues Assignment
|
||||||
/assignment/:id AssignmentDetail
|
/assignment/:id AssignmentDetail
|
||||||
/assignment/:id/edit AssignmentEdit
|
/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/:instituteeId AssignmentFeedbackPage
|
||||||
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
|
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
|
||||||
/evaluation Auswertung (veraltet!)
|
/evaluation Auswertung — zeitgewichteter Bewertungs-Vorschlag + Trend, nach Assignment gruppiert
|
||||||
/consultants Berater
|
/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()`
|
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
|
## Konfigurationsbereich
|
||||||
|
|
||||||
**Tab "Feedback"** (`FeedbackStructureConfig.tsx`):
|
**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!
|
- Nur Feedback-Struktur, kein AssignmentType-CRUD mehr!
|
||||||
|
|
||||||
**Tab "Ass.-Typen"** (`AssignmentTypeConfig.tsx`):
|
**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"
|
- Pro Typ: Sub-Tabs "Phasen" und "Kriterien"
|
||||||
- Kriterien-Tab: Baumstruktur Dimension → Kategorie → Items (Checkbox, Indeterminate)
|
- 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`):
|
**Tab "Backup"** (`Configuration.tsx`):
|
||||||
- JSON-Export aller Tabellen via `exportFullBackup()`
|
- JSON-Export aller Tabellen via `exportFullBackup()`
|
||||||
- JSON-Import via `importFullBackup(file)` (löscht alles, dann bulkAdd)
|
- 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)
|
### Sofort-Fixes (einfach)
|
||||||
1. **Dashboard zeigt keine Assignment-Nummer** — `Dashboard.tsx` Zeile ~57: `{a.assignmentNumber && <span>...{a.assignmentNumber}</span>}` hinzufügen
|
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
|
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. **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
|
### Mittlere Aufgaben
|
||||||
4. **Evaluation.tsx neu schreiben** — nutzt `db.categories` + `db.criteria` (Legacy). Soll `feedbackCriterionItems` + `assessments` nutzen und Rating-Labels zeigen
|
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. **meetingExport.ts anpassen** — Score-Aufschlüsselung nutzt `db.criteria`, sollte `feedbackCriterionItems` nutzen; Kategorie-Namen im Markdown fehlen
|
5. **`db/queries/`-Layer** — DB-Zugriffe aus Komponenten in domänenspezifische Query-Funktionen extrahieren, bereitet SQLite-Migration vor
|
||||||
6. **AI-Prompt in AssignmentFeedbackPage** — nutzt `db.criteria` (alt), sollte `feedbackCriterionItems` + neue Assessment-Scores nutzen
|
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.
|
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)
|
## Stil-Regeln (bitte einhalten)
|
||||||
|
|
||||||
|
|
@ -133,7 +151,7 @@ exportFullBackup(): Promise<void> // Download als JSON
|
||||||
importFullBackup(file: File): Promise<{ ok: boolean; error?: string }> // Clears + bulkAdd
|
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
|
## 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.
|
4. **`aType2`** in `AssignmentDetail.tsx`: Doppelte Variable aus Refactoring — `aType` wird zweimal deklariert, zweite heißt `aType2`. Technische Schuld.
|
||||||
5. **`expandedCat`** in `MeetingView`: `Record<instituteeId, categoryId | null>` — öffnet pro Institutee genau eine Kategorie gleichzeitig
|
5. **`expandedCat`** in `MeetingView`: `Record<instituteeId, categoryId | null>` — öffnet pro Institutee genau eine Kategorie gleichzeitig
|
||||||
6. **Dexie v5**: `bulkAdd` schlägt fehl wenn IDs schon vorhanden → Backup-Import leert erst alle Tabellen
|
6. **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
|
# 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` |
|
| Strukturiertes Abschluss-Feedback pro Institutee | `AssignmentFeedbackPage.tsx` |
|
||||||
| KI-Feedback-Generierung via OpenRouter | `AssignmentFeedbackPage.tsx` |
|
| KI-Feedback-Generierung via OpenRouter | `AssignmentFeedbackPage.tsx` |
|
||||||
| Feedback-Status: Entwurf → Final | `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` |
|
| AssignmentTyp CRUD mit Phasen-Templates | `AssignmentTypeConfig.tsx` |
|
||||||
| Kriterien-Auswahl je Typ (Baumstruktur, Indeterminate) | `AssignmentTypeConfig.tsx` |
|
| Kriterien-Auswahl je Typ (Baumstruktur, Indeterminate) | `AssignmentTypeConfig.tsx` |
|
||||||
| Kriterien-Filterung je Typ in Meetings + Übersicht | `MeetingView.tsx`, `AssignmentDetail.tsx` |
|
| Kriterien-Filterung je Typ in Meetings + Übersicht | `MeetingView.tsx`, `AssignmentDetail.tsx` |
|
||||||
| Vollständiges DB-Backup (JSON Export/Import) | `dbBackup.ts`, `Configuration.tsx` |
|
| Vollständiges DB-Backup (JSON Export/Import) | `dbBackup.ts`, `Configuration.tsx` |
|
||||||
| Berater/Gruppen-Verwaltung | `Consultants.tsx` |
|
| Berater/Gruppen-Verwaltung | `Consultants.tsx` |
|
||||||
| PWA (installierbar, Offline-fähig) | Vite-PWA-Plugin |
|
| 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 |
|
| # | 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) |
|
| 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. | `MeetingView.tsx` | S |
|
| 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
|
### Priorität Mittel
|
||||||
|
|
||||||
| # | Beschreibung | Datei | Aufwand |
|
| # | 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 |
|
| 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 |
|
||||||
| M2 | **AI-Prompt veraltet**: `AssignmentFeedbackPage` liest `db.criteria` (alt) für Score-Zeilen. Sollte auf `feedbackCriterionItems` + `assessments` umgestellt werden. | `AssignmentFeedbackPage.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 |
|
||||||
| M3 | **meetingExport.md veraltet**: Kriterien-Scores werden über `db.criteria` aufgelöst, nicht `feedbackCriterionItems`. Kategorie-Namen fehlen im Export. | `meetingExport.ts` | S |
|
| 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
|
### Priorität Niedrig
|
||||||
|
|
||||||
| # | Beschreibung | Datei | Aufwand |
|
| # | Beschreibung | Datei | Aufwand |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| N1 | **Dashboard**: Zeigt keine Assignment-Nummer. Nur Titel + Kunde + Typ. | `Dashboard.tsx` | XS |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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.
|
**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.
|
**Status:** Konzept beschlossen. Implementierung steht aus. Paralleles Backup-System ist die aktuelle Notlösung.
|
||||||
|
|
||||||
### Evaluation neu (M1 oben)
|
### Evaluation — Zeitgewichteter Vorschlag (erledigt, 2026-07-02)
|
||||||
Auswertungsseite auf neue Datenstruktur migrieren. Soll zeigen:
|
Vollständig umgesetzt, siehe `docs/EVALUATION_FEEDBACK_REDESIGN.md` für die Design-Entscheidungen:
|
||||||
- Pro Institutee: aggregierte Kriterien-Scores über alle Meetings
|
- 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 über mehrere Assignments
|
- Trend (Verbesserung/Verschlechterung) wird ab 2 bewerteten Meetings pro Kategorie erkannt (Delta ≥ 0,5 Punkte)
|
||||||
- Vergleich zwischen Institutees
|
- 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)
|
### Dashboard-Verbesserungen (N1 oben)
|
||||||
- Assignment-Nummer anzeigen
|
- 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| 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/
|
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/
|
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/
|
pages/
|
||||||
Dashboard.tsx ← Assignment-Liste (Home)
|
Dashboard.tsx ← Assignment-Liste (Home)
|
||||||
AssignmentCreate.tsx ← Neues Assignment anlegen
|
AssignmentCreate.tsx ← Neues Assignment anlegen
|
||||||
AssignmentDetail.tsx ← Phasen-Flow, Meeting-Übersicht, Bewertungs-Chips
|
AssignmentDetail.tsx ← Phasen-Flow, Meeting-Übersicht, Bewertungs-Chips
|
||||||
AssignmentEdit.tsx ← Assignment nachträglich bearbeiten
|
AssignmentEdit.tsx ← Assignment nachträglich bearbeiten
|
||||||
MeetingView.tsx ← Live-Meeting: Protokoll + Gesamtbewertung
|
meeting/
|
||||||
AssignmentFeedbackPage.tsx ← Strukturiertes Abschluss-Feedback + KI-Generierung
|
MeetingView.tsx ← Route-Komponente: State, DB-Handler, Header, Tabs
|
||||||
FeedbackDraftPage.tsx ← Einfacher Feedback-Entwurf (Legacy)
|
ConversationTab.tsx ← Gesprächsprotokoll-UI
|
||||||
Evaluation.tsx ← Auswertung (teilweise veraltet, nutzt alte Criterion-Tabelle)
|
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
|
Configuration.tsx ← Tab-Container für alle Konfigurationsseiten
|
||||||
FeedbackStructureConfig.tsx ← Dimensionen/Kategorien/Kriterien CRUD + Gewichtung
|
FeedbackStructureConfig.tsx ← Dimensionen/Kategorien/Kriterien CRUD + Gewichtung
|
||||||
AssignmentTypeConfig.tsx ← AssignmentTypen + Phasen + Kriterien-Auswahl (Baum)
|
AssignmentTypeConfig.tsx ← AssignmentTypen + Phasen + Kriterien-Auswahl (Baum)
|
||||||
|
RatingWeightConfig.tsx ← Config-Tab "Gewichtung": Zeitgewichtungs-Faktor für Bewertungs-Vorschlag
|
||||||
Consultants.tsx ← Berater/Gruppen-Verwaltung
|
Consultants.tsx ← Berater/Gruppen-Verwaltung
|
||||||
utils/
|
utils/
|
||||||
meetingExport.ts ← Markdown + JSON Export-Logik
|
meetingExport.ts ← Markdown + JSON Export-Logik
|
||||||
dbBackup.ts ← Vollständiges DB-Backup (JSON Export/Import)
|
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
|
App.tsx ← Route-Definitionen, Navigation
|
||||||
main.tsx ← React-Einstiegspunkt, DB-Seed-Aufruf
|
main.tsx ← React-Einstiegspunkt, DB-Seed-Aufruf
|
||||||
```
|
```
|
||||||
|
|
@ -48,10 +63,10 @@ src/
|
||||||
|
|
||||||
## Datenmodell (IndexedDB via Dexie v5)
|
## Datenmodell (IndexedDB via Dexie v5)
|
||||||
|
|
||||||
### DB-Version: 5
|
### DB-Version: 6
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Konfiguration (Legacy, nur noch für Evaluation.tsx)
|
// Konfiguration (Legacy, nur noch für FeedbackDraftPage.tsx + Seeds)
|
||||||
categories: '++id, order'
|
categories: '++id, order'
|
||||||
criteria: '++id, categoryId, order'
|
criteria: '++id, categoryId, order'
|
||||||
|
|
||||||
|
|
@ -88,8 +103,13 @@ feedbackDimensionTexts: '++id, assignmentFeedbackId, feedbackDimensionId'
|
||||||
// Mappings (Legacy, kaum genutzt)
|
// Mappings (Legacy, kaum genutzt)
|
||||||
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId'
|
criterionCategoryMappings: '++id, criterionId, feedbackCategoryId'
|
||||||
criterionLevelDescriptions: '++id, criterionItemId, rating'
|
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
|
### Wichtige Interface-Details
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -135,12 +155,34 @@ interface FeedbackCriterionItem {
|
||||||
id?: number
|
id?: number
|
||||||
categoryId: number
|
categoryId: number
|
||||||
name: string
|
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
|
order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
// Assessment.criteriaId → FeedbackCriterionItem.id (Gesamtbewertung)
|
||||||
// ConversationSkillScore.criteriaId → FeedbackCategory.id (Schnellbewertung)
|
// 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)
|
/assignment/:id/feedback-draft/:instituteeId FeedbackDraftPage (Legacy)
|
||||||
/evaluation Auswertungsübersicht
|
/evaluation Auswertungsübersicht
|
||||||
/consultants Berater-Verwaltung
|
/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:
|
CRUD für:
|
||||||
- Feedback-Dimensionen (mit Reihenfolge ↑/↓)
|
- Feedback-Dimensionen (mit Reihenfolge ↑/↓)
|
||||||
- Feedback-Kategorien je Dimension
|
- 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`
|
### Tab "Ass.-Typen" → `AssignmentTypeConfig`
|
||||||
CRUD für:
|
CRUD für:
|
||||||
|
|
@ -177,6 +219,9 @@ CRUD für:
|
||||||
- Kriterien-Auswahl je Typ: Baumstruktur Dimension → Kategorie → Item (mit Checkbox, Indeterminate-State)
|
- Kriterien-Auswahl je Typ: Baumstruktur Dimension → Kategorie → Item (mit Checkbox, Indeterminate-State)
|
||||||
- `criteriaIds = []` → alle Kriterien werden angezeigt
|
- `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`
|
### Tab "Backup" → in `Configuration.tsx`
|
||||||
- JSON-Export aller Tabellen
|
- JSON-Export aller Tabellen
|
||||||
- Zweistufiger JSON-Import mit Überschreib-Warnung
|
- 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')
|
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
||||||
if (scored.length === 0) return null
|
if (scored.length === 0) return null
|
||||||
const wSum = scored.reduce((s, x) => s + x.w, 0)
|
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
|
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 < 1.5) return 'not_client_ready'
|
||||||
if (avg < 2.5) return 'partially_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
|
## Export-Dateiname-Schema
|
||||||
|
|
@ -227,8 +275,9 @@ const buildFilename = (ext: string) => {
|
||||||
- **Provider:** OpenRouter (https://openrouter.ai) — ermöglicht Modellwechsel ohne eigenen API-Zugang
|
- **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
|
- **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`)
|
- **API-Key:** Lokal in `localStorage` gespeichert (`ai_api_key`, `ai_model`)
|
||||||
- **Datenweitergabe:** Kriterien-Scores, Protokoll-Notizen, Kategorie-Bewertungen werden im Prompt strukturiert übermittelt
|
- **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)
|
||||||
- **Hinweis:** Der AI-Prompt nutzt noch die alte `criteria`-Tabelle (Legacy) — Aktualisierung auf `feedbackCriterionItems` steht aus
|
- **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:
|
Die App legt beim ersten Start automatisch an:
|
||||||
|
|
||||||
1. **3 Assignment-Typen** mit je vollständigem Phasen-Flow (Generisch, Case Interview, Stakeholder Meeting / Pitch)
|
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)
|
3. **Feedback-Dimensionsstruktur** mit 5 Dimensionen, 14 Kategorien, ~70 Kriterium-Items (Capgemini-Kompetenzmodell)
|
||||||
4. **Criterion-Mappings** (Legacy, nicht mehr aktiv genutzt)
|
4. **Criterion-Mappings** (Legacy, nicht mehr aktiv genutzt)
|
||||||
|
|
||||||
|
|
@ -247,10 +296,13 @@ Die App legt beim ersten Start automatisch an:
|
||||||
|
|
||||||
| Bereich | Problem | Priorität |
|
| Bereich | Problem | Priorität |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Evaluation.tsx` | Nutzt alte `Category`/`Criterion`-Tabellen statt neue `feedbackCriterionItems` | Mittel |
|
| `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 statt Labels | Niedrig |
|
| `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 |
|
| `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 |
|
| 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 |
|
| 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,
|
nearly_client_ready: 3,
|
||||||
client_ready: 4,
|
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,
|
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
|
||||||
AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText,
|
AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText,
|
||||||
CriterionCategoryMapping, CriterionLevelDescription,
|
CriterionCategoryMapping, CriterionLevelDescription,
|
||||||
|
AppSettings,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
class AssignmentMonitorDB extends Dexie {
|
class AssignmentMonitorDB extends Dexie {
|
||||||
|
|
@ -32,6 +33,7 @@ class AssignmentMonitorDB extends Dexie {
|
||||||
feedbackDimensionTexts!: Table<FeedbackDimensionText>
|
feedbackDimensionTexts!: Table<FeedbackDimensionText>
|
||||||
criterionCategoryMappings!: Table<CriterionCategoryMapping>
|
criterionCategoryMappings!: Table<CriterionCategoryMapping>
|
||||||
criterionLevelDescriptions!: Table<CriterionLevelDescription>
|
criterionLevelDescriptions!: Table<CriterionLevelDescription>
|
||||||
|
appSettings!: Table<AppSettings>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('AssignmentMonitorDB')
|
super('AssignmentMonitorDB')
|
||||||
|
|
@ -129,6 +131,31 @@ class AssignmentMonitorDB extends Dexie {
|
||||||
meetings: null,
|
meetings: null,
|
||||||
consultantNotes: 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 ─────────────────────────────────────────────────────
|
// ─── Gesprächs-Zeitleiste ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ConversationLineTag {
|
||||||
|
text: string
|
||||||
|
criterionItemId: number // → FeedbackCriterionItem.id
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConversationEntry {
|
export interface ConversationEntry {
|
||||||
id?: number
|
id?: number
|
||||||
meetingInstanceId: number
|
meetingInstanceId: number
|
||||||
|
|
@ -106,6 +111,7 @@ export interface ConversationEntry {
|
||||||
note: string
|
note: string
|
||||||
fillerCount: number
|
fillerCount: number
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
lineTags?: ConversationLineTag[] // Kriterium-Zuordnung pro Zeile, gematcht per exaktem Zeilentext
|
||||||
}
|
}
|
||||||
|
|
||||||
// criteriaId → FeedbackCategory.id (Schnellbewertung während Gesprächsbeitrag)
|
// criteriaId → FeedbackCategory.id (Schnellbewertung während Gesprächsbeitrag)
|
||||||
|
|
@ -200,3 +206,10 @@ export interface CriterionLevelDescription {
|
||||||
rating: FeedbackRating
|
rating: FeedbackRating
|
||||||
text: string
|
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: '',
|
title: '',
|
||||||
client: '',
|
client: '',
|
||||||
leadName: '',
|
leadName: '',
|
||||||
|
assignmentNumber: '',
|
||||||
assignmentTypeId: 0,
|
assignmentTypeId: 0,
|
||||||
selectedGroupId: 0,
|
selectedGroupId: 0,
|
||||||
instituteeIds: [] as number[],
|
instituteeIds: [] as number[],
|
||||||
|
|
@ -66,11 +67,12 @@ export default function AssignmentCreate() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const create = async () => {
|
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({
|
const id = await db.assignments.add({
|
||||||
title: form.title,
|
title: form.title,
|
||||||
client: form.client,
|
client: form.client,
|
||||||
leadName: form.leadName,
|
leadName: form.leadName,
|
||||||
|
assignmentNumber: form.assignmentNumber.trim(),
|
||||||
assignmentTypeId: form.assignmentTypeId,
|
assignmentTypeId: form.assignmentTypeId,
|
||||||
instituteeIds: form.instituteeIds,
|
instituteeIds: form.instituteeIds,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|
@ -79,7 +81,7 @@ export default function AssignmentCreate() {
|
||||||
navigate(`/assignment/${id}`)
|
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!))
|
const selectedInstitutees = consultants.filter(c => form.instituteeIds.includes(c.id!))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -88,6 +90,9 @@ export default function AssignmentCreate() {
|
||||||
|
|
||||||
{/* Basisdaten */}
|
{/* Basisdaten */}
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-3">
|
<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"
|
<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}
|
placeholder="Titel des Assignments" value={form.title}
|
||||||
onChange={e => setForm(p => ({ ...p, title: e.target.value }))} />
|
onChange={e => setForm(p => ({ ...p, title: e.target.value }))} />
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ import {
|
||||||
type FeedbackDimensionText,
|
type FeedbackDimensionText,
|
||||||
type FeedbackRating,
|
type FeedbackRating,
|
||||||
} from '../db'
|
} 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 =>
|
const ratingToNum = (r: FeedbackRating | null | undefined): number =>
|
||||||
r ? (RATING_NUM_MAP[r] ?? 0) : 0
|
r ? (RATING_NUM_MAP[r] ?? 0) : 0
|
||||||
|
|
@ -20,6 +22,12 @@ const ratingToNum = (r: FeedbackRating | null | undefined): number =>
|
||||||
const ratingColor = (r: FeedbackRating | undefined) =>
|
const ratingColor = (r: FeedbackRating | undefined) =>
|
||||||
RATING_OPTIONS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200'
|
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() {
|
function getAiSettings() {
|
||||||
return {
|
return {
|
||||||
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
|
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
|
||||||
|
|
@ -39,9 +47,9 @@ async function buildAiPrompt(
|
||||||
assignment: Assignment,
|
assignment: Assignment,
|
||||||
dimensions: FeedbackDimension[],
|
dimensions: FeedbackDimension[],
|
||||||
categories: FeedbackCategory[],
|
categories: FeedbackCategory[],
|
||||||
_items: FeedbackCriterionItem[],
|
items: FeedbackCriterionItem[],
|
||||||
ratings: FeedbackCategoryRating[],
|
ratings: FeedbackCategoryRating[],
|
||||||
_feedbackId: number,
|
catTrends: Map<number, TrendResult>,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const meetings = await db.meetingInstances.where('assignmentId').equals(assignment.id!).toArray()
|
const meetings = await db.meetingInstances.where('assignmentId').equals(assignment.id!).toArray()
|
||||||
const meetingIds = meetings.map(m => m.id!)
|
const meetingIds = meetings.map(m => m.id!)
|
||||||
|
|
@ -54,24 +62,33 @@ async function buildAiPrompt(
|
||||||
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
|
const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id)
|
||||||
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
|
const instEntries = allEntries.filter(e => e.instituteeId === institutee.id)
|
||||||
|
|
||||||
const critIds = [...new Set(instAssessments.map(a => a.criteriaId))]
|
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
|
||||||
const crits = critIds.length > 0
|
|
||||||
? await db.criteria.where('id').anyOf(critIds).toArray()
|
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 scoreLines = crits.map(c => {
|
||||||
const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null)
|
const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null)
|
||||||
const avg = scores.length > 0
|
const avg = scores.length > 0
|
||||||
? (scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length).toFixed(1)
|
? (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('; ')}` : ''}`
|
return ` ${c.name}: ${avg}/5${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
|
||||||
}).join('\n')
|
}).join('\n')
|
||||||
|
|
||||||
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
|
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
|
||||||
const protocolLines = instEntries
|
const protocolLines = instEntries
|
||||||
.filter(e => e.note)
|
.flatMap(e => splitNoteLines(e.note).map(line => ({
|
||||||
.map(e => ` - ${e.note}${(e.fillerCount ?? 0) > 0 ? ` [äh×${e.fillerCount}]` : ''}`)
|
line, tagged: (e.lineTags ?? []).some(t => t.text === line),
|
||||||
|
})))
|
||||||
|
.filter(x => !x.tagged)
|
||||||
|
.map(x => ` - ${x.line}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
|
||||||
// Kategorie-Ratings
|
// Kategorie-Ratings
|
||||||
|
|
@ -85,6 +102,22 @@ async function buildAiPrompt(
|
||||||
return ` Dimension "${dim.name}":\n${lines}`
|
return ` Dimension "${dim.name}":\n${lines}`
|
||||||
}).join('\n')
|
}).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
|
// Feedback-Struktur für Output
|
||||||
const dimStructure = dimensions.map((dim, i) =>
|
const dimStructure = dimensions.map((dim, i) =>
|
||||||
`${i + 1}. ${dim.name}`
|
`${i + 1}. ${dim.name}`
|
||||||
|
|
@ -100,13 +133,16 @@ FÜLLWÖRTER GESAMT: ${fillerTotal}
|
||||||
BEOBACHTETE KRITERIEN UND SCORES (1-5):
|
BEOBACHTETE KRITERIEN UND SCORES (1-5):
|
||||||
${scoreLines || '(keine Scores erfasst)'}
|
${scoreLines || '(keine Scores erfasst)'}
|
||||||
|
|
||||||
PROTOKOLL-NOTIZEN (Kurznotizen aus dem Gespräch, Notation: + gut, ! negativ, > Kundeninput):
|
PROTOKOLL-NOTIZEN, KEINEM KRITERIUM ZUGEORDNET (Notation: + gut, ! negativ, > Kundeninput):
|
||||||
${protocolLines || '(keine Protokolleinträge)'}
|
${protocolLines || '(keine nicht zugeordneten Protokolleinträge)'}
|
||||||
|
|
||||||
KATEGORIE-BEWERTUNGEN:
|
KATEGORIE-BEWERTUNGEN:
|
||||||
${ratingLines}
|
${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:
|
Ausgabe als JSON:
|
||||||
{
|
{
|
||||||
|
|
@ -142,6 +178,7 @@ export default function AssignmentFeedbackPage() {
|
||||||
const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([])
|
const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([])
|
||||||
const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([])
|
const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([])
|
||||||
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||||
|
const [catTrends, setCatTrends] = useState<Map<number, TrendResult>>(new Map())
|
||||||
const [showAiSettings, setShowAiSettings] = useState(false)
|
const [showAiSettings, setShowAiSettings] = useState(false)
|
||||||
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
|
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
|
||||||
const [aiModel, setAiModel] = useState(getAiSettings().model)
|
const [aiModel, setAiModel] = useState(getAiSettings().model)
|
||||||
|
|
@ -163,6 +200,27 @@ export default function AssignmentFeedbackPage() {
|
||||||
setCategories(cats)
|
setCategories(cats)
|
||||||
setItems(itms)
|
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
|
let fb = await db.assignmentFeedbacks
|
||||||
.where('assignmentId').equals(assignmentId)
|
.where('assignmentId').equals(assignmentId)
|
||||||
.filter(f => f.instituteeId === instId)
|
.filter(f => f.instituteeId === instId)
|
||||||
|
|
@ -243,6 +301,17 @@ export default function AssignmentFeedbackPage() {
|
||||||
const getDimText = (dimId: number) =>
|
const getDimText = (dimId: number) =>
|
||||||
dimTexts.find(t => t.feedbackDimensionId === dimId)
|
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 ────────────────────────────────────────────────────────
|
// ── KI-Generierung ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const generateWithAi = async () => {
|
const generateWithAi = async () => {
|
||||||
|
|
@ -253,7 +322,7 @@ export default function AssignmentFeedbackPage() {
|
||||||
setGenerating(true)
|
setGenerating(true)
|
||||||
setAiError('')
|
setAiError('')
|
||||||
try {
|
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', {
|
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -462,6 +531,33 @@ export default function AssignmentFeedbackPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Kriterium-Items als Referenz */}
|
||||||
{catItems.length > 0 && (
|
{catItems.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import FeedbackStructureConfig from './FeedbackStructureConfig'
|
import FeedbackStructureConfig from './FeedbackStructureConfig'
|
||||||
import AssignmentTypeConfig from './AssignmentTypeConfig'
|
import AssignmentTypeConfig from './AssignmentTypeConfig'
|
||||||
|
import RatingWeightConfig from './RatingWeightConfig'
|
||||||
import { exportFullBackup, importFullBackup } from '../utils/dbBackup'
|
import { exportFullBackup, importFullBackup } from '../utils/dbBackup'
|
||||||
|
|
||||||
export default function Configuration() {
|
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 [backupStatus, setBackupStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null)
|
||||||
const [restoreConfirm, setRestoreConfirm] = useState(false)
|
const [restoreConfirm, setRestoreConfirm] = useState(false)
|
||||||
const [importing, setImporting] = 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'}`}>
|
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
|
Ass.-Typen
|
||||||
</button>
|
</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')}
|
<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'}`}>
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'backup' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
||||||
Backup
|
Backup
|
||||||
|
|
@ -65,6 +70,7 @@ export default function Configuration() {
|
||||||
|
|
||||||
{tab === 'feedback' && <FeedbackStructureConfig />}
|
{tab === 'feedback' && <FeedbackStructureConfig />}
|
||||||
{tab === 'types' && <AssignmentTypeConfig />}
|
{tab === 'types' && <AssignmentTypeConfig />}
|
||||||
|
{tab === 'weighting' && <RatingWeightConfig />}
|
||||||
|
|
||||||
{tab === 'backup' && (
|
{tab === 'backup' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,16 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
type Assignment, type Consultant,
|
type Assignment, type Consultant,
|
||||||
type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
|
type FeedbackDimension, type FeedbackCategory,
|
||||||
type FeedbackRating,
|
type FeedbackRating,
|
||||||
} from '../db'
|
} 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 {
|
interface CatScore {
|
||||||
category: FeedbackCategory
|
category: FeedbackCategory
|
||||||
avg: number | null
|
avg: number | null
|
||||||
|
trend: TrendResult['trend']
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InstScore {
|
interface InstScore {
|
||||||
|
|
@ -18,18 +20,7 @@ interface InstScore {
|
||||||
assignment: Assignment
|
assignment: Assignment
|
||||||
catScores: CatScore[]
|
catScores: CatScore[]
|
||||||
overall: number | null
|
overall: number | null
|
||||||
}
|
overallTrend: TrendResult['trend']
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ratingColor(avg: number | null): string {
|
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)
|
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() {
|
export default function Evaluation() {
|
||||||
const [data, setData] = useState<InstScore[]>([])
|
const [data, setData] = useState<InstScore[]>([])
|
||||||
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
||||||
|
|
@ -60,22 +57,26 @@ export default function Evaluation() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
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.assignments.toArray(),
|
||||||
db.consultants.toArray(),
|
db.consultants.toArray(),
|
||||||
db.feedbackDimensions.orderBy('order').toArray(),
|
db.feedbackDimensions.orderBy('order').toArray(),
|
||||||
db.feedbackCategories.orderBy('order').toArray(),
|
db.feedbackCategories.orderBy('order').toArray(),
|
||||||
db.feedbackCriterionItems.orderBy('order').toArray(),
|
db.feedbackCriterionItems.orderBy('order').toArray(),
|
||||||
db.assessments.toArray(),
|
db.assessments.toArray(),
|
||||||
|
db.appSettings.get(1),
|
||||||
])
|
])
|
||||||
setDimensions(dims)
|
setDimensions(dims)
|
||||||
setCategories(cats)
|
setCategories(cats)
|
||||||
|
const weightStep = settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP
|
||||||
|
|
||||||
const rows: InstScore[] = []
|
const rows: InstScore[] = []
|
||||||
for (const a of assignments) {
|
for (const a of assignments) {
|
||||||
const meetingIds = (await db.meetingInstances
|
const meetings = (await db.meetingInstances
|
||||||
.where('assignmentId').equals(a.id!).toArray()
|
.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) {
|
for (const instId of a.instituteeIds) {
|
||||||
const inst = consultants.find(c => c.id === instId)
|
const inst = consultants.find(c => c.id === instId)
|
||||||
|
|
@ -86,25 +87,20 @@ export default function Evaluation() {
|
||||||
)
|
)
|
||||||
if (instAssessments.length === 0) continue
|
if (instAssessments.length === 0) continue
|
||||||
|
|
||||||
const getScore = (itemId: number): FeedbackRating | null => {
|
const getScore = (meetingId: number, itemId: number): FeedbackRating | null =>
|
||||||
const relevant = instAssessments.filter(a => a.criteriaId === itemId && a.score !== null && a.score !== 'na')
|
instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null
|
||||||
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 catScores: CatScore[] = cats.map(cat => ({
|
const catScores: CatScore[] = cats.map(cat => {
|
||||||
category: cat,
|
const history = buildGroupMeetingScores(items.filter(i => i.categoryId === cat.id), meetings, getScore)
|
||||||
avg: weightedAvg(items.filter(i => i.categoryId === cat.id), 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
|
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)
|
setData(rows)
|
||||||
|
|
@ -150,8 +146,8 @@ export default function Evaluation() {
|
||||||
{row.institutee.firstName} {row.institutee.lastName}
|
{row.institutee.firstName} {row.institutee.lastName}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`px-3 py-1 rounded-full text-sm font-bold ${ratingColor(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)}
|
{ratingLabel(row.overall)} {trendArrow(row.overallTrend)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }}
|
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">
|
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">
|
||||||
{dim.name}
|
{dim.name}
|
||||||
</div>
|
</div>
|
||||||
{dimScores.map(({ category, avg }) => (
|
{dimScores.map(({ category, avg, trend }) => (
|
||||||
<div key={category.id}
|
<div key={category.id}
|
||||||
className="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0">
|
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-sm text-gray-700">{category.name}</span>
|
||||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${ratingColor(avg)}`}>
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded flex items-center gap-1 ${ratingColor(avg)}`}>
|
||||||
{ratingLabel(avg)}
|
{ratingLabel(avg)} {trendArrow(trend)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -271,13 +271,13 @@ export default function FeedbackStructureConfig() {
|
||||||
value={item.name}
|
value={item.name}
|
||||||
onChange={e => updateItem(item.id!, e.target.value)}
|
onChange={e => updateItem(item.id!, e.target.value)}
|
||||||
/>
|
/>
|
||||||
<select
|
<input
|
||||||
|
type="number" min={0} max={2} step={0.1}
|
||||||
value={item.weight ?? 1}
|
value={item.weight ?? 1}
|
||||||
onChange={e => updateItemWeight(item.id!, Number(e.target.value))}
|
onChange={e => updateItemWeight(item.id!, Number(e.target.value))}
|
||||||
title="Gewichtung"
|
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">
|
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"
|
||||||
{[1,2,3,4,5].map(w => <option key={w} value={w}>×{w}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
<button onClick={() => deleteItem(item.id!)}
|
<button onClick={() => deleteItem(item.id!)}
|
||||||
className="text-xs text-red-200 hover:text-red-400">×</button>
|
className="text-xs text-red-200 hover:text-red-400">×</button>
|
||||||
</div>
|
</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 { useState } from 'react'
|
||||||
import type {
|
import type {
|
||||||
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
||||||
Assessment, FeedbackRating,
|
Assessment, ConversationEntry, FeedbackRating,
|
||||||
} from '../../db'
|
} from '../../db'
|
||||||
import { INST_COLORS, RATING_OPTIONS, RATING_NUM_MAP } from '../../config/constants'
|
import { INST_COLORS, RATING_OPTIONS, RATING_NUM_MAP } from '../../config/constants'
|
||||||
|
import { detectNotationRating } from '../../utils/notationParser'
|
||||||
|
import { numberToRating } from '../../utils/ratingTrend'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
institutees: Consultant[]
|
institutees: Consultant[]
|
||||||
fbCategories: FeedbackCategory[]
|
fbCategories: FeedbackCategory[]
|
||||||
fbItems: FeedbackCriterionItem[]
|
fbItems: FeedbackCriterionItem[]
|
||||||
assessments: Assessment[]
|
assessments: Assessment[]
|
||||||
|
entries: ConversationEntry[]
|
||||||
hasConv: boolean
|
hasConv: boolean
|
||||||
tagLabel: (inst: Consultant) => string
|
tagLabel: (inst: Consultant) => string
|
||||||
fillerTotal: (instId: number) => number
|
fillerTotal: (instId: number) => number
|
||||||
|
|
@ -30,6 +33,7 @@ function catMode(
|
||||||
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
||||||
if (scored.length === 0) return null
|
if (scored.length === 0) return null
|
||||||
const wSum = scored.reduce((s, x) => s + x.w, 0)
|
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
|
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 < 1.5) return 'not_client_ready'
|
||||||
if (avg < 2.5) return 'partially_client_ready'
|
if (avg < 2.5) return 'partially_client_ready'
|
||||||
|
|
@ -37,8 +41,20 @@ function catMode(
|
||||||
return 'client_ready'
|
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({
|
export function AssessmentTab({
|
||||||
institutees, fbCategories, fbItems, assessments, hasConv,
|
institutees, fbCategories, fbItems, assessments, entries, hasConv,
|
||||||
tagLabel, fillerTotal,
|
tagLabel, fillerTotal,
|
||||||
getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc,
|
getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
|
@ -82,6 +98,11 @@ export function AssessmentTab({
|
||||||
const isOpen = expandedCat[inst.id!] === cat.id
|
const isOpen = expandedCat[inst.id!] === cat.id
|
||||||
const mode = catMode(cat, fbItems, itemId => getAssessmentScore(inst.id!, itemId))
|
const mode = catMode(cat, fbItems, itemId => getAssessmentScore(inst.id!, itemId))
|
||||||
const headerRo = mode ? RATING_OPTIONS.find(r => r.value === mode) : null
|
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 (
|
return (
|
||||||
<div key={cat.id} className="rounded-xl overflow-hidden border border-gray-100">
|
<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>
|
<span className={`text-sm font-medium ${headerRo ? 'text-white' : 'text-gray-700'}`}>{cat.name}</span>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{!headerRo && ratedCount > 0 && (
|
||||||
<span className="text-xs text-brand font-medium">{ratedCount}/{catItems.length}</span>
|
<span className="text-xs text-brand font-medium">{ratedCount}/{catItems.length}</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -106,6 +135,7 @@ export function AssessmentTab({
|
||||||
<div className="border-t border-gray-100 divide-y divide-gray-50">
|
<div className="border-t border-gray-100 divide-y divide-gray-50">
|
||||||
{catItems.map(item => {
|
{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 (
|
return (
|
||||||
<div key={item.id} className="px-3 py-2.5 space-y-1.5">
|
<div key={item.id} className="px-3 py-2.5 space-y-1.5">
|
||||||
<div className="text-xs text-gray-600 font-medium">{item.name}</div>
|
<div className="text-xs text-gray-600 font-medium">{item.name}</div>
|
||||||
|
|
@ -120,6 +150,17 @@ export function AssessmentTab({
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type {
|
||||||
} from '../../db'
|
} from '../../db'
|
||||||
import { NotationText } from '../../utils/notationParser'
|
import { NotationText } from '../../utils/notationParser'
|
||||||
import { INST_COLORS, RATING_OPTIONS } from '../../config/constants'
|
import { INST_COLORS, RATING_OPTIONS } from '../../config/constants'
|
||||||
|
import { CriterionTagPicker } from './CriterionTagPicker'
|
||||||
|
|
||||||
const NOTATIONS = [
|
const NOTATIONS = [
|
||||||
{ sym: '+', tip: 'Gut' },
|
{ sym: '+', tip: 'Gut' },
|
||||||
|
|
@ -37,6 +38,8 @@ interface Props {
|
||||||
onSaveEntryNote: () => void
|
onSaveEntryNote: () => void
|
||||||
onIncrementFiller: () => void
|
onIncrementFiller: () => void
|
||||||
onSetEntryScore: (entryId: number, categoryId: number, score: FeedbackRating) => 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
|
onStartEdit: (entry: ConversationEntry) => void
|
||||||
onSaveEdit: (entryId: number) => void
|
onSaveEdit: (entryId: number) => void
|
||||||
onCancelEdit: () => void
|
onCancelEdit: () => void
|
||||||
|
|
@ -50,7 +53,7 @@ export function ConversationTab({
|
||||||
activeInstId, activeEntryId, entryNote, editingId, editNote, noteRef,
|
activeInstId, activeEntryId, entryNote, editingId, editNote, noteRef,
|
||||||
displayName, tagLabel, fillerTotal, getEntryScore,
|
displayName, tagLabel, fillerTotal, getEntryScore,
|
||||||
onActivateInstitutee, onCloseActiveEntry, onInsertNotation,
|
onActivateInstitutee, onCloseActiveEntry, onInsertNotation,
|
||||||
onEntryNoteChange, onSaveEntryNote, onIncrementFiller, onSetEntryScore,
|
onEntryNoteChange, onSaveEntryNote, onIncrementFiller, onSetEntryScore, onSetLineCriterion, onChangeSpeaker,
|
||||||
onStartEdit, onSaveEdit, onCancelEdit, onEditNoteChange, onDeleteEntry, onAdjustFiller,
|
onStartEdit, onSaveEdit, onCancelEdit, onEditNoteChange, onDeleteEntry, onAdjustFiller,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const activeInstIdx = institutees.findIndex(c => c.id === activeInstId)
|
const activeInstIdx = institutees.findIndex(c => c.id === activeInstId)
|
||||||
|
|
@ -206,6 +209,17 @@ export function ConversationTab({
|
||||||
onChange={e => onEditNoteChange(e.target.value)}
|
onChange={e => onEditNoteChange(e.target.value)}
|
||||||
autoFocus
|
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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-gray-500">Füllwörter:</span>
|
<span className="text-xs text-gray-500">Füllwörter:</span>
|
||||||
<button onClick={() => onAdjustFiller(entry, -1)}
|
<button onClick={() => onAdjustFiller(entry, -1)}
|
||||||
|
|
@ -223,8 +237,20 @@ export function ConversationTab({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{entry.note && <NotationText text={entry.note} />}
|
{entry.note && (
|
||||||
<div className="flex gap-1 flex-wrap mt-1">
|
<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 && (
|
{(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">
|
<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}
|
ä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,
|
type Consultant, type ConversationEntry, type ConversationSkillScore, type Assessment,
|
||||||
} from '../../db'
|
} from '../../db'
|
||||||
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport'
|
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 { RATING_NUM_MAP } from '../../config/constants'
|
||||||
import { ConversationTab } from './ConversationTab'
|
import { ConversationTab } from './ConversationTab'
|
||||||
import { AssessmentTab } from './AssessmentTab'
|
import { AssessmentTab } from './AssessmentTab'
|
||||||
|
|
@ -96,8 +96,12 @@ export default function MeetingView() {
|
||||||
|
|
||||||
const activateInstitutee = async (instId: number) => {
|
const activateInstitutee = async (instId: number) => {
|
||||||
if (activeEntryId !== null) {
|
if (activeEntryId !== null) {
|
||||||
|
if (entryNote.trim() === '') {
|
||||||
|
await db.conversationEntries.delete(activeEntryId)
|
||||||
|
} else {
|
||||||
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
|
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const maxSeq = entries.reduce((m, e) => Math.max(m, e.sequenceIndex), 0)
|
const maxSeq = entries.reduce((m, e) => Math.max(m, e.sequenceIndex), 0)
|
||||||
const eid = await db.conversationEntries.add({
|
const eid = await db.conversationEntries.add({
|
||||||
meetingInstanceId: meetingId, instituteeId: instId,
|
meetingInstanceId: meetingId, instituteeId: instId,
|
||||||
|
|
@ -135,7 +139,11 @@ export default function MeetingView() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeActiveEntry = async () => {
|
const closeActiveEntry = async () => {
|
||||||
|
if (activeEntryId !== null && entryNote.trim() === '') {
|
||||||
|
await db.conversationEntries.delete(activeEntryId)
|
||||||
|
} else {
|
||||||
await saveEntryNote()
|
await saveEntryNote()
|
||||||
|
}
|
||||||
setActiveInstId(null)
|
setActiveInstId(null)
|
||||||
setActiveEntryId(null)
|
setActiveEntryId(null)
|
||||||
setEntryNote('')
|
setEntryNote('')
|
||||||
|
|
@ -145,11 +153,24 @@ export default function MeetingView() {
|
||||||
const startEdit = (entry: ConversationEntry) => { setEditingId(entry.id!); setEditNote(entry.note) }
|
const startEdit = (entry: ConversationEntry) => { setEditingId(entry.id!); setEditNote(entry.note) }
|
||||||
|
|
||||||
const saveEdit = async (entryId: number) => {
|
const saveEdit = async (entryId: number) => {
|
||||||
await db.conversationEntries.update(entryId, { note: editNote, updatedAt: new Date().toISOString() })
|
const entry = entries.find(e => e.id === entryId)
|
||||||
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote } : e))
|
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)
|
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) => {
|
const deleteEntry = async (entryId: number) => {
|
||||||
await db.conversationSkillScores.where('conversationEntryId').equals(entryId).delete()
|
await db.conversationSkillScores.where('conversationEntryId').equals(entryId).delete()
|
||||||
await db.conversationEntries.delete(entryId)
|
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))
|
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 =>
|
const getEntryScore = (entryId: number, categoryId: number): FeedbackRating | null =>
|
||||||
scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score ?? null
|
scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score ?? null
|
||||||
|
|
||||||
|
|
@ -393,6 +419,8 @@ export default function MeetingView() {
|
||||||
onSaveEntryNote={saveEntryNote}
|
onSaveEntryNote={saveEntryNote}
|
||||||
onIncrementFiller={incrementFiller}
|
onIncrementFiller={incrementFiller}
|
||||||
onSetEntryScore={setEntryScore}
|
onSetEntryScore={setEntryScore}
|
||||||
|
onSetLineCriterion={setLineCriterion}
|
||||||
|
onChangeSpeaker={changeEntrySpeaker}
|
||||||
onStartEdit={startEdit}
|
onStartEdit={startEdit}
|
||||||
onSaveEdit={saveEdit}
|
onSaveEdit={saveEdit}
|
||||||
onCancelEdit={() => setEditingId(null)}
|
onCancelEdit={() => setEditingId(null)}
|
||||||
|
|
@ -408,6 +436,7 @@ export default function MeetingView() {
|
||||||
fbCategories={fbCategories}
|
fbCategories={fbCategories}
|
||||||
fbItems={fbItems}
|
fbItems={fbItems}
|
||||||
assessments={assessments}
|
assessments={assessments}
|
||||||
|
entries={entries}
|
||||||
hasConv={hasConv}
|
hasConv={hasConv}
|
||||||
tagLabel={tagLabel}
|
tagLabel={tagLabel}
|
||||||
fillerTotal={fillerTotal}
|
fillerTotal={fillerTotal}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { downloadFile } from './meetingExport'
|
import { downloadFile } from './meetingExport'
|
||||||
|
|
||||||
|
// Muss mit den Tabellen in db/schema.ts synchron gehalten werden — eine neue Tabelle dort
|
||||||
|
// landet nicht automatisch im Backup, sondern muss hier manuell ergänzt werden.
|
||||||
const TABLE_NAMES = [
|
const TABLE_NAMES = [
|
||||||
'categories',
|
'categories',
|
||||||
'criteria',
|
'criteria',
|
||||||
|
|
@ -22,6 +24,7 @@ const TABLE_NAMES = [
|
||||||
'feedbackDimensionTexts',
|
'feedbackDimensionTexts',
|
||||||
'criterionCategoryMappings',
|
'criterionCategoryMappings',
|
||||||
'criterionLevelDescriptions',
|
'criterionLevelDescriptions',
|
||||||
|
'appSettings',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export interface DbBackup {
|
export interface DbBackup {
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,11 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
...new Set([
|
...new Set([
|
||||||
...(template?.criteriaIds ?? []),
|
...(template?.criteriaIds ?? []),
|
||||||
...assessments.map(a => a.criteriaId),
|
...assessments.map(a => a.criteriaId),
|
||||||
|
...entries.flatMap(e => (e.lineTags ?? []).map(t => t.criterionItemId)),
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
const criteria = critIds.length > 0
|
const criteria = critIds.length > 0
|
||||||
? await db.criteria.where('id').anyOf(critIds).toArray()
|
? await db.feedbackCriterionItems.where('id').anyOf(critIds).toArray()
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const institutees = instituteeIds
|
const institutees = instituteeIds
|
||||||
|
|
@ -63,7 +64,9 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
|
|
||||||
if (entry.note?.trim()) {
|
if (entry.note?.trim()) {
|
||||||
for (const noteLine of entry.note.trim().split('\n')) {
|
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 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)
|
// Notations-Regeln (Reihenfolge wichtig: längere Symbole zuerst)
|
||||||
const NOTATION_RULES = [
|
const NOTATION_RULES = [
|
||||||
|
|
@ -57,14 +75,27 @@ function parseLine(line: string, key: number): React.ReactElement {
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string
|
text: string
|
||||||
className?: 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
|
if (!text?.trim()) return null
|
||||||
const lines = text.split('\n')
|
const lines = text.split('\n')
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<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>
|
</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'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
port: process.env.PORT ? Number(process.env.PORT) : 5173,
|
||||||
|
strictPort: !!process.env.PORT,
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user