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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
178 lines
6.2 KiB
TypeScript
178 lines
6.2 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useTransition } from 'react'
|
|
import { caseText, entryText } from '@/lib/i18n/cases'
|
|
import { actionErrorsText, commonText } from '@/lib/i18n/common'
|
|
import { changeStatusAction, reclassifyAction, setDueDateAction } from '../actions'
|
|
|
|
/**
|
|
* 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,
|
|
statusTargets,
|
|
levels,
|
|
maxLevel,
|
|
dueAt,
|
|
}: {
|
|
caseNumber: string
|
|
classification: number
|
|
statusTargets: string[]
|
|
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)
|
|
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">
|
|
{statusTargets.length > 0 ? (
|
|
<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>
|
|
<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>
|
|
)
|
|
}
|