Statuswechsel als Karte statt als wandernde Auswahlliste
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s

Das Auswahlfeld enthielt nur die gerade erreichbaren Zustaende. Damit sah
man weder, wo die Akte steht, noch welche Zustaende es ueberhaupt gibt - und
nach jedem Wechsel standen andere Eintraege darin. Eine Auswahl, die ihre
Eintraege tauscht, ist keine Orientierung.

Jetzt stehen immer alle sieben da, in der Reihenfolge des Lebenszyklus:
Entwurf, Offen, In Pruefung, Abgeschlossen, Archiviert als eine Linie,
darunter Ruhend und Versiegelt als Nebenwege. Der jetzige Zustand ist
markiert, erreichbare sind Knoepfe, gesperrte bleiben blass stehen statt zu
verschwinden - mit dem Grund als Titel daran.

Der Grund ist dabei nicht dasselbe wie die Sperre: "von hier fuehrt kein
Weg dorthin" laesst sich durch kein Recht beheben, "dir fehlt case.close"
schon. Wer das verwechselt, sucht an der falschen Stelle.

Was ein Wechsel verlangt, steht jetzt vorher da statt als Absage danach:
die Pflichtbegruendung beim Wiedereroeffnen und beim Versiegeln, und dass
vor dem Abschluss die noetigen Abschnitte gefuellt sein muessen. Bei
Pflichtbegruendung bleibt der Knopf gesperrt, bis sie dasteht.

Die Rechtepruefung rechnet der Server, mit derselben Funktion, die der
Dienst beim Wechsel anwendet - vorher zeigte die Liste auch Ziele, die
danach abgelehnt wurden. Zwei Pruefungen halten das fest, und zwar in beide
Richtungen: nichts angeboten, was scheitern wuerde, und nichts verschwiegen,
was ginge. Die zweite ist die wichtigere, weil ihr Fehlen nicht auffaellt.
This commit is contained in:
Harlekin
2026-09-04 16:26:29 +02:00
parent 42dbf0f6b1
commit bd750f26d0
6 changed files with 496 additions and 57 deletions
@@ -1,9 +1,11 @@
'use client' 'use client'
import { useState, useTransition } from 'react' 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 { 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. * Statuswechsel und Umklassifizierung.
@@ -15,21 +17,22 @@ import { changeStatusAction, reclassifyAction, setDueDateAction } from '../actio
export function CaseActions({ export function CaseActions({
caseNumber, caseNumber,
classification, classification,
statusTargets, status,
statusOptions,
levels, levels,
maxLevel, maxLevel,
dueAt, dueAt,
}: { }: {
caseNumber: string caseNumber: string
classification: number classification: number
statusTargets: string[] status: string
/** Zu jedem Zustand des Lebenszyklus, ob er von hier aus geht. */
statusOptions: StatusOption[]
levels: { level: number; name: string }[] levels: { level: number; name: string }[]
maxLevel: number maxLevel: number
/** Als `YYYY-MM-DD`, wie das Datumsfeld es erwartet. Leer heisst: keine. */ /** Als `YYYY-MM-DD`, wie das Datumsfeld es erwartet. Leer heisst: keine. */
dueAt: string dueAt: string
}) { }) {
const [nextStatus, setNextStatus] = useState(statusTargets[0] ?? '')
const [statusReason, setStatusReason] = useState('')
const [nextLevel, setNextLevel] = useState(classification) const [nextLevel, setNextLevel] = useState(classification)
const [levelReason, setLevelReason] = useState('') const [levelReason, setLevelReason] = useState('')
const [frist, setFrist] = useState(dueAt) const [frist, setFrist] = useState(dueAt)
@@ -49,54 +52,7 @@ export function CaseActions({
return ( return (
<div className="mt-6 flex flex-wrap gap-x-10 gap-y-5 border-t border-line pt-5 text-xs"> <div className="mt-6 flex flex-wrap gap-x-10 gap-y-5 border-t border-line pt-5 text-xs">
{statusTargets.length > 0 ? ( <StatusMap caseNumber={caseNumber} current={status} options={statusOptions} />
<div>
<p className="font-condensed text-text-dim">{caseText.statusChange}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<select
value={nextStatus}
onChange={(event) => setNextStatus(event.target.value)}
className="field"
>
{statusTargets.map((target) => (
<option key={target} value={target} title={caseText.statusHints[target]}>
{caseText.statusNames[target] ?? target}
</option>
))}
</select>
<input
value={statusReason}
onChange={(event) => setStatusReason(event.target.value)}
placeholder={entryText.changeReason}
className="w-56 field"
/>
<button
type="button"
disabled={pending || !nextStatus}
onClick={() =>
run(
() =>
changeStatusAction({
caseNumber,
to: nextStatus,
reason: statusReason || undefined,
}),
`${caseText.statusChange}: ${caseText.statusNames[nextStatus] ?? nextStatus}?`,
)
}
className="btn btn-primary"
>
{pending ? caseText.saving : commonText.save}
</button>
</div>
{/* 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. */}
<p className="mt-1.5 max-w-sm text-2xs text-text-dim">
{caseText.statusHints[nextStatus] ?? caseText.statusChangeHint}
</p>
</div>
) : null}
<div> <div>
<p className="font-condensed text-text-dim">{caseText.dueAt}</p> <p className="font-condensed text-text-dim">{caseText.dueAt}</p>
+3 -2
View File
@@ -6,7 +6,7 @@ import { requireUser } from '@/lib/auth/current'
import { loadVisibleCase } from '@/lib/cases/service' import { loadVisibleCase } from '@/lib/cases/service'
import { de } from '@/lib/i18n/de' import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark' 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 { CaseActions } from './CaseActions'
import { WatchToggle } from './WatchToggle' import { WatchToggle } from './WatchToggle'
import { isWatching } from '@/lib/cases/watch' import { isWatching } from '@/lib/cases/watch'
@@ -92,7 +92,8 @@ export default async function CaseLayout({
<CaseActions <CaseActions
caseNumber={akte.caseNumber} caseNumber={akte.caseNumber}
classification={akte.classification} classification={akte.classification}
statusTargets={allowedTargets(akte.status as CaseStatusName)} status={akte.status}
statusOptions={statusOptions(user.permissions, akte)}
levels={levels.map((l) => ({ level: l.level, name: l.name }))} levels={levels.map((l) => ({ level: l.level, name: l.name }))}
maxLevel={user.permissions.clearanceLevel} maxLevel={user.permissions.clearanceLevel}
dueAt={akte.dueAt ? akte.dueAt.toISOString().slice(0, 10) : ''} dueAt={akte.dueAt ? akte.dueAt.toISOString().slice(0, 10) : ''}
+200
View File
@@ -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<string | null>(null)
const [grund, setGrund] = useState('')
const [fehler, setFehler] = useState<string | null>(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 (
<div>
<p className="font-condensed text-text-dim">{caseText.statusMap}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{haupt.map((option, index) => (
<span key={option.status} className="flex items-center gap-1.5">
{index > 0 ? <span aria-hidden className="text-text-dim">›</span> : null}
<StatusChip
option={option}
current={current}
selected={ziel === option.status}
onPick={() => setZiel(option.status)}
disabled={pending}
/>
</span>
))}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<span className="font-condensed text-2xs text-text-dim">{caseText.statusSide}</span>
{neben.map((option) => (
<StatusChip
key={option.status}
option={option}
current={current}
selected={ziel === option.status}
onPick={() => setZiel(option.status)}
disabled={pending}
/>
))}
</div>
{/* Was der gewählte Zustand bedeutet - und was er verlangt. Beides stand
vorher nirgends, sondern kam erst als Absage zurück. */}
<p className="mt-3 max-w-md text-2xs text-text-dim">
{ziel
? (caseText.statusHints[ziel] ?? '')
: irgendwasMoeglich
? caseText.statusPick
: caseText.statusNothingPossible}
</p>
{gewaehlt?.requiresCompleteSections ? (
<p className="mt-1 max-w-md text-2xs text-stamp-3">{caseText.statusNeedsComplete}</p>
) : null}
{ziel ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
value={grund}
onChange={(event) => setGrund(event.target.value)}
placeholder={entryText.changeReason}
aria-label={entryText.changeReason}
className="field w-56"
/>
<button
type="button"
onClick={wechseln}
disabled={pending || grundFehlt}
className="btn btn-primary"
>
{pending ? caseText.saving : caseText.statusGo}
</button>
{brauchtGrund ? (
<span className="text-2xs text-stamp-3">{caseText.statusReasonRequired}</span>
) : null}
</div>
) : null}
{fehler ? <p className="mt-2 text-2xs text-stamp-4">{fehler}</p> : null}
</div>
)
}
/**
* 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 (
<span className="stamp-mark" title={grund}>
{name}
<span className="ml-1.5 text-2xs opacity-70">{caseText.statusNow}</span>
</span>
)
}
return (
<button
type="button"
onClick={onPick}
disabled={!option.available || disabled}
title={grund}
aria-pressed={selected}
className={`btn btn-sm ${selected ? 'btn-primary' : ''} ${
option.available ? '' : 'opacity-40'
}`}
>
{name}
</button>
)
}
+96 -1
View File
@@ -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. * 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') if (from !== 'SEALED') targets.push('SEALED')
return targets 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,
}
})
}
+18
View File
@@ -23,6 +23,24 @@ export const caseText = {
dueAtConfirm: (datum: string) => `Wiedervorlage auf ${datum} setzen?`, dueAtConfirm: (datum: string) => `Wiedervorlage auf ${datum} setzen?`,
dueAtClearConfirm: 'Wiedervorlage entfernen?', dueAtClearConfirm: 'Wiedervorlage entfernen?',
statusChangeHint: 'Welcher Zustand von hier aus erreichbar ist, gibt der Lebenszyklus vor.', 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<string, string>,
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', outcome: 'Ergebnis',
createdBy: 'Angelegt von', createdBy: 'Angelegt von',
createdAt: 'Angelegt am', createdAt: 'Angelegt am',
+169
View File
@@ -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<Permission>(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)
})
})