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.
134 lines
4.4 KiB
TypeScript
134 lines
4.4 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useTransition } from 'react'
|
|
import { caseText } from '@/lib/i18n/cases'
|
|
import { actionErrorsText, commonText } from '@/lib/i18n/common'
|
|
import { reclassifyAction, setDueDateAction } from '../actions'
|
|
import { StatusMap } from '@/components/cases/StatusMap'
|
|
import type { StatusOption } from '@/lib/cases/lifecycle'
|
|
|
|
/**
|
|
* Statuswechsel und Umklassifizierung.
|
|
*
|
|
* Bewusst **nicht** optimistisch: beides ist folgenreich und teils nicht
|
|
* umkehrbar. Stattdessen ein sofortiger Wartezustand am Knopf und eine klare
|
|
* Rückmeldung. Siehe docs/09-performance-und-ux.md.
|
|
*/
|
|
export function CaseActions({
|
|
caseNumber,
|
|
classification,
|
|
status,
|
|
statusOptions,
|
|
levels,
|
|
maxLevel,
|
|
dueAt,
|
|
}: {
|
|
caseNumber: string
|
|
classification: number
|
|
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 [nextLevel, setNextLevel] = useState(classification)
|
|
const [levelReason, setLevelReason] = useState('')
|
|
const [frist, setFrist] = useState(dueAt)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [pending, startTransition] = useTransition()
|
|
|
|
const lowering = nextLevel < classification
|
|
|
|
function run(action: () => Promise<{ ok: boolean; error?: string }>, confirmText: string) {
|
|
if (!confirm(confirmText)) return
|
|
setError(null)
|
|
startTransition(async () => {
|
|
const result = await action()
|
|
if (!result.ok) setError(result.error ?? actionErrorsText.default!)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="mt-6 flex flex-wrap gap-x-10 gap-y-5 border-t border-line pt-5 text-xs">
|
|
<StatusMap caseNumber={caseNumber} current={status} options={statusOptions} />
|
|
|
|
<div>
|
|
<p className="font-condensed text-text-dim">{caseText.dueAt}</p>
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
|
<input
|
|
type="date"
|
|
value={frist}
|
|
onChange={(event) => setFrist(event.target.value)}
|
|
className="field font-mono"
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={pending || frist === dueAt}
|
|
onClick={() =>
|
|
run(
|
|
() => setDueDateAction({ caseNumber, dueAt: frist || undefined }),
|
|
frist ? caseText.dueAtConfirm(frist) : caseText.dueAtClearConfirm,
|
|
)
|
|
}
|
|
className="btn btn-primary"
|
|
>
|
|
{pending ? caseText.saving : commonText.save}
|
|
</button>
|
|
</div>
|
|
<p className="mt-1.5 max-w-sm text-2xs text-text-dim">{caseText.dueAtHint}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="font-condensed text-text-dim">{caseText.reclassify}</p>
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
|
<select
|
|
value={nextLevel}
|
|
onChange={(event) => setNextLevel(Number(event.target.value))}
|
|
className="field"
|
|
>
|
|
{levels
|
|
.filter((level) => level.level <= maxLevel)
|
|
.map((level) => (
|
|
<option key={level.level} value={level.level}>
|
|
{level.level} · {level.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{/* Eine Herabstufung macht Inhalte für Leute sichtbar, die sie vorher
|
|
nicht sehen durften. Deshalb ist die Begründung Pflicht. */}
|
|
{lowering ? (
|
|
<input
|
|
value={levelReason}
|
|
onChange={(event) => setLevelReason(event.target.value)}
|
|
placeholder={caseText.reclassifyReason}
|
|
className="w-64 field"
|
|
/>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
disabled={pending || nextLevel === classification}
|
|
onClick={() =>
|
|
run(
|
|
() =>
|
|
reclassifyAction({
|
|
caseNumber,
|
|
to: nextLevel,
|
|
reason: levelReason || undefined,
|
|
}),
|
|
`${caseText.reclassify}: ${nextLevel}?`,
|
|
)
|
|
}
|
|
className="btn btn-primary"
|
|
>
|
|
{pending ? caseText.saving : commonText.save}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? <p className="w-full font-condensed text-2xs text-stamp-4">{error}</p> : null}
|
|
</div>
|
|
)
|
|
}
|