From 782678a1cf5c4a2464da8bd2024e6a4356453946 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 13 Jul 2026 12:45:35 +0200 Subject: [PATCH] Verbesserung. Gesamtview, papierkorb Archiv --- CLAUDE.md | 7 + docs/HANDOVER.md | 6 +- server/db/queries/assignmentFeedback.ts | 4 +- server/db/queries/assignments.ts | 69 +++++++- server/db/tableRegistry.ts | 2 + src/App.tsx | 6 + src/db/queries/assignmentFeedback.ts | 2 + src/db/queries/assignments.ts | 12 ++ src/db/types.ts | 4 + src/pages/AssignmentArchive.tsx | 57 +++++++ src/pages/AssignmentDetail.tsx | 78 ++++++++- src/pages/AssignmentTrash.tsx | 97 ++++++++++++ src/pages/Dashboard.tsx | 92 +++++++++-- src/pages/Evaluation.tsx | 20 ++- src/pages/MeetingHistory.tsx | 201 ++++++++++++++++++++++++ src/pages/meeting/MeetingView.tsx | 5 + 16 files changed, 640 insertions(+), 22 deletions(-) create mode 100644 src/pages/AssignmentArchive.tsx create mode 100644 src/pages/AssignmentTrash.tsx create mode 100644 src/pages/MeetingHistory.tsx diff --git a/CLAUDE.md b/CLAUDE.md index be2032e..e5d9931 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,9 @@ src/ pages/ meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx 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) ...übrige Pages unverändert am alten Ort 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 | 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-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. diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index f1049cc..8d43629 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -6,7 +6,11 @@ Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt naht ## 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,0–2,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`. diff --git a/server/db/queries/assignmentFeedback.ts b/server/db/queries/assignmentFeedback.ts index 4217990..d829ffd 100644 --- a/server/db/queries/assignmentFeedback.ts +++ b/server/db/queries/assignmentFeedback.ts @@ -1,4 +1,4 @@ -import { getRowById, getRowsWhereEquals, insertRow, updateRow } from '../crud' +import { getAllRows, getRowById, getRowsWhereEquals, insertRow, updateRow } from '../crud' import type { FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText, FeedbackCategoryText, } from '../../../src/db/types' @@ -10,6 +10,8 @@ export const updateFeedbackDraft = (id: number, patch: Partial): export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): AssignmentFeedback | undefined => getRowsWhereEquals('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('assignmentFeedbacks') export const getAssignmentFeedbackById = (id: number): AssignmentFeedback | undefined => getRowById('assignmentFeedbacks', id) export const addAssignmentFeedback = (f: Omit): number => insertRow('assignmentFeedbacks', f) diff --git a/server/db/queries/assignments.ts b/server/db/queries/assignments.ts index 93fe887..bd00749 100644 --- a/server/db/queries/assignments.ts +++ b/server/db/queries/assignments.ts @@ -1,5 +1,9 @@ -import { getAllRows, getRowById, insertRow, updateRow } from '../crud' -import type { Assignment, AssignmentType, Consultant } from '../../../src/db/types' +import { + 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('assignments') export const listAssignmentsNewestFirst = (): Assignment[] => @@ -8,16 +12,74 @@ export const getAssignment = (id: number): Assignment | undefined => getRowById< export const addAssignment = (a: Omit): number => insertRow('assignments', a) export const updateAssignment = (id: number, patch: Partial): 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('assignments', assignmentId) + if (!a) return false + const feedbacks = getRowsWhereEquals('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('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('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('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('assignments')) { + if (a.deletedAt && now - new Date(a.deletedAt).getTime() > TRASH_RETENTION_MS) { + hardDeleteAssignment(a.id!) + } + } +} + export interface AssignmentWithDetails extends Assignment { typeName: 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. */ export function listAssignmentsWithDetails(): AssignmentWithDetails[] { + purgeExpiredAssignmentTrash() const raw = listAssignmentsNewestFirst() const types = getAllRows('assignmentTypes') const consultants = getAllRows('consultants') + const allFeedbacks = getAllRows('assignmentFeedbacks') const typeMap = new Map(types.map(t => [t.id!, t])) const consMap = new Map(consultants.map(c => [c.id!, c])) return raw.map(a => ({ @@ -27,5 +89,8 @@ export function listAssignmentsWithDetails(): AssignmentWithDetails[] { const c = consMap.get(id) return c ? `${c.firstName} ${c.lastName}` : '?' }), + feedbackComplete: a.instituteeIds.every(iid => + allFeedbacks.some(f => f.assignmentId === a.id && f.instituteeId === iid && f.status === 'final'), + ), })) } diff --git a/server/db/tableRegistry.ts b/server/db/tableRegistry.ts index 17bedc0..78a6494 100644 --- a/server/db/tableRegistry.ts +++ b/server/db/tableRegistry.ts @@ -72,6 +72,8 @@ export const TABLE_DEFINITIONS: TableDef[] = [ col('assignmentNumber', 'TEXT'), col('description', 'TEXT'), col('instituteeEnrollments', 'TEXT', 'json'), + col('deletedAt', 'TEXT'), + col('archivedAt', 'TEXT'), ]}, { name: 'meetingInstances', columns: [ col('assignmentId', 'INTEGER'), diff --git a/src/App.tsx b/src/App.tsx index 4e0bb11..263d716 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,9 @@ import Consultants from './pages/Consultants' import AssignmentCreate from './pages/AssignmentCreate' import AssignmentDetail from './pages/AssignmentDetail' 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 FeedbackDraftPage from './pages/FeedbackDraftPage' import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage' @@ -21,8 +24,11 @@ export default function App() { } /> } /> + } /> + } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/db/queries/assignmentFeedback.ts b/src/db/queries/assignmentFeedback.ts index 2df0d55..8876c25 100644 --- a/src/db/queries/assignmentFeedback.ts +++ b/src/db/queries/assignmentFeedback.ts @@ -11,6 +11,8 @@ export const updateFeedbackDraft = (id: number, patch: Partial): export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): Promise => 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 => rpc('assignmentFeedback', 'listAssignmentFeedbacksAll') export const getAssignmentFeedbackById = (id: number): Promise => rpc('assignmentFeedback', 'getAssignmentFeedbackById', id) export const addAssignmentFeedback = (f: Omit): Promise => diff --git a/src/db/queries/assignments.ts b/src/db/queries/assignments.ts index b998646..2ebd206 100644 --- a/src/db/queries/assignments.ts +++ b/src/db/queries/assignments.ts @@ -8,9 +8,21 @@ export const addAssignment = (a: Omit): Promise => rpc export const updateAssignment = (id: number, patch: Partial): Promise => rpc('assignments', 'updateAssignment', id, patch) +export const isAssignmentFeedbackComplete = (assignmentId: number): Promise => + rpc('assignments', 'isAssignmentFeedbackComplete', assignmentId) +export const softDeleteAssignment = (id: number): Promise => rpc('assignments', 'softDeleteAssignment', id) +export const restoreAssignment = (id: number): Promise => rpc('assignments', 'restoreAssignment', id) +export const archiveAssignment = (id: number): Promise => rpc('assignments', 'archiveAssignment', id) +export const unarchiveAssignment = (id: number): Promise => rpc('assignments', 'unarchiveAssignment', id) + +/** Cascade: meetingInstances (+ deren Kinder) → feedbackDrafts → assignmentFeedbacks (+ deren Kinder) → assignment. */ +export const hardDeleteAssignment = (id: number): Promise => rpc('assignments', 'hardDeleteAssignment', id) + export interface AssignmentWithDetails extends Assignment { typeName: 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. */ diff --git a/src/db/types.ts b/src/db/types.ts index 792cea7..4cbaacf 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -81,6 +81,10 @@ export interface Assignment { from?: 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 ──────────────────────────────────────────────────────── diff --git a/src/pages/AssignmentArchive.tsx b/src/pages/AssignmentArchive.tsx new file mode 100644 index 0000000..00d5740 --- /dev/null +++ b/src/pages/AssignmentArchive.tsx @@ -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([]) + 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 ( +
+ +

🗄 Archiv

+ + {archived.length === 0 && ( +

Archiv ist leer.

+ )} + + {archived.map(a => ( +
+
+
+ {a.assignmentNumber && ( +
+ {a.assignmentNumber} +
+ )} +
{a.title}
+
{a.client}
+
+ {a.typeName} · {a.instituteeNames.join(', ')} +
+ {a.archivedAt && ( +
+ Archiviert am {new Date(a.archivedAt).toLocaleDateString('de-DE')} +
+ )} +
+ +
+
+ ))} +
+ ) +} diff --git a/src/pages/AssignmentDetail.tsx b/src/pages/AssignmentDetail.tsx index 797e516..37352ff 100644 --- a/src/pages/AssignmentDetail.tsx +++ b/src/pages/AssignmentDetail.tsx @@ -8,6 +8,7 @@ import { getConsultantsByIds, listAssessmentsForMeetings, listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure, + isAssignmentFeedbackComplete, softDeleteAssignment, archiveAssignment, } from '../db' import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport' import { weightedAverageRating } from '../utils/ratingTrend' @@ -44,11 +45,15 @@ export default function AssignmentDetail() { const [allAssessments, setAllAssessments] = useState([]) const [fbCategories, setFbCategories] = useState([]) const [fbItems, setFbItems] = useState([]) + const [feedbackComplete, setFeedbackComplete] = useState(false) + const [confirmAssignmentTrash, setConfirmAssignmentTrash] = useState(false) + const [confirmAssignmentArchive, setConfirmAssignmentArchive] = useState(false) const load = async () => { const a = await getAssignment(assignmentId) if (!a) return setAssignment(a) + setFeedbackComplete(a.status === 'done' ? await isAssignmentFeedbackComplete(assignmentId) : false) const [aType, templates, instances, cons] = await Promise.all([ getAssignmentType(a.assignmentTypeId), @@ -132,6 +137,16 @@ export default function AssignmentDetail() { await load() } + const trashAssignment = async () => { + await softDeleteAssignment(assignmentId) + navigate('/') + } + + const archiveThisAssignment = async () => { + await archiveAssignment(assignmentId) + navigate('/') + } + const statusBadge = (status: MeetingInstance['status']) => { const map = { planned: 'bg-yellow-100 text-yellow-700', @@ -155,11 +170,18 @@ export default function AssignmentDetail() { )}

{assignment.title}

- +
+ + +
{assignment.client} · {assignmentType?.name}
@@ -167,6 +189,52 @@ export default function AssignmentDetail() {
+ {/* Assignment-Aktionen (nur für abgeschlossene Assignments) */} + {assignment.status === 'done' && ( +
+ Assignment-Aktionen +
+ + +
+ {!feedbackComplete && ( +
+ Löschen erst möglich, sobald für alle Institutees ein finales Feedback vorliegt. +
+ )} + {confirmAssignmentTrash && ( +
+ Assignment in den Papierkorb verschieben? +
+ + +
+
+ )} + {confirmAssignmentArchive && ( +
+ Assignment archivieren? +
+ + +
+
+ )} +
+ )} + {/* Ad-hoc Meeting */}
diff --git a/src/pages/AssignmentTrash.tsx b/src/pages/AssignmentTrash.tsx new file mode 100644 index 0000000..bc68315 --- /dev/null +++ b/src/pages/AssignmentTrash.tsx @@ -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([]) + const [confirmHardDelete, setConfirmHardDelete] = useState(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 ( +
+ +

🗑 Papierkorb

+ + {trashed.length === 0 && ( +

Papierkorb ist leer.

+ )} + + {trashed.map(a => ( +
+
+
+ {a.assignmentNumber && ( +
+ {a.assignmentNumber} +
+ )} +
{a.title}
+
{a.client}
+
+ {a.typeName} · {a.instituteeNames.join(', ')} +
+ {a.deletedAt && ( +
+ Gelöscht am {new Date(a.deletedAt).toLocaleDateString('de-DE')} · noch {remainingDays(a.deletedAt)} Tage bis zur endgültigen Löschung +
+ )} +
+
+ + +
+
+ {confirmHardDelete === a.id && ( +
+
+ Endgültig löschen? Alle Meetings, Protokolle und Feedback-Daten dieses Assignments werden unwiderruflich entfernt. +
+
+ + +
+
+ )} +
+ ))} +
+ ) +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 29e92e0..4b9d433 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,20 +1,37 @@ import { useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { listAssignmentsWithDetails, type AssignmentWithDetails } from '../db' +import { listAssignmentsWithDetails, softDeleteAssignment, archiveAssignment, type AssignmentWithDetails } from '../db' export default function Dashboard() { const [assignments, setAssignments] = 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(null) + const [confirmArchiveId, setConfirmArchiveId] = useState(null) const navigate = useNavigate() - useEffect(() => { - listAssignmentsWithDetails().then(setAssignments) - }, []) + const load = () => { 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 q = query.trim().toLowerCase() return assignments.filter(a => { + if (a.deletedAt || a.archivedAt) return false if (statusFilter !== 'all' && a.status !== statusFilter) return false if (!q) return true return ( @@ -31,12 +48,26 @@ export default function Dashboard() { {/* Header */}

Assignments

- +
+ + + +
{/* Suche + Status-Filter */} @@ -92,6 +123,45 @@ export default function Dashboard() { {a.status === 'active' ? 'Aktiv' : 'Fertig'}
+ + {a.status === 'done' && ( +
e.stopPropagation()}> + + +
+ )} + + {confirmTrashId === a.id && ( +
e.stopPropagation()}> + In den Papierkorb verschieben? +
+ + +
+
+ )} + + {confirmArchiveId === a.id && ( +
e.stopPropagation()}> + Archivieren? +
+ + +
+
+ )}
))} diff --git a/src/pages/Evaluation.tsx b/src/pages/Evaluation.tsx index 24d4a52..63e4e30 100644 --- a/src/pages/Evaluation.tsx +++ b/src/pages/Evaluation.tsx @@ -9,6 +9,7 @@ import { listAssessmentsAll, getRatingTrendWeightStep, listDoneMeetingsChronological, + listAssignmentFeedbacksAll, } from '../db' import { RATING_OPTIONS } from '../config/constants' import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend' @@ -25,6 +26,13 @@ interface InstScore { catScores: CatScore[] overall: number | null overallTrend: TrendResult['trend'] + feedbackStatus: 'none' | 'draft' | 'final' +} + +function feedbackStatusBadge(status: InstScore['feedbackStatus']) { + if (status === 'final') return ✓ Final + if (status === 'draft') return Entwurf + return Kein Feedback } function ratingColor(avg: number | null): string { @@ -61,17 +69,21 @@ export default function Evaluation() { useEffect(() => { const load = async () => { - const [assignments, consultants, structure, assessments, weightStep] = await Promise.all([ + const [allAssignments, consultants, structure, assessments, weightStep, feedbacks] = await Promise.all([ listAssignments(), listConsultants(), loadFeedbackStructure(), listAssessmentsAll(), getRatingTrendWeightStep(), + listAssignmentFeedbacksAll(), ]) const { dimensions: dims, categories: cats, items } = structure setDimensions(dims) setCategories(cats) + // Papierkorb/Archiv ausgeschlossen — analog zum Dashboard-Filter, siehe CLAUDE.md. + const assignments = allAssignments.filter(a => !a.deletedAt && !a.archivedAt) + const rows: InstScore[] = [] for (const a of assignments) { const meetings = await listDoneMeetingsChronological(a.id!) @@ -99,7 +111,10 @@ export default function Evaluation() { const { suggestion: overall, trend: overallTrend } = computeTrend(overallHistory, weightStep) if (overall === null && catScores.every(c => c.avg === null)) continue - rows.push({ institutee: inst, assignment: a, catScores, overall, 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) @@ -148,6 +163,7 @@ export default function Evaluation() {
{ratingLabel(row.overall)} {trendArrow(row.overallTrend)}
+ {feedbackStatusBadge(row.feedbackStatus)} +

📜 Gesamtprotokoll

+
{assignment.title} · {assignment.client}
+ + + {meetings.length === 0 && ( +

Noch keine abgeschlossenen Meetings.

+ )} + + {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 ( +
+
+
+
+ {template?.label ?? 'Meeting'}{template?.isRepeatable ? ` #${meeting.phaseIndex}` : ''} +
+
{new Date(meeting.date).toLocaleDateString('de-DE')}
+
+ {isLatest && ( + + Aktuellstes Meeting + + )} +
+ + {meeting.generalNotes?.trim() && ( +
+ +
+ )} + + {meetingEntries.length > 0 && ( +
+ {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 ( +
+
+ + {inst ? displayName(inst) : '?'} + +
+ {entry.note && ( + { + const critId = entry.lineTags?.find(t => t.text === line)?.criterionItemId + const critName = critId ? fbItems.find(i => i.id === critId)?.name : null + return critName + ? {critName} + : null + }} + /> + )} + {(entry.fillerCount ?? 0) > 0 && ( + + äh×{entry.fillerCount} + + )} +
+
+
+ ) + })} +
+ )} + + {institutees.length > 0 && fbCategories.length > 0 && meetingAssessments.length > 0 && ( +
+ {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 ( +
+ {displayName(inst)}: + {chips.map(({ cat, mode }) => ( + + {cat.name} + + ))} +
+ ) + })} +
+ )} +
+ ) + })} + + ) +} diff --git a/src/pages/meeting/MeetingView.tsx b/src/pages/meeting/MeetingView.tsx index 4655ac6..dbfeff9 100644 --- a/src/pages/meeting/MeetingView.tsx +++ b/src/pages/meeting/MeetingView.tsx @@ -311,6 +311,11 @@ export default function MeetingView() {
+