diff --git a/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx b/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx index 697e1af..3ee9fab 100644 --- a/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx +++ b/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx @@ -1,9 +1,11 @@ 'use client' import { useState, useTransition } from 'react' -import { caseText, entryText } from '@/lib/i18n/cases' +import { caseText } from '@/lib/i18n/cases' import { actionErrorsText, commonText } from '@/lib/i18n/common' -import { changeStatusAction, reclassifyAction, setDueDateAction } from '../actions' +import { reclassifyAction, setDueDateAction } from '../actions' +import { StatusMap } from '@/components/cases/StatusMap' +import type { StatusOption } from '@/lib/cases/lifecycle' /** * Statuswechsel und Umklassifizierung. @@ -15,21 +17,22 @@ import { changeStatusAction, reclassifyAction, setDueDateAction } from '../actio export function CaseActions({ caseNumber, classification, - statusTargets, + status, + statusOptions, levels, maxLevel, dueAt, }: { caseNumber: string classification: number - statusTargets: string[] + status: string + /** Zu jedem Zustand des Lebenszyklus, ob er von hier aus geht. */ + statusOptions: StatusOption[] levels: { level: number; name: string }[] maxLevel: number /** Als `YYYY-MM-DD`, wie das Datumsfeld es erwartet. Leer heisst: keine. */ dueAt: string }) { - const [nextStatus, setNextStatus] = useState(statusTargets[0] ?? '') - const [statusReason, setStatusReason] = useState('') const [nextLevel, setNextLevel] = useState(classification) const [levelReason, setLevelReason] = useState('') const [frist, setFrist] = useState(dueAt) @@ -49,54 +52,7 @@ export function CaseActions({ return (
- {statusTargets.length > 0 ? ( -
-

{caseText.statusChange}

-
- - setStatusReason(event.target.value)} - placeholder={entryText.changeReason} - className="w-56 field" - /> - -
- {/* Welche Zustände von hier aus erreichbar sind, ergibt sich aus dem - Lebenszyklus - aus einem Entwurf führt nur „Offen" heraus. Ohne - diesen Satz sieht die kurze Auswahl aus wie ein Fehler. */} -

- {caseText.statusHints[nextStatus] ?? caseText.statusChangeHint} -

-
- ) : null} +

{caseText.dueAt}

diff --git a/web/app/(app)/faelle/[caseNumber]/layout.tsx b/web/app/(app)/faelle/[caseNumber]/layout.tsx index a4a4199..5938306 100644 --- a/web/app/(app)/faelle/[caseNumber]/layout.tsx +++ b/web/app/(app)/faelle/[caseNumber]/layout.tsx @@ -6,7 +6,7 @@ import { requireUser } from '@/lib/auth/current' import { loadVisibleCase } from '@/lib/cases/service' import { de } from '@/lib/i18n/de' import { StampMark, classificationEdgeStyle } from '@/components/StampMark' -import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle' +import { statusOptions } from '@/lib/cases/lifecycle' import { CaseActions } from './CaseActions' import { WatchToggle } from './WatchToggle' import { isWatching } from '@/lib/cases/watch' @@ -92,7 +92,8 @@ export default async function CaseLayout({ ({ level: l.level, name: l.name }))} maxLevel={user.permissions.clearanceLevel} dueAt={akte.dueAt ? akte.dueAt.toISOString().slice(0, 10) : ''} diff --git a/web/components/cases/StatusMap.tsx b/web/components/cases/StatusMap.tsx new file mode 100644 index 0000000..6c9d644 --- /dev/null +++ b/web/components/cases/StatusMap.tsx @@ -0,0 +1,200 @@ +'use client' + +import { useState, useTransition } from 'react' +import { caseText, entryText } from '@/lib/i18n/cases' +import { actionErrorsText } from '@/lib/i18n/common' +import { changeStatusAction } from '@/app/(app)/faelle/actions' +import type { StatusOption } from '@/lib/cases/lifecycle' + +/** + * Der Lebenszyklus als Karte statt als Auswahlliste. + * + * Vorher stand hier ein Auswahlfeld, das nur die gerade erreichbaren Zustände + * enthielt. Das liest sich nicht: man sah weder, wo die Akte steht, noch + * welche Zustände es überhaupt gibt, und die Liste hatte nach jedem Wechsel + * andere Einträge. Eine Auswahl, die ihre Einträge tauscht, ist keine + * Orientierung. + * + * Jetzt stehen **immer alle sieben** da, in der Reihenfolge des Lebenszyklus. + * Was sich ändert, ist allein die Schaltbarkeit - und wer auf einen gesperrten + * Zustand zeigt, erfährt den Grund, statt ihn zu raten. + * + * Nicht optimistisch: ein Statuswechsel schreibt einen Verlaufseintrag und ist + * teils nicht umkehrbar. Siehe docs/09-performance-und-ux.md. + */ +const MIN_BEGRUENDUNG = 10 + +export function StatusMap({ + caseNumber, + current, + options, +}: { + caseNumber: string + current: string + /** Zu jedem Zustand die Auskunft vom Server. Reihenfolge = Lebenszyklus. */ + options: StatusOption[] +}) { + const [ziel, setZiel] = useState(null) + const [grund, setGrund] = useState('') + const [fehler, setFehler] = useState(null) + const [pending, startTransition] = useTransition() + + const gewaehlt = options.find((o) => o.status === ziel) ?? null + const brauchtGrund = gewaehlt?.requiresReason ?? false + const grundFehlt = brauchtGrund && grund.trim().length < MIN_BEGRUENDUNG + const irgendwasMoeglich = options.some((o) => o.available) + + // Hauptweg und Nebenwege getrennt: DRAFT bis ARCHIVED ist eine Linie, + // Ruhend und Versiegelt hängen daneben. In einer Reihe sähe es aus, als + // käme nach "Archiviert" noch etwas. + const haupt = options.slice(0, 5) + const neben = options.slice(5) + + function wechseln() { + if (!ziel) return + const name = caseText.statusNames[ziel] ?? ziel + if (!confirm(`${caseText.statusTo(name)}?`)) return + + setFehler(null) + startTransition(async () => { + const ergebnis = await changeStatusAction({ + caseNumber, + to: ziel, + reason: grund.trim() || undefined, + }) + if (ergebnis.ok) { + setZiel(null) + setGrund('') + } else { + setFehler(ergebnis.error ?? actionErrorsText.default!) + } + }) + } + + return ( +
+

{caseText.statusMap}

+ +
+ {haupt.map((option, index) => ( + + {index > 0 ? › : null} + setZiel(option.status)} + disabled={pending} + /> + + ))} +
+ +
+ {caseText.statusSide} + {neben.map((option) => ( + setZiel(option.status)} + disabled={pending} + /> + ))} +
+ + {/* Was der gewählte Zustand bedeutet - und was er verlangt. Beides stand + vorher nirgends, sondern kam erst als Absage zurück. */} +

+ {ziel + ? (caseText.statusHints[ziel] ?? '') + : irgendwasMoeglich + ? caseText.statusPick + : caseText.statusNothingPossible} +

+ + {gewaehlt?.requiresCompleteSections ? ( +

{caseText.statusNeedsComplete}

+ ) : null} + + {ziel ? ( +
+ setGrund(event.target.value)} + placeholder={entryText.changeReason} + aria-label={entryText.changeReason} + className="field w-56" + /> + + {brauchtGrund ? ( + {caseText.statusReasonRequired} + ) : null} +
+ ) : null} + + {fehler ?

{fehler}

: null} +
+ ) +} + +/** + * Ein Zustand. Immer sichtbar - gesperrt heisst blass und nicht anklickbar, + * nicht abwesend. Der Grund steht als Titel daran, damit niemand rät. + */ +function StatusChip({ + option, + current, + selected, + onPick, + disabled, +}: { + option: StatusOption + current: string + selected: boolean + onPick: () => void + disabled: boolean +}) { + const name = caseText.statusNames[option.status] ?? option.status + const istJetzt = option.status === current + + const grund = option.block + ? option.block.kind === 'no-permission' + ? caseText.statusNoPermission(option.block.permission) + : option.block.kind === 'locked' + ? (caseText.statusBlocked[option.block.reason] ?? caseText.statusBlocked.locked!) + : (caseText.statusBlocked[option.block.kind] ?? '') + : (caseText.statusHints[option.status] ?? '') + + if (istJetzt) { + return ( + + {name} + {caseText.statusNow} + + ) + } + + return ( + + ) +} diff --git a/web/lib/cases/lifecycle.ts b/web/lib/cases/lifecycle.ts index 8d38a7a..0352de3 100644 --- a/web/lib/cases/lifecycle.ts +++ b/web/lib/cases/lifecycle.ts @@ -1,4 +1,5 @@ -import type { Permission } from '@/lib/auth/permissions' +import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' +import { canEditCase } from '@/lib/access/caseWrite' /** * Lebenszyklus einer Akte, siehe docs/05-akten-und-vorlagen.md. @@ -59,3 +60,97 @@ export function allowedTargets(from: CaseStatusName): CaseStatusName[] { if (from !== 'SEALED') targets.push('SEALED') return targets } + +/** + * Der Hauptweg einer Akte, in der Reihenfolge, in der sie ihn geht. + * + * Steht hier und nicht in der Oberflaeche, damit die Darstellung dieselbe + * Ordnung benutzt wie der Lebenszyklus - und nicht irgendeine. + */ +export const LIFECYCLE_MAIN: readonly CaseStatusName[] = [ + 'DRAFT', + 'OPEN', + 'REVIEW', + 'CLOSED', + 'ARCHIVED', +] + +/** Was neben dem Hauptweg liegt: eine Pause und die Versiegelung. */ +export const LIFECYCLE_SIDE: readonly CaseStatusName[] = ['DORMANT', 'SEALED'] + +/** Warum ein Zustand gerade nicht angesteuert werden kann. */ +export type StatusBlock = + | { kind: 'current' } + /** Vom jetzigen Zustand fuehrt kein Weg dorthin. */ + | { kind: 'unreachable' } + /** Der Weg gaebe es, aber das Recht fehlt. */ + | { kind: 'no-permission'; permission: Permission } + /** Die Akte ist schreibgeschuetzt oder abgeschlossen. */ + | { kind: 'locked'; reason: string } + +export type StatusOption = { + status: CaseStatusName + /** Anklickbar? Nur dann fuehrt der Server den Wechsel auch aus. */ + available: boolean + block: StatusBlock | null + requiresReason: boolean + requiresCompleteSections: boolean +} + +/** + * Zu **jedem** Zustand die Auskunft, ob er von hier aus erreichbar ist. + * + * Bewusst ueber alle sieben und nicht nur ueber die erreichbaren: eine Auswahl, + * die je nach Zustand andere Eintraege hat, laesst sich nicht lesen - man sieht + * nie, wo man steht und was es sonst noch gibt. Die Oberflaeche zeigt deshalb + * immer dieselbe Karte und schaltet nur, was gerade geht. + * + * Die Rechtepruefung steht hier und nicht im Browser. Sie ist dieselbe, die der + * Dienst beim Wechsel anwendet - ein Knopf, der zu einer Absage fuehrt, ist + * schlimmer als keiner. + */ +export function statusOptions( + p: EffectivePermissions, + target: { status: string; classification: number }, +): StatusOption[] { + const von = target.status as CaseStatusName + const alle = [...LIFECYCLE_MAIN, ...LIFECYCLE_SIDE] + + return alle.map((status) => { + if (status === von) { + return { + status, + available: false, + block: { kind: 'current' } as StatusBlock, + requiresReason: false, + requiresCompleteSections: false, + } + } + + const uebergang = findTransition(von, status) + if (!uebergang) { + return { + status, + available: false, + block: { kind: 'unreachable' } as StatusBlock, + requiresReason: false, + requiresCompleteSections: false, + } + } + + const erlaubt = canEditCase(p, target, uebergang.permission) + const block: StatusBlock | null = erlaubt.ok + ? null + : erlaubt.reason === 'no-permission' + ? { kind: 'no-permission', permission: erlaubt.permission } + : { kind: 'locked', reason: erlaubt.reason } + + return { + status, + available: erlaubt.ok, + block, + requiresReason: uebergang.requiresReason ?? false, + requiresCompleteSections: uebergang.requiresCompleteSections ?? false, + } + }) +} diff --git a/web/lib/i18n/cases.ts b/web/lib/i18n/cases.ts index 1cc6eb1..ee0c8bb 100644 --- a/web/lib/i18n/cases.ts +++ b/web/lib/i18n/cases.ts @@ -23,6 +23,24 @@ export const caseText = { dueAtConfirm: (datum: string) => `Wiedervorlage auf ${datum} setzen?`, dueAtClearConfirm: 'Wiedervorlage entfernen?', statusChangeHint: 'Welcher Zustand von hier aus erreichbar ist, gibt der Lebenszyklus vor.', + statusMap: 'Lebenszyklus', + statusNow: 'jetzt', + statusSide: 'Nebenwege', + statusPick: 'Ziel wählen', + statusTo: (name: string) => `Wechsel nach ${name}`, + statusGo: 'Wechseln', + statusBlocked: { + current: 'Hier steht die Akte gerade.', + unreachable: 'Von hier aus nicht erreichbar.', + locked: 'Die Akte ist in diesem Zustand schreibgeschützt.', + closed: 'Abgeschlossene Akten zu ändern braucht ein eigenes Recht.', + } as Record, + statusNoPermission: (recht: string) => `Dafür fehlt dir das Recht ${recht}.`, + statusReasonRequired: 'Begründung ist hier Pflicht, mindestens 10 Zeichen.', + statusNeedsComplete: + 'Erst wenn alle für den Abschluss nötigen Abschnitte gefüllt sind.', + statusNothingPossible: + 'Von hier aus ist gerade kein Wechsel möglich - weder durch den Lebenszyklus noch mit deinen Rechten.', outcome: 'Ergebnis', createdBy: 'Angelegt von', createdAt: 'Angelegt am', diff --git a/web/tests/lebenszyklus.test.ts b/web/tests/lebenszyklus.test.ts new file mode 100644 index 0000000..3b2c8e9 --- /dev/null +++ b/web/tests/lebenszyklus.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { + findTransition, + LIFECYCLE_MAIN, + LIFECYCLE_SIDE, + statusOptions, + type CaseStatusName, +} from '@/lib/cases/lifecycle' +import { canEditCase } from '@/lib/access/caseWrite' +import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' + +/** + * Die Zustandskarte. + * + * Sie ersetzt eine Auswahlliste, die nur die gerade erreichbaren Zustände + * enthielt - man sah nie, wo die Akte steht und was es sonst noch gibt, und + * nach jedem Wechsel standen andere Einträge darin. + * + * Zwei Zusicherungen hängen daran, und beide stehen hier: + * + * 1. Es sind **immer alle** Zustände dabei, in derselben Reihenfolge. Sonst + * wandert die Karte wieder. + * 2. Was die Karte als schaltbar zeigt, führt der Dienst auch aus. Ein Knopf, + * der in eine Absage läuft, ist schlimmer als kein Knopf. + */ +function perms( + clearanceLevel: number, + options: { isAdmin?: boolean; permissions?: Permission[] } = {}, +): EffectivePermissions { + return { + userId: 'u', + clearanceLevel, + permissions: new Set(options.permissions ?? []), + isAdmin: options.isAdmin ?? false, + hasMembership: true, + } +} + +const ALLE = [...LIFECYCLE_MAIN, ...LIFECYCLE_SIDE] + +describe('Die Karte zeigt immer dasselbe', () => { + it('enthält aus jedem Zustand alle sieben, in gleicher Reihenfolge', () => { + const admin = perms(5, { isAdmin: true }) + for (const von of ALLE) { + const karte = statusOptions(admin, { status: von, classification: 1 }) + expect(karte.map((o) => o.status), von).toEqual(ALLE) + } + }) + + it('markiert genau einen Zustand als den jetzigen', () => { + for (const von of ALLE) { + const karte = statusOptions(perms(5, { isAdmin: true }), { status: von, classification: 1 }) + const jetzt = karte.filter((o) => o.block?.kind === 'current') + expect(jetzt.map((o) => o.status)).toEqual([von]) + // Der jetzige Zustand ist kein Ziel. + expect(jetzt[0]?.available).toBe(false) + } + }) +}) + +describe('Was die Karte anbietet, führt der Dienst auch aus', () => { + const faelle: { p: EffectivePermissions; name: string }[] = [ + { p: perms(5, { isAdmin: true }), name: 'Admin' }, + { p: perms(3, { permissions: ['case.edit'] }), name: 'nur case.edit' }, + { p: perms(3, { permissions: ['case.edit', 'case.close'] }), name: 'edit und close' }, + { p: perms(1, { permissions: ['case.edit'] }), name: 'zu niedrige Stufe' }, + { p: perms(3), name: 'ohne jedes Recht' }, + ] + + it('bietet nichts an, was an Übergang oder Recht scheitern würde', () => { + // Der Kern: dieselbe Prüfung, die changeStatus anwendet, gegen jede + // Zelle der Karte. + for (const { p, name } of faelle) { + for (const von of ALLE) { + const akte = { status: von, classification: 3 } + for (const option of statusOptions(p, akte)) { + if (!option.available) continue + + const uebergang = findTransition(von, option.status) + expect(uebergang, `${name}: ${von} -> ${option.status}`).toBeDefined() + expect( + canEditCase(p, akte, uebergang!.permission).ok, + `${name}: ${von} -> ${option.status}`, + ).toBe(true) + } + } + } + }) + + it('lässt nichts aus, was ginge', () => { + // Die Gegenrichtung. Eine Karte, die einen gültigen Weg verschweigt, wäre + // genauso unbrauchbar - nur unauffälliger. + for (const { p, name } of faelle) { + for (const von of ALLE) { + const akte = { status: von, classification: 3 } + const karte = statusOptions(p, akte) + + for (const nach of ALLE) { + if (nach === von) continue + const uebergang = findTransition(von, nach) + if (!uebergang) continue + if (!canEditCase(p, akte, uebergang.permission).ok) continue + + expect( + karte.find((o) => o.status === nach)?.available, + `${name}: ${von} -> ${nach}`, + ).toBe(true) + } + } + } + }) +}) + +describe('Der Grund für eine Sperre', () => { + it('nennt das fehlende Recht', () => { + const karte = statusOptions(perms(3), { status: 'OPEN', classification: 1 }) + const ruhend = karte.find((o) => o.status === 'DORMANT') + expect(ruhend?.available).toBe(false) + expect(ruhend?.block).toEqual({ kind: 'no-permission', permission: 'case.edit' }) + }) + + it('unterscheidet "geht von hier nicht" von "darfst du nicht"', () => { + // Beides sperrt, aber nur eines lässt sich durch Rechte beheben. Wer das + // verwechselt, sucht an der falschen Stelle. + const karte = statusOptions(perms(5, { isAdmin: true }), { status: 'DRAFT', classification: 1 }) + expect(karte.find((o) => o.status === 'ARCHIVED')?.block).toEqual({ kind: 'unreachable' }) + expect(karte.find((o) => o.status === 'OPEN')?.available).toBe(true) + }) + + it('sperrt aus einer archivierten Akte alles, auch für den Admin', () => { + const karte = statusOptions(perms(5, { isAdmin: true }), { + status: 'ARCHIVED', + classification: 1, + }) + expect(karte.filter((o) => o.available)).toEqual([]) + }) +}) + +describe('Was ein Wechsel verlangt', () => { + it('meldet die Pflichtbegründung beim Wiedereröffnen', () => { + const karte = statusOptions(perms(5, { isAdmin: true }), { + status: 'CLOSED', + classification: 1, + }) + expect(karte.find((o) => o.status === 'OPEN')?.requiresReason).toBe(true) + }) + + it('meldet die Pflichtbegründung beim Versiegeln', () => { + for (const von of ['DRAFT', 'OPEN', 'REVIEW'] as CaseStatusName[]) { + const karte = statusOptions(perms(5, { isAdmin: true }), { status: von, classification: 1 }) + expect(karte.find((o) => o.status === 'SEALED')?.requiresReason, von).toBe(true) + } + }) + + it('meldet die vollständigen Abschnitte vor dem Abschluss', () => { + const karte = statusOptions(perms(5, { isAdmin: true }), { + status: 'REVIEW', + classification: 1, + }) + expect(karte.find((o) => o.status === 'CLOSED')?.requiresCompleteSections).toBe(true) + }) + + it('verlangt sonst nichts', () => { + const karte = statusOptions(perms(5, { isAdmin: true }), { status: 'DRAFT', classification: 1 }) + const offen = karte.find((o) => o.status === 'OPEN') + expect(offen?.requiresReason).toBe(false) + expect(offen?.requiresCompleteSections).toBe(false) + }) +})