Files
AfiS-Aktensystem/web/app/(app)/personalakten/[id]/PersonnelHead.tsx
T
Harlekin e0b03ea336
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
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

312 lines
8.7 KiB
TypeScript

'use client'
import { useActionState, useState, useTransition } from 'react'
import { caseText } from '@/lib/i18n/cases'
import { clearanceText, commonText } from '@/lib/i18n/common'
import { personnelText } from '@/lib/i18n/people'
import { StatusTags } from '@/components/StatusTags'
import {
reclassifyPersonnelAction,
updatePersonnelAction,
type ActionResult,
} from '../actions'
type File = {
id: string
displayName: string
rank: string | null
departmentId: string | null
department: { id: string; key: string; name: string } | null
statusTags: string[]
note: string | null
classification: number
userId: string | null
}
/**
* Kopfdaten einer Personalakte: lesen und ändern in derselben Ansicht.
*
* Die Klassifizierung steht bewusst getrennt. Sie ändert, wer die Akte
* überhaupt sieht, und eine Herabstufung braucht eine Begründung - das gehört
* nicht in dasselbe Formular wie eine Korrektur der Dienstnummer.
*/
export function PersonnelHead({
file,
canEdit,
levels,
statusTags,
departments,
}: {
file: File
canEdit: boolean
levels: { level: number; name: string }[]
statusTags: { key: string; name: string; colorToken: string }[]
/** Die Abteilungen zur Auswahl. Freitext gibt es hier nicht mehr: sonst wäre
jede Schreibweise eine eigene Abteilung, und genau das war der Grund für
das Modell. */
departments: { id: string; key: string; name: string }[]
}) {
const [editing, setEditing] = useState(false)
const [state, action, pending] = useActionState<ActionResult | null, FormData>(
updatePersonnelAction,
null,
)
if (!editing) {
return (
<div>
<dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-2">
<Zeile label={personnelText.rank} value={file.rank} />
<Zeile label={personnelText.department} value={file.department?.name ?? null} />
<Zeile
label={personnelText.linkedAccount}
value={file.userId ? file.userId : personnelText.noAccount}
mono={Boolean(file.userId)}
/>
</dl>
<div className="mt-3 flex flex-wrap items-baseline gap-3">
<span className="font-condensed text-xs text-text-dim">{personnelText.status}</span>
{file.statusTags.length > 0 ? (
<StatusTags tags={file.statusTags} known={statusTags} />
) : (
<span className="text-sm text-text-dim">{personnelText.statusNone}</span>
)}
</div>
{file.note ? (
<p className="mt-4 max-w-(--container-measure) border-l-(length:--spacing-edge) border-line pl-3 text-sm whitespace-pre-wrap">
{file.note}
</p>
) : null}
{canEdit ? (
<div className="mt-4 flex flex-wrap items-center gap-4">
<button
type="button"
onClick={() => setEditing(true)}
className="btn btn-sm btn-primary"
>
{personnelText.edit}
</button>
<Reclassify id={file.id} current={file.classification} levels={levels} />
</div>
) : null}
</div>
)
}
return (
<form
action={action}
onSubmit={() => setEditing(false)}
className="max-w-(--container-measure) space-y-4"
>
<input type="hidden" name="id" value={file.id} />
<Feld id="displayName" label={personnelText.name} defaultValue={file.displayName} required />
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="rank" label={personnelText.rank} defaultValue={file.rank} />
<label className="block">
<span className="field-label">
{personnelText.department}
</span>
<select
name="departmentId"
defaultValue={file.departmentId ?? ''}
className="field w-full"
>
<option value="">{personnelText.noDepartment}</option>
{departments.map((abteilung) => (
<option key={abteilung.id} value={abteilung.id}>
{abteilung.name}
</option>
))}
</select>
</label>
</div>
<fieldset>
<legend className="font-condensed text-2xs text-text-dim">{personnelText.status}</legend>
<div className="mt-2 flex flex-wrap gap-x-6 gap-y-2">
{statusTags.map((tag) => (
<label key={tag.key} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="statusTags"
value={tag.key}
defaultChecked={file.statusTags.includes(tag.key)}
/>
{tag.name}
</label>
))}
</div>
<p className="field-hint">{personnelText.statusHint}</p>
</fieldset>
<div>
<label htmlFor="note" className="field-label">
{personnelText.note}
</label>
<textarea
id="note"
name="note"
rows={3}
defaultValue={file.note ?? ''}
className="mt-1 block w-full field"
/>
</div>
{state && !state.ok ? (
<p className="font-condensed text-2xs text-stamp-4">{state.error}</p>
) : null}
<div className="flex gap-3">
<button
type="submit"
disabled={pending}
className="btn btn-sm btn-primary"
>
{pending ? commonText.loading : commonText.save}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="btn btn-sm"
>
{commonText.cancel}
</button>
</div>
</form>
)
}
function Reclassify({
id,
current,
levels,
}: {
id: string
current: number
levels: { level: number; name: string }[]
}) {
const [open, setOpen] = useState(false)
const [to, setTo] = useState(current)
const [reason, setReason] = useState('')
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
const lowering = to < current
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="btn btn-sm"
>
{personnelText.reclassify}
</button>
)
}
return (
<div className="w-full border border-line p-3">
<div className="flex flex-wrap items-center gap-3">
<select
value={to}
onChange={(event) => setTo(Number(event.target.value))}
aria-label={clearanceText.classification}
className="field text-2xs"
>
{levels.map((level) => (
<option key={level.level} value={level.level}>
{level.level} · {level.name}
</option>
))}
</select>
<button
type="button"
disabled={pending || to === current}
onClick={() =>
startTransition(async () => {
const result = await reclassifyPersonnelAction({ id, to, reason })
if (result.ok) setOpen(false)
else setError(result.error)
})
}
className="btn btn-sm btn-primary"
>
{commonText.save}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="btn btn-sm"
>
{commonText.cancel}
</button>
</div>
{/* Nur die Herabstufung braucht eine Begründung: sie macht die Akte für
Leute sichtbar, die sie vorher nicht sehen durften. */}
{lowering ? (
<textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={2}
placeholder={caseText.reclassifyReason}
aria-label={caseText.reclassifyReason}
className="mt-2 block w-full field text-2xs"
/>
) : null}
{error ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{error}</p> : null}
</div>
)
}
function Zeile({
label,
value,
mono,
}: {
label: string
value: string | null
mono?: boolean
}) {
return (
<div className="flex gap-3">
<dt className="font-condensed text-xs text-text-dim">{label}</dt>
<dd className={mono ? 'font-mono text-xs' : ''}>{value || '-'}</dd>
</div>
)
}
function Feld({
id,
label,
defaultValue,
required,
}: {
id: string
label: string
defaultValue?: string | null
required?: boolean
}) {
return (
<div>
<label htmlFor={id} className="field-label">
{label}
</label>
<input
id={id}
name={id}
required={required}
minLength={required ? 2 : undefined}
maxLength={120}
defaultValue={defaultValue ?? ''}
className="mt-1 block w-full field"
/>
</div>
)
}