diff --git a/TASKS.md b/TASKS.md index a66b9b9..b8f365e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -44,8 +44,7 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. - [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] Statusübergänge mit Rechteprüfung und automatischem Systemeintrag - [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` @@ -53,12 +52,12 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. - [x] Negativtests dazu: Stufe 2 kann keine Stufe-4-Akte anlegen, nicht ändern, nicht hochstufen, auch nicht über einen direkten Aufruf der Server Action - [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`: +- [x] 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 +- [x] Zurückziehen mit Grabstein und Pflichtbegründung, kein hartes Löschen - [x] Postgres-Regeln gegen `DELETE` auf `CaseEntry` und `CaseEntryVersion` -- [ ] Änderungsübersicht als Reiter an der Akte -- [ ] Adminseite für `AppSetting`: Änderungsrichtlinie, Karenzzeit, Markierungsverhalten +- [x] Änderungsübersicht als Reiter an der Akte +- [x] Adminseite für `AppSetting`: Änderungsrichtlinie, Karenzzeit, Markierungsverhalten ## Phase 3 — Vorlagen und Abschnitte diff --git a/packages/db/scripts/prepare-test-db.mjs b/packages/db/scripts/prepare-test-db.mjs index 635cab1..9538c42 100644 --- a/packages/db/scripts/prepare-test-db.mjs +++ b/packages/db/scripts/prepare-test-db.mjs @@ -51,4 +51,23 @@ execFileSync(process.execPath, [tsxCli, 'prisma/seed.ts'], { env: testEnv, }) +// Die Änderungsrichtlinie ist eine einzige Zeile für die ganze Anwendung. Der +// Seed lässt sie bewusst in Ruhe, damit er die Einstellung des Leiters nicht +// überschreibt — in der Testdatenbank soll sie aber bei jedem Lauf im +// Auslieferungszustand starten, sonst hängt das Ergebnis davon ab, was der +// vorige Lauf hinterlassen hat. +const reset = new pg.Client({ connectionString: testUrl }) +await reset.connect() +try { + await reset.query( + `UPDATE "AppSetting" + SET "entryEditPolicy" = 'GRACE_WINDOW', + "entryEditGraceMinutes" = 15, + "entryEditMarkGrace" = false + WHERE id = 1`, + ) +} finally { + await reset.end() +} + console.log(`[test-db] ${dbName} ist auf Stand`) diff --git a/web/app/(app)/admin/richtlinie/PolicyForm.tsx b/web/app/(app)/admin/richtlinie/PolicyForm.tsx new file mode 100644 index 0000000..cda3a21 --- /dev/null +++ b/web/app/(app)/admin/richtlinie/PolicyForm.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useState, useTransition } from 'react' +import { de } from '@/lib/i18n/de' +import { savePolicy } from './actions' + +const POLICIES = ['APPEND_ONLY', 'GRACE_WINDOW', 'OPEN'] as const + +/** + * Nicht optimistisch: die Richtlinie wirkt sofort für alle Akten. + * Bestätigungsdialog, Wartezustand am Knopf, klare Rückmeldung. + * Siehe docs/09-performance-und-ux.md. + */ +export function PolicyForm({ + initial, +}: { + initial: { entryEditPolicy: string; entryEditGraceMinutes: number; entryEditMarkGrace: boolean } +}) { + const [policy, setPolicy] = useState(initial.entryEditPolicy) + const [grace, setGrace] = useState(initial.entryEditGraceMinutes) + const [mark, setMark] = useState(initial.entryEditMarkGrace) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [pending, startTransition] = useTransition() + + function submit() { + if (!confirm(de.settings.confirm)) return + setError(null) + startTransition(async () => { + const result = await savePolicy({ + entryEditPolicy: policy, + entryEditGraceMinutes: grace, + entryEditMarkGrace: mark, + }) + if (result.ok) setSaved(true) + else setError(result.error) + }) + } + + return ( +
+
+ {de.settings.policy} +
+ {POLICIES.map((value) => ( + + ))} +
+
+ + + + + +
+ + {saved ? ( + {de.settings.saved} + ) : null} + {error ? {error} : null} +
+
+ ) +} diff --git a/web/app/(app)/admin/richtlinie/actions.ts b/web/app/(app)/admin/richtlinie/actions.ts new file mode 100644 index 0000000..8c8850c --- /dev/null +++ b/web/app/(app)/admin/richtlinie/actions.ts @@ -0,0 +1,57 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { db } from '@/lib/db' +import { requirePermissionForAction } from '@/lib/auth/current' +import { writeAudit } from '@/lib/audit' +import { de } from '@/lib/i18n/de' + +const schema = z.object({ + entryEditPolicy: z.enum(['APPEND_ONLY', 'GRACE_WINDOW', 'OPEN']), + entryEditGraceMinutes: z.coerce.number().int().min(0).max(1440), + entryEditMarkGrace: z.boolean(), +}) + +export type PolicyResult = { ok: true } | { ok: false; error: string } + +/** + * Die Änderungsrichtlinie gilt für die ganze Abteilung und wird serverseitig + * gelesen, nie aus dem Request. Deshalb ist das hier die einzige Stelle, an + * der sie sich ändern lässt — mit Recht `admin` und mit Protokolleintrag. + */ +export async function savePolicy(input: unknown): Promise { + const user = await requirePermissionForAction('admin') + + const parsed = schema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.case.errors.default! } + + const before = await db.appSetting.findUnique({ where: { id: 1 } }) + + await db.$transaction(async (tx) => { + await tx.appSetting.upsert({ + where: { id: 1 }, + create: { id: 1, ...parsed.data, updatedById: user.id }, + update: { ...parsed.data, updatedById: user.id }, + }) + + await writeAudit(tx, { + actorId: user.id, + action: 'roles.update', + targetType: 'AppSetting', + targetId: '1', + clearanceAtTime: user.permissions.clearanceLevel, + meta: { + from: { + policy: before?.entryEditPolicy, + grace: before?.entryEditGraceMinutes, + mark: before?.entryEditMarkGrace, + }, + to: parsed.data, + }, + }) + }) + + revalidatePath('/admin/richtlinie') + return { ok: true } +} diff --git a/web/app/(app)/admin/richtlinie/page.tsx b/web/app/(app)/admin/richtlinie/page.tsx new file mode 100644 index 0000000..6e78957 --- /dev/null +++ b/web/app/(app)/admin/richtlinie/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next' +import { db } from '@/lib/db' +import { requirePermission } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' +import { PolicyForm } from './PolicyForm' + +export const metadata: Metadata = { title: de.settings.heading } +export const dynamic = 'force-dynamic' + +export default async function PolicyPage() { + await requirePermission('admin') + + const setting = await db.appSetting.findUnique({ where: { id: 1 } }) + + return ( +
+

{de.settings.heading}

+

{de.settings.intro}

+ +
+ ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx b/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx new file mode 100644 index 0000000..a89fc9d --- /dev/null +++ b/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useState, useTransition } from 'react' +import { de } from '@/lib/i18n/de' +import { changeStatusAction, reclassifyAction } from '../actions' + +/** + * Statuswechsel und Umklassifizierung. + * + * Bewusst **nicht** optimistisch: beides ist folgenreich und teils nicht + * umkehrbar. Stattdessen ein sofortiger Wartezustand am Knopf und eine klare + * Rückmeldung. Siehe docs/09-performance-und-ux.md. + */ +export function CaseActions({ + caseNumber, + classification, + statusTargets, + levels, + maxLevel, +}: { + caseNumber: string + classification: number + statusTargets: string[] + levels: { level: number; name: string }[] + maxLevel: number +}) { + const [nextStatus, setNextStatus] = useState(statusTargets[0] ?? '') + const [statusReason, setStatusReason] = useState('') + const [nextLevel, setNextLevel] = useState(classification) + const [levelReason, setLevelReason] = useState('') + const [error, setError] = useState(null) + const [pending, startTransition] = useTransition() + + const lowering = nextLevel < classification + + function run(action: () => Promise<{ ok: boolean; error?: string }>, confirmText: string) { + if (!confirm(confirmText)) return + setError(null) + startTransition(async () => { + const result = await action() + if (!result.ok) setError(result.error ?? de.case.errors.default!) + }) + } + + return ( +
+ {statusTargets.length > 0 ? ( +
+

{de.case.statusChange}

+
+ + setStatusReason(event.target.value)} + placeholder={de.entry.changeReason} + className="w-56 border border-line bg-raised px-2 py-1" + /> + +
+
+ ) : null} + +
+

{de.case.reclassify}

+
+ + {/* Eine Herabstufung macht Inhalte für Leute sichtbar, die sie vorher + nicht sehen durften. Deshalb ist die Begründung Pflicht. */} + {lowering ? ( + setLevelReason(event.target.value)} + placeholder={de.case.reclassifyReason} + className="w-64 border border-line bg-raised px-2 py-1" + /> + ) : null} + +
+
+ + {error ?

{error}

: null} +
+ ) +} diff --git a/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx b/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx new file mode 100644 index 0000000..8153334 --- /dev/null +++ b/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useState, useTransition } from 'react' +import { de } from '@/lib/i18n/de' +import { editEntryAction, restoreEntryAction, retractEntryAction } from '../actions' + +/** + * Ändern und Zurückziehen eines Verlaufseintrags. + * + * Nicht optimistisch: die Begründung gehört erst abgeschickt und bestätigt, + * bevor der neue Text steht. Siehe docs/09-performance-und-ux.md. + */ +export function EntryActions({ + entryId, + caseNumber, + body, + retracted, + canEdit, + canRetract, +}: { + entryId: string + caseNumber: string + body: string + retracted: boolean + canEdit: boolean + canRetract: boolean +}) { + const [mode, setMode] = useState<'idle' | 'edit' | 'retract'>('idle') + const [draft, setDraft] = useState(body) + const [reason, setReason] = useState('') + const [error, setError] = useState(null) + const [pending, startTransition] = useTransition() + + function run(action: () => Promise<{ ok: boolean; error?: string }>) { + setError(null) + startTransition(async () => { + const result = await action() + if (result.ok) { + setMode('idle') + setReason('') + } else { + setError(result.error ?? de.case.errors.default!) + } + }) + } + + if (retracted) { + if (!canRetract) return null + return ( +
+ setReason(event.target.value)} + placeholder={de.entry.retractReason} + className="w-64 border border-line bg-raised px-2 py-1" + /> + + {error ? {error} : null} +
+ ) + } + + if (mode === 'idle') { + if (!canEdit && !canRetract) return null + return ( +
+ {canEdit ? ( + + ) : null} + {canRetract ? ( + + ) : null} +
+ ) + } + + return ( +
+ {mode === 'edit' ? ( +