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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user