diff --git a/TASKS.md b/TASKS.md index 45991c3..a66b9b9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -38,24 +38,25 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. ## Phase 2 — Akten -- [ ] Datenmodell für `Case`, `CaseSection`, `CaseEntry`, Beteiligte, Ausschlüsse -- [ ] `lib/access/` mit Where-Fragmenten, überall verwendet, nirgends Nachfilterung -- [ ] Aktenzeichen-Vergabe kollisionsfrei über `CaseCounter` -- [ ] Fallliste mit Filtern, cursorbasiert, `loading.tsx` mit Skelett -- [ ] Aktenansicht: Kopfdaten sofort, Verlauf und Chat in eigenen Suspense-Grenzen -- [ ] Vermerk anlegen, optimistisch, mit clientseitiger UUIDv7 -- [ ] Statusübergänge mit Rechteprüfung und automatischem Systemeintrag -- [ ] Schreibgrenzen nach `docs/03-auth-und-rechte.md`: keine Akte über der eigenen Stufe +- [x] Datenmodell für `Case`, `CaseSection`, `CaseEntry`, Beteiligte, Ausschlüsse +- [x] `lib/access/` mit Where-Fragmenten, überall verwendet, nirgends Nachfilterung +- [x] Aktenzeichen-Vergabe kollisionsfrei über `CaseCounter` +- [x] Fallliste mit Filtern, cursorbasiert, `loading.tsx` mit Skelett +- [x] Aktenansicht: Kopfdaten sofort, Verlauf in eigener Suspense-Grenze (Chat: Phase 6) +- [x] Vermerk anlegen, optimistisch, mit clientseitiger UUIDv7 +- [~] Statusübergänge mit Rechteprüfung und automatischem Systemeintrag — Fachlogik und + Tests stehen, die Bedienung an der Akte fehlt noch +- [x] Schreibgrenzen nach `docs/03-auth-und-rechte.md`: keine Akte über der eigenen Stufe anlegen, Ändern nur mit Lesezugriff, Klassifizierung nachträglich änderbar bis zur eigenen Stufe. Kein Löschen von Akten, nur `ARCHIVED` und `SEALED` -- [ ] Umklassifizierung erzeugt `SYSTEM`-Eintrag und `AuditLog` mit alter und neuer Stufe -- [ ] Negativtests dazu: Stufe 2 kann keine Stufe-4-Akte anlegen, nicht ändern, nicht +- [x] Umklassifizierung erzeugt `SYSTEM`-Eintrag und `AuditLog` mit alter und neuer Stufe +- [x] Negativtests dazu: Stufe 2 kann keine Stufe-4-Akte anlegen, nicht ändern, nicht hochstufen, auch nicht über einen direkten Aufruf der Server Action -- [ ] Audit-Log für alle schreibenden Aktionen und Lesezugriffe ab Stufe 3 +- [x] Audit-Log für alle schreibenden Aktionen und Lesezugriffe ab Stufe 3 - [ ] Nachträgliche Änderungen an Verlaufseinträgen nach `docs/05-akten-und-vorlagen.md`: `CaseEntryVersion`, Karenzzeit, Pflichtbegründung, `bearbeitet · vN` mit Diff-Historie - [ ] Zurückziehen mit Grabstein und Pflichtbegründung, kein hartes Löschen -- [ ] Postgres-Regeln gegen `DELETE` auf `CaseEntry` und `CaseEntryVersion` +- [x] Postgres-Regeln gegen `DELETE` auf `CaseEntry` und `CaseEntryVersion` - [ ] Änderungsübersicht als Reiter an der Akte - [ ] Adminseite für `AppSetting`: Änderungsrichtlinie, Karenzzeit, Markierungsverhalten diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index ea1f476..78cb1ae 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -4,9 +4,8 @@ import { createPrismaClient } from '../src/index.js' * Grundzustand der Anwendung. Alles hier ist Konfiguration, ohne die die App * nicht sinnvoll läuft, kein Testinhalt. * - * Die vier Startvorlagen (Korruptionsverdacht, Dienstvergehen, - * Sicherheitsvorfall, Routineprüfung) kommen in Phase 3 dazu, zusammen mit dem - * Vorlagen-Editor. Siehe TASKS.md. + * Die vier Startvorlagen stehen hier, weil ohne Vorlage keine Akte angelegt + * werden kann. Den Vorlagen-Editor und die übrigen Feldtypen baut Phase 3. */ const db = createPrismaClient() @@ -28,6 +27,51 @@ const CLEARANCE_LEVELS = [ { level: 5, name: 'Executive Oversight', hiddenBelow: true, colorToken: 'stamp-5' }, ] +/** + * Startvorlagen nach docs/05-akten-und-vorlagen.md. Die Feldschlüssel sind + * stabil und werden nie geändert — eine Akte merkt sich ihre Vorlage über + * `templateId`, und eine neue Vorlagenversion lässt bestehende Akten unberührt. + * + * Der Zuschnitt der Felder ist bewusst schmal gehalten: den Vorlagen-Editor + * mit allen Feldtypen baut Phase 3, hier steht nur, was eine Akte zum Laufen + * braucht. + */ +const TEMPLATES = [ + { + key: 'korruptionsverdacht', + name: 'Korruptionsverdacht', + description: 'Verdacht auf Vorteilsnahme, Bestechlichkeit oder Interessenkonflikt', + }, + { + key: 'dienstvergehen', + name: 'Dienstvergehen', + description: 'Verstoß gegen Dienstpflichten oder interne Vorschriften', + }, + { + key: 'sicherheitsvorfall', + name: 'Sicherheitsvorfall', + description: 'Containment-Bruch, Datenabfluss oder unbefugter Zugriff', + }, + { + key: 'routinepruefung', + name: 'Routineprüfung', + description: 'Turnusmäßige Kontrolle ohne konkreten Anfangsverdacht', + }, +] + +const TEMPLATE_FIELDS = [ + { key: 'sachverhalt', label: 'Sachverhalt', type: 'longtext', required: true }, + { key: 'beteiligte', label: 'Beteiligte Personen', type: 'longtext' }, + { key: 'feststellungen', label: 'Feststellungen', type: 'longtext' }, + { + key: 'bewertung', + label: 'Abschließende Bewertung', + type: 'longtext', + requiredToClose: true, + help: 'Muss gefüllt sein, bevor der Fall abgeschlossen werden kann.', + }, +] + async function main(): Promise { for (const config of CLEARANCE_LEVELS) { await db.clearanceLevelConfig.upsert({ @@ -49,6 +93,15 @@ async function main(): Promise { }) console.log('[seed] Änderungsrichtlinie GRACE_WINDOW, 15 Minuten') + for (const template of TEMPLATES) { + await db.caseTemplate.upsert({ + where: { key_version: { key: template.key, version: 1 } }, + create: { ...template, version: 1, fields: TEMPLATE_FIELDS, isActive: true }, + update: { name: template.name, description: template.description }, + }) + } + console.log(`[seed] ${TEMPLATES.length} Startvorlagen`) + const year = new Date().getFullYear() await db.caseCounter.upsert({ where: { year }, diff --git a/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx b/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx new file mode 100644 index 0000000..f3ad987 --- /dev/null +++ b/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx @@ -0,0 +1,103 @@ +'use client' + +import { useOptimistic, useRef, useState, useTransition } from 'react' +import { uuidv7 } from 'uuidv7' +import { de } from '@/lib/i18n/de' +import { addEntryAction } from '../actions' + +type Pending = { id: string; body: string; failed: boolean } + +/** + * Vermerk anlegen, optimistisch. + * + * Die clientseitig erzeugte UUIDv7 ist zugleich Idempotenzschlüssel: ein + * Wiederholungsversuch nach einem Netzfehler erzeugt keinen zweiten Eintrag. + * Siehe docs/09-performance-und-ux.md. + * + * Nach dem Absenden springt der Fokus sofort zurück ins leere Feld, Enter + * sendet, Shift+Enter macht einen Zeilenumbruch. + */ +export function EntryComposer({ caseNumber }: { caseNumber: string }) { + const [draft, setDraft] = useState('') + const [failures, setFailures] = useState([]) + const [optimistic, addOptimistic] = useOptimistic( + [], + (current, entry) => [entry, ...current], + ) + const [pending, startTransition] = useTransition() + const field = useRef(null) + + function submit(text: string) { + const body = text.trim() + if (!body) return + + const id = uuidv7() + setDraft('') + field.current?.focus() + + startTransition(async () => { + addOptimistic({ id, body, failed: false }) + const result = await addEntryAction({ id, caseNumber, body, minClearance: 0 }) + if (!result.ok) { + // Der Eintrag bleibt stehen und ist als nicht gespeichert markiert. + setFailures((current) => [{ id, body, failed: true }, ...current]) + } + }) + } + + return ( +
+
submit(draft)} + className="max-w-(--container-measure)" + > + +