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.
312 lines
8.7 KiB
TypeScript
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>
|
|
)
|
|
}
|