Verbesserung. Gesamtview, papierkorb Archiv

This commit is contained in:
Lars 2026-07-13 12:45:35 +02:00
parent 019c686254
commit 782678a1cf
16 changed files with 640 additions and 22 deletions

View File

@ -28,6 +28,9 @@ src/
pages/ pages/
meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx
RatingWeightConfig.tsx ← Settings-Tab für Zeitgewichtungs-Faktor (in Configuration.tsx eingebunden) RatingWeightConfig.tsx ← Settings-Tab für Zeitgewichtungs-Faktor (in Configuration.tsx eingebunden)
AssignmentTrash.tsx ← Papierkorb-Ansicht für Assignments (`/assignments/trash`), seit 2026-07-13
AssignmentArchive.tsx ← Archiv-Ansicht für Assignments (`/assignments/archive`), seit 2026-07-13
MeetingHistory.tsx ← Rein lesendes chronologisches Gesamtprotokoll aller Meetings eines Assignments (`/assignment/:id/history`), seit 2026-07-13
AiPromptConfig.tsx ← Settings-Tab „KI-Prompts" — ein editierbares Prompt-Template je Dimension, Default-Vorbelegung + Reset (in Configuration.tsx eingebunden, seit 2026-07-07) AiPromptConfig.tsx ← Settings-Tab „KI-Prompts" — ein editierbares Prompt-Template je Dimension, Default-Vorbelegung + Reset (in Configuration.tsx eingebunden, seit 2026-07-07)
...übrige Pages unverändert am alten Ort ...übrige Pages unverändert am alten Ort
utils/ utils/
@ -149,6 +152,10 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()`
| 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-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):** 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.
**Erledigt (2026-07-04, zweite Runde):** Lokaler Node/Express-Server mit `node:sqlite` (statt IndexedDB) eingeführt, RPC-Bridge (`POST /api/rpc`) zwischen `src/db/queries/*.ts` (Client) und neuem `server/db/queries/*.ts` (Server) — siehe eigener Abschnitt "Datenbank: lokaler Server statt IndexedDB" oben für Details, Abweichungen (node:sqlite statt better-sqlite3) und die wichtige `npx tsc -b`-statt-`--noEmit`-Korrektur. Ziel: mehrere Browser auf demselben Laptop teilen sich dieselben Daten, unabhängig vom Browser-Cache, als Zwischenschritt vor einem Docker/Linux-Deployment. **Erledigt (2026-07-04, zweite Runde):** Lokaler Node/Express-Server mit `node:sqlite` (statt IndexedDB) eingeführt, RPC-Bridge (`POST /api/rpc`) zwischen `src/db/queries/*.ts` (Client) und neuem `server/db/queries/*.ts` (Server) — siehe eigener Abschnitt "Datenbank: lokaler Server statt IndexedDB" oben für Details, Abweichungen (node:sqlite statt better-sqlite3) und die wichtige `npx tsc -b`-statt-`--noEmit`-Korrektur. Ziel: mehrere Browser auf demselben Laptop teilen sich dieselben Daten, unabhängig vom Browser-Cache, als Zwischenschritt vor einem Docker/Linux-Deployment.

View File

@ -6,7 +6,11 @@ Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt naht
## Stand & nächste Schritte ## Stand & nächste Schritte
Zuletzt abgeschlossen (2026-07-04): Umstellung von Dexie/IndexedDB auf einen lokalen Node/Express-Server mit `node:sqlite` (RPC-Bridge `POST /api/rpc`), damit mehrere Browser auf demselben Laptop dieselben Daten sehen. Details: `CLAUDE.md` → Abschnitt "Datenbank: lokaler Server statt IndexedDB". **Sofort nächster Schritt: die echten IndexedDB-Daten müssen noch per Backup-Export/-Import in die neue SQLite-DB migriert werden** — siehe CLAUDE.md-Offene-Punkte-Tabelle, Prio M. 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).
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-04): Umstellung von Dexie/IndexedDB auf einen lokalen Node/Express-Server mit `node:sqlite` (RPC-Bridge `POST /api/rpc`), damit mehrere Browser auf demselben Laptop dieselben Daten sehen. Details: `CLAUDE.md` → Abschnitt "Datenbank: lokaler Server statt IndexedDB". **Sofort nächster Schritt: die echten IndexedDB-Daten müssen noch per Backup-Export/-Import in die neue SQLite-DB migriert werden** — siehe CLAUDE.md-Offene-Punkte-Tabelle, Prio M.
Davor abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ratingTrend.ts`), Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags` + `CriterionTagPicker.tsx`), Notation → Bewertungs-Vorschlag in der Gesamtbewertung, feinere Kriterium-Gewichtung (0,02,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`. Davor abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ratingTrend.ts`), Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags` + `CriterionTagPicker.tsx`), Notation → Bewertungs-Vorschlag in der Gesamtbewertung, feinere Kriterium-Gewichtung (0,02,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`.

View File

@ -1,4 +1,4 @@
import { getRowById, getRowsWhereEquals, insertRow, updateRow } from '../crud' import { getAllRows, getRowById, getRowsWhereEquals, insertRow, updateRow } from '../crud'
import type { import type {
FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText, FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText,
} from '../../../src/db/types' } from '../../../src/db/types'
@ -10,6 +10,8 @@ export const updateFeedbackDraft = (id: number, patch: Partial<FeedbackDraft>):
export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): AssignmentFeedback | undefined => export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): AssignmentFeedback | undefined =>
getRowsWhereEquals<AssignmentFeedback>('assignmentFeedbacks', 'assignmentId', assignmentId).find(f => f.instituteeId === instituteeId) getRowsWhereEquals<AssignmentFeedback>('assignmentFeedbacks', 'assignmentId', assignmentId).find(f => f.instituteeId === instituteeId)
/** Für die Auswertungsübersicht: Feedback-Status (Entwurf/Final) aller Assignments auf einen Schlag, statt N+1-Abfragen pro Zeile. */
export const listAssignmentFeedbacksAll = (): AssignmentFeedback[] => getAllRows<AssignmentFeedback>('assignmentFeedbacks')
export const getAssignmentFeedbackById = (id: number): AssignmentFeedback | undefined => export const getAssignmentFeedbackById = (id: number): AssignmentFeedback | undefined =>
getRowById<AssignmentFeedback>('assignmentFeedbacks', id) getRowById<AssignmentFeedback>('assignmentFeedbacks', id)
export const addAssignmentFeedback = (f: Omit<AssignmentFeedback, 'id'>): number => insertRow('assignmentFeedbacks', f) export const addAssignmentFeedback = (f: Omit<AssignmentFeedback, 'id'>): number => insertRow('assignmentFeedbacks', f)

View File

@ -1,5 +1,9 @@
import { getAllRows, getRowById, insertRow, updateRow } from '../crud' import {
import type { Assignment, AssignmentType, Consultant } from '../../../src/db/types' getAllRows, getRowById, getRowsWhereEquals, insertRow, updateRow,
deleteRowsWhereEquals, deleteRowsWhereIn, deleteRowById,
} from '../crud'
import type { Assignment, AssignmentType, Consultant, AssignmentFeedback } from '../../../src/db/types'
import { listMeetingInstancesForAssignment, hardDeleteMeetingInstance } from './meetings'
export const listAssignments = (): Assignment[] => getAllRows<Assignment>('assignments') export const listAssignments = (): Assignment[] => getAllRows<Assignment>('assignments')
export const listAssignmentsNewestFirst = (): Assignment[] => export const listAssignmentsNewestFirst = (): Assignment[] =>
@ -8,16 +12,74 @@ export const getAssignment = (id: number): Assignment | undefined => getRowById<
export const addAssignment = (a: Omit<Assignment, 'id'>): number => insertRow('assignments', a) export const addAssignment = (a: Omit<Assignment, 'id'>): number => insertRow('assignments', a)
export const updateAssignment = (id: number, patch: Partial<Assignment>): number => updateRow('assignments', id, patch) export const updateAssignment = (id: number, patch: Partial<Assignment>): number => updateRow('assignments', id, patch)
/** Erfüllt für's Löschen nötige Voraussetzung: jedes zugeordnete Institutee hat ein finales Feedback. */
export function isAssignmentFeedbackComplete(assignmentId: number): boolean {
const a = getRowById<Assignment>('assignments', assignmentId)
if (!a) return false
const feedbacks = getRowsWhereEquals<AssignmentFeedback>('assignmentFeedbacks', 'assignmentId', assignmentId)
return a.instituteeIds.every(iid => feedbacks.some(f => f.instituteeId === iid && f.status === 'final'))
}
export function softDeleteAssignment(id: number): number {
const a = getRowById<Assignment>('assignments', id)
if (!a) throw new Error('Assignment nicht gefunden')
if (a.status !== 'done' || !isAssignmentFeedbackComplete(id)) {
throw new Error('Löschen nur möglich, wenn das Assignment abgeschlossen ist und für alle Institutees finales Feedback vorliegt')
}
return updateRow('assignments', id, { deletedAt: new Date().toISOString() })
}
export const restoreAssignment = (id: number): number =>
updateRow('assignments', id, { deletedAt: null })
export function archiveAssignment(id: number): number {
const a = getRowById<Assignment>('assignments', id)
if (!a) throw new Error('Assignment nicht gefunden')
if (a.status !== 'done') throw new Error('Nur abgeschlossene Assignments können archiviert werden')
return updateRow('assignments', id, { archivedAt: new Date().toISOString() })
}
export const unarchiveAssignment = (id: number): number =>
updateRow('assignments', id, { archivedAt: null })
/** Cascade: meetingInstances (+ deren Kinder) → feedbackDrafts → assignmentFeedbacks (+ deren Kinder) → assignment. */
export function hardDeleteAssignment(id: number): void {
for (const m of listMeetingInstancesForAssignment(id)) hardDeleteMeetingInstance(m.id!)
deleteRowsWhereEquals('feedbackDrafts', 'assignmentId', id)
const feedbackIds = getRowsWhereEquals<AssignmentFeedback>('assignmentFeedbacks', 'assignmentId', id).map(f => f.id!)
deleteRowsWhereIn('feedbackCategoryRatings', 'assignmentFeedbackId', feedbackIds)
deleteRowsWhereIn('feedbackDimensionTexts', 'assignmentFeedbackId', feedbackIds)
deleteRowsWhereIn('feedbackCategoryTexts', 'assignmentFeedbackId', feedbackIds)
deleteRowsWhereEquals('assignmentFeedbacks', 'assignmentId', id)
deleteRowById('assignments', id)
}
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
/** Lazy-Sweep statt Cron: läuft bei jedem Dashboard-Load mit, da der Server nicht dauerhaft läuft. */
export function purgeExpiredAssignmentTrash(): void {
const now = Date.now()
for (const a of getAllRows<Assignment>('assignments')) {
if (a.deletedAt && now - new Date(a.deletedAt).getTime() > TRASH_RETENTION_MS) {
hardDeleteAssignment(a.id!)
}
}
}
export interface AssignmentWithDetails extends Assignment { export interface AssignmentWithDetails extends Assignment {
typeName: string typeName: string
instituteeNames: string[] instituteeNames: string[]
/** Nur für status==='done' relevant: jedes Institutee hat ein finales Feedback — steuert den Löschen-Button in der Übersicht. */
feedbackComplete: boolean
} }
/** Für die Dashboard-Liste: Assignments mit aufgelöstem Typ- und Institutee-Namen, neueste zuerst. */ /** Für die Dashboard-Liste: Assignments mit aufgelöstem Typ- und Institutee-Namen, neueste zuerst. */
export function listAssignmentsWithDetails(): AssignmentWithDetails[] { export function listAssignmentsWithDetails(): AssignmentWithDetails[] {
purgeExpiredAssignmentTrash()
const raw = listAssignmentsNewestFirst() const raw = listAssignmentsNewestFirst()
const types = getAllRows<AssignmentType>('assignmentTypes') const types = getAllRows<AssignmentType>('assignmentTypes')
const consultants = getAllRows<Consultant>('consultants') const consultants = getAllRows<Consultant>('consultants')
const allFeedbacks = getAllRows<AssignmentFeedback>('assignmentFeedbacks')
const typeMap = new Map<number, AssignmentType>(types.map(t => [t.id!, t])) const typeMap = new Map<number, AssignmentType>(types.map(t => [t.id!, t]))
const consMap = new Map<number, Consultant>(consultants.map(c => [c.id!, c])) const consMap = new Map<number, Consultant>(consultants.map(c => [c.id!, c]))
return raw.map(a => ({ return raw.map(a => ({
@ -27,5 +89,8 @@ export function listAssignmentsWithDetails(): AssignmentWithDetails[] {
const c = consMap.get(id) const c = consMap.get(id)
return c ? `${c.firstName} ${c.lastName}` : '?' return c ? `${c.firstName} ${c.lastName}` : '?'
}), }),
feedbackComplete: a.instituteeIds.every(iid =>
allFeedbacks.some(f => f.assignmentId === a.id && f.instituteeId === iid && f.status === 'final'),
),
})) }))
} }

View File

@ -72,6 +72,8 @@ export const TABLE_DEFINITIONS: TableDef[] = [
col('assignmentNumber', 'TEXT'), col('assignmentNumber', 'TEXT'),
col('description', 'TEXT'), col('description', 'TEXT'),
col('instituteeEnrollments', 'TEXT', 'json'), col('instituteeEnrollments', 'TEXT', 'json'),
col('deletedAt', 'TEXT'),
col('archivedAt', 'TEXT'),
]}, ]},
{ name: 'meetingInstances', columns: [ { name: 'meetingInstances', columns: [
col('assignmentId', 'INTEGER'), col('assignmentId', 'INTEGER'),

View File

@ -6,6 +6,9 @@ import Consultants from './pages/Consultants'
import AssignmentCreate from './pages/AssignmentCreate' import AssignmentCreate from './pages/AssignmentCreate'
import AssignmentDetail from './pages/AssignmentDetail' import AssignmentDetail from './pages/AssignmentDetail'
import AssignmentEdit from './pages/AssignmentEdit' import AssignmentEdit from './pages/AssignmentEdit'
import AssignmentTrash from './pages/AssignmentTrash'
import AssignmentArchive from './pages/AssignmentArchive'
import MeetingHistory from './pages/MeetingHistory'
import MeetingView from './pages/meeting/MeetingView' import MeetingView from './pages/meeting/MeetingView'
import FeedbackDraftPage from './pages/FeedbackDraftPage' import FeedbackDraftPage from './pages/FeedbackDraftPage'
import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage' import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage'
@ -21,8 +24,11 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/assignment/new" element={<AssignmentCreate />} /> <Route path="/assignment/new" element={<AssignmentCreate />} />
<Route path="/assignments/trash" element={<AssignmentTrash />} />
<Route path="/assignments/archive" element={<AssignmentArchive />} />
<Route path="/assignment/:id" element={<AssignmentDetail />} /> <Route path="/assignment/:id" element={<AssignmentDetail />} />
<Route path="/assignment/:id/edit" element={<AssignmentEdit />} /> <Route path="/assignment/:id/edit" element={<AssignmentEdit />} />
<Route path="/assignment/:id/history" element={<MeetingHistory />} />
<Route path="/assignment/:id/meeting/:meetingId" element={<MeetingView />} /> <Route path="/assignment/:id/meeting/:meetingId" element={<MeetingView />} />
<Route path="/assignment/:id/feedback/:instituteeId" element={<AssignmentFeedbackPage />} /> <Route path="/assignment/:id/feedback/:instituteeId" element={<AssignmentFeedbackPage />} />
<Route path="/assignment/:id/feedback-draft/:instituteeId" element={<FeedbackDraftPage />} /> <Route path="/assignment/:id/feedback-draft/:instituteeId" element={<FeedbackDraftPage />} />

View File

@ -11,6 +11,8 @@ export const updateFeedbackDraft = (id: number, patch: Partial<FeedbackDraft>):
export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): Promise<AssignmentFeedback | undefined> => export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): Promise<AssignmentFeedback | undefined> =>
rpc('assignmentFeedback', 'getAssignmentFeedback', assignmentId, instituteeId) rpc('assignmentFeedback', 'getAssignmentFeedback', assignmentId, instituteeId)
/** Für die Auswertungsübersicht: Feedback-Status (Entwurf/Final) aller Assignments auf einen Schlag, statt N+1-Abfragen pro Zeile. */
export const listAssignmentFeedbacksAll = (): Promise<AssignmentFeedback[]> => rpc('assignmentFeedback', 'listAssignmentFeedbacksAll')
export const getAssignmentFeedbackById = (id: number): Promise<AssignmentFeedback | undefined> => export const getAssignmentFeedbackById = (id: number): Promise<AssignmentFeedback | undefined> =>
rpc('assignmentFeedback', 'getAssignmentFeedbackById', id) rpc('assignmentFeedback', 'getAssignmentFeedbackById', id)
export const addAssignmentFeedback = (f: Omit<AssignmentFeedback, 'id'>): Promise<number> => export const addAssignmentFeedback = (f: Omit<AssignmentFeedback, 'id'>): Promise<number> =>

View File

@ -8,9 +8,21 @@ export const addAssignment = (a: Omit<Assignment, 'id'>): Promise<number> => rpc
export const updateAssignment = (id: number, patch: Partial<Assignment>): Promise<number> => export const updateAssignment = (id: number, patch: Partial<Assignment>): Promise<number> =>
rpc('assignments', 'updateAssignment', id, patch) rpc('assignments', 'updateAssignment', id, patch)
export const isAssignmentFeedbackComplete = (assignmentId: number): Promise<boolean> =>
rpc('assignments', 'isAssignmentFeedbackComplete', assignmentId)
export const softDeleteAssignment = (id: number): Promise<number> => rpc('assignments', 'softDeleteAssignment', id)
export const restoreAssignment = (id: number): Promise<number> => rpc('assignments', 'restoreAssignment', id)
export const archiveAssignment = (id: number): Promise<number> => rpc('assignments', 'archiveAssignment', id)
export const unarchiveAssignment = (id: number): Promise<number> => rpc('assignments', 'unarchiveAssignment', id)
/** Cascade: meetingInstances (+ deren Kinder) → feedbackDrafts → assignmentFeedbacks (+ deren Kinder) → assignment. */
export const hardDeleteAssignment = (id: number): Promise<void> => rpc('assignments', 'hardDeleteAssignment', id)
export interface AssignmentWithDetails extends Assignment { export interface AssignmentWithDetails extends Assignment {
typeName: string typeName: string
instituteeNames: string[] instituteeNames: string[]
/** Nur für status==='done' relevant: jedes Institutee hat ein finales Feedback — steuert den Löschen-Button in der Übersicht. */
feedbackComplete: boolean
} }
/** Für die Dashboard-Liste: Assignments mit aufgelöstem Typ- und Institutee-Namen, neueste zuerst. */ /** Für die Dashboard-Liste: Assignments mit aufgelöstem Typ- und Institutee-Namen, neueste zuerst. */

View File

@ -81,6 +81,10 @@ export interface Assignment {
from?: string from?: string
to?: string to?: string
}> }>
/** Papierkorb-Zeitpunkt — analog MeetingInstance.deletedAt, nach 30 Tagen automatisch endgültig gelöscht. */
deletedAt?: string
/** Archiv-Zeitpunkt — dauerhafte Aufbewahrung (spätere Auswertung/ML), unabhängig vom Papierkorb. */
archivedAt?: string
} }
// ─── Meeting-Instanzen ──────────────────────────────────────────────────────── // ─── Meeting-Instanzen ────────────────────────────────────────────────────────

View File

@ -0,0 +1,57 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { listAssignmentsWithDetails, unarchiveAssignment, type AssignmentWithDetails } from '../db'
export default function AssignmentArchive() {
const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
const navigate = useNavigate()
const load = () => { listAssignmentsWithDetails().then(setAssignments) }
useEffect(() => { load() }, [])
const archived = assignments.filter(a => a.archivedAt)
const unarchive = async (id: number) => {
await unarchiveAssignment(id)
load()
}
return (
<div className="space-y-3">
<button onClick={() => navigate('/')} className="text-sm text-brand mb-2 block"> Zurück</button>
<h1 className="text-xl font-bold text-gray-800">🗄 Archiv</h1>
{archived.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">Archiv ist leer.</p>
)}
{archived.map(a => (
<div key={a.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{a.assignmentNumber && (
<div className="text-xs font-semibold text-brand uppercase tracking-wide leading-none mb-0.5">
{a.assignmentNumber}
</div>
)}
<div className="font-semibold text-gray-800 truncate">{a.title}</div>
<div className="text-sm text-gray-500">{a.client}</div>
<div className="text-xs text-gray-400 mt-0.5">
{a.typeName} · {a.instituteeNames.join(', ')}
</div>
{a.archivedAt && (
<div className="text-xs text-gray-400 mt-1">
Archiviert am {new Date(a.archivedAt).toLocaleDateString('de-DE')}
</div>
)}
</div>
<button onClick={() => unarchive(a.id!)}
className="flex-shrink-0 text-xs bg-white border border-gray-200 text-gray-600 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Aus Archiv zurückholen
</button>
</div>
</div>
))}
</div>
)
}

View File

@ -8,6 +8,7 @@ import {
getConsultantsByIds, getConsultantsByIds,
listAssessmentsForMeetings, listAssessmentsForMeetings,
listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure, listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure,
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 { weightedAverageRating } from '../utils/ratingTrend'
@ -44,11 +45,15 @@ export default function AssignmentDetail() {
const [allAssessments, setAllAssessments] = useState<Assessment[]>([]) const [allAssessments, setAllAssessments] = useState<Assessment[]>([])
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([]) const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([]) const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const [feedbackComplete, setFeedbackComplete] = useState(false)
const [confirmAssignmentTrash, setConfirmAssignmentTrash] = useState(false)
const [confirmAssignmentArchive, setConfirmAssignmentArchive] = useState(false)
const load = async () => { const load = async () => {
const a = await getAssignment(assignmentId) const a = await getAssignment(assignmentId)
if (!a) return if (!a) return
setAssignment(a) setAssignment(a)
setFeedbackComplete(a.status === 'done' ? await isAssignmentFeedbackComplete(assignmentId) : false)
const [aType, templates, instances, cons] = await Promise.all([ const [aType, templates, instances, cons] = await Promise.all([
getAssignmentType(a.assignmentTypeId), getAssignmentType(a.assignmentTypeId),
@ -132,6 +137,16 @@ export default function AssignmentDetail() {
await load() await load()
} }
const trashAssignment = async () => {
await softDeleteAssignment(assignmentId)
navigate('/')
}
const archiveThisAssignment = async () => {
await archiveAssignment(assignmentId)
navigate('/')
}
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',
@ -155,11 +170,18 @@ export default function AssignmentDetail() {
)} )}
<h1 className="text-xl font-bold text-gray-800 leading-tight">{assignment.title}</h1> <h1 className="text-xl font-bold text-gray-800 leading-tight">{assignment.title}</h1>
</div> </div>
<button <div className="flex-shrink-0 flex gap-1.5">
onClick={() => navigate(`/assignment/${assignmentId}/edit`)} <button
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"> onClick={() => navigate(`/assignment/${assignmentId}/history`)}
Bearbeiten className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
</button> 📜 Gesamtprotokoll
</button>
<button
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">
Bearbeiten
</button>
</div>
</div> </div>
<div className="text-sm text-gray-500">{assignment.client} · {assignmentType?.name}</div> <div className="text-sm text-gray-500">{assignment.client} · {assignmentType?.name}</div>
<div className="text-xs text-gray-400 mt-1"> <div className="text-xs text-gray-400 mt-1">
@ -167,6 +189,52 @@ export default function AssignmentDetail() {
</div> </div>
</div> </div>
{/* Assignment-Aktionen (nur für abgeschlossene Assignments) */}
{assignment.status === 'done' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-2">
<span className="text-sm font-semibold text-gray-700">Assignment-Aktionen</span>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setConfirmAssignmentTrash(p => !p)}
disabled={!feedbackComplete}
title={!feedbackComplete ? 'Erst möglich, wenn für alle Institutees ein finales Feedback vorliegt' : undefined}
className={`text-xs px-3 py-1.5 rounded-lg font-medium ${
feedbackComplete ? 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}>
🗑 In Papierkorb verschieben
</button>
<button
onClick={() => setConfirmAssignmentArchive(p => !p)}
className="text-xs px-3 py-1.5 rounded-lg font-medium bg-gray-50 text-gray-600 border border-gray-200 hover:bg-gray-100">
🗄 Archivieren
</button>
</div>
{!feedbackComplete && (
<div className="text-xs text-gray-400">
Löschen erst möglich, sobald für alle Institutees ein finales Feedback vorliegt.
</div>
)}
{confirmAssignmentTrash && (
<div className="bg-red-50 border border-red-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2">
<span className="text-xs text-red-700">Assignment in den Papierkorb verschieben?</span>
<div className="flex gap-1.5">
<button onClick={trashAssignment} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmAssignmentTrash(false)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
{confirmAssignmentArchive && (
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2">
<span className="text-xs text-gray-600">Assignment archivieren?</span>
<div className="flex gap-1.5">
<button onClick={archiveThisAssignment} className="text-xs bg-gray-700 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmAssignmentArchive(false)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
</div>
)}
{/* Ad-hoc Meeting */} {/* Ad-hoc Meeting */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4"> <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -0,0 +1,97 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
listAssignmentsWithDetails, restoreAssignment, hardDeleteAssignment,
type AssignmentWithDetails,
} from '../db'
const TRASH_RETENTION_DAYS = 30
export default function AssignmentTrash() {
const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
const [confirmHardDelete, setConfirmHardDelete] = useState<number | null>(null)
const navigate = useNavigate()
const load = () => { listAssignmentsWithDetails().then(setAssignments) }
useEffect(() => { load() }, [])
const trashed = assignments.filter(a => a.deletedAt)
const restore = async (id: number) => {
await restoreAssignment(id)
load()
}
const hardDelete = async (id: number) => {
await hardDeleteAssignment(id)
setConfirmHardDelete(null)
load()
}
const remainingDays = (deletedAt: string) => {
const elapsedDays = Math.floor((Date.now() - new Date(deletedAt).getTime()) / 86_400_000)
return Math.max(0, TRASH_RETENTION_DAYS - elapsedDays)
}
return (
<div className="space-y-3">
<button onClick={() => navigate('/')} className="text-sm text-brand mb-2 block"> Zurück</button>
<h1 className="text-xl font-bold text-gray-800">🗑 Papierkorb</h1>
{trashed.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">Papierkorb ist leer.</p>
)}
{trashed.map(a => (
<div key={a.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{a.assignmentNumber && (
<div className="text-xs font-semibold text-brand uppercase tracking-wide leading-none mb-0.5">
{a.assignmentNumber}
</div>
)}
<div className="font-semibold text-gray-800 truncate">{a.title}</div>
<div className="text-sm text-gray-500">{a.client}</div>
<div className="text-xs text-gray-400 mt-0.5">
{a.typeName} · {a.instituteeNames.join(', ')}
</div>
{a.deletedAt && (
<div className="text-xs text-gray-400 mt-1">
Gelöscht am {new Date(a.deletedAt).toLocaleDateString('de-DE')} · noch {remainingDays(a.deletedAt)} Tage bis zur endgültigen Löschung
</div>
)}
</div>
<div className="flex flex-col gap-1.5 items-end flex-shrink-0">
<button onClick={() => restore(a.id!)}
className="text-xs bg-white border border-gray-200 text-gray-600 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Wiederherstellen
</button>
<button onClick={() => setConfirmHardDelete(confirmHardDelete === a.id ? null : a.id!)}
className="text-xs text-red-400 hover:text-red-600 px-2 py-1">
Endgültig löschen
</button>
</div>
</div>
{confirmHardDelete === a.id && (
<div className="mt-2 bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
<div className="text-xs text-red-700 font-medium">
Endgültig löschen? Alle Meetings, Protokolle und Feedback-Daten dieses Assignments werden unwiderruflich entfernt.
</div>
<div className="flex gap-2">
<button onClick={() => hardDelete(a.id!)}
className="flex-1 py-1.5 rounded-lg text-xs font-medium bg-red-600 text-white">
Endgültig löschen
</button>
<button onClick={() => setConfirmHardDelete(null)}
className="px-3 py-1.5 rounded-lg text-xs border border-gray-200 text-gray-500">
Abbrechen
</button>
</div>
</div>
)}
</div>
))}
</div>
)
}

View File

@ -1,20 +1,37 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { listAssignmentsWithDetails, type AssignmentWithDetails } from '../db' import { listAssignmentsWithDetails, softDeleteAssignment, archiveAssignment, type AssignmentWithDetails } from '../db'
export default function Dashboard() { export default function Dashboard() {
const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([]) const [assignments, setAssignments] = useState<AssignmentWithDetails[]>([])
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'done'>('all') const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'done'>('active')
const [confirmTrashId, setConfirmTrashId] = useState<number | null>(null)
const [confirmArchiveId, setConfirmArchiveId] = useState<number | null>(null)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { const load = () => { listAssignmentsWithDetails().then(setAssignments) }
listAssignmentsWithDetails().then(setAssignments) useEffect(() => { load() }, [])
}, [])
const trashAssignment = async (id: number) => {
await softDeleteAssignment(id)
setConfirmTrashId(null)
load()
}
const archiveThisAssignment = async (id: number) => {
await archiveAssignment(id)
setConfirmArchiveId(null)
load()
}
const trashCount = useMemo(() => assignments.filter(a => a.deletedAt).length, [assignments])
const archiveCount = useMemo(() => assignments.filter(a => a.archivedAt).length, [assignments])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
return assignments.filter(a => { return assignments.filter(a => {
if (a.deletedAt || a.archivedAt) return false
if (statusFilter !== 'all' && a.status !== statusFilter) return false if (statusFilter !== 'all' && a.status !== statusFilter) return false
if (!q) return true if (!q) return true
return ( return (
@ -31,12 +48,26 @@ export default function Dashboard() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800">Assignments</h1> <h1 className="text-xl font-bold text-gray-800">Assignments</h1>
<button <div className="flex items-center gap-2">
onClick={() => navigate('/assignment/new')} <button
className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium" onClick={() => navigate('/assignments/trash')}
> 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"
+ Neues >
</button> 🗑 Papierkorb{trashCount > 0 && ` (${trashCount})`}
</button>
<button
onClick={() => navigate('/assignments/archive')}
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"
>
🗄 Archiv{archiveCount > 0 && ` (${archiveCount})`}
</button>
<button
onClick={() => navigate('/assignment/new')}
className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium"
>
+ Neues
</button>
</div>
</div> </div>
{/* Suche + Status-Filter */} {/* Suche + Status-Filter */}
@ -92,6 +123,45 @@ export default function Dashboard() {
{a.status === 'active' ? 'Aktiv' : 'Fertig'} {a.status === 'active' ? 'Aktiv' : 'Fertig'}
</span> </span>
</div> </div>
{a.status === 'done' && (
<div className="mt-2 pt-2 border-t border-gray-100 flex gap-2" onClick={e => e.stopPropagation()}>
<button
onClick={() => { setConfirmTrashId(confirmTrashId === a.id ? null : a.id!); setConfirmArchiveId(null) }}
disabled={!a.feedbackComplete}
title={!a.feedbackComplete ? 'Erst möglich, wenn für alle Institutees ein finales Feedback vorliegt' : undefined}
className={`text-xs px-2.5 py-1 rounded-lg font-medium ${
a.feedbackComplete ? 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}>
🗑 Papierkorb
</button>
<button
onClick={() => { setConfirmArchiveId(confirmArchiveId === a.id ? null : a.id!); setConfirmTrashId(null) }}
className="text-xs px-2.5 py-1 rounded-lg font-medium bg-gray-50 text-gray-600 border border-gray-200 hover:bg-gray-100">
🗄 Archivieren
</button>
</div>
)}
{confirmTrashId === a.id && (
<div className="mt-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
<span className="text-xs text-red-700">In den Papierkorb verschieben?</span>
<div className="flex gap-1.5">
<button onClick={() => trashAssignment(a.id!)} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmTrashId(null)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
{confirmArchiveId === a.id && (
<div className="mt-2 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
<span className="text-xs text-gray-600">Archivieren?</span>
<div className="flex gap-1.5">
<button onClick={() => archiveThisAssignment(a.id!)} className="text-xs bg-gray-700 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmArchiveId(null)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@ -9,6 +9,7 @@ import {
listAssessmentsAll, listAssessmentsAll,
getRatingTrendWeightStep, getRatingTrendWeightStep,
listDoneMeetingsChronological, listDoneMeetingsChronological,
listAssignmentFeedbacksAll,
} from '../db' } from '../db'
import { RATING_OPTIONS } from '../config/constants' import { RATING_OPTIONS } from '../config/constants'
import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend' import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend'
@ -25,6 +26,13 @@ interface InstScore {
catScores: CatScore[] catScores: CatScore[]
overall: number | null overall: number | null
overallTrend: TrendResult['trend'] overallTrend: TrendResult['trend']
feedbackStatus: 'none' | 'draft' | 'final'
}
function feedbackStatusBadge(status: InstScore['feedbackStatus']) {
if (status === 'final') return <span className="text-xs font-medium px-2 py-1 rounded-lg bg-green-100 text-green-700"> Final</span>
if (status === 'draft') return <span className="text-xs font-medium px-2 py-1 rounded-lg bg-amber-100 text-amber-700">Entwurf</span>
return <span className="text-xs font-medium px-2 py-1 rounded-lg bg-gray-100 text-gray-400">Kein Feedback</span>
} }
function ratingColor(avg: number | null): string { function ratingColor(avg: number | null): string {
@ -61,17 +69,21 @@ export default function Evaluation() {
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
const [assignments, consultants, structure, assessments, weightStep] = await Promise.all([ const [allAssignments, consultants, structure, assessments, weightStep, feedbacks] = await Promise.all([
listAssignments(), listAssignments(),
listConsultants(), listConsultants(),
loadFeedbackStructure(), loadFeedbackStructure(),
listAssessmentsAll(), listAssessmentsAll(),
getRatingTrendWeightStep(), getRatingTrendWeightStep(),
listAssignmentFeedbacksAll(),
]) ])
const { dimensions: dims, categories: cats, items } = structure const { dimensions: dims, categories: cats, items } = structure
setDimensions(dims) setDimensions(dims)
setCategories(cats) setCategories(cats)
// Papierkorb/Archiv ausgeschlossen — analog zum Dashboard-Filter, siehe CLAUDE.md.
const assignments = allAssignments.filter(a => !a.deletedAt && !a.archivedAt)
const rows: InstScore[] = [] const rows: InstScore[] = []
for (const a of assignments) { for (const a of assignments) {
const meetings = await listDoneMeetingsChronological(a.id!) const meetings = await listDoneMeetingsChronological(a.id!)
@ -99,7 +111,10 @@ export default function Evaluation() {
const { suggestion: overall, trend: overallTrend } = computeTrend(overallHistory, weightStep) const { suggestion: overall, trend: overallTrend } = computeTrend(overallHistory, weightStep)
if (overall === null && catScores.every(c => c.avg === null)) continue if (overall === null && catScores.every(c => c.avg === null)) continue
rows.push({ institutee: inst, assignment: a, catScores, overall, overallTrend }) const fb = feedbacks.find(f => f.assignmentId === a.id && f.instituteeId === instId)
const feedbackStatus: InstScore['feedbackStatus'] = fb?.status === 'final' ? 'final' : fb?.status === 'draft' ? 'draft' : 'none'
rows.push({ institutee: inst, assignment: a, catScores, overall, overallTrend, feedbackStatus })
} }
} }
setData(rows) setData(rows)
@ -148,6 +163,7 @@ export default function Evaluation() {
<div className={`px-3 py-1 rounded-full text-sm font-bold flex items-center gap-1 ${ratingColor(row.overall)}`}> <div className={`px-3 py-1 rounded-full text-sm font-bold flex items-center gap-1 ${ratingColor(row.overall)}`}>
{ratingLabel(row.overall)} {trendArrow(row.overallTrend)} {ratingLabel(row.overall)} {trendArrow(row.overallTrend)}
</div> </div>
{feedbackStatusBadge(row.feedbackStatus)}
<button <button
onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }} onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }}
className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-lg"> className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-lg">

View File

@ -0,0 +1,201 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
type Assignment, type PhaseTemplate, type MeetingInstance, type Consultant,
type FeedbackCategory, type FeedbackCriterionItem, type ConversationEntry, type Assessment, type FeedbackRating,
getAssignment, getAssignmentType,
listPhaseTemplatesForType,
listDoneMeetingsChronological,
getConsultantsByIds,
listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure,
listConversationEntriesForMeetings,
listAssessmentsForMeetings,
} from '../db'
import { NotationText } from '../utils/notationParser'
import { INST_COLORS } from '../config/constants'
import { weightedAverageRating } from '../utils/ratingTrend'
const RATING_BG: Record<FeedbackRating, string> = {
na: 'bg-gray-400 text-white',
not_client_ready: 'bg-red-500 text-white',
partially_client_ready: 'bg-orange-500 text-white',
nearly_client_ready: 'bg-green-400 text-white',
client_ready: 'bg-green-700 text-white',
}
export default function MeetingHistory() {
const { id } = useParams()
const navigate = useNavigate()
const assignmentId = Number(id)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [templates, setTemplates] = useState<PhaseTemplate[]>([])
const [meetings, setMeetings] = useState<MeetingInstance[]>([])
const [institutees, setInstitutees] = useState<Consultant[]>([])
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const [entries, setEntries] = useState<ConversationEntry[]>([])
const [assessments, setAssessments] = useState<Assessment[]>([])
const [loaded, setLoaded] = useState(false)
const latestRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const load = async () => {
const a = await getAssignment(assignmentId)
if (!a) return
setAssignment(a)
const [aType, tmpls, doneMeetings, cons] = await Promise.all([
getAssignmentType(a.assignmentTypeId),
listPhaseTemplatesForType(a.assignmentTypeId),
listDoneMeetingsChronological(assignmentId),
getConsultantsByIds(a.instituteeIds),
])
setTemplates(tmpls)
setMeetings(doneMeetings)
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
const [allCats, allItems] = await Promise.all([
listFeedbackCategoriesAll(),
listFeedbackCriterionItemsAll(),
])
const { categories: visibleCats, items: visibleItems } = filterVisibleFeedbackStructure(allCats, allItems, aType?.criteriaIds)
setFbCategories(visibleCats)
setFbItems(visibleItems)
const meetingIds = doneMeetings.map(m => m.id!)
const [convEntries, asmts] = await Promise.all([
listConversationEntriesForMeetings(meetingIds),
listAssessmentsForMeetings(meetingIds),
])
setEntries(convEntries)
setAssessments(asmts)
setLoaded(true)
}
load()
}, [assignmentId])
useEffect(() => {
if (loaded) latestRef.current?.scrollIntoView({ block: 'start' })
}, [loaded])
const displayName = (inst: Consultant) => {
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
return dup ? `${inst.firstName} ${inst.lastName.charAt(0)}.` : inst.firstName
}
if (!assignment) return <div className="text-center py-10 text-gray-400">Lädt</div>
return (
<div className="space-y-4">
<div>
<button onClick={() => navigate(`/assignment/${assignmentId}`)} className="text-sm text-brand mb-2 block"> Assignment</button>
<h1 className="text-xl font-bold text-gray-800">📜 Gesamtprotokoll</h1>
<div className="text-sm text-gray-500">{assignment.title} · {assignment.client}</div>
</div>
{meetings.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">Noch keine abgeschlossenen Meetings.</p>
)}
{meetings.map((meeting, idx) => {
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 isLatest = idx === meetings.length - 1
return (
<div key={meeting.id}
ref={isLatest ? latestRef : undefined}
className={`bg-white rounded-xl shadow-sm p-4 space-y-3 ${isLatest ? 'border-2 border-brand' : 'border border-gray-100'}`}>
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-gray-800">
{template?.label ?? 'Meeting'}{template?.isRepeatable ? ` #${meeting.phaseIndex}` : ''}
</div>
<div className="text-xs text-gray-400">{new Date(meeting.date).toLocaleDateString('de-DE')}</div>
</div>
{isLatest && (
<span className="text-xs bg-brand text-white px-2 py-1 rounded-full font-medium flex-shrink-0">
Aktuellstes Meeting
</span>
)}
</div>
{meeting.generalNotes?.trim() && (
<div className="bg-gray-50 rounded-lg px-3 py-2">
<NotationText text={meeting.generalNotes} />
</div>
)}
{meetingEntries.length > 0 && (
<div className="space-y-1.5">
{meetingEntries.map(entry => {
const instIdx = institutees.findIndex(c => c.id === entry.instituteeId)
const inst = institutees[instIdx]
const color = INST_COLORS[instIdx % INST_COLORS.length]
return (
<div key={entry.id}
className={`rounded-lg border px-3 py-2 ${color?.bg ?? 'bg-gray-50'} ${color?.border ?? 'border-gray-200'}`}>
<div className="flex items-start gap-2">
<span className={`flex-shrink-0 text-xs font-bold text-white px-2 py-0.5 rounded mt-0.5 ${color?.tag ?? 'bg-gray-400'}`}>
{inst ? displayName(inst) : '?'}
</span>
<div className="flex-1 min-w-0">
{entry.note && (
<NotationText
text={entry.note}
renderLineAddon={line => {
const critId = entry.lineTags?.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
}}
/>
)}
{(entry.fillerCount ?? 0) > 0 && (
<span className="inline-block mt-1 text-xs bg-red-50 text-red-500 border border-red-200 px-1.5 py-0.5 rounded font-medium">
äh×{entry.fillerCount}
</span>
)}
</div>
</div>
</div>
)
})}
</div>
)}
{institutees.length > 0 && fbCategories.length > 0 && meetingAssessments.length > 0 && (
<div className="space-y-1 pt-1 border-t border-gray-50">
{institutees.map(inst => {
const chips = fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
const mode = weightedAverageRating(catItems, itemId =>
meetingAssessments.find(a => a.instituteeId === inst.id && a.criteriaId === itemId)?.score ?? null,
)
return mode ? { cat, mode } : null
}).filter(Boolean) as { cat: FeedbackCategory; mode: FeedbackRating }[]
if (chips.length === 0) return null
return (
<div key={inst.id} className="flex items-center gap-1 flex-wrap">
<span className="text-xs text-gray-400">{displayName(inst)}:</span>
{chips.map(({ cat, mode }) => (
<span key={cat.id} className={`text-xs px-2 py-0.5 rounded font-medium ${RATING_BG[mode]}`}>
{cat.name}
</span>
))}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}

View File

@ -311,6 +311,11 @@ export default function MeetingView() {
</div> </div>
</div> </div>
<div className="flex gap-1.5 flex-shrink-0 items-start"> <div className="flex gap-1.5 flex-shrink-0 items-start">
<button onClick={() => navigate(`/assignment/${assignment?.id}/history`)}
title="Gesamtprotokoll aller Meetings"
className="text-sm bg-white border border-gray-200 text-gray-600 px-2.5 py-1.5 rounded-lg font-medium">
📜 Verlauf
</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">