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}
-
- setNextStatus(event.target.value)}
- className="field"
- >
- {statusTargets.map((target) => (
-
- {caseText.statusNames[target] ?? target}
-
- ))}
-
- setStatusReason(event.target.value)}
- placeholder={entryText.changeReason}
- className="w-56 field"
- />
-
- run(
- () =>
- changeStatusAction({
- caseNumber,
- to: nextStatus,
- reason: statusReason || undefined,
- }),
- `${caseText.statusChange}: ${caseText.statusNames[nextStatus] ?? nextStatus}?`,
- )
- }
- className="btn btn-primary"
- >
- {pending ? caseText.saving : commonText.save}
-
-
- {/* 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"
+ />
+
+ {pending ? caseText.saving : caseText.statusGo}
+
+ {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 (
+
+ {name}
+
+ )
+}
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)
+ })
+})