Verbesserung. Gesamtview, papierkorb Archiv
This commit is contained in:
parent
019c686254
commit
782678a1cf
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,0–2,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,0–2,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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> =>
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
57
src/pages/AssignmentArchive.tsx
Normal file
57
src/pages/AssignmentArchive.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,18 +170,71 @@ 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>
|
||||||
|
<div className="flex-shrink-0 flex gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/assignment/${assignmentId}/history`)}
|
||||||
|
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
|
||||||
|
📜 Gesamtprotokoll
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
|
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
|
||||||
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">
|
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
|
✏️ Bearbeiten
|
||||||
</button>
|
</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">
|
||||||
{institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}
|
{institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}
|
||||||
</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">
|
||||||
|
|
|
||||||
97
src/pages/AssignmentTrash.tsx
Normal file
97
src/pages/AssignmentTrash.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,6 +48,19 @@ 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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
🗑 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
|
<button
|
||||||
onClick={() => navigate('/assignment/new')}
|
onClick={() => navigate('/assignment/new')}
|
||||||
className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium"
|
className="bg-brand text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||||
|
|
@ -38,6 +68,7 @@ export default function Dashboard() {
|
||||||
+ Neues
|
+ Neues
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Suche + Status-Filter */}
|
{/* Suche + Status-Filter */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
201
src/pages/MeetingHistory.tsx
Normal file
201
src/pages/MeetingHistory.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user