Files
Harlekin bd750f26d0
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
Statuswechsel als Karte statt als wandernde Auswahlliste
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.
2026-09-04 16:26:29 +02:00

157 lines
5.0 KiB
TypeScript

import type { EffectivePermissions, Permission } from '@/lib/auth/permissions'
import { canEditCase } from '@/lib/access/caseWrite'
/**
* Lebenszyklus einer Akte, siehe docs/05-akten-und-vorlagen.md.
*
* DRAFT ──► OPEN ──► REVIEW ──► CLOSED ──► ARCHIVED
* │ ▲ │
* ▼ │ └──► OPEN (Rückgabe zur Nachbesserung)
* DORMANT
*
* SEALED ist aus jedem Zustand erreichbar und nur mit Recht 'admin' oder
* Stufe 5 lesbar.
*/
export type CaseStatusName =
| 'DRAFT'
| 'OPEN'
| 'DORMANT'
| 'REVIEW'
| 'CLOSED'
| 'ARCHIVED'
| 'SEALED'
export type Transition = {
from: CaseStatusName
to: CaseStatusName
permission: Permission
/** Verlangt eine Begründung, die als Vermerk im Verlauf landet. */
requiresReason?: boolean
/** Verlangt, dass alle `requiredToClose`-Felder gefüllt sind. */
requiresCompleteSections?: boolean
}
export const TRANSITIONS: readonly Transition[] = [
{ from: 'DRAFT', to: 'OPEN', permission: 'case.edit' },
{ from: 'OPEN', to: 'DORMANT', permission: 'case.edit' },
{ from: 'DORMANT', to: 'OPEN', permission: 'case.edit' },
{ from: 'OPEN', to: 'REVIEW', permission: 'case.edit' },
{ from: 'REVIEW', to: 'OPEN', permission: 'case.edit' },
{ from: 'REVIEW', to: 'CLOSED', permission: 'case.close', requiresCompleteSections: true },
{ from: 'CLOSED', to: 'OPEN', permission: 'case.reopen', requiresReason: true },
{ from: 'CLOSED', to: 'ARCHIVED', permission: 'case.close' },
]
/** SEALED erreicht man aus jedem Zustand, aber nur mit dem Recht 'admin'. */
export const SEAL_PERMISSION: Permission = 'admin'
export function findTransition(
from: CaseStatusName,
to: CaseStatusName,
): Transition | undefined {
if (to === 'SEALED' && from !== 'SEALED') {
return { from, to, permission: SEAL_PERMISSION, requiresReason: true }
}
return TRANSITIONS.find((t) => t.from === from && t.to === to)
}
export function allowedTargets(from: CaseStatusName): CaseStatusName[] {
const targets = TRANSITIONS.filter((t) => t.from === from).map((t) => t.to)
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,
}
})
}