V2-Dev-Stand: Skala 1-10, Benefits/Concerns, DEV-Isolation und Gitea-Doku.
Bündelt die Neuentwicklung in AssigmentMonitorV2 (eigene Ports/DB), die Umstellung auf numerische Bewertungen, Meeting-Checklisten, KI-Tagging und die geplante Feedback-Kaskade — als Basis für Versionsverwaltung in Gitea. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
782678a1cf
commit
234f87d408
16
.env.example
Normal file
16
.env.example
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Assignment Monitor V2 — lokale Entwicklungs-Isolation
|
||||||
|
# Kopie nach `.env` anlegen. Diese Werte dürfen NICHT mit dem Produktivsystem
|
||||||
|
# (c:\dev\AssigmentMonitor, Ports 5173/4000) kollidieren.
|
||||||
|
|
||||||
|
# Vite-Frontend (Produktiv: 5173)
|
||||||
|
PORT=5174
|
||||||
|
|
||||||
|
# Express-RPC-API (Produktiv: 4000)
|
||||||
|
API_PORT=4001
|
||||||
|
|
||||||
|
# Vite-Proxy-Ziel → muss zu API_PORT passen
|
||||||
|
API_URL=http://localhost:4001
|
||||||
|
|
||||||
|
# SQLite-Dateiname unter server/data/ (Produktiv: assignment-monitor.sqlite3)
|
||||||
|
# Nur Dateiname, kein Pfad — der Server erzwingt Ablage innerhalb dieses V2-Projekts.
|
||||||
|
SQLITE_FILENAME=assignment-monitor-v2-dev.sqlite3
|
||||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"syncfusioninc.document-viewer-vscode-extensions"
|
||||||
|
]
|
||||||
|
}
|
||||||
70
CLAUDE.md
70
CLAUDE.md
|
|
@ -4,6 +4,8 @@ Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) wäh
|
||||||
|
|
||||||
**Vollständige Dokumentation:** `docs/` — bei neuer Session zuerst `docs/HANDOVER.md` lesen.
|
**Vollständige Dokumentation:** `docs/` — bei neuer Session zuerst `docs/HANDOVER.md` lesen.
|
||||||
|
|
||||||
|
**DEV-Isolation:** Dieses Repo (`AssigmentMonitorV2`) ist von Produktiv (`c:\dev\AssigmentMonitor`) getrennt — Ports **5174/4001**, DB `assignment-monitor-v2-dev.sqlite3`. Regeln: `docs/DEV_ISOLATION.md`. Produktiv-Code und -Daten niemals anfassen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
@ -36,6 +38,7 @@ src/
|
||||||
utils/
|
utils/
|
||||||
ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung + weightedAverageScore/weightedAverageRating (genutzt von Evaluation.tsx, AssignmentFeedbackPage.tsx, AssessmentTab.tsx, AssignmentDetail.tsx)
|
ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung + weightedAverageScore/weightedAverageRating (genutzt von Evaluation.tsx, AssignmentFeedbackPage.tsx, AssessmentTab.tsx, AssignmentDetail.tsx)
|
||||||
aiPrompt.ts ← Prompt-Engine für die KI-Feedback-Generierung (seit 2026-07-07): Platzhalter-Kontext pro Dimension, Rendering, Antwort-Parsing — siehe eigener Abschnitt „KI-Feedback-Generierung" unten
|
aiPrompt.ts ← Prompt-Engine für die KI-Feedback-Generierung (seit 2026-07-07): Platzhalter-Kontext pro Dimension, Rendering, Antwort-Parsing — siehe eigener Abschnitt „KI-Feedback-Generierung" unten
|
||||||
|
pdfExport.tsx ← PDF-Export (seit 2026-07-14, `@react-pdf/renderer`): Assignment-Report (Gesamtprotokoll + Feedback aller Institutees) und Einzel-Feedback-PDF pro Institutee
|
||||||
server/
|
server/
|
||||||
index.ts ← Express-App, ein Endpunkt POST /api/rpc
|
index.ts ← Express-App, ein Endpunkt POST /api/rpc
|
||||||
database.ts ← node:sqlite-Verbindung (server/data/assignment-monitor.sqlite3), Schema aus tableRegistry.ts
|
database.ts ← node:sqlite-Verbindung (server/data/assignment-monitor.sqlite3), Schema aus tableRegistry.ts
|
||||||
|
|
@ -146,14 +149,79 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
|
||||||
|
|
||||||
| Prio | Aufgabe | Datei |
|
| Prio | Aufgabe | Datei |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 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` |
|
|
||||||
| M | **Migration der echten Daten aussteht:** Aktuelle IndexedDB-Daten müssen noch einmalig über Config → Backup → Export (alte Version) exportiert und über Config → Backup → Import (neue, serverbasierte Version) in die SQLite-DB eingespielt werden. Siehe Datenbank-Abschnitt oben. | — |
|
| M | **Migration der echten Daten aussteht:** Aktuelle IndexedDB-Daten müssen noch einmalig über Config → Backup → Export (alte Version) exportiert und über Config → Backup → Import (neue, serverbasierte Version) in die SQLite-DB eingespielt werden. Siehe Datenbank-Abschnitt oben. | — |
|
||||||
| L | Dexie/IndexedDB-Legacy-Code vollständig entfernen (`src/db/schema.ts`, `src/db/seeds/`) — aktuell bewusst nur stillgelegt, nicht gelöscht | `src/db/schema.ts`, `src/db/seeds/` |
|
| L | Dexie/IndexedDB-Legacy-Code vollständig entfernen (`src/db/schema.ts`, `src/db/seeds/`) — aktuell bewusst nur stillgelegt, nicht gelöscht | `src/db/schema.ts`, `src/db/seeds/` |
|
||||||
| L | Server-seitiges "Seed-if-empty" für Assignment-Typen/Feedback-Struktur (aktuell nur clientseitig vorhanden und nicht mehr wirksam) — nicht dringend, da der Backup-Import denselben Zweck erfüllt | `server/db/queries/` (neu) |
|
| L | Server-seitiges "Seed-if-empty" für Assignment-Typen/Feedback-Struktur (aktuell nur clientseitig vorhanden und nicht mehr wirksam) — nicht dringend, da der Backup-Import denselben Zweck erfüllt | `server/db/queries/` (neu) |
|
||||||
| L | SQLite WASM + OPFS Migration — durch den lokalen Server-Ansatz (2026-07-04) überholt/hinfällig | `src/db/schema.ts` |
|
| L | SQLite WASM + OPFS Migration — durch den lokalen Server-Ansatz (2026-07-04) überholt/hinfällig | `src/db/schema.ts` |
|
||||||
|
|
||||||
|
**Erledigt (2026-07-19): Live-Formulierungshilfe, Beobachter-Fokus, Meeting-Checkliste umgesetzt** (Konzept siehe Eintrag unten, dort auch die Design-Begründungen). Neue Dateien: `src/utils/criterionOptions.tsx` (aus `FeedbackStructureConfig.tsx` extrahiertes `criterionOptionGroups()`, jetzt auch von `AssignmentTypeConfig.tsx` genutzt), `server/db/queries/checklists.ts` + `src/db/queries/checklists.ts` (neues RPC-Modul `checklists`), `src/pages/meeting/ChecklistTab.tsx` (neuer dritter Meeting-Tab, nur sichtbar wenn `PhaseTemplate.hasChecklist`). `AssignmentTypeConfig.tsx` bekam pro Phase eine „Checkliste"-Checkbox + aufklappbaren Punkte-Editor (Label, Scope-Auswahl, bei `perInstitutee` optionales Kriterium-Dropdown). `deletePhaseTemplate`/`deleteAssignmentType` cascaden jetzt zusätzlich über `checklistItemDefs` (gleiches Verteidigungsprinzip wie bei `deleteFeedbackCriterionItem`). Live-Formulierungshilfe: neuer State `corpus` in `MeetingView.tsx` (einmal pro Meeting-Load geholt, nicht pro Tastendruck), debounced (~800ms) Aufruf von `findSimilarTaggedLines()` sowohl in `ConversationTab.tsx` (aktive Notiz) als auch für die allgemeinen Notizen — rein lesendes „💭 Ähnliche frühere Notizen"-Panel. Fokus-Übersicht als neue aufklappbare „📊 Fokus-Übersicht"-Box in `FeedbackStructureConfig.tsx` (Top 5 Kriterien nach `getCriterionUsageMap()`-Verwendung, inkl. `learnedPatternHints` als Kontext). Verifiziert: `npx tsc -b` fehlerfrei (dabei einen fehlenden `hasChecklist`-Wert im toten Dexie-Seed-Code `src/db/seeds/assignmentTypes.ts` nachgezogen, da dieser weiterhin vom Client-Build mitkompiliert wird); neue RPC-Endpunkte (`checklists.listChecklistItemDefsForPhaseTemplate`, `assignmentTypes.listPhaseTemplates`) direkt gegen die laufende Dev-Instanz getestet — Schema-Migration (neue Spalte `hasChecklist`, neue Tabellen `checklistItemDefs`/`checklistResponses`) griff automatisch ohne manuellen Server-Neustart. Manueller Ausfüll-Test einer Checkliste in einem echten Meeting (inkl. Prüfung, dass verknüpfte Punkte korrekt in `Assessment` landen) steht noch aus.
|
||||||
|
|
||||||
|
**Nachtrag (2026-07-19, nach erstem echten Nutzungstest):** Drei Korrekturen an der Checkliste, nachdem sich die ursprüngliche Einschränkung in der Praxis als zu eng erwies:
|
||||||
|
1. **Kriterium-Verknüpfung jetzt für beide Scopes möglich** (vorher nur `perInstitutee`). Bei `scope: meetingWide` + verknüpft schreibt `MeetingView.tsx::saveChecklistResponse` dieselbe Bewertung/Notiz per Fan-out in `Assessment` **für jeden aktuellen Institutee gleichzeitig** — ein meeting-weiter Checkpunkt gilt für alle Beteiligten gleichermaßen. Löst das ursprüngliche Problem („`Assessment` verlangt `instituteeId`") durch Duplizieren statt durch Ausschluss.
|
||||||
|
2. **`ChecklistResponse` ist jetzt immer die Quelle für die Checkliste-UI selbst**, unabhängig von Kriterium-Verknüpfung (vorher las/schrieb die UI bei verknüpften Punkten direkt `Assessment`, was zu einem unsauberen Round-Trip führte, sobald die Notiz auch das Checklisten-Label enthalten sollte). Bei Verknüpfung spiegelt `saveChecklistResponse()` zusätzlich **einseitig** (Checkliste → Assessment, nie zurück) nach `Assessment`, inkl. komponierter Notiz `Checkliste „<Label>" (<Bewertung>): <Kommentar>` über `composeChecklistNote()` — dadurch landet der Checkpunkt-Name selbst (nicht nur der freie Kommentar) in `aiPrompt.ts::kriterienScores`, das `Assessment.note` bereits einsammelt; kein Änderung an `aiPrompt.ts` nötig. Bekannte Einschränkung: Wird dasselbe Kriterium zusätzlich direkt über den (weiterhin vorhandenen) Gesamtbewertungs-Tab bearbeitet, gewinnt der jeweils letzte Schreibzugriff — gleiches Verhalten wie beim bestehenden „Aus Protokoll"-Button, keine neue Fehlerklasse.
|
||||||
|
3. **Keine eigene Checkliste-Tab-Karte mehr** — `ChecklistTab` wird jetzt direkt oberhalb der Gesprächs-Zeitleiste innerhalb des Gesprächsprotokoll-Tabs gerendert (`tab === 'conversation'`), nicht mehr über einen separaten dritten Tab. Der Tab-Button „Gesprächsprotokoll" erscheint entsprechend schon bei `hasConv || hasChecklist`, damit auch reine Checklisten-Phasen (ohne Protokoll-Funktion) eine Heimat haben.
|
||||||
|
|
||||||
|
**Sechster Nachtrag (2026-07-20): Bug + fehlende Diagnose bei „Vorschläge analysieren" behoben.** Nutzer meldete: Aufruf lieferte keine Vorschläge, ohne jede Rückmeldung, was schiefläuft. Zwei Ursachen gefunden und behoben (`MeetingView.tsx::analyzeSuggestions`):
|
||||||
|
1. **Bug:** Checklisten-Punkte ohne effektives Kriterium wurden nur analysiert, wenn bereits ein Kommentar eingetragen war (`if (!comment) continue`) — reine Bewertungen ohne Freitext fielen dadurch komplett aus der Analyse heraus, obwohl der Checklisten-Punkt-Name (Label) selbst schon ein brauchbares Signal für die KI ist. Jetzt wird bei fehlendem Kommentar das Label allein als Text übergeben (`comment ? \`${ci.label}: ${comment}\` : ci.label`).
|
||||||
|
2. **Fehlende Diagnose:** Sowohl das stille `return` bei `lineRefs.length === 0` (nichts zu analysieren) als auch der Fall „KI hat für keine Zeile/keinen Punkt einen Vorschlag geliefert" gaben bisher keinerlei Rückmeldung. Neu: `suggestionInfo`-Statuszeile (blau, neben dem bestehenden roten Fehlerbanner) mit Zählung „X Zeile(n)/Punkt(e) analysiert, Y Vorschläge erhalten" sowie ein aufklappbares „🔍 Rohantwort ansehen"-Panel (`lastAnalysisRaw`), das die komplette, ungeparste KI-Antwort zeigt — macht Format-/Parsing-Probleme künftig direkt sichtbar statt raten zu müssen.
|
||||||
|
|
||||||
|
**Fünfter Nachtrag (2026-07-20): Checkliste in allen Export-/Berichts-Pfaden nachgezogen.** Bei der Prüfung stellte sich heraus, dass sämtliche bestehenden Export-/Berichtsfunktionen die Checkliste komplett ignorierten (vor Einführung der Checkliste geschrieben, nicht mitgepflegt). Nachgezogen:
|
||||||
|
- `server/db/queries/meetings.ts::getMeetingExportData()` liefert jetzt zusätzlich `checklistItems`/`checklistResponses` (nur wenn `template.hasChecklist`) — einzige Datenquelle für Meeting-Export, dadurch automatisch in MD/JSON verfügbar.
|
||||||
|
- Neue Bulk-Funktionen `listChecklistItemDefsForPhaseTemplates()`/`listChecklistResponsesForMeetings()` (Server + Client) analog zu `listAssessmentsForMeetings()` — für Berichte über mehrere Meetings/Phasentypen hinweg (`MeetingHistory.tsx`, Assignment-Report-PDF), vermeidet N+1-Abfragen.
|
||||||
|
- `src/utils/meetingExport.ts` (Markdown + JSON), `src/utils/pdfExport.tsx::AssignmentReportDocument` (PDF) und `src/pages/MeetingHistory.tsx` (Gesamtprotokoll-Ansicht) zeigen die Checkliste jetzt jeweils in derselben Position (nach dem Gesprächsprotokoll, vor der Gesamtbewertung) — Label, effektives Kriterium (individuelle Zuordnung > Konfigurations-Vorgabe, gleiche Auflösung wie in `MeetingView.tsx`), Bewertung, Kommentar.
|
||||||
|
- `src/utils/structureExport.ts` (Markdown) und `pdfExport.tsx::AssignmentTypesDocument` (PDF) — der Assignment-Typen-Bericht listet jetzt pro Phase zusätzlich `hasChecklist` und die konfigurierten Checklisten-Punkte (inkl. Scope und ggf. verknüpftem Kriterium), analog zum bereits bestehenden Kriterien-Mapping.
|
||||||
|
- Bewusst nicht angefasst: `AssignmentDetail.tsx`s Chip-Reihe (nur Rating-Aggregation, kein direkter Bezug zur Checkliste) — wäre ein separates, kleines UI-Ergänzung, kein Export/Bericht im eigentlichen Sinn.
|
||||||
|
|
||||||
|
**Vierter Nachtrag (2026-07-19): „Vorschläge analysieren" + Kriterium-Vorschläge jetzt auch für die Checkliste, individuelle Zuordnung pro Antwort.** Bisher war die Kriterium-Verknüpfung eines Checklisten-Punkts ausschließlich in der Config fix hinterlegt (`ChecklistItemDef.linkedCriterionItemId`) und für „🏷 Vorschläge analysieren" unsichtbar. Neu:
|
||||||
|
- **`ChecklistResponse.criterionItemId`** (neue, automatisch migrierte Spalte) überschreibt pro Antwort individuell die Konfigurations-Vorgabe — `getEffectiveChecklistCriterion()` in `MeetingView.tsx` liefert `response.criterionItemId ?? ci.linkedCriterionItemId ?? null`. Zuordnung direkt in `ChecklistTab.tsx` über denselben `CriterionTagPicker` wie im Protokoll (Chip + Popover, inkl. Suche und Vorschlägen) — keine separate Komponente nötig.
|
||||||
|
- **`analyzeSuggestions()` bezieht jetzt auch Checklisten-Kommentare ein:** Antworten ohne effektives Kriterium (weder individuell noch konfiguriert) mit vorhandenem Kommentar werden mit `${Label}: ${Kommentar}` als zusätzliche Zeile in denselben gebündelten KI-Call eingespeist wie Protokoll-/Notizzeilen. Da Checklisten-Antworten nicht textbasiert (wie `lineTags`) adressierbar sind, führt ein neuer optionaler `checklistKey`-Marker (`${checklistItemId}:${instituteeId ?? ''}`) am `LineRef` das Routing der KI-Antwort in eine eigene Map `checklistSuggestions` statt in die text-basierte `lineSuggestions`-Map.
|
||||||
|
- **Lokale (KI-freie) Checklisten-Vorschläge** analog zu `localLineSuggestions`: neuer `useMemo` `localChecklistSuggestions` wendet `findSimilarTaggedLines()` auf den Kommentartext jeder unzugeordneten Antwort an (Schwelle ≥ 0,25) — sofort verfügbar, ohne „Vorschläge analysieren" auszulösen. Merge mit den KI-Vorschlägen (`mergedChecklistSuggestions`, KI hat Vorrang) liefert `getChecklistSuggestions()`, das `ChecklistTab.tsx` genau wie im Protokoll als 💡-Chip im `CriterionTagPicker` anzeigt.
|
||||||
|
- **Mirroring nach `Assessment`** nutzt jetzt konsequent das *effektive* Kriterium (individuell > konfiguriert) statt nur `ci.linkedCriterionItemId`. Wechselt die individuelle Zuordnung nachträglich auf ein anderes Kriterium, bleibt ein bereits gespiegelter `Assessment`-Eintrag beim alten Kriterium bewusst unangetastet (keine rückwirkende Korrektur/Löschung) — gleiches einfaches Prinzip wie beim Löschen eines Checklisten-Punkts.
|
||||||
|
|
||||||
|
**Dritter Nachtrag (2026-07-19): Direkte Kriterien-Vorschläge ohne KI.** Zwei Ergänzungen zur Live-Formulierungshilfe, beide ohne KI-Call: (1) Das „💭 Ähnliche frühere Notizen"-Panel (`ConversationTab.tsx`/`MeetingView.tsx`) zeigt jetzt zusätzlich das Kriterium der jeweiligen Alt-Zeile an (`„Text" → 🏷 Kriterium`), aufgelöst aus dem bereits vorhandenen `criterionItemId` in `SimilarTaggedLine` — reine Zusatzinfo, keine neue Berechnung nötig. (2) Neuer `useMemo` `localLineSuggestions` in `MeetingView.tsx`: berechnet für **alle** noch nicht zugeordneten Zeilen (Gesprächsbeiträge + allgemeine Notizen, gleiche Sammel-/Ausschlusslogik wie `analyzeSuggestions`) lokale Kriterium-Vorschläge über `findSimilarTaggedLines()`, gefiltert auf Ähnlichkeit ≥ 0,25 („mittel", gleiche Schwelle wie in `criterionSuggestionPrompt.ts`) — macht den 💡-Vorschlags-Chip im `CriterionTagPicker` **sofort verfügbar, ohne „🏷 Vorschläge analysieren" auszulösen**. Wird mit den KI-Vorschlägen (`lineSuggestions`) zu `mergedLineSuggestions` zusammengeführt — KI-Vorschläge haben Vorrang, wenn für dieselbe Zeile beides vorliegt (`lineSuggestions`-Einträge überschreiben `localLineSuggestions` beim Merge). Geprüft und bestätigt: „🏷 Vorschläge analysieren" überschreibt nie eine bereits bestehende Zuordnung — `analyzeSuggestions` schließt bereits getaggte Zeilen von vornherein von der KI-Anfrage aus (`taggedTexts.has(line)`-Filter), und `CriterionTagPicker` zeigt bei vorhandener Zuordnung ohnehin immer den Tag-Chip statt des Vorschlags-Chips (Vorschläge sind nur bei leerer Zuordnung überhaupt sichtbar).
|
||||||
|
|
||||||
|
**Zweiter Nachtrag (2026-07-19):** Zwei UI-Korrekturen nach echtem Nutzungsfeedback. (1) `ChecklistTab.tsx` nahm zu viel Bildschirmplatz ein (eine Karte mit großen Buttons + immer sichtbarem Kommentarfeld pro Institutee/Punkt) — umgebaut auf Zeilen-Darstellung: `<select>` statt fünf breiter Bewertungs-Buttons, Kommentar nur über ein 💬-Icon ausklappbar (Icon zeigt blau eingefärbt an, wenn bereits ein Kommentar hinterlegt ist). (2) Die Live-Formulierungshilfe (`💭 Ähnliche frühere Notizen`, `ConversationTab.tsx` + `MeetingView.tsx`) zeigte Vorschläge bisher nur rein lesend an — jetzt per Klick übernehmbar, ersetzt dabei die gerade getippte (letzte) Zeile der Notiz. Wichtige Falle dabei: In `MeetingView.tsx` hätte ein reiner Klick auf den Vorschlag zuerst den `onBlur`-Handler der Notizen-Textarea ausgelöst (`finishEditingGeneralNotes()`, beendet den Editier-Modus und entfernt damit Textarea+Vorschlagsliste aus dem DOM, bevor der Klick verarbeitet wird) — behoben über `onMouseDown={e => e.preventDefault()}` auf den Vorschlags-Buttons, verhindert den Fokuswechsel/Blur überhaupt erst (gleiches Muster wie `stopPropagation` beim `CriterionTagPicker` in den allgemeinen Notizen, nur mit `preventDefault` statt `stopPropagation`, da hier kein Klick-Bubbling sondern der native Blur-vor-Click-Ablauf das Problem ist).
|
||||||
|
|
||||||
|
**Konzept (2026-07-18): Live-Formulierungshilfe, Beobachter-Fokus, Meeting-Checkliste.** Zwei unabhängige Erweiterungen, in einer Design-Session gemeinsam geprüft:
|
||||||
|
- **Live-Formulierungshilfe (kein KI-Call):** Während des Tippens in `ConversationTab.tsx`/allgemeinen Notizen liefert ein debounced Aufruf der bereits bestehenden `findSimilarTaggedLines()` (`src/utils/criterionSuggestion.ts`) ein rein lesendes Referenz-Panel „Ähnliche frühere Notizen" — bewusst **kein** Autocomplete/Text-Einfügung (würde die 2026-07-02 getroffene „nicht live während des Tippens"-Entscheidung für die Zuordnung unterlaufen; hier geht es nur um unverbindliche Formulierungs-Inspiration, kein Eingriff in den Text). Kein neues Backend nötig.
|
||||||
|
- **Beobachter-Fokus (global, keine Bewerter-Identität nötig):** Die App hat kein Login-/Nutzerkonzept — „was wird typischerweise fokussiert" bezieht sich daher bewusst auf alle Bewerter zusammen. Leistet im Kern bereits `learnedPatternHints` (Wie wird formuliert) + `getCriterionUsageMap()` (Was wird häufig kommentiert, bereits für die Config→Feedback-Verwendungs-Badges gebaut). Nur eine neue, rein lesende Anzeige nötig, kein neues Datenmodell.
|
||||||
|
- **Meeting-Checkliste pro Phasentyp:** Neues Feld `PhaseTemplate.hasChecklist` (Opt-in wie `hasConversation`/`hasAssessment`) + neue Tabelle `ChecklistItemDef` (`phaseTemplateId`, `label`, `order`, optional `linkedCriterionItemId`, `scope: 'perInstitutee' | 'meetingWide'` — pro Eintrag konfigurierbar). Wichtiger Fund: `PhaseTemplate.criteriaIds` existiert zwar schon, wird aber aktuell nirgends live im Meeting ausgewertet (nur für `getCriterionUsageMap`-Zählung) — bewusst NICHT für die Checkliste umgewidmet, da es bereits eine andere, etablierte Sichtbarkeits-Scoping-Semantik hat. Antwort-Ablage maximiert Wiederverwendung bestehender Strukturen statt einer vierten Bewertungs-Struktur: „pro Institutee + verknüpft" → direkt in `Assessment` (bestehende Funktionen); „meeting-weit + verknüpft" → direkt in `MeetingInstance.generalNoteTags` (bestehender, institutee-unabhängiger Mechanismus); nur „frei definiert" (verknüpft oder nicht, jeweils pro Institutee oder meeting-weit) braucht die einzige wirklich neue Tabelle `ChecklistResponse` (`meetingInstanceId`, `instituteeId?`, `checklistItemId`, `rating`, `note`) — bewusst **ohne** Aggregation in Evaluation/gewichteten Durchschnitt (reine lokale Meeting-Notiz, vermeidet eine dritte manuell synchron zu haltende Bewertungsstruktur neben Assessment/FeedbackCategoryRating). UI: neuer optionaler Tab „Checkliste" in `MeetingView.tsx`, Konfiguration als neuer Sub-Tab in `AssignmentTypeConfig.tsx`, Kriterium-Verknüpfung über das bestehende `criterionOptionGroups()`-Dropdown-Muster aus `FeedbackStructureConfig.tsx`. **Präzisiert bei der Umsetzungsplanung (gleicher Tag):** „meeting-weit + verknüpft" hat keine saubere Heimat (`Assessment` verlangt zwingend `instituteeId` — Kriterien sind in diesem Modell immer Personen-Kompetenzen, nie Meeting-Kollektiv-Eigenschaften; `generalNoteTags` kennt keine Bewertungsstufe, nur Text+Tag). Deshalb: Kriterium-Verknüpfung ist nur bei `scope: perInstitutee` möglich, `scope: meetingWide`-Punkte sind immer „frei" (kein Kriterium-Dropdown im UI). Vereinfacht die Ablage auf 3 statt 4 Fälle, siehe Umsetzungsplan.
|
||||||
|
|
||||||
|
**Konzept (2026-07-16, noch nicht umgesetzt): Selbstlernendes Kriterien-Tagging.** Löst den Offene-Punkte-Eintrag „Selbstlernendes Tagging" oben ab (bisher nur Idee, jetzt fertig gescoped in einer eigenen Design-Session, siehe Vorgabe oben). Ziel: eine priorisierte, KI-gestützte Vorschlagsliste für die Kriterium-Zuordnung noch nicht getaggter Protokollzeilen, direkt im `CriterionTagPicker` aufrufbar. Kernentscheidungen:
|
||||||
|
- **Kein neues Datenmodell für den Lernkorpus.** Die Lernbasis ist der bereits bestehende `lineTags`/`generalNoteTags`-Bestand, global über alle Assignments hinweg (siehe `getCriterionUsageMap()`/`getCriterionUsageDetails()` in `server/db/queries/feedbackStructure.ts`). Jede neu vergebene Zuordnung vergrößert den Korpus automatisch — kein separater Trainingsschritt, keine Migration nötig.
|
||||||
|
- **Zweistufiger Ablauf.** (1) Rein lokale Ähnlichkeitssuche ohne KI-Call (Wortüberschneidung/Jaccard über normalisierten Zeilentext; `parseNotationPrefix()`/`splitNoteLines()` zur Normalisierung wiederverwendet) liefert pro noch nicht zugeordneter Zeile ~5–8 ähnlichste Alt-Beispiele als Kontext-Hinweis. (2) EIN gebündelter KI-Call pro Protokoll-Ansicht (nicht pro Zeile) analysiert alle noch nicht zugeordneten Zeilen des gesamten offenen Protokolls auf einmal und liefert pro Zeile eine priorisierte Kandidatenliste (Kriterium-IDs) statt nur eines Einzelvorschlags. Fallback auf Einzel-Zeilen-Calls ist vorgesehen, aber erst nach einem ersten Testlauf zu entscheiden, falls die Bündelung qualitativ nicht ausreicht — nicht vorab bauen.
|
||||||
|
- **Expliziter Trigger-Button** („🏷 Vorschläge analysieren"), kein automatischer Call beim Öffnen des Meetings — gleiches Muster wie die bestehende KI-Feedback-Generierung (keine versteckten API-Kosten/Latenz im Hintergrund).
|
||||||
|
- **Neuer Platzhalter `{{KRITERIEN_STRUKTUR}}`** für den Tagging-Prompt: vollständige, ID-referenzierte Struktur Dimension → Kategorie (Name+Beschreibung) → Kriterium (Name+Beschreibung), gefiltert über die bestehende `filterVisibleFeedbackStructure()` anhand `AssignmentType.criteriaIds` — dieselbe Filterlogik wie überall sonst im Projekt, nicht neu implementiert. Kriterien werden über ihre ID referenziert statt über den Namen (robust gegen Namenskollisionen beim Parsen der KI-Antwort). `FeedbackDimension` hat aktuell kein `description`-Feld (anders als Kategorie/Kriterium) — „alle Erklärungsebenen" bezieht sich daher auf Dimension-Name + Kategorie-Name/Beschreibung + Kriterium-Name/Beschreibung. Weitere neue Platzhalter: `{{ASSIGNMENT_TYP}}`, `{{PROTOKOLL_ZEILEN}}` (nummerierte, noch nicht zugeordnete Zeilen des gesamten Protokolls), `{{AEHNLICHE_ALT_ZUORDNUNGEN}}` (Ergebnis aus Schritt 1).
|
||||||
|
- **EIN gemeinsames Prompt-Template statt eines pro Assignment-Typ** — die Typ-Abhängigkeit steckt im Inhalt von `{{KRITERIEN_STRUKTUR}}`, nicht in der Anzahl der Templates (gleiches Prinzip wie `{{KATEGORIEN_STRUKTUR}}` heute schon). Persistiert als neues Feld `AppSettings.criterionTaggingPromptTemplate` (automatische Spalten-Migration wie gehabt), editierbar in einem neuen, eigenen Abschnitt in `AiPromptConfig.tsx` („KI-Prompt: Kriterien-Zuordnungsvorschläge") — gleiches Aufklapp-Textarea-Muster + eigene Platzhalter-Legende wie der bestehende Standard-Prompt-Bereich. Kein Eintrag pro Dimension nötig, da es nur eine Protokoll-weite Analyse gibt, nicht pro Dimension.
|
||||||
|
- **Antwortformat:** Zeilennummer + priorisierte Kriterium-ID-Liste als Klartext-Marker (`ZEILE 3: 12, 47, 5`, `-` bei keinem Vorschlag) statt JSON — bewusst gleiches Prinzip wie `parseDimensionAiResponse` (JSON/Codefence-Parsing-Bugs waren 2026-07-07 der Grund für die Umstellung auf Marker-Text dort). Parser tolerant gegenüber fehlenden/kaputten Zeilen.
|
||||||
|
- **Datenschutz-Hinweis:** Gesprächsnotizen gehen für Schritt 2 an dieselbe bereits genutzte externe KI-API wie die bestehende Feedback-Generierung (gleiches Muster, kein neues Risiko-Muster) — vor Produktivnutzung trotzdem kurz mit dem Datenschutzbeauftragten abstimmen, da es eine neue Nutzungsart derselben Datenklasse ist.
|
||||||
|
|
||||||
|
**Nachtrag (2026-07-17):** Zusätzliche Lernebene beschlossen — bewusster Mittelweg zwischen reiner Wortüberschneidung und einem separaten trainierten Klassifikator (Letzteres verworfen, da ein eigener Trainings-/Aktualisierungsschritt der Prämisse „kein separater Trainingsschritt" widersprochen hätte). Der ohnehin gebündelte KI-Aufruf liefert pro Analyse-Lauf zusätzlich einen `MUSTER:`-Block mit kurzen, generalisierten Formulierungsmustern **pro Kriterium** (nicht pro Zeile), z.B. „Kunde wird häufig nach offenen Fragen gefragt, bevor...". Diese landen in einem neuen, optionalen Feld `FeedbackCriterionItem.learnedPatternHints` (automatische Spalten-Migration wie gehabt, kein neues Datenmodell/keine neue Tabelle) und werden bei jedem weiteren Lauf überschrieben/verfeinert — reines Nebenprodukt des normalen „🏷 Vorschläge analysieren"-Klicks, kein separater Trainingslauf. Neuer Platzhalter `{{GELERNTE_MUSTER}}` speist diese Muster (nur für die im aktuellen Assignment-Typ sichtbaren Kriterien) zusätzlich zu `{{AEHNLICHE_ALT_ZUORDNUNGEN}}` in künftige Prompts ein — verbessert die Vorschlagsqualität über die Zeit, ersetzt den KI-Call aber bewusst nicht.
|
||||||
|
|
||||||
|
**Erledigt (2026-07-17): Selbstlernendes Kriterien-Tagging umgesetzt** (siehe Konzept + Nachtrag oben). Neuer Button „🏷 Vorschläge analysieren" in `MeetingView.tsx` löst EINEN gebündelten KI-Call für das gesamte offene Protokoll aus (Gesprächsbeiträge + allgemeine Notizen, Kundenzitate ausgenommen). Neue Bausteine: `server/db/queries/criterionSuggestions.ts` (`getGlobalLineTagCorpus()`, flacht `lineTags`/`generalNoteTags` global ab, gleiches Iterationsmuster wie `getCriterionUsageMap()`), `src/utils/criterionSuggestion.ts` (rein lokale Jaccard-Ähnlichkeitssuche, kein KI-Call), `src/utils/criterionSuggestionPrompt.ts` (`buildCriterionStructureText()`/`buildCriterionSuggestionPromptContext()`/`parseCriterionSuggestionResponse()` — Response-Format bewusst Klartext-Marker `ZEILE n: <IDs>` + `MUSTER:`-Block statt JSON, gleiches Robustheitsprinzip wie `parseDimensionAiResponse`). `AppSettings.criterionTaggingPromptTemplate` (ein gemeinsames Template statt eines pro Assignment-Typ, editierbar in `AiPromptConfig.tsx` unter „KI-Prompt: Kriterien-Zuordnungsvorschläge") und `FeedbackCriterionItem.learnedPatternHints` sind neue, automatisch migrierte Spalten. `CriterionTagPicker.tsx` zeigt bei fehlender Zuordnung einen 💡-Vorschlags-Chip (Top-Vorschlag per Klick übernehmbar, Alternativen + volle Suche im Popover) statt des leeren „+"-Buttons. Nebenbei extrahiert: `src/utils/aiClient.ts` (`callOpenRouter()`/`getAiSettings()`/`saveAiSettings()`) aus `AssignmentFeedbackPage.tsx`, damit die bestehende KI-Feedback-Generierung und das neue Tagging dieselbe OpenRouter-Anbindung nutzen statt zwei leicht abweichende Implementierungen zu pflegen — reiner Refactor, kein Verhaltensunterschied. Verifiziert: `npx tsc -b` fehlerfrei; neue RPC-Endpunkte (`criterionSuggestions.getGlobalLineTagCorpus`, `appSettings.getOrSeedCriterionTaggingPromptTemplate`) direkt gegen die laufende Dev-Instanz getestet (der Server läuft unter `tsx watch` und hat Schema-Migration + neue Module automatisch übernommen, ohne manuellen Neustart). Manueller Browser-Test des Gesamtflusses (Klick auf „Vorschläge analysieren" in einem echten Meeting, Übernehmen eines Vorschlags) steht noch aus — sollte vor breiter Nutzung nachgeholt werden.
|
||||||
|
|
||||||
|
**Erledigt (2026-07-13, dritte Runde):** Kriterium-Zuordnung für „Allgemeine Notizen" (`MeetingInstance.generalNoteTags`, neue Spalte, gleicher Typ `ConversationLineTag[]` wie `ConversationEntry.lineTags`) — anders als die bestehende Zeilen-Zuordnung in Gesprächsbeiträgen ist diese **nicht** an ein Institutee gebunden, sondern gilt für **alle** Institutees des Assignments gleichzeitig, weil allgemeine Meeting-Notizen keinem einzelnen Sprecher zugeordnet sind. Erfassung in `MeetingView.tsx`: derselbe `CriterionTagPicker` wie bei Gesprächsbeiträgen, per `renderLineAddon` an `NotationText` gehängt (Klick auf den Picker mit `stopPropagation`, da der umgebende Notizen-Block sonst in den Editier-Modus wechseln würde); verwaiste Zuordnungen werden beim Verlassen des Editier-Modus anhand der aktuellen Zeilen bereinigt (gleiches Muster wie `saveEdit` bei Gesprächsbeiträgen). Fünf bestehende Konsumenten von `lineTags` wurden konsistent um die neue, ungefilterte Quelle ergänzt (Recherche in zwei unabhängigen Durchläufen bestätigt, dass dies eine vollständige Liste ist): `AssessmentTab.suggestedRating()` (💡-Vorschlags-Badges, jetzt pro Kriterium für jedes Institutee identisch beeinflusst), `aiPrompt.ts::buildDimensionPromptContext` (`{{KRITERIEN_SCORES}}` bekommt die zugeordneten Zeilen zusätzlich, `{{NOTIZEN_ALLGEMEIN_MEETING}}` blendet bereits zugeordnete Zeilen aus — exakt das bestehende Muster von `{{PROTOKOLL_UNZUGEORDNET}}`), `meetingExport.ts` (Kriterien-ID-Sammlung + `_(→ Kriteriumsname)_`-Annotation pro Zeile im Markdown-Export), `FeedbackDraftPage.tsx` (Legacy-Feedback-Entwurf, gleiche Erweiterung von Kriterien-Sammlung und Notizen), `MeetingHistory.tsx` (reine Anzeige, sonst wäre die neue Zuordnung dort unsichtbar geblieben). Bei leerem/fehlendem `generalNoteTags` ist das Verhalten überall identisch zu vorher — bestehende Daten/Prompts brechen nicht.
|
||||||
|
|
||||||
**Erledigt (2026-07-13, zweite Runde):** Chronologisches Gesamtprotokoll aller Meetings eines Assignments (`MeetingHistory.tsx`, Route `/assignment/:id/history`) — Anlass: Recap-Calls nach jedem Kundenmeeting brauchen einen schnellen Blick auf die Historie vorheriger Meetings, zusätzlich hilfreich bei der Gesamtdurchsprache im finalen Feedback-Call. Rein lesende Seite, baut bewusst auf bereits vorhandenen Bulk-Abfragen auf statt neuen Server-Code zu schreiben: `listDoneMeetingsChronological()` (kanonischer Trash-Filter + Sortierung, siehe unten) liefert die Meetings, `listConversationEntriesForMeetings()`/`listAssessmentsForMeetings()` (beide bereits vorhanden, u.a. schon von `AssignmentDetail.tsx` genutzt) liefern Notizen/Bewertungen über alle Meetings auf einen Schlag statt N+1-Abfragen pro Meeting. Rendering nutzt bestehende Bausteine statt Neuimplementierung: `NotationText` (`notationParser.tsx`) für Notiz-Formatierung, dieselbe `weightedAverageRating()`+`RATING_BG`-Chip-Logik wie die Meeting-Kacheln in `AssignmentDetail.tsx`. Kriterium-Tags werden hier bewusst nur als statisches Badge angezeigt (kein interaktiver `CriterionTagPicker`), da die Seite reine Anzeige ist. Das jeweils neueste Meeting wird optisch hervorgehoben und die Seite scrollt beim Laden automatisch dorthin — deckt den Recap-Anwendungsfall ab, während beim Hochscrollen die volle Historie für die Feedback-Gesamtdurchsprache verfügbar bleibt. Erreichbar über neue Buttons sowohl auf `AssignmentDetail.tsx` („📜 Gesamtprotokoll") als auch direkt aus `MeetingView.tsx` heraus („📜 Verlauf"), damit man auch mitten im Recap-Call ohne Umweg über die Detailseite wechseln kann.
|
**Erledigt (2026-07-13, zweite Runde):** Chronologisches Gesamtprotokoll aller Meetings eines Assignments (`MeetingHistory.tsx`, Route `/assignment/:id/history`) — Anlass: Recap-Calls nach jedem Kundenmeeting brauchen einen schnellen Blick auf die Historie vorheriger Meetings, zusätzlich hilfreich bei der Gesamtdurchsprache im finalen Feedback-Call. Rein lesende Seite, baut bewusst auf bereits vorhandenen Bulk-Abfragen auf statt neuen Server-Code zu schreiben: `listDoneMeetingsChronological()` (kanonischer Trash-Filter + Sortierung, siehe unten) liefert die Meetings, `listConversationEntriesForMeetings()`/`listAssessmentsForMeetings()` (beide bereits vorhanden, u.a. schon von `AssignmentDetail.tsx` genutzt) liefern Notizen/Bewertungen über alle Meetings auf einen Schlag statt N+1-Abfragen pro Meeting. Rendering nutzt bestehende Bausteine statt Neuimplementierung: `NotationText` (`notationParser.tsx`) für Notiz-Formatierung, dieselbe `weightedAverageRating()`+`RATING_BG`-Chip-Logik wie die Meeting-Kacheln in `AssignmentDetail.tsx`. Kriterium-Tags werden hier bewusst nur als statisches Badge angezeigt (kein interaktiver `CriterionTagPicker`), da die Seite reine Anzeige ist. Das jeweils neueste Meeting wird optisch hervorgehoben und die Seite scrollt beim Laden automatisch dorthin — deckt den Recap-Anwendungsfall ab, während beim Hochscrollen die volle Historie für die Feedback-Gesamtdurchsprache verfügbar bleibt. Erreichbar über neue Buttons sowohl auf `AssignmentDetail.tsx` („📜 Gesamtprotokoll") als auch direkt aus `MeetingView.tsx` heraus („📜 Verlauf"), damit man auch mitten im Recap-Call ohne Umweg über die Detailseite wechseln kann.
|
||||||
|
|
||||||
|
**Erledigt (2026-07-14):** KI-Feedback-Generierung (siehe 2026-07-07) anhand echter Testläufe nachgeschärft. `RATING_OPTIONS` (`src/config/constants.ts`) hat ein neues `full`-Feld ("Not/Partially/Nearly/Fully Client Ready", "Not Applicable") als einzige Quelle der Wahrheit für ausgeschriebene Bewertungslabels — wichtig fürs KI-Prompting, da die kurzen Labels ("Not") für ein Modell ohne Kontext zum Zweck des Trainee-Programms (Ziel: "Client Ready" werden) nicht aussagekräftig genug sind. `src/utils/notationParser.tsx` hat eine neue Funktion `parseNotationPrefix()` (liefert Rating/Zitat-Flag/Resttext, `detectNotationRating()` ist jetzt darauf aufgebaut, keine doppelte Symbol-Zuordnung mehr). In `aiPrompt.ts` löst `formatNotationLine()` jede Notiz-Zeile (`{{KRITERIEN_SCORES}}`-Notizen, `{{NOTIZEN_ALLGEMEIN_MEETING}}`, `{{PROTOKOLL_UNZUGEORDNET}}`) konsistent auf — Format `Label — Text` (bewusst **keine** eckigen Klammern, da `[Titel]` bereits für Slide-/Themen-Referenzen reserviert ist und sonst mit aufgelösten Bewertungen kollidieren würde). Zwei fachliche Korrekturen dabei: (1) `{{KRITERIEN_SCORES}}` lässt Kriterien ohne jede Bewertung/Notiz jetzt komplett aus (reines Token-Rauschen), ebenso ganze Kategorien ohne verwertbare Kriterien. (2) Mit `>` markierte Kundenzitate werden aus `{{NOTIZEN_ALLGEMEIN_MEETING}}`/`{{PROTOKOLL_UNZUGEORDNET}}` jetzt **komplett herausgefiltert** statt nur aufgelöst — Kundenaussagen sind fachlicher Assignment-Kontext, keine Aussage über den Berater, und würden besonders beim Feedback-Call mit dem Kunden ein verzerrtes Bild erzeugen (der Kunde gibt ohnehin separates Feedback). Explizit einem Kriterium zugeordnete Zitate (bewusste Tagging-Entscheidung des Bewerters) werden davon nicht angefasst. Output-Format umgestellt von Fließtext-Absätzen auf Stichpunkte (`- `-Zeilen) — leichter in die offizielle Feedback-App zu übertragen; Development-Needs-Stichpunkte müssen jetzt explizit als Handlungsempfehlung formuliert sein (z.B. "Sollte darauf achten, dass ..."). Zusätzlich eine explizite Anti-Halluzinations-Anweisung im Prompt ("nutze ausschließlich die angegebenen Daten, erfinde nichts") ergänzt, nachdem die KI vereinzelt Details erfunden hatte. **Wichtige Einschränkung, die bei jeder künftigen Prompt-Engine-Änderung zu beachten ist:** Da jede Dimension einen **vollständig unabhängigen** Prompt-Text in der DB hat (bewusste Design-Entscheidung, siehe 2026-07-07), wirken Verbesserungen an `AI_CONFIG.defaultDimensionPromptTemplate` NICHT automatisch auf bereits existierende Dimensionen — nur auf neu angelegte oder explizit per "Auf Standard zurücksetzen" aktualisierte. Alle Änderungen dieser Runde wurden nach Rückfrage per Skript einmalig auf alle 5 bestehenden Dimensionen nachgezogen (nur unbedenklich, weil vorher per Read-Check bestätigt wurde, dass noch keine individuellen Anpassungen existierten).
|
||||||
|
|
||||||
|
**Erledigt (2026-07-14, dritte Runde):** PDF-Export ergänzt (`src/utils/pdfExport.tsx`, neue Abhängigkeit `@react-pdf/renderer` — reines JS, kein natives Kompilieren nötig, läuft browser- und Node-seitig). Zwei Dokument-Typen: `AssignmentReportDocument` (Gesamtprotokoll wie `MeetingHistory.tsx` + Feedback-Anhang aller Institutees, unabhängig vom Status Entwurf/Final) und `InstituteeFeedbackDocument` (nur das Feedback einer Person). Wichtig: Der Feedback-Teil zeigt ausschließlich die **finalen** Felder (`achievements`/`developmentNeeds`/`text`/`rating`) — niemals die `suggested*`-KI-Vorschläge, die noch nicht per „Übernehmen" bestätigt wurden. Notation in den Protokollzeilen wird wie in der App selbst als Icon/Farbe dargestellt (✓/✗/✓~/✗~/›, aus `parseNotationPrefix()` abgeleitet) — bewusst anders als die textuelle "Label — Text"-Auflösung in `aiPrompt.ts`, da das PDF für Menschen ist, die die App-Notation schon kennen, nicht für ein KI-Modell ohne Kontext. Erzeugung/Download sind getrennt (`buildAssignmentReportPdfBlob`/`buildInstituteeFeedbackPdfBlob` liefern nur den Blob, `exportAssignmentReportPdf`/`exportInstituteeFeedbackPdf` triggern zusätzlich den Browser-Download) — dadurch auch außerhalb des Browsers (Node/tsx) testbar, was beim Verifizieren genutzt wurde (die Download-Hälfte braucht `document`/`URL`, ist also browser-only). Buttons an drei Stellen: `MeetingHistory.tsx` (Gesamt-Report), `AssignmentFeedbackPage.tsx` (eigenes Feedback), `AssignmentDetail.tsx` (Schnellzugriff für beide Varianten ohne Navigation). Nachträglich (nach erstem Testlauf) Seitenumbrüche korrigiert: `wrap={false}` auf ganzen Meeting-/Dimension-Blöcken erzwang unsinnige Umbrüche/Lücken, sobald ein Block länger als eine Seite war — stattdessen nur noch kleine Kopfzeilen-Blöcke (Meeting-Titel+Datum, Dimension-/Institutee-Name, Achievements/Development-Needs-Label) mit `wrap={false}` + `minPresenceAhead` vor Verwaisung geschützt, der eigentliche Inhalt darf frei umbrechen. Zusätzlich erzwungener Seitenwechsel (`break`) vor dem „Feedback"-Abschnitt und vor jedem weiteren Institutee, damit Protokoll und Feedback klar getrennt bleiben. Ebenfalls ergänzt: Kriterium-Zuordnung pro Protokollzeile (`lineTags`/`generalNoteTags`) wird jetzt wie in `MeetingHistory.tsx`/`meetingExport.ts` als kleiner „→ Kriteriumsname"-Zusatz hinter der Zeile angezeigt (indigo, per `visibleItems`-Lookup) — vorher zeigte das PDF nur die Notation, aber keine Kriterium-Zuordnung.
|
||||||
|
|
||||||
|
**Erledigt (2026-07-16, zweite Runde):** Sicherer Umbau der Feedback-Struktur (Config → Feedback), motiviert durch laufende Umbenennungen/Löschungen/Verschiebungen von Kriterien ohne Überblick, wo diese bereits verwendet werden. Neu in `server/db/queries/feedbackStructure.ts`: `getCriterionUsageMap()` — EIN Durchlauf über `assessments`, `conversationEntries.lineTags`, `meetingInstances.generalNoteTags`, `assignmentTypes.criteriaIds`, `phaseTemplates.criteriaIds` liefert eine `criterionId → CriterionUsage`-Map (Zählungen + betroffene Meetings), wird beim Laden von `FeedbackStructureConfig.tsx` mitgeladen und zeigt pro Kriterium einen aufklappbaren „N Verwendungen"-Badge. **Verschieben** zwischen Kategorien per einfachem Dropdown (kein Drag & Drop, keine neue Abhängigkeit — nutzt das bereits vorhandene `updateFeedbackCriterionItem(id, { categoryId })`). **Löschen mit Verwendung** zeigt jetzt ein Inline-Panel mit zwei Wegen: „Übertragen auf ▾" (`reassignCriterionReferences(fromId, toId)`, verschiebt Assessments/Tags/Sichtbarkeitslisten auf ein anderes Kriterium, dann Löschen) oder „Trotzdem löschen" (Bewertungen werden gelöscht, Protokoll-Zuordnungen nur entfernt, Notiztext bleibt). **Wichtiger Edge-Case bei `reassignCriterionReferences`:** `assessments` hat keine DB-Unique-Constraint auf `(meetingInstanceId, instituteeId, criteriaId)` — ein blindes Umschreiben von `criteriaId` könnte eine Dubletten-Zeile für dieselbe Meeting/Institutee-Kombination erzeugen. Bei Konflikt wird die Quell-Zeile stattdessen in die bestehende Ziel-Zeile gemergt (Notizen zusammengeführt, vorhandener Score der Zielzeile hat Vorrang) und gelöscht; Rückgabewert `mergedConflicts` macht das der UI gegenüber transparent. `deleteFeedbackCriterionItem()` räumt jetzt selbst alle Referenzen auf (Verteidigung in der Tiefe, unabhängig vom UI-Pfad), `deleteFeedbackCategory()`/`deleteFeedbackDimension()` cascaden ab jetzt **durch** diese Funktion statt per direktem Bulk-Delete, damit die Aufräum-Logik auch bei Kategorie-/Dimension-Löschung greift. Verifiziert per Skript auf rein synthetischen Testdaten (temporäre Dimension/Kategorie/2 Kriterien + ein Test-Meeting unter einem echten Assignment, absichtlich erzeugter Konfliktfall bestätigt korrektes Merge-Verhalten ohne Dublette, danach vollständig wieder entfernt).
|
||||||
|
|
||||||
|
**Nachtrag (gleicher Tag):** Reine Zählungen/Meeting-Liste reichten nicht — beim Konsolidieren zweier Kriterien musste man sonst weiterhin jedes Meeting einzeln durchsuchen. Neu: `getCriterionUsageDetails(criterionId)` liefert pro Kriterium eine **Einzelinstanzen-Liste** (`CriterionUsageInstance`, discriminated union `assessment`/`lineTag`/`generalNoteTag`, mit Meeting/Assignment/Person/Text aufgelöst) — bewusst lazy pro Kriterium beim Aufklappen geladen, nicht eager für alle (im Unterschied zu `getCriterionUsageMap`), da hier echte Texte/Namen aufgelöst werden. Direkt im aufgeklappten Verwendungs-Badge kann jede einzelne Instanz jetzt per „→ anderes Kriterium…"-Dropdown + „Übertragen" gezielt umgehängt oder per „Entfernen" einzeln entfernt werden (`reassignUsageInstance()`/`removeUsageInstance()`) — ohne das ganze Kriterium zu löschen und ohne alle Verwendungen auf einmal zu bewegen. Die Konflikt-Merge-Logik für Assessments wurde dafür aus `reassignCriterionReferences` in eine gemeinsame Hilfsfunktion `mergeOrReassignAssessment()` extrahiert, damit Bulk- und Einzel-Reassign nicht zwei leicht unterschiedliche Implementierungen derselben Regel pflegen. Ebenfalls per Skript auf synthetischen Testdaten verifiziert (Einzel-Entfernen erhält den Notiztext, Einzel-Übertragen löst denselben Merge-Konflikt korrekt wie die Bulk-Variante).
|
||||||
|
|
||||||
|
**Zweiter Nachtrag (gleicher Tag):** Nach erstem Testlauf gemeldet: Ein Kriterium zeigte nach Bereinigung aller Bewertungen/Protokoll-Tags weiterhin „4 Verwendungen" — Ursache war, dass `assignmentTypeNames`/`phaseTemplateLabels` (Sichtbarkeits-Zuordnung in `AssignmentType.criteriaIds`/`PhaseTemplate.criteriaIds`) zwar in die Zählung eingehen, aber bis dahin nur als reiner Text angezeigt wurden — keine Einzelinstanz, kein Weg, sie von hier aus zu bereinigen. `CriterionUsageInstance` um zwei weitere Kinds ergänzt (`assignmentTypeVisibility`, `phaseTemplateVisibility`, mit `criterionId`-Feld, damit `reassignUsageInstance`/`removeUsageInstance` wissen, welche ID sie in der jeweiligen `criteriaIds`-Liste ersetzen/entfernen sollen). Damit sind jetzt **alle** in der Zählung enthaltenen Verwendungsarten auch einzeln direkt aus dem aufgeklappten Badge heraus behebbar, nicht nur die drei Protokoll-/Bewertungsarten. Client-Anzeige unterscheidet per `isDataInstance()`-Typprädikat zwischen den beiden Darstellungsformen (Meeting/Person-Kontext vs. reiner Name). Ebenfalls per Skript auf synthetischen Testdaten (temporärer Assignment-Typ + Phase-Vorlage, danach entfernt) verifiziert.
|
||||||
|
|
||||||
|
**Dritter Nachtrag (gleicher Tag):** Zwei UI-Bugs nach erstem echten Nutzungstest gefixt (`FeedbackStructureConfig.tsx`). (1) Alle „Ziel-Kriterium"-Dropdowns (Verschieben/Übertragen) gruppierten nur nach Dimension und übersprangen die Kategorie-Ebene — HTML kennt kein verschachteltes `<optgroup>`, daher jetzt eine kombinierte Optgroup-Beschriftung `"Dimension → Kategorie"` über die neue gemeinsame Hilfsfunktion `criterionOptionGroups()` (an allen drei Stellen genutzt: Verschieben-Dropdown, Lösch-Dialog-Übertragen, Einzel-Verwendungs-Übertragen). (2) Schwerwiegenderer Bug: Die Ziel-Auswahl pro Einzelverwendung war über den **Array-Index** in der Instanzen-Liste verknüpft (`` `${itemId}:${idx}` ``). Nach jedem „Übertragen" wird die Liste neu vom Server geladen, wodurch sich die Indizes der verbleibenden Instanzen verschieben — eine bei Index 3 gespeicherte Ziel-Auswahl gehörte danach plötzlich zu einer anderen Instanz, und ein Klick auf „Übertragen" verschob nachweislich die falsche Zeile. Gefixt durch einen **stabilen, inhaltsbasierten Schlüssel** (`instanceKey()`: `assessmentId` bzw. `conversationEntryId`/`meetingInstanceId` + Zeilentext bzw. `assignmentTypeId`/`phaseTemplateId`) statt des Index — Auswahlen bleiben dadurch auch nach einem Reload an der richtigen Instanz hängen, und bereits übertragene/entfernte Instanzen verschwinden korrekt aus der Liste statt eine falsche Zeile zu verändern.
|
||||||
|
|
||||||
|
**Erledigt (2026-07-16):** Zwei weitere Exporte ergänzt, jeweils als Markdown UND PDF. (1) „Feedback-Struktur" (Config → Feedback, neue Buttons „⬇ MD"/„⬇ PDF" im Header): alle Dimensionen/Kategorien/Kriterien inkl. Gewichtung und aller Beschreibungen (`FeedbackCategory.description`/`FeedbackCriterionItem.description`, siehe 2026-07-07/2026-07-15). (2) „Assignment-Typen-Bericht" (Config → Ass.-Typen, gleiche Buttons): pro Assignment-Typ die Phasen sowie das Kriterien-Mapping (`AssignmentType.criteriaIds`) — bei leerer/undefined `criteriaIds` „Alle Kriterien", sonst nach Dimension/Kategorie gruppierte Liste der zugeordneten Kriteriennamen (exakt dieselbe Lookup-Logik wie im „Kriterien"-Tab von `AssignmentTypeConfig.tsx`, nicht neu erfunden). Markdown-Erzeugung in neuer Datei `src/utils/structureExport.ts` (nutzt bestehendes `downloadFile()` aus `meetingExport.ts`), PDF-Dokumente als zwei weitere Komponenten in `pdfExport.tsx` (gleiche Styles/Pagination-Helfer wie die bestehenden Berichte). Bei der Verifikation aufgefallen, aber bewusst nicht angefasst: Phasen-Labels enthalten in den Bestandsdaten teils schon "(Lead)"/"(Kunde)" als Teil des Freitexts, wodurch der Bericht das zusätzlich per `PhaseTemplate.withWhom` angehängte "(Lead)"/"(Kunde)" doppelt zeigt — das ist eine Dateneingabe-Redundanz, keine Automatisierung, mit der ein Freitextfeld sicher bereinigt werden könnte, daher nicht angetastet.
|
||||||
|
|
||||||
|
**Erledigt (2026-07-15):** Analog zu `FeedbackCriterionItem.description` (2026-07-07) jetzt auch `FeedbackCategory.description` — Erläuterung, welche Themen/Beobachtungen in eine Kategorie einzahlen sollen, editierbar in `FeedbackStructureConfig.tsx` direkt unter dem Kategorienamen. Grund: Die KI kannte bisher nur die Kriterien-Ebene im Detail, nicht aber, wonach auf Kategorie-/Dimensionsebene grundsätzlich gefragt ist. Bewusst **keinen neuen Platzhalter** eingeführt, sondern die bestehende `{{KATEGORIEN_STRUKTUR}}` in `aiPrompt.ts` angereichert (`Name — Erläuterung` statt nur `Name`) — dadurch profitieren auch bereits bestehende, individuell angepasste Dimension-Prompts automatisch, ohne dass sie erneut synchronisiert werden müssen (der Platzhalter-Token selbst ändert sich nicht, nur was dahinter berechnet wird).
|
||||||
|
|
||||||
|
**Erledigt (2026-07-14, zweite Runde):** Zwei weitere Nachschärfungen der KI-Feedback-Generierung (siehe Eintrag oben). (1) Abschnittsmarker wie `[Background]`/`[Whiteboard - Storyboard]` (reine Navigationshilfe des Bewerters, an welcher Stelle der Präsentation/des Dialogs eine Beobachtung entstand) werden jetzt aus `{{NOTIZEN_ALLGEMEIN_MEETING}}`/`{{PROTOKOLL_UNZUGEORDNET}}` gefiltert, wenn ihnen keine Inhalts-Zeile folgt (`dropDanglingMarkers()` in `aiPrompt.ts`, Lookahead auf die jeweils nächste Zeile) — bleiben aber erhalten, wenn direkt danach noch etwas steht, da sie dann als Kontext-Header dienen. (2) `AI_CONFIG.defaultDimensionPromptTemplate` (der Basis-Prompt für neue Dimensionen und „Auf Standard zurücksetzen") ist nicht mehr rein im Code gepflegt, sondern zusätzlich in `appSettings` (Singleton-Tabelle, neue Spalte `defaultDimensionPromptTemplate`) persistiert und über einen neuen, aufklappbaren Bereich ganz oben im „KI-Prompts"-Tab (`AiPromptConfig.tsx`) direkt in der App editierbar — analog zum bestehenden Zeitgewichtungs-Faktor-Muster (`getOrSeedDefaultDimensionPromptTemplate()`/`saveDefaultDimensionPromptTemplate()` in `server/db/queries/appSettings.ts`, exakt wie `getOrSeedRatingTrendWeightStep()`). Der Code-Konstante dient jetzt nur noch als einmaliger Erstbefüllungswert für neue Installationen; `feedbackStructure.ts` (Auto-Anlage neuer Dimensionen, Backfill, Lazy-Create) liest den Default jetzt ausschließlich aus `appSettings`. Wie bei jeder Custom-Prompt-Änderung gilt weiterhin: bereits bestehende, individuell abweichende Dimension-Prompts ändern sich dadurch nicht automatisch mit.
|
||||||
|
|
||||||
**Erledigt (2026-07-13):** Papierkorb + Archiv für Assignments umgesetzt. Dashboard zeigt standardmäßig nur noch aktive Assignments (`statusFilter`-Default auf `'active'`), trashed/archivierte Assignments sind in keinem Status-Tab mehr sichtbar. `Assignment` hat zwei neue optionale Felder `deletedAt`/`archivedAt` (analog zu `MeetingInstance.deletedAt`). Löschen (`softDeleteAssignment`) ist nur möglich, wenn `status === 'done'` UND für **alle** `instituteeIds` ein Feedback mit Status `'final'` vorliegt (`isAssignmentFeedbackComplete()` in `server/db/queries/assignments.ts`) — Gate wird sowohl serverseitig erzwungen (wirft sonst) als auch clientseitig auf `AssignmentDetail.tsx` zum Deaktivieren des Buttons genutzt. Löschung ist zweistufig: `softDeleteAssignment` setzt nur `deletedAt` (Papierkorb, neue Seite `/assignments/trash`), `restoreAssignment` löscht es wieder (zurück nach „Abgeschlossen"). Nach 30 Tagen wird der Eintrag automatisch endgültig entfernt — kein Cron/Timer, sondern ein Lazy-Sweep (`purgeExpiredAssignmentTrash()`), der am Anfang von `listAssignmentsWithDetails()` mitläuft (Server läuft nicht dauerhaft, ein echter Scheduler wäre unzuverlässig). `hardDeleteAssignment()` kaskadiert manuell durch den kompletten Assignment-Baum (`meetingInstances` inkl. deren Kindern über das bestehende `hardDeleteMeetingInstance()`, `feedbackDrafts`, `assignmentFeedbacks` inkl. `feedbackCategoryRatings`/`feedbackDimensionTexts`/`feedbackCategoryTexts`) — Muster 1:1 von der bestehenden Meeting-Papierkorb-Kaskade in `server/db/queries/meetings.ts` übernommen. Zusätzlich unabhängiges Archiv (`archiveAssignment`/`unarchiveAssignment`, neue Seite `/assignments/archive`) für dauerhafte Aufbewahrung (spätere Auswertung/ML) — bewusst ohne Feedback-Zwang und ohne Lösch-Option in der UI, aber umkehrbar (Rückholen möglich). Neue Spalten `deletedAt`/`archivedAt` auf `assignments` werden automatisch per bestehendem `ALTER TABLE`-Migrationsmechanismus in `database.ts` nachgezogen, kein manuelles Migrationsskript nötig.
|
**Erledigt (2026-07-13):** Papierkorb + Archiv für Assignments umgesetzt. Dashboard zeigt standardmäßig nur noch aktive Assignments (`statusFilter`-Default auf `'active'`), trashed/archivierte Assignments sind in keinem Status-Tab mehr sichtbar. `Assignment` hat zwei neue optionale Felder `deletedAt`/`archivedAt` (analog zu `MeetingInstance.deletedAt`). Löschen (`softDeleteAssignment`) ist nur möglich, wenn `status === 'done'` UND für **alle** `instituteeIds` ein Feedback mit Status `'final'` vorliegt (`isAssignmentFeedbackComplete()` in `server/db/queries/assignments.ts`) — Gate wird sowohl serverseitig erzwungen (wirft sonst) als auch clientseitig auf `AssignmentDetail.tsx` zum Deaktivieren des Buttons genutzt. Löschung ist zweistufig: `softDeleteAssignment` setzt nur `deletedAt` (Papierkorb, neue Seite `/assignments/trash`), `restoreAssignment` löscht es wieder (zurück nach „Abgeschlossen"). Nach 30 Tagen wird der Eintrag automatisch endgültig entfernt — kein Cron/Timer, sondern ein Lazy-Sweep (`purgeExpiredAssignmentTrash()`), der am Anfang von `listAssignmentsWithDetails()` mitläuft (Server läuft nicht dauerhaft, ein echter Scheduler wäre unzuverlässig). `hardDeleteAssignment()` kaskadiert manuell durch den kompletten Assignment-Baum (`meetingInstances` inkl. deren Kindern über das bestehende `hardDeleteMeetingInstance()`, `feedbackDrafts`, `assignmentFeedbacks` inkl. `feedbackCategoryRatings`/`feedbackDimensionTexts`/`feedbackCategoryTexts`) — Muster 1:1 von der bestehenden Meeting-Papierkorb-Kaskade in `server/db/queries/meetings.ts` übernommen. Zusätzlich unabhängiges Archiv (`archiveAssignment`/`unarchiveAssignment`, neue Seite `/assignments/archive`) für dauerhafte Aufbewahrung (spätere Auswertung/ML) — bewusst ohne Feedback-Zwang und ohne Lösch-Option in der UI, aber umkehrbar (Rückholen möglich). Neue Spalten `deletedAt`/`archivedAt` auf `assignments` werden automatisch per bestehendem `ALTER TABLE`-Migrationsmechanismus in `database.ts` nachgezogen, kein manuelles Migrationsskript nötig.
|
||||||
|
|
||||||
**Erledigt (2026-07-07):** KI-Feedback-Generierung neu konzipiert und umgesetzt — löst den zuvor diagnostizierten `response_format`/Markdown-Codefence-Bug strukturell statt punktuell. Statt einem Monolith-Call für alle Dimensionen macht `generateAllDimensions()` (`AssignmentFeedbackPage.tsx`) jetzt **sequenziell einen Call pro Dimension**, mit Fortschrittsanzeige und Teil-Fehlerbehandlung (fehlgeschlagene Dimensionen einzeln erneut versuchbar, `dimensionErrors`). Jede Dimension hat einen **vollständig frei editierbaren Prompt** (`FeedbackDimensionPrompt`, neuer Admin-Tab „KI-Prompts" → `AiPromptConfig.tsx`), der bei `addFeedbackDimension()` automatisch mit einem Default-Template vorbelegt wird (`AI_CONFIG.defaultDimensionPromptTemplate`) — neue Dimensionen funktionieren dadurch ohne Zutun. Antwortformat ist jetzt einfacher Text statt JSON (`KATEGORIE:`/`ACHIEVEMENTS:`/`DEVELOPMENT_NEEDS:`-Marker, geparst in `src/utils/aiPrompt.ts::parseDimensionAiResponse`), da pro Call ohnehin nur eine Dimension behandelt wird — macht den JSON/Codefence-Bug strukturell unmöglich statt ihn nur zu patchen. KI-generierte Texte landen zunächst als **Vorschlag** (`suggestedAchievements`/`suggestedDevelopmentNeeds` auf `FeedbackDimensionText`, `suggestedText` auf neuer Tabelle `FeedbackCategoryText`) und werden erst durch expliziten „Übernehmen"-Klick in die echten Felder kopiert (gleiches Muster wie die bestehende Rating-Vorschlags-Badge) — persistiert, übersteht also einen Seitenwechsel. Neu: kurzer KI-Text zusätzlich auf Kategorie-Ebene (`FeedbackCategoryText`), nicht nur auf Dimension-Ebene. Cross-Boundary-Falle dabei gefunden: `src/config/constants.ts` importierte bisher `FeedbackRating` aus dem Barrel `'../db'` (re-exportiert Dexie/rpcClient/Seeds) statt direkt aus `'../db/types'` — beim Import von `AI_CONFIG` in `server/db/queries/feedbackStructure.ts` hätte das transitiv Browser-only-Code (`fetch`, `localStorage`) in den Server-TS-Scope gezogen; auf direkten Typ-Import umgestellt und `src/config/constants.ts` zusätzlich explizit in `tsconfig.server.json`s `include` aufgenommen (gleiches Muster wie das dort schon gelistete `src/db/types.ts`). Nachträglich (gleicher Tag, nach erstem Testlauf) zwei Lücken gefixt/ergänzt: (1) `database.ts` migrierte nur neue Tabellen, nie Spalten einer bereits bestehenden Tabelle — `suggestedAchievements` u.ä. auf `feedbackDimensionTexts` fehlten dadurch auf bereits laufenden Installationen und scheiterten erst zur Laufzeit; jetzt gleicht `database.ts` zusätzlich per `PRAGMA table_info` ab und zieht fehlende Spalten per `ALTER TABLE ... ADD COLUMN` nach (siehe Datenbank-Abschnitt oben). (2) Prompt-Vorschau ergänzt: pro Dimension ein „👁 KI-Prompt ansehen"-Toggle auf `AssignmentFeedbackPage.tsx`, der den vollständig gerenderten Prompt (Template + aufgelöste Platzhalter) ohne API-Call anzeigt — Feedback war, dass die generierten Texte „brauchbar, aber nicht konkret genug" waren und ohne Einblick in den tatsächlich gesendeten Prompt nicht nachvollziehbar. Zusätzlich neues optionales Feld `FeedbackCriterionItem.description` (editierbar in `FeedbackStructureConfig.tsx`, direkt unter dem Kriteriumsnamen), das automatisch in `{{KRITERIEN_SCORES}}` einfließt (`src/utils/aiPrompt.ts`) — macht die KI-Vorgabe pro Kriterium einmal zentral pflegbar statt in jedem Dimension-Prompt wiederholt werden zu müssen. Bewusst **nicht** reaktiviert: die bereits vorhandene, aber seit Langem unverdrahtete Legacy-Tabelle `CriterionLevelDescription` (Text pro Kriterium UND Rating-Stufe) — wäre eine präzisere Rubrik gewesen, aber deutlich mehr Pflege-/UI-Aufwand; bei Bedarf spätere, bewusste Entscheidung.
|
**Erledigt (2026-07-07):** KI-Feedback-Generierung neu konzipiert und umgesetzt — löst den zuvor diagnostizierten `response_format`/Markdown-Codefence-Bug strukturell statt punktuell. Statt einem Monolith-Call für alle Dimensionen macht `generateAllDimensions()` (`AssignmentFeedbackPage.tsx`) jetzt **sequenziell einen Call pro Dimension**, mit Fortschrittsanzeige und Teil-Fehlerbehandlung (fehlgeschlagene Dimensionen einzeln erneut versuchbar, `dimensionErrors`). Jede Dimension hat einen **vollständig frei editierbaren Prompt** (`FeedbackDimensionPrompt`, neuer Admin-Tab „KI-Prompts" → `AiPromptConfig.tsx`), der bei `addFeedbackDimension()` automatisch mit einem Default-Template vorbelegt wird (`AI_CONFIG.defaultDimensionPromptTemplate`) — neue Dimensionen funktionieren dadurch ohne Zutun. Antwortformat ist jetzt einfacher Text statt JSON (`KATEGORIE:`/`ACHIEVEMENTS:`/`DEVELOPMENT_NEEDS:`-Marker, geparst in `src/utils/aiPrompt.ts::parseDimensionAiResponse`), da pro Call ohnehin nur eine Dimension behandelt wird — macht den JSON/Codefence-Bug strukturell unmöglich statt ihn nur zu patchen. KI-generierte Texte landen zunächst als **Vorschlag** (`suggestedAchievements`/`suggestedDevelopmentNeeds` auf `FeedbackDimensionText`, `suggestedText` auf neuer Tabelle `FeedbackCategoryText`) und werden erst durch expliziten „Übernehmen"-Klick in die echten Felder kopiert (gleiches Muster wie die bestehende Rating-Vorschlags-Badge) — persistiert, übersteht also einen Seitenwechsel. Neu: kurzer KI-Text zusätzlich auf Kategorie-Ebene (`FeedbackCategoryText`), nicht nur auf Dimension-Ebene. Cross-Boundary-Falle dabei gefunden: `src/config/constants.ts` importierte bisher `FeedbackRating` aus dem Barrel `'../db'` (re-exportiert Dexie/rpcClient/Seeds) statt direkt aus `'../db/types'` — beim Import von `AI_CONFIG` in `server/db/queries/feedbackStructure.ts` hätte das transitiv Browser-only-Code (`fetch`, `localStorage`) in den Server-TS-Scope gezogen; auf direkten Typ-Import umgestellt und `src/config/constants.ts` zusätzlich explizit in `tsconfig.server.json`s `include` aufgenommen (gleiches Muster wie das dort schon gelistete `src/db/types.ts`). Nachträglich (gleicher Tag, nach erstem Testlauf) zwei Lücken gefixt/ergänzt: (1) `database.ts` migrierte nur neue Tabellen, nie Spalten einer bereits bestehenden Tabelle — `suggestedAchievements` u.ä. auf `feedbackDimensionTexts` fehlten dadurch auf bereits laufenden Installationen und scheiterten erst zur Laufzeit; jetzt gleicht `database.ts` zusätzlich per `PRAGMA table_info` ab und zieht fehlende Spalten per `ALTER TABLE ... ADD COLUMN` nach (siehe Datenbank-Abschnitt oben). (2) Prompt-Vorschau ergänzt: pro Dimension ein „👁 KI-Prompt ansehen"-Toggle auf `AssignmentFeedbackPage.tsx`, der den vollständig gerenderten Prompt (Template + aufgelöste Platzhalter) ohne API-Call anzeigt — Feedback war, dass die generierten Texte „brauchbar, aber nicht konkret genug" waren und ohne Einblick in den tatsächlich gesendeten Prompt nicht nachvollziehbar. Zusätzlich neues optionales Feld `FeedbackCriterionItem.description` (editierbar in `FeedbackStructureConfig.tsx`, direkt unter dem Kriteriumsnamen), das automatisch in `{{KRITERIEN_SCORES}}` einfließt (`src/utils/aiPrompt.ts`) — macht die KI-Vorgabe pro Kriterium einmal zentral pflegbar statt in jedem Dimension-Prompt wiederholt werden zu müssen. Bewusst **nicht** reaktiviert: die bereits vorhandene, aber seit Langem unverdrahtete Legacy-Tabelle `CriterionLevelDescription` (Text pro Kriterium UND Rating-Stufe) — wäre eine präzisere Rubrik gewesen, aber deutlich mehr Pflege-/UI-Aufwand; bei Bedarf spätere, bewusste Entscheidung.
|
||||||
|
|
|
||||||
45
docs/DEV_ISOLATION.md
Normal file
45
docs/DEV_ISOLATION.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Entwicklungs-Isolation — AssignmentMonitorV2
|
||||||
|
|
||||||
|
Dieses Projekt ist die **Neuentwicklung**. Das Produktivsystem darf nicht verändert werden.
|
||||||
|
|
||||||
|
| | Produktiv | DEV (dieses Repo) |
|
||||||
|
|---|---|---|
|
||||||
|
| Ordner | `c:\dev\AssigmentMonitor` | `c:\dev\AssigmentMonitorV2` |
|
||||||
|
| Frontend | http://localhost:5173 | http://localhost:5174 |
|
||||||
|
| API | http://localhost:4000 | http://localhost:4001 |
|
||||||
|
| SQLite | `server/data/assignment-monitor.sqlite3` | `server/data/assignment-monitor-v2-dev.sqlite3` |
|
||||||
|
| PWA-Name | Assignment Monitor | Assignment Monitor DEV |
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# einmalig: .env aus Vorlage (bereits vorhanden, falls nicht:)
|
||||||
|
copy .env.example .env
|
||||||
|
|
||||||
|
npm run dev:all
|
||||||
|
```
|
||||||
|
|
||||||
|
Öffnen: **http://localhost:5174** (nicht 5173).
|
||||||
|
|
||||||
|
## Frische Datenbank
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:reset
|
||||||
|
npm run dev:all
|
||||||
|
```
|
||||||
|
|
||||||
|
`db:reset` löscht nur die V2-Dev-Datei. Der Produktiv-Ordner wird per Pfad-Guard abgelehnt.
|
||||||
|
|
||||||
|
## Sicherheitsmechanismen
|
||||||
|
|
||||||
|
1. **Eigene Ports** (5174 / 4001) — Defaults und `.env`; Start bricht ab, wenn Port 5173 bzw. 4000 gewählt wird.
|
||||||
|
2. **Eigene DB-Datei** — anderer Dateiname; `SQLITE_FILENAME` darf kein Pfad sein.
|
||||||
|
3. **Pfad-Guard** in `server/database.ts` — DB nur innerhalb von `AssigmentMonitorV2`; Zugriff auf `AssigmentMonitor\server\data` wird blockiert.
|
||||||
|
4. **`server/data/` ist gitignored** — keine DB im Repo.
|
||||||
|
5. **strictPort** — Vite weicht nicht still auf den Produktiv-Port aus.
|
||||||
|
|
||||||
|
## Regeln
|
||||||
|
|
||||||
|
- Niemals Skripte aus V2 gegen den Produktiv-Ordner ausführen.
|
||||||
|
- Niemals die Produktiv-SQLite nach `AssigmentMonitorV2/server/data/` kopieren, außer bewusst als read-only Analyse (dann anderen Dateinamen verwenden und nicht als `SQLITE_FILENAME` setzen).
|
||||||
|
- Backup-Import in V2 nur mit **anonymisierten / Testdaten**, nicht mit dem Produktiv-Backup, solange ihr an der Skala umbaut — sonst vermischt ihr personenbezogene Produktivdaten mit Experimenten.
|
||||||
280
docs/FEEDBACK_CASCADE_PLAN.md
Normal file
280
docs/FEEDBACK_CASCADE_PLAN.md
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
# Plan: Feedback-Neukonzeption, KI-Kaskadierung & UX
|
||||||
|
|
||||||
|
Stand: 2026-08-06 · nur DEV (`AssigmentMonitorV2`)
|
||||||
|
Voraussetzung: Bewertungsskala 1–10 weitgehend umgesetzt (`docs/SCALE_1_TO_10.md`).
|
||||||
|
|
||||||
|
Dieses Dokument ist die **Arbeitsliste**. Pakete nacheinander abarbeiten; innerhalb eines Pakets Unterpunkte möglichst in der angegebenen Reihenfolge. Abhaken in diesem File oder in der Session-Todo-Liste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielbild (festgezogen)
|
||||||
|
|
||||||
|
### Assignment-Feedback (Gesamtauswertung pro Person)
|
||||||
|
|
||||||
|
| Ebene | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| **Kategorie** | Score 1–10 · Benefits · Concerns (Gesprächsbasis) |
|
||||||
|
| **Dimension** | Benefits · Concerns — **3 bis max. 5** Hauptpunkte (verdichtet) |
|
||||||
|
|
||||||
|
- PDF enthält **beides** (Kategorie + Dimension) — intern/Manager-Gespräch, spätere Anbindung Erfassungssystem.
|
||||||
|
- Keine Pflicht-Rubrik „Reifegrad pro Scorelevel 1–10“; Score-Vorschlag primär aus **zeitgewichteten Meeting-Kategorie-Scores**, KI darf ±1 mit Begründung feinjustieren.
|
||||||
|
- Achievements/Development Needs → fachlich **Benefits/Concerns** (UI-Sprache umstellen).
|
||||||
|
|
||||||
|
### KI-Kaskade Feedback
|
||||||
|
|
||||||
|
```text
|
||||||
|
Stufe A — 1 Call pro Kategorie (schmaler Kontext)
|
||||||
|
→ suggested Score, Benefits, Concerns (Kategorie)
|
||||||
|
|
||||||
|
Stufe B — 1 Call pro Dimension
|
||||||
|
Input: NUR Ergebnisse aus A (kein Rohprotokoll)
|
||||||
|
→ 3–5 Benefits + 3–5 Concerns (Dimension)
|
||||||
|
```
|
||||||
|
|
||||||
|
### KI-Kaskade Tagging („Vorschläge analysieren“)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nur ungetaggte Zeilen (bereits zugeordnete nie an KI)
|
||||||
|
→ Batches nach Zeilenanzahl UND Zeichenbudget
|
||||||
|
→ optional Hybrid: bei Monster-Batches Kriterien auf lokale Top-Kandidaten beschränken
|
||||||
|
→ Dimensions-Schnitt kein Standardweg
|
||||||
|
→ lokale Jaccard-Vorschläge + learnedPatternHints bleiben
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entscheidungen (Nachtrag 2026-08-06)
|
||||||
|
|
||||||
|
1. **Score:** KI darf vorschlagen. Primärquelle = Meeting-Trend (wie bisherige Gewichtung). Keine 10-stufige Erwartungs-Rubrik als Voraussetzung; optional später globale Anker (z.B. 2/5/8/10), nicht jetzt.
|
||||||
|
2. **PDF:** Kategorie-Benefits/Concerns mit ausgeben.
|
||||||
|
3. **Tagging-Batches:** A (zeilen- + zeichenbasiert). B (Dimension) verworfen als Standard; Hybrid nur bei extrem großen Einträgen (Top-K Kriterien). C: getaggte Zeilen weiter ausschließen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket 0 — Aufräumen Skala 1–10 (Rest aus SCALE Phase 2–4)
|
||||||
|
|
||||||
|
Bereits weitgehend erledigt; nur nachziehen, was noch hinkt.
|
||||||
|
|
||||||
|
| # | Aufgabe | Done |
|
||||||
|
|---|---|---|
|
||||||
|
| 0.1 | Farbbänder speichern/laden (`getOrSeed`/`saveRatingColorBands` Server) — Hell-/Dunkelgrün unterscheidbar | ☐ prüfen |
|
||||||
|
| 0.2 | Chips Detail/History/PDF/MD aus `meetingCategoryRatings` (+ Fallback) | ☐ prüfen |
|
||||||
|
| 0.3 | Dimension-Prompts per „Auf Standard (1–10) setzen“ nachziehen / manuell testen | ☐ |
|
||||||
|
| 0.4 | Kurz-Smoke: Speichern Skala → Meeting-Einschätzung → Farben greifen nach Reload | ☐ |
|
||||||
|
|
||||||
|
**Exit:** Skala und Farben verhalten sich in Config, Meeting und Feedback konsistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket 1 — Datenmodell Feedback (Benefits/Concerns)
|
||||||
|
|
||||||
|
| # | Aufgabe | Dateien (Orientierung) | Done |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1.1 | `FeedbackCategoryText`: `benefits`, `concerns`, `suggestedBenefits`, `suggestedConcerns` (+ Migration ALTER). Altes `text`/`suggestedText` entfernt (Dev: ggf. `npm run db:reset` oder Spalten bleiben ungenutzt in alter SQLite) | `types.ts`, `tableRegistry`, UI/PDF | ☑ |
|
||||||
|
| 1.2 | Dimension: DB `achievements`/`developmentNeeds` behalten; UI/PDF-Labels **Benefits/Concerns** | | ☑ |
|
||||||
|
| 1.3 | CRUD unverändert (generisch); Parser + Feedback-Seite + PDF angepasst | | ☑ |
|
||||||
|
| 1.4 | `npx tsc -b` grün | | ☑ |
|
||||||
|
|
||||||
|
**Exit:** Persistenz für Kategorie- und Dimension-Benefits/Concerns inkl. `suggested*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket 2 — Prompt-Engine Stufe A (pro Kategorie)
|
||||||
|
|
||||||
|
| # | Aufgabe | Done |
|
||||||
|
|---|---|---|
|
||||||
|
| 2.1 | Neues Template (AppSettings oder pro Dimension): Kategorie-Call — Platzhalter nur für **eine** Kategorie (Struktur, Kriterien-Scores/Notizen dieser Cat, Meeting-Cat-Scores/Trend, getaggte Zeilen dieser Cat) | ☑ |
|
||||||
|
| 2.2 | Antwortformat Marker z.B. `SCORE:`, `BENEFITS:`, `CONCERNS:` — Parser analog `parseDimensionAiResponse` | ☑ |
|
||||||
|
| 2.3 | Score-Vorschlag: berechneten Trend **im Prompt vorgeben**; KI nur bestätigen oder ±1 mit Begründung (nicht frei aus Protokoll erfinden) | ☑ |
|
||||||
|
| 2.4 | Sequenz: alle Kategorien eines Institutee (Fortschritt, Teilfehler, Retry pro Kategorie) | ☑ |
|
||||||
|
| 2.5 | Config-UI: Kategorie-Prompt editierbar (+ Reset), Platzhalter-Legende | ☑ |
|
||||||
|
|
||||||
|
**Exit:** „KI generieren“ füllt `suggested*` auf Kategorie-Ebene; Score-Badge aus Trend bleibt führend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket 3 — Prompt-Engine Stufe B (Dimension verdichten)
|
||||||
|
|
||||||
|
| # | Aufgabe | Done |
|
||||||
|
|---|---|---|
|
||||||
|
| 3.1 | Dimension-Template umbauen: Input = Kategorie-Benefits/Concerns + Scores der Dimension; **kein** Rohprotokoll | ☑ |
|
||||||
|
| 3.2 | Output: genau 3–5 Benefits, 3–5 Concerns; Parser/Validierung (zu viele kürzen-Hinweis oder Prompt-Zwang) | ☑ |
|
||||||
|
| 3.3 | Orchestrierung: erst alle Cats der Dim (A), dann ein Call B; fehlende Cats = Retry A vor B | ☑ |
|
||||||
|
| 3.4 | Bestehende Dimension-Prompts in DB: Reset-Button / Sync auf neues Default-Template | ☑ |
|
||||||
|
|
||||||
|
**Exit:** Dimension-Texte sind Verdichtung, nicht Parallel-Erfindung aus dem Protokoll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket 4 — Feedback-UI
|
||||||
|
|
||||||
|
| # | Aufgabe | Done |
|
||||||
|
|---|---|---|
|
||||||
|
| 4.1 | Pro Kategorie: ScorePicker + Trend/KI-Score-Vorschlag („Übernehmen“) + Benefits/Concerns (editierbar + suggested-Boxen) | ☐ |
|
||||||
|
| 4.2 | Pro Dimension: nur Benefits/Concerns (3–5), suggested übernehmen/verwerfen | ☐ |
|
||||||
|
| 4.3 | Alten Ein-Text-pro-Kategorie und Achievements/DevNeeds-Labels entfernen bzw. umbenennen | ☐ |
|
||||||
|
| 4.4 | „Trend in Text“ → sinnvoll auf Concerns oder Benefits mappen (Regel festlegen, z.B. Verschlechterung→Concern) | ☑ |
|
||||||
|
| 4.5 | Fortschrittsanzeige Generierung: Kategorie X/Y, dann Dimension Z | ☑ |
|
||||||
|
|
||||||
|
**Exit:** Direktgespräch möglich: Kategorie-Details + Dimensions-Hauptpunkte auf einer Seite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket 5 — PDF & sonstige Exporte
|
||||||
|
|
||||||
|
| # | Aufgabe | Done |
|
||||||
|
|---|---|---|
|
||||||
|
| 5.1 | `InstituteeFeedbackDocument` / Assignment-Report: pro Kategorie Score + Benefits + Concerns; pro Dimension Benefits + Concerns | ☐ |
|
||||||
|
| 5.2 | Nur finale Felder, nie `suggested*` | ☐ |
|
||||||
|
| 5.3 | Markdown/JSON Assignment-Feedback falls vorhanden nachziehen | ☐ |
|
||||||
|
|
||||||
|
**Exit:** Internes PDF für Manager-Gespräch brauchbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket 6 — Tagging-Kaskade (Zeichen-Batches)
|
||||||
|
|
||||||
|
| # | Aufgabe | Done |
|
||||||
|
|---|---|---|
|
||||||
|
| 6.1 | Hilfsfunktion: offene Zeilen sammeln (unverändert: ohne bereits Getaggte, ohne Kundenzitate wie bisher) | ☐ |
|
||||||
|
| 6.2 | Batch-Builder: max. Zeilen **und** max. Zeichensumme (Konstanten in `constants` oder AI_CONFIG, konfigurierbar später) | ☐ |
|
||||||
|
| 6.3 | `analyzeSuggestions`: Schleife über Batches; Ergebnisse mergen; Fortschritt „Batch i/n“; Teilfehler retrybar | ☐ |
|
||||||
|
| 6.4 | Hybrid-Notnagel: wenn ein Batch trotz 1 Zeile zu groß (Struktur+Text), `{{KRITERIEN_STRUKTUR}}` auf Jaccard-Top-K der Batch-Zeilen beschränken | ☐ |
|
||||||
|
| 6.5 | `MUSTER:`-Block: nach allen Batches einmal zusammenführen/schreiben oder nur aus Batches mit Treffern — Regel dokumentieren | ☐ |
|
||||||
|
| 6.6 | Info-Zeile + Rohantwort weiter nutzbar (pro Batch oder letzte) | ☐ |
|
||||||
|
|
||||||
|
**Exit:** Lange Protokolle brechen nicht mehr am Einzelcall; getaggte Zeilen bleiben draußen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket 7 — Robustheit KI-Client (gemeinsam)
|
||||||
|
|
||||||
|
Betrifft Feedback- und Tagging-Calls.
|
||||||
|
|
||||||
|
| # | Aufgabe | Done |
|
||||||
|
|---|---|---|
|
||||||
|
| 7.1 | Timeouts / Abbruch klar melden (nicht still) | ☐ |
|
||||||
|
| 7.2 | Retry mit Backoff für transient failures (einmalig) | ☐ |
|
||||||
|
| 7.3 | Grobe Prompt-Größen-Schätzung vor Call → Warnung oder Auto-Split | ☐ |
|
||||||
|
| 7.4 | Gleiche `aiClient`-Hilfen für Feedback und Tagging | ☐ |
|
||||||
|
|
||||||
|
**Exit:** Abbrüche sind erklärbar und oft automatisch reparierbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket UX — Professionelle Oberfläche (Capgemini-Tool, kein Marketing-Landing)
|
||||||
|
|
||||||
|
Bisher wirkt die App funktional, aber wie ein schneller Prototyp (graue Fläche, Emoji-Navigation, uneinheitliche Karten/Abstände). Ziel: **ruhig, markenklar, gut lesbar im Direktgespräch und im Live-Meeting** — nicht „bunte Dashboard-Show“.
|
||||||
|
|
||||||
|
### Leitplanken
|
||||||
|
|
||||||
|
- Capgemini-Brand `#0070AD` / Tailwind `brand` als Primärsignal; keine lila/Indie-Themes.
|
||||||
|
- Bestehende Interaktionsmuster behalten (Tabs, ScorePicker, Tag-Picker) — visuell vereinheitlichen, nicht neu erfinden.
|
||||||
|
- Schreibfluss im Meeting schützen (keine zusätzlichen Overlays beim Tippen).
|
||||||
|
- Emojis in Nav/Buttons schrittweise durch klare Labels oder einfache Icons ersetzen (oder stark reduzieren).
|
||||||
|
- Typografie und Abstände einmal zentral (`index.css` / kleine UI-Primitives), nicht Seite für Seite wildwuchs.
|
||||||
|
|
||||||
|
| # | Aufgabe | Done |
|
||||||
|
|---|---|---|
|
||||||
|
| UX.1 | **Design-Tokens:** Schrift, Abstände, Radius, Schatten, Flächen (`bg-app`, `surface`, `border-subtle`) in `index.css` / `@theme` — App-Shell (`App.tsx`) darauf umstellen | ☐ |
|
||||||
|
| UX.2 | **App-Chrome:** Header (Titel/Kontext), Bottom-Nav ohne Emoji-Overload, aktiver Zustand klar, sichere Touch-Targets | ☐ |
|
||||||
|
| UX.3 | **Dashboard / Assignment-Listen:** einheitliche Zeilen/Kacheln, Status, Typografie-Hierarchie (Nummer, Kunde, Institutees) | ☐ |
|
||||||
|
| UX.4 | **Meeting-Live-UX:** Einschätzung + Protokoll dichter und ruhiger; ScorePicker/Chips konsistent; Fokus auf Tippen + schnelle Scores | ☐ |
|
||||||
|
| UX.5 | **Feedback-Seite:** nach Paket 4 — klare Hierarchie Dimension → Kategorie; Benefits/Concerns optisch getrennt; KI-Vorschläge als „Entwurf“, nicht als Lärm | ☐ |
|
||||||
|
| UX.6 | **Config:** Tabs und Formulare angleichen (Skala, KI-Prompts, Struktur) — gleiche Button-/Hinweis-Muster | ☐ |
|
||||||
|
| UX.7 | **Leere Zustände & Fehler:** kurze, professionelle Leer-/Fehlertexte statt stiller Lücken | ☐ |
|
||||||
|
| UX.8 | **PDF-Feinschliff:** Typo/Abstände an Capgemini-Report-Anmutung (ohne Corporate-Template-Zwang) | ☐ |
|
||||||
|
|
||||||
|
**Timing:** UX.1–UX.2 früh (nach Paket 0) als Fundament. UX.4 mit Meeting-Arbeit; **UX.5 bewusst nach Paket 4** (sonst doppelte UI-Arbeit). UX.3/6/7 parallel zu funktionalen Paketen möglich. UX.8 nach Paket 5.
|
||||||
|
|
||||||
|
**Exit:** App wirkt wie ein internes Capgemini-Fachtool: einheitlich, ruhig, brandklar — ohne Feature-Regression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket 8 — SCALE / Struktur Prio-2 und bekannte Offenheiten
|
||||||
|
|
||||||
|
Nicht blockierend für Pakete 1–7 / UX; einplanen wenn Kapazität.
|
||||||
|
|
||||||
|
| # | Aufgabe | Quelle | Done |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 8.1 | Sichtbarkeit Dim/Cat pro Typ — **vorgezogen nach Paket 1b** | SCALE Phase 6 | → 1b |
|
||||||
|
| 8.2 | Notation-Schnellbuttons / mobile Hit-Areas (kann in UX.4 aufgehen) | SCALE Phase 5 | ☐ |
|
||||||
|
| 8.3 | Dexie/IndexedDB-Legacy entfernen | CLAUDE Offene Punkte L | ☐ |
|
||||||
|
| 8.4 | Server Seed-if-empty Assignment-Typen/Struktur | CLAUDE L | ☐ |
|
||||||
|
| 8.5 | Evaluation.tsx / FeedbackDraftPage auf neue Benefits/Concerns + Bänder prüfen oder Legacy deprecaten | | ☐ |
|
||||||
|
| 8.6 | Datenschutz: Hinweis Tagging+Feedback (externe API) vor Prod | CLAUDE | ☐ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene Abarbeitungsreihenfolge
|
||||||
|
|
||||||
|
```text
|
||||||
|
0 (Smoke Skala)
|
||||||
|
→ 1 (Schema Benefits/Concerns) ← erledigt
|
||||||
|
→ 1b (Typ → sichtbare Struktur) ← VOR Feedback-KI: gleiche Teilmenge in Meeting + Feedback + Kaskade
|
||||||
|
→ 6 + 7 parallel möglich ← Tagging-Abbrüche lindern
|
||||||
|
→ 2 → 3 → 4 → 5 ← Feedback-Neukonzeption Ende-zu-Ende
|
||||||
|
→ 8 Rest nach Bedarf
|
||||||
|
→ UX später
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Paket UX ist spezifiziert, aber **zurückgestellt** — erst Features fertig, dann Optik.
|
||||||
|
|
||||||
|
**Warum 1b vor 2:** Stufe A (ein Call pro Kategorie) und Feedback-UI müssen nur Kategorien/Dimensionen des Assignment-Typs sehen. Ohne kanonische Sichtbarkeit generiert die KI später Inhalte für ausgeblendete Strukturteile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paket 1b — Feedback-Struktur je Assignment-Typ (vorziehen aus 8.1)
|
||||||
|
|
||||||
|
Ziel: Ein Assignment-Typ blendet Dimensionen und/oder Kategorien aus → Meeting-Einschätzung, Tagging-Struktur und Assignment-Feedback arbeiten auf **derselben** Teilmenge.
|
||||||
|
|
||||||
|
| # | Aufgabe | Done |
|
||||||
|
|---|---|---|
|
||||||
|
| 1b.1 | Kanonischer Helper z.B. `resolveVisibleStructure(allDims, allCats, allItems, criteriaIds)` → `{ dimensions, categories, items }` (Cats ohne Items und Dims ohne Cats entfallen) — baut auf `filterVisibleFeedbackStructure` auf | ☑ |
|
||||||
|
| 1b.2 | Überall verdrahten: MeetingView, Assessment, Feedback-Seite, Tagging-Prompt, PDF-Feedback-Teil, History/Detail wo nötig | ☑ |
|
||||||
|
| 1b.3 | Config Ass.-Typen: Auswahl auf **Dimension- / Kategorie-Ebene** (Checkboxen), persistiert weiter als `criteriaIds` (alle Items der gewählten Cats) — kein zweites Parallel-Feld nötig | ☑ |
|
||||||
|
| 1b.4 | Leere `criteriaIds` = alles sichtbar (unverändert); Smoke mit Typ der z.B. nur 1–2 Dimensionen hat | ☐ manuell |
|
||||||
|
|
||||||
|
**Nicht nötig jetzt:** separates `dimensionIds[]`/`categoryIds[]` in der DB, solange die UI Dim/Cat pflegt und auf `criteriaIds` mappt.
|
||||||
|
|
||||||
|
**Exit:** Feedback und Einschätzung zeigen für denselben Assignment-Typ dieselbe Struktur; KI-Kaskade kann darauf aufsetzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfohlene Abarbeitungsreihenfolge (Detail)
|
||||||
|
|
||||||
|
```text
|
||||||
|
0 → 1 → 1b → (6+7 optional) → 2 → 3 → 4 → 5 → 8 → UX
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative, wenn Feedback-Calls zuerst stören:** nach 1b sofort `2→3→4→5`, Tagging `6→7` dazwischen wenn Abbrüche blockieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht-Ziele (jetzt)
|
||||||
|
|
||||||
|
- Produktiv-Migration / Mapping alter Enum-Skala
|
||||||
|
- 10-stufige Score-Rubrik pro Kategorie
|
||||||
|
- Dimensions-Score als eigene Pflicht-Ebene
|
||||||
|
- Tagging-Batches standardmäßig nach Dimension schneiden
|
||||||
|
- Automatischer KI-Call beim Meeting-Öffnen
|
||||||
|
- Anbindung externes Erfassungssystem (PDF/Struktur vorbereiten reicht)
|
||||||
|
- Komplett-Redesign als Marketing-Landing / Dark Mode / Illustration-Heavy UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahmekriterien (Gesamt)
|
||||||
|
|
||||||
|
1. Feedback-Seite zeigt Kategorie-Score + Benefits/Concerns und Dimensions-Hauptpunkte (3–5).
|
||||||
|
2. Generierung läuft kaskadiert (Cat → Dim), Teilfehler retrybar, Prompt ohne wiederholtes Vollprotokoll in Stufe B.
|
||||||
|
3. PDF enthält Kategorie- und Dimension-Benefits/Concerns (final).
|
||||||
|
4. „Vorschläge analysieren“ arbeitet in zeichenbewussten Batches; bereits getaggte Zeilen gehen nicht an die KI.
|
||||||
|
5. Score-Vorschlag bleibt an Meeting-Trend gekoppelt; KI erfindet keinen Score aus dem Nichts.
|
||||||
|
6. Oberfläche wirkt professionell und einheitlich (Shell, Typo, Abstände, Meeting + Feedback).
|
||||||
|
7. `npx tsc -b` fehlerfrei; manuelle Smoke-Tests Meeting + Feedback + PDF.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächster Schritt
|
||||||
|
|
||||||
|
Beim Start der Umsetzung: **Paket 0 kurz verifizieren**, optional **UX.1–UX.2** für den ersten visuellen Sprung, dann entscheiden ob zuerst **Paket 6 (Tagging)** oder **Paket 1+2 (Feedback-Schema/KI)**.
|
||||||
115
docs/GITEA.md
Normal file
115
docs/GITEA.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Gitea — Repository-Setup (Assignment Monitor V2)
|
||||||
|
|
||||||
|
Dieses Projekt ist lokal ein Git-Repo auf Branch `main`. Für Gitea fehlt nur noch der Remote und der erste Push.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Gitea-Zugang (Benutzerkonto auf dem Server)
|
||||||
|
- **HTTPS + Personal Access Token** (empfohhen unter Windows / Firmennetz) **oder** SSH-Key bei Gitea hinterlegt
|
||||||
|
- Keine Secrets im Repo: `.env` und `server/data/` sind in `.gitignore`
|
||||||
|
|
||||||
|
## 1. Repository in Gitea anlegen
|
||||||
|
|
||||||
|
1. In Gitea einloggen
|
||||||
|
2. **„+“ → New Repository** (oder Organisation wählen, dann „New Repository“)
|
||||||
|
3. Einstellungen:
|
||||||
|
- **Name:** `AssigmentMonitorV2` (oder gewünschter Name)
|
||||||
|
- **Private** (empfohlen — personenbezogene Trainingsdaten im Backup-Format)
|
||||||
|
- **Initialize repository:** alle Häkchen **aus** (kein README, keine .gitignore — existiert lokal schon)
|
||||||
|
4. Erstellen
|
||||||
|
|
||||||
|
Gitea zeigt danach die Push-URLs an, z. B.:
|
||||||
|
|
||||||
|
- HTTPS: `https://gitea.example.com/lars/AssigmentMonitorV2.git`
|
||||||
|
- SSH: `git@gitea.example.com:lars/AssigmentMonitorV2.git`
|
||||||
|
|
||||||
|
## 2. Remote hinzufügen (lokal)
|
||||||
|
|
||||||
|
HTTPS (empfohlen):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\dev\AssigmentMonitorV2
|
||||||
|
git remote add origin https://gitea.example.com/<user>/AssigmentMonitorV2.git
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
SSH (Alternative):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git remote add origin git@gitea.example.com:<user>/AssigmentMonitorV2.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Authentifizierung
|
||||||
|
|
||||||
|
### HTTPS — Personal Access Token (empfohlen)
|
||||||
|
|
||||||
|
1. Gitea → **Settings → Applications → Generate New Token**
|
||||||
|
2. Scopes: mindestens **`write:repository`** (bzw. „repo“ je nach Gitea-Version)
|
||||||
|
3. Beim ersten `git push`:
|
||||||
|
- **Username:** Gitea-Benutzername
|
||||||
|
- **Password:** der Token (nicht das Login-Passwort)
|
||||||
|
|
||||||
|
Windows speichert Credentials oft über den Credential Manager (`credential.helper=manager`).
|
||||||
|
|
||||||
|
### SSH
|
||||||
|
|
||||||
|
1. Key erzeugen (falls noch keiner existiert):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh-keygen -t ed25519 -C "lars.stommer@capgemini.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Öffentlichen Key (`~/.ssh/id_ed25519.pub`) in Gitea unter **Settings → SSH Keys** eintragen
|
||||||
|
3. Verbindung testen:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh -T git@gitea.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Ersten Push ausführen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\dev\AssigmentMonitorV2
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls Gitea standardmäßig `master` erwartet und leer ist, reicht obiger Befehl. Bei Konflikt mit vorgefertigtem Initial-Commit auf dem Server:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git pull origin main --rebase
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Täglicher Workflow
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status
|
||||||
|
git add -A
|
||||||
|
git commit -m "kurze Beschreibung des Warums"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Feature-Branches (optional):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git checkout -b feature/feedback-kaskade-stufe-a
|
||||||
|
# … arbeiten …
|
||||||
|
git push -u origin feature/feedback-kaskade-stufe-a
|
||||||
|
```
|
||||||
|
|
||||||
|
In Gitea dann Pull Request / Merge Request erstellen.
|
||||||
|
|
||||||
|
## Datenschutz
|
||||||
|
|
||||||
|
- **Nicht committen:** `.env`, `server/data/*.sqlite3`, OpenRouter-API-Keys, Produktiv-Backups mit echten Personendaten
|
||||||
|
- **Private Repo** auf Gitea verwenden
|
||||||
|
- Backup-JSONs aus der App nur verschlüsselt/teambasiert teilen, nicht ins Repo legen
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
|---|---|
|
||||||
|
| `403` / `Authentication failed` | Token statt Passwort; Token-Scopes prüfen |
|
||||||
|
| `remote origin already exists` | `git remote set-url origin <neue-url>` |
|
||||||
|
| Große Dateien abgelehnt | Keine DB/Backups committen; ggf. Gitea-LFS nur wenn wirklich nötig |
|
||||||
|
| SSL-Zertifikat (interne CA) | Firmen-Root-Zertifikat in Windows/Git hinterlegen oder IT fragen |
|
||||||
|
|
@ -2,11 +2,15 @@
|
||||||
|
|
||||||
Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt nahtlos fortzuführen.
|
Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt nahtlos fortzuführen.
|
||||||
|
|
||||||
|
> **DEV-Isolation (V2):** Dieses Repo (`AssigmentMonitorV2`) läuft getrennt vom Produktivsystem (`c:\dev\AssigmentMonitor`). Ports **5174 / 4001**, eigene SQLite `assignment-monitor-v2-dev.sqlite3`. Details und Regeln: [`docs/DEV_ISOLATION.md`](./DEV_ISOLATION.md). Produktiv-Daten und -Code niemals anfassen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stand & nächste Schritte
|
## Stand & nächste Schritte
|
||||||
|
|
||||||
Zuletzt abgeschlossen (2026-07-13, zweite Runde): Chronologisches Gesamtprotokoll aller Meetings eines Assignments (`MeetingHistory.tsx`, `/assignment/:id/history`, erreichbar von `AssignmentDetail.tsx` und `MeetingView.tsx` aus) — für Recap-Calls nach Kundenmeetings und die Gesamtdurchsprache im Feedback-Call. Details: `CLAUDE.md` → Erledigt-Historie (2026-07-13, zweite Runde).
|
Zuletzt abgeschlossen (2026-07-13, dritte Runde): Kriterium-Zuordnung für „Allgemeine Notizen" (`MeetingInstance.generalNoteTags`) — wirkt anders als die bestehende `ConversationEntry.lineTags`-Zuordnung nicht auf ein einzelnes Institutee, sondern auf alle Institutees des Assignments gleichzeitig; alle fünf bestehenden Konsumenten von `lineTags` (Vorschlags-Badges, KI-Prompt, Markdown-Export, Legacy-Feedback-Entwurf, Meeting-Historie) wurden entsprechend erweitert. Details: `CLAUDE.md` → Erledigt-Historie (2026-07-13, dritte Runde).
|
||||||
|
|
||||||
|
Davor abgeschlossen (2026-07-13, zweite Runde): Chronologisches Gesamtprotokoll aller Meetings eines Assignments (`MeetingHistory.tsx`, `/assignment/:id/history`, erreichbar von `AssignmentDetail.tsx` und `MeetingView.tsx` aus) — für Recap-Calls nach Kundenmeetings und die Gesamtdurchsprache im Feedback-Call. Details: `CLAUDE.md` → Erledigt-Historie (2026-07-13, zweite Runde).
|
||||||
|
|
||||||
Davor abgeschlossen (2026-07-13): Papierkorb + Archiv für Assignments — Dashboard zeigt standardmäßig nur aktive Assignments, Löschen abgeschlossener Assignments erst möglich wenn für alle Institutees finales Feedback vorliegt, zweistufige Löschung (Papierkorb → automatische endgültige Löschung nach 30 Tagen, mit Wiederherstellen-Option) sowie ein unabhängiges, umkehrbares Archiv für dauerhafte Aufbewahrung. Details: `CLAUDE.md` → Erledigt-Historie (2026-07-13).
|
Davor abgeschlossen (2026-07-13): Papierkorb + Archiv für Assignments — Dashboard zeigt standardmäßig nur aktive Assignments, Löschen abgeschlossener Assignments erst möglich wenn für alle Institutees finales Feedback vorliegt, zweistufige Löschung (Papierkorb → automatische endgültige Löschung nach 30 Tagen, mit Wiederherstellen-Option) sowie ein unabhängiges, umkehrbares Archiv für dauerhafte Aufbewahrung. Details: `CLAUDE.md` → Erledigt-Historie (2026-07-13).
|
||||||
|
|
||||||
|
|
|
||||||
100
docs/SCALE_1_TO_10.md
Normal file
100
docs/SCALE_1_TO_10.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Konzept: Bewertungsskala 1–10 (AssignmentMonitorV2)
|
||||||
|
|
||||||
|
Stand: 2026-08-06 · nur DEV (`AssigmentMonitorV2`), Produktiv unberührt.
|
||||||
|
|
||||||
|
## Festgezogene Entscheidungen
|
||||||
|
|
||||||
|
| # | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| Skala | **1–10**, ganzzahlig; `null` = nicht gesetzt (kein N/A-String mehr) |
|
||||||
|
| Primäre Ebene | **Kategorie** — Meeting-Bewertung und Assignment-Feedback setzen den Score hier |
|
||||||
|
| Kriterien | Primär **Beobachtungen** (Tags, Notizen, Gewichtung); nicht die Pflicht-Bewertungsebene |
|
||||||
|
| Warum Kategorie direkt? | Nicht alle Kriterien sind in jedem Meeting beobachtbar → Score darf **nicht** nur aus Kriterien-Ø abgeleitet werden müssen |
|
||||||
|
| Notation | Führende Zahl `1`–`10` am Zeilenanfang; **keine** `+` / `!` / `(+)` / `(!)` mehr; `>` Zitat und `[…]` Abschnitte bleiben |
|
||||||
|
| Farben | 4 Bänder, **konfigurierbar** unter Config (Defaults unten) |
|
||||||
|
| Labels | Nur Zahlen, keine Ankertexte |
|
||||||
|
| Altdaten | Keine Migration; frische Dev-DB (`npm run db:reset` bei Bedarf) |
|
||||||
|
| Struktur | Noch nicht angelegt — parallel in Config pflegbar, kein Blocker für Skalen-Umbau |
|
||||||
|
| Typ-Strukturen | Prio 2 (später) |
|
||||||
|
| Schnelle Zuordnung | `lineTags` / Tag-Picker / Vorschläge bleiben |
|
||||||
|
|
||||||
|
### Default-Farbbänder
|
||||||
|
|
||||||
|
Überlappung an der 5 in der Erstidee bereinigt:
|
||||||
|
|
||||||
|
| Score | Farbe |
|
||||||
|
|---|---|
|
||||||
|
| 1–2 | rot |
|
||||||
|
| 3–5 | orange |
|
||||||
|
| 6–8 | hellgrün |
|
||||||
|
| 9–10 | dunkelgrün |
|
||||||
|
|
||||||
|
Speicherung in `appSettings.ratingColorBands` (JSON), editierbar in einem neuen Config-Bereich „Skala / Farben“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zielmodell (Daten)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Beobachtung (Kriterium)
|
||||||
|
lineTags / generalNoteTags / Assessment.note
|
||||||
|
optionale Notations-Zahl auf der Zeile → Vorschlag / Detail
|
||||||
|
|
||||||
|
Meeting-Bewertung (Pflicht-Skala)
|
||||||
|
NEU bzw. klar: Score 1–10 pro Kategorie × Institutee × Meeting
|
||||||
|
direkt setzbar, unabhängig davon ob alle Kriterien getaggt sind
|
||||||
|
|
||||||
|
Assignment-Feedback
|
||||||
|
FeedbackCategoryRating.rating → 1–10
|
||||||
|
Vorschlag aus zeitgewichteten Meeting-Kategorie-Scores
|
||||||
|
|
||||||
|
Checkliste
|
||||||
|
ChecklistResponse.rating → 1–10 (gleiche Skala/Farben)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Speicherung Meeting-Kategorie-Score
|
||||||
|
|
||||||
|
Neue Tabelle `meetingCategoryRatings`:
|
||||||
|
|
||||||
|
- `meetingInstanceId`, `instituteeId`, `categoryId`, `score` (INTEGER nullable 1–10), optional `note`
|
||||||
|
|
||||||
|
Bestehende `ConversationSkillScore` (Schnellscore am Protokoll-Eintrag) ebenfalls auf 1–10 umstellen — bleibt optionaler Feinschliff während des Tippens, ersetzt aber nicht die Meeting-Gesamtbewertung pro Kategorie.
|
||||||
|
|
||||||
|
`Assessment.score` (pro Kriterium): optional `number | null` für Detail/KI; UI der Gesamtbewertung fokussiert auf **Kategorien**. Kriterien darunter zeigen Beobachtungen + optionalen Detail-Score.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umsetzungsphasen
|
||||||
|
|
||||||
|
### Phase 1 — Skalen-Kern ✅ (2026-08-06)
|
||||||
|
- Types: `RatingScore` 1–10, `null` = unset; `meetingCategoryRatings`; `ratingColorBands`
|
||||||
|
- `constants.ts`: Bänder, `resolveScoreStyle`, `ScorePicker`
|
||||||
|
- Config-Tab „Skala“
|
||||||
|
- `ratingTrend` / Notation (führende Zahl) / Meeting-/Feedback-/Export-UI umgestellt
|
||||||
|
- Hard-Delete räumt `meetingCategoryRatings` + `checklistResponses` auf
|
||||||
|
|
||||||
|
### Phase 2 — Meeting-UI Feinschliff
|
||||||
|
- Notation-Schnellbuttons weiter verfeinern; Farbbänder live aus Config laden (nicht nur Defaults)
|
||||||
|
- Detail/History-Chips primär aus `meetingCategoryRatings`
|
||||||
|
|
||||||
|
### Phase 3 — Feedback & Auswertung
|
||||||
|
- Trend/Vorschlag konsequent aus Meeting-Kategorie-Scores
|
||||||
|
- **Neukonzeption Benefits/Concerns + KI-Kaskade:** siehe `docs/FEEDBACK_CASCADE_PLAN.md`
|
||||||
|
|
||||||
|
### Phase 4 — Export & KI
|
||||||
|
- Persistierte Dimension-Prompts auf neue Skalen-Sprache zurücksetzen/syncen
|
||||||
|
- Kategorie-Benefits/Concerns im PDF (Teil von `FEEDBACK_CASCADE_PLAN.md` Paket 5)
|
||||||
|
|
||||||
|
### Phase 5 — UX-Feinschliff
|
||||||
|
- Slider/Stepper, mobile Hit-Areas; Schreibfluss schützen
|
||||||
|
|
||||||
|
### Phase 6 (Prio 2) — Struktur je Assignment-Typ
|
||||||
|
- Sichtbarkeit Dimension/Kategorie pro Typ — **umgesetzt als Paket 1b** (`resolveVisibleStructure`, Config → Ass.-Typen → Struktur)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht-Ziele (jetzt)
|
||||||
|
|
||||||
|
- Produktiv-Migration / Mapping alter Enum-Werte
|
||||||
|
- Ankertexte auf der Skala
|
||||||
|
- Getrennte Taxonomien pro Assignment-Typ (erst Prio 2)
|
||||||
199
docs/preload/Feedback-Struktur-final.md
Normal file
199
docs/preload/Feedback-Struktur-final.md
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
# Feedback-Struktur
|
||||||
|
|
||||||
|
## Client-Readiness
|
||||||
|
|
||||||
|
### Problem Structuring
|
||||||
|
|
||||||
|
Übersetzt eine zunächst offene oder komplexe Kundenfragestellung in eine präzise, logisch gegliederte und bearbeitbare Problemstruktur, die eine fokussierte Analyse und zielgerichtete Entscheidungsfindung ermöglicht.
|
||||||
|
|
||||||
|
- **Problem- und Zielklarheit** (Gewicht 1.0) — Klärt Ausgangslage, eigentliches Problem, angestrebtes Ergebnis und relevante Entscheidung, bevor mit der Bearbeitung begonnen wird.
|
||||||
|
- **Scope-Klarheit** (Gewicht 1.0) — Grenzt Untersuchungsgegenstand, Betrachtungszeitraum, Stakeholder, Annahmen und Nicht-Ziele nachvollziehbar ab.
|
||||||
|
- **Logische Zerlegung** (Gewicht 1.0) — Zerlegt komplexe Fragestellungen in verständliche, trennscharfe und gemeinsam hinreichend vollständige Teilfragen.
|
||||||
|
- **Hypothesenorientierung** (Gewicht 1.0) — Formuliert frühzeitig plausible, überprüfbare Hypothesen und nutzt sie zur Steuerung der weiteren Analyse.
|
||||||
|
- **Priorisierung nach Relevanz** (Gewicht 1.0) — Fokussiert die Struktur auf die entscheidungs- und wirkungsrelevanten Fragestellungen und vermeidet unnötige Analysebreite.
|
||||||
|
- **Treiber- und Abhängigkeitsverständnis** (Gewicht 1.0) — Erkennt wesentliche Ursachen, Wirkzusammenhänge, Abhängigkeiten und Wechselwirkungen innerhalb der Fragestellung.
|
||||||
|
- **Ableitung eines Analyseplans** (Gewicht 1.0) — Übersetzt die Problemstruktur in konkrete Arbeitsschritte, benötigte Informationen, Verantwortlichkeiten und sinnvolle Prüfsequenzen.
|
||||||
|
|
||||||
|
### Problem Solving
|
||||||
|
|
||||||
|
Bearbeitet strukturierte Fragestellungen analytisch belastbar, pragmatisch und ergebnisorientiert. Entwickelt aus Fakten und Erkenntnissen umsetzbare Optionen, Entscheidungen und Empfehlungen.
|
||||||
|
|
||||||
|
- **Fakten- und Evidenzorientierung** (Gewicht 1.0) — Stützt Aussagen auf belastbare Daten, Quellen, Interviews, Analysen oder transparent hergeleitete Annahmen.
|
||||||
|
- **Methodengerechte Analyse** (Gewicht 1.0) — Wählt geeignete qualitative und quantitative Methoden aus und wendet sie fachlich korrekt und zweckmäßig an.
|
||||||
|
- **Kritische Prüfung von Informationen** (Gewicht 1.0) — Hinterfragt Datenqualität, Quellen, Annahmen, Widersprüche und mögliche Verzerrungen, statt Ergebnisse ungeprüft zu übernehmen.
|
||||||
|
- **Hypothesenprüfung und Iteration** (Gewicht 1.0) — Prüft Hypothesen systematisch, verwirft oder schärft sie anhand neuer Erkenntnisse und passt das Vorgehen entsprechend an.
|
||||||
|
- **Entwicklung von Lösungsoptionen** (Gewicht 1.0) — Entwickelt mehrere relevante Handlungsoptionen und macht deren Unterschiede, Vor- und Nachteile sowie Konsequenzen sichtbar.
|
||||||
|
- **Impact- und Value-Orientierung** (Gewicht 1.0) — Richtet Analyse, Vorgehen und Lösung auf den höchsten realistischen Kundennutzen aus. Hinterfragt bei Bedarf den ursprünglich gewünschten Deliverable-Typ und entwickelt wirkungsvollere Alternativen, ohne Ziel, Scope, Risiken oder Umsetzbarkeit eigenmächtig zu verändern.
|
||||||
|
- **Machbarkeit und Realitätsnähe** (Gewicht 1.0) — Berücksichtigt organisatorische, technische, wirtschaftliche und zeitliche Rahmenbedingungen bei der Lösungsentwicklung.
|
||||||
|
- **Entscheidungsorientierte Empfehlung** (Gewicht 1.0) — Verdichtet Analyseergebnisse zu einer klaren Empfehlung mit Begründung, Auswirkungen, Risiken und nächsten Schritten.
|
||||||
|
|
||||||
|
### Storylining
|
||||||
|
|
||||||
|
Verdichtet komplexe Inhalte zu einer logisch schlüssigen, adressatengerechten und entscheidungsorientierten Argumentationslinie, die Kernbotschaften klar transportiert.
|
||||||
|
|
||||||
|
- **Answer First / Kernaussage** (Gewicht 1.0) — Stellt die zentrale Aussage, Empfehlung oder Antwort frühzeitig und eindeutig heraus.
|
||||||
|
- **Logische Argumentationsstruktur** (Gewicht 1.0) — Ordnet Aussagen so, dass Schlussfolgerungen nachvollziehbar aus Fakten, Analysen und Teilargumenten hervorgehen.
|
||||||
|
- **Relevanz und Fokus** (Gewicht 1.0) — Beschränkt die Storyline auf Inhalte, die für Fragestellung, Zielgruppe und Entscheidung tatsächlich erforderlich sind.
|
||||||
|
- **Belegführung** (Gewicht 1.0) — Verknüpft Kernaussagen sichtbar mit belastbaren Fakten, Analysen und nachvollziehbaren Begründungen.
|
||||||
|
- **Managementgerechte Dramaturgie** (Gewicht 1.0) — Führt verständlich von Ausgangslage und Handlungsdruck über Erkenntnisse zu Empfehlung, Wirkung und nächstem Schritt.
|
||||||
|
- **Konsistenz über Medien und Beiträge** (Gewicht 1.0) — Hält Botschaften, Begriffe, Zahlen und Argumentationslogik über Folien, Dokumente und mündliche Beiträge hinweg widerspruchsfrei.
|
||||||
|
|
||||||
|
### Precise Communication
|
||||||
|
|
||||||
|
Nimmt Informationen aktiv auf und kommuniziert mündlich wie schriftlich klar, prägnant, adressatengerecht und handlungsorientiert.
|
||||||
|
|
||||||
|
- **Active Listening** (Gewicht 1.0) — Hört aufmerksam zu, erfasst Inhalt und Kontext und greift relevante Aussagen, Bedenken und Signale gezielt auf.
|
||||||
|
- **Präzise Fragetechnik** (Gewicht 1.0) — Stellt klare, relevante und aufeinander aufbauende Fragen, die Verständnis, Erkenntnisgewinn oder Entscheidung fördern.
|
||||||
|
- **To the Point** (Gewicht 1.0) — Formuliert die wesentliche Aussage ohne unnötige Vorrede, Wiederholungen oder Nebenpfade.
|
||||||
|
- **Adressatengerechte Kommunikation** (Gewicht 1.0) — Passt Tonalität, Detailtiefe, Begriffswahl und Kommunikationsstil an Zielgruppe, Hierarchie und Situation an.
|
||||||
|
- **Sprachliche Klarheit und Verbindlichkeit** (Gewicht 1.0) — Verwendet eindeutige Formulierungen und vermeidet unnötige Füllwörter, Weichmacher, Mehrdeutigkeit und Scheingenauigkeit.
|
||||||
|
- **Recap und Verständnissicherung** (Gewicht 1.0) — Fasst Kernaussagen, Entscheidungen, offene Punkte und nächste Schritte prägnant zusammen und prüft gemeinsames Verständnis.
|
||||||
|
- **Souveräne Präsenz** (Gewicht 1.0) — Tritt ruhig, vorbereitet, klar und entscheidungsorientiert auf. Behält auch bei kritischen Rückfragen, Widerspruch oder unvollständigen Informationen die Handlungsfähigkeit, unterscheidet sicher zwischen Fakten, Annahmen und offenen Punkten und vermittelt Orientierung, ohne Sicherheit vorzutäuschen.
|
||||||
|
|
||||||
|
### Client Intimacy Building
|
||||||
|
|
||||||
|
Baut professionelle, vertrauensvolle Kundenbeziehungen auf, versteht fachliche und persönliche Stakeholderbedürfnisse und entwickelt sich vom Ausführenden zum wirksamen Berater.
|
||||||
|
|
||||||
|
- **Verständnis des Kundenkontexts** (Gewicht 1.0) — Erfasst Ziele, Geschäftsmodell, Prioritäten, Restriktionen, Entscheidungslogiken und relevante Rahmenbedingungen des Kunden.
|
||||||
|
- **Stakeholderverständnis** (Gewicht 1.0) — Erkennt Interessen, Einfluss, Erwartungen und mögliche Widerstände relevanter Stakeholder und berücksichtigt sie im Vorgehen.
|
||||||
|
- **Vertrauensaufbau und Beziehungsqualität** (Gewicht 1.0) — Schafft durch Verlässlichkeit, Integrität, professionelles Verhalten und respektvolle Zusammenarbeit eine belastbare Arbeitsbeziehung.
|
||||||
|
- **Fachliche Glaubwürdigkeit und Kompetenzwahrnehmung** (Gewicht 1.0) — Wird durch fachliche Substanz, Kundenverständnis, klare Einordnung und belastbare Empfehlungen als relevanter und kompetenter Ansprechpartner wahrgenommen. Gibt Orientierung und macht zugleich Grenzen des eigenen Wissens transparent.
|
||||||
|
- **Aktives Erwartungsmanagement** (Gewicht 1.0) — Klärt Ziele, Rollen, Umfang, Annahmen, Risiken und Liefererwartungen und steuert Abweichungen frühzeitig nach.
|
||||||
|
- **Active Advisory und Customer Steering** (Gewicht 1.0) — Führt den Kunden aktiv durch Fragestellungen, Optionen, Konsequenzen und notwendige Entscheidungen. Spricht klare Empfehlungen aus und verhindert eine rein ausführende Rolle, ohne die Entscheidungsverantwortung des Kunden zu übergehen.
|
||||||
|
- **Konstruktives Challengen** (Gewicht 1.0) — Hinterfragt Annahmen oder Kundenwünsche respektvoll, faktenbasiert und lösungsorientiert, wenn dies dem Ergebnis dient.
|
||||||
|
- **Beziehungsentwicklung über den Einzeltermin hinaus** (Gewicht 1.0) — Pflegt relevante Kontakte, erkennt Anschlussbedarfe und schafft Kontinuität in der Zusammenarbeit, ohne aufdringlich oder rein vertriebsorientiert zu handeln.
|
||||||
|
|
||||||
|
### Quality of Deliverables
|
||||||
|
|
||||||
|
Erstellt fachlich belastbare, konsistente und professionell aufbereitete Arbeitsergebnisse, die für den Kunden verständlich, relevant und unmittelbar nutzbar sind.
|
||||||
|
|
||||||
|
- **Fachliche Richtigkeit** (Gewicht 1.0) — Inhalte, Berechnungen, Aussagen und Schlussfolgerungen sind fachlich korrekt und belastbar.
|
||||||
|
- **Inhaltliche Vollständigkeit** (Gewicht 1.0) — Deckt die für Fragestellung und Entscheidung relevanten Aspekte, Perspektiven und Auswirkungen angemessen ab.
|
||||||
|
- **Logische Konsistenz** (Gewicht 1.0) — Stellt sicher, dass Aussagen, Zahlen, Argumente und Empfehlungen in sich sowie untereinander widerspruchsfrei sind.
|
||||||
|
- **Kundenspezifische Konkretisierung** (Gewicht 1.0) — Überträgt generische Erkenntnisse sichtbar auf Situation, Sprache, Daten und Handlungsrealität des Kunden.
|
||||||
|
- **Informationsdichte und Nutzbarkeit** (Gewicht 1.0) — Wählt eine angemessene Detailtiefe und ermöglicht dem Adressaten, Kernaussagen und Handlungsbedarf schnell zu erfassen.
|
||||||
|
- **Visualisierungs- und Darstellungsqualität** (Gewicht 1.0) — Nutzt Struktur, Layout, Tabellen, Grafiken und Visualisierungen so, dass sie Verständnis und Botschaft wirksam unterstützen.
|
||||||
|
- **Formale Qualität und Standardkonformität** (Gewicht 1.0) — Hält relevante Templates, Designvorgaben, Sprache, Quellenangaben, Versionierung und Dokumentationsstandards konsequent ein.
|
||||||
|
|
||||||
|
### Proposal Writing & Value Proposition Design
|
||||||
|
|
||||||
|
Übersetzt einen Kundenbedarf in ein überzeugendes, differenziertes und realistisch lieferbares Leistungsversprechen mit klarer Wirkung, Vorgehensweise und Nutzenargumentation.
|
||||||
|
|
||||||
|
- **Verständnis von Bedarf und Buying Context** (Gewicht 1.0) — Erfasst Problem, Auslöser, Zielbild, Entscheidungskriterien, Stakeholder und Beschaffungskontext hinter der Anfrage.
|
||||||
|
- **Klare Value Proposition** (Gewicht 1.0) — Formuliert prägnant, welchen konkreten Nutzen und welche angestrebten Ergebnisse das Angebot für den Kunden erzeugt.
|
||||||
|
- **Kundenspezifische Differenzierung** (Gewicht 1.0) — Verbindet Kundenbedarf mit relevanten Fähigkeiten, Erfahrungen, Assets und unterscheidbaren Stärken des eigenen Angebots.
|
||||||
|
- **Schlüssiger Scope und Deliverables** (Gewicht 1.0) — Beschreibt Leistungsumfang, Ergebnisse, Grenzen und Verantwortlichkeiten klar und vermeidet Interpretationsspielräume.
|
||||||
|
- **Plausibles Vorgehensmodell** (Gewicht 1.0) — Entwickelt einen nachvollziehbaren Ansatz mit Arbeitspaketen, Meilensteinen, Beteiligten und Entscheidungszeitpunkten.
|
||||||
|
- **Belastbare Annahmen und Machbarkeit** (Gewicht 1.0) — Macht Voraussetzungen, Abhängigkeiten, Risiken und Mitwirkungspflichten transparent und verspricht nur realistisch Lieferbares.
|
||||||
|
- **Kommerzielles Verständnis** (Gewicht 1.0) — Berücksichtigt Aufwand, Preislogik, Wirtschaftlichkeit, Risiken und den für beide Seiten tragfähigen Zuschnitt des Angebots.
|
||||||
|
- **Überzeugende Angebotsstory** (Gewicht 1.0) — Verbindet Kundensituation, Handlungsdruck, Lösung, Nutzen, Vorgehen und Glaubwürdigkeit zu einer konsistenten Entscheidungsgrundlage.
|
||||||
|
|
||||||
|
### Workshop Design & Facilitation
|
||||||
|
|
||||||
|
Konzipiert und moderiert Workshops so, dass relevante Beteiligte fokussiert zusammenarbeiten, belastbare Ergebnisse erzielen und klare Entscheidungen oder nächste Schritte vereinbaren.
|
||||||
|
|
||||||
|
- **Ziel- und Ergebnisdefinition** (Gewicht 1.0) — Klärt vorab, welches konkrete Ergebnis, welche Entscheidung oder welcher Arbeitsfortschritt im Workshop erreicht werden soll.
|
||||||
|
- **Teilnehmer- und Stakeholderdesign** (Gewicht 1.0) — Bindet die für Inhalt, Entscheidung und Umsetzung relevanten Personen mit passenden Rollen ein.
|
||||||
|
- **Dramaturgie und Methodenauswahl** (Gewicht 1.0) — Entwickelt eine logische Agenda und wählt Methoden, die Ziel, Teilnehmergruppe, Zeit und Arbeitsmodus unterstützen.
|
||||||
|
- **Vorbereitung und Arbeitsfähigkeit** (Gewicht 1.0) — Stellt Inhalte, Leitfragen, Daten, Materialien, Technik, Rollen und organisatorische Voraussetzungen rechtzeitig bereit.
|
||||||
|
- **Zielgerichtete Moderation** (Gewicht 1.0) — Führt verständlich durch den Prozess, hält Fokus und Zeit und macht Übergänge sowie Arbeitsaufträge klar.
|
||||||
|
- **Aktivierung und Gruppendynamik** (Gewicht 1.0) — Fördert ausgewogene Beteiligung, geht konstruktiv mit Dominanz, Zurückhaltung, Konflikten und Abschweifungen um.
|
||||||
|
- **Ergebnissicherung und Follow-up** (Gewicht 1.0) — Verdichtet Diskussionen, dokumentiert Entscheidungen, Verantwortlichkeiten und nächste Schritte und stellt eine klare Nachbereitung sicher.
|
||||||
|
|
||||||
|
## AI-Readiness
|
||||||
|
|
||||||
|
### AI Fundamentals
|
||||||
|
|
||||||
|
Versteht Funktionsweise, Einsatzvoraussetzungen, Chancen, Grenzen und Risiken von KI auf einem für die Beratung angemessenen Niveau und handelt verantwortungsvoll.
|
||||||
|
|
||||||
|
- **Grundverständnis generativer KI** (Gewicht 1.0) — Erklärt in angemessener Tiefe, wie Modelle, Trainingsdaten, Kontext, Prompts und probabilistische Ausgabe die Ergebnisse beeinflussen.
|
||||||
|
- **Use-Case-Eignung beurteilen** (Gewicht 1.0) — Erkennt, für welche Aufgaben KI sinnvoll ist und wo menschliche Expertise, klassische Analyse oder andere Werkzeuge geeigneter sind.
|
||||||
|
- **Grenzen und Fehlerbilder erkennen** (Gewicht 1.0) — Berücksichtigt insbesondere Halluzinationen, Bias, fehlende Aktualität, Scheingenauigkeit, Kontextverlust und unvollständige Quellenlage.
|
||||||
|
- **Datenschutz und Vertraulichkeit** (Gewicht 1.0) — Behandelt Kunden-, Personen- und Unternehmensdaten entsprechend den geltenden Sicherheits-, Datenschutz- und Vertraulichkeitsvorgaben.
|
||||||
|
- **Responsible-AI-Verständnis** (Gewicht 1.0) — Berücksichtigt ethische, rechtliche, sicherheitsbezogene und gesellschaftliche Auswirkungen beim Einsatz von KI.
|
||||||
|
- **Qualitätssicherung von KI-Ergebnissen** (Gewicht 1.0) — Prüft Ergebnisse fachlich, logisch, methodisch und quellenbezogen und übernimmt keine KI-Ausgabe ungeprüft.
|
||||||
|
|
||||||
|
### AI-Augmented Consulting
|
||||||
|
|
||||||
|
Integriert KI gezielt und verantwortungsvoll in typische Beratungsaufgaben, um Qualität, Geschwindigkeit und Erkenntnisgewinn zu verbessern, ohne die Verantwortung für Ergebnisse abzugeben.
|
||||||
|
|
||||||
|
- **Zielgerichtete Tool- und Use-Case-Auswahl** (Gewicht 1.0) — Wählt KI-Werkzeug und Einsatzform passend zu Aufgabe, Datenlage, Risiko und erwartetem Mehrwert aus.
|
||||||
|
- **Kontext- und Promptgestaltung** (Gewicht 1.0) — Formuliert klare Arbeitsaufträge, stellt relevanten Kontext bereit und verbessert Ergebnisse iterativ durch geeignete Steuerung.
|
||||||
|
- **KI-gestützte Recherche und Verdichtung** (Gewicht 1.0) — Nutzt KI zur Informationsstrukturierung und Synthese, überprüft jedoch Quellen, Aktualität und Vollständigkeit.
|
||||||
|
- **KI-gestützte Analyse und Hypothesenbildung** (Gewicht 1.0) — Nutzt KI zur Entwicklung von Analysefragen, Hypothesen, Strukturierungsansätzen oder Auswertungsoptionen und validiert diese eigenständig.
|
||||||
|
- **KI-gestützte Storyline- und Deliverable-Erstellung** (Gewicht 1.0) — Erstellt mit KI erste Entwürfe für Storylines, Texte, Folien oder Arbeitsmaterialien und veredelt sie fachlich sowie kundenspezifisch.
|
||||||
|
- **KI-gestützte Meeting- und Workshopvorbereitung** (Gewicht 1.0) — Nutzt KI für Interviewleitfäden, Fragen, Szenarien, Zusammenfassungen oder Moderationsvorbereitung und prüft die Praxistauglichkeit.
|
||||||
|
- **Systematische Workflow-Integration** (Gewicht 1.0) — Identifiziert wiederkehrende Arbeitsschritte, integriert KI reproduzierbar und dokumentiert Vorgehen, Qualitätskontrollen und menschliche Entscheidungen angemessen.
|
||||||
|
|
||||||
|
### AI Certification
|
||||||
|
|
||||||
|
Dokumentiert formale, rollenbezogene KI-Qualifikationsnachweise. Da Zertifizierungen primär einen Nachweisstatus und kein beobachtbares Verhalten darstellen, sollten diese Kriterien möglichst als erfüllt, nicht erfüllt oder nicht erforderlich geführt werden.
|
||||||
|
|
||||||
|
- **Vorgegebene Basiszertifizierung** (Gewicht 1.0) — Hat die für die Rolle oder Entwicklungsstufe vorgeschriebene grundlegende KI-Zertifizierung erfolgreich abgeschlossen.
|
||||||
|
- **Rollen- oder technologiespezifische Zertifizierung** (Gewicht 1.0) — Verfügt über die für Einsatzgebiet, Technologie oder Projektkontext relevanten weiterführenden Nachweise, soweit diese gefordert sind.
|
||||||
|
- **Aktualität des Nachweises** (Gewicht 1.0) — Hält verpflichtende Zertifizierungen, Rezertifizierungen und vorgeschriebene Lernstände aktuell.
|
||||||
|
- **Nachgewiesener Praxistransfer** (Gewicht 1.0) — Kann zentrale Inhalte der Zertifizierung in Übungen oder Assignments korrekt anwenden und deren Grenzen erläutern.
|
||||||
|
|
||||||
|
## Culture-Readiness
|
||||||
|
|
||||||
|
### Quality Excellence
|
||||||
|
|
||||||
|
Verankert Qualität als kontinuierliche Arbeitsweise und sorgt durch Standards, Reviews und kritische Prüfung dafür, dass belastbare Ergebnisse reproduzierbar entstehen.
|
||||||
|
|
||||||
|
- **Qualitätsanspruch** (Gewicht 1.0) — Richtet die eigene Arbeit sichtbar auf fachlich belastbare, verständliche und kundentaugliche Ergebnisse aus.
|
||||||
|
- **Systematische Qualitätschecks** (Gewicht 1.0) — Prüft Inhalte, Zahlen, Logik, Sprache, Quellen, Format und Konsistenz vor Weitergabe anhand geeigneter Kontrollschritte.
|
||||||
|
- **Prozess- und Standardtreue** (Gewicht 1.0) — Kennt relevante Prozesse, Methoden, Templates und Vorgaben und wendet sie zweckmäßig sowie nachvollziehbar an.
|
||||||
|
- **Reviewfähigkeit** (Gewicht 1.0) — Bereitet Arbeitsergebnisse so vor, dass andere sie effizient prüfen können, und arbeitet Reviewhinweise sauber und vollständig ein.
|
||||||
|
- **Frühzeitige Qualitätssicherung** (Gewicht 1.0) — Plant Reviews, Abstimmungen und Prüfschritte rechtzeitig ein, statt Qualität ausschließlich am Ende herzustellen.
|
||||||
|
- **Integrität und Compliance** (Gewicht 1.0) — Handelt ehrlich und regelkonform, macht Unsicherheiten oder Fehler transparent und wahrt Vertraulichkeit sowie relevante fachliche und organisatorische Vorgaben.
|
||||||
|
- **Kontinuierliche Verbesserung** (Gewicht 1.0) — Erkennt wiederkehrende Fehler oder Ineffizienzen und verbessert Vorgehen, Standards oder Hilfsmittel nachhaltig.
|
||||||
|
|
||||||
|
### Drive (Extra Mile)
|
||||||
|
|
||||||
|
Zeigt wirkungsorientierte Initiative, Ausdauer und Gestaltungswillen und leistet bei relevantem Mehrwert bewusst mehr als das formal Geforderte. Bewertet wird nicht die Anzahl der Arbeitsstunden, sondern der zusätzliche Beitrag zum Ergebnis.
|
||||||
|
|
||||||
|
- **Proaktive Initiative** (Gewicht 1.0) — Erkennt Handlungsbedarf oder Chancen und wird selbstständig aktiv, ohne auf detaillierte Einzelaufträge zu warten.
|
||||||
|
- **Ambitionierter Wirkungsanspruch** (Gewicht 1.0) — Strebt nicht nur formale Erledigung, sondern ein erkennbar nützliches und überzeugendes Ergebnis an.
|
||||||
|
- **Beharrlichkeit bei Hindernissen** (Gewicht 1.0) — Verfolgt wichtige Themen trotz Rückschlägen konstruktiv weiter und sucht aktiv nach alternativen Lösungswegen.
|
||||||
|
- **Mehrwert über den Auftrag hinaus** (Gewicht 1.0) — Identifiziert sinnvolle zusätzliche Beiträge, Impulse oder wiederverwendbare Lösungen, ohne Scope, Prioritäten oder Wirtschaftlichkeit aus dem Blick zu verlieren.
|
||||||
|
- **Anpassungsfähigkeit** (Gewicht 1.0) — Bleibt bei veränderten Prioritäten, unvollständigen Informationen oder neuen Anforderungen handlungsfähig und richtet das Vorgehen neu aus.
|
||||||
|
- **Konstruktive Energie unter Druck** (Gewicht 1.0) — Bewahrt auch bei hoher Belastung Fokus, Lösungsorientierung und professionelles Verhalten.
|
||||||
|
|
||||||
|
### Ownership
|
||||||
|
|
||||||
|
Übernimmt End-to-End-Verantwortung für übertragene Aufgaben und angestrebte Ergebnisse, steuert die eigene Arbeit aktiv und schafft jederzeit Transparenz über Fortschritt, Risiken und Handlungsbedarf.
|
||||||
|
|
||||||
|
- **Auftrags- und Ergebnisverständnis** (Gewicht 1.0) — Klärt Ziel, Qualitätsmaßstab, Umfang, Rollen, Abhängigkeiten und erwartete Lieferung, bevor die Umsetzung fortschreitet.
|
||||||
|
- **Eigenständige Arbeitssteuerung** (Gewicht 1.0) — Plant, priorisiert und organisiert die eigene Arbeit nachvollziehbar und benötigt nur ein der Entwicklungsstufe angemessenes Maß an Steuerung.
|
||||||
|
- **Verbindlichkeit und Zuverlässigkeit** (Gewicht 1.0) — Hält Zusagen ein und behandelt vereinbarte Termine, Qualitätsziele und Verantwortlichkeiten verbindlich.
|
||||||
|
- **Fortschritts- und Risikotransparenz** (Gewicht 1.0) — Kommuniziert Arbeitsstand, offene Punkte, Abhängigkeiten und mögliche Abweichungen frühzeitig und konkret und vermittelt eine realistische Einschätzung der Zielerreichung.
|
||||||
|
- **Stufenweises Alignment und Acceptance Management** (Gewicht 1.0) — Validiert Annahmen, Konzepte und Zwischenergebnisse rechtzeitig mit den relevanten Kundenvertretern. Sichert ein explizites gemeinsames Verständnis beziehungsweise eine Teilfreigabe als Grundlage für die weitere Bearbeitung und dokumentiert Entscheidungen, Vorbehalte und Änderungen nachvollziehbar.
|
||||||
|
- **Vorausschauendes Handeln** (Gewicht 1.0) — Erkennt Risiken, Engpässe und Entscheidungsbedarfe früh und leitet geeignete Maßnahmen ein.
|
||||||
|
- **Angemessene Entscheidung und Eskalation** (Gewicht 1.0) — Entscheidet im eigenen Verantwortungsbereich selbstständig und eskaliert rechtzeitig mit klarer Sachlage und Lösungsvorschlägen.
|
||||||
|
- **Konsequenter Abschluss** (Gewicht 1.0) — Verfolgt offene Punkte bis zu einem belastbaren Ergebnis, sichert Übergaben und schließt Kommunikations- und Dokumentationsschleifen.
|
||||||
|
|
||||||
|
### Coachability
|
||||||
|
|
||||||
|
Nutzt Feedback, Reflexion und neue Erfahrungen aktiv zur Weiterentwicklung und zeigt nachvollziehbare Verhaltensänderungen über mehrere Assignments hinweg.
|
||||||
|
|
||||||
|
- **Aktives Einholen von Feedback** (Gewicht 1.0) — Fragt gezielt und rechtzeitig nach Rückmeldung zu Wirkung, Vorgehen und Ergebnis, statt nur auf formale Feedbacktermine zu warten.
|
||||||
|
- **Offene Feedbackannahme** (Gewicht 1.0) — Hört Feedback aufmerksam an, bleibt sachlich und vermeidet vorschnelle Rechtfertigung oder Abwehr.
|
||||||
|
- **Verständnisklärung** (Gewicht 1.0) — Fragt bei unklarer Rückmeldung nach konkreten Situationen, Erwartungen und beobachtbaren Alternativen.
|
||||||
|
- **Übersetzung in Entwicklungsmaßnahmen** (Gewicht 1.0) — Leitet aus Feedback konkrete, realistische und überprüfbare Verhaltens- oder Lernschritte ab.
|
||||||
|
- **Sichtbarer Praxistransfer** (Gewicht 1.0) — Wendet vereinbarte Verbesserungen in folgenden Aufgaben erkennbar an und zeigt nachhaltigen Fortschritt.
|
||||||
|
- **Selbstreflexion und Lernagilität** (Gewicht 1.0) — Analysiert eigene Wirkung, Stärken, Fehler und Muster realistisch und passt Verhalten an neue Situationen an.
|
||||||
|
|
||||||
|
### Collaboration
|
||||||
|
|
||||||
|
Arbeitet verlässlich, respektvoll und lösungsorientiert mit anderen zusammen, stärkt den gemeinsamen Teamerfolg und nutzt unterschiedliche Perspektiven und Fähigkeiten konstruktiv.
|
||||||
|
|
||||||
|
- **Proaktive Abstimmung** (Gewicht 1.0) — Stimmt sich rechtzeitig mit relevanten Beteiligten ab, macht Schnittstellen transparent und vermeidet Informationslücken.
|
||||||
|
- **Wissens- und Ergebnisteilung** (Gewicht 1.0) — Teilt Informationen, Zwischenergebnisse, Erfahrungen und wiederverwendbare Inhalte so, dass das Team wirksam weiterarbeiten kann.
|
||||||
|
- **Unterstützungsbereitschaft** (Gewicht 1.0) — Unterstützt andere konstruktiv und bedarfsgerecht, ohne eigene Verantwortlichkeiten oder gemeinsame Prioritäten zu vernachlässigen.
|
||||||
|
- **Verlässliches Teamverhalten** (Gewicht 1.0) — Hält Teamzusagen ein, respektiert Rollen und schafft durch professionelles Verhalten eine belastbare Arbeitsbasis.
|
||||||
|
- **Konstruktiver Umgang mit Differenzen** (Gewicht 1.0) — Spricht abweichende Sichtweisen sachlich an, hört Gegenargumente an und arbeitet auf eine tragfähige Lösung hin.
|
||||||
|
- **Einbindung unterschiedlicher Perspektiven** (Gewicht 1.0) — Bezieht relevante Erfahrungen und Meinungen aktiv ein und schafft Raum für Beteiligung unabhängig von Rolle oder Hierarchie.
|
||||||
|
- **Team- und organisationsübergreifende Zusammenarbeit** (Gewicht 1.0) — Vernetzt sich bei Bedarf über das unmittelbare Team hinaus und nutzt passende Experten, Communities oder Kontakte für den gemeinsamen Erfolg.
|
||||||
|
|
||||||
|
---
|
||||||
|
_Export: 6.8.2026, finale Kriterienstruktur ohne Änderungen der Kategorie-Ebene
|
||||||
23
docs/preload/README.md
Normal file
23
docs/preload/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Feedback-Struktur Preload
|
||||||
|
|
||||||
|
Finale Kriterienstruktur (Export 2026-08-06) für die Dev-Umgebung.
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `docs/preload/Feedback-Struktur-final.md` | Quelle (Markdown, gleiches Format wie Config → Feedback → ⬇ MD) |
|
||||||
|
| `src/db/seeds/feedbackStructureFinal.json` | Geparste Seed-Kopie (3 Dimensionen, 16 Kategorien, 109 Kriterien) |
|
||||||
|
|
||||||
|
## Einspielen
|
||||||
|
|
||||||
|
**In der App (Config → Feedback):**
|
||||||
|
- **Preload final** — lädt `docs/preload/Feedback-Struktur-final.md` in die Dev-DB (mit Bestätigung; ersetzt bestehende Struktur)
|
||||||
|
- **↑ MD** — beliebige MD-Datei im Export-Format importieren
|
||||||
|
|
||||||
|
**CLI:**
|
||||||
|
```bash
|
||||||
|
npm run preload:feedback-structure
|
||||||
|
```
|
||||||
|
|
||||||
|
Ersetzt die komplette Feedback-Struktur (Dimensionen inkl. Cascade). Nur in `AssigmentMonitorV2` verwenden — nicht gegen Produktiv.
|
||||||
531
package-lock.json
generated
531
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "assignment-monitor",
|
"name": "assignment-monitor",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|
@ -2345,6 +2346,207 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/fns": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/font": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/pdfkit": "^5.1.1",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"is-url": "^1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/image": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/svg": "^1.1.0",
|
||||||
|
"jay-peg": "^1.1.1",
|
||||||
|
"png-js": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/layout": {
|
||||||
|
"version": "4.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.6.1.tgz",
|
||||||
|
"integrity": "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/image": "^3.1.0",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/stylesheet": "^6.2.1",
|
||||||
|
"@react-pdf/textkit": "^6.3.0",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"emoji-regex-xs": "^1.0.0",
|
||||||
|
"queue": "^6.0.1",
|
||||||
|
"yoga-layout": "^3.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/pdfkit": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@noble/ciphers": "^1.0.0",
|
||||||
|
"@noble/hashes": "^1.6.0",
|
||||||
|
"browserify-zlib": "^0.2.0",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"jay-peg": "^1.1.1",
|
||||||
|
"js-md5": "^0.8.3",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"png-js": "^2.0.0",
|
||||||
|
"vite-compatible-readable-stream": "^3.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/primitives": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/reconciler": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"scheduler": "0.25.0-rc-603e6108-20241029"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
|
||||||
|
"version": "0.25.0-rc-603e6108-20241029",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
|
||||||
|
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/render": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/textkit": "^6.3.0",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"abs-svg-path": "^0.1.1",
|
||||||
|
"color-string": "^2.1.4",
|
||||||
|
"normalize-svg-path": "^1.1.0",
|
||||||
|
"parse-svg-path": "^0.1.2",
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/renderer": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/font": "^4.0.8",
|
||||||
|
"@react-pdf/layout": "^4.6.1",
|
||||||
|
"@react-pdf/pdfkit": "^5.1.1",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/reconciler": "^2.0.0",
|
||||||
|
"@react-pdf/render": "^4.5.1",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"prop-types": "^15.6.2",
|
||||||
|
"queue": "^6.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/stylesheet": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"@react-pdf/types": "^2.11.1",
|
||||||
|
"color-string": "^2.1.4",
|
||||||
|
"hsl-to-hex": "^1.0.0",
|
||||||
|
"media-engine": "^1.0.3",
|
||||||
|
"postcss-value-parser": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/svg": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/primitives": "^4.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/textkit": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/fns": "3.1.3",
|
||||||
|
"bidi-js": "^1.0.2",
|
||||||
|
"hyphen": "^1.6.4",
|
||||||
|
"unicode-properties": "^1.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-pdf/types": {
|
||||||
|
"version": "2.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.11.1.tgz",
|
||||||
|
"integrity": "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-pdf/font": "^4.0.8",
|
||||||
|
"@react-pdf/primitives": "^4.3.0",
|
||||||
|
"@react-pdf/stylesheet": "^6.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -2822,6 +3024,15 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||||
|
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||||
|
|
@ -3406,6 +3617,12 @@
|
||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abs-svg-path": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
|
@ -3630,6 +3847,26 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.40",
|
"version": "2.10.40",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||||
|
|
@ -3643,6 +3880,15 @@
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.5",
|
"version": "1.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||||
|
|
@ -3693,6 +3939,24 @@
|
||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/browserify-zlib": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "~1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.4",
|
"version": "4.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||||
|
|
@ -3855,6 +4119,15 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
|
@ -3884,6 +4157,27 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/color-string": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-string/node_modules/color-name": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "2.20.3",
|
"version": "2.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
|
@ -4328,6 +4622,12 @@
|
||||||
"integrity": "sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==",
|
"integrity": "sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-helpers": {
|
"node_modules/dom-helpers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
|
@ -4388,6 +4688,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex-xs": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
|
@ -4850,6 +5156,15 @@
|
||||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.2",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
|
|
@ -4924,7 +5239,6 @@
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-equals": {
|
"node_modules/fast-equals": {
|
||||||
|
|
@ -4985,6 +5299,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
|
|
@ -5102,6 +5422,23 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
|
|
@ -5537,6 +5874,21 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hsl-to-hex": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hsl-to-rgb-for-reals": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hsl-to-rgb-for-reals": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
|
@ -5557,6 +5909,12 @@
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hyphen": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
|
@ -6054,6 +6412,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-weakmap": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
|
|
@ -6148,6 +6512,15 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jay-peg": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"restructure": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
|
|
@ -6158,6 +6531,12 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-md5": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -6552,6 +6931,25 @@
|
||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linebreak/node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|
@ -6636,6 +7034,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/media-engine": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
|
@ -6770,6 +7174,15 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/normalize-svg-path": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|
@ -6909,6 +7322,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -6922,6 +7341,12 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-svg-path": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
|
@ -7011,6 +7436,14 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/png-js": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
|
||||||
|
"dependencies": {
|
||||||
|
"fflate": "^0.8.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
|
|
@ -7050,6 +7483,12 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|
@ -7129,6 +7568,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/queue": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "~2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
|
@ -7410,7 +7858,6 @@
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|
@ -7448,6 +7895,12 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
|
|
@ -7924,6 +8377,15 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
|
@ -8104,6 +8566,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-arc-to-cubic-bezier": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||||
|
|
@ -8174,6 +8642,12 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
|
@ -8230,7 +8704,6 @@
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
|
|
@ -8927,6 +9400,16 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unicode-property-aliases-ecmascript": {
|
"node_modules/unicode-property-aliases-ecmascript": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
|
||||||
|
|
@ -8937,6 +9420,22 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie/node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unique-string": {
|
"node_modules/unique-string": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
|
||||||
|
|
@ -9021,6 +9520,12 @@
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
|
@ -9137,6 +9642,20 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-compatible-readable-stream": {
|
||||||
|
"version": "3.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
|
||||||
|
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite-plugin-pwa": {
|
"node_modules/vite-plugin-pwa": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz",
|
||||||
|
|
@ -9640,6 +10159,12 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yoga-layout": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
package.json
15
package.json
|
|
@ -1,24 +1,27 @@
|
||||||
{
|
{
|
||||||
"name": "assignment-monitor",
|
"name": "assignment-monitor-v2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"server": "tsx watch server/index.ts",
|
"server": "tsx watch --env-file=.env server/index.ts",
|
||||||
"dev:all": "concurrently -n web,api -c blue,green \"npm:dev\" \"npm:server\"",
|
"dev:all": "concurrently -n web,api -c blue,green \"npm:dev\" \"npm:server\"",
|
||||||
"verify-backup": "tsx server/scripts/verifyBackupRoundtrip.ts",
|
"verify-backup": "tsx --env-file=.env server/scripts/verifyBackupRoundtrip.ts",
|
||||||
|
"db:reset": "tsx --env-file=.env server/scripts/resetDevDatabase.ts",
|
||||||
|
"preload:feedback-structure": "tsx --env-file=.env server/scripts/preloadFeedbackStructure.ts",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
|
"dexie": "^4.0.11",
|
||||||
|
"express": "^4.21.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
"dexie": "^4.0.11",
|
"recharts": "^2.15.3"
|
||||||
"recharts": "^2.15.3",
|
|
||||||
"express": "^4.21.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,51 @@
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
import { mkdirSync } from 'node:fs'
|
import { mkdirSync } from 'node:fs'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join, resolve, sep } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { TABLE_DEFINITIONS } from './db/tableRegistry'
|
import { TABLE_DEFINITIONS } from './db/tableRegistry'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const dbPath = join(__dirname, 'data', 'assignment-monitor.sqlite3')
|
const projectRoot = resolve(join(__dirname, '..'))
|
||||||
|
|
||||||
|
/** V2-Defaults weichen bewusst von Produktiv (assignment-monitor.sqlite3) ab. */
|
||||||
|
const sqliteFilename = process.env.SQLITE_FILENAME?.trim() || 'assignment-monitor-v2-dev.sqlite3'
|
||||||
|
if (sqliteFilename.includes('/') || sqliteFilename.includes('\\') || sqliteFilename.includes('..')) {
|
||||||
|
throw new Error(
|
||||||
|
`[db] SAFETY: SQLITE_FILENAME darf nur ein Dateiname sein (kein Pfad): "${sqliteFilename}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = resolve(join(__dirname, 'data', sqliteFilename))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Harte Absicherung: DB darf nur unter diesem V2-Projekt liegen und niemals unter
|
||||||
|
* dem Produktiv-Ordner c:\dev\AssigmentMonitor (ohne V2-Suffix).
|
||||||
|
*/
|
||||||
|
function assertDevDbPath(resolvedDbPath: string): void {
|
||||||
|
const dbNorm = resolvedDbPath.toLowerCase()
|
||||||
|
const rootNorm = projectRoot.toLowerCase()
|
||||||
|
const rootPrefix = rootNorm.endsWith(sep) ? rootNorm : rootNorm + sep
|
||||||
|
|
||||||
|
if (!dbNorm.startsWith(rootPrefix) && dbNorm !== rootNorm) {
|
||||||
|
throw new Error(
|
||||||
|
`[db] SAFETY: DB-Pfad liegt außerhalb von AssignmentMonitorV2.\n` +
|
||||||
|
` DB: ${resolvedDbPath}\n` +
|
||||||
|
` Root: ${projectRoot}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Produktiv-Sibling: ...\AssigmentMonitor\server\data\... — V2-Pfad enthält "assigmentmonitorv2"
|
||||||
|
const looksLikeProdData =
|
||||||
|
/[/\\]assigmentmonitor[/\\]server[/\\]data[/\\]/.test(dbNorm) &&
|
||||||
|
!/[/\\]assigmentmonitorv2[/\\]/.test(dbNorm)
|
||||||
|
if (looksLikeProdData) {
|
||||||
|
throw new Error(
|
||||||
|
`[db] SAFETY: Versuch, die Produktiv-Datenbank zu öffnen wurde blockiert:\n ${resolvedDbPath}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertDevDbPath(dbPath)
|
||||||
mkdirSync(dirname(dbPath), { recursive: true })
|
mkdirSync(dirname(dbPath), { recursive: true })
|
||||||
|
|
||||||
export const db = new DatabaseSync(dbPath)
|
export const db = new DatabaseSync(dbPath)
|
||||||
|
|
@ -31,4 +71,4 @@ for (const table of TABLE_DEFINITIONS) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[db] SQLite bereit unter ${dbPath}`)
|
console.log(`[db] DEV-SQLite bereit unter ${dbPath}`)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Stelle behandelt werden.
|
* Stelle behandelt werden.
|
||||||
*/
|
*/
|
||||||
import { db } from '../database'
|
import { db } from '../database'
|
||||||
import { TABLE_DEFINITIONS, type TableDef, type ColumnCodec } from './tableRegistry'
|
import { TABLE_DEFINITIONS, type TableDef, type ColumnDef, type ColumnCodec } from './tableRegistry'
|
||||||
|
|
||||||
const tablesByName = new Map(TABLE_DEFINITIONS.map(t => [t.name, t]))
|
const tablesByName = new Map(TABLE_DEFINITIONS.map(t => [t.name, t]))
|
||||||
|
|
||||||
|
|
@ -23,16 +23,24 @@ function toBindValue(raw: unknown, codec: ColumnCodec): SqlPrimitive {
|
||||||
return raw as SqlPrimitive
|
return raw as SqlPrimitive
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromSqlValue(raw: unknown, codec: ColumnCodec): unknown {
|
function fromSqlValue(raw: unknown, codec: ColumnCodec, sqlType?: ColumnDef['sqlType']): unknown {
|
||||||
if (raw === null || raw === undefined) return null
|
if (raw === null || raw === undefined) return null
|
||||||
if (codec === 'json') return JSON.parse(raw as string)
|
if (codec === 'json') return JSON.parse(raw as string)
|
||||||
if (codec === 'bool') return raw === 1
|
if (codec === 'bool') return raw === 1
|
||||||
|
// SQLite/Affinity: INTEGER-Spalten können als string/bigint ankommen (v.a. nach TEXT→INTEGER-Schemawechsel)
|
||||||
|
if (sqlType === 'INTEGER' || sqlType === 'REAL') {
|
||||||
|
if (typeof raw === 'bigint') return Number(raw)
|
||||||
|
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isFinite(n) ? n : raw
|
||||||
|
}
|
||||||
|
}
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeRow<T>(table: TableDef, row: Record<string, unknown>): T {
|
export function decodeRow<T>(table: TableDef, row: Record<string, unknown>): T {
|
||||||
const result: Record<string, unknown> = { id: row.id }
|
const result: Record<string, unknown> = { id: row.id }
|
||||||
for (const c of table.columns) result[c.name] = fromSqlValue(row[c.name], c.codec)
|
for (const c of table.columns) result[c.name] = fromSqlValue(row[c.name], c.codec, c.sqlType)
|
||||||
return result as T
|
return result as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { getRowById, insertRowWithId, updateRow } from '../crud'
|
import { getRowById, insertRowWithId, updateRow } from '../crud'
|
||||||
import type { AppSettings } from '../../../src/db/types'
|
import type { AppSettings, RatingColorBand } from '../../../src/db/types'
|
||||||
|
import { AI_CONFIG, DEFAULT_RATING_COLOR_BANDS, normalizeRatingColorBands } from '../../../src/config/constants'
|
||||||
|
|
||||||
/** Muss mit DEFAULT_RATING_TREND_WEIGHT_STEP in src/config/constants.ts synchron gehalten werden. */
|
/** Muss mit DEFAULT_RATING_TREND_WEIGHT_STEP in src/config/constants.ts synchron gehalten werden. */
|
||||||
const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5
|
const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5
|
||||||
|
|
@ -24,3 +25,118 @@ export function saveRatingTrendWeightStep(weightStep: number): number {
|
||||||
else insertRowWithId('appSettings', 1, { ratingTrendWeightStep: weightStep })
|
else insertRowWithId('appSettings', 1, { ratingTrendWeightStep: weightStep })
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOrSeedRatingColorBands(): RatingColorBand[] {
|
||||||
|
let settings = getRowById<AppSettings>('appSettings', 1)
|
||||||
|
if (!settings) {
|
||||||
|
insertRowWithId('appSettings', 1, {
|
||||||
|
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
|
||||||
|
ratingColorBands: DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b })),
|
||||||
|
})
|
||||||
|
return DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b }))
|
||||||
|
}
|
||||||
|
if (!settings.ratingColorBands?.length) {
|
||||||
|
const seeded = DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b }))
|
||||||
|
updateRow('appSettings', 1, { ratingColorBands: seeded })
|
||||||
|
return seeded
|
||||||
|
}
|
||||||
|
return normalizeRatingColorBands(settings.ratingColorBands)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveRatingColorBands(bands: RatingColorBand[]): number {
|
||||||
|
const normalized = normalizeRatingColorBands(bands)
|
||||||
|
const existing = getRowById('appSettings', 1)
|
||||||
|
if (existing) updateRow('appSettings', 1, { ratingColorBands: normalized })
|
||||||
|
else insertRowWithId('appSettings', 1, {
|
||||||
|
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
|
||||||
|
ratingColorBands: normalized,
|
||||||
|
})
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Standard-Prompt für neue Dimensionen/"Zurücksetzen" lebt jetzt in der DB statt im Code
|
||||||
|
* (`AI_CONFIG.defaultDimensionPromptTemplate` dient nur noch als einmaliger Erstbefüllungs-Wert
|
||||||
|
* beim allerersten Zugriff auf einer Installation, siehe Config → KI-Prompts).
|
||||||
|
*/
|
||||||
|
export function getDefaultDimensionPromptTemplate(): string {
|
||||||
|
const settings = getRowById<AppSettings>('appSettings', 1)
|
||||||
|
return settings?.defaultDimensionPromptTemplate ?? AI_CONFIG.defaultDimensionPromptTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrSeedDefaultDimensionPromptTemplate(): string {
|
||||||
|
const settings = getRowById<AppSettings>('appSettings', 1)
|
||||||
|
if (!settings) {
|
||||||
|
insertRowWithId('appSettings', 1, {
|
||||||
|
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
|
||||||
|
defaultDimensionPromptTemplate: AI_CONFIG.defaultDimensionPromptTemplate,
|
||||||
|
})
|
||||||
|
return AI_CONFIG.defaultDimensionPromptTemplate
|
||||||
|
}
|
||||||
|
if (!settings.defaultDimensionPromptTemplate) {
|
||||||
|
updateRow('appSettings', 1, { defaultDimensionPromptTemplate: AI_CONFIG.defaultDimensionPromptTemplate })
|
||||||
|
return AI_CONFIG.defaultDimensionPromptTemplate
|
||||||
|
}
|
||||||
|
return settings.defaultDimensionPromptTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDefaultDimensionPromptTemplate(template: string): number {
|
||||||
|
const existing = getRowById('appSettings', 1)
|
||||||
|
if (existing) updateRow('appSettings', 1, { defaultDimensionPromptTemplate: template })
|
||||||
|
else insertRowWithId('appSettings', 1, { ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP, defaultDimensionPromptTemplate: template })
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt-Template für „🏷 Vorschläge analysieren" (selbstlernendes Kriterien-Tagging) — ein
|
||||||
|
* einziges, protokollweites Template statt eines pro Assignment-Typ, siehe CLAUDE.md. Gleiches
|
||||||
|
* Erstbefüllungs-/Persistenz-Muster wie `getOrSeedDefaultDimensionPromptTemplate`.
|
||||||
|
*/
|
||||||
|
export function getOrSeedCriterionTaggingPromptTemplate(): string {
|
||||||
|
let settings = getRowById<AppSettings>('appSettings', 1)
|
||||||
|
if (!settings) {
|
||||||
|
insertRowWithId('appSettings', 1, {
|
||||||
|
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
|
||||||
|
criterionTaggingPromptTemplate: AI_CONFIG.defaultCriterionTaggingPromptTemplate,
|
||||||
|
})
|
||||||
|
return AI_CONFIG.defaultCriterionTaggingPromptTemplate
|
||||||
|
}
|
||||||
|
if (!settings.criterionTaggingPromptTemplate) {
|
||||||
|
updateRow('appSettings', 1, { criterionTaggingPromptTemplate: AI_CONFIG.defaultCriterionTaggingPromptTemplate })
|
||||||
|
return AI_CONFIG.defaultCriterionTaggingPromptTemplate
|
||||||
|
}
|
||||||
|
return settings.criterionTaggingPromptTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCriterionTaggingPromptTemplate(template: string): number {
|
||||||
|
const existing = getRowById('appSettings', 1)
|
||||||
|
if (existing) updateRow('appSettings', 1, { criterionTaggingPromptTemplate: template })
|
||||||
|
else insertRowWithId('appSettings', 1, { ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP, criterionTaggingPromptTemplate: template })
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrSeedDefaultCategoryPromptTemplate(): string {
|
||||||
|
let settings = getRowById<AppSettings>('appSettings', 1)
|
||||||
|
if (!settings) {
|
||||||
|
insertRowWithId('appSettings', 1, {
|
||||||
|
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
|
||||||
|
defaultCategoryPromptTemplate: AI_CONFIG.defaultCategoryPromptTemplate,
|
||||||
|
})
|
||||||
|
return AI_CONFIG.defaultCategoryPromptTemplate
|
||||||
|
}
|
||||||
|
if (!settings.defaultCategoryPromptTemplate) {
|
||||||
|
updateRow('appSettings', 1, { defaultCategoryPromptTemplate: AI_CONFIG.defaultCategoryPromptTemplate })
|
||||||
|
return AI_CONFIG.defaultCategoryPromptTemplate
|
||||||
|
}
|
||||||
|
return settings.defaultCategoryPromptTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDefaultCategoryPromptTemplate(template: string): number {
|
||||||
|
const existing = getRowById('appSettings', 1)
|
||||||
|
if (existing) updateRow('appSettings', 1, { defaultCategoryPromptTemplate: template })
|
||||||
|
else insertRowWithId('appSettings', 1, {
|
||||||
|
ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP,
|
||||||
|
defaultCategoryPromptTemplate: template,
|
||||||
|
})
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAllRows, getRowById, getRowsWhereEquals, insertRow, updateRow } from '../crud'
|
import { getAllRows, getRowById, getRowsWhereEquals, insertRow, updateRow, deleteRowById } from '../crud'
|
||||||
import type {
|
import type {
|
||||||
FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText,
|
FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText,
|
||||||
} from '../../../src/db/types'
|
} from '../../../src/db/types'
|
||||||
|
|
@ -34,6 +34,9 @@ export const listFeedbackCategoryRatings = (assignmentFeedbackId: number): Feedb
|
||||||
export const addFeedbackCategoryRating = (r: Omit<FeedbackCategoryRating, 'id'>): number => insertRow('feedbackCategoryRatings', r)
|
export const addFeedbackCategoryRating = (r: Omit<FeedbackCategoryRating, 'id'>): number => insertRow('feedbackCategoryRatings', r)
|
||||||
export const updateFeedbackCategoryRating = (id: number, patch: Partial<FeedbackCategoryRating>): number =>
|
export const updateFeedbackCategoryRating = (id: number, patch: Partial<FeedbackCategoryRating>): number =>
|
||||||
updateRow('feedbackCategoryRatings', id, patch)
|
updateRow('feedbackCategoryRatings', id, patch)
|
||||||
|
export const deleteFeedbackCategoryRating = (id: number): void => {
|
||||||
|
deleteRowById('feedbackCategoryRatings', id)
|
||||||
|
}
|
||||||
|
|
||||||
export const listFeedbackDimensionTexts = (assignmentFeedbackId: number): FeedbackDimensionText[] =>
|
export const listFeedbackDimensionTexts = (assignmentFeedbackId: number): FeedbackDimensionText[] =>
|
||||||
getRowsWhereEquals<FeedbackDimensionText>('feedbackDimensionTexts', 'assignmentFeedbackId', assignmentFeedbackId)
|
getRowsWhereEquals<FeedbackDimensionText>('feedbackDimensionTexts', 'assignmentFeedbackId', assignmentFeedbackId)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById, countWhereEquals,
|
getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById, countWhereEquals,
|
||||||
} from '../crud'
|
} from '../crud'
|
||||||
import type { AssignmentType, PhaseTemplate } from '../../../src/db/types'
|
import type { AssignmentType, PhaseTemplate } from '../../../src/db/types'
|
||||||
|
import { deleteChecklistItemDefsForPhaseTemplate } from './checklists'
|
||||||
|
|
||||||
export const listAssignmentTypes = (): AssignmentType[] => getAllRows<AssignmentType>('assignmentTypes')
|
export const listAssignmentTypes = (): AssignmentType[] => getAllRows<AssignmentType>('assignmentTypes')
|
||||||
export const getAssignmentType = (id: number): AssignmentType | undefined => getRowById<AssignmentType>('assignmentTypes', id)
|
export const getAssignmentType = (id: number): AssignmentType | undefined => getRowById<AssignmentType>('assignmentTypes', id)
|
||||||
|
|
@ -14,6 +15,9 @@ export const updateAssignmentType = (id: number, patch: Partial<AssignmentType>)
|
||||||
export function deleteAssignmentType(id: number): { ok: true } | { ok: false; assignmentCount: number } {
|
export function deleteAssignmentType(id: number): { ok: true } | { ok: false; assignmentCount: number } {
|
||||||
const assignmentCount = countWhereEquals('assignments', 'assignmentTypeId', id)
|
const assignmentCount = countWhereEquals('assignments', 'assignmentTypeId', id)
|
||||||
if (assignmentCount > 0) return { ok: false, assignmentCount }
|
if (assignmentCount > 0) return { ok: false, assignmentCount }
|
||||||
|
for (const tmpl of getRowsWhereEquals<PhaseTemplate>('phaseTemplates', 'assignmentTypeId', id)) {
|
||||||
|
deleteChecklistItemDefsForPhaseTemplate(tmpl.id!)
|
||||||
|
}
|
||||||
deleteRowsWhereEquals('phaseTemplates', 'assignmentTypeId', id)
|
deleteRowsWhereEquals('phaseTemplates', 'assignmentTypeId', id)
|
||||||
deleteRowById('assignmentTypes', id)
|
deleteRowById('assignmentTypes', id)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
|
|
@ -28,4 +32,7 @@ export const listPhaseTemplatesForType = (assignmentTypeId: number): PhaseTempla
|
||||||
export const addPhaseTemplate = (tmpl: Omit<PhaseTemplate, 'id'>): number => insertRow('phaseTemplates', tmpl)
|
export const addPhaseTemplate = (tmpl: Omit<PhaseTemplate, 'id'>): number => insertRow('phaseTemplates', tmpl)
|
||||||
export const updatePhaseTemplate = (id: number, patch: Partial<PhaseTemplate>): number =>
|
export const updatePhaseTemplate = (id: number, patch: Partial<PhaseTemplate>): number =>
|
||||||
updateRow('phaseTemplates', id, patch)
|
updateRow('phaseTemplates', id, patch)
|
||||||
export const deletePhaseTemplate = (id: number): void => deleteRowById('phaseTemplates', id)
|
export function deletePhaseTemplate(id: number): void {
|
||||||
|
deleteChecklistItemDefsForPhaseTemplate(id)
|
||||||
|
deleteRowById('phaseTemplates', id)
|
||||||
|
}
|
||||||
|
|
|
||||||
63
server/db/queries/checklists.ts
Normal file
63
server/db/queries/checklists.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import {
|
||||||
|
getAllRows, getRowsWhereEquals, getRowsWhereIn, insertRow, updateRow, deleteRowById, deleteRowsWhereEquals,
|
||||||
|
} from '../crud'
|
||||||
|
import type { ChecklistItemDef, ChecklistResponse } from '../../../src/db/types'
|
||||||
|
|
||||||
|
export const listChecklistItemDefsForPhaseTemplate = (phaseTemplateId: number): ChecklistItemDef[] => {
|
||||||
|
const rows = getRowsWhereEquals<ChecklistItemDef>('checklistItemDefs', 'phaseTemplateId', phaseTemplateId)
|
||||||
|
return rows.sort((a, b) => a.order - b.order)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk-Variante für Berichte über mehrere Phasentypen hinweg (Assignment-Report-PDF, Gesamtprotokoll). */
|
||||||
|
export const listChecklistItemDefsForPhaseTemplates = (phaseTemplateIds: number[]): ChecklistItemDef[] =>
|
||||||
|
getRowsWhereIn<ChecklistItemDef>('checklistItemDefs', 'phaseTemplateId', phaseTemplateIds).sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
export const addChecklistItemDef = (def: Omit<ChecklistItemDef, 'id'>): number => insertRow('checklistItemDefs', def)
|
||||||
|
export const updateChecklistItemDef = (id: number, patch: Partial<ChecklistItemDef>): number =>
|
||||||
|
updateRow('checklistItemDefs', id, patch)
|
||||||
|
|
||||||
|
/** Räumt zugehörige `checklistResponses` mit auf — gleiches Verteidigungs-Prinzip wie `deleteFeedbackCriterionItem`. */
|
||||||
|
export function deleteChecklistItemDef(id: number): void {
|
||||||
|
deleteRowsWhereEquals('checklistResponses', 'checklistItemId', id)
|
||||||
|
deleteRowById('checklistItemDefs', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cascade-Baustein für `deletePhaseTemplate`/`deleteAssignmentType` — löscht alle Checklisten-Punkte samt Antworten einer Phase. */
|
||||||
|
export function deleteChecklistItemDefsForPhaseTemplate(phaseTemplateId: number): void {
|
||||||
|
for (const def of listChecklistItemDefsForPhaseTemplate(phaseTemplateId)) {
|
||||||
|
deleteChecklistItemDef(def.id!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listChecklistResponsesForMeeting = (meetingInstanceId: number): ChecklistResponse[] =>
|
||||||
|
getRowsWhereEquals<ChecklistResponse>('checklistResponses', 'meetingInstanceId', meetingInstanceId)
|
||||||
|
|
||||||
|
/** Bulk-Variante für Berichte über mehrere Meetings hinweg (Assignment-Report-PDF, Gesamtprotokoll). */
|
||||||
|
export const listChecklistResponsesForMeetings = (meetingInstanceIds: number[]): ChecklistResponse[] =>
|
||||||
|
getRowsWhereIn<ChecklistResponse>('checklistResponses', 'meetingInstanceId', meetingInstanceIds)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert per Komposit-Schlüssel (meetingInstanceId, checklistItemId, instituteeId ?? null), gleiches
|
||||||
|
* Find-or-Insert-Muster wie `setAssessmentScore`/`saveAssessmentNote` in MeetingView.tsx. `criterionItemId`
|
||||||
|
* überschreibt pro Antwort individuell die Konfigurations-Vorgabe (`ChecklistItemDef.linkedCriterionItemId`)
|
||||||
|
* — siehe CLAUDE.md „Individuelle Kriterium-Zuordnung pro Checklisten-Antwort".
|
||||||
|
*/
|
||||||
|
export function upsertChecklistResponse(input: {
|
||||||
|
meetingInstanceId: number
|
||||||
|
instituteeId?: number
|
||||||
|
checklistItemId: number
|
||||||
|
rating: ChecklistResponse['rating']
|
||||||
|
note: string
|
||||||
|
criterionItemId?: number
|
||||||
|
}): number {
|
||||||
|
const existing = getAllRows<ChecklistResponse>('checklistResponses').find(r =>
|
||||||
|
r.meetingInstanceId === input.meetingInstanceId &&
|
||||||
|
r.checklistItemId === input.checklistItemId &&
|
||||||
|
(r.instituteeId ?? null) === (input.instituteeId ?? null),
|
||||||
|
)
|
||||||
|
if (existing?.id) {
|
||||||
|
updateRow('checklistResponses', existing.id, { rating: input.rating, note: input.note, criterionItemId: input.criterionItemId })
|
||||||
|
return existing.id
|
||||||
|
}
|
||||||
|
return insertRow('checklistResponses', input)
|
||||||
|
}
|
||||||
28
server/db/queries/criterionSuggestions.ts
Normal file
28
server/db/queries/criterionSuggestions.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { getAllRows } from '../crud'
|
||||||
|
import type { ConversationEntry, MeetingInstance, ConversationLineTag } from '../../../src/db/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flacht `lineTags`/`generalNoteTags` über ALLE Assignments hinweg zu einem globalen
|
||||||
|
* (Zeilentext → Kriterium)-Korpus ab — Grundlage für die lokale Ähnlichkeitssuche im
|
||||||
|
* selbstlernenden Kriterien-Tagging (Client, `src/utils/criterionSuggestion.ts`). Gleiches
|
||||||
|
* Iterationsmuster wie `getCriterionUsageMap()` in `feedbackStructure.ts`, hier aber die
|
||||||
|
* Rohdaten statt aggregierter Zählungen. Rückgabetyp ist bewusst `ConversationLineTag[]` (kein
|
||||||
|
* neuer Typ) — jeder Korpus-Eintrag IST eine bereits vergebene Zeilen-Zuordnung, nur aus zwei
|
||||||
|
* Quellen (Gesprächsbeiträge + allgemeine Notizen) zusammengeführt.
|
||||||
|
*/
|
||||||
|
export function getGlobalLineTagCorpus(): ConversationLineTag[] {
|
||||||
|
const corpus: ConversationLineTag[] = []
|
||||||
|
|
||||||
|
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
|
||||||
|
for (const tag of e.lineTags ?? []) {
|
||||||
|
corpus.push({ text: tag.text, criterionItemId: tag.criterionItemId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of getAllRows<MeetingInstance>('meetingInstances')) {
|
||||||
|
for (const tag of m.generalNoteTags ?? []) {
|
||||||
|
corpus.push({ text: tag.text, criterionItemId: tag.criterionItemId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return corpus
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
import {
|
import {
|
||||||
getAllRows, getRowsWhereIn, insertRow, updateRow,
|
getAllRows, getRowsWhereIn, insertRow, updateRow, getRowById, insertRowWithId,
|
||||||
getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById,
|
getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById,
|
||||||
} from '../crud'
|
} from '../crud'
|
||||||
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, FeedbackDimensionPrompt } from '../../../src/db/types'
|
import type {
|
||||||
|
FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, FeedbackDimensionPrompt,
|
||||||
|
Assessment, ConversationEntry, MeetingInstance, Assignment, AssignmentType, PhaseTemplate, Consultant,
|
||||||
|
AppSettings,
|
||||||
|
} from '../../../src/db/types'
|
||||||
import { AI_CONFIG } from '../../../src/config/constants'
|
import { AI_CONFIG } from '../../../src/config/constants'
|
||||||
|
import { getOrSeedDefaultDimensionPromptTemplate } from './appSettings'
|
||||||
|
import {
|
||||||
|
parseFeedbackStructureMarkdown,
|
||||||
|
type FeedbackStructureSeed,
|
||||||
|
} from '../../../src/utils/feedbackStructureImport'
|
||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
export interface FeedbackStructure {
|
export interface FeedbackStructure {
|
||||||
dimensions: FeedbackDimension[]
|
dimensions: FeedbackDimension[]
|
||||||
|
|
@ -28,20 +42,311 @@ export const listFeedbackCriterionItemsAll = (): FeedbackCriterionItem[] => getA
|
||||||
export const getFeedbackCriterionItemsByIds = (ids: number[]): FeedbackCriterionItem[] =>
|
export const getFeedbackCriterionItemsByIds = (ids: number[]): FeedbackCriterionItem[] =>
|
||||||
getRowsWhereIn<FeedbackCriterionItem>('feedbackCriterionItems', 'id', ids)
|
getRowsWhereIn<FeedbackCriterionItem>('feedbackCriterionItems', 'id', ids)
|
||||||
|
|
||||||
|
export interface CriterionUsage {
|
||||||
|
assessmentCount: number
|
||||||
|
lineTagCount: number
|
||||||
|
generalNoteTagCount: number
|
||||||
|
assignmentTypeNames: string[]
|
||||||
|
phaseTemplateLabels: string[]
|
||||||
|
meetings: { meetingId: number; assignmentId: number; assignmentTitle: string; date: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwendungs-Übersicht für ALLE Kriterien in einem Durchlauf (nicht pro Kriterium einzeln) —
|
||||||
|
* macht sichtbar, wo ein Kriterium schon referenziert ist, bevor man es beim Umbau der
|
||||||
|
* Feedback-Struktur umbenennt/verschiebt/löscht. Siehe CLAUDE.md.
|
||||||
|
*/
|
||||||
|
export function getCriterionUsageMap(): Record<number, CriterionUsage> {
|
||||||
|
const usage = new Map<number, CriterionUsage>()
|
||||||
|
const ensure = (id: number): CriterionUsage => {
|
||||||
|
let u = usage.get(id)
|
||||||
|
if (!u) {
|
||||||
|
u = { assessmentCount: 0, lineTagCount: 0, generalNoteTagCount: 0, assignmentTypeNames: [], phaseTemplateLabels: [], meetings: [] }
|
||||||
|
usage.set(id, u)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
const meetings = getAllRows<MeetingInstance>('meetingInstances')
|
||||||
|
const meetingMap = new Map(meetings.map(m => [m.id!, m]))
|
||||||
|
const assignments = getAllRows<Assignment>('assignments')
|
||||||
|
const assignmentMap = new Map(assignments.map(a => [a.id!, a]))
|
||||||
|
|
||||||
|
const addMeetingRef = (u: CriterionUsage, meetingId: number) => {
|
||||||
|
if (u.meetings.some(x => x.meetingId === meetingId)) return
|
||||||
|
const m = meetingMap.get(meetingId)
|
||||||
|
if (!m) return
|
||||||
|
const a = assignmentMap.get(m.assignmentId)
|
||||||
|
u.meetings.push({ meetingId, assignmentId: m.assignmentId, assignmentTitle: a?.title ?? '—', date: m.date })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of getAllRows<Assessment>('assessments')) {
|
||||||
|
const u = ensure(a.criteriaId)
|
||||||
|
u.assessmentCount++
|
||||||
|
addMeetingRef(u, a.meetingInstanceId)
|
||||||
|
}
|
||||||
|
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
|
||||||
|
for (const tag of e.lineTags ?? []) {
|
||||||
|
const u = ensure(tag.criterionItemId)
|
||||||
|
u.lineTagCount++
|
||||||
|
addMeetingRef(u, e.meetingInstanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of meetings) {
|
||||||
|
for (const tag of m.generalNoteTags ?? []) {
|
||||||
|
const u = ensure(tag.criterionItemId)
|
||||||
|
u.generalNoteTagCount++
|
||||||
|
addMeetingRef(u, m.id!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
|
||||||
|
for (const cid of at.criteriaIds ?? []) ensure(cid).assignmentTypeNames.push(at.name)
|
||||||
|
}
|
||||||
|
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
|
||||||
|
for (const cid of pt.criteriaIds ?? []) ensure(cid).phaseTemplateLabels.push(pt.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CriterionUsageInstance =
|
||||||
|
| { kind: 'assessment'; assessmentId: number; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
|
||||||
|
| { kind: 'lineTag'; conversationEntryId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
|
||||||
|
| { kind: 'generalNoteTag'; meetingInstanceId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
|
||||||
|
| { kind: 'assignmentTypeVisibility'; assignmentTypeId: number; criterionId: number; text: string }
|
||||||
|
| { kind: 'phaseTemplateVisibility'; phaseTemplateId: number; criterionId: number; text: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelinstanzen-Liste für EIN Kriterium (im Unterschied zu `getCriterionUsageMap`, das nur
|
||||||
|
* Zählungen für ALLE Kriterien liefert) — Grundlage dafür, eine einzelne Zuordnung direkt
|
||||||
|
* umzuhängen oder zu entfernen, ohne dafür ins jeweilige Meeting navigieren zu müssen.
|
||||||
|
* Bewusst lazy/pro-Kriterium (nicht eager für alle), da hier echte Texte/Namen aufgelöst werden.
|
||||||
|
*/
|
||||||
|
export function getCriterionUsageDetails(criterionId: number): CriterionUsageInstance[] {
|
||||||
|
const instances: CriterionUsageInstance[] = []
|
||||||
|
|
||||||
|
const meetings = getAllRows<MeetingInstance>('meetingInstances')
|
||||||
|
const meetingMap = new Map(meetings.map(m => [m.id!, m]))
|
||||||
|
const assignments = getAllRows<Assignment>('assignments')
|
||||||
|
const assignmentMap = new Map(assignments.map(a => [a.id!, a]))
|
||||||
|
const consultants = getAllRows<Consultant>('consultants')
|
||||||
|
const consultantMap = new Map(consultants.map(c => [c.id!, c]))
|
||||||
|
|
||||||
|
const meetingInfo = (meetingId: number) => {
|
||||||
|
const m = meetingMap.get(meetingId)
|
||||||
|
const a = m ? assignmentMap.get(m.assignmentId) : undefined
|
||||||
|
return { meetingId, assignmentTitle: a?.title ?? '—', date: m?.date ?? '' }
|
||||||
|
}
|
||||||
|
const personLabel = (instituteeId: number | undefined) => {
|
||||||
|
if (instituteeId === undefined) return 'Allgemein'
|
||||||
|
const c = consultantMap.get(instituteeId)
|
||||||
|
return c ? `${c.firstName} ${c.lastName}` : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of getAllRows<Assessment>('assessments')) {
|
||||||
|
if (a.criteriaId !== criterionId) continue
|
||||||
|
instances.push({
|
||||||
|
kind: 'assessment', assessmentId: a.id!, personLabel: personLabel(a.instituteeId),
|
||||||
|
text: `Score: ${a.score ?? '—'}${a.note ? ` · Notiz: ${a.note}` : ''}`,
|
||||||
|
...meetingInfo(a.meetingInstanceId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
|
||||||
|
for (const tag of e.lineTags ?? []) {
|
||||||
|
if (tag.criterionItemId !== criterionId) continue
|
||||||
|
instances.push({
|
||||||
|
kind: 'lineTag', conversationEntryId: e.id!, tagText: tag.text, personLabel: personLabel(e.instituteeId),
|
||||||
|
text: tag.text,
|
||||||
|
...meetingInfo(e.meetingInstanceId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of meetings) {
|
||||||
|
for (const tag of m.generalNoteTags ?? []) {
|
||||||
|
if (tag.criterionItemId !== criterionId) continue
|
||||||
|
instances.push({
|
||||||
|
kind: 'generalNoteTag', meetingInstanceId: m.id!, tagText: tag.text, personLabel: personLabel(undefined),
|
||||||
|
text: tag.text,
|
||||||
|
...meetingInfo(m.id!),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
|
||||||
|
if (!at.criteriaIds?.includes(criterionId)) continue
|
||||||
|
instances.push({ kind: 'assignmentTypeVisibility', assignmentTypeId: at.id!, criterionId, text: at.name })
|
||||||
|
}
|
||||||
|
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
|
||||||
|
if (!pt.criteriaIds?.includes(criterionId)) continue
|
||||||
|
instances.push({ kind: 'phaseTemplateVisibility', phaseTemplateId: pt.id!, criterionId, text: pt.label })
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hängt EINE einzelne Verwendungsinstanz auf ein anderes Kriterium um (Konsolidieren ohne den ganzen Rest mitzuziehen). */
|
||||||
|
export function reassignUsageInstance(instance: CriterionUsageInstance, toId: number): { merged: boolean } {
|
||||||
|
if (instance.kind === 'assessment') {
|
||||||
|
const allAssessments = getAllRows<Assessment>('assessments')
|
||||||
|
const row = allAssessments.find(a => a.id === instance.assessmentId)
|
||||||
|
if (!row) return { merged: false }
|
||||||
|
return { merged: mergeOrReassignAssessment(row, toId, allAssessments) }
|
||||||
|
}
|
||||||
|
if (instance.kind === 'lineTag') {
|
||||||
|
const e = getAllRows<ConversationEntry>('conversationEntries').find(x => x.id === instance.conversationEntryId)
|
||||||
|
if (!e?.lineTags) return { merged: false }
|
||||||
|
const lineTags = e.lineTags.map(t => t.text === instance.tagText ? { ...t, criterionItemId: toId } : t)
|
||||||
|
updateRow('conversationEntries', e.id!, { lineTags })
|
||||||
|
return { merged: false }
|
||||||
|
}
|
||||||
|
if (instance.kind === 'generalNoteTag') {
|
||||||
|
const m = getAllRows<MeetingInstance>('meetingInstances').find(x => x.id === instance.meetingInstanceId)
|
||||||
|
if (!m?.generalNoteTags) return { merged: false }
|
||||||
|
const generalNoteTags = m.generalNoteTags.map(t => t.text === instance.tagText ? { ...t, criterionItemId: toId } : t)
|
||||||
|
updateRow('meetingInstances', m.id!, { generalNoteTags })
|
||||||
|
return { merged: false }
|
||||||
|
}
|
||||||
|
if (instance.kind === 'assignmentTypeVisibility') {
|
||||||
|
const at = getAllRows<AssignmentType>('assignmentTypes').find(x => x.id === instance.assignmentTypeId)
|
||||||
|
if (!at?.criteriaIds) return { merged: false }
|
||||||
|
const criteriaIds = [...new Set(at.criteriaIds.map(id => id === instance.criterionId ? toId : id))]
|
||||||
|
updateRow('assignmentTypes', at.id!, { criteriaIds })
|
||||||
|
return { merged: false }
|
||||||
|
}
|
||||||
|
// phaseTemplateVisibility
|
||||||
|
const pt = getAllRows<PhaseTemplate>('phaseTemplates').find(x => x.id === instance.phaseTemplateId)
|
||||||
|
if (!pt?.criteriaIds) return { merged: false }
|
||||||
|
const criteriaIds = [...new Set(pt.criteriaIds.map(id => id === instance.criterionId ? toId : id))]
|
||||||
|
updateRow('phaseTemplates', pt.id!, { criteriaIds })
|
||||||
|
return { merged: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt EINE einzelne Verwendungsinstanz (Assessment wird gelöscht, Tags nur von der Kriterium-Zuordnung befreit, Text bleibt). */
|
||||||
|
export function removeUsageInstance(instance: CriterionUsageInstance): void {
|
||||||
|
if (instance.kind === 'assessment') {
|
||||||
|
deleteRowById('assessments', instance.assessmentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (instance.kind === 'lineTag') {
|
||||||
|
const e = getAllRows<ConversationEntry>('conversationEntries').find(x => x.id === instance.conversationEntryId)
|
||||||
|
if (!e?.lineTags) return
|
||||||
|
updateRow('conversationEntries', e.id!, { lineTags: e.lineTags.filter(t => t.text !== instance.tagText) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (instance.kind === 'generalNoteTag') {
|
||||||
|
const m = getAllRows<MeetingInstance>('meetingInstances').find(x => x.id === instance.meetingInstanceId)
|
||||||
|
if (!m?.generalNoteTags) return
|
||||||
|
updateRow('meetingInstances', m.id!, { generalNoteTags: m.generalNoteTags.filter(t => t.text !== instance.tagText) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (instance.kind === 'assignmentTypeVisibility') {
|
||||||
|
const at = getAllRows<AssignmentType>('assignmentTypes').find(x => x.id === instance.assignmentTypeId)
|
||||||
|
if (!at?.criteriaIds) return
|
||||||
|
updateRow('assignmentTypes', at.id!, { criteriaIds: at.criteriaIds.filter(id => id !== instance.criterionId) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// phaseTemplateVisibility
|
||||||
|
const pt = getAllRows<PhaseTemplate>('phaseTemplates').find(x => x.id === instance.phaseTemplateId)
|
||||||
|
if (!pt?.criteriaIds) return
|
||||||
|
updateRow('phaseTemplates', pt.id!, { criteriaIds: pt.criteriaIds.filter(id => id !== instance.criterionId) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt EINE Assessment-Zeile auf `toId` um. Konfliktfall: existiert für dieselbe
|
||||||
|
* (meetingInstanceId, instituteeId) bereits eine Zeile mit criteriaId=toId (keine DB-Unique-
|
||||||
|
* Constraint verhindert das), wird `row` stattdessen in die bestehende Zeile gemergt (Notizen
|
||||||
|
* zusammengeführt, vorhandener Score der Zielzeile hat Vorrang) statt eine Dubletten-Zeile zu
|
||||||
|
* erzeugen. Von `reassignCriterionReferences` (alle Zeilen) UND `reassignUsageInstance` (eine
|
||||||
|
* einzelne Zeile) genutzt, damit die Konflikt-Logik nicht doppelt gepflegt werden muss.
|
||||||
|
*/
|
||||||
|
function mergeOrReassignAssessment(row: Assessment, toId: number, allAssessments: Assessment[]): boolean {
|
||||||
|
const conflict = allAssessments.find(a =>
|
||||||
|
a.id !== row.id && a.criteriaId === toId && a.meetingInstanceId === row.meetingInstanceId && a.instituteeId === row.instituteeId,
|
||||||
|
)
|
||||||
|
if (conflict) {
|
||||||
|
const mergedNote = [conflict.note, row.note].filter(n => n?.trim()).join('\n')
|
||||||
|
updateRow('assessments', conflict.id!, { note: mergedNote, score: conflict.score ?? row.score })
|
||||||
|
deleteRowById('assessments', row.id!)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
updateRow('assessments', row.id!, { criteriaId: toId })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Überträgt alle Referenzen von `fromId` auf `toId` (Assessments, Protokoll-/Notiz-Tags,
|
||||||
|
* Sichtbarkeits-Listen) — für sicheres Löschen mit Zusammenführung statt Datenverlust.
|
||||||
|
*/
|
||||||
|
export function reassignCriterionReferences(fromId: number, toId: number): { mergedConflicts: number } {
|
||||||
|
let mergedConflicts = 0
|
||||||
|
|
||||||
|
const allAssessments = getAllRows<Assessment>('assessments')
|
||||||
|
for (const row of allAssessments.filter(a => a.criteriaId === fromId)) {
|
||||||
|
if (mergeOrReassignAssessment(row, toId, allAssessments)) mergedConflicts++
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
|
||||||
|
if (!e.lineTags?.some(t => t.criterionItemId === fromId)) continue
|
||||||
|
const lineTags = e.lineTags.map(t => t.criterionItemId === fromId ? { ...t, criterionItemId: toId } : t)
|
||||||
|
updateRow('conversationEntries', e.id!, { lineTags })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of getAllRows<MeetingInstance>('meetingInstances')) {
|
||||||
|
if (!m.generalNoteTags?.some(t => t.criterionItemId === fromId)) continue
|
||||||
|
const generalNoteTags = m.generalNoteTags.map(t => t.criterionItemId === fromId ? { ...t, criterionItemId: toId } : t)
|
||||||
|
updateRow('meetingInstances', m.id!, { generalNoteTags })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
|
||||||
|
if (!at.criteriaIds?.includes(fromId)) continue
|
||||||
|
const criteriaIds = [...new Set(at.criteriaIds.map(id => id === fromId ? toId : id))]
|
||||||
|
updateRow('assignmentTypes', at.id!, { criteriaIds })
|
||||||
|
}
|
||||||
|
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
|
||||||
|
if (!pt.criteriaIds?.includes(fromId)) continue
|
||||||
|
const criteriaIds = [...new Set(pt.criteriaIds.map(id => id === fromId ? toId : id))]
|
||||||
|
updateRow('phaseTemplates', pt.id!, { criteriaIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mergedConflicts }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt alle Referenzen auf `id` (Assessments werden gelöscht, Tags/Sichtbarkeitslisten nur von der ID befreit). */
|
||||||
|
function clearCriterionReferences(id: number): void {
|
||||||
|
deleteRowsWhereEquals('assessments', 'criteriaId', id)
|
||||||
|
|
||||||
|
for (const e of getAllRows<ConversationEntry>('conversationEntries')) {
|
||||||
|
if (!e.lineTags?.some(t => t.criterionItemId === id)) continue
|
||||||
|
updateRow('conversationEntries', e.id!, { lineTags: e.lineTags.filter(t => t.criterionItemId !== id) })
|
||||||
|
}
|
||||||
|
for (const m of getAllRows<MeetingInstance>('meetingInstances')) {
|
||||||
|
if (!m.generalNoteTags?.some(t => t.criterionItemId === id)) continue
|
||||||
|
updateRow('meetingInstances', m.id!, { generalNoteTags: m.generalNoteTags.filter(t => t.criterionItemId !== id) })
|
||||||
|
}
|
||||||
|
for (const at of getAllRows<AssignmentType>('assignmentTypes')) {
|
||||||
|
if (!at.criteriaIds?.includes(id)) continue
|
||||||
|
updateRow('assignmentTypes', at.id!, { criteriaIds: at.criteriaIds.filter(cid => cid !== id) })
|
||||||
|
}
|
||||||
|
for (const pt of getAllRows<PhaseTemplate>('phaseTemplates')) {
|
||||||
|
if (!pt.criteriaIds?.includes(id)) continue
|
||||||
|
updateRow('phaseTemplates', pt.id!, { criteriaIds: pt.criteriaIds.filter(cid => cid !== id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Legt zusätzlich einen Default-KI-Prompt für die neue Dimension an, siehe CLAUDE.md „Mehrstufiges Prompt-System". */
|
/** Legt zusätzlich einen Default-KI-Prompt für die neue Dimension an, siehe CLAUDE.md „Mehrstufiges Prompt-System". */
|
||||||
export function addFeedbackDimension(d: Omit<FeedbackDimension, 'id'>): number {
|
export function addFeedbackDimension(d: Omit<FeedbackDimension, 'id'>): number {
|
||||||
const id = insertRow('feedbackDimensions', d)
|
const id = insertRow('feedbackDimensions', d)
|
||||||
insertRow('feedbackDimensionPrompts', { dimensionId: id, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate })
|
insertRow('feedbackDimensionPrompts', { dimensionId: id, promptTemplate: getOrSeedDefaultDimensionPromptTemplate() })
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): number =>
|
export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): number =>
|
||||||
updateRow('feedbackDimensions', id, patch)
|
updateRow('feedbackDimensions', id, patch)
|
||||||
|
|
||||||
/** Cascade: Items der Kategorien → Kategorien → Prompt-Konfiguration → Dimension. */
|
/** Cascade: Items der Kategorien (über deleteFeedbackCriterionItem, räumt Verwendungen mit auf) → Kategorien → Prompt-Konfiguration → Dimension. */
|
||||||
export function deleteFeedbackDimension(id: number): void {
|
export function deleteFeedbackDimension(id: number): void {
|
||||||
const cats = getRowsWhereEquals<FeedbackCategory>('feedbackCategories', 'dimensionId', id)
|
const cats = getRowsWhereEquals<FeedbackCategory>('feedbackCategories', 'dimensionId', id)
|
||||||
for (const cat of cats) {
|
for (const cat of cats) {
|
||||||
deleteRowsWhereEquals('feedbackCriterionItems', 'categoryId', cat.id!)
|
const catItems = getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', cat.id!)
|
||||||
|
for (const item of catItems) deleteFeedbackCriterionItem(item.id!)
|
||||||
}
|
}
|
||||||
deleteRowsWhereEquals('feedbackCategories', 'dimensionId', id)
|
deleteRowsWhereEquals('feedbackCategories', 'dimensionId', id)
|
||||||
deleteRowsWhereEquals('feedbackDimensionPrompts', 'dimensionId', id)
|
deleteRowsWhereEquals('feedbackDimensionPrompts', 'dimensionId', id)
|
||||||
|
|
@ -56,9 +361,11 @@ export function deleteFeedbackDimension(id: number): void {
|
||||||
export function listFeedbackDimensionPrompts(): FeedbackDimensionPrompt[] {
|
export function listFeedbackDimensionPrompts(): FeedbackDimensionPrompt[] {
|
||||||
const dims = getAllRows<FeedbackDimension>('feedbackDimensions')
|
const dims = getAllRows<FeedbackDimension>('feedbackDimensions')
|
||||||
const existingDimIds = new Set(getAllRows<FeedbackDimensionPrompt>('feedbackDimensionPrompts').map(p => p.dimensionId))
|
const existingDimIds = new Set(getAllRows<FeedbackDimensionPrompt>('feedbackDimensionPrompts').map(p => p.dimensionId))
|
||||||
for (const dim of dims) {
|
const missing = dims.filter(dim => !existingDimIds.has(dim.id!))
|
||||||
if (!existingDimIds.has(dim.id!)) {
|
if (missing.length > 0) {
|
||||||
insertRow('feedbackDimensionPrompts', { dimensionId: dim.id!, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate })
|
const defaultTemplate = getOrSeedDefaultDimensionPromptTemplate()
|
||||||
|
for (const dim of missing) {
|
||||||
|
insertRow('feedbackDimensionPrompts', { dimensionId: dim.id!, promptTemplate: defaultTemplate })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return getAllRows<FeedbackDimensionPrompt>('feedbackDimensionPrompts')
|
return getAllRows<FeedbackDimensionPrompt>('feedbackDimensionPrompts')
|
||||||
|
|
@ -68,24 +375,137 @@ export function listFeedbackDimensionPrompts(): FeedbackDimensionPrompt[] {
|
||||||
export function getFeedbackDimensionPrompt(dimensionId: number): FeedbackDimensionPrompt {
|
export function getFeedbackDimensionPrompt(dimensionId: number): FeedbackDimensionPrompt {
|
||||||
const existing = getRowsWhereEquals<FeedbackDimensionPrompt>('feedbackDimensionPrompts', 'dimensionId', dimensionId)[0]
|
const existing = getRowsWhereEquals<FeedbackDimensionPrompt>('feedbackDimensionPrompts', 'dimensionId', dimensionId)[0]
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const id = insertRow('feedbackDimensionPrompts', { dimensionId, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate })
|
const promptTemplate = getOrSeedDefaultDimensionPromptTemplate()
|
||||||
return { id, dimensionId, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate }
|
const id = insertRow('feedbackDimensionPrompts', { dimensionId, promptTemplate })
|
||||||
|
return { id, dimensionId, promptTemplate }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateFeedbackDimensionPrompt = (id: number, patch: Partial<FeedbackDimensionPrompt>): number =>
|
export const updateFeedbackDimensionPrompt = (id: number, patch: Partial<FeedbackDimensionPrompt>): number =>
|
||||||
updateRow('feedbackDimensionPrompts', id, patch)
|
updateRow('feedbackDimensionPrompts', id, patch)
|
||||||
|
|
||||||
|
/** Setzt alle Dimension-Prompts auf den aktuellen Code-Standard (AI_CONFIG) und synchronisiert appSettings. */
|
||||||
|
export function resetAllDimensionPromptsToDefault(): { updated: number } {
|
||||||
|
const template = AI_CONFIG.defaultDimensionPromptTemplate
|
||||||
|
const settings = getRowById<AppSettings>('appSettings', 1)
|
||||||
|
if (settings) updateRow('appSettings', 1, { defaultDimensionPromptTemplate: template })
|
||||||
|
else insertRowWithId('appSettings', 1, {
|
||||||
|
ratingTrendWeightStep: 0.5,
|
||||||
|
defaultDimensionPromptTemplate: template,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dims = getAllRows<FeedbackDimension>('feedbackDimensions')
|
||||||
|
let updated = 0
|
||||||
|
for (const dim of dims) {
|
||||||
|
const existing = getRowsWhereEquals<FeedbackDimensionPrompt>('feedbackDimensionPrompts', 'dimensionId', dim.id!)[0]
|
||||||
|
if (existing?.id) {
|
||||||
|
updateRow('feedbackDimensionPrompts', existing.id, { promptTemplate: template })
|
||||||
|
} else {
|
||||||
|
insertRow('feedbackDimensionPrompts', { dimensionId: dim.id!, promptTemplate: template })
|
||||||
|
}
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
return { updated }
|
||||||
|
}
|
||||||
|
|
||||||
export const addFeedbackCategory = (c: Omit<FeedbackCategory, 'id'>): number => insertRow('feedbackCategories', c)
|
export const addFeedbackCategory = (c: Omit<FeedbackCategory, 'id'>): number => insertRow('feedbackCategories', c)
|
||||||
export const updateFeedbackCategory = (id: number, patch: Partial<FeedbackCategory>): number =>
|
export const updateFeedbackCategory = (id: number, patch: Partial<FeedbackCategory>): number =>
|
||||||
updateRow('feedbackCategories', id, patch)
|
updateRow('feedbackCategories', id, patch)
|
||||||
|
|
||||||
/** Cascade: Items der Kategorie → Kategorie. */
|
/** Cascade: Items der Kategorie → Kategorie. */
|
||||||
export function deleteFeedbackCategory(id: number): void {
|
export function deleteFeedbackCategory(id: number): void {
|
||||||
deleteRowsWhereEquals('feedbackCriterionItems', 'categoryId', id)
|
const catItems = getRowsWhereEquals<FeedbackCriterionItem>('feedbackCriterionItems', 'categoryId', id)
|
||||||
|
for (const item of catItems) deleteFeedbackCriterionItem(item.id!)
|
||||||
deleteRowById('feedbackCategories', id)
|
deleteRowById('feedbackCategories', id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addFeedbackCriterionItem = (i: Omit<FeedbackCriterionItem, 'id'>): number => insertRow('feedbackCriterionItems', i)
|
export const addFeedbackCriterionItem = (i: Omit<FeedbackCriterionItem, 'id'>): number => insertRow('feedbackCriterionItems', i)
|
||||||
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): number =>
|
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): number =>
|
||||||
updateRow('feedbackCriterionItems', id, patch)
|
updateRow('feedbackCriterionItems', id, patch)
|
||||||
export const deleteFeedbackCriterionItem = (id: number): void => deleteRowById('feedbackCriterionItems', id)
|
|
||||||
|
/**
|
||||||
|
* Räumt vor dem eigentlichen Löschen selbst alle Verwendungen auf (Verteidigung in der Tiefe,
|
||||||
|
* unabhängig davon ob die UI vorher per reassignCriterionReferences umgehängt hat) — siehe
|
||||||
|
* clearCriterionReferences() oben. Dadurch kann nach dem Löschen nie eine verwaiste Referenz
|
||||||
|
* übrig bleiben, egal über welchen Aufrufpfad (auch Kategorie-/Dimension-Cascade).
|
||||||
|
*/
|
||||||
|
export function deleteFeedbackCriterionItem(id: number): void {
|
||||||
|
clearCriterionReferences(id)
|
||||||
|
deleteRowById('feedbackCriterionItems', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplaceFeedbackStructureResult {
|
||||||
|
dimensions: number
|
||||||
|
categories: number
|
||||||
|
items: number
|
||||||
|
clearedDimensions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt die gesamte Feedback-Struktur durch ein Seed-Objekt (MD-Import / Preload).
|
||||||
|
* Löscht bestehende Dimensionen inkl. Cascade (Kategorien, Kriterien, Verwendungen, Prompts).
|
||||||
|
*/
|
||||||
|
export function replaceFeedbackStructure(seed: FeedbackStructureSeed): ReplaceFeedbackStructureResult {
|
||||||
|
if (!seed?.dimensions?.length) throw new Error('Seed enthält keine Dimensionen.')
|
||||||
|
|
||||||
|
const existing = getAllRows<FeedbackDimension>('feedbackDimensions')
|
||||||
|
for (const dim of existing) deleteFeedbackDimension(dim.id!)
|
||||||
|
|
||||||
|
let categoryCount = 0
|
||||||
|
let itemCount = 0
|
||||||
|
for (let di = 0; di < seed.dimensions.length; di++) {
|
||||||
|
const dimSeed = seed.dimensions[di]
|
||||||
|
const dimId = addFeedbackDimension({ name: dimSeed.name, order: di + 1 })
|
||||||
|
for (let ci = 0; ci < dimSeed.categories.length; ci++) {
|
||||||
|
const catSeed = dimSeed.categories[ci]
|
||||||
|
const catId = addFeedbackCategory({
|
||||||
|
dimensionId: dimId,
|
||||||
|
name: catSeed.name,
|
||||||
|
order: ci + 1,
|
||||||
|
description: catSeed.description,
|
||||||
|
})
|
||||||
|
categoryCount++
|
||||||
|
for (let ii = 0; ii < catSeed.items.length; ii++) {
|
||||||
|
const itemSeed = catSeed.items[ii]
|
||||||
|
addFeedbackCriterionItem({
|
||||||
|
categoryId: catId,
|
||||||
|
name: itemSeed.name,
|
||||||
|
order: ii + 1,
|
||||||
|
weight: itemSeed.weight,
|
||||||
|
description: itemSeed.description,
|
||||||
|
})
|
||||||
|
itemCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dimensions: seed.dimensions.length,
|
||||||
|
categories: categoryCount,
|
||||||
|
items: itemCount,
|
||||||
|
clearedDimensions: existing.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parst Markdown (Export-Format) und ersetzt die Struktur. */
|
||||||
|
export function importFeedbackStructureFromMarkdown(md: string): ReplaceFeedbackStructureResult {
|
||||||
|
return replaceFeedbackStructure(parseFeedbackStructureMarkdown(md))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die finale Struktur aus docs/preload/Feedback-Struktur-final.md (DEV-Preload).
|
||||||
|
* Pfad relativ zum Projektroot — nur innerhalb AssigmentMonitorV2.
|
||||||
|
*/
|
||||||
|
export function preloadFinalFeedbackStructure(): ReplaceFeedbackStructureResult {
|
||||||
|
const projectRoot = resolve(join(__dirname, '../../..'))
|
||||||
|
const mdPath = resolve(join(projectRoot, 'docs', 'preload', 'Feedback-Struktur-final.md'))
|
||||||
|
const rootNorm = projectRoot.toLowerCase()
|
||||||
|
const pathNorm = mdPath.toLowerCase()
|
||||||
|
if (!pathNorm.startsWith(rootNorm)) {
|
||||||
|
throw new Error(`[preload] SAFETY: Preload-Datei außerhalb des V2-Projekts: ${mdPath}`)
|
||||||
|
}
|
||||||
|
if (!existsSync(mdPath)) {
|
||||||
|
throw new Error(`Preload-Datei fehlt: ${mdPath}`)
|
||||||
|
}
|
||||||
|
const md = readFileSync(mdPath, 'utf8')
|
||||||
|
return importFeedbackStructureFromMarkdown(md)
|
||||||
|
}
|
||||||
|
|
|
||||||
47
server/db/queries/meetingCategoryRatings.ts
Normal file
47
server/db/queries/meetingCategoryRatings.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import {
|
||||||
|
deleteRowsWhereEquals,
|
||||||
|
getAllRows,
|
||||||
|
getRowsWhereEquals,
|
||||||
|
getRowsWhereIn,
|
||||||
|
insertRow,
|
||||||
|
updateRow,
|
||||||
|
} from '../crud'
|
||||||
|
import type { MeetingCategoryRating, RatingScore } from '../../../src/db/types'
|
||||||
|
|
||||||
|
export const listMeetingCategoryRatingsForMeeting = (meetingInstanceId: number): MeetingCategoryRating[] =>
|
||||||
|
getRowsWhereEquals<MeetingCategoryRating>('meetingCategoryRatings', 'meetingInstanceId', meetingInstanceId)
|
||||||
|
|
||||||
|
export const listMeetingCategoryRatingsForMeetings = (meetingInstanceIds: number[]): MeetingCategoryRating[] =>
|
||||||
|
getRowsWhereIn<MeetingCategoryRating>('meetingCategoryRatings', 'meetingInstanceId', meetingInstanceIds)
|
||||||
|
|
||||||
|
export function upsertMeetingCategoryRating(input: {
|
||||||
|
meetingInstanceId: number
|
||||||
|
instituteeId: number
|
||||||
|
categoryId: number
|
||||||
|
score: RatingScore | null
|
||||||
|
note?: string
|
||||||
|
}): number {
|
||||||
|
const existing = getAllRows<MeetingCategoryRating>('meetingCategoryRatings').find(r =>
|
||||||
|
r.meetingInstanceId === input.meetingInstanceId &&
|
||||||
|
r.instituteeId === input.instituteeId &&
|
||||||
|
r.categoryId === input.categoryId,
|
||||||
|
)
|
||||||
|
if (existing?.id != null) {
|
||||||
|
updateRow('meetingCategoryRatings', existing.id, {
|
||||||
|
score: input.score,
|
||||||
|
note: input.note ?? existing.note ?? '',
|
||||||
|
})
|
||||||
|
return existing.id
|
||||||
|
}
|
||||||
|
return insertRow('meetingCategoryRatings', {
|
||||||
|
meetingInstanceId: input.meetingInstanceId,
|
||||||
|
instituteeId: input.instituteeId,
|
||||||
|
categoryId: input.categoryId,
|
||||||
|
score: input.score,
|
||||||
|
note: input.note ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteMeetingCategoryRatingsForMeeting = (meetingInstanceId: number): void => {
|
||||||
|
deleteRowsWhereEquals('meetingCategoryRatings', 'meetingInstanceId', meetingInstanceId)
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,11 @@ import {
|
||||||
import type {
|
import type {
|
||||||
MeetingInstance, PhaseTemplate, Assignment, Consultant,
|
MeetingInstance, PhaseTemplate, Assignment, Consultant,
|
||||||
ConversationEntry, ConversationSkillScore, Assessment,
|
ConversationEntry, ConversationSkillScore, Assessment,
|
||||||
|
ChecklistItemDef, ChecklistResponse, MeetingCategoryRating,
|
||||||
} from '../../../src/db/types'
|
} from '../../../src/db/types'
|
||||||
import { getConsultantsByIds } from './consultants'
|
import { getConsultantsByIds } from './consultants'
|
||||||
|
import { listChecklistItemDefsForPhaseTemplate, listChecklistResponsesForMeeting } from './checklists'
|
||||||
|
import { listMeetingCategoryRatingsForMeeting } from './meetingCategoryRatings'
|
||||||
|
|
||||||
export const getMeetingInstance = (id: number): MeetingInstance | undefined => getRowById<MeetingInstance>('meetingInstances', id)
|
export const getMeetingInstance = (id: number): MeetingInstance | undefined => getRowById<MeetingInstance>('meetingInstances', id)
|
||||||
export const listMeetingInstancesForAssignment = (assignmentId: number): MeetingInstance[] =>
|
export const listMeetingInstancesForAssignment = (assignmentId: number): MeetingInstance[] =>
|
||||||
|
|
@ -31,12 +34,14 @@ export const softDeleteMeetingInstance = (id: number): number =>
|
||||||
export const restoreMeetingInstance = (id: number): number =>
|
export const restoreMeetingInstance = (id: number): number =>
|
||||||
updateRow('meetingInstances', id, { deletedAt: null })
|
updateRow('meetingInstances', id, { deletedAt: null })
|
||||||
|
|
||||||
/** Cascade: conversationSkillScores der Entries → conversationEntries → assessments → meetingInstance. */
|
/** Cascade: conversationSkillScores der Entries → conversationEntries → assessments → meetingCategoryRatings → meetingInstance. */
|
||||||
export function hardDeleteMeetingInstance(id: number): void {
|
export function hardDeleteMeetingInstance(id: number): void {
|
||||||
const entryIds = getRowsWhereEquals<ConversationEntry>('conversationEntries', 'meetingInstanceId', id).map(e => e.id!)
|
const entryIds = getRowsWhereEquals<ConversationEntry>('conversationEntries', 'meetingInstanceId', id).map(e => e.id!)
|
||||||
deleteRowsWhereIn('conversationSkillScores', 'conversationEntryId', entryIds)
|
deleteRowsWhereIn('conversationSkillScores', 'conversationEntryId', entryIds)
|
||||||
deleteRowsWhereEquals('conversationEntries', 'meetingInstanceId', id)
|
deleteRowsWhereEquals('conversationEntries', 'meetingInstanceId', id)
|
||||||
deleteRowsWhereEquals('assessments', 'meetingInstanceId', id)
|
deleteRowsWhereEquals('assessments', 'meetingInstanceId', id)
|
||||||
|
deleteRowsWhereEquals('meetingCategoryRatings', 'meetingInstanceId', id)
|
||||||
|
deleteRowsWhereEquals('checklistResponses', 'meetingInstanceId', id)
|
||||||
deleteRowById('meetingInstances', id)
|
deleteRowById('meetingInstances', id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +53,9 @@ export interface MeetingExportData {
|
||||||
entries: ConversationEntry[]
|
entries: ConversationEntry[]
|
||||||
allScores: ConversationSkillScore[]
|
allScores: ConversationSkillScore[]
|
||||||
assessments: Assessment[]
|
assessments: Assessment[]
|
||||||
|
categoryRatings: MeetingCategoryRating[]
|
||||||
|
checklistItems: ChecklistItemDef[]
|
||||||
|
checklistResponses: ChecklistResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gebündelte Rohdaten für Markdown-/JSON-Export eines Meetings — vorher in meetingExport.ts dupliziert. */
|
/** Gebündelte Rohdaten für Markdown-/JSON-Export eines Meetings — vorher in meetingExport.ts dupliziert. */
|
||||||
|
|
@ -64,6 +72,12 @@ export function getMeetingExportData(meetingId: number): MeetingExportData {
|
||||||
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
|
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
|
||||||
const allScores = getAllRows<ConversationSkillScore>('conversationSkillScores')
|
const allScores = getAllRows<ConversationSkillScore>('conversationSkillScores')
|
||||||
const assessments = getRowsWhereEquals<Assessment>('assessments', 'meetingInstanceId', meetingId)
|
const assessments = getRowsWhereEquals<Assessment>('assessments', 'meetingInstanceId', meetingId)
|
||||||
|
const categoryRatings = listMeetingCategoryRatingsForMeeting(meetingId)
|
||||||
|
const checklistItems = template?.hasChecklist ? listChecklistItemDefsForPhaseTemplate(template.id!) : []
|
||||||
|
const checklistResponses = template?.hasChecklist ? listChecklistResponsesForMeeting(meetingId) : []
|
||||||
|
|
||||||
return { meeting, template, assignment, consultants, entries, allScores, assessments }
|
return {
|
||||||
|
meeting, template, assignment, consultants, entries, allScores,
|
||||||
|
assessments, categoryRatings, checklistItems, checklistResponses,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('isRepeatable', 'INTEGER', 'bool'),
|
col('isRepeatable', 'INTEGER', 'bool'),
|
||||||
col('hasConversation', 'INTEGER', 'bool'),
|
col('hasConversation', 'INTEGER', 'bool'),
|
||||||
col('hasAssessment', 'INTEGER', 'bool'),
|
col('hasAssessment', 'INTEGER', 'bool'),
|
||||||
|
col('hasChecklist', 'INTEGER', 'bool'),
|
||||||
col('criteriaIds', 'TEXT', 'json'),
|
col('criteriaIds', 'TEXT', 'json'),
|
||||||
col('order', 'INTEGER'),
|
col('order', 'INTEGER'),
|
||||||
]},
|
]},
|
||||||
|
|
@ -83,6 +84,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('status', 'TEXT'),
|
col('status', 'TEXT'),
|
||||||
col('generalNotes', 'TEXT'),
|
col('generalNotes', 'TEXT'),
|
||||||
col('deletedAt', 'TEXT'),
|
col('deletedAt', 'TEXT'),
|
||||||
|
col('generalNoteTags', 'TEXT', 'json'),
|
||||||
]},
|
]},
|
||||||
{ name: 'conversationEntries', columns: [
|
{ name: 'conversationEntries', columns: [
|
||||||
col('meetingInstanceId', 'INTEGER'),
|
col('meetingInstanceId', 'INTEGER'),
|
||||||
|
|
@ -96,16 +98,23 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
{ name: 'conversationSkillScores', columns: [
|
{ name: 'conversationSkillScores', columns: [
|
||||||
col('conversationEntryId', 'INTEGER'),
|
col('conversationEntryId', 'INTEGER'),
|
||||||
col('criteriaId', 'INTEGER'),
|
col('criteriaId', 'INTEGER'),
|
||||||
col('score', 'TEXT'),
|
col('score', 'INTEGER'),
|
||||||
]},
|
]},
|
||||||
{ name: 'assessments', columns: [
|
{ name: 'assessments', columns: [
|
||||||
col('meetingInstanceId', 'INTEGER'),
|
col('meetingInstanceId', 'INTEGER'),
|
||||||
col('instituteeId', 'INTEGER'),
|
col('instituteeId', 'INTEGER'),
|
||||||
col('criteriaId', 'INTEGER'),
|
col('criteriaId', 'INTEGER'),
|
||||||
col('score', 'TEXT'),
|
col('score', 'INTEGER'),
|
||||||
col('note', 'TEXT'),
|
col('note', 'TEXT'),
|
||||||
col('updatedAt', 'TEXT'),
|
col('updatedAt', 'TEXT'),
|
||||||
]},
|
]},
|
||||||
|
{ name: 'meetingCategoryRatings', columns: [
|
||||||
|
col('meetingInstanceId', 'INTEGER'),
|
||||||
|
col('instituteeId', 'INTEGER'),
|
||||||
|
col('categoryId', 'INTEGER'),
|
||||||
|
col('score', 'INTEGER'),
|
||||||
|
col('note', 'TEXT'),
|
||||||
|
]},
|
||||||
{ name: 'feedbackDrafts', columns: [
|
{ name: 'feedbackDrafts', columns: [
|
||||||
col('assignmentId', 'INTEGER'),
|
col('assignmentId', 'INTEGER'),
|
||||||
col('instituteeId', 'INTEGER'),
|
col('instituteeId', 'INTEGER'),
|
||||||
|
|
@ -120,6 +129,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('dimensionId', 'INTEGER'),
|
col('dimensionId', 'INTEGER'),
|
||||||
col('name', 'TEXT'),
|
col('name', 'TEXT'),
|
||||||
col('order', 'INTEGER'),
|
col('order', 'INTEGER'),
|
||||||
|
col('description', 'TEXT'),
|
||||||
]},
|
]},
|
||||||
{ name: 'feedbackCriterionItems', columns: [
|
{ name: 'feedbackCriterionItems', columns: [
|
||||||
col('categoryId', 'INTEGER'),
|
col('categoryId', 'INTEGER'),
|
||||||
|
|
@ -127,6 +137,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
col('weight', 'REAL'),
|
col('weight', 'REAL'),
|
||||||
col('order', 'INTEGER'),
|
col('order', 'INTEGER'),
|
||||||
col('description', 'TEXT'),
|
col('description', 'TEXT'),
|
||||||
|
col('learnedPatternHints', 'TEXT'),
|
||||||
]},
|
]},
|
||||||
{ name: 'assignmentFeedbacks', columns: [
|
{ name: 'assignmentFeedbacks', columns: [
|
||||||
col('assignmentId', 'INTEGER'),
|
col('assignmentId', 'INTEGER'),
|
||||||
|
|
@ -138,7 +149,7 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
{ name: 'feedbackCategoryRatings', columns: [
|
{ name: 'feedbackCategoryRatings', columns: [
|
||||||
col('assignmentFeedbackId', 'INTEGER'),
|
col('assignmentFeedbackId', 'INTEGER'),
|
||||||
col('feedbackCategoryId', 'INTEGER'),
|
col('feedbackCategoryId', 'INTEGER'),
|
||||||
col('rating', 'TEXT'),
|
col('rating', 'INTEGER'),
|
||||||
]},
|
]},
|
||||||
{ name: 'feedbackDimensionTexts', columns: [
|
{ name: 'feedbackDimensionTexts', columns: [
|
||||||
col('assignmentFeedbackId', 'INTEGER'),
|
col('assignmentFeedbackId', 'INTEGER'),
|
||||||
|
|
@ -155,8 +166,10 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
{ name: 'feedbackCategoryTexts', columns: [
|
{ name: 'feedbackCategoryTexts', columns: [
|
||||||
col('assignmentFeedbackId', 'INTEGER'),
|
col('assignmentFeedbackId', 'INTEGER'),
|
||||||
col('feedbackCategoryId', 'INTEGER'),
|
col('feedbackCategoryId', 'INTEGER'),
|
||||||
col('text', 'TEXT'),
|
col('benefits', 'TEXT'),
|
||||||
col('suggestedText', 'TEXT'),
|
col('concerns', 'TEXT'),
|
||||||
|
col('suggestedBenefits', 'TEXT'),
|
||||||
|
col('suggestedConcerns', 'TEXT'),
|
||||||
]},
|
]},
|
||||||
{ name: 'criterionCategoryMappings', columns: [
|
{ name: 'criterionCategoryMappings', columns: [
|
||||||
col('criterionId', 'INTEGER'),
|
col('criterionId', 'INTEGER'),
|
||||||
|
|
@ -164,11 +177,30 @@ export const TABLE_DEFINITIONS: TableDef[] = [
|
||||||
]},
|
]},
|
||||||
{ name: 'criterionLevelDescriptions', columns: [
|
{ name: 'criterionLevelDescriptions', columns: [
|
||||||
col('criterionItemId', 'INTEGER'),
|
col('criterionItemId', 'INTEGER'),
|
||||||
col('rating', 'TEXT'),
|
col('rating', 'INTEGER'),
|
||||||
col('text', 'TEXT'),
|
col('text', 'TEXT'),
|
||||||
]},
|
]},
|
||||||
{ name: 'appSettings', columns: [
|
{ name: 'appSettings', columns: [
|
||||||
col('ratingTrendWeightStep', 'REAL'),
|
col('ratingTrendWeightStep', 'REAL'),
|
||||||
|
col('defaultDimensionPromptTemplate', 'TEXT'),
|
||||||
|
col('defaultCategoryPromptTemplate', 'TEXT'),
|
||||||
|
col('criterionTaggingPromptTemplate', 'TEXT'),
|
||||||
|
col('ratingColorBands', 'TEXT', 'json'),
|
||||||
|
]},
|
||||||
|
{ name: 'checklistItemDefs', columns: [
|
||||||
|
col('phaseTemplateId', 'INTEGER'),
|
||||||
|
col('label', 'TEXT'),
|
||||||
|
col('linkedCriterionItemId', 'INTEGER'),
|
||||||
|
col('scope', 'TEXT'),
|
||||||
|
col('order', 'INTEGER'),
|
||||||
|
]},
|
||||||
|
{ name: 'checklistResponses', columns: [
|
||||||
|
col('meetingInstanceId', 'INTEGER'),
|
||||||
|
col('instituteeId', 'INTEGER'),
|
||||||
|
col('checklistItemId', 'INTEGER'),
|
||||||
|
col('rating', 'INTEGER'),
|
||||||
|
col('note', 'TEXT'),
|
||||||
|
col('criterionItemId', 'INTEGER'),
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,16 @@ app.post('/api/rpc', (req, res) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const port = process.env.API_PORT ? Number(process.env.API_PORT) : 4000
|
/** V2-Default 4001 — Produktiv bleibt auf 4000. */
|
||||||
|
const port = process.env.API_PORT ? Number(process.env.API_PORT) : 4001
|
||||||
|
if (!Number.isFinite(port) || port === 4000) {
|
||||||
|
throw new Error(
|
||||||
|
`[server] SAFETY: API_PORT=${process.env.API_PORT ?? '(unset)'} ist ungültig oder kollidiert mit Produktiv-Port 4000. ` +
|
||||||
|
`V2 erwartet z.B. 4001 (siehe .env).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`[server] RPC-API läuft auf http://localhost:${port}/api/rpc`)
|
console.log(`[server] DEV-RPC-API (AssignmentMonitorV2) auf http://localhost:${port}/api/rpc`)
|
||||||
|
console.log(`[server] Produktiv (AssigmentMonitor) nutzt Port 4000 — diese Instanz ist davon getrennt.`)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ import * as consultants from './db/queries/consultants'
|
||||||
import * as assignmentTypes from './db/queries/assignmentTypes'
|
import * as assignmentTypes from './db/queries/assignmentTypes'
|
||||||
import * as assignments from './db/queries/assignments'
|
import * as assignments from './db/queries/assignments'
|
||||||
import * as feedbackStructure from './db/queries/feedbackStructure'
|
import * as feedbackStructure from './db/queries/feedbackStructure'
|
||||||
|
import * as criterionSuggestions from './db/queries/criterionSuggestions'
|
||||||
|
import * as checklists from './db/queries/checklists'
|
||||||
import * as meetings from './db/queries/meetings'
|
import * as meetings from './db/queries/meetings'
|
||||||
import * as conversationEntries from './db/queries/conversationEntries'
|
import * as conversationEntries from './db/queries/conversationEntries'
|
||||||
import * as assessments from './db/queries/assessments'
|
import * as assessments from './db/queries/assessments'
|
||||||
import * as assignmentFeedback from './db/queries/assignmentFeedback'
|
import * as assignmentFeedback from './db/queries/assignmentFeedback'
|
||||||
|
import * as meetingCategoryRatings from './db/queries/meetingCategoryRatings'
|
||||||
import * as backup from './db/queries/backup'
|
import * as backup from './db/queries/backup'
|
||||||
|
|
||||||
const modules = {
|
const modules = {
|
||||||
|
|
@ -15,10 +18,13 @@ const modules = {
|
||||||
assignmentTypes,
|
assignmentTypes,
|
||||||
assignments,
|
assignments,
|
||||||
feedbackStructure,
|
feedbackStructure,
|
||||||
|
criterionSuggestions,
|
||||||
|
checklists,
|
||||||
meetings,
|
meetings,
|
||||||
conversationEntries,
|
conversationEntries,
|
||||||
assessments,
|
assessments,
|
||||||
assignmentFeedback,
|
assignmentFeedback,
|
||||||
|
meetingCategoryRatings,
|
||||||
backup,
|
backup,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
13
server/scripts/preloadFeedbackStructure.ts
Normal file
13
server/scripts/preloadFeedbackStructure.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* CLI: finale Feedback-Struktur aus docs/preload in die Dev-SQLite laden.
|
||||||
|
* Usage: npm run preload:feedback-structure
|
||||||
|
*/
|
||||||
|
import '../database'
|
||||||
|
import { preloadFinalFeedbackStructure } from '../db/queries/feedbackStructure'
|
||||||
|
|
||||||
|
const result = preloadFinalFeedbackStructure()
|
||||||
|
console.log(
|
||||||
|
`[preload] Feedback-Struktur geladen: ${result.dimensions} Dimensionen, `
|
||||||
|
+ `${result.categories} Kategorien, ${result.items} Kriterien `
|
||||||
|
+ `(ersetzt ${result.clearedDimensions} bestehende Dimensionen)`,
|
||||||
|
)
|
||||||
42
server/scripts/resetDevDatabase.ts
Normal file
42
server/scripts/resetDevDatabase.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Setzt NUR die V2-Dev-Datenbank zurück (löscht die Datei unter server/data/).
|
||||||
|
* Produktiv (c:\dev\AssigmentMonitor) wird nicht angefasst — gleiche Pfad-Guards wie database.ts.
|
||||||
|
*
|
||||||
|
* Usage: npm run db:reset
|
||||||
|
*/
|
||||||
|
import { existsSync, unlinkSync } from 'node:fs'
|
||||||
|
import { dirname, join, resolve, sep } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const projectRoot = resolve(join(__dirname, '../..'))
|
||||||
|
const sqliteFilename = process.env.SQLITE_FILENAME?.trim() || 'assignment-monitor-v2-dev.sqlite3'
|
||||||
|
|
||||||
|
if (sqliteFilename.includes('/') || sqliteFilename.includes('\\') || sqliteFilename.includes('..')) {
|
||||||
|
throw new Error(`[db:reset] SAFETY: SQLITE_FILENAME ungültig: "${sqliteFilename}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = resolve(join(__dirname, '..', 'data', sqliteFilename))
|
||||||
|
const dbNorm = dbPath.toLowerCase()
|
||||||
|
const rootNorm = projectRoot.toLowerCase()
|
||||||
|
const rootPrefix = rootNorm.endsWith(sep) ? rootNorm : rootNorm + sep
|
||||||
|
|
||||||
|
if (!dbNorm.startsWith(rootPrefix)) {
|
||||||
|
throw new Error(`[db:reset] SAFETY: Pfad außerhalb V2: ${dbPath}`)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/[/\\]assigmentmonitor[/\\]server[/\\]data[/\\]/.test(dbNorm) &&
|
||||||
|
!/[/\\]assigmentmonitorv2[/\\]/.test(dbNorm)
|
||||||
|
) {
|
||||||
|
throw new Error(`[db:reset] SAFETY: Produktiv-DB blockiert: ${dbPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(dbPath)) {
|
||||||
|
console.log(`[db:reset] Keine Datei vorhanden — nichts zu löschen:\n ${dbPath}`)
|
||||||
|
console.log(`[db:reset] Beim nächsten Server-Start wird eine leere DB angelegt.`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlinkSync(dbPath)
|
||||||
|
console.log(`[db:reset] Dev-DB gelöscht:\n ${dbPath}`)
|
||||||
|
console.log(`[db:reset] Beim nächsten \`npm run server\` / \`npm run dev:all\` wird eine frische DB erzeugt.`)
|
||||||
85
src/components/ScorePicker.tsx
Normal file
85
src/components/ScorePicker.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import {
|
||||||
|
DEFAULT_RATING_COLOR_BANDS,
|
||||||
|
RATING_SCORES,
|
||||||
|
normalizeRatingScore,
|
||||||
|
resolveScoreStyle,
|
||||||
|
} from '../config/constants'
|
||||||
|
import type { RatingColorBand, RatingScore } from '../db/types'
|
||||||
|
|
||||||
|
interface ScorePickerProps {
|
||||||
|
value: RatingScore | null
|
||||||
|
onChange: (score: RatingScore | null) => void
|
||||||
|
bands?: RatingColorBand[]
|
||||||
|
/** 'buttons' = 1–10-Leiste, 'select' = kompaktes Dropdown */
|
||||||
|
variant?: 'buttons' | 'select'
|
||||||
|
className?: string
|
||||||
|
/** Zeigt n/a / Leeren — setzt Score auf null. */
|
||||||
|
allowClear?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScorePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
bands = DEFAULT_RATING_COLOR_BANDS,
|
||||||
|
variant = 'buttons',
|
||||||
|
className = '',
|
||||||
|
allowClear = true,
|
||||||
|
}: ScorePickerProps) {
|
||||||
|
const normalized = normalizeRatingScore(value)
|
||||||
|
|
||||||
|
if (variant === 'select') {
|
||||||
|
const style = resolveScoreStyle(normalized, bands)
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={normalized ?? ''}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value
|
||||||
|
onChange(v === '' ? null : Number(v) as RatingScore)
|
||||||
|
}}
|
||||||
|
className={`text-xs rounded-lg border px-1 py-1 focus:outline-none flex-shrink-0 tabular-nums ${
|
||||||
|
normalized !== null ? style.active : style.color
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
<option value="">{allowClear ? 'n/a' : '–'}</option>
|
||||||
|
{RATING_SCORES.map(n => (
|
||||||
|
<option key={n} value={n}>{n}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearStyle = resolveScoreStyle(null, bands)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-0.5 ${className}`}>
|
||||||
|
{allowClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Nicht bewertet / zurücksetzen"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
className={`flex-shrink-0 px-1.5 py-1.5 rounded text-xs font-bold border transition ${
|
||||||
|
normalized === null ? clearStyle.active : clearStyle.color
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
n/a
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{RATING_SCORES.map(n => {
|
||||||
|
const style = resolveScoreStyle(n, bands)
|
||||||
|
const selected = normalized === n
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(n)}
|
||||||
|
className={`flex-1 min-w-0 py-1.5 rounded text-xs font-bold border transition tabular-nums ${
|
||||||
|
selected ? style.active : style.color
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,101 @@
|
||||||
import type { FeedbackRating } from '../db/types'
|
import type { RatingBandKey, RatingColorBand, RatingScore } from '../db/types'
|
||||||
|
|
||||||
export const BRAND_COLOR = '#0070AD'
|
export const BRAND_COLOR = '#0070AD'
|
||||||
|
|
||||||
|
export const RATING_MIN = 1
|
||||||
|
export const RATING_MAX = 10
|
||||||
|
|
||||||
|
/** Kanonische Scores 1–10 — einzige Quelle für Picker/Selects. */
|
||||||
|
export const RATING_SCORES: readonly RatingScore[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kanonische Reihenfolge der Rating-Skala (nicht ändern — DB-Werte sind stabil).
|
* Abwärtskompatible Options-Liste (short/label/full = Zahl als Text).
|
||||||
* `color` = Badge/Display-State (inaktiv)
|
* Farben kommen dynamisch über `resolveScoreStyle` / Farbbänder — `color`/`active` hier nur Fallback.
|
||||||
* `active` = ausgewählter Button-State
|
|
||||||
*/
|
*/
|
||||||
export const RATING_OPTIONS: ReadonlyArray<{
|
export const RATING_OPTIONS: ReadonlyArray<{
|
||||||
value: FeedbackRating
|
value: RatingScore
|
||||||
short: string
|
short: string
|
||||||
label: string
|
label: string
|
||||||
/** Ausgeschriebene Bezeichnung fürs KI-Prompting — "Not" allein ist für ein Modell nicht aussagekräftig, "Not Client Ready" transportiert das eigentliche Bewertungsziel des Trainee-Programms. */
|
|
||||||
full: string
|
full: string
|
||||||
color: string
|
color: string
|
||||||
active: string
|
active: string
|
||||||
}> = [
|
}> = RATING_SCORES.map(n => ({
|
||||||
{ value: 'na', short: 'N/A', label: 'N/A (0)', full: 'Not Applicable', color: 'bg-gray-100 text-gray-500 border-gray-300', active: 'bg-gray-400 text-white border-gray-400' },
|
value: n,
|
||||||
{ value: 'not_client_ready', short: 'Not', label: 'Not (1)', full: 'Not Client Ready', color: 'bg-red-100 text-red-700 border-red-300', active: 'bg-red-500 text-white border-red-500' },
|
short: String(n),
|
||||||
{ value: 'partially_client_ready', short: 'Partially', label: 'Partially (2)', full: 'Partially Client Ready', color: 'bg-orange-100 text-orange-700 border-orange-300', active: 'bg-orange-500 text-white border-orange-500' },
|
label: String(n),
|
||||||
{ value: 'nearly_client_ready', short: 'Nearly', label: 'Nearly (3)', full: 'Nearly Client Ready', color: 'bg-green-100 text-green-600 border-green-300', active: 'bg-green-400 text-white border-green-400' },
|
full: String(n),
|
||||||
{ value: 'client_ready', short: 'Fully', label: 'Fully (4)', full: 'Fully Client Ready', color: 'bg-green-100 text-green-800 border-green-400', active: 'bg-green-700 text-white border-green-700' },
|
color: 'bg-gray-100 text-gray-600 border-gray-300',
|
||||||
|
active: 'bg-brand text-white border-brand',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const BAND_STYLES: Record<RatingBandKey, { color: string; active: string; hex: string }> = {
|
||||||
|
red: { color: 'bg-red-100 text-red-700 border-red-300', active: 'bg-red-500 text-white border-red-500', hex: '#ef4444' },
|
||||||
|
orange: { color: 'bg-orange-100 text-orange-700 border-orange-300', active: 'bg-orange-500 text-white border-orange-500', hex: '#f97316' },
|
||||||
|
// Unselected: light lime vs saturated green — both used bg-green-100 before and looked identical.
|
||||||
|
lightGreen: { color: 'bg-lime-50 text-lime-700 border-lime-300', active: 'bg-lime-400 text-white border-lime-400', hex: '#a3e635' },
|
||||||
|
darkGreen: { color: 'bg-green-200 text-green-900 border-green-700', active: 'bg-green-700 text-white border-green-700', hex: '#15803d' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default: 1–2 rot, 3–5 orange, 6–8 hellgrün, 9–10 dunkelgrün (siehe docs/SCALE_1_TO_10.md). */
|
||||||
|
export const DEFAULT_RATING_COLOR_BANDS: RatingColorBand[] = [
|
||||||
|
{ max: 2, key: 'red' },
|
||||||
|
{ max: 5, key: 'orange' },
|
||||||
|
{ max: 8, key: 'lightGreen' },
|
||||||
|
{ max: 10, key: 'darkGreen' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export function isValidRatingScore(value: unknown): value is RatingScore {
|
||||||
|
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN
|
||||||
|
return Number.isInteger(n) && n >= RATING_MIN && n <= RATING_MAX
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalisiert DB-/UI-Werte auf 1–10 oder null (SQLite kann INTEGER-Spalten als String liefern). */
|
||||||
|
export function normalizeRatingScore(value: unknown): RatingScore | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null
|
||||||
|
const n = typeof value === 'number' ? value : Number(value)
|
||||||
|
if (!Number.isInteger(n) || n < RATING_MIN || n > RATING_MAX) return null
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rundet Mittelwerte auf 1–10; außerhalb / NaN → null. */
|
||||||
|
export function clampRatingScore(avg: number): RatingScore | null {
|
||||||
|
if (!Number.isFinite(avg)) return null
|
||||||
|
const rounded = Math.round(avg)
|
||||||
|
if (rounded < RATING_MIN || rounded > RATING_MAX) return null
|
||||||
|
return rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRatingColorBands(bands: RatingColorBand[] | null | undefined): RatingColorBand[] {
|
||||||
|
if (!bands?.length) return DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b }))
|
||||||
|
const sorted = [...bands].sort((a, b) => a.max - b.max)
|
||||||
|
if (sorted[sorted.length - 1]?.max !== RATING_MAX) return DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b }))
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bandForScore(score: RatingScore, bands: RatingColorBand[] = DEFAULT_RATING_COLOR_BANDS): RatingColorBand {
|
||||||
|
const normalized = normalizeRatingColorBands(bands)
|
||||||
|
return normalized.find(b => score <= b.max) ?? normalized[normalized.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveScoreStyle(
|
||||||
|
score: RatingScore | null | undefined,
|
||||||
|
bands: RatingColorBand[] = DEFAULT_RATING_COLOR_BANDS,
|
||||||
|
): { value: RatingScore | null; short: string; color: string; active: string; hex: string } {
|
||||||
|
const normalized = normalizeRatingScore(score)
|
||||||
|
if (normalized === null) {
|
||||||
|
return {
|
||||||
|
value: null,
|
||||||
|
short: '–',
|
||||||
|
color: 'bg-gray-100 text-gray-500 border-gray-300',
|
||||||
|
active: 'bg-gray-400 text-white border-gray-400',
|
||||||
|
hex: '#9ca3af',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const band = bandForScore(normalized, bands)
|
||||||
|
const style = BAND_STYLES[band.key]
|
||||||
|
return { value: normalized, short: String(normalized), color: style.color, active: style.active, hex: style.hex }
|
||||||
|
}
|
||||||
|
|
||||||
/** Farb-Schemata für Teilnehmer-Badges (Index = Reihenfolge im Assignment). */
|
/** Farb-Schemata für Teilnehmer-Badges (Index = Reihenfolge im Assignment). */
|
||||||
export const INST_COLORS = [
|
export const INST_COLORS = [
|
||||||
{ bg: 'bg-blue-50', border: 'border-blue-300', tag: 'bg-blue-500', text: 'text-blue-700' },
|
{ bg: 'bg-blue-50', border: 'border-blue-300', tag: 'bg-blue-500', text: 'text-blue-700' },
|
||||||
|
|
@ -36,74 +109,120 @@ export const AI_CONFIG = {
|
||||||
localStorageKeyModel: 'ai_model',
|
localStorageKeyModel: 'ai_model',
|
||||||
defaultModel: 'openai/gpt-4o-mini',
|
defaultModel: 'openai/gpt-4o-mini',
|
||||||
openRouterBaseUrl: 'https://openrouter.ai/api/v1',
|
openRouterBaseUrl: 'https://openrouter.ai/api/v1',
|
||||||
defaultDimensionPromptTemplate: `Du bist Führungskraft und Mentor bei Capgemini. Erstelle einen professionellen, konstruktiven Beitrag zum Performance-Feedback für einen Berater/Trainee, beschränkt auf die Dimension "{{DIMENSION_NAME}}", auf Basis der folgenden Beobachtungsdaten aus echten Kundenterminen.
|
/**
|
||||||
|
* Seit 2026-07-14 nur noch einmaliger Erstbefüllungs-Wert für `appSettings.defaultDimensionPromptTemplate`
|
||||||
|
* (siehe `getOrSeedDefaultDimensionPromptTemplate()` in server/db/queries/appSettings.ts) — der tatsächlich
|
||||||
|
* aktive Standard-Prompt lebt danach in der DB und ist im Admin-Tab „KI-Prompts" editierbar, nicht mehr hier.
|
||||||
|
*/
|
||||||
|
defaultCategoryPromptTemplate: `Du bist Führungskraft bei Capgemini. Erstelle Feedback-Inhalte für EINE Kategorie auf Basis der Beobachtungsdaten aus Kundenterminen.
|
||||||
|
|
||||||
BERATER: {{BERATER_NAME}}
|
BERATER: {{BERATER_NAME}}
|
||||||
ASSIGNMENT: {{ASSIGNMENT_TITEL}} | Kunde: {{ASSIGNMENT_KUNDE}}
|
ASSIGNMENT: {{ASSIGNMENT_TITEL}} | Kunde: {{ASSIGNMENT_KUNDE}}
|
||||||
FÜLLWÖRTER GESAMT: {{FUELLWOERTER_GESAMT}}
|
DIMENSION: {{DIMENSION_NAME}}
|
||||||
|
KATEGORIE: {{KATEGORIE_NAME}}
|
||||||
|
{{KATEGORIE_ERLAEUTERUNG}}
|
||||||
|
|
||||||
KATEGORIEN DIESER DIMENSION:
|
KRITERIEN & BEOBACHTUNGEN (nur diese Kategorie):
|
||||||
{{KATEGORIEN_STRUKTUR}}
|
|
||||||
|
|
||||||
BEOBACHTETE KRITERIEN (nur mit tatsächlicher Bewertung oder Notiz):
|
|
||||||
{{KRITERIEN_SCORES}}
|
{{KRITERIEN_SCORES}}
|
||||||
|
|
||||||
MANUELLE KATEGORIE-BEWERTUNGEN:
|
MANUELLE KATEGORIE-BEWERTUNG: {{KATEGORIE_BEWERTUNG}}
|
||||||
{{KATEGORIE_BEWERTUNGEN}}
|
ENTWICKLUNG ÜBER MEETINGS: {{ENTWICKLUNG_VERLAUF}}
|
||||||
|
{{TREND_VORSCHLAG}}
|
||||||
|
|
||||||
ENTWICKLUNG ÜBER DIE LAUFZEIT (chronologisch je Kategorie):
|
GETAGGTE PROTOKOLLZEILEN (dieser Kategorie zugeordnet):
|
||||||
{{ENTWICKLUNG_VERLAUF}}
|
{{PROTOKOLL_GETAGGT}}
|
||||||
|
|
||||||
ALLGEMEINE MEETING-NOTIZEN (Berater-unabhängig, chronologisch):
|
Schreibe:
|
||||||
{{NOTIZEN_ALLGEMEIN_MEETING}}
|
- SCORE: 1–10 (Trend-Vorschlag beachten — bestätigen oder maximal ±1 abweichen)
|
||||||
|
- BENEFITS: 1–3 kurze positive Beobachtungen (Stichpunkte mit "- ")
|
||||||
|
- CONCERNS: 1–3 kurze kritische Beobachtungen (Stichpunkte mit "- "). Concerns sind Feststellungen, KEINE Handlungsempfehlungen.
|
||||||
|
|
||||||
NICHT EINEM KRITERIUM ZUGEORDNETE PROTOKOLL-NOTIZEN DIESES BERATERS (chronologisch, Bewertungs-Notation bereits als "Not/Partially/Nearly/Fully Client Ready — Text" bzw. "Kundenzitat — Text" aufgelöst):
|
Nutze ausschließlich die oben genannten Daten. Reicht die Datenlage nicht: SCORE weglassen, BENEFITS/CONCERNS mit "- n/a".
|
||||||
{{PROTOKOLL_UNZUGEORDNET}}
|
|
||||||
|
|
||||||
Schreibe für JEDE der oben gelisteten Kategorien 1-3 kurze Stichpunkte (konkret, auf Beobachtungen gestützt, jede Zeile beginnt mit "- "). Schreibe abschließend für die gesamte Dimension einen Achievements-Abschnitt (2-4 Stichpunkte, anerkennend, jede Zeile beginnt mit "- ") und einen Development-Needs-Abschnitt (2-4 Stichpunkte, jeder als konkrete Handlungsempfehlung formuliert, z.B. "Sollte darauf achten, dass ..." oder "Empfehlenswert wäre, ...", jede Zeile beginnt mit "- "). Falls eine Kategorie aus den Daten nicht beurteilbar ist, schreibe "- n/a". Beziehe erkennbare Verbesserungen/Verschlechterungen aus der Entwicklung über die Laufzeit ein. Schreibe direkt und persönlich (du-Form), vermeide generische Phrasen und ganze Fließtext-Absätze.
|
Antwortformat (Marker in eigener Zeile oder mit Text in derselben Zeile), ohne Markdown-Codefences:
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH in exakt diesem Format, ohne zusätzliche Erklärungen, ohne Markdown-Formatierung:
|
SCORE: <1-10>
|
||||||
|
BENEFITS:
|
||||||
KATEGORIE: <Name der ersten Kategorie>
|
|
||||||
- <Stichpunkt>
|
|
||||||
- <Stichpunkt>
|
- <Stichpunkt>
|
||||||
|
CONCERNS:
|
||||||
|
- <Stichpunkt>`,
|
||||||
|
defaultDimensionPromptTemplate: `Du bist Führungskraft bei Capgemini. Verdichte die folgenden Kategorie-Feedbacks zu übergreifenden Dimension-Hauptpunkten — ohne Rohprotokoll, nur auf Basis der Kategorie-Ergebnisse.
|
||||||
|
|
||||||
KATEGORIE: <Name der zweiten Kategorie>
|
BERATER: {{BERATER_NAME}}
|
||||||
- <Stichpunkt>
|
ASSIGNMENT: {{ASSIGNMENT_TITEL}} | Kunde: {{ASSIGNMENT_KUNDE}}
|
||||||
|
DIMENSION: {{DIMENSION_NAME}}
|
||||||
|
|
||||||
|
KATEGORIE-ERGEBNISSE:
|
||||||
|
{{KATEGORIE_ERGEBNISSE}}
|
||||||
|
|
||||||
|
Schreibe für die gesamte Dimension:
|
||||||
|
- ACHIEVEMENTS: genau 3 bis 5 übergreifende Benefits (keine bloße Wiederholung einzelner Kategorien)
|
||||||
|
- DEVELOPMENT_NEEDS: genau 3 bis 5 übergreifende Concerns als Beobachtungen (keine Empfehlungsformeln wie "Sollte …")
|
||||||
|
|
||||||
|
Nutze ausschließlich die Kategorie-Ergebnisse oben. Antwortformat:
|
||||||
|
|
||||||
ACHIEVEMENTS:
|
ACHIEVEMENTS:
|
||||||
- <Stichpunkt>
|
- <Hauptpunkt>
|
||||||
- <Stichpunkt>
|
|
||||||
|
|
||||||
DEVELOPMENT_NEEDS:
|
DEVELOPMENT_NEEDS:
|
||||||
- <Handlungsempfehlung, z.B. "Sollte darauf achten, dass ...">
|
- <Beobachtung>`,
|
||||||
- <Handlungsempfehlung>`,
|
categoryPromptPlaceholders: [
|
||||||
|
{ token: '{{BERATER_NAME}}', description: 'Vor- und Nachname des Institutee' },
|
||||||
|
{ token: '{{ASSIGNMENT_TITEL}}', description: 'Titel des Assignments' },
|
||||||
|
{ token: '{{ASSIGNMENT_KUNDE}}', description: 'Kundenname des Assignments' },
|
||||||
|
{ token: '{{DIMENSION_NAME}}', description: 'Name der übergeordneten Dimension' },
|
||||||
|
{ token: '{{KATEGORIE_NAME}}', description: 'Name der aktuellen Kategorie' },
|
||||||
|
{ token: '{{KATEGORIE_ERLAEUTERUNG}}', description: 'Optional: Erläuterung der Kategorie' },
|
||||||
|
{ token: '{{KRITERIEN_SCORES}}', description: 'Kriterien dieser Kategorie mit Ø-Score und Notizen' },
|
||||||
|
{ token: '{{KATEGORIE_BEWERTUNG}}', description: 'Manuelle Kategorie-Bewertung (1–10)' },
|
||||||
|
{ token: '{{ENTWICKLUNG_VERLAUF}}', description: 'Meeting-Verlauf dieser Kategorie' },
|
||||||
|
{ token: '{{TREND_VORSCHLAG}}', description: 'Vorgeschlagener Score aus Meeting-Trend (±1 Regel)' },
|
||||||
|
{ token: '{{PROTOKOLL_GETAGGT}}', description: 'Protokollzeilen, die Kriterien dieser Kategorie zugeordnet sind' },
|
||||||
|
],
|
||||||
dimensionPromptPlaceholders: [
|
dimensionPromptPlaceholders: [
|
||||||
{ token: '{{BERATER_NAME}}', description: 'Vor- und Nachname des Institutee' },
|
{ token: '{{BERATER_NAME}}', description: 'Vor- und Nachname des Institutee' },
|
||||||
{ token: '{{ASSIGNMENT_TITEL}}', description: 'Titel des Assignments' },
|
{ token: '{{ASSIGNMENT_TITEL}}', description: 'Titel des Assignments' },
|
||||||
{ token: '{{ASSIGNMENT_KUNDE}}', description: 'Kundenname des Assignments' },
|
{ token: '{{ASSIGNMENT_KUNDE}}', description: 'Kundenname des Assignments' },
|
||||||
{ token: '{{DIMENSION_NAME}}', description: 'Name der aktuellen Dimension' },
|
{ token: '{{DIMENSION_NAME}}', description: 'Name der aktuellen Dimension' },
|
||||||
{ token: '{{KATEGORIEN_STRUKTUR}}', description: 'Liste der Kategorienamen dieser Dimension' },
|
{ token: '{{KATEGORIE_ERGEBNISSE}}', description: 'Scores + Benefits/Concerns aller Kategorien (Stufe A)' },
|
||||||
{ token: '{{KRITERIEN_SCORES}}', description: 'Kriterien mit Ø-Score und zusammengeführten Notizen, gruppiert nach Kategorie' },
|
],
|
||||||
{ token: '{{KATEGORIE_BEWERTUNGEN}}', description: 'Manuell gesetzte Kategorie-Bewertungen (Not/Partially/Nearly/Fully)' },
|
defaultCriterionTaggingPromptTemplate: `Du bist Assistent bei der Auswertung von Kundentermin-Protokollen für Capgemini-Trainees (Institutees). Analysiere die folgenden noch nicht einem Bewertungskriterium zugeordneten Protokollzeilen und schlage pro Zeile passende Kriterien vor.
|
||||||
{ token: '{{ENTWICKLUNG_VERLAUF}}', description: 'Chronologischer Bewertungs-Trend je Kategorie über die Meetings' },
|
|
||||||
{ token: '{{NOTIZEN_ALLGEMEIN_MEETING}}', description: 'Allgemeine, Berater-unabhängige Notizen je Meeting, chronologisch' },
|
ASSIGNMENT-TYP: {{ASSIGNMENT_TYP}}
|
||||||
{ token: '{{PROTOKOLL_UNZUGEORDNET}}', description: 'Nicht getaggte Protokollzeilen dieses Beraters, chronologisch je Meeting' },
|
|
||||||
{ token: '{{FUELLWOERTER_GESAMT}}', description: 'Füllwörter-Summe dieses Beraters über alle Meetings' },
|
VERFÜGBARE KRITERIEN (Dimension → Kategorie → Kriterium, referenziert über die Zahl in eckigen Klammern):
|
||||||
|
{{KRITERIEN_STRUKTUR}}
|
||||||
|
|
||||||
|
BEREITS BEKANNTE, TYPISCHE FORMULIERUNGSMUSTER PRO KRITERIUM (aus früheren Analyse-Läufen, sofern vorhanden):
|
||||||
|
{{GELERNTE_MUSTER}}
|
||||||
|
|
||||||
|
ÄHNLICHE, BEREITS ZUGEORDNETE ALT-ZEILEN (Kontext-Hinweis, keine feste Vorgabe):
|
||||||
|
{{AEHNLICHE_ALT_ZUORDNUNGEN}}
|
||||||
|
|
||||||
|
NOCH NICHT ZUGEORDNETE PROTOKOLLZEILEN (nummeriert):
|
||||||
|
{{PROTOKOLL_ZEILEN}}
|
||||||
|
|
||||||
|
Ordne jede Zeile 0 bis 3 Kriterien zu, priorisiert nach Passung (wahrscheinlichstes zuerst). Nutze ausschließlich die oben gelisteten Kriterium-IDs. Wenn keine sinnvolle Zuordnung erkennbar ist, gib "-" an. Erfinde keine Kriterien und keine Beobachtungen, die nicht im Zeilentext stehen.
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH in exakt diesem Format, ohne zusätzliche Erklärungen, ohne Markdown-Formatierung:
|
||||||
|
|
||||||
|
ZEILE <Nummer>: <Kriterium-ID>, <Kriterium-ID>, ...
|
||||||
|
ZEILE <Nummer>: -
|
||||||
|
|
||||||
|
Ergänze danach, nur für Kriterien mit mindestens einer Zuordnung in dieser Analyse, kurze generalisierte Formulierungsmuster (1-2 Sätze, keine Zitate einzelner Zeilen):
|
||||||
|
|
||||||
|
MUSTER:
|
||||||
|
<Kriterium-ID>: <kurze Beschreibung typischer Formulierungen für dieses Kriterium>`,
|
||||||
|
criterionTaggingPromptPlaceholders: [
|
||||||
|
{ token: '{{ASSIGNMENT_TYP}}', description: 'Name des Assignment-Typs des aktuellen Meetings' },
|
||||||
|
{ token: '{{KRITERIEN_STRUKTUR}}', description: 'ID-referenzierte Struktur Dimension → Kategorie → Kriterium, gefiltert auf die im Assignment-Typ sichtbaren Kriterien, inkl. aller Beschreibungen' },
|
||||||
|
{ token: '{{GELERNTE_MUSTER}}', description: 'KI-destillierte Formulierungsmuster pro Kriterium aus früheren Analyse-Läufen (Nebenprodukt, siehe FeedbackCriterionItem.learnedPatternHints)' },
|
||||||
|
{ token: '{{AEHNLICHE_ALT_ZUORDNUNGEN}}', description: 'Lokal (ohne KI) ermittelte, ähnlichste bereits getaggte Alt-Zeilen je noch offener Zeile' },
|
||||||
|
{ token: '{{PROTOKOLL_ZEILEN}}', description: 'Nummerierte, noch nicht zugeordnete Zeilen des gesamten offenen Protokolls (Gesprächsbeiträge + allgemeine Notizen)' },
|
||||||
],
|
],
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Numerische Gewichte für Rating-Aggregation. N/A wird aus Berechnungen ausgeschlossen. */
|
|
||||||
export const RATING_NUM_MAP: Record<FeedbackRating, number> = {
|
|
||||||
na: 0,
|
|
||||||
not_client_ready: 1,
|
|
||||||
partially_client_ready: 2,
|
|
||||||
nearly_client_ready: 3,
|
|
||||||
client_ready: 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default-Zeitgewichtung für den Bewertungs-Vorschlag, überschreibbar über Config > Gewichtung. */
|
/** Default-Zeitgewichtung für den Bewertungs-Vorschlag, überschreibbar über Config > Gewichtung. */
|
||||||
export const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5
|
export const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5
|
||||||
|
|
||||||
/** Mindest-Punktedelta zwischen erstem und letztem bewerteten Meeting einer Kategorie, ab dem ein Trend gilt. */
|
/** Mindest-Punktedelta auf der 1–10-Skala zwischen erstem und letztem Meeting, ab dem ein Trend gilt. */
|
||||||
export const RATING_TREND_DELTA_THRESHOLD = 0.5
|
export const RATING_TREND_DELTA_THRESHOLD = 1
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,29 @@
|
||||||
import { rpc } from '../rpcClient'
|
import { rpc } from '../rpcClient'
|
||||||
|
import type { RatingColorBand } from '../types'
|
||||||
|
|
||||||
export const getRatingTrendWeightStep = (): Promise<number> => rpc('appSettings', 'getRatingTrendWeightStep')
|
export const getRatingTrendWeightStep = (): Promise<number> => rpc('appSettings', 'getRatingTrendWeightStep')
|
||||||
export const getOrSeedRatingTrendWeightStep = (): Promise<number> => rpc('appSettings', 'getOrSeedRatingTrendWeightStep')
|
export const getOrSeedRatingTrendWeightStep = (): Promise<number> => rpc('appSettings', 'getOrSeedRatingTrendWeightStep')
|
||||||
export const saveRatingTrendWeightStep = (weightStep: number): Promise<number> =>
|
export const saveRatingTrendWeightStep = (weightStep: number): Promise<number> =>
|
||||||
rpc('appSettings', 'saveRatingTrendWeightStep', weightStep)
|
rpc('appSettings', 'saveRatingTrendWeightStep', weightStep)
|
||||||
|
|
||||||
|
export const getOrSeedRatingColorBands = (): Promise<RatingColorBand[]> =>
|
||||||
|
rpc('appSettings', 'getOrSeedRatingColorBands')
|
||||||
|
export const saveRatingColorBands = (bands: RatingColorBand[]): Promise<number> =>
|
||||||
|
rpc('appSettings', 'saveRatingColorBands', bands)
|
||||||
|
|
||||||
|
export const getDefaultDimensionPromptTemplate = (): Promise<string> =>
|
||||||
|
rpc('appSettings', 'getDefaultDimensionPromptTemplate')
|
||||||
|
export const getOrSeedDefaultDimensionPromptTemplate = (): Promise<string> =>
|
||||||
|
rpc('appSettings', 'getOrSeedDefaultDimensionPromptTemplate')
|
||||||
|
export const saveDefaultDimensionPromptTemplate = (template: string): Promise<number> =>
|
||||||
|
rpc('appSettings', 'saveDefaultDimensionPromptTemplate', template)
|
||||||
|
|
||||||
|
export const getOrSeedCriterionTaggingPromptTemplate = (): Promise<string> =>
|
||||||
|
rpc('appSettings', 'getOrSeedCriterionTaggingPromptTemplate')
|
||||||
|
export const saveCriterionTaggingPromptTemplate = (template: string): Promise<number> =>
|
||||||
|
rpc('appSettings', 'saveCriterionTaggingPromptTemplate', template)
|
||||||
|
|
||||||
|
export const getOrSeedDefaultCategoryPromptTemplate = (): Promise<string> =>
|
||||||
|
rpc('appSettings', 'getOrSeedDefaultCategoryPromptTemplate')
|
||||||
|
export const saveDefaultCategoryPromptTemplate = (template: string): Promise<number> =>
|
||||||
|
rpc('appSettings', 'saveDefaultCategoryPromptTemplate', template)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ export const addFeedbackCategoryRating = (r: Omit<FeedbackCategoryRating, 'id'>)
|
||||||
rpc('assignmentFeedback', 'addFeedbackCategoryRating', r)
|
rpc('assignmentFeedback', 'addFeedbackCategoryRating', r)
|
||||||
export const updateFeedbackCategoryRating = (id: number, patch: Partial<FeedbackCategoryRating>): Promise<number> =>
|
export const updateFeedbackCategoryRating = (id: number, patch: Partial<FeedbackCategoryRating>): Promise<number> =>
|
||||||
rpc('assignmentFeedback', 'updateFeedbackCategoryRating', id, patch)
|
rpc('assignmentFeedback', 'updateFeedbackCategoryRating', id, patch)
|
||||||
|
export const deleteFeedbackCategoryRating = (id: number): Promise<void> =>
|
||||||
|
rpc('assignmentFeedback', 'deleteFeedbackCategoryRating', id)
|
||||||
|
|
||||||
export const listFeedbackDimensionTexts = (assignmentFeedbackId: number): Promise<FeedbackDimensionText[]> =>
|
export const listFeedbackDimensionTexts = (assignmentFeedbackId: number): Promise<FeedbackDimensionText[]> =>
|
||||||
rpc('assignmentFeedback', 'listFeedbackDimensionTexts', assignmentFeedbackId)
|
rpc('assignmentFeedback', 'listFeedbackDimensionTexts', assignmentFeedbackId)
|
||||||
|
|
|
||||||
27
src/db/queries/checklists.ts
Normal file
27
src/db/queries/checklists.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { rpc } from '../rpcClient'
|
||||||
|
import type { ChecklistItemDef, ChecklistResponse } from '../types'
|
||||||
|
|
||||||
|
export const listChecklistItemDefsForPhaseTemplate = (phaseTemplateId: number): Promise<ChecklistItemDef[]> =>
|
||||||
|
rpc('checklists', 'listChecklistItemDefsForPhaseTemplate', phaseTemplateId)
|
||||||
|
export const listChecklistItemDefsForPhaseTemplates = (phaseTemplateIds: number[]): Promise<ChecklistItemDef[]> =>
|
||||||
|
rpc('checklists', 'listChecklistItemDefsForPhaseTemplates', phaseTemplateIds)
|
||||||
|
export const addChecklistItemDef = (def: Omit<ChecklistItemDef, 'id'>): Promise<number> =>
|
||||||
|
rpc('checklists', 'addChecklistItemDef', def)
|
||||||
|
export const updateChecklistItemDef = (id: number, patch: Partial<ChecklistItemDef>): Promise<number> =>
|
||||||
|
rpc('checklists', 'updateChecklistItemDef', id, patch)
|
||||||
|
export const deleteChecklistItemDef = (id: number): Promise<void> =>
|
||||||
|
rpc('checklists', 'deleteChecklistItemDef', id)
|
||||||
|
|
||||||
|
export const listChecklistResponsesForMeeting = (meetingInstanceId: number): Promise<ChecklistResponse[]> =>
|
||||||
|
rpc('checklists', 'listChecklistResponsesForMeeting', meetingInstanceId)
|
||||||
|
export const listChecklistResponsesForMeetings = (meetingInstanceIds: number[]): Promise<ChecklistResponse[]> =>
|
||||||
|
rpc('checklists', 'listChecklistResponsesForMeetings', meetingInstanceIds)
|
||||||
|
|
||||||
|
export const upsertChecklistResponse = (input: {
|
||||||
|
meetingInstanceId: number
|
||||||
|
instituteeId?: number
|
||||||
|
checklistItemId: number
|
||||||
|
rating: ChecklistResponse['rating']
|
||||||
|
note: string
|
||||||
|
criterionItemId?: number
|
||||||
|
}): Promise<number> => rpc('checklists', 'upsertChecklistResponse', input)
|
||||||
6
src/db/queries/criterionSuggestions.ts
Normal file
6
src/db/queries/criterionSuggestions.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { rpc } from '../rpcClient'
|
||||||
|
import type { ConversationLineTag } from '../types'
|
||||||
|
|
||||||
|
/** Globaler (Zeilentext → Kriterium)-Korpus über alle Assignments — siehe server/db/queries/criterionSuggestions.ts. */
|
||||||
|
export const getGlobalLineTagCorpus = (): Promise<ConversationLineTag[]> =>
|
||||||
|
rpc('criterionSuggestions', 'getGlobalLineTagCorpus')
|
||||||
|
|
@ -33,6 +33,36 @@ export function filterVisibleFeedbackStructure<
|
||||||
return { categories, items }
|
return { categories, items }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kanonische Sichtbarkeit für Assignment-Typ: Dimension → Kategorie → Kriterien.
|
||||||
|
* Kategorien ohne sichtbare Items und Dimensionen ohne sichtbare Kategorien entfallen.
|
||||||
|
* Leere/undefined criteriaIds = alles sichtbar. Baut auf `filterVisibleFeedbackStructure`.
|
||||||
|
*/
|
||||||
|
export function resolveVisibleStructure<
|
||||||
|
D extends { id?: number },
|
||||||
|
C extends { id?: number; dimensionId: number },
|
||||||
|
I extends { id?: number; categoryId: number },
|
||||||
|
>(
|
||||||
|
allDimensions: D[],
|
||||||
|
allCategories: C[],
|
||||||
|
allItems: I[],
|
||||||
|
criteriaIds: number[] | undefined,
|
||||||
|
): { dimensions: D[]; categories: C[]; items: I[] } {
|
||||||
|
const { categories, items } = filterVisibleFeedbackStructure(allCategories, allItems, criteriaIds)
|
||||||
|
const visibleDimIds = new Set(categories.map(c => c.dimensionId))
|
||||||
|
const dimensions = allDimensions.filter(d => visibleDimIds.has(d.id!))
|
||||||
|
return { dimensions, categories, items }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Kriterium-IDs der gegebenen Kategorien — für Config „Kategorie/Dimension wählen → criteriaIds“. */
|
||||||
|
export function criterionIdsForCategories(
|
||||||
|
categoryIds: number[],
|
||||||
|
allItems: { id?: number; categoryId: number }[],
|
||||||
|
): number[] {
|
||||||
|
const catSet = new Set(categoryIds)
|
||||||
|
return allItems.filter(i => catSet.has(i.categoryId) && i.id != null).map(i => i.id!)
|
||||||
|
}
|
||||||
|
|
||||||
export const addFeedbackDimension = (d: Omit<FeedbackDimension, 'id'>): Promise<number> =>
|
export const addFeedbackDimension = (d: Omit<FeedbackDimension, 'id'>): Promise<number> =>
|
||||||
rpc('feedbackStructure', 'addFeedbackDimension', d)
|
rpc('feedbackStructure', 'addFeedbackDimension', d)
|
||||||
export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): Promise<number> =>
|
export const updateFeedbackDimension = (id: number, patch: Partial<FeedbackDimension>): Promise<number> =>
|
||||||
|
|
@ -53,8 +83,45 @@ export const addFeedbackCriterionItem = (i: Omit<FeedbackCriterionItem, 'id'>):
|
||||||
rpc('feedbackStructure', 'addFeedbackCriterionItem', i)
|
rpc('feedbackStructure', 'addFeedbackCriterionItem', i)
|
||||||
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): Promise<number> =>
|
export const updateFeedbackCriterionItem = (id: number, patch: Partial<FeedbackCriterionItem>): Promise<number> =>
|
||||||
rpc('feedbackStructure', 'updateFeedbackCriterionItem', id, patch)
|
rpc('feedbackStructure', 'updateFeedbackCriterionItem', id, patch)
|
||||||
|
/** Räumt vorher selbst alle Verwendungen auf (Assessments/Tags/Sichtbarkeitslisten) — siehe CLAUDE.md. */
|
||||||
export const deleteFeedbackCriterionItem = (id: number): Promise<void> => rpc('feedbackStructure', 'deleteFeedbackCriterionItem', id)
|
export const deleteFeedbackCriterionItem = (id: number): Promise<void> => rpc('feedbackStructure', 'deleteFeedbackCriterionItem', id)
|
||||||
|
|
||||||
|
export interface CriterionUsage {
|
||||||
|
assessmentCount: number
|
||||||
|
lineTagCount: number
|
||||||
|
generalNoteTagCount: number
|
||||||
|
assignmentTypeNames: string[]
|
||||||
|
phaseTemplateLabels: string[]
|
||||||
|
meetings: { meetingId: number; assignmentId: number; assignmentTitle: string; date: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verwendungs-Übersicht für alle Kriterien in einem Rutsch — siehe CLAUDE.md „Kriterien-Umbau". */
|
||||||
|
export const getCriterionUsageMap = (): Promise<Record<number, CriterionUsage>> =>
|
||||||
|
rpc('feedbackStructure', 'getCriterionUsageMap')
|
||||||
|
|
||||||
|
/** Überträgt alle Referenzen von `fromId` auf `toId` (für sicheres Löschen mit Zusammenführung statt Datenverlust). */
|
||||||
|
export const reassignCriterionReferences = (fromId: number, toId: number): Promise<{ mergedConflicts: number }> =>
|
||||||
|
rpc('feedbackStructure', 'reassignCriterionReferences', fromId, toId)
|
||||||
|
|
||||||
|
export type CriterionUsageInstance =
|
||||||
|
| { kind: 'assessment'; assessmentId: number; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
|
||||||
|
| { kind: 'lineTag'; conversationEntryId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
|
||||||
|
| { kind: 'generalNoteTag'; meetingInstanceId: number; tagText: string; meetingId: number; assignmentTitle: string; date: string; personLabel: string; text: string }
|
||||||
|
| { kind: 'assignmentTypeVisibility'; assignmentTypeId: number; criterionId: number; text: string }
|
||||||
|
| { kind: 'phaseTemplateVisibility'; phaseTemplateId: number; criterionId: number; text: string }
|
||||||
|
|
||||||
|
/** Einzelinstanzen-Liste für EIN Kriterium — Grundlage, um eine Zuordnung direkt umzuhängen/zu entfernen. */
|
||||||
|
export const getCriterionUsageDetails = (criterionId: number): Promise<CriterionUsageInstance[]> =>
|
||||||
|
rpc('feedbackStructure', 'getCriterionUsageDetails', criterionId)
|
||||||
|
|
||||||
|
/** Hängt EINE einzelne Verwendungsinstanz auf ein anderes Kriterium um. */
|
||||||
|
export const reassignUsageInstance = (instance: CriterionUsageInstance, toId: number): Promise<{ merged: boolean }> =>
|
||||||
|
rpc('feedbackStructure', 'reassignUsageInstance', instance, toId)
|
||||||
|
|
||||||
|
/** Entfernt EINE einzelne Verwendungsinstanz (Assessment wird gelöscht, Tags nur von der Zuordnung befreit). */
|
||||||
|
export const removeUsageInstance = (instance: CriterionUsageInstance): Promise<void> =>
|
||||||
|
rpc('feedbackStructure', 'removeUsageInstance', instance)
|
||||||
|
|
||||||
export const listFeedbackDimensionPrompts = (): Promise<FeedbackDimensionPrompt[]> =>
|
export const listFeedbackDimensionPrompts = (): Promise<FeedbackDimensionPrompt[]> =>
|
||||||
rpc('feedbackStructure', 'listFeedbackDimensionPrompts')
|
rpc('feedbackStructure', 'listFeedbackDimensionPrompts')
|
||||||
/** Selbstheilend — legt serverseitig bei Bedarf eine fehlende Prompt-Zeile mit Default-Template an. */
|
/** Selbstheilend — legt serverseitig bei Bedarf eine fehlende Prompt-Zeile mit Default-Template an. */
|
||||||
|
|
@ -62,3 +129,26 @@ export const getFeedbackDimensionPrompt = (dimensionId: number): Promise<Feedbac
|
||||||
rpc('feedbackStructure', 'getFeedbackDimensionPrompt', dimensionId)
|
rpc('feedbackStructure', 'getFeedbackDimensionPrompt', dimensionId)
|
||||||
export const updateFeedbackDimensionPrompt = (id: number, patch: Partial<FeedbackDimensionPrompt>): Promise<number> =>
|
export const updateFeedbackDimensionPrompt = (id: number, patch: Partial<FeedbackDimensionPrompt>): Promise<number> =>
|
||||||
rpc('feedbackStructure', 'updateFeedbackDimensionPrompt', id, patch)
|
rpc('feedbackStructure', 'updateFeedbackDimensionPrompt', id, patch)
|
||||||
|
|
||||||
|
export const resetAllDimensionPromptsToDefault = (): Promise<{ updated: number }> =>
|
||||||
|
rpc('feedbackStructure', 'resetAllDimensionPromptsToDefault')
|
||||||
|
|
||||||
|
export type { FeedbackStructureSeed } from '../../utils/feedbackStructureImport'
|
||||||
|
export type ReplaceFeedbackStructureResult = {
|
||||||
|
dimensions: number
|
||||||
|
categories: number
|
||||||
|
items: number
|
||||||
|
clearedDimensions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ersetzt die gesamte Feedback-Struktur durch ein Seed-Objekt. */
|
||||||
|
export const replaceFeedbackStructure = (seed: import('../../utils/feedbackStructureImport').FeedbackStructureSeed): Promise<ReplaceFeedbackStructureResult> =>
|
||||||
|
rpc('feedbackStructure', 'replaceFeedbackStructure', seed)
|
||||||
|
|
||||||
|
/** Parst Markdown (Export-Format) und ersetzt die Struktur. */
|
||||||
|
export const importFeedbackStructureFromMarkdown = (md: string): Promise<ReplaceFeedbackStructureResult> =>
|
||||||
|
rpc('feedbackStructure', 'importFeedbackStructureFromMarkdown', md)
|
||||||
|
|
||||||
|
/** Lädt docs/preload/Feedback-Struktur-final.md in die Dev-DB. */
|
||||||
|
export const preloadFinalFeedbackStructure = (): Promise<ReplaceFeedbackStructureResult> =>
|
||||||
|
rpc('feedbackStructure', 'preloadFinalFeedbackStructure')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ export * from './consultants'
|
||||||
export * from './assignmentTypes'
|
export * from './assignmentTypes'
|
||||||
export * from './assignments'
|
export * from './assignments'
|
||||||
export * from './feedbackStructure'
|
export * from './feedbackStructure'
|
||||||
|
export * from './criterionSuggestions'
|
||||||
|
export * from './checklists'
|
||||||
export * from './meetings'
|
export * from './meetings'
|
||||||
export * from './conversationEntries'
|
export * from './conversationEntries'
|
||||||
export * from './assessments'
|
export * from './assessments'
|
||||||
export * from './assignmentFeedback'
|
export * from './assignmentFeedback'
|
||||||
|
export * from './meetingCategoryRatings'
|
||||||
|
|
|
||||||
16
src/db/queries/meetingCategoryRatings.ts
Normal file
16
src/db/queries/meetingCategoryRatings.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { rpc } from '../rpcClient'
|
||||||
|
import type { MeetingCategoryRating, RatingScore } from '../types'
|
||||||
|
|
||||||
|
export const listMeetingCategoryRatingsForMeeting = (meetingInstanceId: number): Promise<MeetingCategoryRating[]> =>
|
||||||
|
rpc('meetingCategoryRatings', 'listMeetingCategoryRatingsForMeeting', meetingInstanceId)
|
||||||
|
|
||||||
|
export const listMeetingCategoryRatingsForMeetings = (meetingInstanceIds: number[]): Promise<MeetingCategoryRating[]> =>
|
||||||
|
rpc('meetingCategoryRatings', 'listMeetingCategoryRatingsForMeetings', meetingInstanceIds)
|
||||||
|
|
||||||
|
export const upsertMeetingCategoryRating = (input: {
|
||||||
|
meetingInstanceId: number
|
||||||
|
instituteeId: number
|
||||||
|
categoryId: number
|
||||||
|
score: RatingScore | null
|
||||||
|
note?: string
|
||||||
|
}): Promise<number> => rpc('meetingCategoryRatings', 'upsertMeetingCategoryRating', input)
|
||||||
|
|
@ -2,6 +2,7 @@ import { rpc } from '../rpcClient'
|
||||||
import type {
|
import type {
|
||||||
MeetingInstance, PhaseTemplate, Assignment, Consultant,
|
MeetingInstance, PhaseTemplate, Assignment, Consultant,
|
||||||
ConversationEntry, ConversationSkillScore, Assessment,
|
ConversationEntry, ConversationSkillScore, Assessment,
|
||||||
|
ChecklistItemDef, ChecklistResponse, MeetingCategoryRating,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
export const getMeetingInstance = (id: number): Promise<MeetingInstance | undefined> => rpc('meetings', 'getMeetingInstance', id)
|
export const getMeetingInstance = (id: number): Promise<MeetingInstance | undefined> => rpc('meetings', 'getMeetingInstance', id)
|
||||||
|
|
@ -32,6 +33,9 @@ export interface MeetingExportData {
|
||||||
entries: ConversationEntry[]
|
entries: ConversationEntry[]
|
||||||
allScores: ConversationSkillScore[]
|
allScores: ConversationSkillScore[]
|
||||||
assessments: Assessment[]
|
assessments: Assessment[]
|
||||||
|
categoryRatings: MeetingCategoryRating[]
|
||||||
|
checklistItems: ChecklistItemDef[]
|
||||||
|
checklistResponses: ChecklistResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gebündelte Rohdaten für Markdown-/JSON-Export eines Meetings — vorher in meetingExport.ts dupliziert. */
|
/** Gebündelte Rohdaten für Markdown-/JSON-Export eines Meetings — vorher in meetingExport.ts dupliziert. */
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,25 @@
|
||||||
* unverändert über den Vite-Dev-Proxy UND später hinter einem Reverse Proxy/Docker funktioniert.
|
* unverändert über den Vite-Dev-Proxy UND später hinter einem Reverse Proxy/Docker funktioniert.
|
||||||
*/
|
*/
|
||||||
export async function rpc<T>(module: string, fn: string, ...args: unknown[]): Promise<T> {
|
export async function rpc<T>(module: string, fn: string, ...args: unknown[]): Promise<T> {
|
||||||
const res = await fetch('/api/rpc', {
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch('/api/rpc', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ module, fn, args }),
|
body: JSON.stringify({ module, fn, args }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`RPC-Fehler: ${res.status} ${res.statusText}`)
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`API nicht erreichbar (${err instanceof Error ? err.message : String(err)}). `
|
||||||
|
+ `Läuft der Dev-Server? In AssigmentMonitorV2: npm run dev:all (API-Port 4001).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const hint = res.status === 502 || res.status === 504 || res.status === 500
|
||||||
|
? ` — vermutlich läuft die API auf Port 4001 nicht (npm run server / npm run dev:all).`
|
||||||
|
: ''
|
||||||
|
throw new Error(`RPC-Fehler: ${res.status} ${res.statusText}${hint}`)
|
||||||
|
}
|
||||||
const body = await res.json() as { ok: boolean; result?: T; error?: string }
|
const body = await res.json() as { ok: boolean; result?: T; error?: string }
|
||||||
if (!body.ok) throw new Error(body.error ?? 'RPC-Fehler')
|
if (!body.ok) throw new Error(body.error ?? 'RPC-Fehler')
|
||||||
return body.result as T
|
return body.result as T
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export async function seedIfEmpty() {
|
||||||
isRepeatable: opts.repeatable ?? false,
|
isRepeatable: opts.repeatable ?? false,
|
||||||
hasConversation: opts.conv ?? false,
|
hasConversation: opts.conv ?? false,
|
||||||
hasAssessment: opts.assess ?? false,
|
hasAssessment: opts.assess ?? false,
|
||||||
|
hasChecklist: false,
|
||||||
criteriaIds: opts.criteriaIds ?? [],
|
criteriaIds: opts.criteriaIds ?? [],
|
||||||
order: opts.order,
|
order: opts.order,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
660
src/db/seeds/feedbackStructureFinal.json
Normal file
660
src/db/seeds/feedbackStructureFinal.json
Normal file
|
|
@ -0,0 +1,660 @@
|
||||||
|
{
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"name": "Client-Readiness",
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"name": "Problem Structuring",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Problem- und Zielklarheit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Klärt Ausgangslage, eigentliches Problem, angestrebtes Ergebnis und relevante Entscheidung, bevor mit der Bearbeitung begonnen wird."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Scope-Klarheit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Grenzt Untersuchungsgegenstand, Betrachtungszeitraum, Stakeholder, Annahmen und Nicht-Ziele nachvollziehbar ab."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Logische Zerlegung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Zerlegt komplexe Fragestellungen in verständliche, trennscharfe und gemeinsam hinreichend vollständige Teilfragen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hypothesenorientierung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Formuliert frühzeitig plausible, überprüfbare Hypothesen und nutzt sie zur Steuerung der weiteren Analyse."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Priorisierung nach Relevanz",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Fokussiert die Struktur auf die entscheidungs- und wirkungsrelevanten Fragestellungen und vermeidet unnötige Analysebreite."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Treiber- und Abhängigkeitsverständnis",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Erkennt wesentliche Ursachen, Wirkzusammenhänge, Abhängigkeiten und Wechselwirkungen innerhalb der Fragestellung."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ableitung eines Analyseplans",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Übersetzt die Problemstruktur in konkrete Arbeitsschritte, benötigte Informationen, Verantwortlichkeiten und sinnvolle Prüfsequenzen."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Übersetzt eine zunächst offene oder komplexe Kundenfragestellung in eine präzise, logisch gegliederte und bearbeitbare Problemstruktur, die eine fokussierte Analyse und zielgerichtete Entscheidungsfindung ermöglicht."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Problem Solving",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Fakten- und Evidenzorientierung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Stützt Aussagen auf belastbare Daten, Quellen, Interviews, Analysen oder transparent hergeleitete Annahmen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Methodengerechte Analyse",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Wählt geeignete qualitative und quantitative Methoden aus und wendet sie fachlich korrekt und zweckmäßig an."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kritische Prüfung von Informationen",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Hinterfragt Datenqualität, Quellen, Annahmen, Widersprüche und mögliche Verzerrungen, statt Ergebnisse ungeprüft zu übernehmen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hypothesenprüfung und Iteration",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Prüft Hypothesen systematisch, verwirft oder schärft sie anhand neuer Erkenntnisse und passt das Vorgehen entsprechend an."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Entwicklung von Lösungsoptionen",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Entwickelt mehrere relevante Handlungsoptionen und macht deren Unterschiede, Vor- und Nachteile sowie Konsequenzen sichtbar."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Impact- und Value-Orientierung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Richtet Analyse, Vorgehen und Lösung auf den höchsten realistischen Kundennutzen aus. Hinterfragt bei Bedarf den ursprünglich gewünschten Deliverable-Typ und entwickelt wirkungsvollere Alternativen, ohne Ziel, Scope, Risiken oder Umsetzbarkeit eigenmächtig zu verändern."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Machbarkeit und Realitätsnähe",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Berücksichtigt organisatorische, technische, wirtschaftliche und zeitliche Rahmenbedingungen bei der Lösungsentwicklung."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Entscheidungsorientierte Empfehlung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Verdichtet Analyseergebnisse zu einer klaren Empfehlung mit Begründung, Auswirkungen, Risiken und nächsten Schritten."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Bearbeitet strukturierte Fragestellungen analytisch belastbar, pragmatisch und ergebnisorientiert. Entwickelt aus Fakten und Erkenntnissen umsetzbare Optionen, Entscheidungen und Empfehlungen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Storylining",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Answer First / Kernaussage",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Stellt die zentrale Aussage, Empfehlung oder Antwort frühzeitig und eindeutig heraus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Logische Argumentationsstruktur",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Ordnet Aussagen so, dass Schlussfolgerungen nachvollziehbar aus Fakten, Analysen und Teilargumenten hervorgehen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Relevanz und Fokus",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Beschränkt die Storyline auf Inhalte, die für Fragestellung, Zielgruppe und Entscheidung tatsächlich erforderlich sind."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belegführung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Verknüpft Kernaussagen sichtbar mit belastbaren Fakten, Analysen und nachvollziehbaren Begründungen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Managementgerechte Dramaturgie",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Führt verständlich von Ausgangslage und Handlungsdruck über Erkenntnisse zu Empfehlung, Wirkung und nächstem Schritt."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Konsistenz über Medien und Beiträge",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Hält Botschaften, Begriffe, Zahlen und Argumentationslogik über Folien, Dokumente und mündliche Beiträge hinweg widerspruchsfrei."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Verdichtet komplexe Inhalte zu einer logisch schlüssigen, adressatengerechten und entscheidungsorientierten Argumentationslinie, die Kernbotschaften klar transportiert."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Precise Communication",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Active Listening",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Hört aufmerksam zu, erfasst Inhalt und Kontext und greift relevante Aussagen, Bedenken und Signale gezielt auf."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Präzise Fragetechnik",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Stellt klare, relevante und aufeinander aufbauende Fragen, die Verständnis, Erkenntnisgewinn oder Entscheidung fördern."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "To the Point",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Formuliert die wesentliche Aussage ohne unnötige Vorrede, Wiederholungen oder Nebenpfade."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Adressatengerechte Kommunikation",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Passt Tonalität, Detailtiefe, Begriffswahl und Kommunikationsstil an Zielgruppe, Hierarchie und Situation an."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sprachliche Klarheit und Verbindlichkeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Verwendet eindeutige Formulierungen und vermeidet unnötige Füllwörter, Weichmacher, Mehrdeutigkeit und Scheingenauigkeit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Recap und Verständnissicherung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Fasst Kernaussagen, Entscheidungen, offene Punkte und nächste Schritte prägnant zusammen und prüft gemeinsames Verständnis."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Souveräne Präsenz",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Tritt ruhig, vorbereitet, klar und entscheidungsorientiert auf. Behält auch bei kritischen Rückfragen, Widerspruch oder unvollständigen Informationen die Handlungsfähigkeit, unterscheidet sicher zwischen Fakten, Annahmen und offenen Punkten und vermittelt Orientierung, ohne Sicherheit vorzutäuschen."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Nimmt Informationen aktiv auf und kommuniziert mündlich wie schriftlich klar, prägnant, adressatengerecht und handlungsorientiert."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Client Intimacy Building",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Verständnis des Kundenkontexts",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Erfasst Ziele, Geschäftsmodell, Prioritäten, Restriktionen, Entscheidungslogiken und relevante Rahmenbedingungen des Kunden."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Stakeholderverständnis",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Erkennt Interessen, Einfluss, Erwartungen und mögliche Widerstände relevanter Stakeholder und berücksichtigt sie im Vorgehen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vertrauensaufbau und Beziehungsqualität",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Schafft durch Verlässlichkeit, Integrität, professionelles Verhalten und respektvolle Zusammenarbeit eine belastbare Arbeitsbeziehung."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fachliche Glaubwürdigkeit und Kompetenzwahrnehmung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Wird durch fachliche Substanz, Kundenverständnis, klare Einordnung und belastbare Empfehlungen als relevanter und kompetenter Ansprechpartner wahrgenommen. Gibt Orientierung und macht zugleich Grenzen des eigenen Wissens transparent."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Aktives Erwartungsmanagement",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Klärt Ziele, Rollen, Umfang, Annahmen, Risiken und Liefererwartungen und steuert Abweichungen frühzeitig nach."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Active Advisory und Customer Steering",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Führt den Kunden aktiv durch Fragestellungen, Optionen, Konsequenzen und notwendige Entscheidungen. Spricht klare Empfehlungen aus und verhindert eine rein ausführende Rolle, ohne die Entscheidungsverantwortung des Kunden zu übergehen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Konstruktives Challengen",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Hinterfragt Annahmen oder Kundenwünsche respektvoll, faktenbasiert und lösungsorientiert, wenn dies dem Ergebnis dient."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Beziehungsentwicklung über den Einzeltermin hinaus",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Pflegt relevante Kontakte, erkennt Anschlussbedarfe und schafft Kontinuität in der Zusammenarbeit, ohne aufdringlich oder rein vertriebsorientiert zu handeln."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Baut professionelle, vertrauensvolle Kundenbeziehungen auf, versteht fachliche und persönliche Stakeholderbedürfnisse und entwickelt sich vom Ausführenden zum wirksamen Berater."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Quality of Deliverables",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Fachliche Richtigkeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Inhalte, Berechnungen, Aussagen und Schlussfolgerungen sind fachlich korrekt und belastbar."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Inhaltliche Vollständigkeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Deckt die für Fragestellung und Entscheidung relevanten Aspekte, Perspektiven und Auswirkungen angemessen ab."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Logische Konsistenz",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Stellt sicher, dass Aussagen, Zahlen, Argumente und Empfehlungen in sich sowie untereinander widerspruchsfrei sind."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kundenspezifische Konkretisierung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Überträgt generische Erkenntnisse sichtbar auf Situation, Sprache, Daten und Handlungsrealität des Kunden."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Informationsdichte und Nutzbarkeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Wählt eine angemessene Detailtiefe und ermöglicht dem Adressaten, Kernaussagen und Handlungsbedarf schnell zu erfassen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Visualisierungs- und Darstellungsqualität",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Nutzt Struktur, Layout, Tabellen, Grafiken und Visualisierungen so, dass sie Verständnis und Botschaft wirksam unterstützen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Formale Qualität und Standardkonformität",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Hält relevante Templates, Designvorgaben, Sprache, Quellenangaben, Versionierung und Dokumentationsstandards konsequent ein."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Erstellt fachlich belastbare, konsistente und professionell aufbereitete Arbeitsergebnisse, die für den Kunden verständlich, relevant und unmittelbar nutzbar sind."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Proposal Writing & Value Proposition Design",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Verständnis von Bedarf und Buying Context",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Erfasst Problem, Auslöser, Zielbild, Entscheidungskriterien, Stakeholder und Beschaffungskontext hinter der Anfrage."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Klare Value Proposition",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Formuliert prägnant, welchen konkreten Nutzen und welche angestrebten Ergebnisse das Angebot für den Kunden erzeugt."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kundenspezifische Differenzierung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Verbindet Kundenbedarf mit relevanten Fähigkeiten, Erfahrungen, Assets und unterscheidbaren Stärken des eigenen Angebots."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Schlüssiger Scope und Deliverables",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Beschreibt Leistungsumfang, Ergebnisse, Grenzen und Verantwortlichkeiten klar und vermeidet Interpretationsspielräume."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Plausibles Vorgehensmodell",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Entwickelt einen nachvollziehbaren Ansatz mit Arbeitspaketen, Meilensteinen, Beteiligten und Entscheidungszeitpunkten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belastbare Annahmen und Machbarkeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Macht Voraussetzungen, Abhängigkeiten, Risiken und Mitwirkungspflichten transparent und verspricht nur realistisch Lieferbares."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kommerzielles Verständnis",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Berücksichtigt Aufwand, Preislogik, Wirtschaftlichkeit, Risiken und den für beide Seiten tragfähigen Zuschnitt des Angebots."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Überzeugende Angebotsstory",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Verbindet Kundensituation, Handlungsdruck, Lösung, Nutzen, Vorgehen und Glaubwürdigkeit zu einer konsistenten Entscheidungsgrundlage."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Übersetzt einen Kundenbedarf in ein überzeugendes, differenziertes und realistisch lieferbares Leistungsversprechen mit klarer Wirkung, Vorgehensweise und Nutzenargumentation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Workshop Design & Facilitation",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Ziel- und Ergebnisdefinition",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Klärt vorab, welches konkrete Ergebnis, welche Entscheidung oder welcher Arbeitsfortschritt im Workshop erreicht werden soll."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Teilnehmer- und Stakeholderdesign",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Bindet die für Inhalt, Entscheidung und Umsetzung relevanten Personen mit passenden Rollen ein."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dramaturgie und Methodenauswahl",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Entwickelt eine logische Agenda und wählt Methoden, die Ziel, Teilnehmergruppe, Zeit und Arbeitsmodus unterstützen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vorbereitung und Arbeitsfähigkeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Stellt Inhalte, Leitfragen, Daten, Materialien, Technik, Rollen und organisatorische Voraussetzungen rechtzeitig bereit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zielgerichtete Moderation",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Führt verständlich durch den Prozess, hält Fokus und Zeit und macht Übergänge sowie Arbeitsaufträge klar."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Aktivierung und Gruppendynamik",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Fördert ausgewogene Beteiligung, geht konstruktiv mit Dominanz, Zurückhaltung, Konflikten und Abschweifungen um."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ergebnissicherung und Follow-up",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Verdichtet Diskussionen, dokumentiert Entscheidungen, Verantwortlichkeiten und nächste Schritte und stellt eine klare Nachbereitung sicher."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Konzipiert und moderiert Workshops so, dass relevante Beteiligte fokussiert zusammenarbeiten, belastbare Ergebnisse erzielen und klare Entscheidungen oder nächste Schritte vereinbaren."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AI-Readiness",
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"name": "AI Fundamentals",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Grundverständnis generativer KI",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Erklärt in angemessener Tiefe, wie Modelle, Trainingsdaten, Kontext, Prompts und probabilistische Ausgabe die Ergebnisse beeinflussen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Use-Case-Eignung beurteilen",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Erkennt, für welche Aufgaben KI sinnvoll ist und wo menschliche Expertise, klassische Analyse oder andere Werkzeuge geeigneter sind."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Grenzen und Fehlerbilder erkennen",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Berücksichtigt insbesondere Halluzinationen, Bias, fehlende Aktualität, Scheingenauigkeit, Kontextverlust und unvollständige Quellenlage."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Datenschutz und Vertraulichkeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Behandelt Kunden-, Personen- und Unternehmensdaten entsprechend den geltenden Sicherheits-, Datenschutz- und Vertraulichkeitsvorgaben."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Responsible-AI-Verständnis",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Berücksichtigt ethische, rechtliche, sicherheitsbezogene und gesellschaftliche Auswirkungen beim Einsatz von KI."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Qualitätssicherung von KI-Ergebnissen",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Prüft Ergebnisse fachlich, logisch, methodisch und quellenbezogen und übernimmt keine KI-Ausgabe ungeprüft."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Versteht Funktionsweise, Einsatzvoraussetzungen, Chancen, Grenzen und Risiken von KI auf einem für die Beratung angemessenen Niveau und handelt verantwortungsvoll."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AI-Augmented Consulting",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Zielgerichtete Tool- und Use-Case-Auswahl",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Wählt KI-Werkzeug und Einsatzform passend zu Aufgabe, Datenlage, Risiko und erwartetem Mehrwert aus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kontext- und Promptgestaltung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Formuliert klare Arbeitsaufträge, stellt relevanten Kontext bereit und verbessert Ergebnisse iterativ durch geeignete Steuerung."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KI-gestützte Recherche und Verdichtung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Nutzt KI zur Informationsstrukturierung und Synthese, überprüft jedoch Quellen, Aktualität und Vollständigkeit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KI-gestützte Analyse und Hypothesenbildung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Nutzt KI zur Entwicklung von Analysefragen, Hypothesen, Strukturierungsansätzen oder Auswertungsoptionen und validiert diese eigenständig."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KI-gestützte Storyline- und Deliverable-Erstellung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Erstellt mit KI erste Entwürfe für Storylines, Texte, Folien oder Arbeitsmaterialien und veredelt sie fachlich sowie kundenspezifisch."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KI-gestützte Meeting- und Workshopvorbereitung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Nutzt KI für Interviewleitfäden, Fragen, Szenarien, Zusammenfassungen oder Moderationsvorbereitung und prüft die Praxistauglichkeit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Systematische Workflow-Integration",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Identifiziert wiederkehrende Arbeitsschritte, integriert KI reproduzierbar und dokumentiert Vorgehen, Qualitätskontrollen und menschliche Entscheidungen angemessen."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Integriert KI gezielt und verantwortungsvoll in typische Beratungsaufgaben, um Qualität, Geschwindigkeit und Erkenntnisgewinn zu verbessern, ohne die Verantwortung für Ergebnisse abzugeben."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AI Certification",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Vorgegebene Basiszertifizierung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Hat die für die Rolle oder Entwicklungsstufe vorgeschriebene grundlegende KI-Zertifizierung erfolgreich abgeschlossen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rollen- oder technologiespezifische Zertifizierung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Verfügt über die für Einsatzgebiet, Technologie oder Projektkontext relevanten weiterführenden Nachweise, soweit diese gefordert sind."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Aktualität des Nachweises",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Hält verpflichtende Zertifizierungen, Rezertifizierungen und vorgeschriebene Lernstände aktuell."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Nachgewiesener Praxistransfer",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Kann zentrale Inhalte der Zertifizierung in Übungen oder Assignments korrekt anwenden und deren Grenzen erläutern."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Dokumentiert formale, rollenbezogene KI-Qualifikationsnachweise. Da Zertifizierungen primär einen Nachweisstatus und kein beobachtbares Verhalten darstellen, sollten diese Kriterien möglichst als erfüllt, nicht erfüllt oder nicht erforderlich geführt werden."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Culture-Readiness",
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"name": "Quality Excellence",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Qualitätsanspruch",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Richtet die eigene Arbeit sichtbar auf fachlich belastbare, verständliche und kundentaugliche Ergebnisse aus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Systematische Qualitätschecks",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Prüft Inhalte, Zahlen, Logik, Sprache, Quellen, Format und Konsistenz vor Weitergabe anhand geeigneter Kontrollschritte."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Prozess- und Standardtreue",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Kennt relevante Prozesse, Methoden, Templates und Vorgaben und wendet sie zweckmäßig sowie nachvollziehbar an."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reviewfähigkeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Bereitet Arbeitsergebnisse so vor, dass andere sie effizient prüfen können, und arbeitet Reviewhinweise sauber und vollständig ein."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frühzeitige Qualitätssicherung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Plant Reviews, Abstimmungen und Prüfschritte rechtzeitig ein, statt Qualität ausschließlich am Ende herzustellen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Integrität und Compliance",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Handelt ehrlich und regelkonform, macht Unsicherheiten oder Fehler transparent und wahrt Vertraulichkeit sowie relevante fachliche und organisatorische Vorgaben."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kontinuierliche Verbesserung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Erkennt wiederkehrende Fehler oder Ineffizienzen und verbessert Vorgehen, Standards oder Hilfsmittel nachhaltig."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Verankert Qualität als kontinuierliche Arbeitsweise und sorgt durch Standards, Reviews und kritische Prüfung dafür, dass belastbare Ergebnisse reproduzierbar entstehen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Drive (Extra Mile)",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Proaktive Initiative",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Erkennt Handlungsbedarf oder Chancen und wird selbstständig aktiv, ohne auf detaillierte Einzelaufträge zu warten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ambitionierter Wirkungsanspruch",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Strebt nicht nur formale Erledigung, sondern ein erkennbar nützliches und überzeugendes Ergebnis an."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Beharrlichkeit bei Hindernissen",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Verfolgt wichtige Themen trotz Rückschlägen konstruktiv weiter und sucht aktiv nach alternativen Lösungswegen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mehrwert über den Auftrag hinaus",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Identifiziert sinnvolle zusätzliche Beiträge, Impulse oder wiederverwendbare Lösungen, ohne Scope, Prioritäten oder Wirtschaftlichkeit aus dem Blick zu verlieren."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anpassungsfähigkeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Bleibt bei veränderten Prioritäten, unvollständigen Informationen oder neuen Anforderungen handlungsfähig und richtet das Vorgehen neu aus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Konstruktive Energie unter Druck",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Bewahrt auch bei hoher Belastung Fokus, Lösungsorientierung und professionelles Verhalten."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Zeigt wirkungsorientierte Initiative, Ausdauer und Gestaltungswillen und leistet bei relevantem Mehrwert bewusst mehr als das formal Geforderte. Bewertet wird nicht die Anzahl der Arbeitsstunden, sondern der zusätzliche Beitrag zum Ergebnis."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ownership",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Auftrags- und Ergebnisverständnis",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Klärt Ziel, Qualitätsmaßstab, Umfang, Rollen, Abhängigkeiten und erwartete Lieferung, bevor die Umsetzung fortschreitet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Eigenständige Arbeitssteuerung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Plant, priorisiert und organisiert die eigene Arbeit nachvollziehbar und benötigt nur ein der Entwicklungsstufe angemessenes Maß an Steuerung."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Verbindlichkeit und Zuverlässigkeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Hält Zusagen ein und behandelt vereinbarte Termine, Qualitätsziele und Verantwortlichkeiten verbindlich."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fortschritts- und Risikotransparenz",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Kommuniziert Arbeitsstand, offene Punkte, Abhängigkeiten und mögliche Abweichungen frühzeitig und konkret und vermittelt eine realistische Einschätzung der Zielerreichung."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Stufenweises Alignment und Acceptance Management",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Validiert Annahmen, Konzepte und Zwischenergebnisse rechtzeitig mit den relevanten Kundenvertretern. Sichert ein explizites gemeinsames Verständnis beziehungsweise eine Teilfreigabe als Grundlage für die weitere Bearbeitung und dokumentiert Entscheidungen, Vorbehalte und Änderungen nachvollziehbar."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vorausschauendes Handeln",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Erkennt Risiken, Engpässe und Entscheidungsbedarfe früh und leitet geeignete Maßnahmen ein."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Angemessene Entscheidung und Eskalation",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Entscheidet im eigenen Verantwortungsbereich selbstständig und eskaliert rechtzeitig mit klarer Sachlage und Lösungsvorschlägen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Konsequenter Abschluss",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Verfolgt offene Punkte bis zu einem belastbaren Ergebnis, sichert Übergaben und schließt Kommunikations- und Dokumentationsschleifen."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Übernimmt End-to-End-Verantwortung für übertragene Aufgaben und angestrebte Ergebnisse, steuert die eigene Arbeit aktiv und schafft jederzeit Transparenz über Fortschritt, Risiken und Handlungsbedarf."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Coachability",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Aktives Einholen von Feedback",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Fragt gezielt und rechtzeitig nach Rückmeldung zu Wirkung, Vorgehen und Ergebnis, statt nur auf formale Feedbacktermine zu warten."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Offene Feedbackannahme",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Hört Feedback aufmerksam an, bleibt sachlich und vermeidet vorschnelle Rechtfertigung oder Abwehr."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Verständnisklärung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Fragt bei unklarer Rückmeldung nach konkreten Situationen, Erwartungen und beobachtbaren Alternativen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Übersetzung in Entwicklungsmaßnahmen",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Leitet aus Feedback konkrete, realistische und überprüfbare Verhaltens- oder Lernschritte ab."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sichtbarer Praxistransfer",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Wendet vereinbarte Verbesserungen in folgenden Aufgaben erkennbar an und zeigt nachhaltigen Fortschritt."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Selbstreflexion und Lernagilität",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Analysiert eigene Wirkung, Stärken, Fehler und Muster realistisch und passt Verhalten an neue Situationen an."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Nutzt Feedback, Reflexion und neue Erfahrungen aktiv zur Weiterentwicklung und zeigt nachvollziehbare Verhaltensänderungen über mehrere Assignments hinweg."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Collaboration",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Proaktive Abstimmung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Stimmt sich rechtzeitig mit relevanten Beteiligten ab, macht Schnittstellen transparent und vermeidet Informationslücken."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wissens- und Ergebnisteilung",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Teilt Informationen, Zwischenergebnisse, Erfahrungen und wiederverwendbare Inhalte so, dass das Team wirksam weiterarbeiten kann."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unterstützungsbereitschaft",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Unterstützt andere konstruktiv und bedarfsgerecht, ohne eigene Verantwortlichkeiten oder gemeinsame Prioritäten zu vernachlässigen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Verlässliches Teamverhalten",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Hält Teamzusagen ein, respektiert Rollen und schafft durch professionelles Verhalten eine belastbare Arbeitsbasis."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Konstruktiver Umgang mit Differenzen",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Spricht abweichende Sichtweisen sachlich an, hört Gegenargumente an und arbeitet auf eine tragfähige Lösung hin."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Einbindung unterschiedlicher Perspektiven",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Bezieht relevante Erfahrungen und Meinungen aktiv ein und schafft Raum für Beteiligung unabhängig von Rolle oder Hierarchie."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Team- und organisationsübergreifende Zusammenarbeit",
|
||||||
|
"weight": 1,
|
||||||
|
"description": "Vernetzt sich bei Bedarf über das unmittelbare Team hinaus und nutzt passende Experten, Communities oder Kontakte für den gemeinsamen Erfolg."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Arbeitet verlässlich, respektvoll und lösungsorientiert mit anderen zusammen, stärkt den gemeinsamen Teamerfolg und nutzt unterschiedliche Perspektiven und Fähigkeiten konstruktiv."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
// ─── Bewertungs-Skala ────────────────────────────────────────────────────────
|
// ─── Bewertungs-Skala (1–10) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export type FeedbackRating =
|
/** Ganzzahlige Bewertung 1–10. Speicherung: INTEGER; „nicht gesetzt" = null (kein N/A-String). */
|
||||||
| 'na'
|
export type RatingScore = number
|
||||||
| 'not_client_ready'
|
|
||||||
| 'partially_client_ready'
|
/** Farbband für die 1–10-Skala — Grenzen/Keys editierbar in Config → Skala. */
|
||||||
| 'nearly_client_ready'
|
export type RatingBandKey = 'red' | 'orange' | 'lightGreen' | 'darkGreen'
|
||||||
| 'client_ready'
|
|
||||||
|
export interface RatingColorBand {
|
||||||
|
/** Inklusive Obergrenze dieses Bandes (aufsteigend, letzte max muss 10 sein). */
|
||||||
|
max: number
|
||||||
|
key: RatingBandKey
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Legacy-Konfiguration (nur noch für Evaluation.tsx) ───────────────────────
|
// ─── Legacy-Konfiguration (nur noch für Evaluation.tsx) ───────────────────────
|
||||||
|
|
||||||
|
|
@ -44,10 +49,45 @@ export interface PhaseTemplate {
|
||||||
isRepeatable: boolean
|
isRepeatable: boolean
|
||||||
hasConversation: boolean
|
hasConversation: boolean
|
||||||
hasAssessment: boolean
|
hasAssessment: boolean
|
||||||
|
/** Opt-in wie hasConversation/hasAssessment — schaltet den Checkliste-Tab in MeetingView frei. */
|
||||||
|
hasChecklist: boolean
|
||||||
criteriaIds: number[]
|
criteriaIds: number[]
|
||||||
order: number
|
order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definition eines Checklisten-Punkts für einen Phasentyp (Config → Assignment-Typen).
|
||||||
|
* Kriterium-Verknüpfung ist bewusst nur bei scope==='perInstitutee' sinnvoll/erlaubt — Kriterien
|
||||||
|
* sind in diesem Modell immer Personen-Kompetenzen, nie Meeting-Kollektiv-Eigenschaften (siehe
|
||||||
|
* CLAUDE.md „Präzisiert bei der Umsetzungsplanung"). Verknüpfte Punkte schreiben direkt in
|
||||||
|
* `Assessment` (keine eigene Antwort-Speicherung), unverknüpfte in `ChecklistResponse`.
|
||||||
|
*/
|
||||||
|
export interface ChecklistItemDef {
|
||||||
|
id?: number
|
||||||
|
phaseTemplateId: number
|
||||||
|
label: string
|
||||||
|
linkedCriterionItemId?: number
|
||||||
|
scope: 'perInstitutee' | 'meetingWide'
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Antwort auf einen Checklisten-Punkt für dieses Meeting. `criterionItemId` überschreibt pro
|
||||||
|
* Antwort individuell die Konfigurations-Vorgabe aus `ChecklistItemDef.linkedCriterionItemId`
|
||||||
|
* (leer = es gilt die konfigurierte Vorgabe, falls vorhanden) — nur bei effektiv vorhandenem
|
||||||
|
* Kriterium wird nach `Assessment` gespiegelt (siehe CLAUDE.md).
|
||||||
|
*/
|
||||||
|
export interface ChecklistResponse {
|
||||||
|
id?: number
|
||||||
|
meetingInstanceId: number
|
||||||
|
/** Gesetzt bei scope 'perInstitutee', leer bei 'meetingWide'. */
|
||||||
|
instituteeId?: number
|
||||||
|
checklistItemId: number
|
||||||
|
rating: RatingScore | null
|
||||||
|
note: string
|
||||||
|
criterionItemId?: number
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Stammdaten ───────────────────────────────────────────────────────────────
|
// ─── Stammdaten ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
|
|
@ -98,6 +138,8 @@ export interface MeetingInstance {
|
||||||
status: 'planned' | 'active' | 'done'
|
status: 'planned' | 'active' | 'done'
|
||||||
generalNotes: string
|
generalNotes: string
|
||||||
deletedAt?: string
|
deletedAt?: string
|
||||||
|
/** Kriterium-Zuordnung pro Zeile der allgemeinen Notizen — anders als ConversationEntry.lineTags NICHT an ein Institutee gebunden, gilt für alle Institutees des Assignments. */
|
||||||
|
generalNoteTags?: ConversationLineTag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Gesprächs-Zeitleiste ─────────────────────────────────────────────────────
|
// ─── Gesprächs-Zeitleiste ─────────────────────────────────────────────────────
|
||||||
|
|
@ -123,22 +165,35 @@ export interface ConversationSkillScore {
|
||||||
id?: number
|
id?: number
|
||||||
conversationEntryId: number
|
conversationEntryId: number
|
||||||
criteriaId: number
|
criteriaId: number
|
||||||
score: FeedbackRating | null
|
score: RatingScore | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Gesamtbewertung ─────────────────────────────────────────────────────────
|
// ─── Gesamtbewertung ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// criteriaId → FeedbackCriterionItem.id (Gesamtbewertung am Meeting-Ende)
|
// criteriaId → FeedbackCriterionItem.id (optionale Detail-Bewertung / Beobachtungsnotiz am Meeting)
|
||||||
export interface Assessment {
|
export interface Assessment {
|
||||||
id?: number
|
id?: number
|
||||||
meetingInstanceId: number
|
meetingInstanceId: number
|
||||||
instituteeId: number
|
instituteeId: number
|
||||||
criteriaId: number
|
criteriaId: number
|
||||||
score: FeedbackRating | null
|
score: RatingScore | null
|
||||||
note: string
|
note: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pflicht-Meeting-Bewertung auf Kategorie-Ebene (1–10), unabhängig davon ob alle
|
||||||
|
* Kriterien beobachtet wurden — siehe docs/SCALE_1_TO_10.md.
|
||||||
|
*/
|
||||||
|
export interface MeetingCategoryRating {
|
||||||
|
id?: number
|
||||||
|
meetingInstanceId: number
|
||||||
|
instituteeId: number
|
||||||
|
categoryId: number
|
||||||
|
score: RatingScore | null
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Feedback-Entwurf (Legacy) ────────────────────────────────────────────────
|
// ─── Feedback-Entwurf (Legacy) ────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface FeedbackDraft {
|
export interface FeedbackDraft {
|
||||||
|
|
@ -162,6 +217,8 @@ export interface FeedbackCategory {
|
||||||
dimensionId: number
|
dimensionId: number
|
||||||
name: string
|
name: string
|
||||||
order: number
|
order: number
|
||||||
|
/** Erläuterung, welche Themen/Beobachtungen in diese Kategorie einzahlen — fließt in {{KATEGORIEN_STRUKTUR}} der KI-Prompts ein. */
|
||||||
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackCriterionItem {
|
export interface FeedbackCriterionItem {
|
||||||
|
|
@ -172,6 +229,8 @@ export interface FeedbackCriterionItem {
|
||||||
order: number
|
order: number
|
||||||
/** Konkretisierung, worauf bei diesem Kriterium zu achten ist — fließt in {{KRITERIEN_SCORES}} der KI-Prompts ein. */
|
/** Konkretisierung, worauf bei diesem Kriterium zu achten ist — fließt in {{KRITERIEN_SCORES}} der KI-Prompts ein. */
|
||||||
description?: string
|
description?: string
|
||||||
|
/** KI-destillierte, generalisierte Formulierungsmuster (Nebenprodukt des „🏷 Vorschläge analysieren"-Laufs) — fließt in {{GELERNTE_MUSTER}} des Tagging-Prompts ein, wird bei jedem weiteren Lauf überschrieben. */
|
||||||
|
learnedPatternHints?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignmentFeedback {
|
export interface AssignmentFeedback {
|
||||||
|
|
@ -187,14 +246,16 @@ export interface FeedbackCategoryRating {
|
||||||
id?: number
|
id?: number
|
||||||
assignmentFeedbackId: number
|
assignmentFeedbackId: number
|
||||||
feedbackCategoryId: number
|
feedbackCategoryId: number
|
||||||
rating: FeedbackRating
|
rating: RatingScore
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedbackDimensionText {
|
export interface FeedbackDimensionText {
|
||||||
id?: number
|
id?: number
|
||||||
assignmentFeedbackId: number
|
assignmentFeedbackId: number
|
||||||
feedbackDimensionId: number
|
feedbackDimensionId: number
|
||||||
|
/** DB-Feldname historisch; UI/PDF: Benefits */
|
||||||
achievements: string
|
achievements: string
|
||||||
|
/** DB-Feldname historisch; UI/PDF: Concerns */
|
||||||
developmentNeeds: string
|
developmentNeeds: string
|
||||||
suggestedAchievements?: string
|
suggestedAchievements?: string
|
||||||
suggestedDevelopmentNeeds?: string
|
suggestedDevelopmentNeeds?: string
|
||||||
|
|
@ -212,8 +273,10 @@ export interface FeedbackCategoryText {
|
||||||
id?: number
|
id?: number
|
||||||
assignmentFeedbackId: number
|
assignmentFeedbackId: number
|
||||||
feedbackCategoryId: number
|
feedbackCategoryId: number
|
||||||
text: string
|
benefits: string
|
||||||
suggestedText?: string
|
concerns: string
|
||||||
|
suggestedBenefits?: string
|
||||||
|
suggestedConcerns?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Legacy-Mappings ──────────────────────────────────────────────────────────
|
// ─── Legacy-Mappings ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -227,7 +290,7 @@ export interface CriterionCategoryMapping {
|
||||||
export interface CriterionLevelDescription {
|
export interface CriterionLevelDescription {
|
||||||
id?: number
|
id?: number
|
||||||
criterionItemId: number
|
criterionItemId: number
|
||||||
rating: FeedbackRating
|
rating: RatingScore
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,4 +299,12 @@ export interface CriterionLevelDescription {
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
id?: number
|
id?: number
|
||||||
ratingTrendWeightStep: number // 0 = keine Zeitgewichtung, höher = stärkere Gewichtung späterer Meetings
|
ratingTrendWeightStep: number // 0 = keine Zeitgewichtung, höher = stärkere Gewichtung späterer Meetings
|
||||||
|
/** Verdichtungs-Prompt Stufe B (Dimension aus Kategorie-Ergebnissen), editierbar in Config → KI-Prompts. */
|
||||||
|
defaultDimensionPromptTemplate?: string
|
||||||
|
/** Kategorie-Prompt Stufe A (1 Call pro Kategorie), editierbar in Config → KI-Prompts. */
|
||||||
|
defaultCategoryPromptTemplate?: string
|
||||||
|
/** Prompt-Template für „🏷 Vorschläge analysieren" (selbstlernendes Kriterien-Tagging), editierbar in Config → KI-Prompts. */
|
||||||
|
criterionTaggingPromptTemplate?: string
|
||||||
|
/** Farbbänder der 1–10-Skala (Defaults in constants.ts), editierbar in Config → Skala. */
|
||||||
|
ratingColorBands?: RatingColorBand[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
src/hooks/useRatingColorBands.ts
Normal file
17
src/hooks/useRatingColorBands.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { DEFAULT_RATING_COLOR_BANDS, normalizeRatingColorBands } from '../config/constants'
|
||||||
|
import { getOrSeedRatingColorBands } from '../db'
|
||||||
|
import type { RatingColorBand } from '../db/types'
|
||||||
|
|
||||||
|
/** Lädt die konfigurierten Farbbänder einmalig (Config → Skala). */
|
||||||
|
export function useRatingColorBands(): RatingColorBand[] {
|
||||||
|
const [bands, setBands] = useState<RatingColorBand[]>(DEFAULT_RATING_COLOR_BANDS)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getOrSeedRatingColorBands()
|
||||||
|
.then(b => setBands(normalizeRatingColorBands(b)))
|
||||||
|
.catch(() => setBands(DEFAULT_RATING_COLOR_BANDS))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return bands
|
||||||
|
}
|
||||||
|
|
@ -2,23 +2,47 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
type FeedbackDimension, type FeedbackDimensionPrompt,
|
type FeedbackDimension, type FeedbackDimensionPrompt,
|
||||||
loadFeedbackStructure, listFeedbackDimensionPrompts, updateFeedbackDimensionPrompt,
|
loadFeedbackStructure, listFeedbackDimensionPrompts, updateFeedbackDimensionPrompt,
|
||||||
|
resetAllDimensionPromptsToDefault,
|
||||||
|
getOrSeedDefaultDimensionPromptTemplate, saveDefaultDimensionPromptTemplate,
|
||||||
|
getOrSeedDefaultCategoryPromptTemplate, saveDefaultCategoryPromptTemplate,
|
||||||
|
getOrSeedCriterionTaggingPromptTemplate, saveCriterionTaggingPromptTemplate,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
import { AI_CONFIG } from '../config/constants'
|
import { AI_CONFIG } from '../config/constants'
|
||||||
|
import { getAiSettings, saveAiSettings } from '../utils/aiClient'
|
||||||
|
|
||||||
export default function AiPromptConfig() {
|
export default function AiPromptConfig() {
|
||||||
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
||||||
const [prompts, setPrompts] = useState<FeedbackDimensionPrompt[]>([])
|
const [prompts, setPrompts] = useState<FeedbackDimensionPrompt[]>([])
|
||||||
|
const [defaultTemplate, setDefaultTemplate] = useState('')
|
||||||
|
const [defaultExpanded, setDefaultExpanded] = useState(false)
|
||||||
|
const [defaultSaved, setDefaultSaved] = useState(false)
|
||||||
|
const [categoryTemplate, setCategoryTemplate] = useState('')
|
||||||
|
const [categoryExpanded, setCategoryExpanded] = useState(false)
|
||||||
|
const [categorySaved, setCategorySaved] = useState(false)
|
||||||
|
const [taggingTemplate, setTaggingTemplate] = useState('')
|
||||||
|
const [taggingExpanded, setTaggingExpanded] = useState(false)
|
||||||
|
const [taggingSaved, setTaggingSaved] = useState(false)
|
||||||
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||||
const [savedDimId, setSavedDimId] = useState<number | null>(null)
|
const [savedDimId, setSavedDimId] = useState<number | null>(null)
|
||||||
const saveTimer = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
|
const [bulkResetMsg, setBulkResetMsg] = useState('')
|
||||||
|
const saveTimer = useRef<Map<number | 'default' | 'category' | 'tagging', ReturnType<typeof setTimeout>>>(new Map())
|
||||||
|
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
|
||||||
|
const [aiModel, setAiModel] = useState(getAiSettings().model)
|
||||||
|
const [aiSaved, setAiSaved] = useState(false)
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [structure, promptRows] = await Promise.all([
|
const [structure, promptRows, defTemplate, catTemplate, taggingTpl] = await Promise.all([
|
||||||
loadFeedbackStructure(),
|
loadFeedbackStructure(),
|
||||||
listFeedbackDimensionPrompts(),
|
listFeedbackDimensionPrompts(),
|
||||||
|
getOrSeedDefaultDimensionPromptTemplate(),
|
||||||
|
getOrSeedDefaultCategoryPromptTemplate(),
|
||||||
|
getOrSeedCriterionTaggingPromptTemplate(),
|
||||||
])
|
])
|
||||||
setDimensions([...structure.dimensions].sort((a, b) => a.order - b.order))
|
setDimensions([...structure.dimensions].sort((a, b) => a.order - b.order))
|
||||||
setPrompts(promptRows)
|
setPrompts(promptRows)
|
||||||
|
setDefaultTemplate(defTemplate)
|
||||||
|
setCategoryTemplate(catTemplate)
|
||||||
|
setTaggingTemplate(taggingTpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
@ -41,20 +65,167 @@ export default function AiPromptConfig() {
|
||||||
const resetToDefault = async (dimId: number) => {
|
const resetToDefault = async (dimId: number) => {
|
||||||
const existing = getPrompt(dimId)
|
const existing = getPrompt(dimId)
|
||||||
if (!existing?.id) return
|
if (!existing?.id) return
|
||||||
await updateFeedbackDimensionPrompt(existing.id, { promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate })
|
await updateFeedbackDimensionPrompt(existing.id, { promptTemplate: defaultTemplate })
|
||||||
setPrompts(prev => prev.map(p => p.dimensionId === dimId ? { ...p, promptTemplate: AI_CONFIG.defaultDimensionPromptTemplate } : p))
|
setPrompts(prev => prev.map(p => p.dimensionId === dimId ? { ...p, promptTemplate: defaultTemplate } : p))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAllToDefault = async () => {
|
||||||
|
if (!confirm('Alle Dimension-Prompts wirklich auf den Standard (1–10) setzen? Individuelle Anpassungen gehen verloren.')) return
|
||||||
|
const { updated } = await resetAllDimensionPromptsToDefault()
|
||||||
|
const promptRows = await listFeedbackDimensionPrompts()
|
||||||
|
setPrompts(promptRows)
|
||||||
|
setBulkResetMsg(`${updated} Dimension-Prompt(s) auf Standard gesetzt.`)
|
||||||
|
setTimeout(() => setBulkResetMsg(''), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDefaultTemplate = (value: string) => {
|
||||||
|
setDefaultTemplate(value)
|
||||||
|
const prevTimer = saveTimer.current.get('default')
|
||||||
|
if (prevTimer) clearTimeout(prevTimer)
|
||||||
|
saveTimer.current.set('default', setTimeout(async () => {
|
||||||
|
await saveDefaultDimensionPromptTemplate(value)
|
||||||
|
setDefaultSaved(true)
|
||||||
|
setTimeout(() => setDefaultSaved(false), 2000)
|
||||||
|
}, 600))
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCategoryTemplate = (value: string) => {
|
||||||
|
setCategoryTemplate(value)
|
||||||
|
const prevTimer = saveTimer.current.get('category')
|
||||||
|
if (prevTimer) clearTimeout(prevTimer)
|
||||||
|
saveTimer.current.set('category', setTimeout(async () => {
|
||||||
|
await saveDefaultCategoryPromptTemplate(value)
|
||||||
|
setCategorySaved(true)
|
||||||
|
setTimeout(() => setCategorySaved(false), 2000)
|
||||||
|
}, 600))
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveTaggingTemplate = (value: string) => {
|
||||||
|
setTaggingTemplate(value)
|
||||||
|
const prevTimer = saveTimer.current.get('tagging')
|
||||||
|
if (prevTimer) clearTimeout(prevTimer)
|
||||||
|
saveTimer.current.set('tagging', setTimeout(async () => {
|
||||||
|
await saveCriterionTaggingPromptTemplate(value)
|
||||||
|
setTaggingSaved(true)
|
||||||
|
setTimeout(() => setTaggingSaved(false), 2000)
|
||||||
|
}, 600))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-gray-800">KI-Prompts je Dimension</h2>
|
<h2 className="text-lg font-bold text-gray-800">KI-Feedback (Kaskade)</h2>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Jede Dimension bekommt bei der KI-Feedback-Generierung einen eigenen, separaten Call. Der Prompt-Text
|
Stufe A: ein Call pro Kategorie (globaler Kategorie-Prompt). Stufe B: ein Call pro Dimension
|
||||||
ist frei editierbar und wird bei neuen Dimensionen automatisch mit einem Standard-Prompt vorbelegt.
|
(Verdichtung aus Kategorie-Ergebnissen — pro Dimension editierbar unten).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-blue-100 shadow-sm p-4 space-y-3">
|
||||||
|
<div className="text-sm font-semibold text-blue-800">OpenRouter API-Key</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Wird lokal im Browser gespeichert (<span className="font-mono">localStorage</span>) — nötig für
|
||||||
|
KI-Feedback und „Vorschläge analysieren". Key von{' '}
|
||||||
|
<span className="font-mono">openrouter.ai</span>.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full border border-blue-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
|
||||||
|
placeholder="sk-or-..."
|
||||||
|
value={aiKey}
|
||||||
|
onChange={e => setAiKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="w-full border border-blue-200 rounded-lg px-3 py-2 text-sm bg-white"
|
||||||
|
value={aiModel}
|
||||||
|
onChange={e => setAiModel(e.target.value)}>
|
||||||
|
<option value="openai/gpt-4o-mini">GPT-4o mini (günstig)</option>
|
||||||
|
<option value="openai/gpt-4o">GPT-4o</option>
|
||||||
|
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
|
||||||
|
<option value="anthropic/claude-haiku-4-5">Claude Haiku 4.5 (schnell)</option>
|
||||||
|
<option value="google/gemini-flash-1.5">Gemini Flash 1.5</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
saveAiSettings(aiKey, aiModel)
|
||||||
|
setAiSaved(true)
|
||||||
|
setTimeout(() => setAiSaved(false), 2000)
|
||||||
|
}}
|
||||||
|
className="w-full py-2.5 bg-brand text-white rounded-xl text-sm font-semibold">
|
||||||
|
API-Key speichern
|
||||||
|
</button>
|
||||||
|
{aiSaved && (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Kategorie-Prompt Stufe A */}
|
||||||
|
<div className="bg-white rounded-xl border border-green-100 shadow-sm overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setCategoryExpanded(p => !p)}
|
||||||
|
className="w-full flex items-center justify-between p-3 bg-green-50 border-b border-green-100 text-left">
|
||||||
|
<span className="font-semibold text-sm text-green-800">Stufe A — Kategorie-Prompt (1 Call pro Kategorie)</span>
|
||||||
|
<span className="text-xs text-green-400">{categoryExpanded ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{categoryExpanded && (
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Wird für jede Kategorie einzeln aufgerufen. Der Trend-Vorschlag aus Meetings wird im Prompt
|
||||||
|
vorgegeben; die KI bestätigt oder passt maximal ±1 an.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[280px]"
|
||||||
|
value={categoryTemplate}
|
||||||
|
onChange={e => saveCategoryTemplate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{categorySaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
|
||||||
|
<div className="grid gap-1.5 pt-2">
|
||||||
|
{AI_CONFIG.categoryPromptPlaceholders.map(p => (
|
||||||
|
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
|
||||||
|
<code className="bg-white border border-gray-200 rounded px-1.5 py-0.5 text-brand font-mono flex-shrink-0">{p.token}</code>
|
||||||
|
<span>{p.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Standard-Prompt Stufe B */}
|
||||||
|
<div className="bg-white rounded-xl border border-blue-100 shadow-sm overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setDefaultExpanded(p => !p)}
|
||||||
|
className="w-full flex items-center justify-between p-3 bg-blue-50 border-b border-blue-100 text-left">
|
||||||
|
<span className="font-semibold text-sm text-blue-800">Stufe B — Dimensions-Verdichtung (Basis für neue Dimensionen & „Zurücksetzen")</span>
|
||||||
|
<span className="text-xs text-blue-400">{defaultExpanded ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{defaultExpanded && (
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Input sind nur die Kategorie-Ergebnisse aus Stufe A — kein Rohprotokoll. Wird beim Anlegen
|
||||||
|
neuer Dimensionen automatisch übernommen und ist die Basis für „Auf Standard zurücksetzen".
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[320px]"
|
||||||
|
value={defaultTemplate}
|
||||||
|
onChange={e => saveDefaultTemplate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{defaultSaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={resetAllToDefault}
|
||||||
|
className="text-xs bg-amber-50 hover:bg-amber-100 text-amber-800 border border-amber-200 px-3 py-1.5 rounded-lg font-medium">
|
||||||
|
Alle Dimension-Prompts auf Standard (Stufe B) setzen
|
||||||
|
</button>
|
||||||
|
{bulkResetMsg && <span className="text-xs text-green-600 font-medium">{bulkResetMsg}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Platzhalter-Legende */}
|
{/* Platzhalter-Legende */}
|
||||||
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-2">
|
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-2">
|
||||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Verfügbare Platzhalter</div>
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Verfügbare Platzhalter</div>
|
||||||
|
|
@ -68,6 +239,45 @@ export default function AiPromptConfig() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">KI-Prompt: Kriterien-Zuordnungsvorschläge</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Wird durch den „🏷 Vorschläge analysieren"-Button im Protokoll ausgelöst — ein gebündelter Call pro
|
||||||
|
Protokoll-Ansicht, nicht pro Zeile. Lernt aus bereits vergebenen Kriterium-Zuordnungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-indigo-100 shadow-sm overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setTaggingExpanded(p => !p)}
|
||||||
|
className="w-full flex items-center justify-between p-3 bg-indigo-50 border-b border-indigo-100 text-left">
|
||||||
|
<span className="font-semibold text-sm text-indigo-800">🏷 Tagging-Prompt</span>
|
||||||
|
<span className="text-xs text-indigo-400">{taggingExpanded ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{taggingExpanded && (
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-xs font-mono focus:outline-none focus:border-brand min-h-[320px]"
|
||||||
|
value={taggingTemplate}
|
||||||
|
onChange={e => saveTaggingTemplate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{taggingSaved && <span className="text-xs text-green-600 font-medium">Gespeichert.</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-xl border border-gray-100 p-4 space-y-2">
|
||||||
|
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Platzhalter im Tagging-Prompt</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{AI_CONFIG.criterionTaggingPromptPlaceholders.map(p => (
|
||||||
|
<div key={p.token} className="text-xs text-gray-600 flex gap-2">
|
||||||
|
<code className="bg-white border border-gray-200 rounded px-1.5 py-0.5 text-brand font-mono flex-shrink-0">{p.token}</code>
|
||||||
|
<span>{p.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Dimensionen */}
|
{/* Dimensionen */}
|
||||||
{dimensions.map(dim => {
|
{dimensions.map(dim => {
|
||||||
const isOpen = expanded.has(dim.id!)
|
const isOpen = expanded.has(dim.id!)
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,22 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type FeedbackRating, type FeedbackCategory, type FeedbackCriterionItem,
|
type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type RatingScore, type FeedbackCategory, type FeedbackCriterionItem,
|
||||||
getAssignment, getAssignmentType,
|
getAssignment, getAssignmentType,
|
||||||
listPhaseTemplatesForType,
|
listPhaseTemplatesForType,
|
||||||
listMeetingInstancesForAssignment, addMeetingInstance, softDeleteMeetingInstance, restoreMeetingInstance, hardDeleteMeetingInstance,
|
listMeetingInstancesForAssignment, addMeetingInstance, softDeleteMeetingInstance, restoreMeetingInstance, hardDeleteMeetingInstance,
|
||||||
getConsultantsByIds,
|
getConsultantsByIds,
|
||||||
listAssessmentsForMeetings,
|
listAssessmentsForMeetings,
|
||||||
listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure,
|
listMeetingCategoryRatingsForMeetings,
|
||||||
|
loadFeedbackStructure, resolveVisibleStructure,
|
||||||
isAssignmentFeedbackComplete, softDeleteAssignment, archiveAssignment,
|
isAssignmentFeedbackComplete, softDeleteAssignment, archiveAssignment,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
|
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
|
||||||
import { weightedAverageRating } from '../utils/ratingTrend'
|
import { exportAssignmentReportPdf, exportInstituteeFeedbackPdf } from '../utils/pdfExport'
|
||||||
|
import { resolveScoreStyle } from '../config/constants'
|
||||||
const RATING_BG: Record<FeedbackRating, string> = {
|
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
||||||
na: 'bg-gray-400 text-white',
|
import { useRatingColorBands } from '../hooks/useRatingColorBands'
|
||||||
not_client_ready: 'bg-red-500 text-white',
|
import type { MeetingCategoryRating } from '../db'
|
||||||
partially_client_ready: 'bg-orange-500 text-white',
|
|
||||||
nearly_client_ready: 'bg-green-400 text-white',
|
|
||||||
client_ready: 'bg-green-700 text-white',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PhaseRow {
|
interface PhaseRow {
|
||||||
template: PhaseTemplate
|
template: PhaseTemplate
|
||||||
|
|
@ -43,11 +40,14 @@ export default function AssignmentDetail() {
|
||||||
const [confirmSoftDelete, setConfirmSoftDelete] = useState<number | null>(null)
|
const [confirmSoftDelete, setConfirmSoftDelete] = useState<number | null>(null)
|
||||||
const [confirmHardDelete, setConfirmHardDelete] = useState<number | null>(null)
|
const [confirmHardDelete, setConfirmHardDelete] = useState<number | null>(null)
|
||||||
const [allAssessments, setAllAssessments] = useState<Assessment[]>([])
|
const [allAssessments, setAllAssessments] = useState<Assessment[]>([])
|
||||||
|
const [categoryRatings, setCategoryRatings] = useState<MeetingCategoryRating[]>([])
|
||||||
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
|
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
|
||||||
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
|
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
|
||||||
|
const colorBands = useRatingColorBands()
|
||||||
const [feedbackComplete, setFeedbackComplete] = useState(false)
|
const [feedbackComplete, setFeedbackComplete] = useState(false)
|
||||||
const [confirmAssignmentTrash, setConfirmAssignmentTrash] = useState(false)
|
const [confirmAssignmentTrash, setConfirmAssignmentTrash] = useState(false)
|
||||||
const [confirmAssignmentArchive, setConfirmAssignmentArchive] = useState(false)
|
const [confirmAssignmentArchive, setConfirmAssignmentArchive] = useState(false)
|
||||||
|
const [pdfExportingKey, setPdfExportingKey] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const a = await getAssignment(assignmentId)
|
const a = await getAssignment(assignmentId)
|
||||||
|
|
@ -73,16 +73,17 @@ export default function AssignmentDetail() {
|
||||||
instances: active.filter(i => i.phaseTemplateId === t.id).sort((a, b) => a.phaseIndex - b.phaseIndex),
|
instances: active.filter(i => i.phaseTemplateId === t.id).sort((a, b) => a.phaseIndex - b.phaseIndex),
|
||||||
})))
|
})))
|
||||||
const instIds = active.map(i => i.id!)
|
const instIds = active.map(i => i.id!)
|
||||||
const [asmts, allCats, allItems, aType2] = await Promise.all([
|
const [asmts, catRatings, structure, aType2] = await Promise.all([
|
||||||
listAssessmentsForMeetings(instIds),
|
listAssessmentsForMeetings(instIds),
|
||||||
listFeedbackCategoriesAll(),
|
listMeetingCategoryRatingsForMeetings(instIds),
|
||||||
listFeedbackCriterionItemsAll(),
|
loadFeedbackStructure(),
|
||||||
getAssignmentType(a.assignmentTypeId),
|
getAssignmentType(a.assignmentTypeId),
|
||||||
])
|
])
|
||||||
setAllAssessments(asmts)
|
setAllAssessments(asmts)
|
||||||
const { categories: visibleCats, items: visibleItems } = filterVisibleFeedbackStructure(allCats, allItems, aType2?.criteriaIds)
|
setCategoryRatings(catRatings)
|
||||||
setFbItems(visibleItems)
|
const visible = resolveVisibleStructure(structure.dimensions, structure.categories, structure.items, aType2?.criteriaIds)
|
||||||
setFbCategories(visibleCats)
|
setFbItems(visible.items)
|
||||||
|
setFbCategories(visible.categories)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [assignmentId])
|
useEffect(() => { load() }, [assignmentId])
|
||||||
|
|
@ -147,6 +148,25 @@ export default function AssignmentDetail() {
|
||||||
navigate('/')
|
navigate('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExportReportPdf = async () => {
|
||||||
|
setPdfExportingKey('report')
|
||||||
|
try {
|
||||||
|
await exportAssignmentReportPdf(assignmentId)
|
||||||
|
} finally {
|
||||||
|
setPdfExportingKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportInstituteeFeedbackPdf = async (instituteeId: number) => {
|
||||||
|
const key = `inst-${instituteeId}`
|
||||||
|
setPdfExportingKey(key)
|
||||||
|
try {
|
||||||
|
await exportInstituteeFeedbackPdf(assignmentId, instituteeId)
|
||||||
|
} finally {
|
||||||
|
setPdfExportingKey(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const statusBadge = (status: MeetingInstance['status']) => {
|
const statusBadge = (status: MeetingInstance['status']) => {
|
||||||
const map = {
|
const map = {
|
||||||
planned: 'bg-yellow-100 text-yellow-700',
|
planned: 'bg-yellow-100 text-yellow-700',
|
||||||
|
|
@ -176,6 +196,13 @@ export default function AssignmentDetail() {
|
||||||
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
|
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
|
||||||
📜 Gesamtprotokoll
|
📜 Gesamtprotokoll
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExportReportPdf}
|
||||||
|
disabled={pdfExportingKey === 'report'}
|
||||||
|
title="Gesamtprotokoll + Feedback als PDF"
|
||||||
|
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition disabled:opacity-50">
|
||||||
|
{pdfExportingKey === 'report' ? '⏳' : '⬇ PDF'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
|
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
|
||||||
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
|
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
|
||||||
|
|
@ -269,11 +296,20 @@ export default function AssignmentDetail() {
|
||||||
<span className="text-sm text-amber-800 font-medium">Feedback-Entwürfe</span>
|
<span className="text-sm text-amber-800 font-medium">Feedback-Entwürfe</span>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{institutees.map(c => (
|
{institutees.map(c => (
|
||||||
<button key={c.id}
|
<div key={c.id} className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
onClick={() => navigate(`/assignment/${assignmentId}/feedback/${c.id}`)}
|
onClick={() => navigate(`/assignment/${assignmentId}/feedback/${c.id}`)}
|
||||||
className="text-xs bg-amber-600 text-white px-3 py-1 rounded-lg">
|
className="text-xs bg-amber-600 text-white px-3 py-1 rounded-lg">
|
||||||
{c.firstName}
|
{c.firstName}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExportInstituteeFeedbackPdf(c.id!)}
|
||||||
|
disabled={pdfExportingKey === `inst-${c.id}`}
|
||||||
|
title={`Feedback für ${c.firstName} als PDF`}
|
||||||
|
className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-lg disabled:opacity-50">
|
||||||
|
{pdfExportingKey === `inst-${c.id}` ? '⏳' : '⬇'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -323,24 +359,24 @@ export default function AssignmentDetail() {
|
||||||
{institutees.map(c => {
|
{institutees.map(c => {
|
||||||
const chips = fbCategories.map(cat => {
|
const chips = fbCategories.map(cat => {
|
||||||
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
||||||
const mode = weightedAverageRating(catItems, itemId => {
|
const mode = resolveCategoryChipScore({
|
||||||
const asmt = allAssessments.find(a =>
|
meetingId: inst.id!,
|
||||||
a.meetingInstanceId === inst.id &&
|
instituteeId: c.id!,
|
||||||
a.instituteeId === c.id &&
|
categoryId: cat.id!,
|
||||||
a.criteriaId === itemId
|
categoryRatings,
|
||||||
)
|
assessments: allAssessments,
|
||||||
return asmt?.score ?? null
|
catItems,
|
||||||
})
|
})
|
||||||
return mode ? { cat, mode } : null
|
return mode ? { cat, mode } : null
|
||||||
}).filter(Boolean) as { cat: FeedbackCategory; mode: FeedbackRating }[]
|
}).filter(Boolean) as { cat: FeedbackCategory; mode: RatingScore }[]
|
||||||
|
|
||||||
if (chips.length === 0) return null
|
if (chips.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div key={c.id} className="mt-1">
|
<div key={c.id} className="mt-1">
|
||||||
<span className="text-xs text-gray-400 mr-1">{c.firstName}:</span>
|
<span className="text-xs text-gray-400 mr-1">{c.firstName}:</span>
|
||||||
{chips.map(({ cat, mode }) => (
|
{chips.map(({ cat, mode }) => (
|
||||||
<span key={cat.id} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${RATING_BG[mode]}`}>
|
<span key={cat.id} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 tabular-nums ${resolveScoreStyle(mode, colorBands).active}`}>
|
||||||
{cat.name}
|
{cat.name} {mode}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,27 +10,38 @@ import {
|
||||||
type FeedbackCategoryRating,
|
type FeedbackCategoryRating,
|
||||||
type FeedbackDimensionText,
|
type FeedbackDimensionText,
|
||||||
type FeedbackCategoryText,
|
type FeedbackCategoryText,
|
||||||
type FeedbackRating,
|
type RatingScore,
|
||||||
type MeetingInstance,
|
type MeetingInstance,
|
||||||
type Assessment,
|
type Assessment,
|
||||||
type ConversationEntry,
|
type ConversationEntry,
|
||||||
getAssignment, getConsultant,
|
getAssignment, getConsultant, getAssignmentType,
|
||||||
loadFeedbackStructure,
|
loadFeedbackStructure, resolveVisibleStructure,
|
||||||
listDoneMeetingsChronological,
|
listDoneMeetingsChronological,
|
||||||
listAssessmentsForMeetings, listConversationEntriesForMeetings,
|
listAssessmentsForMeetings, listConversationEntriesForMeetings,
|
||||||
|
listMeetingCategoryRatingsForMeetings,
|
||||||
getRatingTrendWeightStep,
|
getRatingTrendWeightStep,
|
||||||
getOrCreateAssignmentFeedback, updateAssignmentFeedback,
|
getOrCreateAssignmentFeedback, updateAssignmentFeedback,
|
||||||
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating,
|
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, deleteFeedbackCategoryRating,
|
||||||
listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText,
|
listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText,
|
||||||
listFeedbackCategoryTexts, getFeedbackCategoryText, addFeedbackCategoryText, updateFeedbackCategoryText,
|
listFeedbackCategoryTexts, getFeedbackCategoryText, addFeedbackCategoryText, updateFeedbackCategoryText,
|
||||||
getFeedbackDimensionPrompt,
|
getFeedbackDimensionPrompt,
|
||||||
|
getOrSeedDefaultCategoryPromptTemplate,
|
||||||
|
getOrSeedDefaultDimensionPromptTemplate,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
import { RATING_OPTIONS, AI_CONFIG } from '../config/constants'
|
import {
|
||||||
import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend'
|
AI_CONFIG, isValidRatingScore, resolveScoreStyle,
|
||||||
import { buildDimensionPromptContext, renderDimensionPrompt, parseDimensionAiResponse } from '../utils/aiPrompt'
|
} from '../config/constants'
|
||||||
|
import { ScorePicker } from '../components/ScorePicker'
|
||||||
const ratingColor = (r: FeedbackRating | undefined) =>
|
import { useRatingColorBands } from '../hooks/useRatingColorBands'
|
||||||
RATING_OPTIONS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200'
|
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
||||||
|
import { computeTrend, numberToRating, type MeetingScorePoint, type TrendResult } from '../utils/ratingTrend'
|
||||||
|
import {
|
||||||
|
buildDimensionCondenseContext, buildCategoryPromptContext,
|
||||||
|
renderPrompt, parseCategoryAiResponse, parseDimensionCondenseResponse,
|
||||||
|
type CategoryCondenseEntry,
|
||||||
|
} from '../utils/aiPrompt'
|
||||||
|
import { getAiSettings, saveAiSettings, callOpenRouter } from '../utils/aiClient'
|
||||||
|
import { exportInstituteeFeedbackPdf } from '../utils/pdfExport'
|
||||||
|
|
||||||
const trendArrow = (trend: TrendResult['trend']) => {
|
const trendArrow = (trend: TrendResult['trend']) => {
|
||||||
if (trend === 'up') return <span title="Verbesserung über die Laufzeit">↑</span>
|
if (trend === 'up') return <span title="Verbesserung über die Laufzeit">↑</span>
|
||||||
|
|
@ -38,18 +49,6 @@ const trendArrow = (trend: TrendResult['trend']) => {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAiSettings() {
|
|
||||||
return {
|
|
||||||
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
|
|
||||||
model: localStorage.getItem(AI_CONFIG.localStorageKeyModel) ?? AI_CONFIG.defaultModel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveAiSettings(apiKey: string, model: string) {
|
|
||||||
localStorage.setItem(AI_CONFIG.localStorageKeyApiKey, apiKey)
|
|
||||||
localStorage.setItem(AI_CONFIG.localStorageKeyModel, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Klein gehaltene KI-Vorschlag-Box mit Übernehmen/Verwerfen — gleiche Bedienlogik überall im Feedback. */
|
/** Klein gehaltene KI-Vorschlag-Box mit Übernehmen/Verwerfen — gleiche Bedienlogik überall im Feedback. */
|
||||||
function AiSuggestionBox({ text, onAccept, onDiscard }: { text: string; onAccept: () => void; onDiscard: () => void }) {
|
function AiSuggestionBox({ text, onAccept, onDiscard }: { text: string; onAccept: () => void; onDiscard: () => void }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -77,6 +76,7 @@ export default function AssignmentFeedbackPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const assignmentId = Number(id)
|
const assignmentId = Number(id)
|
||||||
const instId = Number(instituteeId)
|
const instId = Number(instituteeId)
|
||||||
|
const colorBands = useRatingColorBands()
|
||||||
|
|
||||||
const [assignment, setAssignment] = useState<Assignment | null>(null)
|
const [assignment, setAssignment] = useState<Assignment | null>(null)
|
||||||
const [institutee, setInstitutee] = useState<Consultant | null>(null)
|
const [institutee, setInstitutee] = useState<Consultant | null>(null)
|
||||||
|
|
@ -96,6 +96,8 @@ export default function AssignmentFeedbackPage() {
|
||||||
const [generationProgress, setGenerationProgress] = useState('')
|
const [generationProgress, setGenerationProgress] = useState('')
|
||||||
const [aiError, setAiError] = useState('')
|
const [aiError, setAiError] = useState('')
|
||||||
const [dimensionErrors, setDimensionErrors] = useState<Record<number, string>>({})
|
const [dimensionErrors, setDimensionErrors] = useState<Record<number, string>>({})
|
||||||
|
const [categoryErrors, setCategoryErrors] = useState<Record<string, string>>({})
|
||||||
|
const [exportingPdf, setExportingPdf] = useState(false)
|
||||||
const [previewOpen, setPreviewOpen] = useState<Set<number>>(new Set())
|
const [previewOpen, setPreviewOpen] = useState<Set<number>>(new Set())
|
||||||
const [previewText, setPreviewText] = useState<Record<number, string>>({})
|
const [previewText, setPreviewText] = useState<Record<number, string>>({})
|
||||||
const [previewLoading, setPreviewLoading] = useState<Set<number>>(new Set())
|
const [previewLoading, setPreviewLoading] = useState<Set<number>>(new Set())
|
||||||
|
|
@ -109,25 +111,44 @@ export default function AssignmentFeedbackPage() {
|
||||||
])
|
])
|
||||||
setAssignment(a ?? null)
|
setAssignment(a ?? null)
|
||||||
setInstitutee(cons ?? null)
|
setInstitutee(cons ?? null)
|
||||||
setDimensions(structure.dimensions)
|
|
||||||
setCategories(structure.categories)
|
const aType = a ? await getAssignmentType(a.assignmentTypeId) : undefined
|
||||||
setItems(structure.items)
|
const visible = resolveVisibleStructure(
|
||||||
const { categories: cats, items: itms } = structure
|
structure.dimensions,
|
||||||
|
structure.categories,
|
||||||
|
structure.items,
|
||||||
|
aType?.criteriaIds,
|
||||||
|
)
|
||||||
|
setDimensions(visible.dimensions)
|
||||||
|
setCategories(visible.categories)
|
||||||
|
setItems(visible.items)
|
||||||
|
const { categories: cats, items: itms } = visible
|
||||||
|
|
||||||
const meetings = await listDoneMeetingsChronological(assignmentId)
|
const meetings = await listDoneMeetingsChronological(assignmentId)
|
||||||
const meetingIds = meetings.map(m => m.id!)
|
const meetingIds = meetings.map(m => m.id!)
|
||||||
const [meetingAssessments, weightStep] = await Promise.all([
|
const [meetingAssessments, categoryRatings, weightStep] = await Promise.all([
|
||||||
listAssessmentsForMeetings(meetingIds),
|
listAssessmentsForMeetings(meetingIds),
|
||||||
|
listMeetingCategoryRatingsForMeetings(meetingIds),
|
||||||
getRatingTrendWeightStep(),
|
getRatingTrendWeightStep(),
|
||||||
])
|
])
|
||||||
const instAssessments = meetingAssessments.filter(as => as.instituteeId === instId)
|
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>()
|
const trends = new Map<number, TrendResult>()
|
||||||
for (const cat of cats) {
|
for (const cat of cats) {
|
||||||
const catItems = itms.filter(i => i.categoryId === cat.id)
|
const catItems = itms.filter(i => i.categoryId === cat.id)
|
||||||
const history = buildGroupMeetingScores(catItems, meetings, getScore)
|
const history: MeetingScorePoint[] = []
|
||||||
|
for (const meeting of meetings) {
|
||||||
|
const score = resolveCategoryChipScore({
|
||||||
|
meetingId: meeting.id!,
|
||||||
|
instituteeId: instId,
|
||||||
|
categoryId: cat.id!,
|
||||||
|
categoryRatings,
|
||||||
|
assessments: instAssessments,
|
||||||
|
catItems,
|
||||||
|
})
|
||||||
|
if (score === null) continue
|
||||||
|
history.push({ meetingId: meeting.id!, date: meeting.date, score })
|
||||||
|
}
|
||||||
trends.set(cat.id!, computeTrend(history, weightStep))
|
trends.set(cat.id!, computeTrend(history, weightStep))
|
||||||
}
|
}
|
||||||
setCatTrends(trends)
|
setCatTrends(trends)
|
||||||
|
|
@ -146,17 +167,23 @@ export default function AssignmentFeedbackPage() {
|
||||||
setCategoryTexts(catTxts)
|
setCategoryTexts(catTxts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle Dimensionen initial expandiert
|
setExpanded(new Set(visible.dimensions.map(d => d.id!)))
|
||||||
setExpanded(new Set(structure.dimensions.map(d => d.id!)))
|
|
||||||
}, [assignmentId, instId])
|
}, [assignmentId, instId])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
// ── Rating setzen ─────────────────────────────────────────────────────────
|
// ── Rating setzen ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const setRating = async (categoryId: number, rating: FeedbackRating) => {
|
const setRating = async (categoryId: number, rating: RatingScore | null) => {
|
||||||
if (!feedback?.id) return
|
if (!feedback?.id) return
|
||||||
const existing = ratings.find(r => r.feedbackCategoryId === categoryId)
|
const existing = ratings.find(r => r.feedbackCategoryId === categoryId)
|
||||||
|
if (rating === null) {
|
||||||
|
if (existing?.id) {
|
||||||
|
await deleteFeedbackCategoryRating(existing.id)
|
||||||
|
setRatings(prev => prev.filter(r => r.feedbackCategoryId !== categoryId))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
await updateFeedbackCategoryRating(existing.id, { rating })
|
await updateFeedbackCategoryRating(existing.id, { rating })
|
||||||
setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r))
|
setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r))
|
||||||
|
|
@ -168,8 +195,8 @@ export default function AssignmentFeedbackPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRating = (categoryId: number): FeedbackRating | undefined =>
|
const getRating = (categoryId: number): RatingScore | null =>
|
||||||
ratings.find(r => r.feedbackCategoryId === categoryId)?.rating
|
ratings.find(r => r.feedbackCategoryId === categoryId)?.rating ?? null
|
||||||
|
|
||||||
// ── Dimension-Text speichern (debounced) ──────────────────────────────────
|
// ── Dimension-Text speichern (debounced) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -201,15 +228,15 @@ 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) => {
|
const appendTrendSentence = (cat: FeedbackCategory, trend: TrendResult) => {
|
||||||
if (trend.trend !== 'up' && trend.trend !== 'down') return
|
if (trend.trend !== 'up' && trend.trend !== 'down') return
|
||||||
const field = trend.trend === 'up' ? 'achievements' : 'developmentNeeds'
|
const field = trend.trend === 'up' ? 'benefits' : 'concerns'
|
||||||
const firstLabel = RATING_OPTIONS.find(o => o.value === numberToRating(trend.history[0].score))?.short
|
const firstLabel = String(numberToRating(trend.history[0].score) ?? trend.history[0].score.toFixed(1))
|
||||||
const lastLabel = RATING_OPTIONS.find(o => o.value === numberToRating(trend.history[trend.history.length - 1].score))?.short
|
const lastLabel = String(numberToRating(trend.history[trend.history.length - 1].score) ?? trend.history[trend.history.length - 1].score.toFixed(1))
|
||||||
const verb = trend.trend === 'up' ? 'Verbesserung' : 'Verschlechterung'
|
const verb = trend.trend === 'up' ? 'Verbesserung' : 'Verschlechterung'
|
||||||
const sentence = `Zeigt im Verlauf des Assignments in ${cat.name} eine ${verb} von ${firstLabel} zu ${lastLabel}.`
|
const sentence = `Zeigt im Verlauf des Assignments in ${cat.name} eine ${verb} von ${firstLabel} zu ${lastLabel}.`
|
||||||
const existing = getDimText(dimId)?.[field] ?? ''
|
const existing = getCategoryText(cat.id!)?.[field] ?? ''
|
||||||
saveDimText(dimId, field, existing ? `${existing}\n${sentence}` : sentence)
|
saveCategoryField(cat.id!, field, existing ? `${existing}\n${sentence}` : sentence)
|
||||||
}
|
}
|
||||||
|
|
||||||
const acceptDimSuggestion = (dimId: number, field: 'achievements' | 'developmentNeeds') => {
|
const acceptDimSuggestion = (dimId: number, field: 'achievements' | 'developmentNeeds') => {
|
||||||
|
|
@ -229,42 +256,55 @@ export default function AssignmentFeedbackPage() {
|
||||||
if (existing?.id) await updateFeedbackDimensionText(existing.id, { [sugField]: '' })
|
if (existing?.id) await updateFeedbackDimensionText(existing.id, { [sugField]: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Kategorie-Text speichern (debounced) ──────────────────────────────────
|
// ── Kategorie Benefits/Concerns speichern (debounced) ─────────────────────
|
||||||
|
|
||||||
const getCategoryText = (catId: number) => categoryTexts.find(t => t.feedbackCategoryId === catId)
|
const getCategoryText = (catId: number) => categoryTexts.find(t => t.feedbackCategoryId === catId)
|
||||||
|
|
||||||
const saveCategoryText = (catId: number, value: string) => {
|
const saveCategoryField = (catId: number, field: 'benefits' | 'concerns', value: string) => {
|
||||||
setCategoryTexts(prev => {
|
setCategoryTexts(prev => {
|
||||||
const existing = prev.find(t => t.feedbackCategoryId === catId)
|
const existing = prev.find(t => t.feedbackCategoryId === catId)
|
||||||
if (existing) return prev.map(t => t.feedbackCategoryId === catId ? { ...t, text: value } : t)
|
if (existing) return prev.map(t => t.feedbackCategoryId === catId ? { ...t, [field]: value } : t)
|
||||||
return [...prev, { assignmentFeedbackId: feedback?.id ?? 0, feedbackCategoryId: catId, text: value }]
|
return [...prev, {
|
||||||
|
assignmentFeedbackId: feedback?.id ?? 0,
|
||||||
|
feedbackCategoryId: catId,
|
||||||
|
benefits: '',
|
||||||
|
concerns: '',
|
||||||
|
[field]: value,
|
||||||
|
}]
|
||||||
})
|
})
|
||||||
const key = `cat-${catId}`
|
const key = `cat-${catId}-${field}`
|
||||||
const prev = saveTimer.current.get(key)
|
const prev = saveTimer.current.get(key)
|
||||||
if (prev) clearTimeout(prev)
|
if (prev) clearTimeout(prev)
|
||||||
saveTimer.current.set(key, setTimeout(async () => {
|
saveTimer.current.set(key, setTimeout(async () => {
|
||||||
if (!feedback?.id) return
|
if (!feedback?.id) return
|
||||||
const existing = await getFeedbackCategoryText(feedback.id, catId)
|
const existing = await getFeedbackCategoryText(feedback.id, catId)
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
await updateFeedbackCategoryText(existing.id, { text: value })
|
await updateFeedbackCategoryText(existing.id, { [field]: value })
|
||||||
} else {
|
} else {
|
||||||
await addFeedbackCategoryText({ assignmentFeedbackId: feedback.id, feedbackCategoryId: catId, text: value })
|
await addFeedbackCategoryText({
|
||||||
|
assignmentFeedbackId: feedback.id,
|
||||||
|
feedbackCategoryId: catId,
|
||||||
|
benefits: field === 'benefits' ? value : '',
|
||||||
|
concerns: field === 'concerns' ? value : '',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, 600))
|
}, 600))
|
||||||
}
|
}
|
||||||
|
|
||||||
const acceptCategorySuggestion = (catId: number) => {
|
const acceptCategorySuggestion = (catId: number, field: 'benefits' | 'concerns') => {
|
||||||
const value = getCategoryText(catId)?.suggestedText
|
const row = getCategoryText(catId)
|
||||||
|
const value = field === 'benefits' ? row?.suggestedBenefits : row?.suggestedConcerns
|
||||||
if (!value) return
|
if (!value) return
|
||||||
saveCategoryText(catId, value)
|
saveCategoryField(catId, field, value)
|
||||||
discardCategorySuggestion(catId)
|
discardCategorySuggestion(catId, field)
|
||||||
}
|
}
|
||||||
|
|
||||||
const discardCategorySuggestion = async (catId: number) => {
|
const discardCategorySuggestion = async (catId: number, field: 'benefits' | 'concerns') => {
|
||||||
setCategoryTexts(prev => prev.map(t => t.feedbackCategoryId === catId ? { ...t, suggestedText: '' } : t))
|
const sugField = field === 'benefits' ? 'suggestedBenefits' : 'suggestedConcerns'
|
||||||
|
setCategoryTexts(prev => prev.map(t => t.feedbackCategoryId === catId ? { ...t, [sugField]: '' } : t))
|
||||||
if (!feedback?.id) return
|
if (!feedback?.id) return
|
||||||
const existing = await getFeedbackCategoryText(feedback.id, catId)
|
const existing = await getFeedbackCategoryText(feedback.id, catId)
|
||||||
if (existing?.id) await updateFeedbackCategoryText(existing.id, { suggestedText: '' })
|
if (existing?.id) await updateFeedbackCategoryText(existing.id, { [sugField]: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Prompt-Vorschau (ohne API-Call) ───────────────────────────────────────
|
// ── Prompt-Vorschau (ohne API-Call) ───────────────────────────────────────
|
||||||
|
|
@ -279,7 +319,11 @@ export default function AssignmentFeedbackPage() {
|
||||||
|
|
||||||
setPreviewLoading(prev => new Set(prev).add(dim.id!))
|
setPreviewLoading(prev => new Set(prev).add(dim.id!))
|
||||||
try {
|
try {
|
||||||
const meetings = await listDoneMeetingsChronological(assignmentId)
|
const [meetings, categoryTemplate, dimensionTemplate] = await Promise.all([
|
||||||
|
listDoneMeetingsChronological(assignmentId),
|
||||||
|
getOrSeedDefaultCategoryPromptTemplate(),
|
||||||
|
getOrSeedDefaultDimensionPromptTemplate(),
|
||||||
|
])
|
||||||
const meetingIds = meetings.map(m => m.id!)
|
const meetingIds = meetings.map(m => m.id!)
|
||||||
const [allAssessments, allEntries] = await Promise.all([
|
const [allAssessments, allEntries] = await Promise.all([
|
||||||
listAssessmentsForMeetings(meetingIds),
|
listAssessmentsForMeetings(meetingIds),
|
||||||
|
|
@ -288,60 +332,101 @@ export default function AssignmentFeedbackPage() {
|
||||||
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 dimCategories = categories.filter(c => c.dimensionId === dim.id)
|
const dimCategories = categories.filter(c => c.dimensionId === dim.id)
|
||||||
const dimItems = items.filter(i => dimCategories.some(c => c.id === i.categoryId))
|
|
||||||
|
|
||||||
const promptCfg = await getFeedbackDimensionPrompt(dim.id!)
|
const firstCat = dimCategories[0]
|
||||||
const context = buildDimensionPromptContext({
|
const catPreview = firstCat
|
||||||
institutee, assignment, dimension: dim, dimCategories, dimItems,
|
? renderPrompt(categoryTemplate, buildCategoryPromptContext({
|
||||||
ratings, catTrends, instAssessments, instEntries, meetings,
|
institutee, assignment, dimension: dim, category: firstCat,
|
||||||
})
|
catItems: items.filter(i => i.categoryId === firstCat.id),
|
||||||
const rendered = renderDimensionPrompt(promptCfg.promptTemplate, context)
|
ratings, catTrend: catTrends.get(firstCat.id!), instAssessments, instEntries, meetings,
|
||||||
|
}))
|
||||||
|
: '(keine Kategorien)'
|
||||||
|
|
||||||
|
const sampleResults: CategoryCondenseEntry[] = dimCategories.map(c => ({
|
||||||
|
name: c.name,
|
||||||
|
score: catTrends.get(c.id!)?.suggestion ?? null,
|
||||||
|
benefits: '(Beispiel — wird aus Stufe A befüllt)',
|
||||||
|
concerns: '(Beispiel — wird aus Stufe A befüllt)',
|
||||||
|
}))
|
||||||
|
const dimPreview = renderPrompt(dimensionTemplate, buildDimensionCondenseContext({
|
||||||
|
institutee, assignment, dimension: dim, categoryResults: sampleResults,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const rendered = `=== Stufe A: Kategorie-Prompt (Beispiel: ${firstCat?.name ?? '—'}) ===\n\n${catPreview}\n\n=== Stufe B: Dimensions-Verdichtung ===\n\n${dimPreview}`
|
||||||
setPreviewText(prev => ({ ...prev, [dim.id!]: rendered }))
|
setPreviewText(prev => ({ ...prev, [dim.id!]: rendered }))
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewLoading(prev => { const next = new Set(prev); next.delete(dim.id!); return next })
|
setPreviewLoading(prev => { const next = new Set(prev); next.delete(dim.id!); return next })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── KI-Generierung: ein Call pro Dimension ────────────────────────────────
|
const saveCategorySuggestions = async (
|
||||||
|
catId: number,
|
||||||
|
parsed: { benefits: string; concerns: string },
|
||||||
|
) => {
|
||||||
|
if (!feedback?.id) return
|
||||||
|
const existing = await getFeedbackCategoryText(feedback.id, catId)
|
||||||
|
if (existing?.id) {
|
||||||
|
await updateFeedbackCategoryText(existing.id, {
|
||||||
|
suggestedBenefits: parsed.benefits,
|
||||||
|
suggestedConcerns: parsed.concerns,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await addFeedbackCategoryText({
|
||||||
|
assignmentFeedbackId: feedback.id,
|
||||||
|
feedbackCategoryId: catId,
|
||||||
|
benefits: '', concerns: '',
|
||||||
|
suggestedBenefits: parsed.benefits,
|
||||||
|
suggestedConcerns: parsed.concerns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const generateOneDimension = async (
|
// ── KI-Kaskade: Stufe A (Kategorie) → Stufe B (Dimension) ─────────────────
|
||||||
|
|
||||||
|
const generateOneCategory = async (
|
||||||
dim: FeedbackDimension,
|
dim: FeedbackDimension,
|
||||||
|
cat: FeedbackCategory,
|
||||||
meetings: MeetingInstance[],
|
meetings: MeetingInstance[],
|
||||||
instAssessments: Assessment[],
|
instAssessments: Assessment[],
|
||||||
instEntries: ConversationEntry[],
|
instEntries: ConversationEntry[],
|
||||||
|
categoryTemplate: string,
|
||||||
|
settings: { apiKey: string; model: string },
|
||||||
|
): Promise<CategoryCondenseEntry> => {
|
||||||
|
if (!institutee || !assignment || !feedback?.id) {
|
||||||
|
return { name: cat.name, score: null, benefits: '', concerns: '' }
|
||||||
|
}
|
||||||
|
const catItems = items.filter(i => i.categoryId === cat.id)
|
||||||
|
const context = buildCategoryPromptContext({
|
||||||
|
institutee, assignment, dimension: dim, category: cat, catItems,
|
||||||
|
ratings, catTrend: catTrends.get(cat.id!), instAssessments, instEntries, meetings,
|
||||||
|
})
|
||||||
|
const prompt = renderPrompt(categoryTemplate, context)
|
||||||
|
const text = await callOpenRouter(prompt, settings)
|
||||||
|
const parsed = parseCategoryAiResponse(text)
|
||||||
|
await saveCategorySuggestions(cat.id!, parsed)
|
||||||
|
return {
|
||||||
|
name: cat.name,
|
||||||
|
score: parsed.score,
|
||||||
|
benefits: parsed.benefits,
|
||||||
|
concerns: parsed.concerns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateDimensionCondense = async (
|
||||||
|
dim: FeedbackDimension,
|
||||||
|
categoryResults: CategoryCondenseEntry[],
|
||||||
settings: { apiKey: string; model: string },
|
settings: { apiKey: string; model: string },
|
||||||
) => {
|
) => {
|
||||||
if (!institutee || !assignment || !feedback?.id) return
|
if (!institutee || !assignment || !feedback?.id) return
|
||||||
const dimCategories = categories.filter(c => c.dimensionId === dim.id)
|
|
||||||
const dimItems = items.filter(i => dimCategories.some(c => c.id === i.categoryId))
|
|
||||||
|
|
||||||
const promptCfg = await getFeedbackDimensionPrompt(dim.id!)
|
const promptCfg = await getFeedbackDimensionPrompt(dim.id!)
|
||||||
const template = promptCfg?.promptTemplate ?? AI_CONFIG.defaultDimensionPromptTemplate
|
const template = promptCfg?.promptTemplate ?? AI_CONFIG.defaultDimensionPromptTemplate
|
||||||
|
const context = buildDimensionCondenseContext({
|
||||||
const context = buildDimensionPromptContext({
|
institutee, assignment, dimension: dim, categoryResults,
|
||||||
institutee, assignment, dimension: dim, dimCategories, dimItems,
|
|
||||||
ratings, catTrends, instAssessments, instEntries, meetings,
|
|
||||||
})
|
})
|
||||||
const prompt = renderDimensionPrompt(template, context)
|
const prompt = renderPrompt(template, context)
|
||||||
|
const text = await callOpenRouter(prompt, settings)
|
||||||
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
const parsed = parseDimensionCondenseResponse(text)
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${settings.apiKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'HTTP-Referer': window.location.origin,
|
|
||||||
'X-Title': 'Assignment Monitor',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: settings.model,
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`API-Fehler: ${res.status} ${res.statusText}`)
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
const text = data.choices?.[0]?.message?.content ?? ''
|
|
||||||
const parsed = parseDimensionAiResponse(text)
|
|
||||||
|
|
||||||
const existingDimText = await getFeedbackDimensionText(feedback.id, dim.id!)
|
const existingDimText = await getFeedbackDimensionText(feedback.id, dim.id!)
|
||||||
if (existingDimText?.id) {
|
if (existingDimText?.id) {
|
||||||
|
|
@ -358,17 +443,43 @@ export default function AssignmentFeedbackPage() {
|
||||||
suggestedDevelopmentNeeds: parsed.developmentNeeds,
|
suggestedDevelopmentNeeds: parsed.developmentNeeds,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const ct of parsed.categoryTexts) {
|
const generateOneDimension = async (
|
||||||
const cat = dimCategories.find(c => c.name === ct.name || ct.name.includes(c.name) || c.name.includes(ct.name))
|
dim: FeedbackDimension,
|
||||||
if (!cat?.id) continue
|
dimIndex: number,
|
||||||
const existing = await getFeedbackCategoryText(feedback.id, cat.id)
|
dimTotal: number,
|
||||||
if (existing?.id) {
|
meetings: MeetingInstance[],
|
||||||
await updateFeedbackCategoryText(existing.id, { suggestedText: ct.text })
|
instAssessments: Assessment[],
|
||||||
} else {
|
instEntries: ConversationEntry[],
|
||||||
await addFeedbackCategoryText({ assignmentFeedbackId: feedback.id, feedbackCategoryId: cat.id, text: '', suggestedText: ct.text })
|
categoryTemplate: string,
|
||||||
|
settings: { apiKey: string; model: string },
|
||||||
|
) => {
|
||||||
|
if (!institutee || !assignment || !feedback?.id) return
|
||||||
|
const dimCategories = categories.filter(c => c.dimensionId === dim.id)
|
||||||
|
const categoryResults: CategoryCondenseEntry[] = []
|
||||||
|
|
||||||
|
for (let ci = 0; ci < dimCategories.length; ci++) {
|
||||||
|
const cat = dimCategories[ci]
|
||||||
|
const errKey = `${dim.id}:${cat.id}`
|
||||||
|
setGenerationProgress(
|
||||||
|
`Dimension ${dimIndex + 1}/${dimTotal} · Kategorie ${ci + 1}/${dimCategories.length}: ${cat.name}…`,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const result = await generateOneCategory(
|
||||||
|
dim, cat, meetings, instAssessments, instEntries, categoryTemplate, settings,
|
||||||
|
)
|
||||||
|
categoryResults.push(result)
|
||||||
|
setCategoryErrors(prev => { const next = { ...prev }; delete next[errKey]; return next })
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Unbekannter Fehler'
|
||||||
|
setCategoryErrors(prev => ({ ...prev, [errKey]: msg }))
|
||||||
|
throw new Error(`Kategorie „${cat.name}": ${msg}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setGenerationProgress(`Dimension ${dimIndex + 1}/${dimTotal}: ${dim.name} verdichten…`)
|
||||||
|
await generateDimensionCondense(dim, categoryResults, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sequenziell mit Fortschrittsanzeige — Fehler einzelner Dimensionen stoppen den Lauf nicht. */
|
/** Sequenziell mit Fortschrittsanzeige — Fehler einzelner Dimensionen stoppen den Lauf nicht. */
|
||||||
|
|
@ -384,9 +495,13 @@ export default function AssignmentFeedbackPage() {
|
||||||
for (const d of dimsToRun) delete next[d.id!]
|
for (const d of dimsToRun) delete next[d.id!]
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
setCategoryErrors({})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const meetings = await listDoneMeetingsChronological(assignmentId)
|
const [meetings, categoryTemplate] = await Promise.all([
|
||||||
|
listDoneMeetingsChronological(assignmentId),
|
||||||
|
getOrSeedDefaultCategoryPromptTemplate(),
|
||||||
|
])
|
||||||
const meetingIds = meetings.map(m => m.id!)
|
const meetingIds = meetings.map(m => m.id!)
|
||||||
const [allAssessments, allEntries] = await Promise.all([
|
const [allAssessments, allEntries] = await Promise.all([
|
||||||
listAssessmentsForMeetings(meetingIds),
|
listAssessmentsForMeetings(meetingIds),
|
||||||
|
|
@ -397,11 +512,15 @@ export default function AssignmentFeedbackPage() {
|
||||||
|
|
||||||
for (let i = 0; i < dimsToRun.length; i++) {
|
for (let i = 0; i < dimsToRun.length; i++) {
|
||||||
const dim = dimsToRun[i]
|
const dim = dimsToRun[i]
|
||||||
setGenerationProgress(`Generiere Dimension ${i + 1}/${dimsToRun.length}: ${dim.name}…`)
|
|
||||||
try {
|
try {
|
||||||
await generateOneDimension(dim, meetings, instAssessments, instEntries, settings)
|
await generateOneDimension(
|
||||||
|
dim, i, dimsToRun.length, meetings, instAssessments, instEntries, categoryTemplate, settings,
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setDimensionErrors(prev => ({ ...prev, [dim.id!]: e instanceof Error ? e.message : 'Unbekannter Fehler' }))
|
setDimensionErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[dim.id!]: e instanceof Error ? e.message : 'Unbekannter Fehler',
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await updateAssignmentFeedback(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() })
|
await updateAssignmentFeedback(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() })
|
||||||
|
|
@ -427,6 +546,15 @@ export default function AssignmentFeedbackPage() {
|
||||||
setFeedback(prev => prev ? { ...prev, status: next } : null)
|
setFeedback(prev => prev ? { ...prev, status: next } : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExportPdf = async () => {
|
||||||
|
setExportingPdf(true)
|
||||||
|
try {
|
||||||
|
await exportInstituteeFeedbackPdf(assignmentId, instId)
|
||||||
|
} finally {
|
||||||
|
setExportingPdf(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!assignment || !institutee) return <div className="text-center py-10 text-gray-400">Lädt…</div>
|
if (!assignment || !institutee) return <div className="text-center py-10 text-gray-400">Lädt…</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -454,6 +582,10 @@ export default function AssignmentFeedbackPage() {
|
||||||
className="text-xs text-gray-400 hover:text-gray-600">
|
className="text-xs text-gray-400 hover:text-gray-600">
|
||||||
⚙ KI-Einstellungen
|
⚙ KI-Einstellungen
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={handleExportPdf} disabled={exportingPdf}
|
||||||
|
className="text-xs text-gray-400 hover:text-gray-600 disabled:opacity-50">
|
||||||
|
{exportingPdf ? 'Erstelle PDF…' : '⬇ Als PDF exportieren'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -512,16 +644,28 @@ export default function AssignmentFeedbackPage() {
|
||||||
{aiError}
|
{aiError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!generating && Object.keys(dimensionErrors).length > 0 && (
|
{!generating && (Object.keys(dimensionErrors).length > 0 || Object.keys(categoryErrors).length > 0) && (
|
||||||
<div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 space-y-1.5">
|
<div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 space-y-1.5">
|
||||||
|
{Object.keys(dimensionErrors).length > 0 && (
|
||||||
|
<>
|
||||||
<div className="font-medium">Fehler bei {Object.keys(dimensionErrors).length} Dimension(en):</div>
|
<div className="font-medium">Fehler bei {Object.keys(dimensionErrors).length} Dimension(en):</div>
|
||||||
{Object.entries(dimensionErrors).map(([dimId, msg]) => (
|
{Object.entries(dimensionErrors).map(([dimId, msg]) => (
|
||||||
<div key={dimId}>• {dimensions.find(d => d.id === Number(dimId))?.name}: {msg}</div>
|
<div key={dimId}>• {dimensions.find(d => d.id === Number(dimId))?.name}: {msg}</div>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{Object.entries(categoryErrors).map(([key, msg]) => {
|
||||||
|
const [dimId, catId] = key.split(':').map(Number)
|
||||||
|
const dimName = dimensions.find(d => d.id === dimId)?.name ?? 'Dimension'
|
||||||
|
const catName = categories.find(c => c.id === catId)?.name ?? 'Kategorie'
|
||||||
|
return <div key={key}>• {dimName} → {catName}: {msg}</div>
|
||||||
|
})}
|
||||||
|
{Object.keys(dimensionErrors).length > 0 && (
|
||||||
<button onClick={retryFailedDimensions}
|
<button onClick={retryFailedDimensions}
|
||||||
className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded-lg font-medium hover:bg-red-200">
|
className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded-lg font-medium hover:bg-red-200">
|
||||||
Fehlgeschlagene Dimensionen erneut versuchen
|
Fehlgeschlagene Dimensionen erneut versuchen
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -531,14 +675,9 @@ export default function AssignmentFeedbackPage() {
|
||||||
const isOpen = expanded.has(dim.id!)
|
const isOpen = expanded.has(dim.id!)
|
||||||
const text = getDimText(dim.id!)
|
const text = getDimText(dim.id!)
|
||||||
|
|
||||||
// Zusammenfassung der Bewertungen
|
const catRatings = dimCats.map(c => getRating(c.id!)).filter(isValidRatingScore)
|
||||||
const catRatings = dimCats.map(c => getRating(c.id!)).filter(Boolean) as FeedbackRating[]
|
const worstRating = catRatings.length > 0 ? Math.min(...catRatings) : null
|
||||||
const worstRating = catRatings.includes('not_client_ready') ? 'not_client_ready'
|
const summaryStyle = resolveScoreStyle(worstRating, colorBands)
|
||||||
: catRatings.includes('partially_client_ready') ? 'partially_client_ready'
|
|
||||||
: catRatings.includes('nearly_client_ready') ? 'nearly_client_ready'
|
|
||||||
: catRatings.includes('client_ready') ? 'client_ready'
|
|
||||||
: catRatings.length > 0 ? 'na' : undefined
|
|
||||||
const summaryColor = ratingColor(worstRating)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={dim.id} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
<div key={dim.id} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
|
@ -553,9 +692,9 @@ export default function AssignmentFeedbackPage() {
|
||||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition">
|
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-gray-800 text-sm">{dim.name}</span>
|
<span className="font-semibold text-gray-800 text-sm">{dim.name}</span>
|
||||||
{worstRating && (
|
{worstRating !== null && (
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${summaryColor}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium tabular-nums ${summaryStyle.color}`}>
|
||||||
{RATING_OPTIONS.find(r => r.value === worstRating)?.short}
|
{summaryStyle.short}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -599,31 +738,21 @@ export default function AssignmentFeedbackPage() {
|
||||||
<div key={cat.id} className="space-y-2">
|
<div key={cat.id} className="space-y-2">
|
||||||
<div className="font-medium text-sm text-gray-700">{cat.name}</div>
|
<div className="font-medium text-sm text-gray-700">{cat.name}</div>
|
||||||
|
|
||||||
{/* Rating-Buttons */}
|
<ScorePicker
|
||||||
<div className="flex gap-1 flex-wrap">
|
value={currentRating ?? null}
|
||||||
{RATING_OPTIONS.map(r => (
|
onChange={score => setRating(cat.id!, score)}
|
||||||
<button key={r.value}
|
bands={colorBands}
|
||||||
onClick={() => setRating(cat.id!, r.value)}
|
/>
|
||||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-semibold border transition ${
|
|
||||||
currentRating === r.value
|
|
||||||
? r.color + ' ring-2 ring-offset-1 ring-current'
|
|
||||||
: 'bg-gray-50 text-gray-400 border-gray-200 hover:bg-gray-100'
|
|
||||||
}`}>
|
|
||||||
{r.short}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bewertungs-Vorschlag aus Meeting-Assessments */}
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const trend = catTrends.get(cat.id!)
|
const trend = catTrends.get(cat.id!)
|
||||||
if (!trend || trend.suggestion === null) return null
|
if (!trend || trend.suggestion === null) return null
|
||||||
const suggestedRating = numberToRating(trend.suggestion)
|
const suggestedRating = numberToRating(trend.suggestion)
|
||||||
const shortLabel = RATING_OPTIONS.find(o => o.value === suggestedRating)?.short
|
if (suggestedRating === null) return null
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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">
|
<span className="text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-1 rounded-lg font-medium tabular-nums">
|
||||||
Vorschlag: {shortLabel} ({trend.suggestion.toFixed(1)}) {trendArrow(trend.trend)}
|
Vorschlag: {suggestedRating} ({trend.suggestion.toFixed(1)}) {trendArrow(trend.trend)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRating(cat.id!, suggestedRating)}
|
onClick={() => setRating(cat.id!, suggestedRating)}
|
||||||
|
|
@ -632,7 +761,7 @@ export default function AssignmentFeedbackPage() {
|
||||||
</button>
|
</button>
|
||||||
{(trend.trend === 'up' || trend.trend === 'down') && (
|
{(trend.trend === 'up' || trend.trend === 'down') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => appendTrendSentence(dim.id!, cat, trend)}
|
onClick={() => appendTrendSentence(cat, trend)}
|
||||||
className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-lg font-medium hover:bg-gray-200">
|
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
|
Trend in Text übernehmen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -653,22 +782,41 @@ export default function AssignmentFeedbackPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Kategorie-Notiz */}
|
{/* Kategorie Benefits / Concerns */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-green-700">Benefits</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand min-h-[50px]"
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand min-h-[44px]"
|
||||||
placeholder="Kurze Einschätzung zu dieser Kategorie…"
|
placeholder="Positive Beobachtungen zu dieser Kategorie…"
|
||||||
value={catText?.text ?? ''}
|
value={catText?.benefits ?? ''}
|
||||||
onChange={e => saveCategoryText(cat.id!, e.target.value)}
|
onChange={e => saveCategoryField(cat.id!, 'benefits', e.target.value)}
|
||||||
/>
|
/>
|
||||||
{catText?.suggestedText && (
|
{catText?.suggestedBenefits && (
|
||||||
<AiSuggestionBox
|
<AiSuggestionBox
|
||||||
text={catText.suggestedText}
|
text={catText.suggestedBenefits}
|
||||||
onAccept={() => acceptCategorySuggestion(cat.id!)}
|
onAccept={() => acceptCategorySuggestion(cat.id!, 'benefits')}
|
||||||
onDiscard={() => discardCategorySuggestion(cat.id!)}
|
onDiscard={() => discardCategorySuggestion(cat.id!, 'benefits')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-amber-700">Concerns</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand min-h-[44px]"
|
||||||
|
placeholder="Entwicklungsfelder / Risiken zu dieser Kategorie…"
|
||||||
|
value={catText?.concerns ?? ''}
|
||||||
|
onChange={e => saveCategoryField(cat.id!, 'concerns', e.target.value)}
|
||||||
|
/>
|
||||||
|
{catText?.suggestedConcerns && (
|
||||||
|
<AiSuggestionBox
|
||||||
|
text={catText.suggestedConcerns}
|
||||||
|
onAccept={() => acceptCategorySuggestion(cat.id!, 'concerns')}
|
||||||
|
onDiscard={() => discardCategorySuggestion(cat.id!, 'concerns')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -677,11 +825,11 @@ export default function AssignmentFeedbackPage() {
|
||||||
<div className="space-y-3 pt-2 border-t border-gray-50">
|
<div className="space-y-3 pt-2 border-t border-gray-50">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-1.5">
|
<label className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-1.5">
|
||||||
Achievements
|
Benefits (Dimension)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-green-400 min-h-[80px]"
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-green-400 min-h-[80px]"
|
||||||
placeholder="Konkrete Stärken und positive Beobachtungen…"
|
placeholder="3–5 Hauptstärken dieser Dimension…"
|
||||||
value={text?.achievements ?? ''}
|
value={text?.achievements ?? ''}
|
||||||
onChange={e => saveDimText(dim.id!, 'achievements', e.target.value)}
|
onChange={e => saveDimText(dim.id!, 'achievements', e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -695,11 +843,11 @@ export default function AssignmentFeedbackPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5">
|
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5">
|
||||||
Development Needs
|
Concerns (Dimension)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-amber-400 min-h-[80px]"
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-amber-400 min-h-[80px]"
|
||||||
placeholder="Entwicklungsfelder und konkrete Handlungsempfehlungen…"
|
placeholder="3–5 zentrale Entwicklungsfelder dieser Dimension…"
|
||||||
value={text?.developmentNeeds ?? ''}
|
value={text?.developmentNeeds ?? ''}
|
||||||
onChange={e => saveDimText(dim.id!, 'developmentNeeds', e.target.value)}
|
onChange={e => saveDimText(dim.id!, 'developmentNeeds', e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
|
type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
|
||||||
|
type ChecklistItemDef,
|
||||||
listAssignmentTypes, listPhaseTemplates, loadFeedbackStructure,
|
listAssignmentTypes, listPhaseTemplates, loadFeedbackStructure,
|
||||||
addAssignmentType, updateAssignmentType, deleteAssignmentType,
|
addAssignmentType, updateAssignmentType, deleteAssignmentType,
|
||||||
addPhaseTemplate, updatePhaseTemplate, deletePhaseTemplate,
|
addPhaseTemplate, updatePhaseTemplate, deletePhaseTemplate,
|
||||||
|
listChecklistItemDefsForPhaseTemplate, addChecklistItemDef, updateChecklistItemDef, deleteChecklistItemDef,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
|
import { exportAssignmentTypesReportAsMarkdown } from '../utils/structureExport'
|
||||||
|
import { exportAssignmentTypesReportPdf } from '../utils/pdfExport'
|
||||||
|
import { criterionOptionGroups } from '../utils/criterionOptions'
|
||||||
|
|
||||||
export default function AssignmentTypeConfig() {
|
export default function AssignmentTypeConfig() {
|
||||||
|
const [exportingPdf, setExportingPdf] = useState(false)
|
||||||
const [types, setTypes] = useState<AssignmentType[]>([])
|
const [types, setTypes] = useState<AssignmentType[]>([])
|
||||||
const [templates, setTemplates] = useState<PhaseTemplate[]>([])
|
const [templates, setTemplates] = useState<PhaseTemplate[]>([])
|
||||||
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
||||||
|
|
@ -19,7 +25,12 @@ export default function AssignmentTypeConfig() {
|
||||||
const [typeError, setTypeError] = useState<Record<number, string>>({})
|
const [typeError, setTypeError] = useState<Record<number, string>>({})
|
||||||
const [newPhase, setNewPhase] = useState<Record<number, {
|
const [newPhase, setNewPhase] = useState<Record<number, {
|
||||||
label: string; withWhom: 'lead' | 'client'
|
label: string; withWhom: 'lead' | 'client'
|
||||||
isRepeatable: boolean; hasConversation: boolean; hasAssessment: boolean
|
isRepeatable: boolean; hasConversation: boolean; hasAssessment: boolean; hasChecklist: boolean
|
||||||
|
}>>({})
|
||||||
|
const [checklistItems, setChecklistItems] = useState<ChecklistItemDef[]>([])
|
||||||
|
const [expandedChecklist, setExpandedChecklist] = useState<Set<number>>(new Set())
|
||||||
|
const [newChecklistItem, setNewChecklistItem] = useState<Record<number, {
|
||||||
|
label: string; scope: 'perInstitutee' | 'meetingWide'; linkedCriterionItemId: string
|
||||||
}>>({})
|
}>>({})
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
|
@ -33,6 +44,8 @@ export default function AssignmentTypeConfig() {
|
||||||
setDimensions(structure.dimensions)
|
setDimensions(structure.dimensions)
|
||||||
setCategories(structure.categories)
|
setCategories(structure.categories)
|
||||||
setItems(structure.items)
|
setItems(structure.items)
|
||||||
|
const checklistLists = await Promise.all(tmpl.map(p => listChecklistItemDefsForPhaseTemplate(p.id!)))
|
||||||
|
setChecklistItems(checklistLists.flat())
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
@ -74,7 +87,7 @@ export default function AssignmentTypeConfig() {
|
||||||
const toggleAllInCategory = async (type: AssignmentType, catId: number) => {
|
const toggleAllInCategory = async (type: AssignmentType, catId: number) => {
|
||||||
const catItemIds = items.filter(i => i.categoryId === catId).map(i => i.id!)
|
const catItemIds = items.filter(i => i.categoryId === catId).map(i => i.id!)
|
||||||
const current = type.criteriaIds ?? []
|
const current = type.criteriaIds ?? []
|
||||||
const allSelected = catItemIds.every(id => current.includes(id))
|
const allSelected = catItemIds.length > 0 && catItemIds.every(id => current.includes(id))
|
||||||
const next = allSelected
|
const next = allSelected
|
||||||
? current.filter(id => !catItemIds.includes(id))
|
? current.filter(id => !catItemIds.includes(id))
|
||||||
: [...new Set([...current, ...catItemIds])]
|
: [...new Set([...current, ...catItemIds])]
|
||||||
|
|
@ -82,6 +95,23 @@ export default function AssignmentTypeConfig() {
|
||||||
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
|
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleAllInDimension = async (type: AssignmentType, dimId: number) => {
|
||||||
|
const dimCatIds = categories.filter(c => c.dimensionId === dimId).map(c => c.id!)
|
||||||
|
const dimItemIds = items.filter(i => dimCatIds.includes(i.categoryId)).map(i => i.id!)
|
||||||
|
const current = type.criteriaIds ?? []
|
||||||
|
const allSelected = dimItemIds.length > 0 && dimItemIds.every(id => current.includes(id))
|
||||||
|
const next = allSelected
|
||||||
|
? current.filter(id => !dimItemIds.includes(id))
|
||||||
|
: [...new Set([...current, ...dimItemIds])]
|
||||||
|
await updateAssignmentType(type.id!, { criteriaIds: next })
|
||||||
|
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearStructureFilter = async (type: AssignmentType) => {
|
||||||
|
await updateAssignmentType(type.id!, { criteriaIds: [] })
|
||||||
|
setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: [] } : t))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Phasen-Templates ───────────────────────────────────────────────────────
|
// ── Phasen-Templates ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const addPhase = async (typeId: number) => {
|
const addPhase = async (typeId: number) => {
|
||||||
|
|
@ -96,10 +126,11 @@ export default function AssignmentTypeConfig() {
|
||||||
isRepeatable: np.isRepeatable,
|
isRepeatable: np.isRepeatable,
|
||||||
hasConversation: np.hasConversation,
|
hasConversation: np.hasConversation,
|
||||||
hasAssessment: np.hasAssessment,
|
hasAssessment: np.hasAssessment,
|
||||||
|
hasChecklist: np.hasChecklist,
|
||||||
criteriaIds: [],
|
criteriaIds: [],
|
||||||
order: existing.length + 1,
|
order: existing.length + 1,
|
||||||
})
|
})
|
||||||
setNewPhase(prev => ({ ...prev, [typeId]: { label: '', withWhom: 'lead', isRepeatable: false, hasConversation: false, hasAssessment: false } }))
|
setNewPhase(prev => ({ ...prev, [typeId]: { label: '', withWhom: 'lead', isRepeatable: false, hasConversation: false, hasAssessment: false, hasChecklist: false } }))
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,8 +144,51 @@ export default function AssignmentTypeConfig() {
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Checkliste pro Phase ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const addChecklistItem = async (phaseId: number) => {
|
||||||
|
const nci = newChecklistItem[phaseId]
|
||||||
|
if (!nci?.label.trim()) return
|
||||||
|
const existing = checklistItems.filter(c => c.phaseTemplateId === phaseId)
|
||||||
|
await addChecklistItemDef({
|
||||||
|
phaseTemplateId: phaseId,
|
||||||
|
label: nci.label.trim(),
|
||||||
|
scope: nci.scope,
|
||||||
|
linkedCriterionItemId: nci.linkedCriterionItemId ? Number(nci.linkedCriterionItemId) : undefined,
|
||||||
|
order: existing.length + 1,
|
||||||
|
})
|
||||||
|
setNewChecklistItem(prev => ({ ...prev, [phaseId]: { label: '', scope: 'perInstitutee', linkedCriterionItemId: '' } }))
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateChecklistItem = async (id: number, patch: Partial<ChecklistItemDef>) => {
|
||||||
|
await updateChecklistItemDef(id, patch)
|
||||||
|
setChecklistItems(prev => prev.map(c => c.id === id ? { ...c, ...patch } : c))
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteChecklistItem = async (id: number) => {
|
||||||
|
await deleteChecklistItemDef(id)
|
||||||
|
setChecklistItems(prev => prev.filter(c => c.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">Assignment-Typen</h2>
|
||||||
|
<div className="flex gap-1.5 flex-shrink-0">
|
||||||
|
<button onClick={() => exportAssignmentTypesReportAsMarkdown()}
|
||||||
|
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
|
||||||
|
⬇ MD
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => { setExportingPdf(true); try { await exportAssignmentTypesReportPdf() } finally { setExportingPdf(false) } }}
|
||||||
|
disabled={exportingPdf}
|
||||||
|
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition disabled:opacity-50">
|
||||||
|
{exportingPdf ? '⏳' : '⬇ PDF'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Neuer Typ */}
|
{/* Neuer Typ */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
|
|
@ -138,7 +212,7 @@ export default function AssignmentTypeConfig() {
|
||||||
const isOpen = expanded === type.id
|
const isOpen = expanded === type.id
|
||||||
const typPhases = templates.filter(t => t.assignmentTypeId === type.id).sort((a, b) => a.order - b.order)
|
const typPhases = templates.filter(t => t.assignmentTypeId === type.id).sort((a, b) => a.order - b.order)
|
||||||
const tab = activeTab[type.id!] ?? 'phases'
|
const tab = activeTab[type.id!] ?? 'phases'
|
||||||
const np = newPhase[type.id!] ?? { label: '', withWhom: 'lead' as const, isRepeatable: false, hasConversation: false, hasAssessment: false }
|
const np = newPhase[type.id!] ?? { label: '', withWhom: 'lead' as const, isRepeatable: false, hasConversation: false, hasAssessment: false, hasChecklist: false }
|
||||||
const selected = type.criteriaIds ?? []
|
const selected = type.criteriaIds ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -150,7 +224,7 @@ export default function AssignmentTypeConfig() {
|
||||||
value={type.name}
|
value={type.name}
|
||||||
onChange={e => updateTypeName(type.id!, e.target.value)}
|
onChange={e => updateTypeName(type.id!, e.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-400">{typPhases.length} Phasen · {selected.length ? `${selected.length} Kriterien` : 'alle Kriterien'}</span>
|
<span className="text-xs text-gray-400">{typPhases.length} Phasen · {selected.length ? `${selected.length} Kriterien` : 'gesamte Struktur'}</span>
|
||||||
<button onClick={() => setExpanded(isOpen ? null : type.id!)}
|
<button onClick={() => setExpanded(isOpen ? null : type.id!)}
|
||||||
className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
|
className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
|
||||||
<button onClick={() => deleteType(type.id!)}
|
<button onClick={() => deleteType(type.id!)}
|
||||||
|
|
@ -173,7 +247,7 @@ export default function AssignmentTypeConfig() {
|
||||||
className={`flex-1 py-2 text-xs font-medium transition ${
|
className={`flex-1 py-2 text-xs font-medium transition ${
|
||||||
tab === t ? 'text-brand border-b-2 border-brand' : 'text-gray-400'
|
tab === t ? 'text-brand border-b-2 border-brand' : 'text-gray-400'
|
||||||
}`}>
|
}`}>
|
||||||
{t === 'phases' ? `Phasen (${typPhases.length})` : `Kriterien (${selected.length || 'alle'})`}
|
{t === 'phases' ? `Phasen (${typPhases.length})` : `Struktur (${selected.length || 'alle'})`}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -216,7 +290,90 @@ export default function AssignmentTypeConfig() {
|
||||||
onChange={e => updatePhase(phase.id!, { hasAssessment: e.target.checked })} />
|
onChange={e => updatePhase(phase.id!, { hasAssessment: e.target.checked })} />
|
||||||
Bewertung
|
Bewertung
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={phase.hasChecklist}
|
||||||
|
onChange={e => updatePhase(phase.id!, { hasChecklist: e.target.checked })} />
|
||||||
|
Checkliste
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{phase.hasChecklist && (() => {
|
||||||
|
const phaseChecklist = checklistItems
|
||||||
|
.filter(c => c.phaseTemplateId === phase.id)
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
const isChecklistOpen = expandedChecklist.has(phase.id!)
|
||||||
|
const nci = newChecklistItem[phase.id!] ?? { label: '', scope: 'perInstitutee' as const, linkedCriterionItemId: '' }
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-100 pt-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedChecklist(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(phase.id!)) next.delete(phase.id!); else next.add(phase.id!)
|
||||||
|
return next
|
||||||
|
})}
|
||||||
|
className="text-xs text-gray-500 font-medium">
|
||||||
|
🗹 Checkliste ({phaseChecklist.length}) {isChecklistOpen ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
{isChecklistOpen && (
|
||||||
|
<div className="mt-1.5 space-y-1.5">
|
||||||
|
{phaseChecklist.map(ci => (
|
||||||
|
<div key={ci.id} className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1.5">
|
||||||
|
<input
|
||||||
|
className="flex-1 text-xs bg-transparent border-b border-transparent focus:border-brand focus:outline-none"
|
||||||
|
value={ci.label}
|
||||||
|
onChange={e => updateChecklistItem(ci.id!, { label: e.target.value })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={ci.scope}
|
||||||
|
onChange={e => updateChecklistItem(ci.id!, { scope: e.target.value as 'perInstitutee' | 'meetingWide' })}
|
||||||
|
className="text-xs border border-gray-200 rounded px-1 py-0.5 bg-white focus:outline-none">
|
||||||
|
<option value="perInstitutee">pro Institutee</option>
|
||||||
|
<option value="meetingWide">meeting-weit</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={ci.linkedCriterionItemId ?? ''}
|
||||||
|
onChange={e => updateChecklistItem(ci.id!, { linkedCriterionItemId: e.target.value ? Number(e.target.value) : undefined })}
|
||||||
|
className="text-xs border border-gray-200 rounded px-1 py-0.5 bg-white focus:outline-none max-w-[140px]">
|
||||||
|
<option value="">— frei —</option>
|
||||||
|
{criterionOptionGroups(dimensions, categories, items)}
|
||||||
|
</select>
|
||||||
|
<button onClick={() => deleteChecklistItem(ci.id!)}
|
||||||
|
className="text-red-300 hover:text-red-500 text-xs">×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-brand bg-white"
|
||||||
|
placeholder="Neuer Checklisten-Punkt…"
|
||||||
|
value={nci.label}
|
||||||
|
onChange={e => setNewChecklistItem(prev => ({ ...prev, [phase.id!]: { ...nci, label: e.target.value } }))}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && addChecklistItem(phase.id!)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={nci.scope}
|
||||||
|
onChange={e => setNewChecklistItem(prev => ({ ...prev, [phase.id!]: { ...nci, scope: e.target.value as 'perInstitutee' | 'meetingWide' } }))}
|
||||||
|
className="text-xs border border-gray-200 rounded px-1 py-1 bg-white focus:outline-none">
|
||||||
|
<option value="perInstitutee">pro Institutee</option>
|
||||||
|
<option value="meetingWide">meeting-weit</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={nci.linkedCriterionItemId}
|
||||||
|
onChange={e => setNewChecklistItem(prev => ({ ...prev, [phase.id!]: { ...nci, linkedCriterionItemId: e.target.value } }))}
|
||||||
|
className="text-xs border border-gray-200 rounded px-1 py-1 bg-white focus:outline-none max-w-[140px]">
|
||||||
|
<option value="">— frei —</option>
|
||||||
|
{criterionOptionGroups(dimensions, categories, items)}
|
||||||
|
</select>
|
||||||
|
<button onClick={() => addChecklistItem(phase.id!)}
|
||||||
|
className="text-xs bg-brand text-white px-2 py-1 rounded-lg font-medium">+ Punkt</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-400">
|
||||||
|
Verknüpfte Punkte fließen automatisch in Gesamtbewertung und KI-Auswertung ein — bei „meeting-weit" für alle Institutees gleichzeitig.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -255,6 +412,11 @@ export default function AssignmentTypeConfig() {
|
||||||
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, hasAssessment: e.target.checked } }))} />
|
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, hasAssessment: e.target.checked } }))} />
|
||||||
Bewertung
|
Bewertung
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={np.hasChecklist}
|
||||||
|
onChange={e => setNewPhase(prev => ({ ...prev, [type.id!]: { ...np, hasChecklist: e.target.checked } }))} />
|
||||||
|
Checkliste
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => addPhase(type.id!)}
|
<button onClick={() => addPhase(type.id!)}
|
||||||
className="px-4 py-1.5 bg-brand text-white text-xs rounded-lg font-medium">
|
className="px-4 py-1.5 bg-brand text-white text-xs rounded-lg font-medium">
|
||||||
|
|
@ -267,9 +429,18 @@ export default function AssignmentTypeConfig() {
|
||||||
{/* Kriterien-Tab */}
|
{/* Kriterien-Tab */}
|
||||||
{tab === 'criteria' && (
|
{tab === 'criteria' && (
|
||||||
<div className="p-3 space-y-1">
|
<div className="p-3 space-y-1">
|
||||||
<p className="text-xs text-gray-400 pb-2">
|
<div className="flex items-start justify-between gap-2 pb-2">
|
||||||
Kein Haken = alle Kriterien werden angezeigt.
|
<p className="text-xs text-gray-400">
|
||||||
|
Steuert, welche Dimensionen/Kategorien in Meeting und Feedback sichtbar sind.
|
||||||
|
Kein Haken = gesamte Struktur. Haken setzen schränkt auf die Auswahl ein (gespeichert als Kriterien-IDs).
|
||||||
</p>
|
</p>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<button type="button" onClick={() => clearStructureFilter(type)}
|
||||||
|
className="text-xs text-brand whitespace-nowrap flex-shrink-0">
|
||||||
|
Alles freigeben
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<div className="text-xs text-gray-400">Keine Kriterien konfiguriert. Erst unter "Feedback" anlegen.</div>
|
<div className="text-xs text-gray-400">Keine Kriterien konfiguriert. Erst unter "Feedback" anlegen.</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -279,20 +450,33 @@ export default function AssignmentTypeConfig() {
|
||||||
if (dimItems.length === 0) return null
|
if (dimItems.length === 0) return null
|
||||||
const isDimOpen = expandedDim.has(dim.id!)
|
const isDimOpen = expandedDim.has(dim.id!)
|
||||||
const dimSelectedCount = dimItems.filter(i => selected.includes(i.id!)).length
|
const dimSelectedCount = dimItems.filter(i => selected.includes(i.id!)).length
|
||||||
|
const allDimSelected = dimItems.every(i => selected.includes(i.id!))
|
||||||
|
const someDimSelected = dimItems.some(i => selected.includes(i.id!))
|
||||||
return (
|
return (
|
||||||
<div key={dim.id} className="border border-gray-100 rounded-lg overflow-hidden">
|
<div key={dim.id} className="border border-gray-100 rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allDimSelected}
|
||||||
|
ref={el => { if (el) el.indeterminate = someDimSelected && !allDimSelected }}
|
||||||
|
onChange={() => toggleAllInDimension(type, dim.id!)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
title="Gesamte Dimension"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setExpandedDim(prev => {
|
onClick={() => setExpandedDim(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
|
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
|
||||||
return next
|
return next
|
||||||
})}
|
})}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 bg-gray-50 hover:bg-gray-100 text-left">
|
className="flex-1 flex items-center justify-between text-left hover:opacity-80">
|
||||||
<span className="text-xs font-semibold text-gray-700">{dim.name}</span>
|
<span className="text-xs font-semibold text-gray-700">{dim.name}</span>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{dimSelectedCount > 0 ? `${dimSelectedCount}/${dimItems.length}` : 'alle'} · {isDimOpen ? '▲' : '▼'}
|
{selected.length === 0 ? 'alle' : `${dimSelectedCount}/${dimItems.length}`} · {isDimOpen ? '▲' : '▼'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{isDimOpen && (
|
{isDimOpen && (
|
||||||
<div className="divide-y divide-gray-50">
|
<div className="divide-y divide-gray-50">
|
||||||
{dimCats.map(cat => {
|
{dimCats.map(cat => {
|
||||||
|
|
@ -311,6 +495,7 @@ export default function AssignmentTypeConfig() {
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-semibold text-gray-600">{cat.name}</span>
|
<span className="text-xs font-semibold text-gray-600">{cat.name}</span>
|
||||||
|
<span className="text-xs text-gray-300">{catItems.length} Krit.</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="pl-5 space-y-1">
|
<div className="pl-5 space-y-1">
|
||||||
{catItems.map(item => (
|
{catItems.map(item => (
|
||||||
|
|
@ -322,7 +507,7 @@ export default function AssignmentTypeConfig() {
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-600">{item.name}</span>
|
<span className="text-xs text-gray-600">{item.name}</span>
|
||||||
{(item.weight ?? 1) > 1 && (
|
{(item.weight ?? 1) !== 1 && (
|
||||||
<span className="text-xs text-gray-300">×{item.weight}</span>
|
<span className="text-xs text-gray-300">×{item.weight}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ 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 RatingWeightConfig from './RatingWeightConfig'
|
||||||
|
import RatingScaleConfig from './RatingScaleConfig'
|
||||||
import AiPromptConfig from './AiPromptConfig'
|
import AiPromptConfig from './AiPromptConfig'
|
||||||
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' | 'weighting' | 'aiPrompts' | 'backup'>('feedback')
|
const [tab, setTab] = useState<'feedback' | 'types' | 'weighting' | 'scale' | 'aiPrompts' | '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)
|
||||||
|
|
@ -40,26 +41,29 @@ export default function Configuration() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-bold text-gray-800">Konfiguration</h1>
|
<h1 className="text-xl font-bold text-gray-800">Konfiguration</h1>
|
||||||
|
|
||||||
{/* Tab-Switcher */}
|
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5 flex-wrap">
|
||||||
<div className="flex bg-gray-100 rounded-xl p-1 gap-0.5">
|
|
||||||
<button onClick={() => setTab('feedback')}
|
<button onClick={() => setTab('feedback')}
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'feedback' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
className={`flex-1 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'feedback' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
||||||
Feedback
|
Feedback
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setTab('types')}
|
<button onClick={() => setTab('types')}
|
||||||
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 min-w-[4.5rem] 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')}
|
<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'}`}>
|
className={`flex-1 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'weighting' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
||||||
Gewichtung
|
Gewichtung
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => setTab('scale')}
|
||||||
|
className={`flex-1 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'scale' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
||||||
|
Skala
|
||||||
|
</button>
|
||||||
<button onClick={() => setTab('aiPrompts')}
|
<button onClick={() => setTab('aiPrompts')}
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${tab === 'aiPrompts' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
className={`flex-1 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'aiPrompts' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
||||||
KI-Prompts
|
KI-Prompts
|
||||||
</button>
|
</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 min-w-[4.5rem] py-2 rounded-lg text-sm font-medium transition ${tab === 'backup' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'}`}>
|
||||||
Backup
|
Backup
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,6 +80,7 @@ export default function Configuration() {
|
||||||
{tab === 'feedback' && <FeedbackStructureConfig />}
|
{tab === 'feedback' && <FeedbackStructureConfig />}
|
||||||
{tab === 'types' && <AssignmentTypeConfig />}
|
{tab === 'types' && <AssignmentTypeConfig />}
|
||||||
{tab === 'weighting' && <RatingWeightConfig />}
|
{tab === 'weighting' && <RatingWeightConfig />}
|
||||||
|
{tab === 'scale' && <RatingScaleConfig />}
|
||||||
{tab === 'aiPrompts' && <AiPromptConfig />}
|
{tab === 'aiPrompts' && <AiPromptConfig />}
|
||||||
|
|
||||||
{tab === 'backup' && (
|
{tab === 'backup' && (
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
type Assignment, type Consultant,
|
type Assignment, type Consultant,
|
||||||
type FeedbackDimension, type FeedbackCategory,
|
type FeedbackDimension, type FeedbackCategory,
|
||||||
type FeedbackRating,
|
type RatingScore,
|
||||||
listAssignments, listConsultants,
|
listAssignments, listConsultants,
|
||||||
loadFeedbackStructure,
|
loadFeedbackStructure,
|
||||||
listAssessmentsAll,
|
listAssessmentsAll,
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
listDoneMeetingsChronological,
|
listDoneMeetingsChronological,
|
||||||
listAssignmentFeedbacksAll,
|
listAssignmentFeedbacksAll,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
import { RATING_OPTIONS } from '../config/constants'
|
import { DEFAULT_RATING_COLOR_BANDS, resolveScoreStyle } from '../config/constants'
|
||||||
import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend'
|
import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend'
|
||||||
|
|
||||||
interface CatScore {
|
interface CatScore {
|
||||||
|
|
@ -36,22 +36,13 @@ function feedbackStatusBadge(status: InstScore['feedbackStatus']) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ratingColor(avg: number | null): string {
|
function ratingColor(avg: number | null): string {
|
||||||
if (avg === null) return 'bg-gray-100 text-gray-400'
|
const rounded = avg === null ? null : Math.round(avg)
|
||||||
if (avg >= 3.5) return 'bg-green-100 text-green-700'
|
return resolveScoreStyle(rounded as RatingScore | null, DEFAULT_RATING_COLOR_BANDS).color
|
||||||
if (avg >= 2.5) return 'bg-lime-100 text-lime-700'
|
|
||||||
if (avg >= 1.5) return 'bg-orange-100 text-orange-700'
|
|
||||||
return 'bg-red-100 text-red-700'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ratingLabel(avg: number | null): string {
|
function ratingLabel(avg: number | null): string {
|
||||||
if (avg === null) return '—'
|
if (avg === null) return '—'
|
||||||
const r = RATING_OPTIONS.find(o =>
|
return avg.toFixed(1)
|
||||||
avg >= 3.5 ? o.value === 'client_ready' :
|
|
||||||
avg >= 2.5 ? o.value === 'nearly_client_ready' :
|
|
||||||
avg >= 1.5 ? o.value === 'partially_client_ready' :
|
|
||||||
o.value === 'not_client_ready'
|
|
||||||
)
|
|
||||||
return r ? `${r.short} (${avg.toFixed(1)})` : avg.toFixed(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function trendArrow(trend: TrendResult['trend']) {
|
function trendArrow(trend: TrendResult['trend']) {
|
||||||
|
|
@ -98,7 +89,7 @@ export default function Evaluation() {
|
||||||
)
|
)
|
||||||
if (instAssessments.length === 0) continue
|
if (instAssessments.length === 0) continue
|
||||||
|
|
||||||
const getScore = (meetingId: number, itemId: number): FeedbackRating | null =>
|
const getScore = (meetingId: number, itemId: number): RatingScore | null =>
|
||||||
instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null
|
instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null
|
||||||
|
|
||||||
const catScores: CatScore[] = cats.map(cat => {
|
const catScores: CatScore[] = cats.map(cat => {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
type FeedbackCategory,
|
type FeedbackCategory,
|
||||||
type FeedbackCriterionItem,
|
type FeedbackCriterionItem,
|
||||||
type FeedbackDraft,
|
type FeedbackDraft,
|
||||||
type FeedbackRating,
|
|
||||||
getAssignment, getConsultant,
|
getAssignment, getConsultant,
|
||||||
listFeedbackCategoriesOrdered, getFeedbackCriterionItemsByIds,
|
listFeedbackCategoriesOrdered, getFeedbackCriterionItemsByIds,
|
||||||
listMeetingInstancesForAssignment,
|
listMeetingInstancesForAssignment,
|
||||||
|
|
@ -14,21 +13,19 @@ import {
|
||||||
listConversationEntriesForMeetings,
|
listConversationEntriesForMeetings,
|
||||||
getFeedbackDraft, addFeedbackDraft, updateFeedbackDraft,
|
getFeedbackDraft, addFeedbackDraft, updateFeedbackDraft,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants'
|
import {
|
||||||
import { numberToRating } from '../utils/ratingTrend'
|
DEFAULT_RATING_COLOR_BANDS, isValidRatingScore, resolveScoreStyle,
|
||||||
|
} from '../config/constants'
|
||||||
const ratingToNum = (r: FeedbackRating | null | undefined): number =>
|
import type { RatingScore } from '../db/types'
|
||||||
r ? (RATING_NUM_MAP[r] ?? 0) : 0
|
|
||||||
|
|
||||||
const ratingLabel = (avg: number | null): string => {
|
const ratingLabel = (avg: number | null): string => {
|
||||||
if (avg === null) return '—'
|
if (avg === null) return '—'
|
||||||
const r = RATING_OPTIONS.find(x => x.value === numberToRating(avg))
|
return `${avg.toFixed(1)}/10`
|
||||||
return r ? `${r.short} (${avg.toFixed(1)})` : avg.toFixed(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scoreColor = (avg: number | null): string => {
|
const scoreColor = (avg: number | null): string => {
|
||||||
if (avg === null) return 'bg-gray-100 text-gray-400'
|
const rounded = avg === null ? null : Math.round(avg)
|
||||||
return RATING_OPTIONS.find(r => r.value === numberToRating(avg))?.color ?? 'bg-gray-100 text-gray-400'
|
return resolveScoreStyle(rounded as RatingScore | null, DEFAULT_RATING_COLOR_BANDS).color
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CriterionSummary {
|
interface CriterionSummary {
|
||||||
|
|
@ -73,21 +70,25 @@ export default function FeedbackDraftPage() {
|
||||||
const instAssessments = allAssessments.filter(a => a.instituteeId === instId)
|
const instAssessments = allAssessments.filter(a => a.instituteeId === instId)
|
||||||
const instEntries = allEntries.filter(e => e.instituteeId === instId)
|
const instEntries = allEntries.filter(e => e.instituteeId === instId)
|
||||||
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
|
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
|
||||||
|
// Allgemeine Notizen sind an kein Institutee gebunden — zählen daher ungefiltert für jedes Institutee.
|
||||||
|
const generalNoteTags = meetings.flatMap(m => m.generalNoteTags ?? [])
|
||||||
|
|
||||||
const criteriaIds = [...new Set([
|
const criteriaIds = [...new Set([
|
||||||
...instAssessments.map(a => a.criteriaId),
|
...instAssessments.map(a => a.criteriaId),
|
||||||
...allLineTags.map(t => t.criterionItemId),
|
...allLineTags.map(t => t.criterionItemId),
|
||||||
|
...generalNoteTags.map(t => t.criterionItemId),
|
||||||
])]
|
])]
|
||||||
const crits = await getFeedbackCriterionItemsByIds(criteriaIds)
|
const crits = await getFeedbackCriterionItemsByIds(criteriaIds)
|
||||||
|
|
||||||
const sum: CriterionSummary[] = crits.map(crit => {
|
const sum: CriterionSummary[] = crits.map(crit => {
|
||||||
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null && a.score !== 'na')
|
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && isValidRatingScore(a.score))
|
||||||
const avg = relevant.length > 0
|
const avg = relevant.length > 0
|
||||||
? relevant.reduce((s, a) => s + ratingToNum(a.score), 0) / relevant.length
|
? relevant.reduce((s, a) => s + (a.score as number), 0) / relevant.length
|
||||||
: null
|
: null
|
||||||
const notes = [
|
const notes = [
|
||||||
...instAssessments.filter(a => a.criteriaId === crit.id && a.note).map(a => a.note),
|
...instAssessments.filter(a => a.criteriaId === crit.id && a.note).map(a => a.note),
|
||||||
...allLineTags.filter(t => t.criterionItemId === crit.id).map(t => t.text),
|
...allLineTags.filter(t => t.criterionItemId === crit.id).map(t => t.text),
|
||||||
|
...generalNoteTags.filter(t => t.criterionItemId === crit.id).map(t => t.text),
|
||||||
]
|
]
|
||||||
return { criterion: crit, avg, notes }
|
return { criterion: crit, avg, notes }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,87 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
|
type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem, type CriterionUsage, type CriterionUsageInstance,
|
||||||
loadFeedbackStructure,
|
loadFeedbackStructure,
|
||||||
addFeedbackDimension, updateFeedbackDimension, deleteFeedbackDimension,
|
addFeedbackDimension, updateFeedbackDimension, deleteFeedbackDimension,
|
||||||
addFeedbackCategory, updateFeedbackCategory, deleteFeedbackCategory,
|
addFeedbackCategory, updateFeedbackCategory, deleteFeedbackCategory,
|
||||||
addFeedbackCriterionItem, updateFeedbackCriterionItem, deleteFeedbackCriterionItem,
|
addFeedbackCriterionItem, updateFeedbackCriterionItem, deleteFeedbackCriterionItem,
|
||||||
|
getCriterionUsageMap, reassignCriterionReferences,
|
||||||
|
getCriterionUsageDetails, reassignUsageInstance, removeUsageInstance,
|
||||||
|
preloadFinalFeedbackStructure, importFeedbackStructureFromMarkdown,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
|
import { exportFeedbackStructureAsMarkdown } from '../utils/structureExport'
|
||||||
|
import { exportFeedbackStructurePdf } from '../utils/pdfExport'
|
||||||
|
import { criterionOptionGroups } from '../utils/criterionOptions'
|
||||||
|
import { summarizeFeedbackStructureSeed, parseFeedbackStructureMarkdown } from '../utils/feedbackStructureImport'
|
||||||
|
|
||||||
|
const totalUsage = (u: CriterionUsage | undefined): number =>
|
||||||
|
!u ? 0 : u.assessmentCount + u.lineTagCount + u.generalNoteTagCount + u.assignmentTypeNames.length + u.phaseTemplateLabels.length
|
||||||
|
|
||||||
|
const instanceKindLabel: Record<CriterionUsageInstance['kind'], string> = {
|
||||||
|
assessment: 'Bewertung',
|
||||||
|
lineTag: 'Protokollzeile',
|
||||||
|
generalNoteTag: 'Allgemeine Notiz',
|
||||||
|
assignmentTypeVisibility: 'Sichtbarkeit (Assignment-Typ)',
|
||||||
|
phaseTemplateVisibility: 'Sichtbarkeit (Phase)',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die drei "Daten"-Kinds haben Meeting/Person-Kontext, die zwei "Sichtbarkeits"-Kinds nicht. */
|
||||||
|
const isDataInstance = (
|
||||||
|
instance: CriterionUsageInstance,
|
||||||
|
): instance is Extract<CriterionUsageInstance, { kind: 'assessment' | 'lineTag' | 'generalNoteTag' }> =>
|
||||||
|
instance.kind === 'assessment' || instance.kind === 'lineTag' || instance.kind === 'generalNoteTag'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stabiler, inhaltsbasierter Schlüssel statt Array-Index — der Index verschiebt sich nach jedem
|
||||||
|
* Übertragen/Entfernen, weil die Instanzen-Liste neu vom Server geladen wird. Mit dem Index als
|
||||||
|
* Schlüssel hingen ausgewählte Ziel-Kriterien danach plötzlich an der falschen (neu an dieser
|
||||||
|
* Position stehenden) Instanz.
|
||||||
|
*/
|
||||||
|
const instanceKey = (instance: CriterionUsageInstance): string => {
|
||||||
|
switch (instance.kind) {
|
||||||
|
case 'assessment': return `assessment:${instance.assessmentId}`
|
||||||
|
case 'lineTag': return `lineTag:${instance.conversationEntryId}:${instance.tagText}`
|
||||||
|
case 'generalNoteTag': return `generalNoteTag:${instance.meetingInstanceId}:${instance.tagText}`
|
||||||
|
case 'assignmentTypeVisibility': return `atVis:${instance.assignmentTypeId}`
|
||||||
|
case 'phaseTemplateVisibility': return `ptVis:${instance.phaseTemplateId}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function FeedbackStructureConfig() {
|
export default function FeedbackStructureConfig() {
|
||||||
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
||||||
const [categories, setCategories] = useState<FeedbackCategory[]>([])
|
const [categories, setCategories] = useState<FeedbackCategory[]>([])
|
||||||
const [items, setItems] = useState<FeedbackCriterionItem[]>([])
|
const [items, setItems] = useState<FeedbackCriterionItem[]>([])
|
||||||
|
const [usageMap, setUsageMap] = useState<Record<number, CriterionUsage>>({})
|
||||||
|
const [usageDetails, setUsageDetails] = useState<Record<number, CriterionUsageInstance[]>>({})
|
||||||
|
const [loadingUsage, setLoadingUsage] = useState<Set<number>>(new Set())
|
||||||
|
const [instanceTarget, setInstanceTarget] = useState<Record<string, number>>({})
|
||||||
|
const [instanceNotice, setInstanceNotice] = useState<Record<number, string>>({})
|
||||||
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||||
const [expandedCat, setExpandedCat] = useState<Set<number>>(new Set())
|
const [expandedCat, setExpandedCat] = useState<Set<number>>(new Set())
|
||||||
|
const [expandedUsage, setExpandedUsage] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
// Neue Einträge
|
// Neue Einträge
|
||||||
const [newDimName, setNewDimName] = useState('')
|
const [newDimName, setNewDimName] = useState('')
|
||||||
const [newCatName, setNewCatName] = useState<Record<number, string>>({})
|
const [newCatName, setNewCatName] = useState<Record<number, string>>({})
|
||||||
const [newItemName, setNewItemName] = useState<Record<number, string>>({})
|
const [newItemName, setNewItemName] = useState<Record<number, string>>({})
|
||||||
|
const [exportingPdf, setExportingPdf] = useState(false)
|
||||||
|
const [importingStructure, setImportingStructure] = useState(false)
|
||||||
|
const [importNotice, setImportNotice] = useState<string | null>(null)
|
||||||
|
const [confirmPreload, setConfirmPreload] = useState(false)
|
||||||
|
|
||||||
|
// Lösch-Bestätigungen & Übertragen-auf-anderes-Kriterium
|
||||||
|
const [confirmDeleteDimId, setConfirmDeleteDimId] = useState<number | null>(null)
|
||||||
|
const [confirmDeleteCatId, setConfirmDeleteCatId] = useState<number | null>(null)
|
||||||
|
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState<number | null>(null)
|
||||||
|
const [reassignTarget, setReassignTarget] = useState<Record<number, number>>({})
|
||||||
|
const [mergeNotice, setMergeNotice] = useState('')
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const structure = await loadFeedbackStructure()
|
const [structure, usage] = await Promise.all([loadFeedbackStructure(), getCriterionUsageMap()])
|
||||||
setDimensions(structure.dimensions)
|
setDimensions(structure.dimensions)
|
||||||
setCategories(structure.categories)
|
setCategories(structure.categories)
|
||||||
setItems(structure.items)
|
setItems(structure.items)
|
||||||
|
setUsageMap(usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
@ -39,13 +97,23 @@ export default function FeedbackStructureConfig() {
|
||||||
setExpanded(prev => new Set([...prev, id]))
|
setExpanded(prev => new Set([...prev, id]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateDimension = async (id: number, name: string) => {
|
const updateDimension = (id: number, name: string) => {
|
||||||
await updateFeedbackDimension(id, { name })
|
|
||||||
setDimensions(prev => prev.map(d => d.id === id ? { ...d, name } : d))
|
setDimensions(prev => prev.map(d => d.id === id ? { ...d, name } : d))
|
||||||
|
updateFeedbackDimension(id, { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimensionUsageTotal = (dimId: number): number =>
|
||||||
|
categories.filter(c => c.dimensionId === dimId)
|
||||||
|
.reduce((sum, cat) => sum + categoryUsageTotal(cat.id!), 0)
|
||||||
|
|
||||||
|
const requestDeleteDimension = (id: number) => {
|
||||||
|
if (dimensionUsageTotal(id) === 0) { deleteDimension(id); return }
|
||||||
|
setConfirmDeleteDimId(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteDimension = async (id: number) => {
|
const deleteDimension = async (id: number) => {
|
||||||
await deleteFeedbackDimension(id)
|
await deleteFeedbackDimension(id)
|
||||||
|
setConfirmDeleteDimId(null)
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,13 +140,27 @@ export default function FeedbackStructureConfig() {
|
||||||
setExpandedCat(prev => new Set([...prev, id]))
|
setExpandedCat(prev => new Set([...prev, id]))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateCategory = async (id: number, name: string) => {
|
const updateCategory = (id: number, name: string) => {
|
||||||
await updateFeedbackCategory(id, { name })
|
|
||||||
setCategories(prev => prev.map(c => c.id === id ? { ...c, name } : c))
|
setCategories(prev => prev.map(c => c.id === id ? { ...c, name } : c))
|
||||||
|
updateFeedbackCategory(id, { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCategoryDescription = (id: number, description: string) => {
|
||||||
|
setCategories(prev => prev.map(c => c.id === id ? { ...c, description } : c))
|
||||||
|
updateFeedbackCategory(id, { description })
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryUsageTotal = (catId: number): number =>
|
||||||
|
items.filter(i => i.categoryId === catId).reduce((sum, i) => sum + totalUsage(usageMap[i.id!]), 0)
|
||||||
|
|
||||||
|
const requestDeleteCategory = (id: number) => {
|
||||||
|
if (categoryUsageTotal(id) === 0) { deleteCategory(id); return }
|
||||||
|
setConfirmDeleteCatId(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteCategory = async (id: number) => {
|
const deleteCategory = async (id: number) => {
|
||||||
await deleteFeedbackCategory(id)
|
await deleteFeedbackCategory(id)
|
||||||
|
setConfirmDeleteCatId(null)
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,31 +176,220 @@ export default function FeedbackStructureConfig() {
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItem = async (id: number, name: string) => {
|
const updateItem = (id: number, name: string) => {
|
||||||
await updateFeedbackCriterionItem(id, { name })
|
|
||||||
setItems(prev => prev.map(i => i.id === id ? { ...i, name } : i))
|
setItems(prev => prev.map(i => i.id === id ? { ...i, name } : i))
|
||||||
|
updateFeedbackCriterionItem(id, { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestDeleteItem = (id: number) => {
|
||||||
|
if (totalUsage(usageMap[id]) === 0) { deleteItem(id); return }
|
||||||
|
setMergeNotice('')
|
||||||
|
setConfirmDeleteItemId(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteItem = async (id: number) => {
|
const deleteItem = async (id: number) => {
|
||||||
await deleteFeedbackCriterionItem(id)
|
await deleteFeedbackCriterionItem(id)
|
||||||
setItems(prev => prev.filter(i => i.id !== id))
|
setItems(prev => prev.filter(i => i.id !== id))
|
||||||
|
setConfirmDeleteItemId(null)
|
||||||
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItemWeight = async (id: number, weight: number) => {
|
const reassignAndDeleteItem = async (fromId: number) => {
|
||||||
await updateFeedbackCriterionItem(id, { weight })
|
const toId = reassignTarget[fromId]
|
||||||
|
if (!toId) return
|
||||||
|
const { mergedConflicts } = await reassignCriterionReferences(fromId, toId)
|
||||||
|
await deleteFeedbackCriterionItem(fromId)
|
||||||
|
setConfirmDeleteItemId(null)
|
||||||
|
setMergeNotice(mergedConflicts > 0 ? `${mergedConflicts} Bewertung(en) wurden dabei mit bereits vorhandenen Bewertungen des Ziel-Kriteriums zusammengeführt.` : '')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateItemWeight = (id: number, weight: number) => {
|
||||||
setItems(prev => prev.map(i => i.id === id ? { ...i, weight } : i))
|
setItems(prev => prev.map(i => i.id === id ? { ...i, weight } : i))
|
||||||
|
updateFeedbackCriterionItem(id, { weight })
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItemDescription = async (id: number, description: string) => {
|
const updateItemDescription = (id: number, description: string) => {
|
||||||
await updateFeedbackCriterionItem(id, { description })
|
|
||||||
setItems(prev => prev.map(i => i.id === id ? { ...i, description } : i))
|
setItems(prev => prev.map(i => i.id === id ? { ...i, description } : i))
|
||||||
|
updateFeedbackCriterionItem(id, { description })
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveItemToCategory = (itemId: number, newCategoryId: number) => {
|
||||||
|
setItems(prev => prev.map(i => i.id === itemId ? { ...i, categoryId: newCategoryId } : i))
|
||||||
|
updateFeedbackCriterionItem(itemId, { categoryId: newCategoryId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleUsageExpanded = async (itemId: number) => {
|
||||||
|
const willOpen = !expandedUsage.has(itemId)
|
||||||
|
setExpandedUsage(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(itemId)) next.delete(itemId); else next.add(itemId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
if (willOpen && !usageDetails[itemId]) {
|
||||||
|
setLoadingUsage(prev => new Set(prev).add(itemId))
|
||||||
|
const details = await getCriterionUsageDetails(itemId)
|
||||||
|
setUsageDetails(prev => ({ ...prev, [itemId]: details }))
|
||||||
|
setLoadingUsage(prev => { const next = new Set(prev); next.delete(itemId); return next })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshUsageAfterInstanceChange = async (itemId: number) => {
|
||||||
|
const [details, usage] = await Promise.all([getCriterionUsageDetails(itemId), getCriterionUsageMap()])
|
||||||
|
setUsageDetails(prev => ({ ...prev, [itemId]: details }))
|
||||||
|
setUsageMap(usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reassignOneInstance = async (itemId: number, instance: CriterionUsageInstance) => {
|
||||||
|
const key = `${itemId}:${instanceKey(instance)}`
|
||||||
|
const toId = instanceTarget[key]
|
||||||
|
if (!toId) return
|
||||||
|
const { merged } = await reassignUsageInstance(instance, toId)
|
||||||
|
setInstanceTarget(prev => { const next = { ...prev }; delete next[key]; return next })
|
||||||
|
setInstanceNotice(prev => ({ ...prev, [itemId]: merged ? 'Mit vorhandener Bewertung des Ziel-Kriteriums zusammengeführt.' : 'Übertragen.' }))
|
||||||
|
await refreshUsageAfterInstanceChange(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeOneInstance = async (itemId: number, instance: CriterionUsageInstance) => {
|
||||||
|
await removeUsageInstance(instance)
|
||||||
|
setInstanceNotice(prev => ({ ...prev, [itemId]: 'Zuordnung entfernt.' }))
|
||||||
|
await refreshUsageAfterInstanceChange(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runPreloadFinal = async () => {
|
||||||
|
setImportingStructure(true)
|
||||||
|
setImportNotice(null)
|
||||||
|
setConfirmPreload(false)
|
||||||
|
try {
|
||||||
|
const result = await preloadFinalFeedbackStructure()
|
||||||
|
setImportNotice(
|
||||||
|
`Finale Struktur geladen: ${result.dimensions} Dimensionen, ${result.categories} Kategorien, ${result.items} Kriterien`
|
||||||
|
+ (result.clearedDimensions ? ` (vorher ${result.clearedDimensions} Dimensionen ersetzt)` : ''),
|
||||||
|
)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setImportNotice(`Import fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
} finally {
|
||||||
|
setImportingStructure(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const importMarkdownFile = async (file: File) => {
|
||||||
|
setImportingStructure(true)
|
||||||
|
setImportNotice(null)
|
||||||
|
try {
|
||||||
|
const md = await file.text()
|
||||||
|
const seed = parseFeedbackStructureMarkdown(md)
|
||||||
|
const sum = summarizeFeedbackStructureSeed(seed)
|
||||||
|
const result = await importFeedbackStructureFromMarkdown(md)
|
||||||
|
setImportNotice(
|
||||||
|
`MD importiert: ${result.dimensions} Dimensionen, ${result.categories} Kategorien, ${result.items} Kriterien`
|
||||||
|
+ ` (Datei: ${sum.items} Kriterien erkannt)`,
|
||||||
|
)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setImportNotice(`MD-Import fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}`)
|
||||||
|
} finally {
|
||||||
|
setImportingStructure(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
|
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<h2 className="text-lg font-bold text-gray-800">Feedback-Dimensionen & Kategorien</h2>
|
<h2 className="text-lg font-bold text-gray-800">Feedback-Dimensionen & Kategorien</h2>
|
||||||
|
<div className="flex gap-1.5 flex-shrink-0 flex-wrap">
|
||||||
|
{!confirmPreload ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmPreload(true)}
|
||||||
|
disabled={importingStructure}
|
||||||
|
className="text-xs px-2.5 py-1.5 border border-brand/30 rounded-lg text-brand hover:bg-brand/5 transition disabled:opacity-50">
|
||||||
|
Preload final
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={runPreloadFinal}
|
||||||
|
disabled={importingStructure}
|
||||||
|
className="text-xs px-2.5 py-1.5 bg-brand text-white rounded-lg disabled:opacity-50">
|
||||||
|
{importingStructure ? '…' : 'Ersetzen bestätigen'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirmPreload(false)}
|
||||||
|
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className={`text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition cursor-pointer ${importingStructure ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
|
↑ MD
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".md,text/markdown,text/plain"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) importMarkdownFile(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button onClick={() => exportFeedbackStructureAsMarkdown()}
|
||||||
|
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
|
||||||
|
⬇ MD
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => { setExportingPdf(true); try { await exportFeedbackStructurePdf() } finally { setExportingPdf(false) } }}
|
||||||
|
disabled={exportingPdf}
|
||||||
|
className="text-xs px-2.5 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition disabled:opacity-50">
|
||||||
|
{exportingPdf ? '⏳' : '⬇ PDF'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importNotice && (
|
||||||
|
<div className="rounded-xl px-4 py-3 text-sm bg-blue-50 text-blue-800 border border-blue-200">
|
||||||
|
{importNotice}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmPreload && (
|
||||||
|
<div className="rounded-xl px-4 py-3 text-sm bg-amber-50 text-amber-800 border border-amber-200">
|
||||||
|
Ersetzt die gesamte Feedback-Struktur durch die finale Preload-Datei
|
||||||
|
(<code className="mx-1 text-xs bg-amber-100 px-1 rounded">docs/preload/Feedback-Struktur-final.md</code>
|
||||||
|
(3 Dimensionen, 16 Kategorien, 109 Kriterien). Bestehende Dimensionen/Kategorien/Kriterien werden gelöscht.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fokus-Übersicht: was wird über alle Bewerter hinweg am häufigsten kommentiert (siehe CLAUDE.md „Beobachter-Fokus") */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<details className="bg-white rounded-xl border border-gray-100 shadow-sm p-3">
|
||||||
|
<summary className="text-sm font-semibold text-gray-700 cursor-pointer select-none">📊 Fokus-Übersicht — am häufigsten kommentierte Kriterien</summary>
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{[...items]
|
||||||
|
.sort((a, b) => totalUsage(usageMap[b.id!]) - totalUsage(usageMap[a.id!]))
|
||||||
|
.slice(0, 5)
|
||||||
|
.filter(i => totalUsage(usageMap[i.id!]) > 0)
|
||||||
|
.map(i => (
|
||||||
|
<div key={i.id} className="flex items-start justify-between gap-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-medium text-gray-700">{i.name}</span>
|
||||||
|
{i.learnedPatternHints && (
|
||||||
|
<div className="text-gray-400 truncate">{i.learnedPatternHints}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="flex-shrink-0 bg-blue-50 text-blue-700 border border-blue-200 px-1.5 py-0.5 rounded-full font-semibold">
|
||||||
|
{totalUsage(usageMap[i.id!])}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{items.every(i => totalUsage(usageMap[i.id!]) === 0) && (
|
||||||
|
<div className="text-xs text-gray-400">Noch keine Verwendungen erfasst.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Neue Dimension */}
|
{/* Neue Dimension */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -160,10 +431,22 @@ export default function FeedbackStructureConfig() {
|
||||||
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
|
if (next.has(dim.id!)) next.delete(dim.id!); else next.add(dim.id!)
|
||||||
return next
|
return next
|
||||||
})} className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
|
})} className="text-xs text-gray-400 px-1">{isOpen ? '▲' : '▼'}</button>
|
||||||
<button onClick={() => deleteDimension(dim.id!)}
|
<button onClick={() => requestDeleteDimension(dim.id!)}
|
||||||
className="text-xs text-red-300 hover:text-red-500 px-1">🗑</button>
|
className="text-xs text-red-300 hover:text-red-500 px-1">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{confirmDeleteDimId === dim.id && (
|
||||||
|
<div className="px-3 py-2 bg-red-50 border-b border-red-100 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-red-700">
|
||||||
|
Diese Dimension enthält Kriterien mit insgesamt {dimensionUsageTotal(dim.id!)} Verwendung(en) — werden beim Löschen mit entfernt.
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1.5 flex-shrink-0">
|
||||||
|
<button onClick={() => deleteDimension(dim.id!)} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">Trotzdem löschen</button>
|
||||||
|
<button onClick={() => setConfirmDeleteDimId(null)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
{dimCats.map(cat => {
|
{dimCats.map(cat => {
|
||||||
|
|
@ -182,13 +465,35 @@ export default function FeedbackStructureConfig() {
|
||||||
if (next.has(cat.id!)) next.delete(cat.id!); else next.add(cat.id!)
|
if (next.has(cat.id!)) next.delete(cat.id!); else next.add(cat.id!)
|
||||||
return next
|
return next
|
||||||
})} className="text-xs text-gray-400">{isCatOpen ? '▲' : '▼'}</button>
|
})} className="text-xs text-gray-400">{isCatOpen ? '▲' : '▼'}</button>
|
||||||
<button onClick={() => deleteCategory(cat.id!)}
|
<button onClick={() => requestDeleteCategory(cat.id!)}
|
||||||
className="text-xs text-red-300 hover:text-red-500">🗑</button>
|
className="text-xs text-red-300 hover:text-red-500">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
className="w-full px-3 py-1.5 text-xs text-gray-400 border-b border-gray-100 focus:outline-none focus:border-brand bg-white"
|
||||||
|
placeholder="Erläuterung, welche Themen/Beobachtungen in diese Kategorie einzahlen (optional, fließt in KI-Prompts ein)…"
|
||||||
|
value={cat.description ?? ''}
|
||||||
|
onChange={e => updateCategoryDescription(cat.id!, e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{confirmDeleteCatId === cat.id && (
|
||||||
|
<div className="px-3 py-2 bg-red-50 border-b border-red-100 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-red-700">
|
||||||
|
Diese Kategorie enthält Kriterien mit insgesamt {categoryUsageTotal(cat.id!)} Verwendung(en) — werden beim Löschen mit entfernt.
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1.5 flex-shrink-0">
|
||||||
|
<button onClick={() => deleteCategory(cat.id!)} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">Trotzdem löschen</button>
|
||||||
|
<button onClick={() => setConfirmDeleteCatId(null)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isCatOpen && (
|
{isCatOpen && (
|
||||||
<div className="p-2 space-y-1.5">
|
<div className="p-2 space-y-1.5">
|
||||||
{catItems.map(item => (
|
{catItems.map(item => {
|
||||||
|
const usage = usageMap[item.id!]
|
||||||
|
const usageCount = totalUsage(usage)
|
||||||
|
const isUsageOpen = expandedUsage.has(item.id!)
|
||||||
|
return (
|
||||||
<div key={item.id} className="space-y-0.5">
|
<div key={item.id} className="space-y-0.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-gray-300 text-xs">·</span>
|
<span className="text-gray-300 text-xs">·</span>
|
||||||
|
|
@ -204,7 +509,23 @@ export default function FeedbackStructureConfig() {
|
||||||
title="Gewichtung"
|
title="Gewichtung"
|
||||||
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"
|
className="w-14 text-xs border border-gray-200 rounded px-1 py-0.5 text-gray-500 bg-white focus:outline-none focus:border-brand"
|
||||||
/>
|
/>
|
||||||
<button onClick={() => deleteItem(item.id!)}
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={e => e.target.value && moveItemToCategory(item.id!, Number(e.target.value))}
|
||||||
|
title="In andere Kategorie verschieben"
|
||||||
|
className="text-xs border border-gray-200 rounded px-1 py-0.5 text-gray-500 bg-white focus:outline-none focus:border-brand max-w-[7rem]">
|
||||||
|
<option value="">Verschieben…</option>
|
||||||
|
{sorted.map(d => {
|
||||||
|
const dimCats = categories.filter(c => c.dimensionId === d.id && c.id !== item.categoryId).sort((a, b) => a.order - b.order)
|
||||||
|
if (dimCats.length === 0) return null
|
||||||
|
return (
|
||||||
|
<optgroup key={d.id} label={d.name}>
|
||||||
|
{dimCats.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<button onClick={() => requestDeleteItem(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>
|
||||||
<input
|
<input
|
||||||
|
|
@ -213,8 +534,101 @@ export default function FeedbackStructureConfig() {
|
||||||
value={item.description ?? ''}
|
value={item.description ?? ''}
|
||||||
onChange={e => updateItemDescription(item.id!, e.target.value)}
|
onChange={e => updateItemDescription(item.id!, e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{usageCount > 0 && (
|
||||||
|
<div className="ml-3.5">
|
||||||
|
<button onClick={() => toggleUsageExpanded(item.id!)}
|
||||||
|
className="text-xs bg-indigo-50 text-indigo-600 border border-indigo-100 px-1.5 py-0.5 rounded-full">
|
||||||
|
{usageCount} Verwendung(en) {isUsageOpen ? '▲' : '▼'}
|
||||||
|
</button>
|
||||||
|
{isUsageOpen && (
|
||||||
|
<div className="mt-1 space-y-1.5">
|
||||||
|
{instanceNotice[item.id!] && (
|
||||||
|
<div className="text-xs text-indigo-600 bg-indigo-50 border border-indigo-100 rounded-lg px-2 py-1">
|
||||||
|
{instanceNotice[item.id!]}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
{loadingUsage.has(item.id!) && <div className="text-xs text-gray-400">Lädt…</div>}
|
||||||
|
{(usageDetails[item.id!] ?? []).map(instance => {
|
||||||
|
const key = `${item.id}:${instanceKey(instance)}`
|
||||||
|
return (
|
||||||
|
<div key={key} className="bg-gray-50 border border-gray-100 rounded-lg p-1.5 space-y-1">
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
<span className="font-medium">{instanceKindLabel[instance.kind]}</span>
|
||||||
|
{isDataInstance(instance) && (
|
||||||
|
<>
|
||||||
|
{' · '}{instance.assignmentTitle} ({instance.date ? new Date(instance.date).toLocaleDateString('de-DE') : '—'})
|
||||||
|
{' · '}{instance.personLabel}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="text-gray-400 truncate">{instance.text}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
<select
|
||||||
|
value={instanceTarget[key] ?? ''}
|
||||||
|
onChange={e => setInstanceTarget(prev => ({ ...prev, [key]: Number(e.target.value) }))}
|
||||||
|
className="text-xs border border-gray-200 rounded px-1 py-0.5 bg-white focus:outline-none">
|
||||||
|
<option value="">→ anderes Kriterium…</option>
|
||||||
|
{criterionOptionGroups(dimensions, categories, items, item.id)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => reassignOneInstance(item.id!, instance)}
|
||||||
|
disabled={!instanceTarget[key]}
|
||||||
|
className="text-xs bg-indigo-600 text-white px-1.5 py-0.5 rounded font-medium disabled:opacity-40">
|
||||||
|
Übertragen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeOneInstance(item.id!, instance)}
|
||||||
|
className="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded font-medium">
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmDeleteItemId === item.id && (
|
||||||
|
<div className="ml-3.5 bg-red-50 border border-red-200 rounded-lg p-2 space-y-1.5">
|
||||||
|
<div className="text-xs text-red-700">
|
||||||
|
Wird an {usageCount} Stelle(n) verwendet. „Übertragen" verschiebt alle Bewertungen/Zuordnungen auf ein anderes Kriterium (kein Datenverlust). „Trotzdem löschen" entfernt Bewertungen vollständig, Protokollzeilen bleiben als Text erhalten, nur die Zuordnung geht verloren.
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<select
|
||||||
|
value={reassignTarget[item.id!] ?? ''}
|
||||||
|
onChange={e => setReassignTarget(prev => ({ ...prev, [item.id!]: Number(e.target.value) }))}
|
||||||
|
className="text-xs border border-red-200 rounded px-1.5 py-1 bg-white focus:outline-none">
|
||||||
|
<option value="">Übertragen auf…</option>
|
||||||
|
{criterionOptionGroups(dimensions, categories, items, item.id)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => reassignAndDeleteItem(item.id!)}
|
||||||
|
disabled={!reassignTarget[item.id!]}
|
||||||
|
className="text-xs bg-indigo-600 text-white px-2 py-1 rounded-lg font-medium disabled:opacity-40">
|
||||||
|
Übertragen & löschen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteItem(item.id!)}
|
||||||
|
className="text-xs bg-red-600 text-white px-2 py-1 rounded-lg font-medium">
|
||||||
|
Trotzdem löschen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirmDeleteItemId(null)}
|
||||||
|
className="text-xs border border-gray-200 text-gray-500 px-2 py-1 rounded-lg">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{mergeNotice && (
|
||||||
|
<div className="text-xs text-indigo-600 bg-indigo-50 border border-indigo-100 rounded-lg px-2 py-1.5">
|
||||||
|
{mergeNotice}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex gap-1.5 mt-1">
|
<div className="flex gap-1.5 mt-1">
|
||||||
<input
|
<input
|
||||||
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-brand"
|
className="flex-1 border border-gray-200 rounded px-2 py-1 text-xs focus:outline-none focus:border-brand"
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,25 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
type Assignment, type PhaseTemplate, type MeetingInstance, type Consultant,
|
type Assignment, type PhaseTemplate, type MeetingInstance, type Consultant,
|
||||||
type FeedbackCategory, type FeedbackCriterionItem, type ConversationEntry, type Assessment, type FeedbackRating,
|
type FeedbackCategory, type FeedbackCriterionItem, type ConversationEntry, type Assessment, type RatingScore,
|
||||||
|
type ChecklistItemDef, type ChecklistResponse,
|
||||||
getAssignment, getAssignmentType,
|
getAssignment, getAssignmentType,
|
||||||
listPhaseTemplatesForType,
|
listPhaseTemplatesForType,
|
||||||
listDoneMeetingsChronological,
|
listDoneMeetingsChronological,
|
||||||
getConsultantsByIds,
|
getConsultantsByIds,
|
||||||
listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure,
|
loadFeedbackStructure, resolveVisibleStructure,
|
||||||
listConversationEntriesForMeetings,
|
listConversationEntriesForMeetings,
|
||||||
listAssessmentsForMeetings,
|
listAssessmentsForMeetings,
|
||||||
|
listMeetingCategoryRatingsForMeetings,
|
||||||
|
listChecklistItemDefsForPhaseTemplates,
|
||||||
|
listChecklistResponsesForMeetings,
|
||||||
} from '../db'
|
} from '../db'
|
||||||
import { NotationText } from '../utils/notationParser'
|
import { NotationText } from '../utils/notationParser'
|
||||||
import { INST_COLORS } from '../config/constants'
|
import { INST_COLORS, resolveScoreStyle } from '../config/constants'
|
||||||
import { weightedAverageRating } from '../utils/ratingTrend'
|
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
||||||
|
import { useRatingColorBands } from '../hooks/useRatingColorBands'
|
||||||
const RATING_BG: Record<FeedbackRating, string> = {
|
import { exportAssignmentReportPdf } from '../utils/pdfExport'
|
||||||
na: 'bg-gray-400 text-white',
|
import type { MeetingCategoryRating } from '../db'
|
||||||
not_client_ready: 'bg-red-500 text-white',
|
|
||||||
partially_client_ready: 'bg-orange-500 text-white',
|
|
||||||
nearly_client_ready: 'bg-green-400 text-white',
|
|
||||||
client_ready: 'bg-green-700 text-white',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MeetingHistory() {
|
export default function MeetingHistory() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
|
@ -36,8 +35,13 @@ export default function MeetingHistory() {
|
||||||
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
|
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
|
||||||
const [entries, setEntries] = useState<ConversationEntry[]>([])
|
const [entries, setEntries] = useState<ConversationEntry[]>([])
|
||||||
const [assessments, setAssessments] = useState<Assessment[]>([])
|
const [assessments, setAssessments] = useState<Assessment[]>([])
|
||||||
|
const [categoryRatings, setCategoryRatings] = useState<MeetingCategoryRating[]>([])
|
||||||
|
const [checklistItems, setChecklistItems] = useState<ChecklistItemDef[]>([])
|
||||||
|
const [checklistResponses, setChecklistResponses] = useState<ChecklistResponse[]>([])
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [exportingPdf, setExportingPdf] = useState(false)
|
||||||
const latestRef = useRef<HTMLDivElement>(null)
|
const latestRef = useRef<HTMLDivElement>(null)
|
||||||
|
const colorBands = useRatingColorBands()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
|
@ -55,21 +59,25 @@ export default function MeetingHistory() {
|
||||||
setMeetings(doneMeetings)
|
setMeetings(doneMeetings)
|
||||||
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
|
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
|
||||||
|
|
||||||
const [allCats, allItems] = await Promise.all([
|
const structure = await loadFeedbackStructure()
|
||||||
listFeedbackCategoriesAll(),
|
const visible = resolveVisibleStructure(structure.dimensions, structure.categories, structure.items, aType?.criteriaIds)
|
||||||
listFeedbackCriterionItemsAll(),
|
setFbCategories(visible.categories)
|
||||||
])
|
setFbItems(visible.items)
|
||||||
const { categories: visibleCats, items: visibleItems } = filterVisibleFeedbackStructure(allCats, allItems, aType?.criteriaIds)
|
|
||||||
setFbCategories(visibleCats)
|
|
||||||
setFbItems(visibleItems)
|
|
||||||
|
|
||||||
const meetingIds = doneMeetings.map(m => m.id!)
|
const meetingIds = doneMeetings.map(m => m.id!)
|
||||||
const [convEntries, asmts] = await Promise.all([
|
const templateIds = tmpls.map(t => t.id!)
|
||||||
|
const [convEntries, asmts, catRatings, checklistDefs, checklistResp] = await Promise.all([
|
||||||
listConversationEntriesForMeetings(meetingIds),
|
listConversationEntriesForMeetings(meetingIds),
|
||||||
listAssessmentsForMeetings(meetingIds),
|
listAssessmentsForMeetings(meetingIds),
|
||||||
|
listMeetingCategoryRatingsForMeetings(meetingIds),
|
||||||
|
listChecklistItemDefsForPhaseTemplates(templateIds),
|
||||||
|
listChecklistResponsesForMeetings(meetingIds),
|
||||||
])
|
])
|
||||||
setEntries(convEntries)
|
setEntries(convEntries)
|
||||||
setAssessments(asmts)
|
setAssessments(asmts)
|
||||||
|
setCategoryRatings(catRatings)
|
||||||
|
setChecklistItems(checklistDefs)
|
||||||
|
setChecklistResponses(checklistResp)
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
|
|
@ -79,6 +87,15 @@ export default function MeetingHistory() {
|
||||||
if (loaded) latestRef.current?.scrollIntoView({ block: 'start' })
|
if (loaded) latestRef.current?.scrollIntoView({ block: 'start' })
|
||||||
}, [loaded])
|
}, [loaded])
|
||||||
|
|
||||||
|
const handleExportPdf = async () => {
|
||||||
|
setExportingPdf(true)
|
||||||
|
try {
|
||||||
|
await exportAssignmentReportPdf(assignmentId)
|
||||||
|
} finally {
|
||||||
|
setExportingPdf(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const displayName = (inst: Consultant) => {
|
const displayName = (inst: Consultant) => {
|
||||||
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
|
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
|
||||||
return dup ? `${inst.firstName} ${inst.lastName.charAt(0)}.` : inst.firstName
|
return dup ? `${inst.firstName} ${inst.lastName.charAt(0)}.` : inst.firstName
|
||||||
|
|
@ -88,11 +105,17 @@ export default function MeetingHistory() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => navigate(`/assignment/${assignmentId}`)} className="text-sm text-brand mb-2 block">← Assignment</button>
|
<button onClick={() => navigate(`/assignment/${assignmentId}`)} className="text-sm text-brand mb-2 block">← Assignment</button>
|
||||||
<h1 className="text-xl font-bold text-gray-800">📜 Gesamtprotokoll</h1>
|
<h1 className="text-xl font-bold text-gray-800">📜 Gesamtprotokoll</h1>
|
||||||
<div className="text-sm text-gray-500">{assignment.title} · {assignment.client}</div>
|
<div className="text-sm text-gray-500">{assignment.title} · {assignment.client}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={handleExportPdf} disabled={exportingPdf}
|
||||||
|
className="flex-shrink-0 text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition disabled:opacity-50">
|
||||||
|
{exportingPdf ? 'Erstelle PDF…' : '⬇ Als PDF exportieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{meetings.length === 0 && (
|
{meetings.length === 0 && (
|
||||||
<p className="text-gray-400 text-sm text-center py-8">Noch keine abgeschlossenen Meetings.</p>
|
<p className="text-gray-400 text-sm text-center py-8">Noch keine abgeschlossenen Meetings.</p>
|
||||||
|
|
@ -103,7 +126,7 @@ export default function MeetingHistory() {
|
||||||
const meetingEntries = entries
|
const meetingEntries = entries
|
||||||
.filter(e => e.meetingInstanceId === meeting.id)
|
.filter(e => e.meetingInstanceId === meeting.id)
|
||||||
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
|
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
|
||||||
const meetingAssessments = assessments.filter(a => a.meetingInstanceId === meeting.id)
|
const meetingChecklistItems = checklistItems.filter(ci => ci.phaseTemplateId === template?.id).sort((a, b) => a.order - b.order)
|
||||||
const isLatest = idx === meetings.length - 1
|
const isLatest = idx === meetings.length - 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -126,7 +149,16 @@ export default function MeetingHistory() {
|
||||||
|
|
||||||
{meeting.generalNotes?.trim() && (
|
{meeting.generalNotes?.trim() && (
|
||||||
<div className="bg-gray-50 rounded-lg px-3 py-2">
|
<div className="bg-gray-50 rounded-lg px-3 py-2">
|
||||||
<NotationText text={meeting.generalNotes} />
|
<NotationText
|
||||||
|
text={meeting.generalNotes}
|
||||||
|
renderLineAddon={line => {
|
||||||
|
const critId = meeting.generalNoteTags?.find(t => t.text === line)?.criterionItemId
|
||||||
|
const critName = critId ? fbItems.find(i => i.id === critId)?.name : null
|
||||||
|
return critName
|
||||||
|
? <span className="flex-shrink-0 text-xs bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-full px-2 py-0.5 mt-0.5">{critName}</span>
|
||||||
|
: null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -169,23 +201,56 @@ export default function MeetingHistory() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{institutees.length > 0 && fbCategories.length > 0 && meetingAssessments.length > 0 && (
|
{meetingChecklistItems.length > 0 && (
|
||||||
|
<div className="space-y-1 pt-1 border-t border-gray-50">
|
||||||
|
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide">🗹 Checkliste</div>
|
||||||
|
{meetingChecklistItems.map(ci => {
|
||||||
|
const responsesForItem = checklistResponses.filter(r => r.checklistItemId === ci.id)
|
||||||
|
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
|
||||||
|
return targets.map(instituteeId => {
|
||||||
|
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
|
||||||
|
if (!response) return null
|
||||||
|
const effectiveCritId = response.criterionItemId ?? ci.linkedCriterionItemId
|
||||||
|
const critName = effectiveCritId ? fbItems.find(i => i.id === effectiveCritId)?.name : null
|
||||||
|
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
|
||||||
|
return (
|
||||||
|
<div key={`${ci.id}-${instituteeId ?? 'm'}`} className="text-xs text-gray-600 flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className="font-medium">{ci.label}</span>
|
||||||
|
{inst && <span className="text-gray-400">— {displayName(inst)}</span>}
|
||||||
|
<span className={`px-1.5 py-0.5 rounded font-medium tabular-nums ${resolveScoreStyle(response.rating, colorBands).active}`}>
|
||||||
|
{response.rating ?? '—'}
|
||||||
|
</span>
|
||||||
|
{critName && <span className="text-indigo-600">→ {critName}</span>}
|
||||||
|
{response.note?.trim() && <span className="text-gray-500">— {response.note.trim()}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{institutees.length > 0 && fbCategories.length > 0 && (
|
||||||
<div className="space-y-1 pt-1 border-t border-gray-50">
|
<div className="space-y-1 pt-1 border-t border-gray-50">
|
||||||
{institutees.map(inst => {
|
{institutees.map(inst => {
|
||||||
const chips = fbCategories.map(cat => {
|
const chips = fbCategories.map(cat => {
|
||||||
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
||||||
const mode = weightedAverageRating(catItems, itemId =>
|
const mode = resolveCategoryChipScore({
|
||||||
meetingAssessments.find(a => a.instituteeId === inst.id && a.criteriaId === itemId)?.score ?? null,
|
meetingId: meeting.id!,
|
||||||
)
|
instituteeId: inst.id!,
|
||||||
|
categoryId: cat.id!,
|
||||||
|
categoryRatings,
|
||||||
|
assessments,
|
||||||
|
catItems,
|
||||||
|
})
|
||||||
return mode ? { cat, mode } : null
|
return mode ? { cat, mode } : null
|
||||||
}).filter(Boolean) as { cat: FeedbackCategory; mode: FeedbackRating }[]
|
}).filter(Boolean) as { cat: FeedbackCategory; mode: RatingScore }[]
|
||||||
if (chips.length === 0) return null
|
if (chips.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div key={inst.id} className="flex items-center gap-1 flex-wrap">
|
<div key={inst.id} className="flex items-center gap-1 flex-wrap">
|
||||||
<span className="text-xs text-gray-400">{displayName(inst)}:</span>
|
<span className="text-xs text-gray-400">{displayName(inst)}:</span>
|
||||||
{chips.map(({ cat, mode }) => (
|
{chips.map(({ cat, mode }) => (
|
||||||
<span key={cat.id} className={`text-xs px-2 py-0.5 rounded font-medium ${RATING_BG[mode]}`}>
|
<span key={cat.id} className={`text-xs px-2 py-0.5 rounded font-medium tabular-nums ${resolveScoreStyle(mode, colorBands).active}`}>
|
||||||
{cat.name}
|
{cat.name} {mode}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
144
src/pages/RatingScaleConfig.tsx
Normal file
144
src/pages/RatingScaleConfig.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
BAND_STYLES,
|
||||||
|
DEFAULT_RATING_COLOR_BANDS,
|
||||||
|
RATING_MAX,
|
||||||
|
normalizeRatingColorBands,
|
||||||
|
resolveScoreStyle,
|
||||||
|
} from '../config/constants'
|
||||||
|
import { getOrSeedRatingColorBands, saveRatingColorBands } from '../db'
|
||||||
|
import type { RatingBandKey, RatingColorBand } from '../db/types'
|
||||||
|
|
||||||
|
const BAND_KEYS: RatingBandKey[] = ['red', 'orange', 'lightGreen', 'darkGreen']
|
||||||
|
const BAND_LABELS: Record<RatingBandKey, string> = {
|
||||||
|
red: 'Rot',
|
||||||
|
orange: 'Orange',
|
||||||
|
lightGreen: 'Hellgrün',
|
||||||
|
darkGreen: 'Dunkelgrün',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RatingScaleConfig() {
|
||||||
|
const [bands, setBands] = useState<RatingColorBand[]>(DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b })))
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getOrSeedRatingColorBands().then(b => setBands(normalizeRatingColorBands(b)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateMax = (idx: number, max: number) => {
|
||||||
|
setBands(prev => prev.map((b, i) => i === idx ? { ...b, max } : b))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateKey = (idx: number, key: RatingBandKey) => {
|
||||||
|
setBands(prev => prev.map((b, i) => i === idx ? { ...b, key } : b))
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const sorted = [...bands].sort((a, b) => a.max - b.max)
|
||||||
|
if (sorted[sorted.length - 1]?.max !== RATING_MAX) {
|
||||||
|
setError(`Das letzte Band muss bei ${RATING_MAX} enden.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
if (sorted[i].max <= sorted[i - 1].max) {
|
||||||
|
setError('Obergrenzen müssen streng aufsteigend sein.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await saveRatingColorBands(sorted)
|
||||||
|
setBands(sorted)
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2500)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => setBands(DEFAULT_RATING_COLOR_BANDS.map(b => ({ ...b })))
|
||||||
|
|
||||||
|
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">Bewertungsskala 1–10 · Farbbänder</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Steuert die farbliche Darstellung von Scores in Notation, Meeting und Feedback.
|
||||||
|
Jedes Band gilt von der vorherigen Obergrenze + 1 bis zur eigenen Obergrenze (inklusive).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-gray-400">Vorschau (nicht ausgewählt / ausgewählt)</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Array.from({ length: 10 }, (_, i) => i + 1).map(n => {
|
||||||
|
const style = resolveScoreStyle(n, bands)
|
||||||
|
return (
|
||||||
|
<span key={`idle-${n}`} className={`w-8 text-center text-xs font-bold py-1.5 rounded border tabular-nums ${style.color}`}>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Array.from({ length: 10 }, (_, i) => i + 1).map(n => {
|
||||||
|
const style = resolveScoreStyle(n, bands)
|
||||||
|
return (
|
||||||
|
<span key={`active-${n}`} className={`w-8 text-center text-xs font-bold py-1.5 rounded border tabular-nums ${style.active}`}>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{bands.map((band, idx) => {
|
||||||
|
const prevMax = idx === 0 ? 0 : bands[idx - 1].max
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-gray-500 w-16">{prevMax + 1}–{band.max}</span>
|
||||||
|
<label className="text-xs text-gray-600">bis</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={band.max}
|
||||||
|
onChange={e => updateMax(idx, Number(e.target.value))}
|
||||||
|
className="w-14 border border-gray-200 rounded-lg px-2 py-1 text-sm tabular-nums"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={band.key}
|
||||||
|
onChange={e => updateKey(idx, e.target.value as RatingBandKey)}
|
||||||
|
className={`text-xs border rounded-lg px-2 py-1 ${BAND_STYLES[band.key].color}`}
|
||||||
|
>
|
||||||
|
{BAND_KEYS.map(k => (
|
||||||
|
<option key={k} value={k}>{BAND_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={save} className="flex-1 py-2.5 bg-brand text-white rounded-xl text-sm font-semibold">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button onClick={reset} className="px-4 py-2.5 border border-gray-200 text-gray-600 rounded-xl text-sm">
|
||||||
|
Standard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{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,9 +1,12 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type {
|
import type {
|
||||||
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
||||||
Assessment, ConversationEntry, FeedbackRating,
|
Assessment, ConversationEntry, ConversationLineTag, RatingScore, RatingColorBand,
|
||||||
} from '../../db'
|
} from '../../db'
|
||||||
import { INST_COLORS, RATING_OPTIONS, RATING_NUM_MAP } from '../../config/constants'
|
import {
|
||||||
|
INST_COLORS, DEFAULT_RATING_COLOR_BANDS, isValidRatingScore, resolveScoreStyle,
|
||||||
|
} from '../../config/constants'
|
||||||
|
import { ScorePicker } from '../../components/ScorePicker'
|
||||||
import { detectNotationRating } from '../../utils/notationParser'
|
import { detectNotationRating } from '../../utils/notationParser'
|
||||||
import { numberToRating, weightedAverageRating } from '../../utils/ratingTrend'
|
import { numberToRating, weightedAverageRating } from '../../utils/ratingTrend'
|
||||||
|
|
||||||
|
|
@ -13,39 +16,53 @@ interface Props {
|
||||||
fbItems: FeedbackCriterionItem[]
|
fbItems: FeedbackCriterionItem[]
|
||||||
assessments: Assessment[]
|
assessments: Assessment[]
|
||||||
entries: ConversationEntry[]
|
entries: ConversationEntry[]
|
||||||
|
/** Kriterium-Zuordnungen aus den allgemeinen Notizen — gelten für ALLE Institutees, nicht gefiltert. */
|
||||||
|
generalNoteTags: ConversationLineTag[]
|
||||||
hasConv: boolean
|
hasConv: boolean
|
||||||
tagLabel: (inst: Consultant) => string
|
tagLabel: (inst: Consultant) => string
|
||||||
fillerTotal: (instId: number) => number
|
fillerTotal: (instId: number) => number
|
||||||
getAssessmentScore: (instituteeId: number, itemId: number) => FeedbackRating | null
|
getAssessmentScore: (instituteeId: number, itemId: number) => RatingScore | null
|
||||||
onSetAssessmentScore: (instituteeId: number, itemId: number, score: FeedbackRating) => void
|
onSetAssessmentScore: (instituteeId: number, itemId: number, score: RatingScore | null) => void
|
||||||
onSaveAssessmentNote: (instituteeId: number, itemId: number, note: string) => void
|
onSaveAssessmentNote: (instituteeId: number, itemId: number, note: string) => void
|
||||||
onAutoCalc: (instituteeId: number) => void
|
onAutoCalc: (instituteeId: number) => void
|
||||||
|
colorBands?: RatingColorBand[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function catMode(
|
function catMode(
|
||||||
cat: FeedbackCategory,
|
cat: FeedbackCategory,
|
||||||
fbItems: FeedbackCriterionItem[],
|
fbItems: FeedbackCriterionItem[],
|
||||||
getScore: (itemId: number) => FeedbackRating | null,
|
getScore: (itemId: number) => RatingScore | null,
|
||||||
): FeedbackRating | null {
|
): RatingScore | null {
|
||||||
return weightedAverageRating(fbItems.filter(i => i.categoryId === cat.id), getScore)
|
return weightedAverageRating(fbItems.filter(i => i.categoryId === cat.id), getScore)
|
||||||
}
|
}
|
||||||
|
|
||||||
function suggestedRating(entries: ConversationEntry[], instituteeId: number, itemId: number): FeedbackRating | null {
|
function suggestedRating(
|
||||||
const ratings = entries
|
entries: ConversationEntry[],
|
||||||
|
generalNoteTags: ConversationLineTag[],
|
||||||
|
instituteeId: number,
|
||||||
|
itemId: number,
|
||||||
|
): RatingScore | null {
|
||||||
|
const entryTexts = entries
|
||||||
.filter(e => e.instituteeId === instituteeId)
|
.filter(e => e.instituteeId === instituteeId)
|
||||||
.flatMap(e => e.lineTags ?? [])
|
.flatMap(e => e.lineTags ?? [])
|
||||||
.filter(t => t.criterionItemId === itemId)
|
.filter(t => t.criterionItemId === itemId)
|
||||||
.map(t => detectNotationRating(t.text))
|
.map(t => t.text)
|
||||||
.filter((r): r is FeedbackRating => r !== null)
|
const generalTexts = generalNoteTags
|
||||||
|
.filter(t => t.criterionItemId === itemId)
|
||||||
|
.map(t => t.text)
|
||||||
|
const ratings = [...entryTexts, ...generalTexts]
|
||||||
|
.map(detectNotationRating)
|
||||||
|
.filter(isValidRatingScore)
|
||||||
if (ratings.length === 0) return null
|
if (ratings.length === 0) return null
|
||||||
const avg = ratings.reduce((s, r) => s + RATING_NUM_MAP[r], 0) / ratings.length
|
const avg = ratings.reduce((s, r) => s + r, 0) / ratings.length
|
||||||
return numberToRating(avg)
|
return numberToRating(avg)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssessmentTab({
|
export function AssessmentTab({
|
||||||
institutees, fbCategories, fbItems, assessments, entries, hasConv,
|
institutees, fbCategories, fbItems, assessments, entries, generalNoteTags, hasConv,
|
||||||
tagLabel, fillerTotal,
|
tagLabel, fillerTotal,
|
||||||
getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc,
|
getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc,
|
||||||
|
colorBands = DEFAULT_RATING_COLOR_BANDS,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [expandedCat, setExpandedCat] = useState<Record<number, number | null>>({})
|
const [expandedCat, setExpandedCat] = useState<Record<number, number | null>>({})
|
||||||
|
|
||||||
|
|
@ -79,18 +96,15 @@ export function AssessmentTab({
|
||||||
|
|
||||||
{fbCategories.map(cat => {
|
{fbCategories.map(cat => {
|
||||||
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
||||||
if (catItems.length === 0) return null
|
const ratedCount = catItems.filter(i => isValidRatingScore(getAssessmentScore(inst.id!, i.id!))).length
|
||||||
const ratedCount = catItems.filter(i => {
|
|
||||||
const s = getAssessmentScore(inst.id!, i.id!)
|
|
||||||
return s !== null && s !== 'na'
|
|
||||||
}).length
|
|
||||||
const isOpen = expandedCat[inst.id!] === cat.id
|
const 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 = resolveScoreStyle(mode, colorBands)
|
||||||
|
const hasScore = isValidRatingScore(mode)
|
||||||
const suggestionCount = catItems.filter(item => {
|
const suggestionCount = catItems.filter(item => {
|
||||||
const current = getAssessmentScore(inst.id!, item.id!)
|
const current = getAssessmentScore(inst.id!, item.id!)
|
||||||
const suggestion = suggestedRating(entries, inst.id!, item.id!)
|
const suggestion = suggestedRating(entries, generalNoteTags, inst.id!, item.id!)
|
||||||
return suggestion && suggestion !== current
|
return suggestion !== null && suggestion !== current
|
||||||
}).length
|
}).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -101,48 +115,57 @@ export function AssessmentTab({
|
||||||
[inst.id!]: prev[inst.id!] === cat.id ? null : cat.id!,
|
[inst.id!]: prev[inst.id!] === cat.id ? null : cat.id!,
|
||||||
}))}
|
}))}
|
||||||
className={`w-full flex items-center justify-between px-3 py-2.5 text-left transition ${
|
className={`w-full flex items-center justify-between px-3 py-2.5 text-left transition ${
|
||||||
headerRo ? headerRo.active : 'bg-white hover:bg-gray-50'
|
hasScore ? headerRo.active : 'bg-white hover:bg-gray-50'
|
||||||
}`}>
|
}`}>
|
||||||
<span className={`text-sm font-medium ${headerRo ? 'text-white' : 'text-gray-700'}`}>{cat.name}</span>
|
<span className={`text-sm font-medium ${hasScore ? 'text-white' : 'text-gray-700'}`}>
|
||||||
|
{cat.name}
|
||||||
|
{hasScore && <span className="ml-2 tabular-nums opacity-90">{headerRo.short}</span>}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{suggestionCount > 0 && (
|
{suggestionCount > 0 && (
|
||||||
<span title="Vorschläge aus dem Protokoll verfügbar"
|
<span title="Vorschläge aus dem Protokoll verfügbar"
|
||||||
className={`text-xs px-1.5 py-0.5 rounded-full font-semibold ${
|
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'
|
hasScore ? 'bg-white/20 text-white' : 'bg-blue-100 text-blue-700'
|
||||||
}`}>
|
}`}>
|
||||||
💡 {suggestionCount}
|
💡 {suggestionCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!headerRo && ratedCount > 0 && (
|
<span className={`text-xs ${hasScore ? 'text-white/70' : 'text-gray-400'}`}>
|
||||||
<span className="text-xs text-brand font-medium">{ratedCount}/{catItems.length}</span>
|
{ratedCount}/{catItems.length}
|
||||||
)}
|
</span>
|
||||||
<span className={`text-xs ${headerRo ? 'text-white/70' : 'text-gray-300'}`}>{isOpen ? '▲' : '▼'}</span>
|
<span className={`text-xs ${hasScore ? 'text-white/70' : 'text-gray-300'}`}>{isOpen ? '▲' : '▼'}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<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.length === 0 ? (
|
||||||
|
<div className="px-3 py-2.5 text-xs text-gray-400">Keine Kriterien in dieser Kategorie.</div>
|
||||||
|
) : 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!)
|
const suggestion = suggestedRating(entries, generalNoteTags, inst.id!, item.id!)
|
||||||
|
const note = assessments.find(a => a.instituteeId === inst.id && a.criteriaId === item.id)?.note ?? ''
|
||||||
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="flex items-baseline justify-between gap-2">
|
||||||
<div className="flex gap-1">
|
<div className="text-xs text-gray-700 font-medium">{item.name}</div>
|
||||||
{RATING_OPTIONS.map(r => (
|
{(item.weight ?? 1) !== 1 && (
|
||||||
<button key={r.value}
|
<span className="text-xs text-gray-400 flex-shrink-0">×{item.weight}</span>
|
||||||
onClick={() => onSetAssessmentScore(inst.id!, item.id!, r.value)}
|
)}
|
||||||
className={`flex-1 py-1.5 rounded-lg text-xs font-semibold border transition ${
|
|
||||||
current === r.value ? r.active : r.color
|
|
||||||
}`}>
|
|
||||||
{r.short}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{suggestion && suggestion !== current && (
|
{item.description && (
|
||||||
|
<div className="text-xs text-gray-400 leading-snug">{item.description}</div>
|
||||||
|
)}
|
||||||
|
<ScorePicker
|
||||||
|
value={current}
|
||||||
|
onChange={score => onSetAssessmentScore(inst.id!, item.id!, score)}
|
||||||
|
bands={colorBands}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
{suggestion !== null && suggestion !== current && (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<span className="text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-0.5 rounded-lg font-medium tabular-nums">
|
||||||
Vorschlag aus Protokoll: {RATING_OPTIONS.find(r => r.value === suggestion)?.short}
|
Vorschlag aus Protokoll: {suggestion}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => onSetAssessmentScore(inst.id!, item.id!, suggestion)}
|
<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">
|
className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-lg font-medium hover:bg-blue-200">
|
||||||
|
|
@ -150,18 +173,17 @@ export function AssessmentTab({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<div className="px-3 py-2 bg-gray-50">
|
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-brand bg-white"
|
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-brand bg-white"
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder="Kommentar zur Kategorie"
|
placeholder="Notiz zum Kriterium"
|
||||||
defaultValue={assessments.find(a => a.instituteeId === inst.id && a.criteriaId === cat.id)?.note ?? ''}
|
defaultValue={note}
|
||||||
onBlur={e => onSaveAssessmentNote(inst.id!, cat.id!, e.target.value)}
|
key={`note-${item.id}-${inst.id}-${note}`}
|
||||||
|
onBlur={e => onSaveAssessmentNote(inst.id!, item.id!, e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
157
src/pages/meeting/ChecklistTab.tsx
Normal file
157
src/pages/meeting/ChecklistTab.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type {
|
||||||
|
Consultant, FeedbackCategory, FeedbackCriterionItem, RatingScore, RatingColorBand,
|
||||||
|
ChecklistItemDef, ChecklistResponse,
|
||||||
|
} from '../../db'
|
||||||
|
import { DEFAULT_RATING_COLOR_BANDS } from '../../config/constants'
|
||||||
|
import { ScorePicker } from '../../components/ScorePicker'
|
||||||
|
import { CriterionTagPicker, type CriterionSuggestion } from './CriterionTagPicker'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
institutees: Consultant[]
|
||||||
|
checklist: ChecklistItemDef[]
|
||||||
|
fbItems: FeedbackCriterionItem[]
|
||||||
|
fbCategories: FeedbackCategory[]
|
||||||
|
tagLabel: (inst: Consultant) => string
|
||||||
|
getChecklistResponse: (checklistItemId: number, instituteeId: number | undefined) => ChecklistResponse | undefined
|
||||||
|
onSetChecklistRating: (item: ChecklistItemDef, instituteeId: number | undefined, rating: RatingScore | null) => void
|
||||||
|
onSaveChecklistNote: (item: ChecklistItemDef, instituteeId: number | undefined, note: string) => void
|
||||||
|
getEffectiveCriterion: (item: ChecklistItemDef, instituteeId: number | undefined) => number | null
|
||||||
|
onSetChecklistCriterion: (item: ChecklistItemDef, instituteeId: number | undefined, criterionItemId: number | null) => void
|
||||||
|
getChecklistSuggestions: (checklistItemId: number, instituteeId: number | undefined) => CriterionSuggestion[] | undefined
|
||||||
|
colorBands?: RatingColorBand[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChecklistLine({
|
||||||
|
label, items, categories, criterionValue, onSetCriterion, suggestions,
|
||||||
|
rating, note, open, onToggleComment, onSelectRating, onSaveNote, colorBands,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
items: FeedbackCriterionItem[]
|
||||||
|
categories: FeedbackCategory[]
|
||||||
|
criterionValue: number | null
|
||||||
|
onSetCriterion: (id: number | null) => void
|
||||||
|
suggestions: CriterionSuggestion[] | undefined
|
||||||
|
rating: RatingScore | null
|
||||||
|
note: string
|
||||||
|
open: boolean
|
||||||
|
onToggleComment: () => void
|
||||||
|
onSelectRating: (r: RatingScore | null) => void
|
||||||
|
onSaveNote: (note: string) => void
|
||||||
|
colorBands: RatingColorBand[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 py-1">
|
||||||
|
<span className="flex-1 truncate text-xs text-gray-700">{label}</span>
|
||||||
|
<CriterionTagPicker
|
||||||
|
items={items}
|
||||||
|
categories={categories}
|
||||||
|
value={criterionValue}
|
||||||
|
onChange={onSetCriterion}
|
||||||
|
suggestions={suggestions}
|
||||||
|
/>
|
||||||
|
<ScorePicker
|
||||||
|
value={rating}
|
||||||
|
onChange={onSelectRating}
|
||||||
|
bands={colorBands}
|
||||||
|
variant="select"
|
||||||
|
/>
|
||||||
|
<button onClick={onToggleComment} title="Kommentar"
|
||||||
|
className={`text-xs flex-shrink-0 rounded-lg px-1.5 py-1 ${note.trim() ? 'bg-blue-100 text-blue-700' : 'text-gray-300 hover:text-gray-500'}`}>
|
||||||
|
💬
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-2 py-1 text-xs focus:outline-none focus:border-brand mb-1"
|
||||||
|
placeholder="Kommentar"
|
||||||
|
defaultValue={note}
|
||||||
|
onBlur={e => onSaveNote(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Ganz normal im Gesprächsprotokoll angezeigt" (siehe CLAUDE.md) — direkt oberhalb der
|
||||||
|
* Gesprächs-Zeitleiste gerendert, kein eigener Meeting-Tab. Zeilenbasiert statt kartenbasiert
|
||||||
|
* (Feedback nach Nutzungstests: nahm zu viel Platz ein). Die Kriterium-Zuordnung ist pro Antwort
|
||||||
|
* individuell überschreibbar (`CriterionTagPicker`, gleiche Komponente wie im Protokoll) — die
|
||||||
|
* Konfigurations-Vorgabe (`ChecklistItemDef.linkedCriterionItemId`) ist nur der Startwert.
|
||||||
|
* `ChecklistResponse` ist immer die Quelle für die UI selbst; bei effektiv vorhandenem Kriterium
|
||||||
|
* spiegelt MeetingView.tsx die Eingabe zusätzlich einseitig nach `Assessment`.
|
||||||
|
*/
|
||||||
|
export function ChecklistTab({
|
||||||
|
institutees, checklist, fbItems, fbCategories, tagLabel,
|
||||||
|
getChecklistResponse, onSetChecklistRating, onSaveChecklistNote,
|
||||||
|
getEffectiveCriterion, onSetChecklistCriterion, getChecklistSuggestions,
|
||||||
|
colorBands = DEFAULT_RATING_COLOR_BANDS,
|
||||||
|
}: Props) {
|
||||||
|
const [openComments, setOpenComments] = useState<Set<string>>(new Set())
|
||||||
|
const toggleComment = (key: string) => setOpenComments(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
if (checklist.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">🗹 Checkliste</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 divide-y divide-gray-50 px-3">
|
||||||
|
{checklist.map(ci => {
|
||||||
|
if (ci.scope === 'meetingWide') {
|
||||||
|
const key = `${ci.id}`
|
||||||
|
return (
|
||||||
|
<ChecklistLine key={ci.id}
|
||||||
|
label={ci.label}
|
||||||
|
items={fbItems}
|
||||||
|
categories={fbCategories}
|
||||||
|
criterionValue={getEffectiveCriterion(ci, undefined)}
|
||||||
|
onSetCriterion={id => onSetChecklistCriterion(ci, undefined, id)}
|
||||||
|
suggestions={getChecklistSuggestions(ci.id!, undefined)}
|
||||||
|
rating={getChecklistResponse(ci.id!, undefined)?.rating ?? null}
|
||||||
|
note={getChecklistResponse(ci.id!, undefined)?.note ?? ''}
|
||||||
|
open={openComments.has(key)}
|
||||||
|
onToggleComment={() => toggleComment(key)}
|
||||||
|
onSelectRating={r => onSetChecklistRating(ci, undefined, r)}
|
||||||
|
onSaveNote={note => onSaveChecklistNote(ci, undefined, note)}
|
||||||
|
colorBands={colorBands}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={ci.id} className="py-1">
|
||||||
|
<div className="text-xs font-medium text-gray-500 truncate">{ci.label}</div>
|
||||||
|
{institutees.map(inst => {
|
||||||
|
const key = `${ci.id}:${inst.id}`
|
||||||
|
return (
|
||||||
|
<ChecklistLine key={inst.id}
|
||||||
|
label={tagLabel(inst)}
|
||||||
|
items={fbItems}
|
||||||
|
categories={fbCategories}
|
||||||
|
criterionValue={getEffectiveCriterion(ci, inst.id!)}
|
||||||
|
onSetCriterion={id => onSetChecklistCriterion(ci, inst.id!, id)}
|
||||||
|
suggestions={getChecklistSuggestions(ci.id!, inst.id!)}
|
||||||
|
rating={getChecklistResponse(ci.id!, inst.id!)?.rating ?? null}
|
||||||
|
note={getChecklistResponse(ci.id!, inst.id!)?.note ?? ''}
|
||||||
|
open={openComments.has(key)}
|
||||||
|
onToggleComment={() => toggleComment(key)}
|
||||||
|
onSelectRating={r => onSetChecklistRating(ci, inst.id!, r)}
|
||||||
|
onSaveNote={note => onSaveChecklistNote(ci, inst.id!, note)}
|
||||||
|
colorBands={colorBands}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type {
|
import type {
|
||||||
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
||||||
ConversationEntry, FeedbackRating,
|
ConversationEntry, RatingScore, ConversationLineTag, RatingColorBand,
|
||||||
} 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, resolveScoreStyle, DEFAULT_RATING_COLOR_BANDS } from '../../config/constants'
|
||||||
import { CriterionTagPicker } from './CriterionTagPicker'
|
import { ScorePicker } from '../../components/ScorePicker'
|
||||||
|
import { findSimilarTaggedLines, type SimilarTaggedLine } from '../../utils/criterionSuggestion'
|
||||||
|
import { CriterionTagPicker, type CriterionSuggestion } from './CriterionTagPicker'
|
||||||
|
|
||||||
const NOTATIONS = [
|
const NOTATIONS = [
|
||||||
{ sym: '+', tip: 'Gut' },
|
{ sym: '1 ', tip: 'Score 1' },
|
||||||
{ sym: '(+)', tip: 'Gut mit Abstrichen' },
|
{ sym: '3 ', tip: 'Score 3' },
|
||||||
{ sym: '!', tip: 'Negativ' },
|
{ sym: '5 ', tip: 'Score 5' },
|
||||||
{ sym: '(!)', tip: 'Negativ mit Abstrichen' },
|
{ sym: '7 ', tip: 'Score 7' },
|
||||||
|
{ sym: '9 ', tip: 'Score 9' },
|
||||||
|
{ sym: '10 ', tip: 'Score 10' },
|
||||||
{ sym: '>', tip: 'Einwand / Kundeninput' },
|
{ sym: '>', tip: 'Einwand / Kundeninput' },
|
||||||
{ sym: '[…]', tip: 'Slide-Referenz' },
|
{ sym: '[…]', tip: 'Slide-Referenz' },
|
||||||
]
|
]
|
||||||
|
|
@ -21,6 +26,10 @@ interface Props {
|
||||||
fbCategories: FeedbackCategory[]
|
fbCategories: FeedbackCategory[]
|
||||||
fbItems: FeedbackCriterionItem[]
|
fbItems: FeedbackCriterionItem[]
|
||||||
entries: ConversationEntry[]
|
entries: ConversationEntry[]
|
||||||
|
/** Vorschläge aus „🏷 Vorschläge analysieren", Schlüssel = exakter Zeilentext. */
|
||||||
|
lineSuggestions: Map<string, CriterionSuggestion[]>
|
||||||
|
/** Einmal pro Meeting-Load geholter globaler Lern-Korpus, für die rein lokale Live-Formulierungshilfe. */
|
||||||
|
corpus: ConversationLineTag[]
|
||||||
activeInstId: number | null
|
activeInstId: number | null
|
||||||
activeEntryId: number | null
|
activeEntryId: number | null
|
||||||
entryNote: string
|
entryNote: string
|
||||||
|
|
@ -30,14 +39,14 @@ interface Props {
|
||||||
displayName: (inst: Consultant) => string
|
displayName: (inst: Consultant) => string
|
||||||
tagLabel: (inst: Consultant) => string
|
tagLabel: (inst: Consultant) => string
|
||||||
fillerTotal: (instId: number) => number
|
fillerTotal: (instId: number) => number
|
||||||
getEntryScore: (entryId: number, categoryId: number) => FeedbackRating | null
|
getEntryScore: (entryId: number, categoryId: number) => RatingScore | null
|
||||||
onActivateInstitutee: (id: number) => void
|
onActivateInstitutee: (id: number) => void
|
||||||
onCloseActiveEntry: () => void
|
onCloseActiveEntry: () => void
|
||||||
onInsertNotation: (sym: string) => void
|
onInsertNotation: (sym: string) => void
|
||||||
onEntryNoteChange: (note: string) => void
|
onEntryNoteChange: (note: string) => void
|
||||||
onSaveEntryNote: () => void
|
onSaveEntryNote: () => void
|
||||||
onIncrementFiller: () => void
|
onIncrementFiller: () => void
|
||||||
onSetEntryScore: (entryId: number, categoryId: number, score: FeedbackRating) => void
|
onSetEntryScore: (entryId: number, categoryId: number, score: RatingScore | null) => void
|
||||||
onSetLineCriterion: (entryId: number, lineText: string, criterionItemId: number | null) => void
|
onSetLineCriterion: (entryId: number, lineText: string, criterionItemId: number | null) => void
|
||||||
onChangeSpeaker: (entryId: number, instituteeId: number) => void
|
onChangeSpeaker: (entryId: number, instituteeId: number) => void
|
||||||
onStartEdit: (entry: ConversationEntry) => void
|
onStartEdit: (entry: ConversationEntry) => void
|
||||||
|
|
@ -46,24 +55,46 @@ interface Props {
|
||||||
onEditNoteChange: (note: string) => void
|
onEditNoteChange: (note: string) => void
|
||||||
onDeleteEntry: (entryId: number) => void
|
onDeleteEntry: (entryId: number) => void
|
||||||
onAdjustFiller: (entry: ConversationEntry, delta: number) => void
|
onAdjustFiller: (entry: ConversationEntry, delta: number) => void
|
||||||
|
colorBands?: RatingColorBand[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationTab({
|
export function ConversationTab({
|
||||||
institutees, fbCategories, fbItems, entries,
|
institutees, fbCategories, fbItems, entries, lineSuggestions, corpus,
|
||||||
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, onSetLineCriterion, onChangeSpeaker,
|
onEntryNoteChange, onSaveEntryNote, onIncrementFiller, onSetEntryScore, onSetLineCriterion, onChangeSpeaker,
|
||||||
onStartEdit, onSaveEdit, onCancelEdit, onEditNoteChange, onDeleteEntry, onAdjustFiller,
|
onStartEdit, onSaveEdit, onCancelEdit, onEditNoteChange, onDeleteEntry, onAdjustFiller,
|
||||||
|
colorBands = DEFAULT_RATING_COLOR_BANDS,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const activeInstIdx = institutees.findIndex(c => c.id === activeInstId)
|
const activeInstIdx = institutees.findIndex(c => c.id === activeInstId)
|
||||||
const activeColor = INST_COLORS[activeInstIdx % INST_COLORS.length]
|
const activeColor = INST_COLORS[activeInstIdx % INST_COLORS.length]
|
||||||
const activeEntry = entries.find(e => e.id === activeEntryId)
|
const activeEntry = entries.find(e => e.id === activeEntryId)
|
||||||
const pastEntries = [...entries].filter(e => e.id !== activeEntryId).reverse()
|
const pastEntries = [...entries].filter(e => e.id !== activeEntryId).reverse()
|
||||||
|
|
||||||
|
const [formulationHints, setFormulationHints] = useState<SimilarTaggedLine[]>([])
|
||||||
|
const hintsTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (hintsTimer.current) clearTimeout(hintsTimer.current)
|
||||||
|
hintsTimer.current = setTimeout(() => {
|
||||||
|
const lastLine = entryNote.split('\n').pop()?.trim() ?? ''
|
||||||
|
setFormulationHints(lastLine ? findSimilarTaggedLines(lastLine, corpus) : [])
|
||||||
|
}, 800)
|
||||||
|
return () => { if (hintsTimer.current) clearTimeout(hintsTimer.current) }
|
||||||
|
}, [entryNote, corpus])
|
||||||
|
|
||||||
|
const criterionName = (id: number) => fbItems.find(i => i.id === id)?.name
|
||||||
|
|
||||||
|
const applyHint = (hintText: string) => {
|
||||||
|
const lines = entryNote.split('\n')
|
||||||
|
lines[lines.length - 1] = hintText
|
||||||
|
onEntryNoteChange(lines.join('\n'))
|
||||||
|
setFormulationHints([])
|
||||||
|
setTimeout(() => noteRef.current?.focus(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Aktiver Sprecher-Bereich */}
|
|
||||||
<div className={`bg-white rounded-xl shadow-sm border-2 p-4 space-y-3 ${
|
<div className={`bg-white rounded-xl shadow-sm border-2 p-4 space-y-3 ${
|
||||||
activeInstId !== null ? (activeColor?.border ?? 'border-brand') : 'border-gray-200'
|
activeInstId !== null ? (activeColor?.border ?? 'border-brand') : 'border-gray-200'
|
||||||
}`}>
|
}`}>
|
||||||
|
|
@ -102,7 +133,6 @@ export function ConversationTab({
|
||||||
|
|
||||||
{activeInstId !== null && (
|
{activeInstId !== null && (
|
||||||
<>
|
<>
|
||||||
{/* Notation + Äh-Counter */}
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="flex gap-1.5 flex-wrap flex-1">
|
<div className="flex gap-1.5 flex-wrap flex-1">
|
||||||
{NOTATIONS.map(({ sym, tip }) => (
|
{NOTATIONS.map(({ sym, tip }) => (
|
||||||
|
|
@ -126,18 +156,37 @@ export function ConversationTab({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notiz */}
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={noteRef}
|
ref={noteRef}
|
||||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="Notizen… (+ gut, (!) negativ mit Abstrichen, > Kundeninput, [Slide 3])"
|
placeholder="Notizen… (1–10 Score am Zeilenanfang, > Kundeninput, [Slide 3])"
|
||||||
value={entryNote}
|
value={entryNote}
|
||||||
onChange={e => onEntryNoteChange(e.target.value)}
|
onChange={e => onEntryNoteChange(e.target.value)}
|
||||||
onBlur={onSaveEntryNote}
|
onBlur={onSaveEntryNote}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Kategorie-Bewertung (optional) */}
|
{formulationHints.length > 0 && (
|
||||||
|
<details className="bg-blue-50 border border-blue-100 rounded-lg px-3 py-1.5" open>
|
||||||
|
<summary className="text-xs font-medium text-blue-700 cursor-pointer select-none">💭 Ähnliche frühere Notizen — klicken zum Übernehmen</summary>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{formulationHints.map((h, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<button type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyHint(h.text)}
|
||||||
|
className="text-xs text-blue-800 hover:text-blue-950 hover:underline truncate text-left w-full">
|
||||||
|
„{h.text}"
|
||||||
|
{criterionName(h.criterionItemId) && (
|
||||||
|
<span className="text-blue-400"> → 🏷 {criterionName(h.criterionItemId)}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
{fbCategories.length > 0 && activeEntryId !== null && (
|
{fbCategories.length > 0 && activeEntryId !== null && (
|
||||||
<details>
|
<details>
|
||||||
<summary className="text-xs text-gray-400 cursor-pointer select-none hover:text-gray-600">
|
<summary className="text-xs text-gray-400 cursor-pointer select-none hover:text-gray-600">
|
||||||
|
|
@ -157,17 +206,11 @@ export function ConversationTab({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-1">
|
<ScorePicker
|
||||||
{RATING_OPTIONS.map(r => (
|
value={current}
|
||||||
<button key={r.value}
|
onChange={score => onSetEntryScore(activeEntryId, cat.id!, score)}
|
||||||
onClick={() => onSetEntryScore(activeEntryId, cat.id!, r.value)}
|
bands={colorBands}
|
||||||
className={`flex-1 py-1.5 rounded text-xs font-bold border transition ${
|
/>
|
||||||
current === r.value ? r.active : r.color
|
|
||||||
}`}>
|
|
||||||
{r.short}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -178,7 +221,6 @@ export function ConversationTab({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Protokoll-Zeitleiste */}
|
|
||||||
{pastEntries.length > 0 && (
|
{pastEntries.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1">Protokoll</div>
|
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1">Protokoll</div>
|
||||||
|
|
@ -240,12 +282,14 @@ export function ConversationTab({
|
||||||
{entry.note && (
|
{entry.note && (
|
||||||
<NotationText
|
<NotationText
|
||||||
text={entry.note}
|
text={entry.note}
|
||||||
|
colorBands={colorBands}
|
||||||
renderLineAddon={line => (
|
renderLineAddon={line => (
|
||||||
<CriterionTagPicker
|
<CriterionTagPicker
|
||||||
items={fbItems}
|
items={fbItems}
|
||||||
categories={fbCategories}
|
categories={fbCategories}
|
||||||
value={(entry.lineTags ?? []).find(t => t.text === line)?.criterionItemId ?? null}
|
value={(entry.lineTags ?? []).find(t => t.text === line)?.criterionItemId ?? null}
|
||||||
onChange={itemId => onSetLineCriterion(entry.id!, line, itemId)}
|
onChange={itemId => onSetLineCriterion(entry.id!, line, itemId)}
|
||||||
|
suggestions={lineSuggestions.get(line)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -257,10 +301,10 @@ export function ConversationTab({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{scoreChips.map(({ name, s }) => {
|
{scoreChips.map(({ name, s }) => {
|
||||||
const ro = RATING_OPTIONS.find(r => r.value === s)
|
const style = resolveScoreStyle(s, colorBands)
|
||||||
return (
|
return (
|
||||||
<span key={name} className={`text-xs px-1.5 py-0.5 rounded border font-medium ${ro?.active ?? 'bg-gray-100 text-gray-500 border-gray-200'}`}>
|
<span key={name} className={`text-xs px-1.5 py-0.5 rounded border font-medium tabular-nums ${style.active}`}>
|
||||||
{name.slice(0, 8)}: {ro?.short ?? s}
|
{name.slice(0, 8)}: {style.short}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { FeedbackCategory, FeedbackCriterionItem } from '../../db'
|
import type { FeedbackCategory, FeedbackCriterionItem } from '../../db'
|
||||||
|
|
||||||
|
/** Priorisierter Vorschlag aus „🏷 Vorschläge analysieren" — rank 1 = wahrscheinlichstes Kriterium. */
|
||||||
|
export interface CriterionSuggestion {
|
||||||
|
criterionItemId: number
|
||||||
|
rank: number
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: FeedbackCriterionItem[]
|
items: FeedbackCriterionItem[]
|
||||||
categories: FeedbackCategory[]
|
categories: FeedbackCategory[]
|
||||||
value: number | null | undefined
|
value: number | null | undefined
|
||||||
onChange: (itemId: number | null) => void
|
onChange: (itemId: number | null) => void
|
||||||
|
/** Nur relevant solange `value` leer ist — überschreibt den leeren „+"-Button durch einen Vorschlags-Chip. */
|
||||||
|
suggestions?: CriterionSuggestion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CriterionTagPicker({ items, categories, value, onChange }: Props) {
|
export function CriterionTagPicker({ items, categories, value, onChange, suggestions }: Props) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
|
|
||||||
|
|
@ -27,6 +35,9 @@ export function CriterionTagPicker({ items, categories, value, onChange }: Props
|
||||||
setFilter('')
|
setFilter('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortedSuggestions = suggestions ? [...suggestions].sort((a, b) => a.rank - b.rank) : []
|
||||||
|
const topItem = sortedSuggestions[0] ? items.find(i => i.id === sortedSuggestions[0].criterionItemId) : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
{current ? (
|
{current ? (
|
||||||
|
|
@ -36,6 +47,15 @@ export function CriterionTagPicker({ items, categories, value, onChange }: Props
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => onChange(null)} className="text-indigo-400 hover:text-indigo-700 text-xs leading-none px-1">✕</button>
|
<button onClick={() => onChange(null)} className="text-indigo-400 hover:text-indigo-700 text-xs leading-none px-1">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : topItem ? (
|
||||||
|
<div className="inline-flex items-center gap-0.5 bg-blue-50 text-blue-700 border border-blue-200 rounded-full pl-2 pr-1 py-0.5">
|
||||||
|
<button onClick={() => select(topItem.id!)} title="Vorschlag übernehmen"
|
||||||
|
className="text-xs font-medium max-w-[120px] truncate">
|
||||||
|
💡 {topItem.name}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setOpen(p => !p)} title="Weitere Vorschläge / Suche"
|
||||||
|
className="text-blue-400 hover:text-blue-700 text-xs leading-none px-1">▾</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => setOpen(p => !p)}
|
<button onClick={() => setOpen(p => !p)}
|
||||||
title="Kriterium zuordnen"
|
title="Kriterium zuordnen"
|
||||||
|
|
@ -46,6 +66,23 @@ export function CriterionTagPicker({ items, categories, value, onChange }: Props
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-20 mt-1 w-64 bg-white border border-gray-200 rounded-xl shadow-lg p-2">
|
<div className="absolute z-20 mt-1 w-64 bg-white border border-gray-200 rounded-xl shadow-lg p-2">
|
||||||
|
{sortedSuggestions.length > 0 && (
|
||||||
|
<div className="mb-2 space-y-0.5">
|
||||||
|
<div className="text-[10px] font-semibold text-blue-400 uppercase tracking-wide px-2">Vorschläge</div>
|
||||||
|
{sortedSuggestions.map(s => {
|
||||||
|
const item = items.find(i => i.id === s.criterionItemId)
|
||||||
|
if (!item) return null
|
||||||
|
return (
|
||||||
|
<button key={s.criterionItemId} onClick={() => select(item.id!)}
|
||||||
|
className="w-full text-left px-2 py-1.5 rounded-lg hover:bg-blue-50">
|
||||||
|
<div className="text-xs font-medium text-blue-700">💡 {item.name}</div>
|
||||||
|
<div className="text-[10px] text-gray-400">{categoryName(item.categoryId)}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="border-t border-gray-100 my-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,37 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
type Assignment, type MeetingInstance, type PhaseTemplate,
|
type Assignment, type MeetingInstance, type PhaseTemplate,
|
||||||
type FeedbackCategory, type FeedbackCriterionItem, type FeedbackRating,
|
type FeedbackCategory, type FeedbackCriterionItem, type RatingScore,
|
||||||
type Consultant, type ConversationEntry, type ConversationSkillScore, type Assessment,
|
type Consultant, type ConversationEntry, type ConversationSkillScore, type Assessment, type ConversationLineTag,
|
||||||
|
type ChecklistItemDef, type ChecklistResponse,
|
||||||
getMeetingInstance, updateMeetingInstance, softDeleteMeetingInstance,
|
getMeetingInstance, updateMeetingInstance, softDeleteMeetingInstance,
|
||||||
getAssignmentType,
|
getAssignmentType,
|
||||||
getAssignment,
|
getAssignment,
|
||||||
getPhaseTemplate,
|
getPhaseTemplate,
|
||||||
getConsultantsByIds,
|
getConsultantsByIds,
|
||||||
listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure,
|
loadFeedbackStructure, resolveVisibleStructure, updateFeedbackCriterionItem,
|
||||||
|
getGlobalLineTagCorpus,
|
||||||
|
getOrSeedCriterionTaggingPromptTemplate,
|
||||||
listConversationEntriesForMeeting, addConversationEntry, updateConversationEntry, deleteConversationEntry,
|
listConversationEntriesForMeeting, addConversationEntry, updateConversationEntry, deleteConversationEntry,
|
||||||
listConversationSkillScoresAll, addConversationSkillScore, updateConversationSkillScore, deleteConversationSkillScoresForEntry,
|
listConversationSkillScoresAll, addConversationSkillScore, updateConversationSkillScore, deleteConversationSkillScoresForEntry,
|
||||||
listAssessmentsForMeeting, addAssessment, updateAssessment,
|
listAssessmentsForMeeting, addAssessment, updateAssessment,
|
||||||
|
listChecklistItemDefsForPhaseTemplate, listChecklistResponsesForMeeting, upsertChecklistResponse,
|
||||||
|
upsertMeetingCategoryRating,
|
||||||
} from '../../db'
|
} from '../../db'
|
||||||
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport'
|
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport'
|
||||||
import { NotationText, splitNoteLines } from '../../utils/notationParser'
|
import { NotationText, splitNoteLines, parseNotationPrefix } from '../../utils/notationParser'
|
||||||
import { RATING_NUM_MAP } from '../../config/constants'
|
import { clampRatingScore, isValidRatingScore, normalizeRatingScore } from '../../config/constants'
|
||||||
|
import { getAiSettings, callOpenRouter } from '../../utils/aiClient'
|
||||||
|
import { weightedAverageRating } from '../../utils/ratingTrend'
|
||||||
|
import { renderDimensionPrompt } from '../../utils/aiPrompt'
|
||||||
|
import { buildCriterionSuggestionPromptContext, parseCriterionSuggestionResponse } from '../../utils/criterionSuggestionPrompt'
|
||||||
|
import { findSimilarTaggedLines, type SimilarTaggedLine } from '../../utils/criterionSuggestion'
|
||||||
|
import { useRatingColorBands } from '../../hooks/useRatingColorBands'
|
||||||
import { ConversationTab } from './ConversationTab'
|
import { ConversationTab } from './ConversationTab'
|
||||||
import { AssessmentTab } from './AssessmentTab'
|
import { AssessmentTab } from './AssessmentTab'
|
||||||
|
import { ChecklistTab } from './ChecklistTab'
|
||||||
|
import { CriterionTagPicker, type CriterionSuggestion } from './CriterionTagPicker'
|
||||||
|
|
||||||
type Tab = 'conversation' | 'assessment'
|
type Tab = 'conversation' | 'assessment'
|
||||||
|
|
||||||
|
|
@ -26,6 +39,7 @@ export default function MeetingView() {
|
||||||
const { id, meetingId: meetingIdParam } = useParams()
|
const { id, meetingId: meetingIdParam } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const meetingId = Number(meetingIdParam ?? id)
|
const meetingId = Number(meetingIdParam ?? id)
|
||||||
|
const colorBands = useRatingColorBands()
|
||||||
|
|
||||||
const [meeting, setMeeting] = useState<MeetingInstance | null>(null)
|
const [meeting, setMeeting] = useState<MeetingInstance | null>(null)
|
||||||
const [assignment, setAssignment] = useState<Assignment | null>(null)
|
const [assignment, setAssignment] = useState<Assignment | null>(null)
|
||||||
|
|
@ -36,18 +50,34 @@ export default function MeetingView() {
|
||||||
const [entries, setEntries] = useState<ConversationEntry[]>([])
|
const [entries, setEntries] = useState<ConversationEntry[]>([])
|
||||||
const [scores, setScores] = useState<ConversationSkillScore[]>([])
|
const [scores, setScores] = useState<ConversationSkillScore[]>([])
|
||||||
const [assessments, setAssessments] = useState<Assessment[]>([])
|
const [assessments, setAssessments] = useState<Assessment[]>([])
|
||||||
|
const [checklist, setChecklist] = useState<ChecklistItemDef[]>([])
|
||||||
|
const [checklistResponses, setChecklistResponses] = useState<ChecklistResponse[]>([])
|
||||||
const [tab, setTab] = useState<Tab>('conversation')
|
const [tab, setTab] = useState<Tab>('conversation')
|
||||||
const [activeInstId, setActiveInstId] = useState<number | null>(null)
|
const [activeInstId, setActiveInstId] = useState<number | null>(null)
|
||||||
const [activeEntryId, setActiveEntryId] = useState<number | null>(null)
|
const [activeEntryId, setActiveEntryId] = useState<number | null>(null)
|
||||||
const [entryNote, setEntryNote] = useState('')
|
const [entryNote, setEntryNote] = useState('')
|
||||||
const [generalNotes, setGeneralNotes] = useState('')
|
const [generalNotes, setGeneralNotes] = useState('')
|
||||||
|
const [generalNoteTags, setGeneralNoteTags] = useState<ConversationLineTag[]>([])
|
||||||
const [editingGeneralNotes, setEditingGeneralNotes] = useState(false)
|
const [editingGeneralNotes, setEditingGeneralNotes] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [editNote, setEditNote] = useState('')
|
const [editNote, setEditNote] = useState('')
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [showExportMenu, setShowExportMenu] = useState(false)
|
const [showExportMenu, setShowExportMenu] = useState(false)
|
||||||
|
const [lineSuggestions, setLineSuggestions] = useState<Map<string, CriterionSuggestion[]>>(new Map())
|
||||||
|
/** KI-Vorschläge für Checklisten-Antworten, Schlüssel `${checklistItemId}:${instituteeId ?? ''}`. */
|
||||||
|
const [checklistSuggestions, setChecklistSuggestions] = useState<Map<string, CriterionSuggestion[]>>(new Map())
|
||||||
|
const [analyzingSuggestions, setAnalyzingSuggestions] = useState(false)
|
||||||
|
const [suggestionError, setSuggestionError] = useState('')
|
||||||
|
/** Status/Diagnose nach „Vorschläge analysieren" — macht sichtbar, ob überhaupt etwas zu analysieren war und was die KI zurückgegeben hat. */
|
||||||
|
const [suggestionInfo, setSuggestionInfo] = useState('')
|
||||||
|
const [lastAnalysisRaw, setLastAnalysisRaw] = useState<string | null>(null)
|
||||||
|
/** Einmal pro Meeting-Load geholt, dient der rein lokalen Live-Formulierungshilfe (kein KI-Call). */
|
||||||
|
const [corpus, setCorpus] = useState<ConversationLineTag[]>([])
|
||||||
|
const [generalNotesHints, setGeneralNotesHints] = useState<SimilarTaggedLine[]>([])
|
||||||
const noteRef = useRef<HTMLTextAreaElement>(null)
|
const noteRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const generalNotesRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const hintsTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const displayName = (inst: Consultant) => {
|
const displayName = (inst: Consultant) => {
|
||||||
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
|
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
|
||||||
|
|
@ -59,11 +89,52 @@ export default function MeetingView() {
|
||||||
const fillerTotal = (instId: number) =>
|
const fillerTotal = (instId: number) =>
|
||||||
entries.filter(e => e.instituteeId === instId).reduce((s, e) => s + (e.fillerCount ?? 0), 0)
|
entries.filter(e => e.instituteeId === instId).reduce((s, e) => s + (e.fillerCount ?? 0), 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rein lokale (KI-freie) Kriterium-Vorschläge für noch nicht zugeordnete Zeilen — direkt verfügbar,
|
||||||
|
* ohne „🏷 Vorschläge analysieren" auszulösen (siehe CLAUDE.md „Direkte Kriterien-Vorschläge ohne KI").
|
||||||
|
* Mindest-Ähnlichkeit 0,25 ("mittel", gleiche Schwelle wie in criterionSuggestionPrompt.ts), damit
|
||||||
|
* nur halbwegs verlässliche Treffer als klickbarer Vorschlags-Chip erscheinen — schwächere Treffer
|
||||||
|
* bleiben im „💭 Ähnliche frühere Notizen"-Panel weiterhin als reine Textreferenz sichtbar.
|
||||||
|
*/
|
||||||
|
const localLineSuggestions = useMemo(() => {
|
||||||
|
const map = new Map<string, CriterionSuggestion[]>()
|
||||||
|
if (corpus.length === 0) return map
|
||||||
|
|
||||||
|
const untaggedLines: string[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const taggedTexts = new Set((entry.lineTags ?? []).map(t => t.text))
|
||||||
|
for (const line of splitNoteLines(entry.note)) {
|
||||||
|
if (taggedTexts.has(line) || parseNotationPrefix(line).isQuote) continue
|
||||||
|
untaggedLines.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const taggedGeneralTexts = new Set(generalNoteTags.map(t => t.text))
|
||||||
|
for (const line of splitNoteLines(generalNotes)) {
|
||||||
|
if (taggedGeneralTexts.has(line) || parseNotationPrefix(line).isQuote) continue
|
||||||
|
untaggedLines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of untaggedLines) {
|
||||||
|
const hints = findSimilarTaggedLines(line, corpus, 3).filter(h => h.score >= 0.25)
|
||||||
|
if (hints.length === 0) continue
|
||||||
|
map.set(line, hints.map((h, idx) => ({ criterionItemId: h.criterionItemId, rank: idx + 1 })))
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [entries, generalNotes, generalNoteTags, corpus])
|
||||||
|
|
||||||
|
/** KI-Vorschläge (aus „🏷 Vorschläge analysieren") haben Vorrang vor den lokalen, wenn beide für dieselbe Zeile vorliegen. */
|
||||||
|
const mergedLineSuggestions = useMemo(() => {
|
||||||
|
const merged = new Map(localLineSuggestions)
|
||||||
|
for (const [text, sugg] of lineSuggestions) merged.set(text, sugg)
|
||||||
|
return merged
|
||||||
|
}, [localLineSuggestions, lineSuggestions])
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
const m = await getMeetingInstance(meetingId)
|
const m = await getMeetingInstance(meetingId)
|
||||||
if (!m) return
|
if (!m) return
|
||||||
setMeeting(m)
|
setMeeting(m)
|
||||||
setGeneralNotes(m.generalNotes ?? '')
|
setGeneralNotes(m.generalNotes ?? '')
|
||||||
|
setGeneralNoteTags(m.generalNoteTags ?? [])
|
||||||
|
|
||||||
const [tmpl, a] = await Promise.all([
|
const [tmpl, a] = await Promise.all([
|
||||||
getPhaseTemplate(m.phaseTemplateId),
|
getPhaseTemplate(m.phaseTemplateId),
|
||||||
|
|
@ -77,14 +148,13 @@ export default function MeetingView() {
|
||||||
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
|
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
|
||||||
}
|
}
|
||||||
|
|
||||||
const [allCats, allItems, aType] = await Promise.all([
|
const [structure, aType] = await Promise.all([
|
||||||
listFeedbackCategoriesAll(),
|
loadFeedbackStructure(),
|
||||||
listFeedbackCriterionItemsAll(),
|
|
||||||
a ? getAssignmentType(a.assignmentTypeId) : Promise.resolve(undefined),
|
a ? getAssignmentType(a.assignmentTypeId) : Promise.resolve(undefined),
|
||||||
])
|
])
|
||||||
const { categories: visibleCats, items: visibleItems } = filterVisibleFeedbackStructure(allCats, allItems, aType?.criteriaIds)
|
const visible = resolveVisibleStructure(structure.dimensions, structure.categories, structure.items, aType?.criteriaIds)
|
||||||
setFbItems(visibleItems)
|
setFbItems(visible.items)
|
||||||
setFbCategories(visibleCats)
|
setFbCategories(visible.categories)
|
||||||
|
|
||||||
const [convEntries, convScores, asmts] = await Promise.all([
|
const [convEntries, convScores, asmts] = await Promise.all([
|
||||||
listConversationEntriesForMeeting(meetingId),
|
listConversationEntriesForMeeting(meetingId),
|
||||||
|
|
@ -94,6 +164,20 @@ export default function MeetingView() {
|
||||||
setEntries(convEntries)
|
setEntries(convEntries)
|
||||||
setScores(convScores)
|
setScores(convScores)
|
||||||
setAssessments(asmts)
|
setAssessments(asmts)
|
||||||
|
|
||||||
|
getGlobalLineTagCorpus().then(setCorpus)
|
||||||
|
|
||||||
|
if (tmpl?.hasChecklist) {
|
||||||
|
const [checklistDefs, responses] = await Promise.all([
|
||||||
|
listChecklistItemDefsForPhaseTemplate(tmpl.id!),
|
||||||
|
listChecklistResponsesForMeeting(meetingId),
|
||||||
|
])
|
||||||
|
setChecklist(checklistDefs)
|
||||||
|
setChecklistResponses(responses)
|
||||||
|
} else {
|
||||||
|
setChecklist([])
|
||||||
|
setChecklistResponses([])
|
||||||
|
}
|
||||||
}, [meetingId])
|
}, [meetingId])
|
||||||
|
|
||||||
useEffect(() => { loadData() }, [loadData])
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
@ -194,12 +278,16 @@ export default function MeetingView() {
|
||||||
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, instituteeId } : e))
|
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): RatingScore | null =>
|
||||||
scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score ?? null
|
normalizeRatingScore(scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score)
|
||||||
|
|
||||||
const setEntryScore = async (entryId: number, categoryId: number, score: FeedbackRating) => {
|
const setEntryScore = async (entryId: number, categoryId: number, score: RatingScore | null) => {
|
||||||
const existing = scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)
|
const existing = scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)
|
||||||
|
if (score === null) {
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
|
await updateConversationSkillScore(existing.id, { score: null })
|
||||||
|
}
|
||||||
|
} else if (existing?.id) {
|
||||||
await updateConversationSkillScore(existing.id, { score })
|
await updateConversationSkillScore(existing.id, { score })
|
||||||
} else {
|
} else {
|
||||||
await addConversationSkillScore({ conversationEntryId: entryId, criteriaId: categoryId, score })
|
await addConversationSkillScore({ conversationEntryId: entryId, criteriaId: categoryId, score })
|
||||||
|
|
@ -209,10 +297,24 @@ export default function MeetingView() {
|
||||||
|
|
||||||
// ── Assessment handlers ───────────────────────────────────────────────────
|
// ── Assessment handlers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const getAssessmentScore = (instituteeId: number, itemId: number): FeedbackRating | null =>
|
const getAssessmentScore = (instituteeId: number, itemId: number): RatingScore | null =>
|
||||||
assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)?.score ?? null
|
normalizeRatingScore(assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)?.score)
|
||||||
|
|
||||||
const setAssessmentScore = async (instituteeId: number, itemId: number, score: FeedbackRating) => {
|
/** Kategorie-Score = gewichteter Ø der Kriterien (wie Altsystem); null wenn nichts bewertet. */
|
||||||
|
const syncCategoryFromCriteria = async (
|
||||||
|
instituteeId: number,
|
||||||
|
categoryId: number,
|
||||||
|
nextAssessments: Assessment[],
|
||||||
|
) => {
|
||||||
|
const catItems = fbItems.filter(i => i.categoryId === categoryId)
|
||||||
|
const avg = weightedAverageRating(
|
||||||
|
catItems,
|
||||||
|
itemId => nextAssessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)?.score ?? null,
|
||||||
|
)
|
||||||
|
await upsertMeetingCategoryRating({ meetingInstanceId: meetingId, instituteeId, categoryId, score: avg })
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAssessmentScore = async (instituteeId: number, itemId: number, score: RatingScore | null) => {
|
||||||
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
|
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
|
|
@ -220,7 +322,12 @@ export default function MeetingView() {
|
||||||
} else {
|
} else {
|
||||||
await addAssessment({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now })
|
await addAssessment({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now })
|
||||||
}
|
}
|
||||||
setAssessments(await listAssessmentsForMeeting(meetingId))
|
const next = await listAssessmentsForMeeting(meetingId)
|
||||||
|
setAssessments(next)
|
||||||
|
const item = fbItems.find(i => i.id === itemId)
|
||||||
|
if (item) {
|
||||||
|
await syncCategoryFromCriteria(instituteeId, item.categoryId, next)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveAssessmentNote = async (instituteeId: number, itemId: number, note: string) => {
|
const saveAssessmentNote = async (instituteeId: number, itemId: number, note: string) => {
|
||||||
|
|
@ -239,22 +346,264 @@ export default function MeetingView() {
|
||||||
for (const cat of fbCategories) {
|
for (const cat of fbCategories) {
|
||||||
const vals = instEntries
|
const vals = instEntries
|
||||||
.map(e => getEntryScore(e.id!, cat.id!))
|
.map(e => getEntryScore(e.id!, cat.id!))
|
||||||
.filter((s): s is FeedbackRating => s !== null && s !== 'na')
|
.filter(isValidRatingScore)
|
||||||
if (vals.length === 0) continue
|
if (vals.length === 0) continue
|
||||||
const avg = vals.reduce((s, v) => s + RATING_NUM_MAP[v], 0) / vals.length
|
const avg = vals.reduce((s, v) => s + v, 0) / vals.length
|
||||||
const rating: FeedbackRating = avg < 1.5 ? 'not_client_ready' : avg < 2.5 ? 'partially_client_ready' : avg < 3.5 ? 'nearly_client_ready' : 'client_ready'
|
const rating = clampRatingScore(avg)
|
||||||
|
if (rating === null) continue
|
||||||
for (const item of fbItems.filter(i => i.categoryId === cat.id)) {
|
for (const item of fbItems.filter(i => i.categoryId === cat.id)) {
|
||||||
await setAssessmentScore(instituteeId, item.id!, rating)
|
await setAssessmentScore(instituteeId, item.id!, rating)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Checkliste-Handler ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const getChecklistResponse = (checklistItemId: number, instituteeId: number | undefined): ChecklistResponse | undefined =>
|
||||||
|
checklistResponses.find(r => r.checklistItemId === checklistItemId && (r.instituteeId ?? undefined) === instituteeId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checklisten-Text für die KI/Assessment-Notiz — Checkpunkt-Label und Bewertung werden explizit
|
||||||
|
* mitgegeben (nicht nur der freie Kommentar), siehe CLAUDE.md „Checkliste an Assessment gespiegelt".
|
||||||
|
*/
|
||||||
|
const composeChecklistNote = (label: string, note: string, rating: RatingScore): string => {
|
||||||
|
const ratingLabel = String(rating)
|
||||||
|
return note.trim() ? `Checkliste „${label}" (${ratingLabel}): ${note.trim()}` : `Checkliste „${label}" (${ratingLabel})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effektives Kriterium einer Antwort — individuelle Zuordnung überschreibt die Konfigurations-Vorgabe. */
|
||||||
|
const getEffectiveChecklistCriterion = (ci: ChecklistItemDef, instituteeId: number | undefined): number | null =>
|
||||||
|
getChecklistResponse(ci.id!, instituteeId)?.criterionItemId ?? ci.linkedCriterionItemId ?? null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ChecklistResponse` ist immer die Quelle für die Checkliste-UI selbst. Bei effektiv vorhandenem
|
||||||
|
* Kriterium (individuelle Zuordnung ODER Konfigurations-Vorgabe) wird zusätzlich einseitig nach
|
||||||
|
* `Assessment` gespiegelt (score+note), damit Gesamtbewertung/Evaluation und die KI-Prompt-Auswertung
|
||||||
|
* (aiPrompt.ts::kriterienScores liest Assessment.note bereits) die Checklisten-Eintragung ganz normal
|
||||||
|
* als Protokolleintrag mitverwenden — bei `scope: meetingWide` für JEDEN aktuellen Institutee
|
||||||
|
* identisch (Kriterien sind Personen-Kompetenzen, ein meeting-weiter Checkpunkt gilt für alle
|
||||||
|
* gleichermaßen). Wechselt die individuelle Zuordnung auf ein anderes Kriterium, bleibt ein bereits
|
||||||
|
* gespiegelter Eintrag beim alten Kriterium unangetastet (keine rückwirkende Korrektur) — bewusst
|
||||||
|
* einfach gehalten, gleiches Prinzip wie beim Löschen eines Checklisten-Punkts.
|
||||||
|
*/
|
||||||
|
const saveChecklistResponse = async (
|
||||||
|
ci: ChecklistItemDef,
|
||||||
|
instituteeId: number | undefined,
|
||||||
|
patch: { rating?: RatingScore | null; note?: string; criterionItemId?: number },
|
||||||
|
) => {
|
||||||
|
const existing = getChecklistResponse(ci.id!, instituteeId)
|
||||||
|
const rating = 'rating' in patch ? (patch.rating ?? null) : (existing?.rating ?? null)
|
||||||
|
const note = patch.note ?? existing?.note ?? ''
|
||||||
|
const criterionItemId = 'criterionItemId' in patch ? patch.criterionItemId : existing?.criterionItemId
|
||||||
|
await upsertChecklistResponse({ meetingInstanceId: meetingId, instituteeId, checklistItemId: ci.id!, rating, note, criterionItemId })
|
||||||
|
setChecklistResponses(await listChecklistResponsesForMeeting(meetingId))
|
||||||
|
|
||||||
|
const effectiveCriterion = criterionItemId ?? ci.linkedCriterionItemId
|
||||||
|
if (effectiveCriterion && isValidRatingScore(rating)) {
|
||||||
|
const composed = composeChecklistNote(ci.label, note, rating)
|
||||||
|
const targets = ci.scope === 'meetingWide' ? institutees.map(i => i.id!) : [instituteeId!]
|
||||||
|
for (const targetId of targets) {
|
||||||
|
await setAssessmentScore(targetId, effectiveCriterion, rating)
|
||||||
|
await saveAssessmentNote(targetId, effectiveCriterion, composed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setChecklistRating = (ci: ChecklistItemDef, instituteeId: number | undefined, rating: RatingScore | null) =>
|
||||||
|
saveChecklistResponse(ci, instituteeId, { rating })
|
||||||
|
|
||||||
|
const saveChecklistNote = (ci: ChecklistItemDef, instituteeId: number | undefined, note: string) =>
|
||||||
|
saveChecklistResponse(ci, instituteeId, { note })
|
||||||
|
|
||||||
|
const setChecklistCriterion = (ci: ChecklistItemDef, instituteeId: number | undefined, criterionItemId: number | null) =>
|
||||||
|
saveChecklistResponse(ci, instituteeId, { criterionItemId: criterionItemId ?? undefined })
|
||||||
|
|
||||||
|
/** Lokale (KI-freie) Kriterium-Vorschläge für Checklisten-Antworten ohne effektives Kriterium — analog `localLineSuggestions`. */
|
||||||
|
const localChecklistSuggestions = useMemo(() => {
|
||||||
|
const map = new Map<string, CriterionSuggestion[]>()
|
||||||
|
if (corpus.length === 0) return map
|
||||||
|
for (const ci of checklist) {
|
||||||
|
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
|
||||||
|
for (const instituteeId of targets) {
|
||||||
|
if (getEffectiveChecklistCriterion(ci, instituteeId) !== null) continue
|
||||||
|
const comment = getChecklistResponse(ci.id!, instituteeId)?.note?.trim()
|
||||||
|
if (!comment) continue
|
||||||
|
const hints = findSimilarTaggedLines(comment, corpus, 3).filter(h => h.score >= 0.25)
|
||||||
|
if (hints.length === 0) continue
|
||||||
|
map.set(`${ci.id}:${instituteeId ?? ''}`, hints.map((h, idx) => ({ criterionItemId: h.criterionItemId, rank: idx + 1 })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [checklist, checklistResponses, institutees, corpus])
|
||||||
|
|
||||||
|
const mergedChecklistSuggestions = useMemo(() => {
|
||||||
|
const merged = new Map(localChecklistSuggestions)
|
||||||
|
for (const [key, sugg] of checklistSuggestions) merged.set(key, sugg)
|
||||||
|
return merged
|
||||||
|
}, [localChecklistSuggestions, checklistSuggestions])
|
||||||
|
|
||||||
|
const getChecklistSuggestions = (checklistItemId: number, instituteeId: number | undefined): CriterionSuggestion[] | undefined =>
|
||||||
|
mergedChecklistSuggestions.get(`${checklistItemId}:${instituteeId ?? ''}`)
|
||||||
|
|
||||||
// ── Meeting-level handlers ────────────────────────────────────────────────
|
// ── Meeting-level handlers ────────────────────────────────────────────────
|
||||||
|
|
||||||
const saveGeneralNotes = (val: string) => {
|
const saveGeneralNotes = (val: string) => {
|
||||||
setGeneralNotes(val)
|
setGeneralNotes(val)
|
||||||
if (notesTimer.current) clearTimeout(notesTimer.current)
|
if (notesTimer.current) clearTimeout(notesTimer.current)
|
||||||
notesTimer.current = setTimeout(() => updateMeetingInstance(meetingId, { generalNotes: val }), 600)
|
notesTimer.current = setTimeout(() => updateMeetingInstance(meetingId, { generalNotes: val }), 600)
|
||||||
|
|
||||||
|
if (hintsTimer.current) clearTimeout(hintsTimer.current)
|
||||||
|
hintsTimer.current = setTimeout(() => {
|
||||||
|
const lastLine = val.split('\n').pop()?.trim() ?? ''
|
||||||
|
setGeneralNotesHints(lastLine ? findSimilarTaggedLines(lastLine, corpus) : [])
|
||||||
|
}, 800)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verwaiste Zuordnungen entfernen, falls eine getaggte Zeile beim Editieren verändert/gelöscht wurde. */
|
||||||
|
const finishEditingGeneralNotes = () => {
|
||||||
|
const currentLines = splitNoteLines(generalNotes)
|
||||||
|
const prunedTags = generalNoteTags.filter(t => currentLines.includes(t.text))
|
||||||
|
if (prunedTags.length !== generalNoteTags.length) {
|
||||||
|
setGeneralNoteTags(prunedTags)
|
||||||
|
updateMeetingInstance(meetingId, { generalNoteTags: prunedTags })
|
||||||
|
}
|
||||||
|
setEditingGeneralNotes(false)
|
||||||
|
setGeneralNotesHints([])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übernimmt einen Formulierungsvorschlag als Ersatz für die gerade getippte (letzte) Zeile. */
|
||||||
|
const applyGeneralHint = (hintText: string) => {
|
||||||
|
const lines = generalNotes.split('\n')
|
||||||
|
lines[lines.length - 1] = hintText
|
||||||
|
saveGeneralNotes(lines.join('\n'))
|
||||||
|
setGeneralNotesHints([])
|
||||||
|
setTimeout(() => generalNotesRef.current?.focus(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie setLineCriterion, aber meeting-weit statt an ein Institutee gebunden — gilt für alle Institutees. */
|
||||||
|
const setGeneralNoteCriterion = async (lineText: string, criterionItemId: number | null) => {
|
||||||
|
const currentLines = splitNoteLines(generalNotes)
|
||||||
|
const kept = generalNoteTags.filter(t => t.text !== lineText && currentLines.includes(t.text))
|
||||||
|
const nextTags = criterionItemId === null ? kept : [...kept, { text: lineText, criterionItemId }]
|
||||||
|
await updateMeetingInstance(meetingId, { generalNoteTags: nextTags })
|
||||||
|
setGeneralNoteTags(nextTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „🏷 Vorschläge analysieren" — EIN gebündelter KI-Call für das gesamte offene Protokoll
|
||||||
|
* (nicht pro Zeile), siehe CLAUDE.md „Selbstlernendes Kriterien-Tagging". Sammelt alle noch
|
||||||
|
* nicht zugeordneten Zeilen (Gesprächsbeiträge + allgemeine Notizen, Kundenzitate ausgenommen —
|
||||||
|
* gleiches Ausschlussprinzip wie in aiPrompt.ts), lässt die KI pro Zeile priorisierte
|
||||||
|
* Kriterium-IDs vorschlagen und persistiert die als Nebenprodukt gelieferten Formulierungsmuster
|
||||||
|
* direkt auf `FeedbackCriterionItem.learnedPatternHints`.
|
||||||
|
*/
|
||||||
|
const analyzeSuggestions = async () => {
|
||||||
|
if (!assignment) return
|
||||||
|
const settings = getAiSettings()
|
||||||
|
if (!settings.apiKey) {
|
||||||
|
setSuggestionError('Kein KI-API-Key hinterlegt (Config → KI-Prompts bzw. Feedback-Seite → KI-Einstellungen).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnalyzingSuggestions(true)
|
||||||
|
setSuggestionError('')
|
||||||
|
setSuggestionInfo('')
|
||||||
|
setLastAnalysisRaw(null)
|
||||||
|
try {
|
||||||
|
const [aType, structure, corpus, template] = await Promise.all([
|
||||||
|
getAssignmentType(assignment.assignmentTypeId),
|
||||||
|
loadFeedbackStructure(),
|
||||||
|
getGlobalLineTagCorpus(),
|
||||||
|
getOrSeedCriterionTaggingPromptTemplate(),
|
||||||
|
])
|
||||||
|
const visible = resolveVisibleStructure(
|
||||||
|
structure.dimensions,
|
||||||
|
structure.categories,
|
||||||
|
structure.items,
|
||||||
|
aType?.criteriaIds,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface LineRef { lineNumber: number; text: string; speakerLabel?: string; checklistKey?: string }
|
||||||
|
const lineRefs: LineRef[] = []
|
||||||
|
let n = 1
|
||||||
|
for (const entry of entries) {
|
||||||
|
const inst = institutees.find(i => i.id === entry.instituteeId)
|
||||||
|
const taggedTexts = new Set((entry.lineTags ?? []).map(t => t.text))
|
||||||
|
for (const line of splitNoteLines(entry.note)) {
|
||||||
|
if (taggedTexts.has(line) || parseNotationPrefix(line).isQuote) continue
|
||||||
|
lineRefs.push({ lineNumber: n++, text: line, speakerLabel: inst ? tagLabel(inst) : undefined })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const taggedGeneralTexts = new Set(generalNoteTags.map(t => t.text))
|
||||||
|
for (const line of splitNoteLines(generalNotes)) {
|
||||||
|
if (taggedGeneralTexts.has(line) || parseNotationPrefix(line).isQuote) continue
|
||||||
|
lineRefs.push({ lineNumber: n++, text: line })
|
||||||
|
}
|
||||||
|
// Checklisten-Antworten ohne effektives Kriterium (weder individuell noch konfiguriert) — auch
|
||||||
|
// ohne Kommentar einbeziehen (Label allein ist bereits ein brauchbares Signal für die KI),
|
||||||
|
// sonst bleiben reine Bewertungen ohne Freitext fälschlich unanalysiert.
|
||||||
|
for (const ci of checklist) {
|
||||||
|
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
|
||||||
|
for (const instituteeId of targets) {
|
||||||
|
if (getEffectiveChecklistCriterion(ci, instituteeId) !== null) continue
|
||||||
|
const comment = getChecklistResponse(ci.id!, instituteeId)?.note?.trim()
|
||||||
|
const inst = instituteeId ? institutees.find(i => i.id === instituteeId) : undefined
|
||||||
|
lineRefs.push({
|
||||||
|
lineNumber: n++,
|
||||||
|
text: comment ? `${ci.label}: ${comment}` : ci.label,
|
||||||
|
speakerLabel: inst ? tagLabel(inst) : undefined,
|
||||||
|
checklistKey: `${ci.id}:${instituteeId ?? ''}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineRefs.length === 0) {
|
||||||
|
setSuggestionInfo('Keine offenen Protokollzeilen oder Checklisten-Punkte gefunden — alles bereits zugeordnet oder leer.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = buildCriterionSuggestionPromptContext({
|
||||||
|
assignmentTypeName: aType?.name ?? '—',
|
||||||
|
dimensions: visible.dimensions,
|
||||||
|
categories: visible.categories,
|
||||||
|
items: visible.items,
|
||||||
|
visibleCriteriaIds: aType?.criteriaIds,
|
||||||
|
lines: lineRefs,
|
||||||
|
corpus,
|
||||||
|
})
|
||||||
|
const prompt = renderDimensionPrompt(template, context)
|
||||||
|
const raw = await callOpenRouter(prompt, settings)
|
||||||
|
setLastAnalysisRaw(raw)
|
||||||
|
const { lineSuggestions: parsedLines, patternHints } = parseCriterionSuggestionResponse(raw)
|
||||||
|
|
||||||
|
const nextSuggestions = new Map<string, CriterionSuggestion[]>()
|
||||||
|
const nextChecklistSuggestions = new Map<string, CriterionSuggestion[]>()
|
||||||
|
for (const ref of lineRefs) {
|
||||||
|
const ids = parsedLines.get(ref.lineNumber)
|
||||||
|
if (!ids || ids.length === 0) continue
|
||||||
|
const suggestions = ids.map((criterionItemId, idx) => ({ criterionItemId, rank: idx + 1 }))
|
||||||
|
if (ref.checklistKey) nextChecklistSuggestions.set(ref.checklistKey, suggestions)
|
||||||
|
else nextSuggestions.set(ref.text, suggestions)
|
||||||
|
}
|
||||||
|
setLineSuggestions(nextSuggestions)
|
||||||
|
setChecklistSuggestions(nextChecklistSuggestions)
|
||||||
|
|
||||||
|
const totalSuggestions = nextSuggestions.size + nextChecklistSuggestions.size
|
||||||
|
setSuggestionInfo(totalSuggestions === 0
|
||||||
|
? `${lineRefs.length} Zeile(n)/Punkt(e) analysiert, aber die KI hat für keine(n) davon einen Vorschlag geliefert — Rohantwort unten einsehbar.`
|
||||||
|
: `${lineRefs.length} Zeile(n)/Punkt(e) analysiert, ${totalSuggestions} Vorschlag/Vorschläge erhalten.`)
|
||||||
|
|
||||||
|
if (patternHints.size > 0) {
|
||||||
|
await Promise.all([...patternHints.entries()].map(([criterionItemId, hint]) =>
|
||||||
|
updateFeedbackCriterionItem(criterionItemId, { learnedPatternHints: hint }),
|
||||||
|
))
|
||||||
|
setFbItems(prev => prev.map(i => patternHints.has(i.id!) ? { ...i, learnedPatternHints: patternHints.get(i.id!) } : i))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setSuggestionError(e instanceof Error ? e.message : 'Unbekannter Fehler bei der Analyse.')
|
||||||
|
} finally {
|
||||||
|
setAnalyzingSuggestions(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildFilename = (ext: string) => {
|
const buildFilename = (ext: string) => {
|
||||||
|
|
@ -293,6 +642,7 @@ export default function MeetingView() {
|
||||||
|
|
||||||
const hasConv = template.hasConversation
|
const hasConv = template.hasConversation
|
||||||
const hasAssess = template.hasAssessment
|
const hasAssess = template.hasAssessment
|
||||||
|
const hasChecklist = template.hasChecklist
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -316,6 +666,12 @@ export default function MeetingView() {
|
||||||
className="text-sm bg-white border border-gray-200 text-gray-600 px-2.5 py-1.5 rounded-lg font-medium">
|
className="text-sm bg-white border border-gray-200 text-gray-600 px-2.5 py-1.5 rounded-lg font-medium">
|
||||||
📜 Verlauf
|
📜 Verlauf
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={analyzeSuggestions}
|
||||||
|
disabled={analyzingSuggestions}
|
||||||
|
title="KI-Vorschläge für noch nicht zugeordnete Protokollzeilen"
|
||||||
|
className="text-sm bg-white border border-blue-200 text-blue-600 px-2.5 py-1.5 rounded-lg font-medium disabled:opacity-50">
|
||||||
|
{analyzingSuggestions ? '⏳ Analysiere…' : '🏷 Vorschläge analysieren'}
|
||||||
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button onClick={() => setShowExportMenu(p => !p)}
|
<button onClick={() => setShowExportMenu(p => !p)}
|
||||||
className="text-sm bg-white border border-gray-200 text-gray-600 px-2.5 py-1.5 rounded-lg font-medium">
|
className="text-sm bg-white border border-gray-200 text-gray-600 px-2.5 py-1.5 rounded-lg font-medium">
|
||||||
|
|
@ -346,6 +702,24 @@ export default function MeetingView() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{suggestionError && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl px-3 py-2 text-xs text-red-600">
|
||||||
|
{suggestionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestionInfo && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl px-3 py-2 text-xs text-blue-700 space-y-1">
|
||||||
|
<div>{suggestionInfo}</div>
|
||||||
|
{lastAnalysisRaw && (
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer select-none font-medium">🔍 Rohantwort ansehen</summary>
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap text-[10px] text-blue-900 bg-white border border-blue-100 rounded-lg p-2 max-h-64 overflow-y-auto">{lastAnalysisRaw}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Löschen-Bestätigung */}
|
{/* Löschen-Bestätigung */}
|
||||||
{showDeleteConfirm && (
|
{showDeleteConfirm && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 space-y-3">
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 space-y-3">
|
||||||
|
|
@ -367,28 +741,66 @@ export default function MeetingView() {
|
||||||
{/* Allgemeine Notizen */}
|
{/* Allgemeine Notizen */}
|
||||||
{editingGeneralNotes ? (
|
{editingGeneralNotes ? (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={generalNotesRef}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full border border-brand rounded-xl px-3 py-2 text-sm bg-white focus:outline-none resize-none"
|
className="w-full border border-brand rounded-xl px-3 py-2 text-sm bg-white focus:outline-none resize-none"
|
||||||
style={{ minHeight: '4rem', fieldSizing: 'content' } as React.CSSProperties}
|
style={{ minHeight: '4rem', fieldSizing: 'content' } as React.CSSProperties}
|
||||||
placeholder="Allgemeine Notizen zum Meeting… (+ gut, ! negativ, > Kundeninput)"
|
placeholder="Allgemeine Notizen zum Meeting… (+ gut, ! negativ, > Kundeninput)"
|
||||||
value={generalNotes}
|
value={generalNotes}
|
||||||
onChange={e => saveGeneralNotes(e.target.value)}
|
onChange={e => saveGeneralNotes(e.target.value)}
|
||||||
onBlur={() => setEditingGeneralNotes(false)}
|
onBlur={finishEditingGeneralNotes}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
|
{editingGeneralNotes && generalNotesHints.length > 0 && (
|
||||||
|
<details className="bg-blue-50 border border-blue-100 rounded-lg px-3 py-1.5" open>
|
||||||
|
<summary className="text-xs font-medium text-blue-700 cursor-pointer select-none">💭 Ähnliche frühere Notizen — klicken zum Übernehmen</summary>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{generalNotesHints.map((h, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<button type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => applyGeneralHint(h.text)}
|
||||||
|
className="text-xs text-blue-800 hover:text-blue-950 hover:underline truncate text-left w-full">
|
||||||
|
„{h.text}"
|
||||||
|
{fbItems.find(i => i.id === h.criterionItemId)?.name && (
|
||||||
|
<span className="text-blue-400"> → 🏷 {fbItems.find(i => i.id === h.criterionItemId)?.name}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{!editingGeneralNotes && (
|
||||||
<div onClick={() => setEditingGeneralNotes(true)}
|
<div onClick={() => setEditingGeneralNotes(true)}
|
||||||
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm bg-white cursor-text min-h-[2.5rem]">
|
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm bg-white cursor-text min-h-[2.5rem]">
|
||||||
{generalNotes.trim()
|
{generalNotes.trim()
|
||||||
? <NotationText text={generalNotes} />
|
? (
|
||||||
|
<NotationText
|
||||||
|
text={generalNotes}
|
||||||
|
colorBands={colorBands}
|
||||||
|
renderLineAddon={line => (
|
||||||
|
<span onClick={e => e.stopPropagation()}>
|
||||||
|
<CriterionTagPicker
|
||||||
|
items={fbItems}
|
||||||
|
categories={fbCategories}
|
||||||
|
value={generalNoteTags.find(t => t.text === line)?.criterionItemId ?? null}
|
||||||
|
onChange={itemId => setGeneralNoteCriterion(line, itemId)}
|
||||||
|
suggestions={mergedLineSuggestions.get(line)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
: <span className="text-gray-300">Allgemeine Notizen zum Meeting…</span>
|
: <span className="text-gray-300">Allgemeine Notizen zum Meeting…</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
{(hasConv || hasAssess) && (
|
{(hasConv || hasAssess || hasChecklist) && (
|
||||||
<div className="flex bg-gray-100 rounded-xl p-1">
|
<div className="flex bg-gray-100 rounded-xl p-1">
|
||||||
{hasConv && (
|
{(hasConv || hasChecklist) && (
|
||||||
<button onClick={() => setTab('conversation')}
|
<button onClick={() => setTab('conversation')}
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
|
||||||
tab === 'conversation' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
|
tab === 'conversation' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
|
||||||
|
|
@ -401,18 +813,38 @@ export default function MeetingView() {
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
|
||||||
tab === 'assessment' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
|
tab === 'assessment' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
|
||||||
}`}>
|
}`}>
|
||||||
Gesamtbewertung
|
Einschätzung
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Checkliste — ganz normal im Gesprächsprotokoll angezeigt, kein eigener Tab (siehe CLAUDE.md) */}
|
||||||
|
{tab === 'conversation' && hasChecklist && (
|
||||||
|
<ChecklistTab
|
||||||
|
institutees={institutees}
|
||||||
|
checklist={checklist}
|
||||||
|
fbItems={fbItems}
|
||||||
|
fbCategories={fbCategories}
|
||||||
|
tagLabel={tagLabel}
|
||||||
|
getChecklistResponse={getChecklistResponse}
|
||||||
|
onSetChecklistRating={setChecklistRating}
|
||||||
|
onSaveChecklistNote={saveChecklistNote}
|
||||||
|
getEffectiveCriterion={getEffectiveChecklistCriterion}
|
||||||
|
onSetChecklistCriterion={setChecklistCriterion}
|
||||||
|
getChecklistSuggestions={getChecklistSuggestions}
|
||||||
|
colorBands={colorBands}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'conversation' && hasConv && (
|
{tab === 'conversation' && hasConv && (
|
||||||
<ConversationTab
|
<ConversationTab
|
||||||
institutees={institutees}
|
institutees={institutees}
|
||||||
fbCategories={fbCategories}
|
fbCategories={fbCategories}
|
||||||
fbItems={fbItems}
|
fbItems={fbItems}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
|
lineSuggestions={mergedLineSuggestions}
|
||||||
|
corpus={corpus}
|
||||||
activeInstId={activeInstId}
|
activeInstId={activeInstId}
|
||||||
activeEntryId={activeEntryId}
|
activeEntryId={activeEntryId}
|
||||||
entryNote={entryNote}
|
entryNote={entryNote}
|
||||||
|
|
@ -438,6 +870,7 @@ export default function MeetingView() {
|
||||||
onEditNoteChange={setEditNote}
|
onEditNoteChange={setEditNote}
|
||||||
onDeleteEntry={deleteEntry}
|
onDeleteEntry={deleteEntry}
|
||||||
onAdjustFiller={adjustFiller}
|
onAdjustFiller={adjustFiller}
|
||||||
|
colorBands={colorBands}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -448,6 +881,7 @@ export default function MeetingView() {
|
||||||
fbItems={fbItems}
|
fbItems={fbItems}
|
||||||
assessments={assessments}
|
assessments={assessments}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
|
generalNoteTags={generalNoteTags}
|
||||||
hasConv={hasConv}
|
hasConv={hasConv}
|
||||||
tagLabel={tagLabel}
|
tagLabel={tagLabel}
|
||||||
fillerTotal={fillerTotal}
|
fillerTotal={fillerTotal}
|
||||||
|
|
@ -455,8 +889,10 @@ export default function MeetingView() {
|
||||||
onSetAssessmentScore={setAssessmentScore}
|
onSetAssessmentScore={setAssessmentScore}
|
||||||
onSaveAssessmentNote={saveAssessmentNote}
|
onSaveAssessmentNote={saveAssessmentNote}
|
||||||
onAutoCalc={autoCalcAssessment}
|
onAutoCalc={autoCalcAssessment}
|
||||||
|
colorBands={colorBands}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
src/utils/aiClient.ts
Normal file
44
src/utils/aiClient.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { AI_CONFIG } from '../config/constants'
|
||||||
|
|
||||||
|
export interface AiSettings {
|
||||||
|
apiKey: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAiSettings(): AiSettings {
|
||||||
|
return {
|
||||||
|
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
|
||||||
|
model: localStorage.getItem(AI_CONFIG.localStorageKeyModel) ?? AI_CONFIG.defaultModel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAiSettings(apiKey: string, model: string): void {
|
||||||
|
localStorage.setItem(AI_CONFIG.localStorageKeyApiKey, apiKey)
|
||||||
|
localStorage.setItem(AI_CONFIG.localStorageKeyModel, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einziger Netzwerk-Kontaktpunkt zu OpenRouter — läuft bewusst client-seitig direkt gegen die
|
||||||
|
* API (nicht über den RPC-Server), API-Key kommt aus `localStorage`. Extrahiert aus
|
||||||
|
* `AssignmentFeedbackPage.tsx` (KI-Feedback-Generierung), damit das selbstlernende
|
||||||
|
* Kriterien-Tagging keine zweite, leicht abweichende Fetch-Implementierung braucht.
|
||||||
|
*/
|
||||||
|
export async function callOpenRouter(prompt: string, settings: AiSettings): Promise<string> {
|
||||||
|
const res = await fetch(`${AI_CONFIG.openRouterBaseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${settings.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': window.location.origin,
|
||||||
|
'X-Title': 'Assignment Monitor',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: settings.model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`API-Fehler: ${res.status} ${res.statusText}`)
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
return data.choices?.[0]?.message?.content ?? ''
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,38 @@
|
||||||
import type {
|
import type {
|
||||||
Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
|
Consultant, Assignment, FeedbackDimension, FeedbackCategory, FeedbackCriterionItem,
|
||||||
FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, FeedbackRating,
|
FeedbackCategoryRating, Assessment, ConversationEntry, MeetingInstance, RatingScore,
|
||||||
} from '../db/types'
|
} from '../db/types'
|
||||||
import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants'
|
import { clampRatingScore, isValidRatingScore } from '../config/constants'
|
||||||
import { splitNoteLines, parseNotationPrefix } from './notationParser'
|
import { splitNoteLines, parseNotationPrefix } from './notationParser'
|
||||||
import { numberToRating, type TrendResult } from './ratingTrend'
|
import type { TrendResult } from './ratingTrend'
|
||||||
|
|
||||||
const ratingToNum = (r: FeedbackRating | null | undefined): number =>
|
|
||||||
r ? (RATING_NUM_MAP[r] ?? 0) : 0
|
|
||||||
|
|
||||||
const fullRatingLabel = (r: FeedbackRating): string => RATING_OPTIONS.find(o => o.value === r)?.full ?? r
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Löst ein Notation-Präfix (`+`/`(+)`/`(!)`/`!`/`>`) am Zeilenanfang zu einem ausgeschriebenen,
|
* Löst ein Notations-Präfix (führende Score-Zahl 1–10 bzw. `>`) am Zeilenanfang auf.
|
||||||
* mit " — " abgetrennten Label auf. Bewusst KEINE eckigen Klammern als Wrapper — die Notizen
|
* Format bewusst „Score N/10 — Text" bzw. „Kundenzitat — Text" — keine eckigen Klammern
|
||||||
* nutzen `[Titel]` bereits für Slide-/Themen-Referenzen (siehe SLIDE_RE in notationParser.tsx),
|
* als Wrapper, da `[Titel]` bereits für Slide-/Themen-Referenzen reserviert ist.
|
||||||
* eckige Klammern um das Label würden damit für die KI ununterscheidbar kollidieren.
|
|
||||||
*/
|
*/
|
||||||
const formatNotationLine = (line: string): string => {
|
const formatNotationLine = (line: string): string => {
|
||||||
const { rating, isQuote, text } = parseNotationPrefix(line)
|
const { rating, isQuote, text } = parseNotationPrefix(line)
|
||||||
const tag = isQuote ? 'Kundenzitat' : rating ? fullRatingLabel(rating) : null
|
const tag = isQuote ? 'Kundenzitat' : rating !== null ? `Score ${rating}/10` : null
|
||||||
return tag ? `${tag} — ${text}` : text
|
return tag ? `${tag} — ${text}` : text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ganze Zeile besteht nur aus "[Titel]" — Abschnittsmarker des Bewerters (Slide-/Themen-Referenz). */
|
||||||
|
const MARKER_ONLY_RE = /^\[[^\]]*\]$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abschnittsmarker wie "[Background]" sind reine Navigationshilfe für den Bewerter (an welcher
|
||||||
|
* Stelle der Präsentation/des Dialogs eine Beobachtung entstand) — für die KI ohne nachfolgenden
|
||||||
|
* Inhalt bedeutungslos und keine Bewertungsgrundlage. Bleibt erhalten, wenn die direkt folgende
|
||||||
|
* Zeile noch Inhalt liefert (dann als Kontext-Header sinnvoll), sonst wird die Marker-Zeile entfernt.
|
||||||
|
*/
|
||||||
|
const dropDanglingMarkers = (lines: string[]): string[] =>
|
||||||
|
lines.filter((line, i) => {
|
||||||
|
if (!MARKER_ONLY_RE.test(line.trim())) return true
|
||||||
|
const next = lines[i + 1]
|
||||||
|
return next !== undefined && !MARKER_ONLY_RE.test(next.trim())
|
||||||
|
})
|
||||||
|
|
||||||
export interface DimensionPromptInput {
|
export interface DimensionPromptInput {
|
||||||
institutee: Consultant
|
institutee: Consultant
|
||||||
assignment: Assignment
|
assignment: Assignment
|
||||||
|
|
@ -53,36 +63,41 @@ export function buildDimensionPromptContext(input: DimensionPromptInput): Record
|
||||||
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
|
const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0)
|
||||||
|
|
||||||
const kategorienStruktur = dimCategories
|
const kategorienStruktur = dimCategories
|
||||||
.map((c, i) => `${i + 1}. ${c.name}`)
|
.map((c, i) => `${i + 1}. ${c.name}${c.description ? ` — ${c.description}` : ''}`)
|
||||||
.join('\n') || '(keine Kategorien)'
|
.join('\n') || '(keine Kategorien)'
|
||||||
|
|
||||||
const kriterienScores = dimCategories.map(cat => {
|
const kriterienScores = dimCategories.map(cat => {
|
||||||
const catItems = dimItems.filter(i => i.categoryId === cat.id)
|
const catItems = dimItems.filter(i => i.categoryId === cat.id)
|
||||||
const lines = catItems.map(c => {
|
const lines = catItems.map(c => {
|
||||||
const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null && a.score !== 'na')
|
const scores = instAssessments.filter(a => a.criteriaId === c.id && isValidRatingScore(a.score))
|
||||||
|
const generalNotesForCrit = meetings.flatMap(m =>
|
||||||
|
(m.generalNoteTags ?? []).filter(t => t.criterionItemId === c.id).map(t => t.text),
|
||||||
|
)
|
||||||
const notes = [
|
const notes = [
|
||||||
...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note),
|
...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note),
|
||||||
...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text),
|
...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text),
|
||||||
|
...generalNotesForCrit,
|
||||||
].map(formatNotationLine)
|
].map(formatNotationLine)
|
||||||
// Kriterien ohne jede Beobachtung (weder Score noch Notiz) auslassen — reines Rauschen für die KI.
|
|
||||||
if (scores.length === 0 && notes.length === 0) return null
|
if (scores.length === 0 && notes.length === 0) return null
|
||||||
const avgNum = scores.length > 0
|
const avgNum = scores.length > 0
|
||||||
? scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length
|
? scores.reduce((s, a) => s + (a.score as number), 0) / scores.length
|
||||||
: null
|
: null
|
||||||
const avgLabel = avgNum !== null ? `${fullRatingLabel(numberToRating(avgNum))} (${avgNum.toFixed(1)})` : 'nur Notiz, keine Bewertung'
|
const clamped = avgNum !== null ? clampRatingScore(avgNum) : null
|
||||||
|
const avgLabel = avgNum !== null
|
||||||
|
? `${clamped ?? avgNum.toFixed(1)}/10 (Ø ${avgNum.toFixed(1)})`
|
||||||
|
: 'nur Notiz, keine Bewertung'
|
||||||
const label = c.description ? `${c.name} (${c.description})` : c.name
|
const label = c.description ? `${c.name} (${c.description})` : c.name
|
||||||
return ` ${label}: ${avgLabel}${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
|
return ` ${label}: ${avgLabel}${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
|
||||||
}).filter((l): l is string => l !== null)
|
}).filter((l): l is string => l !== null)
|
||||||
// Kategorien ohne jede Beobachtung ganz auslassen — tauchen bereits über KATEGORIEN_STRUKTUR/
|
|
||||||
// KATEGORIE_BEWERTUNGEN/ENTWICKLUNG_VERLAUF auf, hier nur zusätzliches Rauschen.
|
|
||||||
if (lines.length === 0) return null
|
if (lines.length === 0) return null
|
||||||
return ` ${cat.name}:\n${lines.join('\n')}`
|
return ` ${cat.name}:\n${lines.join('\n')}`
|
||||||
}).filter((l): l is string => l !== null).join('\n') || '(keine Scores erfasst)'
|
}).filter((l): l is string => l !== null).join('\n') || '(keine Scores erfasst)'
|
||||||
|
|
||||||
const kategorieBewertungen = dimCategories.map(cat => {
|
const kategorieBewertungen = dimCategories.map(cat => {
|
||||||
const r = ratings.find(r => r.feedbackCategoryId === cat.id)
|
const r = ratings.find(x => x.feedbackCategoryId === cat.id)
|
||||||
const rating = RATING_OPTIONS.find(x => x.value === r?.rating)?.label ?? 'nicht bewertet'
|
const rating: RatingScore | undefined = r?.rating
|
||||||
return ` ${cat.name}: ${rating}`
|
const label = isValidRatingScore(rating) ? `${rating}/10` : 'nicht bewertet'
|
||||||
|
return ` ${cat.name}: ${label}`
|
||||||
}).join('\n') || '(keine Kategorien)'
|
}).join('\n') || '(keine Kategorien)'
|
||||||
|
|
||||||
const entwicklungVerlauf = dimCategories.map(cat => {
|
const entwicklungVerlauf = dimCategories.map(cat => {
|
||||||
|
|
@ -99,7 +114,11 @@ export function buildDimensionPromptContext(input: DimensionPromptInput): Record
|
||||||
const notizenAllgemeinMeeting = meetings
|
const notizenAllgemeinMeeting = meetings
|
||||||
.map(m => {
|
.map(m => {
|
||||||
if (!m.generalNotes || m.generalNotes.trim() === '') return null
|
if (!m.generalNotes || m.generalNotes.trim() === '') return null
|
||||||
const lines = splitNoteLines(m.generalNotes).map(line => ` - ${formatNotationLine(line)}`)
|
const rawLines = splitNoteLines(m.generalNotes)
|
||||||
|
.filter(line => !(m.generalNoteTags ?? []).some(t => t.text === line))
|
||||||
|
.filter(line => !parseNotationPrefix(line).isQuote)
|
||||||
|
const lines = dropDanglingMarkers(rawLines).map(line => ` - ${formatNotationLine(line)}`)
|
||||||
|
if (lines.length === 0) return null
|
||||||
return ` [${m.date}]\n${lines.join('\n')}`
|
return ` [${m.date}]\n${lines.join('\n')}`
|
||||||
})
|
})
|
||||||
.filter((x): x is string => x !== null)
|
.filter((x): x is string => x !== null)
|
||||||
|
|
@ -108,12 +127,14 @@ export function buildDimensionPromptContext(input: DimensionPromptInput): Record
|
||||||
const protokollUnzugeordnet = meetings
|
const protokollUnzugeordnet = meetings
|
||||||
.map(m => {
|
.map(m => {
|
||||||
const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id)
|
const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id)
|
||||||
const lines = meetingEntries
|
const rawLines = meetingEntries
|
||||||
.flatMap(e => splitNoteLines(e.note).map(line => ({
|
.flatMap(e => splitNoteLines(e.note).map(line => ({
|
||||||
line, tagged: (e.lineTags ?? []).some(t => t.text === line),
|
line, tagged: (e.lineTags ?? []).some(t => t.text === line),
|
||||||
})))
|
})))
|
||||||
.filter(x => !x.tagged)
|
.filter(x => !x.tagged)
|
||||||
.map(x => ` - ${formatNotationLine(x.line)}`)
|
.filter(x => !parseNotationPrefix(x.line).isQuote)
|
||||||
|
.map(x => x.line)
|
||||||
|
const lines = dropDanglingMarkers(rawLines).map(line => ` - ${formatNotationLine(line)}`)
|
||||||
if (lines.length === 0) return null
|
if (lines.length === 0) return null
|
||||||
return ` [${m.date}]\n${lines.join('\n')}`
|
return ` [${m.date}]\n${lines.join('\n')}`
|
||||||
})
|
})
|
||||||
|
|
@ -143,56 +164,439 @@ export function renderDimensionPrompt(template: string, context: Record<string,
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedDimensionResponse {
|
export const renderPrompt = renderDimensionPrompt
|
||||||
categoryTexts: { name: string; text: string }[]
|
|
||||||
|
export interface CategoryPromptInput {
|
||||||
|
institutee: Consultant
|
||||||
|
assignment: Assignment
|
||||||
|
dimension: FeedbackDimension
|
||||||
|
category: FeedbackCategory
|
||||||
|
catItems: FeedbackCriterionItem[]
|
||||||
|
ratings: FeedbackCategoryRating[]
|
||||||
|
catTrend: TrendResult | undefined
|
||||||
|
instAssessments: Assessment[]
|
||||||
|
instEntries: ConversationEntry[]
|
||||||
|
meetings: MeetingInstance[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryAiResult {
|
||||||
|
score: number | null
|
||||||
|
benefits: string
|
||||||
|
concerns: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryCondenseEntry {
|
||||||
|
name: string
|
||||||
|
score: number | null
|
||||||
|
benefits: string
|
||||||
|
concerns: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DimensionCondenseInput {
|
||||||
|
institutee: Consultant
|
||||||
|
assignment: Assignment
|
||||||
|
dimension: FeedbackDimension
|
||||||
|
categoryResults: CategoryCondenseEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedDimensionCondenseResponse {
|
||||||
achievements: string
|
achievements: string
|
||||||
developmentNeeds: string
|
developmentNeeds: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stufe A: schmaler Kontext für genau eine Kategorie. */
|
||||||
|
export function buildCategoryPromptContext(input: CategoryPromptInput): Record<string, string> {
|
||||||
|
const {
|
||||||
|
institutee, assignment, dimension, category, catItems,
|
||||||
|
ratings, catTrend, instAssessments, instEntries, meetings,
|
||||||
|
} = input
|
||||||
|
|
||||||
|
const catItemIds = new Set(catItems.map(i => i.id))
|
||||||
|
const allLineTags = instEntries.flatMap(e => e.lineTags ?? [])
|
||||||
|
|
||||||
|
const kriterienScores = catItems.map(c => {
|
||||||
|
const scores = instAssessments.filter(a => a.criteriaId === c.id && isValidRatingScore(a.score))
|
||||||
|
const generalNotesForCrit = meetings.flatMap(m =>
|
||||||
|
(m.generalNoteTags ?? []).filter(t => t.criterionItemId === c.id).map(t => t.text),
|
||||||
|
)
|
||||||
|
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),
|
||||||
|
...generalNotesForCrit,
|
||||||
|
].map(formatNotationLine)
|
||||||
|
if (scores.length === 0 && notes.length === 0) return null
|
||||||
|
const avgNum = scores.length > 0
|
||||||
|
? scores.reduce((s, a) => s + (a.score as number), 0) / scores.length
|
||||||
|
: null
|
||||||
|
const clamped = avgNum !== null ? clampRatingScore(avgNum) : null
|
||||||
|
const avgLabel = avgNum !== null
|
||||||
|
? `${clamped ?? avgNum.toFixed(1)}/10 (Ø ${avgNum.toFixed(1)})`
|
||||||
|
: 'nur Notiz, keine Bewertung'
|
||||||
|
const label = c.description ? `${c.name} (${c.description})` : c.name
|
||||||
|
return ` ${label}: ${avgLabel}${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}`
|
||||||
|
}).filter((l): l is string => l !== null).join('\n') || '(keine Scores/Notizen)'
|
||||||
|
|
||||||
|
const r = ratings.find(x => x.feedbackCategoryId === category.id)
|
||||||
|
const manualRating = isValidRatingScore(r?.rating) ? `${r!.rating}/10` : 'nicht bewertet'
|
||||||
|
|
||||||
|
let entwicklungVerlauf = 'keine Verlaufsdaten'
|
||||||
|
if (catTrend && catTrend.history.length > 0) {
|
||||||
|
const seq = catTrend.history.map(h => h.score.toFixed(1)).join(' → ')
|
||||||
|
const trendLabel = catTrend.trend === 'up' ? 'Verbesserung über die Laufzeit'
|
||||||
|
: catTrend.trend === 'down' ? 'Verschlechterung über die Laufzeit'
|
||||||
|
: catTrend.trend === 'stable' ? 'stabil'
|
||||||
|
: 'nur ein Meeting, kein Trend ableitbar'
|
||||||
|
entwicklungVerlauf = `Verlauf ${seq} (${trendLabel})`
|
||||||
|
}
|
||||||
|
|
||||||
|
let trendVorschlag = 'Kein Meeting-Trend berechenbar — SCORE nur setzen wenn manuelle Bewertung oder Kriterien-Daten vorliegen.'
|
||||||
|
if (catTrend?.suggestion !== null && catTrend?.suggestion !== undefined) {
|
||||||
|
const s = catTrend.suggestion.toFixed(1)
|
||||||
|
trendVorschlag = `Vorgeschlagener Score aus Meeting-Verlauf: ${s}/10. Bestätige diesen Wert im SCORE-Feld oder passe maximal um ±1 an (nicht frei erfinden).`
|
||||||
|
}
|
||||||
|
|
||||||
|
const protokollGetaggt = meetings
|
||||||
|
.map(m => {
|
||||||
|
const meetingEntries = instEntries.filter(e => e.meetingInstanceId === m.id)
|
||||||
|
const rawLines = meetingEntries
|
||||||
|
.flatMap(e => splitNoteLines(e.note).map(line => ({
|
||||||
|
line,
|
||||||
|
tags: (e.lineTags ?? []).filter(t => t.text === line && catItemIds.has(t.criterionItemId)),
|
||||||
|
})))
|
||||||
|
.filter(x => x.tags.length > 0)
|
||||||
|
.filter(x => !parseNotationPrefix(x.line).isQuote)
|
||||||
|
.map(x => x.line)
|
||||||
|
const generalTagged = (m.generalNoteTags ?? [])
|
||||||
|
.filter(t => catItemIds.has(t.criterionItemId))
|
||||||
|
.map(t => t.text)
|
||||||
|
const allLines = [...rawLines, ...generalTagged]
|
||||||
|
const lines = dropDanglingMarkers(allLines).map(line => ` - ${formatNotationLine(line)}`)
|
||||||
|
if (lines.length === 0) return null
|
||||||
|
return ` [${m.date}]\n${lines.join('\n')}`
|
||||||
|
})
|
||||||
|
.filter((x): x is string => x !== null)
|
||||||
|
.join('\n') || '(keine getaggten Zeilen)'
|
||||||
|
|
||||||
|
const erlaeuterung = category.description
|
||||||
|
? `Erläuterung: ${category.description}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
'{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`,
|
||||||
|
'{{ASSIGNMENT_TITEL}}': assignment.title,
|
||||||
|
'{{ASSIGNMENT_KUNDE}}': assignment.client,
|
||||||
|
'{{DIMENSION_NAME}}': dimension.name,
|
||||||
|
'{{KATEGORIE_NAME}}': category.name,
|
||||||
|
'{{KATEGORIE_ERLAEUTERUNG}}': erlaeuterung,
|
||||||
|
'{{KRITERIEN_SCORES}}': kriterienScores,
|
||||||
|
'{{KATEGORIE_BEWERTUNG}}': manualRating,
|
||||||
|
'{{ENTWICKLUNG_VERLAUF}}': entwicklungVerlauf,
|
||||||
|
'{{TREND_VORSCHLAG}}': trendVorschlag,
|
||||||
|
'{{PROTOKOLL_GETAGGT}}': protokollGetaggt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stufe B: Verdichtung aus Kategorie-Ergebnissen (ohne Rohprotokoll). */
|
||||||
|
export function buildDimensionCondenseContext(input: DimensionCondenseInput): Record<string, string> {
|
||||||
|
const { institutee, assignment, dimension, categoryResults } = input
|
||||||
|
|
||||||
|
const kategorieErgebnisse = categoryResults.map(cr => {
|
||||||
|
const scoreLine = cr.score !== null ? `Score: ${cr.score}/10` : 'Score: nicht gesetzt'
|
||||||
|
const ben = cr.benefits.trim() || '- n/a'
|
||||||
|
const con = cr.concerns.trim() || '- n/a'
|
||||||
|
return `${cr.name}\n${scoreLine}\nBenefits:\n${ben}\nConcerns:\n${con}`
|
||||||
|
}).join('\n\n') || '(keine Kategorie-Ergebnisse)'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'{{BERATER_NAME}}': `${institutee.firstName} ${institutee.lastName}`,
|
||||||
|
'{{ASSIGNMENT_TITEL}}': assignment.title,
|
||||||
|
'{{ASSIGNMENT_KUNDE}}': assignment.client,
|
||||||
|
'{{DIMENSION_NAME}}': dimension.name,
|
||||||
|
'{{KATEGORIE_ERGEBNISSE}}': kategorieErgebnisse,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCategoryAiResponse(raw: string): CategoryAiResult {
|
||||||
|
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
|
||||||
|
const lines = cleaned.split('\n')
|
||||||
|
|
||||||
|
let score: number | null = null
|
||||||
|
let benefits = ''
|
||||||
|
let concerns = ''
|
||||||
|
type Mode = 'none' | 'benefits' | 'concerns'
|
||||||
|
let mode: Mode = 'none'
|
||||||
|
let buffer: string[] = []
|
||||||
|
|
||||||
|
const takeBuffer = () => {
|
||||||
|
const t = buffer.join('\n').trim()
|
||||||
|
buffer = []
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
const append = (prev: string, next: string) => [prev, next].filter(Boolean).join('\n').trim()
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const scoreMatch = line.match(SCORE_HDR)
|
||||||
|
const benMatch = line.match(BENEFITS_HDR)
|
||||||
|
const conMatch = line.match(CONCERNS_HDR)
|
||||||
|
|
||||||
|
if (scoreMatch) {
|
||||||
|
score = Number(scoreMatch[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (benMatch) {
|
||||||
|
const leftover = takeBuffer()
|
||||||
|
if (leftover && mode === 'concerns') concerns = append(concerns, leftover)
|
||||||
|
else if (leftover && mode === 'none') {
|
||||||
|
const split = splitLooseCategoryBody(leftover)
|
||||||
|
benefits = append(benefits, split.benefits)
|
||||||
|
concerns = append(concerns, split.concerns)
|
||||||
|
}
|
||||||
|
mode = 'benefits'
|
||||||
|
if (benMatch[2].trim()) benefits = append(benefits, benMatch[2].trim())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conMatch) {
|
||||||
|
const leftover = takeBuffer()
|
||||||
|
if (leftover && mode === 'benefits') benefits = append(benefits, leftover)
|
||||||
|
mode = 'concerns'
|
||||||
|
if (conMatch[2].trim()) concerns = append(concerns, conMatch[2].trim())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftover = takeBuffer()
|
||||||
|
if (leftover) {
|
||||||
|
if (mode === 'benefits') benefits = append(benefits, leftover)
|
||||||
|
else if (mode === 'concerns') concerns = append(concerns, leftover)
|
||||||
|
else {
|
||||||
|
const split = splitLooseCategoryBody(leftover)
|
||||||
|
benefits = append(benefits, split.benefits)
|
||||||
|
concerns = append(concerns, split.concerns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score, benefits, concerns }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDimensionCondenseResponse(raw: string): ParsedDimensionCondenseResponse {
|
||||||
|
const parsed = parseDimensionAiResponse(raw)
|
||||||
|
return {
|
||||||
|
achievements: parsed.achievements,
|
||||||
|
developmentNeeds: parsed.developmentNeeds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedDimensionResponse {
|
||||||
|
categoryTexts: { name: string; benefits: string; concerns: string; score?: number | null }[]
|
||||||
|
achievements: string
|
||||||
|
developmentNeeds: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BENEFITS_HDR = /^(BENEFITS|STÄRKEN|STAERKEN)\s*:\s*(.*)$/i
|
||||||
|
const CONCERNS_HDR = /^(CONCERNS|RISIKEN|SCHWÄCHEN|SCHWAECHEN)\s*:\s*(.*)$/i
|
||||||
|
const ACHIEVEMENTS_HDR = /^ACHIEVEMENTS\s*:\s*(.*)$/i
|
||||||
|
const DEV_NEEDS_HDR = /^DEVELOPMENT_NEEDS\s*:\s*(.*)$/i
|
||||||
|
const SCORE_HDR = /^SCORE\s*:\s*(\d{1,2})\s*$/i
|
||||||
|
|
||||||
|
/** Lose Stichpunkte ohne BENEFITS/CONCERNS-Marker: nach Score-/Präfix-Hinweisen aufteilen. */
|
||||||
|
function splitLooseCategoryBody(raw: string): { benefits: string; concerns: string } {
|
||||||
|
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean)
|
||||||
|
if (lines.length === 0) return { benefits: '', concerns: '' }
|
||||||
|
|
||||||
|
const benefits: string[] = []
|
||||||
|
const concerns: string[] = []
|
||||||
|
let bucket: 'b' | 'c' | null = null
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const benInline = line.match(BENEFITS_HDR)
|
||||||
|
if (benInline) {
|
||||||
|
bucket = 'b'
|
||||||
|
if (benInline[2].trim()) benefits.push(benInline[2].trim())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const conInline = line.match(CONCERNS_HDR)
|
||||||
|
if (conInline) {
|
||||||
|
bucket = 'c'
|
||||||
|
if (conInline[2].trim()) concerns.push(conInline[2].trim())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const bullet = line.replace(/^[-*•]\s*/, '')
|
||||||
|
if (/^(benefit|stärke|staerke|positiv)\b/i.test(bullet)) {
|
||||||
|
benefits.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (/^(concern|risiko|schwäche|schwaeche|negativ|sollte|empfehl)/i.test(bullet)) {
|
||||||
|
concerns.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreInLine = bullet.match(/\b(?:score|bewertung)?\s*([1-9]|10)\s*(?:\/\s*10)?\b/i)
|
||||||
|
?? bullet.match(/^([1-9]|10)\s*[:–—-]/)
|
||||||
|
if (scoreInLine) {
|
||||||
|
const n = Number(scoreInLine[1])
|
||||||
|
if (n >= 6) benefits.push(line)
|
||||||
|
else concerns.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bucket === 'b') benefits.push(line)
|
||||||
|
else if (bucket === 'c') concerns.push(line)
|
||||||
|
else concerns.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
benefits: benefits.join('\n').trim(),
|
||||||
|
concerns: concerns.join('\n').trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entfernt vorsorglich Markdown-Codefences (manche Provider ignorieren die Format-Anweisung im
|
* Marker-basiert (kein JSON). Tolerant gegenüber Text in derselben Zeile wie der Marker
|
||||||
* Prompt trotzdem) und parst dann per Zeilen-Scan auf die festen KATEGORIE:/ACHIEVEMENTS:/
|
* (`BENEFITS: - …`) und deutschen Synonymen. Ohne Untermarker: Heuristik über Score/Präfixe.
|
||||||
* DEVELOPMENT_NEEDS:-Marker. Siehe CLAUDE.md — löst den früheren response_format-Bug strukturell.
|
|
||||||
*/
|
*/
|
||||||
export function parseDimensionAiResponse(raw: string): ParsedDimensionResponse {
|
export function parseDimensionAiResponse(raw: string): ParsedDimensionResponse {
|
||||||
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
|
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
|
||||||
const lines = cleaned.split('\n')
|
const lines = cleaned.split('\n')
|
||||||
|
|
||||||
const categoryTexts: { name: string; text: string }[] = []
|
const categoryTexts: { name: string; benefits: string; concerns: string; score?: number | null }[] = []
|
||||||
let achievements = ''
|
let achievements = ''
|
||||||
let developmentNeeds = ''
|
let developmentNeeds = ''
|
||||||
|
|
||||||
let mode: 'none' | 'category' | 'achievements' | 'development' = 'none'
|
type Mode = 'none' | 'category' | 'catBenefits' | 'catConcerns' | 'achievements' | 'development'
|
||||||
|
let mode: Mode = 'none'
|
||||||
let currentName = ''
|
let currentName = ''
|
||||||
|
let catBenefits = ''
|
||||||
|
let catConcerns = ''
|
||||||
|
let catScore: number | null = null
|
||||||
let buffer: string[] = []
|
let buffer: string[] = []
|
||||||
|
|
||||||
const flush = () => {
|
const takeBuffer = () => {
|
||||||
const text = buffer.join('\n').trim()
|
const t = buffer.join('\n').trim()
|
||||||
if (mode === 'category' && currentName) categoryTexts.push({ name: currentName, text })
|
|
||||||
else if (mode === 'achievements') achievements = text
|
|
||||||
else if (mode === 'development') developmentNeeds = text
|
|
||||||
buffer = []
|
buffer = []
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
const append = (prev: string, next: string) => [prev, next].filter(Boolean).join('\n').trim()
|
||||||
|
|
||||||
|
const flushCategory = () => {
|
||||||
|
if (!currentName) { buffer = []; return }
|
||||||
|
const leftover = takeBuffer()
|
||||||
|
let benefits = catBenefits
|
||||||
|
let concerns = catConcerns
|
||||||
|
if (mode === 'catBenefits' && leftover) benefits = append(benefits, leftover)
|
||||||
|
else if (mode === 'catConcerns' && leftover) concerns = append(concerns, leftover)
|
||||||
|
else if (mode === 'category' && leftover) {
|
||||||
|
if (!benefits && !concerns) {
|
||||||
|
const split = splitLooseCategoryBody(leftover)
|
||||||
|
benefits = split.benefits
|
||||||
|
concerns = split.concerns
|
||||||
|
} else {
|
||||||
|
concerns = append(concerns, leftover)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
categoryTexts.push({ name: currentName, benefits, concerns, score: catScore })
|
||||||
|
currentName = ''
|
||||||
|
catBenefits = ''
|
||||||
|
catConcerns = ''
|
||||||
|
catScore = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (mode === 'category' || mode === 'catBenefits' || mode === 'catConcerns') flushCategory()
|
||||||
|
else if (mode === 'achievements') achievements = takeBuffer()
|
||||||
|
else if (mode === 'development') developmentNeeds = takeBuffer()
|
||||||
|
else buffer = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDimBenefits = (sameLine: string) => {
|
||||||
|
flush()
|
||||||
|
mode = 'achievements'
|
||||||
|
currentName = ''
|
||||||
|
if (sameLine.trim()) buffer = [sameLine.trim()]
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDimConcerns = (sameLine: string) => {
|
||||||
|
flush()
|
||||||
|
mode = 'development'
|
||||||
|
currentName = ''
|
||||||
|
if (sameLine.trim()) buffer = [sameLine.trim()]
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const catMatch = line.match(/^KATEGORIE:\s*(.+)$/i)
|
const catMatch = line.match(/^KATEGORIE:\s*(.+)$/i)
|
||||||
const achMatch = line.match(/^ACHIEVEMENTS:\s*$/i)
|
const scoreMatch = line.match(SCORE_HDR)
|
||||||
const devMatch = line.match(/^DEVELOPMENT_NEEDS:\s*$/i)
|
const benMatch = line.match(BENEFITS_HDR)
|
||||||
|
const conMatch = line.match(CONCERNS_HDR)
|
||||||
|
const achMatch = line.match(ACHIEVEMENTS_HDR)
|
||||||
|
const devMatch = line.match(DEV_NEEDS_HDR)
|
||||||
|
const inCat = mode === 'category' || mode === 'catBenefits' || mode === 'catConcerns'
|
||||||
|
|
||||||
if (catMatch) {
|
if (catMatch) {
|
||||||
flush()
|
flush()
|
||||||
mode = 'category'
|
mode = 'category'
|
||||||
currentName = catMatch[1].trim()
|
currentName = catMatch[1].trim()
|
||||||
} else if (achMatch) {
|
catBenefits = ''
|
||||||
flush()
|
catConcerns = ''
|
||||||
mode = 'achievements'
|
catScore = null
|
||||||
currentName = ''
|
continue
|
||||||
} else if (devMatch) {
|
|
||||||
flush()
|
|
||||||
mode = 'development'
|
|
||||||
currentName = ''
|
|
||||||
} else {
|
|
||||||
buffer.push(line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (inCat && scoreMatch) {
|
||||||
|
catScore = Number(scoreMatch[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCat && benMatch) {
|
||||||
|
const leftover = takeBuffer()
|
||||||
|
if (leftover && mode === 'category') {
|
||||||
|
const split = splitLooseCategoryBody(leftover)
|
||||||
|
catBenefits = append(catBenefits, split.benefits)
|
||||||
|
catConcerns = append(catConcerns, split.concerns)
|
||||||
|
} else if (leftover && mode === 'catConcerns') catConcerns = append(catConcerns, leftover)
|
||||||
|
else if (leftover && mode === 'catBenefits') catBenefits = append(catBenefits, leftover)
|
||||||
|
mode = 'catBenefits'
|
||||||
|
if (benMatch[2].trim()) catBenefits = append(catBenefits, benMatch[2].trim())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCat && conMatch) {
|
||||||
|
const leftover = takeBuffer()
|
||||||
|
if (leftover && mode === 'catBenefits') catBenefits = append(catBenefits, leftover)
|
||||||
|
else if (leftover && mode === 'catConcerns') catConcerns = append(catConcerns, leftover)
|
||||||
|
else if (leftover && mode === 'category') {
|
||||||
|
const split = splitLooseCategoryBody(leftover)
|
||||||
|
catBenefits = append(catBenefits, split.benefits)
|
||||||
|
catConcerns = append(catConcerns, split.concerns)
|
||||||
|
}
|
||||||
|
mode = 'catConcerns'
|
||||||
|
if (conMatch[2].trim()) catConcerns = append(catConcerns, conMatch[2].trim())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (achMatch) {
|
||||||
|
startDimBenefits(achMatch[1] ?? '')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (devMatch) {
|
||||||
|
startDimConcerns(devMatch[1] ?? '')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!inCat && benMatch) {
|
||||||
|
startDimBenefits(benMatch[2] ?? '')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!inCat && conMatch) {
|
||||||
|
startDimConcerns(conMatch[2] ?? '')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.push(line)
|
||||||
}
|
}
|
||||||
flush()
|
flush()
|
||||||
|
|
||||||
|
|
|
||||||
27
src/utils/criterionOptions.tsx
Normal file
27
src/utils/criterionOptions.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem } from '../db/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optgroups Dimension → Kategorie (HTML kennt kein verschachteltes optgroup) für "Ziel-Kriterium"-
|
||||||
|
* Dropdowns — extrahiert aus FeedbackStructureConfig.tsx, damit AssignmentTypeConfig.tsx (Checklisten-
|
||||||
|
* Kriterium-Verknüpfung) dieselbe Logik nutzt statt sie zu duplizieren.
|
||||||
|
*/
|
||||||
|
export function criterionOptionGroups(
|
||||||
|
dimensions: FeedbackDimension[],
|
||||||
|
categories: FeedbackCategory[],
|
||||||
|
items: FeedbackCriterionItem[],
|
||||||
|
excludeItemId?: number,
|
||||||
|
) {
|
||||||
|
const sorted = [...dimensions].sort((a, b) => a.order - b.order)
|
||||||
|
return sorted.flatMap(d => {
|
||||||
|
const dimCats = categories.filter(c => c.dimensionId === d.id).sort((a, b) => a.order - b.order)
|
||||||
|
return dimCats.map(c => {
|
||||||
|
const catItems = items.filter(i => i.categoryId === c.id && i.id !== excludeItemId).sort((a, b) => a.order - b.order)
|
||||||
|
if (catItems.length === 0) return null
|
||||||
|
return (
|
||||||
|
<optgroup key={c.id} label={`${d.name} → ${c.name}`}>
|
||||||
|
{catItems.map(i => <option key={i.id} value={i.id}>{i.name}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
61
src/utils/criterionSuggestion.ts
Normal file
61
src/utils/criterionSuggestion.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import type { ConversationLineTag } from '../db/types'
|
||||||
|
import { parseNotationPrefix } from './notationParser'
|
||||||
|
|
||||||
|
/** Wörter unter dieser Länge sind zu unspezifisch (Artikel/Präpositionen) und werden verworfen. */
|
||||||
|
const MIN_TOKEN_LENGTH = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt eine Protokollzeile in ein normalisiertes Token-Set für die lokale Ähnlichkeitssuche —
|
||||||
|
* Notation-Präfix (führende Score-Zahl 1–10 bzw. `>`) wird über `parseNotationPrefix()` entfernt (gleiche
|
||||||
|
* Normalisierung wie überall sonst), Rest kleingeschrieben und auf Wortzeichen aufgesplittet.
|
||||||
|
*/
|
||||||
|
export function normalizeLine(text: string): string[] {
|
||||||
|
const { text: withoutPrefix } = parseNotationPrefix(text)
|
||||||
|
return withoutPrefix
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
|
.filter(token => token.length >= MIN_TOKEN_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimilarTaggedLine {
|
||||||
|
text: string
|
||||||
|
criterionItemId: number
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const jaccard = (a: Set<string>, b: Set<string>): number => {
|
||||||
|
if (a.size === 0 || b.size === 0) return 0
|
||||||
|
let intersection = 0
|
||||||
|
for (const token of a) if (b.has(token)) intersection++
|
||||||
|
const union = a.size + b.size - intersection
|
||||||
|
return union === 0 ? 0 : intersection / union
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rein lokale, KI-freie Vorauswahl ähnlicher bereits getaggter Alt-Zeilen (Wortüberschneidung/
|
||||||
|
* Jaccard) — liefert der KI Kontext-Hinweise, ersetzt aber nicht deren Entscheidung. Pro
|
||||||
|
* Kriterium wird nur der jeweils beste Treffer behalten, damit die Hinweisliste nicht von einem
|
||||||
|
* einzelnen, oft wiederholten Kriterium dominiert wird.
|
||||||
|
*/
|
||||||
|
export function findSimilarTaggedLines(
|
||||||
|
line: string,
|
||||||
|
corpus: ConversationLineTag[],
|
||||||
|
topK = 8,
|
||||||
|
): SimilarTaggedLine[] {
|
||||||
|
const lineTokens = new Set(normalizeLine(line))
|
||||||
|
if (lineTokens.size === 0) return []
|
||||||
|
|
||||||
|
const bestPerCriterion = new Map<number, SimilarTaggedLine>()
|
||||||
|
for (const entry of corpus) {
|
||||||
|
const score = jaccard(lineTokens, new Set(normalizeLine(entry.text)))
|
||||||
|
if (score <= 0) continue
|
||||||
|
const existing = bestPerCriterion.get(entry.criterionItemId)
|
||||||
|
if (!existing || score > existing.score) {
|
||||||
|
bestPerCriterion.set(entry.criterionItemId, { text: entry.text, criterionItemId: entry.criterionItemId, score })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...bestPerCriterion.values()]
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, topK)
|
||||||
|
}
|
||||||
160
src/utils/criterionSuggestionPrompt.ts
Normal file
160
src/utils/criterionSuggestionPrompt.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem, ConversationLineTag } from '../db/types'
|
||||||
|
import { resolveVisibleStructure } from '../db/queries/feedbackStructure'
|
||||||
|
import { findSimilarTaggedLines } from './criterionSuggestion'
|
||||||
|
|
||||||
|
const byOrder = <T extends { order: number }>(rows: T[]) => [...rows].sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{{KRITERIEN_STRUKTUR}}` — ID-referenzierte, verschachtelte Liste Dimension → Kategorie
|
||||||
|
* (Name+Beschreibung) → Kriterium (Name+Beschreibung), gefiltert auf die im Assignment-Typ
|
||||||
|
* sichtbaren Kriterien über `resolveVisibleStructure()` (dieselbe Logik wie Meeting/Feedback).
|
||||||
|
*/
|
||||||
|
export function buildCriterionStructureText(
|
||||||
|
dimensions: FeedbackDimension[],
|
||||||
|
allCategories: FeedbackCategory[],
|
||||||
|
allItems: FeedbackCriterionItem[],
|
||||||
|
visibleCriteriaIds: number[] | undefined,
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
dimensions: visibleDimensions,
|
||||||
|
categories: visibleCategories,
|
||||||
|
items: visibleItems,
|
||||||
|
} = resolveVisibleStructure(dimensions, allCategories, allItems, visibleCriteriaIds)
|
||||||
|
|
||||||
|
const text = byOrder(visibleDimensions).map(dim => {
|
||||||
|
const dimCategories = byOrder(visibleCategories.filter(c => c.dimensionId === dim.id))
|
||||||
|
const catLines = dimCategories.map(cat => {
|
||||||
|
const catItems = byOrder(visibleItems.filter(i => i.categoryId === cat.id))
|
||||||
|
const itemLines = catItems.map(item => {
|
||||||
|
const label = item.description ? `${item.name} — ${item.description}` : item.name
|
||||||
|
return ` [${item.id}] ${label}`
|
||||||
|
}).join('\n')
|
||||||
|
const catLabel = cat.description ? `${cat.name} — ${cat.description}` : cat.name
|
||||||
|
return ` ${catLabel}\n${itemLines}`
|
||||||
|
}).join('\n')
|
||||||
|
return `${dim.name}\n${catLines}`
|
||||||
|
}).join('\n')
|
||||||
|
|
||||||
|
return text || '(keine sichtbaren Kriterien)'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CriterionSuggestionLine {
|
||||||
|
/** Stabiler, nur innerhalb dieser Analyse gültiger Anker — nicht persistiert. */
|
||||||
|
lineNumber: number
|
||||||
|
text: string
|
||||||
|
/** Institutee-Name bei Gesprächsbeiträgen, undefined bei allgemeinen Notizen. */
|
||||||
|
speakerLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CriterionSuggestionPromptInput {
|
||||||
|
assignmentTypeName: string
|
||||||
|
dimensions: FeedbackDimension[]
|
||||||
|
categories: FeedbackCategory[]
|
||||||
|
items: FeedbackCriterionItem[]
|
||||||
|
visibleCriteriaIds: number[] | undefined
|
||||||
|
lines: CriterionSuggestionLine[]
|
||||||
|
/** Globaler Lern-Korpus, siehe `getGlobalLineTagCorpus()`. */
|
||||||
|
corpus: ConversationLineTag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const similarityLabel = (score: number): string => score >= 0.5 ? 'hoch' : score >= 0.25 ? 'mittel' : 'niedrig'
|
||||||
|
|
||||||
|
export function buildCriterionSuggestionPromptContext(input: CriterionSuggestionPromptInput): Record<string, string> {
|
||||||
|
const { assignmentTypeName, dimensions, categories, items, visibleCriteriaIds, lines, corpus } = input
|
||||||
|
|
||||||
|
const { items: visibleItems } = resolveVisibleStructure(dimensions, categories, items, visibleCriteriaIds)
|
||||||
|
const itemName = (id: number) => items.find(i => i.id === id)?.name ?? `#${id}`
|
||||||
|
|
||||||
|
const kriterienStruktur = buildCriterionStructureText(dimensions, categories, items, visibleCriteriaIds)
|
||||||
|
|
||||||
|
const protokollZeilen = lines
|
||||||
|
.map(l => `${l.lineNumber}. ${l.speakerLabel ? `[${l.speakerLabel}] ` : ''}${l.text}`)
|
||||||
|
.join('\n') || '(keine offenen Zeilen)'
|
||||||
|
|
||||||
|
const aehnlicheAltZuordnungen = lines.map(l => {
|
||||||
|
const hints = findSimilarTaggedLines(l.text, corpus)
|
||||||
|
if (hints.length === 0) return `Zeile ${l.lineNumber}: (keine ähnlichen Alt-Zeilen gefunden)`
|
||||||
|
const hintLines = hints.map(h => ` - "${h.text}" → [${h.criterionItemId}] ${itemName(h.criterionItemId)} (Ähnlichkeit ${similarityLabel(h.score)})`)
|
||||||
|
return `Zeile ${l.lineNumber}:\n${hintLines.join('\n')}`
|
||||||
|
}).join('\n') || '(keine offenen Zeilen)'
|
||||||
|
|
||||||
|
const gelernteMuster = visibleItems
|
||||||
|
.filter(i => i.learnedPatternHints?.trim())
|
||||||
|
.map(i => ` [${i.id}] ${i.name}: ${i.learnedPatternHints}`)
|
||||||
|
.join('\n') || '(noch keine gelernten Muster vorhanden)'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'{{ASSIGNMENT_TYP}}': assignmentTypeName,
|
||||||
|
'{{KRITERIEN_STRUKTUR}}': kriterienStruktur,
|
||||||
|
'{{GELERNTE_MUSTER}}': gelernteMuster,
|
||||||
|
'{{AEHNLICHE_ALT_ZUORDNUNGEN}}': aehnlicheAltZuordnungen,
|
||||||
|
'{{PROTOKOLL_ZEILEN}}': protokollZeilen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedCriterionSuggestionResponse {
|
||||||
|
/** Zeilennummer → priorisierte Kriterium-ID-Liste (leer = kein Vorschlag). */
|
||||||
|
lineSuggestions: Map<number, number[]>
|
||||||
|
/** Kriterium-ID → destilliertes Formulierungsmuster (Nebenprodukt dieses Laufs). */
|
||||||
|
patternHints: Map<number, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst das Klartext-Antwortformat (`ZEILE 3: 12, 47, 5` / `ZEILE 9: -`, gefolgt von einem
|
||||||
|
* `MUSTER:`-Block) — bewusst kein JSON, gleiches Robustheitsprinzip wie `parseDimensionAiResponse`
|
||||||
|
* (Codefence-Stripping vorab, tolerant gegenüber fehlenden/kaputten Zeilen, kein Absturz).
|
||||||
|
*/
|
||||||
|
export function parseCriterionSuggestionResponse(raw: string): ParsedCriterionSuggestionResponse {
|
||||||
|
const cleaned = raw.trim().replace(/^```[a-z]*\n?/i, '').replace(/```$/, '').trim()
|
||||||
|
const lines = cleaned.split('\n')
|
||||||
|
|
||||||
|
const lineSuggestions = new Map<number, number[]>()
|
||||||
|
const patternHints = new Map<number, string>()
|
||||||
|
|
||||||
|
let mode: 'lines' | 'patterns' = 'lines'
|
||||||
|
let currentPatternId: number | null = null
|
||||||
|
let patternBuffer: string[] = []
|
||||||
|
|
||||||
|
const flushPattern = () => {
|
||||||
|
if (currentPatternId !== null) {
|
||||||
|
const text = patternBuffer.join(' ').trim()
|
||||||
|
if (text) patternHints.set(currentPatternId, text)
|
||||||
|
}
|
||||||
|
currentPatternId = null
|
||||||
|
patternBuffer = []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
if (/^MUSTER:\s*$/i.test(line)) {
|
||||||
|
flushPattern()
|
||||||
|
mode = 'patterns'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'lines') {
|
||||||
|
const m = line.match(/^ZEILE\s+(\d+)\s*:\s*(.*)$/i)
|
||||||
|
if (!m) continue
|
||||||
|
const lineNumber = Number(m[1])
|
||||||
|
const rest = m[2].trim()
|
||||||
|
if (rest === '' || rest === '-') {
|
||||||
|
lineSuggestions.set(lineNumber, [])
|
||||||
|
} else {
|
||||||
|
const ids = rest.split(',').map(s => Number(s.trim())).filter(n => Number.isFinite(n))
|
||||||
|
lineSuggestions.set(lineNumber, ids)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const m = line.match(/^(\d+)\s*:\s*(.*)$/)
|
||||||
|
if (m) {
|
||||||
|
flushPattern()
|
||||||
|
currentPatternId = Number(m[1])
|
||||||
|
patternBuffer = [m[2].trim()]
|
||||||
|
} else if (currentPatternId !== null && line !== '') {
|
||||||
|
patternBuffer.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushPattern()
|
||||||
|
|
||||||
|
return { lineSuggestions, patternHints }
|
||||||
|
}
|
||||||
99
src/utils/feedbackStructureImport.ts
Normal file
99
src/utils/feedbackStructureImport.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
/**
|
||||||
|
* Parst den Markdown-Export der Feedback-Struktur (gleiches Format wie
|
||||||
|
* `exportFeedbackStructureAsMarkdown`) in ein importierbares Seed-Objekt.
|
||||||
|
*/
|
||||||
|
export interface FeedbackStructureSeedItem {
|
||||||
|
name: string
|
||||||
|
weight: number
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackStructureSeedCategory {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
items: FeedbackStructureSeedItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackStructureSeedDimension {
|
||||||
|
name: string
|
||||||
|
categories: FeedbackStructureSeedCategory[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackStructureSeed {
|
||||||
|
dimensions: FeedbackStructureSeedDimension[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEM_RE = /^- \*\*(.+?)\*\* \(Gewicht ([0-9]+(?:\.[0-9]+)?)\)(?: — (.+))?$/
|
||||||
|
|
||||||
|
export function parseFeedbackStructureMarkdown(md: string): FeedbackStructureSeed {
|
||||||
|
const lines = md.replace(/\r\n/g, '\n').split('\n')
|
||||||
|
const dimensions: FeedbackStructureSeedDimension[] = []
|
||||||
|
let dim: FeedbackStructureSeedDimension | null = null
|
||||||
|
let cat: FeedbackStructureSeedCategory | null = null
|
||||||
|
let pendingCatDesc: string[] = []
|
||||||
|
|
||||||
|
const flushCatDesc = () => {
|
||||||
|
if (!cat || pendingCatDesc.length === 0) return
|
||||||
|
const text = pendingCatDesc.join('\n').trim()
|
||||||
|
if (text) cat.description = text
|
||||||
|
pendingCatDesc = []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trimEnd()
|
||||||
|
if (line.startsWith('# ') || line === '---' || line.startsWith('_Export:')) continue
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
flushCatDesc()
|
||||||
|
dim = { name: line.slice(3).trim(), categories: [] }
|
||||||
|
dimensions.push(dim)
|
||||||
|
cat = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('### ')) {
|
||||||
|
flushCatDesc()
|
||||||
|
if (!dim) throw new Error(`Kategorie ohne Dimension: "${line}"`)
|
||||||
|
cat = { name: line.slice(4).trim(), items: [] }
|
||||||
|
dim.categories.push(cat)
|
||||||
|
pendingCatDesc = []
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMatch = line.trim().match(ITEM_RE)
|
||||||
|
if (itemMatch) {
|
||||||
|
flushCatDesc()
|
||||||
|
if (!cat) throw new Error(`Kriterium ohne Kategorie: "${line}"`)
|
||||||
|
cat.items.push({
|
||||||
|
name: itemMatch[1].trim(),
|
||||||
|
weight: Number(itemMatch[2]),
|
||||||
|
description: itemMatch[3]?.trim() || undefined,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cat && !line.trim().startsWith('- ')) {
|
||||||
|
pendingCatDesc.push(line.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushCatDesc()
|
||||||
|
|
||||||
|
if (dimensions.length === 0) throw new Error('Keine Dimensionen im Markdown gefunden.')
|
||||||
|
return { dimensions }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeFeedbackStructureSeed(seed: FeedbackStructureSeed): {
|
||||||
|
dimensions: number
|
||||||
|
categories: number
|
||||||
|
items: number
|
||||||
|
} {
|
||||||
|
const categories = seed.dimensions.reduce((s, d) => s + d.categories.length, 0)
|
||||||
|
const items = seed.dimensions.reduce(
|
||||||
|
(s, d) => s + d.categories.reduce((cs, c) => cs + c.items.length, 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return { dimensions: seed.dimensions.length, categories, items }
|
||||||
|
}
|
||||||
36
src/utils/meetingCategoryScore.ts
Normal file
36
src/utils/meetingCategoryScore.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { Assessment, FeedbackCriterionItem, MeetingCategoryRating, RatingScore } from '../db/types'
|
||||||
|
import { normalizeRatingScore } from '../config/constants'
|
||||||
|
import { weightedAverageRating } from './ratingTrend'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategorie-Score für Chips/Historie: zuerst gespeicherter Meeting-Kategorie-Score
|
||||||
|
* (aus Einschätzung synchronisiert), sonst gewichteter Ø der Kriterium-Assessments.
|
||||||
|
*/
|
||||||
|
export function resolveCategoryChipScore(input: {
|
||||||
|
meetingId: number
|
||||||
|
instituteeId: number
|
||||||
|
categoryId: number
|
||||||
|
categoryRatings: MeetingCategoryRating[]
|
||||||
|
assessments: Assessment[]
|
||||||
|
catItems: FeedbackCriterionItem[]
|
||||||
|
}): RatingScore | null {
|
||||||
|
const stored = normalizeRatingScore(
|
||||||
|
input.categoryRatings.find(r =>
|
||||||
|
r.meetingInstanceId === input.meetingId &&
|
||||||
|
r.instituteeId === input.instituteeId &&
|
||||||
|
r.categoryId === input.categoryId,
|
||||||
|
)?.score,
|
||||||
|
)
|
||||||
|
if (stored !== null) return stored
|
||||||
|
|
||||||
|
return weightedAverageRating(
|
||||||
|
input.catItems,
|
||||||
|
itemId => normalizeRatingScore(
|
||||||
|
input.assessments.find(a =>
|
||||||
|
a.meetingInstanceId === input.meetingId &&
|
||||||
|
a.instituteeId === input.instituteeId &&
|
||||||
|
a.criteriaId === itemId,
|
||||||
|
)?.score,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { getMeetingExportData, getFeedbackCriterionItemsByIds } from '../db'
|
import { getMeetingExportData, getFeedbackCriterionItemsByIds, listFeedbackCategoriesAll } from '../db'
|
||||||
|
import { normalizeRatingScore } from '../config/constants'
|
||||||
|
|
||||||
export async function exportMeetingAsMarkdown(meetingId: number): Promise<string> {
|
export async function exportMeetingAsMarkdown(meetingId: number): Promise<string> {
|
||||||
const { meeting, template, assignment, consultants, entries, allScores, assessments } = await getMeetingExportData(meetingId)
|
const {
|
||||||
|
meeting, template, assignment, consultants, entries, allScores,
|
||||||
|
assessments, categoryRatings, checklistItems, checklistResponses,
|
||||||
|
} = await getMeetingExportData(meetingId)
|
||||||
|
|
||||||
const instituteeIds = assignment?.instituteeIds ?? []
|
const instituteeIds = assignment?.instituteeIds ?? []
|
||||||
const critIds = [
|
const critIds = [
|
||||||
|
|
@ -9,9 +13,13 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
...(template?.criteriaIds ?? []),
|
...(template?.criteriaIds ?? []),
|
||||||
...assessments.map(a => a.criteriaId),
|
...assessments.map(a => a.criteriaId),
|
||||||
...entries.flatMap(e => (e.lineTags ?? []).map(t => t.criterionItemId)),
|
...entries.flatMap(e => (e.lineTags ?? []).map(t => t.criterionItemId)),
|
||||||
|
...(meeting.generalNoteTags ?? []).map(t => t.criterionItemId),
|
||||||
|
...checklistItems.map(ci => ci.linkedCriterionItemId).filter((id): id is number => id !== undefined),
|
||||||
|
...checklistResponses.map(r => r.criterionItemId).filter((id): id is number => id !== undefined),
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
const criteria = await getFeedbackCriterionItemsByIds(critIds)
|
const criteria = await getFeedbackCriterionItemsByIds(critIds)
|
||||||
|
const categories = await listFeedbackCategoriesAll()
|
||||||
|
|
||||||
const institutees = instituteeIds
|
const institutees = instituteeIds
|
||||||
.map(id => consultants.find(c => c.id === id))
|
.map(id => consultants.find(c => c.id === id))
|
||||||
|
|
@ -32,7 +40,11 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
|
|
||||||
if (meeting.generalNotes?.trim()) {
|
if (meeting.generalNotes?.trim()) {
|
||||||
lines.push('## Allgemeine Notizen')
|
lines.push('## Allgemeine Notizen')
|
||||||
lines.push(meeting.generalNotes.trim())
|
for (const noteLine of meeting.generalNotes.trim().split('\n')) {
|
||||||
|
const lineTag = meeting.generalNoteTags?.find(t => t.text === noteLine.trim())
|
||||||
|
const critName = lineTag ? criteria.find(c => c.id === lineTag.criterionItemId)?.name : null
|
||||||
|
lines.push(`${noteLine}${critName ? ` _(→ ${critName})_` : ''}`)
|
||||||
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,8 +85,52 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (checklistItems.length > 0) {
|
||||||
|
lines.push('## Checkliste')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
for (const ci of checklistItems) {
|
||||||
|
const responsesForItem = checklistResponses.filter(r => r.checklistItemId === ci.id)
|
||||||
|
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
|
||||||
|
|
||||||
|
for (const instituteeId of targets) {
|
||||||
|
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
|
||||||
|
if (!response) continue
|
||||||
|
const effectiveCritId = response.criterionItemId ?? ci.linkedCriterionItemId
|
||||||
|
const critName = effectiveCritId ? criteria.find(c => c.id === effectiveCritId)?.name : null
|
||||||
|
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
|
||||||
|
const who = ci.scope === 'meetingWide' ? '' : ` — ${inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'}`
|
||||||
|
const scoreStr = response.rating ?? '—'
|
||||||
|
const noteStr = response.note?.trim() ? ` — ${response.note.trim()}` : ''
|
||||||
|
lines.push(`- **${ci.label}**${who}: ${scoreStr}${critName ? ` _(→ ${critName})_` : ''}${noteStr}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryRatings.some(r => normalizeRatingScore(r.score) !== null)) {
|
||||||
|
lines.push('## Einschätzung (Kategorien)')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
for (const inst of institutees) {
|
||||||
|
const instRatings = categoryRatings.filter(
|
||||||
|
r => r.instituteeId === inst.id && normalizeRatingScore(r.score) !== null,
|
||||||
|
)
|
||||||
|
if (instRatings.length === 0) continue
|
||||||
|
|
||||||
|
lines.push(`### ${inst.firstName} ${inst.lastName}`)
|
||||||
|
for (const r of instRatings) {
|
||||||
|
const cat = categories.find(c => c.id === r.categoryId)
|
||||||
|
if (!cat) continue
|
||||||
|
const noteStr = r.note?.trim() ? ` — ${r.note.trim()}` : ''
|
||||||
|
lines.push(`- **${cat.name}:** ${normalizeRatingScore(r.score)}/10${noteStr}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (assessments.length > 0) {
|
if (assessments.length > 0) {
|
||||||
lines.push('## Gesamtbewertung')
|
lines.push('## Kriterien-Beobachtungen')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
for (const inst of institutees) {
|
for (const inst of institutees) {
|
||||||
|
|
@ -86,7 +142,7 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
for (const a of instAssessments) {
|
for (const a of instAssessments) {
|
||||||
const crit = criteria.find(c => c.id === a.criteriaId)
|
const crit = criteria.find(c => c.id === a.criteriaId)
|
||||||
if (!crit) continue
|
if (!crit) continue
|
||||||
const scoreStr = a.score !== null ? `${a.score}/5` : '—'
|
const scoreStr = a.score !== null ? `${a.score}/10` : '—'
|
||||||
const noteStr = a.note?.trim() ? ` — ${a.note.trim()}` : ''
|
const noteStr = a.note?.trim() ? ` — ${a.note.trim()}` : ''
|
||||||
lines.push(`- **${crit.name}:** ${scoreStr}${noteStr}`)
|
lines.push(`- **${crit.name}:** ${scoreStr}${noteStr}`)
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +163,10 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise<string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportMeetingAsJson(meetingId: number): Promise<string> {
|
export async function exportMeetingAsJson(meetingId: number): Promise<string> {
|
||||||
const { meeting, template, assignment, consultants, entries, allScores, assessments } = await getMeetingExportData(meetingId)
|
const {
|
||||||
|
meeting, template, assignment, consultants, entries, allScores,
|
||||||
|
assessments, categoryRatings, checklistItems, checklistResponses,
|
||||||
|
} = await getMeetingExportData(meetingId)
|
||||||
|
|
||||||
const entryIds = entries.map(e => e.id!)
|
const entryIds = entries.map(e => e.id!)
|
||||||
const entryScores = allScores.filter(s => entryIds.includes(s.conversationEntryId))
|
const entryScores = allScores.filter(s => entryIds.includes(s.conversationEntryId))
|
||||||
|
|
@ -123,6 +182,9 @@ export async function exportMeetingAsJson(meetingId: number): Promise<string> {
|
||||||
scores: entryScores.filter(s => s.conversationEntryId === e.id),
|
scores: entryScores.filter(s => s.conversationEntryId === e.id),
|
||||||
})),
|
})),
|
||||||
assessments,
|
assessments,
|
||||||
|
categoryRatings,
|
||||||
|
checklistItems,
|
||||||
|
checklistResponses,
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify(payload, null, 2)
|
return JSON.stringify(payload, null, 2)
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,52 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import type { FeedbackRating } from '../db'
|
import type { RatingColorBand, RatingScore } from '../db/types'
|
||||||
|
import {
|
||||||
|
DEFAULT_RATING_COLOR_BANDS,
|
||||||
|
isValidRatingScore,
|
||||||
|
resolveScoreStyle,
|
||||||
|
} from '../config/constants'
|
||||||
|
|
||||||
// Notation → Bewertungsstufe (Reihenfolge wichtig: Klammer-Varianten vor den nackten Zeichen prüfen,
|
export function detectNotationRating(line: string): RatingScore | null {
|
||||||
// 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 {
|
|
||||||
return parseNotationPrefix(line).rating
|
return parseNotationPrefix(line).rating
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotationPrefixMatch {
|
export interface NotationPrefixMatch {
|
||||||
rating: FeedbackRating | null
|
rating: RatingScore | null
|
||||||
/** `>`-Präfix — Zitat/Kundeninput, keine Bewertung. */
|
/** `>`-Präfix — Zitat/Kundeninput, keine Bewertung. */
|
||||||
isQuote: boolean
|
isQuote: boolean
|
||||||
/** Zeile ohne das erkannte Präfix. */
|
/** Zeile ohne das erkannte Präfix. */
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wie `detectNotationRating`, liefert zusätzlich die Zitat-Kennzeichnung und den Text ohne Präfix. */
|
/**
|
||||||
|
* Führende Score-Zahl 1–10 (zuerst `10`, dann `1`–`9`), optional gefolgt von Leerzeichen/Satzzeichen.
|
||||||
|
* `>` bleibt Zitat; `[…]` wird in NotationText separat behandelt.
|
||||||
|
*/
|
||||||
export function parseNotationPrefix(line: string): NotationPrefixMatch {
|
export function parseNotationPrefix(line: string): NotationPrefixMatch {
|
||||||
const trimmed = line.trim()
|
const trimmed = line.trim()
|
||||||
if (trimmed.startsWith('>')) return { rating: null, isQuote: true, text: trimmed.slice(1).trim() }
|
if (trimmed.startsWith('>')) return { rating: null, isQuote: true, text: trimmed.slice(1).trim() }
|
||||||
for (const [prefix, rating] of NOTATION_RATING_ORDER) {
|
|
||||||
if (trimmed.startsWith(prefix)) return { rating, isQuote: false, text: trimmed.slice(prefix.length).trim() }
|
const m = trimmed.match(/^(10|[1-9])(?:\s+|[.:)\-]|$)/)
|
||||||
|
if (m) {
|
||||||
|
const score = Number(m[1])
|
||||||
|
if (isValidRatingScore(score)) {
|
||||||
|
const rest = trimmed.slice(m[1].length).replace(/^[\s.:)\-]+/, '').trim()
|
||||||
|
return { rating: score, isQuote: false, text: rest }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { rating: null, isQuote: false, text: trimmed }
|
return { rating: null, isQuote: false, text: trimmed }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notations-Regeln (Reihenfolge wichtig: längere Symbole zuerst)
|
|
||||||
const NOTATION_RULES = [
|
|
||||||
{ prefix: '(+)', className: 'text-green-600', icon: '✓~', bg: 'bg-green-50', indent: false },
|
|
||||||
{ prefix: '(!)', className: 'text-red-500', icon: '✗~', bg: 'bg-red-50', indent: false },
|
|
||||||
{ prefix: '+', className: 'text-green-700 font-semibold', icon: '✓', bg: 'bg-green-50', indent: false },
|
|
||||||
{ prefix: '!', className: 'text-red-600 font-semibold', icon: '✗', bg: 'bg-red-50', indent: false },
|
|
||||||
{ prefix: '>', className: 'text-blue-600 italic', icon: '›', bg: 'bg-blue-50', indent: true },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
// Erkennt [Slide 3] oder […] am Anfang einer Zeile
|
|
||||||
const SLIDE_RE = /^\[([^\]]*)\]/
|
const SLIDE_RE = /^\[([^\]]*)\]/
|
||||||
|
|
||||||
function parseLine(line: string, key: number): React.ReactElement {
|
function parseLine(
|
||||||
|
line: string,
|
||||||
|
key: number,
|
||||||
|
bands: RatingColorBand[],
|
||||||
|
): React.ReactElement {
|
||||||
const trimmed = line.trim()
|
const trimmed = line.trim()
|
||||||
if (!trimmed) return <div key={key} className="h-1" />
|
if (!trimmed) return <div key={key} className="h-1" />
|
||||||
|
|
||||||
// Slide-Referenz [Titel…]
|
|
||||||
const slideMatch = trimmed.match(SLIDE_RE)
|
const slideMatch = trimmed.match(SLIDE_RE)
|
||||||
if (slideMatch) {
|
if (slideMatch) {
|
||||||
const rest = trimmed.slice(slideMatch[0].length).trim()
|
const rest = trimmed.slice(slideMatch[0].length).trim()
|
||||||
|
|
@ -62,23 +60,29 @@ function parseLine(line: string, key: number): React.ReactElement {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notations-Präfixe
|
if (trimmed.startsWith('>')) {
|
||||||
for (const rule of NOTATION_RULES) {
|
const rest = trimmed.slice(1).trim()
|
||||||
if (trimmed.startsWith(rule.prefix)) {
|
|
||||||
const rest = trimmed.slice(rule.prefix.length).trim()
|
|
||||||
return (
|
return (
|
||||||
<div key={key}
|
<div key={key} className="flex items-start gap-2 py-0.5 px-2 rounded bg-blue-50 ml-2">
|
||||||
className={`flex items-start gap-2 py-0.5 px-2 rounded ${rule.bg} ${rule.indent ? 'ml-2' : ''}`}>
|
<span className="flex-shrink-0 text-xs font-bold mt-0.5 text-blue-600 italic">›</span>
|
||||||
<span className={`flex-shrink-0 text-xs font-bold mt-0.5 ${rule.className}`}>
|
<span className="text-sm text-blue-600 italic">{rest}</span>
|
||||||
{rule.icon}
|
|
||||||
</span>
|
|
||||||
<span className={`text-sm ${rule.className}`}>{rest}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { rating, text } = parseNotationPrefix(trimmed)
|
||||||
|
if (rating !== null) {
|
||||||
|
const style = resolveScoreStyle(rating, bands)
|
||||||
|
return (
|
||||||
|
<div key={key} className={`flex items-start gap-2 py-0.5 px-2 rounded ${style.color}`}>
|
||||||
|
<span className={`flex-shrink-0 text-xs font-bold mt-0.5 tabular-nums ${style.active.includes('text-white') ? '' : ''}`.trim()}>
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded border ${style.active}`}>{rating}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-800">{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normaler Text
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="py-0.5 text-sm text-gray-800">
|
<div key={key} className="py-0.5 text-sm text-gray-800">
|
||||||
{trimmed}
|
{trimmed}
|
||||||
|
|
@ -90,26 +94,28 @@ interface Props {
|
||||||
text: string
|
text: string
|
||||||
className?: string
|
className?: string
|
||||||
renderLineAddon?: (line: string, index: number) => React.ReactNode
|
renderLineAddon?: (line: string, index: number) => React.ReactNode
|
||||||
|
/** Optional — Default aus constants, Config-Bänder können später durchgereicht werden. */
|
||||||
|
colorBands?: RatingColorBand[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotationText({ text, className, renderLineAddon }: Props) {
|
export function NotationText({ text, className, renderLineAddon, colorBands }: Props) {
|
||||||
if (!text?.trim()) return null
|
if (!text?.trim()) return null
|
||||||
|
const bands = colorBands ?? DEFAULT_RATING_COLOR_BANDS
|
||||||
const lines = text.split('\n')
|
const lines = text.split('\n')
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{lines.map((line, i) => renderLineAddon
|
{lines.map((line, i) => renderLineAddon
|
||||||
? (
|
? (
|
||||||
<div key={i} className="flex items-start justify-between gap-2">
|
<div key={i} className="flex items-start gap-1">
|
||||||
<div className="flex-1 min-w-0">{parseLine(line, i)}</div>
|
<div className="flex-1 min-w-0">{parseLine(line, i, bands)}</div>
|
||||||
{line.trim() && renderLineAddon(line.trim(), i)}
|
{renderLineAddon(line, i)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: parseLine(line, i)
|
: parseLine(line, i, bands))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitNoteLines(note: string): string[] {
|
export function splitNoteLines(note: string): string[] {
|
||||||
return note.split('\n').map(l => l.trim()).filter(l => l !== '')
|
return note.split('\n').map(l => l.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
613
src/utils/pdfExport.tsx
Normal file
613
src/utils/pdfExport.tsx
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
import { Document, Page, Text, View, StyleSheet, pdf } from '@react-pdf/renderer'
|
||||||
|
import {
|
||||||
|
type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant,
|
||||||
|
type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
|
||||||
|
type ConversationEntry, type Assessment, type AssignmentFeedback,
|
||||||
|
type FeedbackCategoryRating, type FeedbackDimensionText, type FeedbackCategoryText,
|
||||||
|
type ChecklistItemDef, type ChecklistResponse, type MeetingCategoryRating, type RatingColorBand,
|
||||||
|
getAssignment, getAssignmentType, listPhaseTemplatesForType,
|
||||||
|
listDoneMeetingsChronological, getConsultantsByIds, getConsultant,
|
||||||
|
loadFeedbackStructure, resolveVisibleStructure,
|
||||||
|
listConversationEntriesForMeetings, listAssessmentsForMeetings, listMeetingCategoryRatingsForMeetings,
|
||||||
|
listChecklistItemDefsForPhaseTemplates, listChecklistResponsesForMeetings,
|
||||||
|
getAssignmentFeedback, listFeedbackCategoryRatings, listFeedbackDimensionTexts, listFeedbackCategoryTexts,
|
||||||
|
listAssignmentTypes, listPhaseTemplates, getOrSeedRatingColorBands,
|
||||||
|
} from '../db'
|
||||||
|
import { DEFAULT_RATING_COLOR_BANDS, normalizeRatingColorBands, resolveScoreStyle } from '../config/constants'
|
||||||
|
import { splitNoteLines, parseNotationPrefix } from './notationParser'
|
||||||
|
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
|
||||||
|
|
||||||
|
// ─── Styles ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { padding: 32, fontSize: 9, fontFamily: 'Helvetica', color: '#1f2937' },
|
||||||
|
h1: { fontSize: 16, fontWeight: 700, marginBottom: 2 },
|
||||||
|
assignmentNumber: {
|
||||||
|
fontSize: 12, fontWeight: 700, color: '#0070AD', marginBottom: 4, letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
h2: { fontSize: 13, fontWeight: 700, marginTop: 16, marginBottom: 6, borderBottomWidth: 1, borderBottomColor: '#d1d5db', paddingBottom: 3 },
|
||||||
|
h3: { fontSize: 11, fontWeight: 700, marginTop: 10, marginBottom: 3 },
|
||||||
|
h4: { fontSize: 9.5, fontWeight: 700, marginTop: 6, marginBottom: 2 },
|
||||||
|
meta: { fontSize: 9, color: '#6b7280', marginBottom: 1.5 },
|
||||||
|
meetingCard: { marginBottom: 10, padding: 8, backgroundColor: '#f9fafb', borderRadius: 4 },
|
||||||
|
meetingHeader: { fontSize: 10, fontWeight: 700, marginBottom: 1 },
|
||||||
|
meetingDate: { fontSize: 8, color: '#9ca3af', marginBottom: 4 },
|
||||||
|
speakerLabel: {
|
||||||
|
fontSize: 7.5, fontWeight: 700, color: '#ffffff', backgroundColor: '#4b5563',
|
||||||
|
paddingHorizontal: 4, paddingVertical: 1.5, borderRadius: 2, marginTop: 4, marginBottom: 1.5, alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
line: { fontSize: 9, marginBottom: 1.5, lineHeight: 1.3 },
|
||||||
|
chipRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 3 },
|
||||||
|
chip: {
|
||||||
|
fontSize: 7, color: '#ffffff', backgroundColor: '#0070AD',
|
||||||
|
paddingHorizontal: 4, paddingVertical: 1.5, borderRadius: 3, marginRight: 3, marginBottom: 3,
|
||||||
|
},
|
||||||
|
bullet: { fontSize: 9, marginBottom: 2, lineHeight: 1.3 },
|
||||||
|
statusBadge: {
|
||||||
|
fontSize: 8, fontWeight: 700, color: '#ffffff', paddingHorizontal: 5, paddingVertical: 2,
|
||||||
|
borderRadius: 3, alignSelf: 'flex-start', marginBottom: 6,
|
||||||
|
},
|
||||||
|
categoryBlock: {
|
||||||
|
marginBottom: 8, paddingBottom: 6, paddingTop: 4, paddingHorizontal: 6,
|
||||||
|
backgroundColor: '#f3f4f6', borderRadius: 3,
|
||||||
|
borderLeftWidth: 3, borderLeftColor: '#0070AD',
|
||||||
|
},
|
||||||
|
categoryName: { fontSize: 9, fontWeight: 700, color: '#0070AD', flexGrow: 1 },
|
||||||
|
categoryRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 3, gap: 4 },
|
||||||
|
ratingBadge: { fontSize: 7.5, fontWeight: 700, color: '#ffffff', paddingHorizontal: 4, paddingVertical: 1.5, borderRadius: 3 },
|
||||||
|
footer: { position: 'absolute', bottom: 16, left: 32, right: 32, fontSize: 7, color: '#9ca3af', textAlign: 'center' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Notation-Auflösung fürs PDF (führende Score-Zahl + Farbband) ─────────────
|
||||||
|
|
||||||
|
function classifyLine(
|
||||||
|
line: string,
|
||||||
|
colorBands: RatingColorBand[] = DEFAULT_RATING_COLOR_BANDS,
|
||||||
|
): { icon: string; color: string; bold: boolean; italic: boolean; text: string } {
|
||||||
|
const { rating, isQuote, text } = parseNotationPrefix(line)
|
||||||
|
if (isQuote) return { icon: '›', color: '#2563eb', bold: false, italic: true, text }
|
||||||
|
if (rating !== null) {
|
||||||
|
const style = resolveScoreStyle(rating, colorBands)
|
||||||
|
return { icon: String(rating), color: style.hex, bold: true, italic: false, text }
|
||||||
|
}
|
||||||
|
return { icon: '', color: '#1f2937', bold: false, italic: false, text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtocolLine({ line, criterionName, colorBands }: {
|
||||||
|
line: string; criterionName?: string; colorBands?: RatingColorBand[]
|
||||||
|
}) {
|
||||||
|
const cls = classifyLine(line, colorBands)
|
||||||
|
return (
|
||||||
|
<Text style={[styles.line, { color: cls.color, fontWeight: cls.bold ? 700 : 400, fontStyle: cls.italic ? 'italic' : 'normal' }]}>
|
||||||
|
{cls.icon ? `${cls.icon} ` : ''}{cls.text}
|
||||||
|
{criterionName && <Text style={{ color: '#4f46e5', fontSize: 7.5 }}> → {criterionName}</Text>}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
|
|
||||||
|
const sanitizeFilename = (s: string) => s.replace(/[^a-z0-9äöüß]+/gi, '-').replace(/^-+|-+$/g, '')
|
||||||
|
|
||||||
|
/** Primäres Zuordnungskriterium — am Dateinamen-Anfang; Fallback wenn Nummer fehlt. */
|
||||||
|
function assignmentFilenamePrefix(assignment: Assignment): string {
|
||||||
|
const num = assignment.assignmentNumber?.trim()
|
||||||
|
return sanitizeFilename(num || 'ohne-Nr') || 'ohne-Nr'
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignmentNumberHeader({ assignment }: { assignment: Assignment }) {
|
||||||
|
const num = assignment.assignmentNumber?.trim()
|
||||||
|
return (
|
||||||
|
<Text style={styles.assignmentNumber}>
|
||||||
|
Assignment-Nr.: {num || '—'}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Feedback-Abschnitt (gemeinsam für Einzel- und Sammel-PDF) ──────────────
|
||||||
|
|
||||||
|
interface FeedbackSectionData {
|
||||||
|
institutee: Consultant
|
||||||
|
feedback: AssignmentFeedback | undefined
|
||||||
|
dimensions: FeedbackDimension[]
|
||||||
|
categories: FeedbackCategory[]
|
||||||
|
ratings: FeedbackCategoryRating[]
|
||||||
|
dimTexts: FeedbackDimensionText[]
|
||||||
|
categoryTexts: FeedbackCategoryText[]
|
||||||
|
colorBands?: RatingColorBand[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedbackSection({
|
||||||
|
institutee, feedback, dimensions, categories, ratings, dimTexts, categoryTexts,
|
||||||
|
colorBands = DEFAULT_RATING_COLOR_BANDS,
|
||||||
|
}: FeedbackSectionData) {
|
||||||
|
const isFinal = feedback?.status === 'final'
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View wrap={false} minPresenceAhead={50}>
|
||||||
|
<Text style={styles.h3}>{institutee.firstName} {institutee.lastName}</Text>
|
||||||
|
<Text style={[styles.statusBadge, { backgroundColor: isFinal ? '#15803d' : '#9ca3af' }]}>
|
||||||
|
{isFinal ? '✓ Final' : 'Entwurf'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!feedback && <Text style={styles.meta}>Noch kein Feedback erfasst.</Text>}
|
||||||
|
{dimensions.map(dim => {
|
||||||
|
const dimCats = categories.filter(c => c.dimensionId === dim.id)
|
||||||
|
const text = dimTexts.find(t => t.feedbackDimensionId === dim.id)
|
||||||
|
const hasContent = dimCats.some(c => {
|
||||||
|
const ct = categoryTexts.find(t => t.feedbackCategoryId === c.id)
|
||||||
|
return ratings.find(r => r.feedbackCategoryId === c.id)
|
||||||
|
|| ct?.benefits || ct?.concerns
|
||||||
|
})
|
||||||
|
|| text?.achievements || text?.developmentNeeds
|
||||||
|
if (!hasContent) return null
|
||||||
|
return (
|
||||||
|
<View key={dim.id} style={{ marginBottom: 8 }}>
|
||||||
|
<View wrap={false} minPresenceAhead={40}>
|
||||||
|
<Text style={styles.h4}>{dim.name}</Text>
|
||||||
|
</View>
|
||||||
|
{dimCats.map(cat => {
|
||||||
|
const rating = ratings.find(r => r.feedbackCategoryId === cat.id)?.rating
|
||||||
|
const ct = categoryTexts.find(t => t.feedbackCategoryId === cat.id)
|
||||||
|
if (!rating && !ct?.benefits && !ct?.concerns) return null
|
||||||
|
return (
|
||||||
|
<View key={cat.id} style={styles.categoryBlock} wrap={false}>
|
||||||
|
<View style={styles.categoryRow}>
|
||||||
|
<Text style={styles.categoryName}>{cat.name}</Text>
|
||||||
|
{rating != null && (
|
||||||
|
<Text style={[styles.ratingBadge, { backgroundColor: resolveScoreStyle(rating, colorBands).hex }]}>
|
||||||
|
{rating}/10
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{ct?.benefits ? (
|
||||||
|
<View style={{ marginTop: 2 }}>
|
||||||
|
<Text style={{ fontSize: 7.5, fontWeight: 700, color: '#15803d' }}>Benefits</Text>
|
||||||
|
{splitNoteLines(ct.benefits).map((l, i) => <Text key={`b-${i}`} style={styles.bullet}>{l}</Text>)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{ct?.concerns ? (
|
||||||
|
<View style={{ marginTop: 2 }}>
|
||||||
|
<Text style={{ fontSize: 7.5, fontWeight: 700, color: '#b45309' }}>Concerns</Text>
|
||||||
|
{splitNoteLines(ct.concerns).map((l, i) => <Text key={`c-${i}`} style={styles.bullet}>{l}</Text>)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{(text?.achievements || text?.developmentNeeds) ? (
|
||||||
|
<View style={{ marginTop: 6, paddingTop: 4, borderTopWidth: 1, borderTopColor: '#d1d5db' }}>
|
||||||
|
{text?.achievements ? (
|
||||||
|
<View style={{ marginBottom: 4 }}>
|
||||||
|
<View wrap={false} minPresenceAhead={30}>
|
||||||
|
<Text style={{ fontSize: 8.5, fontWeight: 700, color: '#15803d' }}>Top Benefits (Dimension)</Text>
|
||||||
|
</View>
|
||||||
|
{splitNoteLines(text.achievements).map((l, i) => <Text key={i} style={styles.bullet}>{l}</Text>)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{text?.developmentNeeds ? (
|
||||||
|
<View>
|
||||||
|
<View wrap={false} minPresenceAhead={30}>
|
||||||
|
<Text style={{ fontSize: 8.5, fontWeight: 700, color: '#b45309' }}>Top Concerns (Dimension)</Text>
|
||||||
|
</View>
|
||||||
|
{splitNoteLines(text.developmentNeeds).map((l, i) => <Text key={i} style={styles.bullet}>{l}</Text>)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Kombiniertes Assignment-Report-Dokument ────────────────────────────────
|
||||||
|
|
||||||
|
interface AssignmentReportData {
|
||||||
|
assignment: Assignment
|
||||||
|
templates: PhaseTemplate[]
|
||||||
|
meetings: MeetingInstance[]
|
||||||
|
institutees: Consultant[]
|
||||||
|
visibleCategories: FeedbackCategory[]
|
||||||
|
visibleItems: FeedbackCriterionItem[]
|
||||||
|
entries: ConversationEntry[]
|
||||||
|
assessments: Assessment[]
|
||||||
|
categoryRatings: MeetingCategoryRating[]
|
||||||
|
colorBands: RatingColorBand[]
|
||||||
|
checklistItems: ChecklistItemDef[]
|
||||||
|
checklistResponses: ChecklistResponse[]
|
||||||
|
dimensions: FeedbackDimension[]
|
||||||
|
allCategories: FeedbackCategory[]
|
||||||
|
feedbackByInstitutee: FeedbackSectionData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignmentReportDocument({ data }: { data: AssignmentReportData }) {
|
||||||
|
const {
|
||||||
|
assignment, templates, meetings, institutees, visibleCategories, visibleItems, entries, assessments,
|
||||||
|
categoryRatings, colorBands, checklistItems, checklistResponses, dimensions, allCategories, feedbackByInstitutee,
|
||||||
|
} = data
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page} wrap>
|
||||||
|
<AssignmentNumberHeader assignment={assignment} />
|
||||||
|
<Text style={styles.h1}>{assignment.title}</Text>
|
||||||
|
<Text style={styles.meta}>Kunde: {assignment.client}</Text>
|
||||||
|
<Text style={styles.meta}>Institutees: {institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.h2}>Gesamtprotokoll</Text>
|
||||||
|
{meetings.length === 0 && <Text style={styles.meta}>Keine abgeschlossenen Meetings.</Text>}
|
||||||
|
{meetings.map(meeting => {
|
||||||
|
const template = templates.find(t => t.id === meeting.phaseTemplateId)
|
||||||
|
const meetingEntries = entries
|
||||||
|
.filter(e => e.meetingInstanceId === meeting.id)
|
||||||
|
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
|
||||||
|
const meetingAssessments = assessments.filter(a => a.meetingInstanceId === meeting.id)
|
||||||
|
const meetingChecklistItems = checklistItems.filter(ci => ci.phaseTemplateId === template?.id).sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={meeting.id} style={styles.meetingCard}>
|
||||||
|
<View wrap={false} minPresenceAhead={60}>
|
||||||
|
<Text style={styles.meetingHeader}>
|
||||||
|
{template?.label ?? 'Meeting'}{template?.isRepeatable ? ` #${meeting.phaseIndex}` : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.meetingDate}>{formatDate(meeting.date)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{meeting.generalNotes?.trim() && splitNoteLines(meeting.generalNotes).map((l, i) => {
|
||||||
|
const critId = meeting.generalNoteTags?.find(t => t.text === l)?.criterionItemId
|
||||||
|
const critName = critId ? visibleItems.find(item => item.id === critId)?.name : undefined
|
||||||
|
return <ProtocolLine key={`g${i}`} line={l} criterionName={critName} colorBands={colorBands} />
|
||||||
|
})}
|
||||||
|
|
||||||
|
{meetingEntries.map(entry => {
|
||||||
|
const inst = institutees.find(c => c.id === entry.instituteeId)
|
||||||
|
return (
|
||||||
|
<View key={entry.id} wrap={false}>
|
||||||
|
<Text style={styles.speakerLabel}>{inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'}</Text>
|
||||||
|
{entry.note && splitNoteLines(entry.note).map((l, i) => {
|
||||||
|
const critId = entry.lineTags?.find(t => t.text === l)?.criterionItemId
|
||||||
|
const critName = critId ? visibleItems.find(item => item.id === critId)?.name : undefined
|
||||||
|
return <ProtocolLine key={i} line={l} criterionName={critName} colorBands={colorBands} />
|
||||||
|
})}
|
||||||
|
{(entry.fillerCount ?? 0) > 0 && (
|
||||||
|
<Text style={[styles.line, { color: '#dc2626' }]}>Füllwörter: {entry.fillerCount}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{meetingChecklistItems.length > 0 && (
|
||||||
|
<View style={{ marginTop: 4 }}>
|
||||||
|
<Text style={{ fontSize: 8, fontWeight: 700, color: '#4b5563', marginBottom: 1.5 }}>Checkliste</Text>
|
||||||
|
{meetingChecklistItems.map(ci => {
|
||||||
|
const responsesForItem = checklistResponses.filter(r => r.checklistItemId === ci.id)
|
||||||
|
const targets = ci.scope === 'meetingWide' ? [undefined] : institutees.map(i => i.id)
|
||||||
|
return targets.map(instituteeId => {
|
||||||
|
const response = responsesForItem.find(r => (r.instituteeId ?? undefined) === instituteeId)
|
||||||
|
if (!response) return null
|
||||||
|
const effectiveCritId = response.criterionItemId ?? ci.linkedCriterionItemId
|
||||||
|
const critName = effectiveCritId ? visibleItems.find(i => i.id === effectiveCritId)?.name : undefined
|
||||||
|
const inst = instituteeId ? institutees.find(c => c.id === instituteeId) : undefined
|
||||||
|
const who = ci.scope === 'meetingWide' ? '' : ` — ${inst ? `${inst.firstName} ${inst.lastName}` : 'Unbekannt'}`
|
||||||
|
const ratingLabel = response.rating != null ? `${response.rating}/10` : '—'
|
||||||
|
return (
|
||||||
|
<Text key={`${ci.id}-${instituteeId ?? 'm'}`} style={styles.bullet}>
|
||||||
|
<Text style={{ fontWeight: 700 }}>{ci.label}</Text>{who}: {ratingLabel}
|
||||||
|
{critName && <Text style={{ color: '#4f46e5' }}> ({critName})</Text>}
|
||||||
|
{response.note?.trim() ? ` — ${response.note.trim()}` : ''}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{institutees.length > 0 && visibleCategories.length > 0 && (
|
||||||
|
<View style={styles.chipRow}>
|
||||||
|
{institutees.map(inst => visibleCategories.map(cat => {
|
||||||
|
const catItems = visibleItems.filter(i => i.categoryId === cat.id)
|
||||||
|
const mode = resolveCategoryChipScore({
|
||||||
|
meetingId: meeting.id!,
|
||||||
|
instituteeId: inst.id!,
|
||||||
|
categoryId: cat.id!,
|
||||||
|
categoryRatings,
|
||||||
|
assessments: meetingAssessments,
|
||||||
|
catItems,
|
||||||
|
})
|
||||||
|
if (!mode) return null
|
||||||
|
return (
|
||||||
|
<Text key={`${inst.id}-${cat.id}`} style={[styles.chip, { backgroundColor: resolveScoreStyle(mode, colorBands).hex }]}>
|
||||||
|
{inst.firstName} · {cat.name} {mode}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Text style={styles.h2} break>Feedback</Text>
|
||||||
|
{feedbackByInstitutee.map((fb, i) => (
|
||||||
|
<View key={fb.institutee.id} break={i > 0}>
|
||||||
|
<FeedbackSection {...fb} dimensions={dimensions} categories={allCategories} colorBands={colorBands} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text style={styles.footer} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} / ${totalPages} — Export: ${new Date().toLocaleString('de-DE')}`} fixed />
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Einzel-Feedback-Dokument ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function InstituteeFeedbackDocument({ assignment, data }: { assignment: Assignment; data: FeedbackSectionData }) {
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page} wrap>
|
||||||
|
<AssignmentNumberHeader assignment={assignment} />
|
||||||
|
<Text style={styles.h1}>Feedback: {data.institutee.firstName} {data.institutee.lastName}</Text>
|
||||||
|
<Text style={styles.meta}>{assignment.title} · {assignment.client}</Text>
|
||||||
|
<View style={{ marginTop: 10 }}>
|
||||||
|
<FeedbackSection {...data} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.footer} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} / ${totalPages} — Export: ${new Date().toLocaleString('de-DE')}`} fixed />
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Download-Helfer ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function downloadPdfBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Daten sammeln + Export auslösen ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadFeedbackSectionData(assignmentId: number, institutee: Consultant): Promise<FeedbackSectionData> {
|
||||||
|
const feedback = await getAssignmentFeedback(assignmentId, institutee.id!)
|
||||||
|
if (!feedback?.id) {
|
||||||
|
return { institutee, feedback: undefined, dimensions: [], categories: [], ratings: [], dimTexts: [], categoryTexts: [] }
|
||||||
|
}
|
||||||
|
const [ratings, dimTexts, categoryTexts] = await Promise.all([
|
||||||
|
listFeedbackCategoryRatings(feedback.id),
|
||||||
|
listFeedbackDimensionTexts(feedback.id),
|
||||||
|
listFeedbackCategoryTexts(feedback.id),
|
||||||
|
])
|
||||||
|
return { institutee, feedback, dimensions: [], categories: [], ratings, dimTexts, categoryTexts }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reine Erzeugung ohne Download-Nebeneffekt — auch außerhalb des Browsers (z.B. Node/tsx) testbar. */
|
||||||
|
export async function buildAssignmentReportPdfBlob(assignmentId: number): Promise<{ blob: Blob; filename: string }> {
|
||||||
|
const assignment = await getAssignment(assignmentId)
|
||||||
|
if (!assignment) throw new Error('Assignment nicht gefunden')
|
||||||
|
|
||||||
|
const [aType, templates, meetings, institutees, structure] = await Promise.all([
|
||||||
|
getAssignmentType(assignment.assignmentTypeId),
|
||||||
|
listPhaseTemplatesForType(assignment.assignmentTypeId),
|
||||||
|
listDoneMeetingsChronological(assignmentId),
|
||||||
|
getConsultantsByIds(assignment.instituteeIds),
|
||||||
|
loadFeedbackStructure(),
|
||||||
|
])
|
||||||
|
const {
|
||||||
|
dimensions: visibleDimensions,
|
||||||
|
categories: visibleCategories,
|
||||||
|
items: visibleItems,
|
||||||
|
} = resolveVisibleStructure(structure.dimensions, structure.categories, structure.items, aType?.criteriaIds)
|
||||||
|
|
||||||
|
const meetingIds = meetings.map(m => m.id!)
|
||||||
|
const templateIds = templates.map(t => t.id!)
|
||||||
|
const [entries, assessments, categoryRatings, checklistItems, checklistResponses, rawBands] = await Promise.all([
|
||||||
|
listConversationEntriesForMeetings(meetingIds),
|
||||||
|
listAssessmentsForMeetings(meetingIds),
|
||||||
|
listMeetingCategoryRatingsForMeetings(meetingIds),
|
||||||
|
listChecklistItemDefsForPhaseTemplates(templateIds),
|
||||||
|
listChecklistResponsesForMeetings(meetingIds),
|
||||||
|
getOrSeedRatingColorBands(),
|
||||||
|
])
|
||||||
|
const colorBands = normalizeRatingColorBands(rawBands)
|
||||||
|
|
||||||
|
const orderedInstitutees = assignment.instituteeIds
|
||||||
|
.map(id => institutees.find(c => c.id === id))
|
||||||
|
.filter((c): c is Consultant => !!c)
|
||||||
|
|
||||||
|
const feedbackByInstitutee = await Promise.all(
|
||||||
|
orderedInstitutees.map(inst => loadFeedbackSectionData(assignmentId, inst)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const data: AssignmentReportData = {
|
||||||
|
assignment, templates, meetings, institutees: orderedInstitutees,
|
||||||
|
visibleCategories, visibleItems, entries, assessments, categoryRatings, colorBands,
|
||||||
|
checklistItems, checklistResponses,
|
||||||
|
dimensions: visibleDimensions, allCategories: visibleCategories,
|
||||||
|
feedbackByInstitutee,
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await pdf(<AssignmentReportDocument data={data} />).toBlob()
|
||||||
|
const filename = `${assignmentFilenamePrefix(assignment)}_Assignment-Report-${sanitizeFilename(assignment.title)}-${new Date().toISOString().slice(0, 10)}.pdf`
|
||||||
|
return { blob, filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAssignmentReportPdf(assignmentId: number): Promise<void> {
|
||||||
|
const { blob, filename } = await buildAssignmentReportPdfBlob(assignmentId)
|
||||||
|
downloadPdfBlob(blob, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reine Erzeugung ohne Download-Nebeneffekt — auch außerhalb des Browsers (z.B. Node/tsx) testbar. */
|
||||||
|
export async function buildInstituteeFeedbackPdfBlob(assignmentId: number, instituteeId: number): Promise<{ blob: Blob; filename: string }> {
|
||||||
|
const [assignment, institutee, structure, rawBands] = await Promise.all([
|
||||||
|
getAssignment(assignmentId),
|
||||||
|
getConsultant(instituteeId),
|
||||||
|
loadFeedbackStructure(),
|
||||||
|
getOrSeedRatingColorBands(),
|
||||||
|
])
|
||||||
|
if (!assignment || !institutee) throw new Error('Assignment oder Institutee nicht gefunden')
|
||||||
|
|
||||||
|
const aType = await getAssignmentType(assignment.assignmentTypeId)
|
||||||
|
const visible = resolveVisibleStructure(
|
||||||
|
structure.dimensions,
|
||||||
|
structure.categories,
|
||||||
|
structure.items,
|
||||||
|
aType?.criteriaIds,
|
||||||
|
)
|
||||||
|
|
||||||
|
const base = await loadFeedbackSectionData(assignmentId, institutee)
|
||||||
|
const data: FeedbackSectionData = {
|
||||||
|
...base,
|
||||||
|
dimensions: visible.dimensions,
|
||||||
|
categories: visible.categories,
|
||||||
|
colorBands: normalizeRatingColorBands(rawBands),
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await pdf(<InstituteeFeedbackDocument assignment={assignment} data={data} />).toBlob()
|
||||||
|
const filename = `${assignmentFilenamePrefix(assignment)}_Feedback-${sanitizeFilename(institutee.firstName + '-' + institutee.lastName)}-${sanitizeFilename(assignment.title)}.pdf`
|
||||||
|
return { blob, filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportInstituteeFeedbackPdf(assignmentId: number, instituteeId: number): Promise<void> {
|
||||||
|
const { blob, filename } = await buildInstituteeFeedbackPdfBlob(assignmentId, instituteeId)
|
||||||
|
downloadPdfBlob(blob, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Feedback-Struktur-Dokument (Dimensionen/Kategorien/Kriterien + Beschreibungen) ──
|
||||||
|
|
||||||
|
function FeedbackStructureDocument({ dimensions, categories, items }: {
|
||||||
|
dimensions: FeedbackDimension[]; categories: FeedbackCategory[]; items: FeedbackCriterionItem[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page} wrap>
|
||||||
|
<Text style={styles.h1}>Feedback-Struktur</Text>
|
||||||
|
{dimensions.map((dim, di) => {
|
||||||
|
const dimCats = categories.filter(c => c.dimensionId === dim.id)
|
||||||
|
return (
|
||||||
|
<View key={dim.id} break={di > 0}>
|
||||||
|
<Text style={styles.h2}>{dim.name}</Text>
|
||||||
|
{dimCats.map(cat => {
|
||||||
|
const catItems = items.filter(i => i.categoryId === cat.id)
|
||||||
|
return (
|
||||||
|
<View key={cat.id} style={{ marginBottom: 8 }}>
|
||||||
|
<View wrap={false} minPresenceAhead={40}>
|
||||||
|
<Text style={styles.h4}>{cat.name}</Text>
|
||||||
|
{cat.description && <Text style={[styles.meta, { marginBottom: 2 }]}>{cat.description}</Text>}
|
||||||
|
</View>
|
||||||
|
{catItems.length === 0 && <Text style={styles.meta}>(keine Kriterien)</Text>}
|
||||||
|
{catItems.map(item => (
|
||||||
|
<Text key={item.id} style={styles.bullet}>
|
||||||
|
<Text style={{ fontWeight: 700 }}>{item.name}</Text>
|
||||||
|
{` (Gewicht ${(item.weight ?? 1).toFixed(1)})`}
|
||||||
|
{item.description ? ` — ${item.description}` : ''}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Text style={styles.footer} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} / ${totalPages} — Export: ${new Date().toLocaleString('de-DE')}`} fixed />
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportFeedbackStructurePdf(): Promise<void> {
|
||||||
|
const { dimensions, categories, items } = await loadFeedbackStructure()
|
||||||
|
const blob = await pdf(<FeedbackStructureDocument dimensions={dimensions} categories={categories} items={items} />).toBlob()
|
||||||
|
downloadPdfBlob(blob, `Feedback-Struktur-${new Date().toISOString().slice(0, 10)}.pdf`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Assignment-Typen-Bericht (inkl. Kriterien-Mapping) ─────────────────────
|
||||||
|
|
||||||
|
function AssignmentTypesDocument({ types, templates, checklistItems, dimensions, categories, items }: {
|
||||||
|
types: AssignmentType[]; templates: PhaseTemplate[]; checklistItems: ChecklistItemDef[]
|
||||||
|
dimensions: FeedbackDimension[]; categories: FeedbackCategory[]; items: FeedbackCriterionItem[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page} wrap>
|
||||||
|
<Text style={styles.h1}>Assignment-Typen</Text>
|
||||||
|
{types.map((type, ti) => {
|
||||||
|
const typPhases = templates.filter(t => t.assignmentTypeId === type.id).sort((a, b) => a.order - b.order)
|
||||||
|
const criteriaIds = type.criteriaIds ?? []
|
||||||
|
return (
|
||||||
|
<View key={type.id} break={ti > 0}>
|
||||||
|
<Text style={styles.h2}>{type.name}</Text>
|
||||||
|
|
||||||
|
{typPhases.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 6 }}>
|
||||||
|
<Text style={styles.h4}>Phasen</Text>
|
||||||
|
{typPhases.map(p => {
|
||||||
|
const phaseChecklist = checklistItems.filter(ci => ci.phaseTemplateId === p.id).sort((a, b) => a.order - b.order)
|
||||||
|
return (
|
||||||
|
<View key={p.id} style={{ marginBottom: 2 }}>
|
||||||
|
<Text style={styles.bullet}>
|
||||||
|
{p.label} ({p.withWhom === 'client' ? 'Kunde' : 'Lead'}{p.isRepeatable ? ', wiederholbar' : ''}{p.hasChecklist ? ', mit Checkliste' : ''})
|
||||||
|
</Text>
|
||||||
|
{phaseChecklist.map(ci => {
|
||||||
|
const critName = ci.linkedCriterionItemId ? items.find(i => i.id === ci.linkedCriterionItemId)?.name : undefined
|
||||||
|
return (
|
||||||
|
<Text key={ci.id} style={[styles.bullet, { marginLeft: 10, fontSize: 8 }]}>
|
||||||
|
• {ci.label} ({ci.scope === 'meetingWide' ? 'meeting-weit' : 'pro Institutee'})
|
||||||
|
{critName && <Text style={{ color: '#4f46e5' }}> → {critName}</Text>}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View wrap={false} minPresenceAhead={30}>
|
||||||
|
<Text style={styles.h4}>Kriterien-Mapping</Text>
|
||||||
|
</View>
|
||||||
|
{criteriaIds.length === 0 ? (
|
||||||
|
<Text style={styles.bullet}>Alle Kriterien (keine Einschränkung)</Text>
|
||||||
|
) : dimensions.map(dim => {
|
||||||
|
const dimCats = categories.filter(c => c.dimensionId === dim.id)
|
||||||
|
const relevantCats = dimCats.filter(c => items.some(i => i.categoryId === c.id && criteriaIds.includes(i.id!)))
|
||||||
|
if (relevantCats.length === 0) return null
|
||||||
|
return (
|
||||||
|
<View key={dim.id} style={{ marginBottom: 3 }} wrap={false}>
|
||||||
|
<Text style={{ fontSize: 8.5, fontWeight: 700 }}>{dim.name}</Text>
|
||||||
|
{relevantCats.map(cat => {
|
||||||
|
const names = items.filter(i => i.categoryId === cat.id && criteriaIds.includes(i.id!)).map(i => i.name).join(', ')
|
||||||
|
return <Text key={cat.id} style={styles.bullet}>{cat.name}: {names}</Text>
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Text style={styles.footer} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} / ${totalPages} — Export: ${new Date().toLocaleString('de-DE')}`} fixed />
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAssignmentTypesReportPdf(): Promise<void> {
|
||||||
|
const [types, templates, structure] = await Promise.all([
|
||||||
|
listAssignmentTypes(),
|
||||||
|
listPhaseTemplates(),
|
||||||
|
loadFeedbackStructure(),
|
||||||
|
])
|
||||||
|
const checklistItems = await listChecklistItemDefsForPhaseTemplates(templates.map(t => t.id!))
|
||||||
|
const blob = await pdf(
|
||||||
|
<AssignmentTypesDocument types={types} templates={templates} checklistItems={checklistItems}
|
||||||
|
dimensions={structure.dimensions} categories={structure.categories} items={structure.items} />,
|
||||||
|
).toBlob()
|
||||||
|
downloadPdfBlob(blob, `Assignment-Typen-Bericht-${new Date().toISOString().slice(0, 10)}.pdf`)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { FeedbackCriterionItem, FeedbackRating } from '../db'
|
import type { FeedbackCriterionItem, RatingScore } from '../db'
|
||||||
import { RATING_NUM_MAP, RATING_TREND_DELTA_THRESHOLD } from '../config/constants'
|
import { clampRatingScore, normalizeRatingScore, RATING_TREND_DELTA_THRESHOLD } from '../config/constants'
|
||||||
|
|
||||||
export interface MeetingScorePoint {
|
export interface MeetingScorePoint {
|
||||||
meetingId: number
|
meetingId: number
|
||||||
|
|
@ -15,41 +15,39 @@ export interface TrendResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gewichteter Rohwert (0–4) über eine Menge von Kriterien, N/A ausgeschlossen.
|
* Gewichteter Rohwert (1–10) über eine Menge von Kriterien; ungesetzte Scores ausgeschlossen.
|
||||||
* `null` wenn nichts bewertet ist oder Σweight===0 (sonst NaN < x überall false → fällt auf "Fully" durch).
|
* `null` wenn nichts bewertet ist oder Σweight===0.
|
||||||
* Einzige Implementierung dieser Regel — nicht pro Callsite neu schreiben, siehe CLAUDE.md.
|
|
||||||
*/
|
*/
|
||||||
export function weightedAverageScore(
|
export function weightedAverageScore(
|
||||||
items: { id?: number; weight?: number }[],
|
items: { id?: number; weight?: number }[],
|
||||||
getScore: (itemId: number) => FeedbackRating | null,
|
getScore: (itemId: number) => RatingScore | null,
|
||||||
): number | null {
|
): number | null {
|
||||||
const rated = items
|
const rated = items
|
||||||
.map(i => ({ score: getScore(i.id!), w: i.weight ?? 1 }))
|
.map(i => ({ score: normalizeRatingScore(getScore(i.id!)), w: i.weight ?? 1 }))
|
||||||
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
.filter((x): x is { score: RatingScore; w: number } => x.score !== null)
|
||||||
if (rated.length === 0) return null
|
if (rated.length === 0) return null
|
||||||
const wSum = rated.reduce((s, x) => s + x.w, 0)
|
const wSum = rated.reduce((s, x) => s + x.w, 0)
|
||||||
if (wSum === 0) return null
|
if (wSum === 0) return null
|
||||||
return rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum
|
return rated.reduce((s, x) => s + x.score * x.w, 0) / wSum
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wie weightedAverageScore, aber als FeedbackRating-Bucket statt Rohwert. */
|
/** Wie weightedAverageScore, aber auf ganze Score-Stufe 1–10 gerundet. */
|
||||||
export function weightedAverageRating(
|
export function weightedAverageRating(
|
||||||
items: { id?: number; weight?: number }[],
|
items: { id?: number; weight?: number }[],
|
||||||
getScore: (itemId: number) => FeedbackRating | null,
|
getScore: (itemId: number) => RatingScore | null,
|
||||||
): FeedbackRating | null {
|
): RatingScore | null {
|
||||||
const avg = weightedAverageScore(items, getScore)
|
const avg = weightedAverageScore(items, getScore)
|
||||||
return avg === null ? null : numberToRating(avg)
|
return avg === null ? null : clampRatingScore(avg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Wert pro Meeting für eine Gruppe von Kriterien (eine Kategorie oder alle Items für "overall").
|
* 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
|
* Meetings ohne bewertetes Item aus der Gruppe werden übersprungen.
|
||||||
* Kategorie nicht abfragt, darf deren Zeitreihe nicht verfälschen.
|
|
||||||
*/
|
*/
|
||||||
export function buildGroupMeetingScores(
|
export function buildGroupMeetingScores(
|
||||||
items: FeedbackCriterionItem[],
|
items: FeedbackCriterionItem[],
|
||||||
meetingsChronological: { id?: number; date: string }[],
|
meetingsChronological: { id?: number; date: string }[],
|
||||||
getScore: (meetingId: number, itemId: number) => FeedbackRating | null,
|
getScore: (meetingId: number, itemId: number) => RatingScore | null,
|
||||||
): MeetingScorePoint[] {
|
): MeetingScorePoint[] {
|
||||||
const points: MeetingScorePoint[] = []
|
const points: MeetingScorePoint[] = []
|
||||||
for (const meeting of meetingsChronological) {
|
for (const meeting of meetingsChronological) {
|
||||||
|
|
@ -62,9 +60,7 @@ export function buildGroupMeetingScores(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lineare Zeitgewichtung: rank r (1-basiert, 1 = ältester Datenpunkt) → weight = 1 + (r-1) * weightStep.
|
* 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),
|
* Trend aus Delta zwischen erstem und letztem Rohwert (Schwelle auf 1–10-Skala).
|
||||||
* 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 {
|
export function computeTrend(history: MeetingScorePoint[], weightStep: number): TrendResult {
|
||||||
if (history.length === 0) return { suggestion: null, history, trend: null, delta: null }
|
if (history.length === 0) return { suggestion: null, history, trend: null, delta: null }
|
||||||
|
|
@ -82,11 +78,9 @@ export function computeTrend(history: MeetingScorePoint[], weightStep: number):
|
||||||
return { suggestion, history, trend, delta }
|
return { suggestion, history, trend, delta }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function numberToRating(avg: number): FeedbackRating {
|
/** @deprecated Alias — Rundung auf 1–10; früher Bucket auf Client-Ready-Stufen. */
|
||||||
if (avg < 1.5) return 'not_client_ready'
|
export function numberToRating(avg: number): RatingScore | null {
|
||||||
if (avg < 2.5) return 'partially_client_ready'
|
return clampRatingScore(avg)
|
||||||
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]. */
|
/** Resultierende Gewichte für k chronologische Datenpunkte, z.B. previewWeights(3, 0.5) => [1, 1.5, 2]. */
|
||||||
|
|
|
||||||
80
src/utils/structureExport.ts
Normal file
80
src/utils/structureExport.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { loadFeedbackStructure, listAssignmentTypes, listPhaseTemplates, listChecklistItemDefsForPhaseTemplates } from '../db'
|
||||||
|
import { downloadFile } from './meetingExport'
|
||||||
|
|
||||||
|
export async function exportFeedbackStructureAsMarkdown(): Promise<void> {
|
||||||
|
const { dimensions, categories, items } = await loadFeedbackStructure()
|
||||||
|
const lines: string[] = ['# Feedback-Struktur', '']
|
||||||
|
|
||||||
|
for (const dim of dimensions) {
|
||||||
|
lines.push(`## ${dim.name}`, '')
|
||||||
|
const dimCats = categories.filter(c => c.dimensionId === dim.id)
|
||||||
|
for (const cat of dimCats) {
|
||||||
|
lines.push(`### ${cat.name}`)
|
||||||
|
if (cat.description) lines.push('', cat.description)
|
||||||
|
lines.push('')
|
||||||
|
const catItems = items.filter(i => i.categoryId === cat.id)
|
||||||
|
for (const item of catItems) {
|
||||||
|
const weight = (item.weight ?? 1).toFixed(1)
|
||||||
|
const desc = item.description ? ` — ${item.description}` : ''
|
||||||
|
lines.push(`- **${item.name}** (Gewicht ${weight})${desc}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('---', `_Export: ${new Date().toLocaleString('de-DE')}_`)
|
||||||
|
downloadFile(lines.join('\n'), `Feedback-Struktur-${new Date().toISOString().slice(0, 10)}.md`, 'text/markdown')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAssignmentTypesReportAsMarkdown(): Promise<void> {
|
||||||
|
const [types, templates, structure] = await Promise.all([
|
||||||
|
listAssignmentTypes(),
|
||||||
|
listPhaseTemplates(),
|
||||||
|
loadFeedbackStructure(),
|
||||||
|
])
|
||||||
|
const checklistItems = await listChecklistItemDefsForPhaseTemplates(templates.map(t => t.id!))
|
||||||
|
const lines: string[] = ['# Assignment-Typen', '']
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
lines.push(`## ${type.name}`, '')
|
||||||
|
const typPhases = templates.filter(t => t.assignmentTypeId === type.id).sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
|
if (typPhases.length > 0) {
|
||||||
|
lines.push('**Phasen:**')
|
||||||
|
for (const p of typPhases) {
|
||||||
|
const tags = [p.withWhom === 'client' ? 'Kunde' : 'Lead', p.isRepeatable ? 'wiederholbar' : null, p.hasChecklist ? 'mit Checkliste' : null].filter(Boolean).join(', ')
|
||||||
|
lines.push(`- ${p.label} (${tags})`)
|
||||||
|
const phaseChecklist = checklistItems.filter(ci => ci.phaseTemplateId === p.id).sort((a, b) => a.order - b.order)
|
||||||
|
for (const ci of phaseChecklist) {
|
||||||
|
const critName = ci.linkedCriterionItemId ? structure.items.find(i => i.id === ci.linkedCriterionItemId)?.name : undefined
|
||||||
|
const scopeLabel = ci.scope === 'meetingWide' ? 'meeting-weit' : 'pro Institutee'
|
||||||
|
lines.push(` - 🗹 ${ci.label} (${scopeLabel})${critName ? ` → ${critName}` : ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('**Kriterien-Mapping:**')
|
||||||
|
const criteriaIds = type.criteriaIds ?? []
|
||||||
|
if (criteriaIds.length === 0) {
|
||||||
|
lines.push('Alle Kriterien (keine Einschränkung)', '')
|
||||||
|
} else {
|
||||||
|
for (const dim of structure.dimensions) {
|
||||||
|
const dimCats = structure.categories.filter(c => c.dimensionId === dim.id)
|
||||||
|
const relevantCats = dimCats.filter(c => structure.items.some(i => i.categoryId === c.id && criteriaIds.includes(i.id!)))
|
||||||
|
if (relevantCats.length === 0) continue
|
||||||
|
lines.push(`- ${dim.name}`)
|
||||||
|
for (const cat of relevantCats) {
|
||||||
|
const catItemNames = structure.items
|
||||||
|
.filter(i => i.categoryId === cat.id && criteriaIds.includes(i.id!))
|
||||||
|
.map(i => i.name)
|
||||||
|
lines.push(` - ${cat.name}: ${catItemNames.join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('---', `_Export: ${new Date().toLocaleString('de-DE')}_`)
|
||||||
|
downloadFile(lines.join('\n'), `Assignment-Typen-Bericht-${new Date().toISOString().slice(0, 10)}.md`, 'text/markdown')
|
||||||
|
}
|
||||||
|
|
@ -16,5 +16,5 @@
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["server", "src/db/types.ts", "src/config/constants.ts"]
|
"include": ["server", "src/db/types.ts", "src/config/constants.ts", "src/utils/feedbackStructureImport.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,26 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
/** V2-Defaults — Produktiv bleibt auf 5173 / API 4000. */
|
||||||
|
const port = Number(env.PORT || process.env.PORT || 5174)
|
||||||
|
const apiUrl = env.API_URL || process.env.API_URL || 'http://localhost:4001'
|
||||||
|
|
||||||
|
if (port === 5173) {
|
||||||
|
throw new Error(
|
||||||
|
`[vite] SAFETY: PORT=${port} kollidiert mit dem Produktiv-Frontend. V2 erwartet z.B. 5174 (siehe .env).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
server: {
|
server: {
|
||||||
port: process.env.PORT ? Number(process.env.PORT) : 5173,
|
port,
|
||||||
strictPort: !!process.env.PORT,
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': process.env.API_URL ?? 'http://localhost:4000',
|
'/api': apiUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
@ -17,9 +29,9 @@ export default defineConfig({
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Assignment Monitor',
|
name: 'Assignment Monitor DEV',
|
||||||
short_name: 'AssignMonitor',
|
short_name: 'AM-DEV',
|
||||||
description: 'Trainee-Bewertungs-App für Capgemini Management Consultants',
|
description: 'DEV — Trainee-Bewertungs-App (nicht Produktiv)',
|
||||||
theme_color: '#0070AD',
|
theme_color: '#0070AD',
|
||||||
background_color: '#ffffff',
|
background_color: '#ffffff',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
|
@ -30,4 +42,5 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user