Personalakte als Aktenblatt, Status als Etiketten, Verknüpfen von beiden Seiten

Das Formular der Personalabteilung ist übernommen, nicht durch eine
Bildschirmansicht ersetzt: schwarzer Rahmen, Kopf mit Kennung und
Klassifizierungsbalken, Raster aus fünf Spalten, Unterschriftenzeilen,
Fußzeile. Es steht bewusst außerhalb des Design-Systems — es wird gedruckt und
muss auf Papier aussehen wie das Blatt daneben.

Datengetrieben bleibt es trotzdem. Kein Feld steht im Programm: sheetTitle und
sheetSubtitle an der Vorlage, span für die Zellbreite, rows für das Lichtbild,
dateKey für das Datum unter einer Unterschrift, slot für den
Klassifizierungsbalken. Eine weitere Dokumentart mit layout = DOSSIER braucht
keine Zeile Code.

Der Balken zeigt die Foundation-Stufe aus der neuen Tabelle
FoundationClearance. Sie ist Inhalt, kein Zugriff — was jemand sehen darf,
entscheidet weiterhin allein die interne Stufe. Ohne Auswahl bleibt der Balken
neutral, statt eine Einstufung zu behaupten.

Die Dienstnummer ist entfallen, es gibt sie nicht. Der Status ist zu mehreren
Etiketten geworden: "Gekündigt" und "Verhaftet" schließen einander nicht aus.
Unbekannte Schlüssel werden verworfen und die Reihenfolge kommt aus der
Konfiguration, nicht aus der Eingabe.

PersonnelAssignment hängt AfiS-Mitglieder an eine Person — Aktenführung,
Ermittler, Ansprechpartner — und ist zugleich der ausnahmsweise Zugang, wie
CaseParticipant bei der Fallakte. Betroffen zu sein gibt weiterhin keinen
Zugang: das ist eine Eigenschaft der Person, die Zuordnung eine Entscheidung
der Abteilung.

Fall und Person lassen sich von beiden Seiten verknüpfen und schon beim
Anlegen einer Fallakte. Alle drei Wege laufen in addParticipant zusammen.

Noch offen: das Lichtbild. Feldtyp und Platz stehen, hochladen kommt mit der
Prüfkette in Phase 5 — ein Bild als Data-URL im Abschnittswert verstiesse
gegen harte Regel 4.

102 Tests im Web-Paket, davon sechs auf dem gerenderten Blatt selbst.
This commit is contained in:
Harlekin
2026-09-02 19:56:28 +02:00
parent db438ac57e
commit a278c44e78
31 changed files with 2202 additions and 78 deletions
@@ -15,6 +15,8 @@ import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { SkeletonLine } from '@/components/Skeleton'
import { Sections } from '@/components/sections/Sections'
import { Sheet } from '@/components/documents/Sheet'
import { SheetSwitch } from '@/components/documents/SheetSwitch'
import { saveDocumentSectionAction } from '../actions'
import { DocumentActions } from './DocumentActions'
import type { EffectivePermissions } from '@/lib/auth/permissions'
@@ -35,12 +37,19 @@ async function Body({
permissions,
canEdit,
}: {
document: { id: string; templateId: string; documentNumber: string }
document: {
id: string
templateId: string
documentNumber: string
title: string
layout: string
createdAt: Date
}
permissions: EffectivePermissions
canEdit: boolean
}) {
const sections = await loadDocumentSections(permissions, document)
return (
const editor = (
<Sections
scope={`dokument:${document.documentNumber}`}
save={saveDocumentSectionAction.bind(null, document.documentNumber)}
@@ -48,6 +57,36 @@ async function Body({
canEdit={canEdit}
/>
)
if (document.layout !== 'DOSSIER') return editor
// Das Aktenblatt braucht die Kopfzeilen der Vorlage und die
// Foundation-Stufen für den Balken. Beide steuern keinen Zugriff.
const [template, foundationLevels] = await Promise.all([
db.documentTemplate.findUnique({
where: { id: document.templateId },
select: { sheetTitle: true, sheetSubtitle: true, name: true },
}),
db.foundationClearance.findMany({ orderBy: { sortOrder: 'asc' } }),
])
return (
<SheetSwitch
canEdit={canEdit}
editor={editor}
sheet={
<Sheet
sections={sections}
title={template?.sheetTitle ?? de.app.name}
subtitle={template?.sheetSubtitle ?? template?.name ?? document.title}
documentNumber={document.documentNumber}
issuedAt={document.createdAt}
foundationLevels={foundationLevels}
footerNote={de.document.footerNote}
/>
}
/>
)
}
/**
+13
View File
@@ -25,6 +25,8 @@ const createSchema = z.object({
title: z.string().trim().min(3).max(200),
classification: z.coerce.number().int().min(0).max(5),
templateKey: z.string().min(1),
/** Betroffene Person, gleich beim Anlegen verknüpft. Optional. */
personnelFileId: z.string().max(64).optional(),
})
export type CreateState = { error?: string }
@@ -43,6 +45,7 @@ export async function createCaseAction(
title: formData.get('title'),
classification: formData.get('classification'),
templateKey: formData.get('templateKey'),
personnelFileId: formData.get('personnelFileId') || undefined,
})
if (!parsed.success) return { error: de.actionErrors.default! }
@@ -50,6 +53,16 @@ export async function createCaseAction(
try {
const created = await createCase(user.permissions, parsed.data)
caseNumber = created.caseNumber
// Die betroffene Person gleich mitverknüpfen, damit sie nicht in einem
// zweiten Schritt vergessen wird. Scheitert das, steht die Akte trotzdem.
if (parsed.data.personnelFileId) {
await addParticipant(user.permissions, {
caseNumber,
role: 'SUBJECT',
personnelFileId: parsed.data.personnelFileId,
})
}
} catch (error) {
return { error: toMessage(error) }
}
+25
View File
@@ -7,10 +7,12 @@ import { createCaseAction, type CreateState } from '../actions'
export function NewCaseForm({
templates,
levels,
people,
defaultLevel,
}: {
templates: { key: string; name: string }[]
levels: { level: number; name: string }[]
people: { id: string; displayName: string }[]
defaultLevel: number
}) {
const [state, action, pending] = useActionState<CreateState, FormData>(createCaseAction, {})
@@ -68,6 +70,29 @@ export function NewCaseForm({
</select>
</div>
{people.length > 0 ? (
<div>
<label htmlFor="personnelFileId" className="font-condensed text-xs text-text-dim">
{de.case.subject}
</label>
{/* Gleich beim Anlegen verknüpfen erspart den zweiten Schritt, den
man sonst vergisst. Nachträglich geht es genauso. */}
<select
id="personnelFileId"
name="personnelFileId"
defaultValue=""
className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
>
<option value="">{de.case.subjectNone}</option>
{people.map((person) => (
<option key={person.id} value={person.id}>
{person.displayName}
</option>
))}
</select>
</div>
) : null}
<div className="flex items-center gap-3">
<button
type="submit"
+13 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { db } from '@/lib/db'
import { requirePermission } from '@/lib/auth/current'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { de } from '@/lib/i18n/de'
import { NewCaseForm } from './NewCaseForm'
@@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic'
export default async function NewCasePage() {
const user = await requirePermission('case.create')
const [templates, levels] = await Promise.all([
const [templates, levels, people] = await Promise.all([
db.documentTemplate.findMany({
// Nur Fallarten. Eine Vorlage für Dokumente legt keine Akte an.
where: { isActive: true, uses: { has: 'CASE' } },
@@ -19,6 +20,16 @@ export default async function NewCasePage() {
select: { key: true, name: true },
}),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
// Nur Personalakten, die der Anlegende sehen darf, und nur mit dem Recht
// dazu: sonst verriete die Auswahlliste ihre Existenz.
user.permissions.isAdmin || user.permissions.permissions.has('personnel.read')
? db.personnelFile.findMany({
where: visiblePersonnelWhere(user.permissions),
orderBy: { displayName: 'asc' },
take: 200,
select: { id: true, displayName: true },
})
: Promise.resolve([]),
])
return (
@@ -27,6 +38,7 @@ export default async function NewCasePage() {
<NewCaseForm
templates={templates}
levels={levels.filter((level) => level.level <= user.permissions.clearanceLevel)}
people={people}
defaultLevel={Math.min(1, user.permissions.clearanceLevel)}
/>
</main>
@@ -0,0 +1,149 @@
'use client'
import { useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { ASSIGNMENT_ROLES } from '@/lib/personnel/terms'
import { addAssignmentAction, removeAssignmentAction } from '../actions'
type Zuordnung = {
id: string
role: string
user: { id: string; displayName: string }
}
/**
* Mitglieder der Abteilung, die an dieser Personalakte hängen.
*
* Wie die Beteiligten einer Fallakte ist das zugleich der Weg zum
* ausnahmsweisen Zugang — der Hinweis steht deshalb direkt am Formular.
*/
export function Assignments({
personnelFileId,
assignments,
members,
canEdit,
}: {
personnelFileId: string
assignments: Zuordnung[]
members: { id: string; displayName: string }[]
canEdit: boolean
}) {
const [open, setOpen] = useState(false)
const [role, setRole] = useState<string>('HANDLER')
const [userId, setUserId] = useState('')
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
return (
<div>
{assignments.length === 0 ? (
<p className="text-text-dim">{de.assignment.empty}</p>
) : (
<ul className="border-t border-line">
{assignments.map((zuordnung) => (
<li
key={zuordnung.id}
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line py-2"
>
<span className="font-condensed text-2xs text-text-dim">
{de.assignment.roles[zuordnung.role] ?? zuordnung.role}
</span>
<span className="grow text-sm">{zuordnung.user.displayName}</span>
{canEdit ? (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await removeAssignmentAction({
personnelFileId,
assignmentId: zuordnung.id,
})
if (!result.ok) setError(result.error)
})
}
className="font-condensed text-2xs text-text-dim"
>
{de.assignment.remove}
</button>
) : null}
</li>
))}
</ul>
)}
{error ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{error}</p> : null}
{canEdit ? (
open ? (
<div className="mt-4 border border-line p-3">
<p className="max-w-(--container-measure) text-2xs text-text-dim">
{de.assignment.hint}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<select
value={role}
onChange={(event) => setRole(event.target.value)}
aria-label={de.participant.role}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
{ASSIGNMENT_ROLES.map((value) => (
<option key={value} value={value}>
{de.assignment.roles[value] ?? value}
</option>
))}
</select>
<select
value={userId}
onChange={(event) => setUserId(event.target.value)}
aria-label={de.participant.pickUser}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
<option value="">—</option>
{members.map((member) => (
<option key={member.id} value={member.id}>
{member.displayName}
</option>
))}
</select>
<button
type="button"
disabled={pending || !userId}
onClick={() =>
startTransition(async () => {
setError(null)
const result = await addAssignmentAction({ personnelFileId, userId, role })
if (result.ok) {
setUserId('')
setOpen(false)
} else {
setError(result.error)
}
})
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{de.assignment.add}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{de.common.cancel}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setOpen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{de.assignment.add}
</button>
)
) : null}
</div>
)
}
@@ -0,0 +1,94 @@
'use client'
import { useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { PARTICIPANT_ROLES } from '@/lib/cases/terms'
import { linkCaseToPersonAction } from '../actions'
/**
* Einen Fall von der Personalakte aus verknüpfen.
*
* Derselbe Vorgang wie „Beteiligte eintragen" auf der Fallakte, nur von der
* anderen Seite gedacht: dort sucht man die Person, hier den Fall. Deshalb ein
* Eingabefeld für das Aktenzeichen und keine Auswahlliste — eine Liste aller
* sichtbaren Fälle wäre auf einer Personalakte am falschen Ort.
*/
export function LinkCase({ personnelFileId }: { personnelFileId: string }) {
const [open, setOpen] = useState(false)
const [caseNumber, setCaseNumber] = useState('')
const [role, setRole] = useState<string>('SUBJECT')
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{de.personnel.linkCase}
</button>
)
}
return (
<div className="mt-4 border border-line p-3">
<p className="max-w-(--container-measure) text-2xs text-text-dim">
{de.personnel.linkCaseHint}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<select
value={role}
onChange={(event) => setRole(event.target.value)}
aria-label={de.participant.role}
className="border border-line bg-raised px-2 py-1 text-2xs"
>
{PARTICIPANT_ROLES.map((value) => (
<option key={value} value={value}>
{de.participant.roles[value] ?? value}
</option>
))}
</select>
<input
value={caseNumber}
onChange={(event) => setCaseNumber(event.target.value)}
placeholder={de.link.target}
aria-label={de.link.target}
className="border border-line bg-raised px-2 py-1 font-mono text-2xs"
/>
<button
type="button"
disabled={pending || caseNumber.trim() === ''}
onClick={() =>
startTransition(async () => {
setError(null)
const result = await linkCaseToPersonAction({
personnelFileId,
caseNumber,
role,
})
if (result.ok) {
setCaseNumber('')
setOpen(false)
} else {
setError(result.error)
}
})
}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{de.personnel.linkCase}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
>
{de.common.cancel}
</button>
</div>
{error ? <p className="mt-2 font-condensed text-2xs text-stamp-4">{error}</p> : null}
</div>
)
}
@@ -2,6 +2,7 @@
import { useActionState, useState, useTransition } from 'react'
import { de } from '@/lib/i18n/de'
import { StatusTags } from '@/components/StatusTags'
import {
reclassifyPersonnelAction,
updatePersonnelAction,
@@ -11,10 +12,9 @@ import {
type File = {
id: string
displayName: string
serviceNumber: string | null
rank: string | null
department: string | null
status: string | null
statusTags: string[]
note: string | null
classification: number
userId: string | null
@@ -31,10 +31,12 @@ export function PersonnelHead({
file,
canEdit,
levels,
statusTags,
}: {
file: File
canEdit: boolean
levels: { level: number; name: string }[]
statusTags: { key: string; name: string; colorToken: string }[]
}) {
const [editing, setEditing] = useState(false)
const [state, action, pending] = useActionState<ActionResult | null, FormData>(
@@ -46,10 +48,8 @@ export function PersonnelHead({
return (
<div>
<dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-2">
<Zeile label={de.personnel.serviceNumber} value={file.serviceNumber} mono />
<Zeile label={de.personnel.rank} value={file.rank} />
<Zeile label={de.personnel.department} value={file.department} />
<Zeile label={de.personnel.status} value={file.status} />
<Zeile
label={de.personnel.linkedAccount}
value={file.userId ? file.userId : de.personnel.noAccount}
@@ -57,6 +57,15 @@ export function PersonnelHead({
/>
</dl>
<div className="mt-3 flex flex-wrap items-baseline gap-3">
<span className="font-condensed text-xs text-text-dim">{de.personnel.status}</span>
{file.statusTags.length > 0 ? (
<StatusTags tags={file.statusTags} known={statusTags} />
) : (
<span className="text-sm text-text-dim">{de.personnel.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}
@@ -88,15 +97,27 @@ export function PersonnelHead({
<input type="hidden" name="id" value={file.id} />
<Feld id="displayName" label={de.personnel.name} defaultValue={file.displayName} required />
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="serviceNumber"
label={de.personnel.serviceNumber}
defaultValue={file.serviceNumber}
/>
<Feld id="rank" label={de.personnel.rank} defaultValue={file.rank} />
<Feld id="department" label={de.personnel.department} defaultValue={file.department} />
<Feld id="status" label={de.personnel.status} defaultValue={file.status} />
</div>
<fieldset>
<legend className="font-condensed text-2xs text-text-dim">{de.personnel.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="mt-1 text-2xs text-text-dim">{de.personnel.statusHint}</p>
</fieldset>
<div>
<label htmlFor="note" className="font-condensed text-2xs text-text-dim">
{de.personnel.note}
+67 -4
View File
@@ -5,7 +5,12 @@ import { notFound } from 'next/navigation'
import { db } from '@/lib/db'
import { requirePermission } from '@/lib/auth/current'
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
import { loadBacklinks, loadVisiblePersonnelFile } from '@/lib/personnel/service'
import {
listAssignments,
listStatusTags,
loadBacklinks,
loadVisiblePersonnelFile,
} from '@/lib/personnel/service'
import { listDocuments, listTemplatesFor } from '@/lib/documents/service'
import { canWritePersonnel } from '@/lib/access/documentWrite'
import { de } from '@/lib/i18n/de'
@@ -14,6 +19,8 @@ import { SkeletonLine } from '@/components/Skeleton'
import { DocumentList } from '@/components/documents/DocumentList'
import { NewDocument } from '@/components/documents/NewDocument'
import { PersonnelHead } from './PersonnelHead'
import { Assignments } from './Assignments'
import { LinkCase } from './LinkCase'
import type { EffectivePermissions } from '@/lib/auth/permissions'
export const dynamic = 'force-dynamic'
@@ -39,14 +46,21 @@ export async function generateMetadata({
async function Backlinks({
file,
permissions,
canLink,
}: {
file: { id: string; userId: string | null }
permissions: EffectivePermissions
canLink: boolean
}) {
const { total, byRole } = await loadBacklinks(permissions, file)
if (total === 0) {
return <p className="text-text-dim">{de.personnel.backlinksEmpty}</p>
return (
<div>
<p className="text-text-dim">{de.personnel.backlinksEmpty}</p>
{canLink ? <LinkCase personnelFileId={file.id} /> : null}
</div>
)
}
return (
@@ -76,6 +90,7 @@ async function Backlinks({
</ul>
</div>
))}
{canLink ? <LinkCase personnelFileId={file.id} /> : null}
</div>
)
}
@@ -113,6 +128,37 @@ async function Documents({
)
}
async function AssignmentBlock({
fileId,
permissions,
canEdit,
}: {
fileId: string
permissions: EffectivePermissions
canEdit: boolean
}) {
const [assignments, members] = await Promise.all([
listAssignments(permissions, fileId),
canEdit
? db.user.findMany({
where: { isActive: true },
orderBy: { displayName: 'asc' },
take: 200,
select: { id: true, displayName: true },
})
: Promise.resolve([]),
])
return (
<Assignments
personnelFileId={fileId}
assignments={assignments}
members={members}
canEdit={canEdit}
/>
)
}
export default async function PersonnelPage({ params }: { params: Promise<{ id: string }> }) {
const user = await requirePermission('personnel.read')
const { id } = await params
@@ -133,7 +179,10 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id:
})
}
const levels = await db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } })
const [levels, statusTags] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
listStatusTags(),
])
const level = levels.find((l) => l.level === akte.classification)
const canEdit = canWritePersonnel(user.permissions, akte.classification).ok
@@ -154,10 +203,24 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id:
file={akte}
canEdit={canEdit}
levels={levels.filter((l) => l.level <= user.permissions.clearanceLevel)}
statusTags={statusTags}
/>
</div>
</section>
<section className="mt-10">
<h2 className="font-condensed">{de.assignment.heading}</h2>
<div className="mt-4">
<Suspense fallback={<SkeletonLine width="50%" />}>
<AssignmentBlock
fileId={akte.id}
permissions={user.permissions}
canEdit={canEdit}
/>
</Suspense>
</div>
</section>
<section className="mt-10">
<h2 className="font-condensed">{de.document.heading}</h2>
<div className="mt-4">
@@ -179,7 +242,7 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id:
</div>
}
>
<Backlinks file={akte} permissions={user.permissions} />
<Backlinks file={akte} permissions={user.permissions} canLink={canEdit} />
</Suspense>
</div>
</section>
+150 -4
View File
@@ -6,10 +6,17 @@ import { z } from 'zod'
import { requireUser } from '@/lib/auth/current'
import { AccessError } from '@/lib/errors'
import {
addAssignment,
createPersonnelFile,
reclassifyPersonnelFile,
removeAssignment,
updatePersonnelFile,
} from '@/lib/personnel/service'
import { createDocument, saveDocumentSection } from '@/lib/documents/service'
import { addParticipant } from '@/lib/cases/participants'
import { ASSIGNMENT_ROLES } from '@/lib/personnel/terms'
import { PARTICIPANT_ROLES } from '@/lib/cases/terms'
import { db } from '@/lib/db'
import { de } from '@/lib/i18n/de'
export type ActionResult = { ok: true } | { ok: false; error: string }
@@ -24,13 +31,23 @@ function toMessage(error: unknown): string {
const kopfdaten = {
displayName: z.string().min(2).max(120),
serviceNumber: z.string().max(60).optional(),
rank: z.string().max(60).optional(),
department: z.string().max(60).optional(),
status: z.string().max(60).optional(),
note: z.string().max(2000).optional(),
}
/**
* Mehrfachauswahl aus einem Formular: der Browser schickt denselben Namen
* mehrfach, `Object.fromEntries` behält davon nur den letzten. Deshalb werden
* die Etiketten getrennt eingesammelt.
*/
function statusAusFormular(formData: FormData): string[] {
return formData
.getAll('statusTags')
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.slice(0, 20)
}
const createSchema = z.object({
...kopfdaten,
userId: z.string().max(64).optional(),
@@ -47,8 +64,16 @@ export async function createPersonnelAction(
let id: string
try {
const created = await createPersonnelFile(user.permissions, parsed.data)
const created = await createPersonnelFile(user.permissions, {
...parsed.data,
statusTags: statusAusFormular(formData),
})
id = created.id
// Das Aktenblatt ist der erste Eintrag zu einer Person und entsteht
// deshalb gleich mit — als Entwurf, mit dem Namen schon eingetragen.
// Fehlt das Recht dafür, bleibt es bei der Akte allein.
await erstesAktenblatt(user.permissions, id, parsed.data.displayName)
} catch (error) {
return { ok: false, error: toMessage(error) }
}
@@ -69,7 +94,10 @@ export async function updatePersonnelAction(
try {
const { id, ...rest } = parsed.data
await updatePersonnelFile(user.permissions, id, rest)
await updatePersonnelFile(user.permissions, id, {
...rest,
statusTags: statusAusFormular(formData),
})
revalidatePath(`/personalakten/${id}`)
return { ok: true }
} catch (error) {
@@ -101,3 +129,121 @@ export async function reclassifyPersonnelAction(input: unknown): Promise<ActionR
return { ok: false, error: toMessage(error) }
}
}
/**
* Legt das Aktenblatt zu einer frisch angelegten Person an und trägt den Namen
* ein, soweit er sich zerlegen lässt.
*
* Schlägt es fehl, weil das Recht `document.create` fehlt, bleibt die
* Personalakte trotzdem stehen: das Blatt lässt sich später nachholen, die
* Akte wäre sonst verloren.
*/
async function erstesAktenblatt(
permissions: Awaited<ReturnType<typeof requireUser>>['permissions'],
personnelFileId: string,
displayName: string,
): Promise<void> {
const vorlage = await db.documentTemplate.findFirst({
where: { key: 'personalakte', isActive: true, uses: { has: 'PERSON_DOCUMENT' } },
select: { key: true },
})
if (!vorlage) return
const teile = displayName.trim().split(/\s+/)
const nachname = teile.length > 1 ? teile[teile.length - 1]! : displayName.trim()
const vorname = teile.length > 1 ? teile.slice(0, -1).join(' ') : ''
try {
const doc = await createDocument(permissions, {
templateKey: vorlage.key,
title: displayName,
classification: 1,
owner: { kind: 'personnel', personnelFileId },
})
for (const [fieldKey, value] of [
['name', nachname],
['vorname', vorname],
] as const) {
if (!value) continue
await saveDocumentSection(permissions, {
documentNumber: doc.documentNumber,
fieldKey,
value,
baseVersion: 0,
})
}
} catch (error) {
if (error instanceof AccessError) return
throw error
}
}
const assignmentSchema = z.object({
personnelFileId: z.string().min(1),
userId: z.string().min(1),
role: z.enum(ASSIGNMENT_ROLES),
})
export async function addAssignmentAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = assignmentSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await addAssignment(user.permissions, parsed.data)
revalidatePath(`/personalakten/${parsed.data.personnelFileId}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const removeAssignmentSchema = z.object({
personnelFileId: z.string().min(1),
assignmentId: z.string().min(1),
})
export async function removeAssignmentAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = removeAssignmentSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await removeAssignment(user.permissions, parsed.data)
revalidatePath(`/personalakten/${parsed.data.personnelFileId}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
const linkCaseSchema = z.object({
personnelFileId: z.string().min(1),
caseNumber: z.string().min(1).max(60),
role: z.enum(PARTICIPANT_ROLES),
})
/**
* Einen Fall von der Personalakte aus verknüpfen.
*
* Es ist derselbe Vorgang wie „Beteiligte eintragen" auf der Fallakte, nur von
* der anderen Seite her gedacht: dort sucht man die Person, hier den Fall.
*/
export async function linkCaseToPersonAction(input: unknown): Promise<ActionResult> {
const user = await requireUser()
const parsed = linkCaseSchema.safeParse(input)
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
try {
await addParticipant(user.permissions, {
caseNumber: parsed.data.caseNumber.trim(),
role: parsed.data.role,
personnelFileId: parsed.data.personnelFileId,
})
revalidatePath(`/personalakten/${parsed.data.personnelFileId}`)
return { ok: true }
} catch (error) {
return { ok: false, error: toMessage(error) }
}
}
@@ -14,10 +14,12 @@ import { createPersonnelAction, type ActionResult } from '../actions'
export function NewPersonnelForm({
members,
levels,
statusTags,
defaultLevel,
}: {
members: { id: string; displayName: string }[]
levels: { level: number; name: string }[]
statusTags: { key: string; name: string }[]
defaultLevel: number
}) {
const [state, action, pending] = useActionState<ActionResult | null, FormData>(
@@ -49,12 +51,25 @@ export function NewPersonnelForm({
</div>
<div className="grid gap-5 sm:grid-cols-2">
<Feld id="serviceNumber" label={de.personnel.serviceNumber} />
<Feld id="rank" label={de.personnel.rank} />
<Feld id="department" label={de.personnel.department} />
<Feld id="status" label={de.personnel.status} />
</div>
<fieldset>
<legend className="font-condensed text-xs text-text-dim">{de.personnel.status}</legend>
{/* Kästchen statt Auswahlliste: mehrere Etiketten sind der Normalfall,
und eine Mehrfachauswahl mit Strg-Klick findet niemand. */}
<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} />
{tag.name}
</label>
))}
</div>
<p className="mt-1 text-2xs text-text-dim">{de.personnel.statusHint}</p>
</fieldset>
<div>
<label htmlFor="note" className="font-condensed text-xs text-text-dim">
{de.personnel.note}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { db } from '@/lib/db'
import { requirePermission } from '@/lib/auth/current'
import { listStatusTags } from '@/lib/personnel/service'
import { de } from '@/lib/i18n/de'
import { NewPersonnelForm } from './NewPersonnelForm'
@@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic'
export default async function NewPersonnelPage() {
const user = await requirePermission('personnel.edit')
const [levels, members] = await Promise.all([
const [levels, members, statusTags] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
// Nur Konten, für die es noch keine Akte gibt: zwei Akten zu einer Person
// wären zwei Wahrheiten.
@@ -20,6 +21,7 @@ export default async function NewPersonnelPage() {
take: 200,
select: { id: true, displayName: true },
}),
listStatusTags(),
])
return (
@@ -31,6 +33,7 @@ export default async function NewPersonnelPage() {
<NewPersonnelForm
members={members}
levels={levels.filter((level) => level.level <= user.permissions.clearanceLevel)}
statusTags={statusTags}
defaultLevel={Math.min(1, user.permissions.clearanceLevel)}
/>
</main>
+24 -8
View File
@@ -2,9 +2,10 @@ import type { Metadata } from 'next'
import Link from 'next/link'
import { db } from '@/lib/db'
import { requirePermission } from '@/lib/auth/current'
import { listPersonnelFiles } from '@/lib/personnel/service'
import { listPersonnelFiles, listStatusTags } from '@/lib/personnel/service'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { StatusTags } from '@/components/StatusTags'
export const metadata: Metadata = { title: de.personnel.listHeading }
export const dynamic = 'force-dynamic'
@@ -19,15 +20,16 @@ export const dynamic = 'force-dynamic'
export default async function PersonnelListPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
searchParams: Promise<{ q?: string; status?: string }>
}) {
// Ohne das Recht gibt es die Seite nicht — 404, nicht 403.
const user = await requirePermission('personnel.read')
const { q } = await searchParams
const { q, status } = await searchParams
const [{ files, total }, levels] = await Promise.all([
listPersonnelFiles(user.permissions, { query: q }),
const [{ files, total }, levels, statusTags] = await Promise.all([
listPersonnelFiles(user.permissions, { query: q, statusTag: status }),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
listStatusTags(),
])
const levelName = new Map(levels.map((l) => [l.level, l.name]))
const darfAnlegen =
@@ -49,6 +51,7 @@ export default async function PersonnelListPage({
aria-label={de.personnel.search}
className="w-full max-w-sm border border-line bg-raised px-3 py-2 text-sm"
/>
{status ? <input type="hidden" name="status" value={status} /> : null}
{darfAnlegen ? (
<Link href="/personalakten/neu" className="ml-auto font-condensed text-xs">
{de.personnel.create}
@@ -56,6 +59,21 @@ export default async function PersonnelListPage({
) : null}
</form>
<nav className="mt-4 flex flex-wrap gap-3 font-condensed text-xs">
<Link href={q ? `/personalakten?q=${encodeURIComponent(q)}` : '/personalakten'}>
{de.personnel.filterAll}
</Link>
{statusTags.map((tag) => (
<Link
key={tag.key}
href={`/personalakten?${new URLSearchParams({ ...(q ? { q } : {}), status: tag.key })}`}
className={tag.key === status ? 'text-accent' : undefined}
>
{tag.name}
</Link>
))}
</nav>
{files.length === 0 ? (
<p className="mt-10 text-text-dim">
{q ? de.personnel.emptySearch : de.personnel.empty}
@@ -71,12 +89,10 @@ export default async function PersonnelListPage({
style={classificationEdgeStyle(akte.classification)}
>
<span className="grow">{akte.displayName}</span>
{akte.serviceNumber ? (
<span className="font-mono text-xs text-text-dim">{akte.serviceNumber}</span>
) : null}
{akte.rank ? (
<span className="font-condensed text-2xs text-text-dim">{akte.rank}</span>
) : null}
<StatusTags tags={akte.statusTags} known={statusTags} />
<StampMark
level={akte.classification}
name={levelName.get(akte.classification) ?? de.common.unknown}
+215
View File
@@ -212,6 +212,221 @@
width: 1px;
background-color: var(--color-line);
}
/* ---------------------------------------------------------------------
Das Aktenblatt.
Bewusst **ausserhalb** des Design-Systems der Oberflaeche: schwarz auf
weiss, harte Rahmen, Massangaben in Millimetern. Es ist kein Bildschirm-
element, sondern ein Formular, das gedruckt und weitergereicht wird und
auf Papier genauso aussehen muss wie hier. Deshalb steht hier kein
einziger Farb-Token der Anwendung.
Nachgebaut nach dem Formular, das die Personalabteilung bislang von Hand
ausgefuellt hat. Siehe docs/13-entscheidungen.md.
--------------------------------------------------------------------- */
.sheet {
width: 210mm;
min-height: 297mm;
box-sizing: border-box;
margin: 0 auto;
padding: 10mm;
background: #ffffff;
color: #000000;
font-family: var(--font-sans, sans-serif);
}
.sheet-frame {
border: 4px solid #000;
min-height: calc(297mm - 20mm);
display: flex;
flex-direction: column;
}
.sheet-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 15px;
border-bottom: 4px solid #000;
}
.sheet-head h1 {
margin: 0 0 5px;
font-size: 28px;
font-weight: 900;
letter-spacing: 1px;
line-height: 1.1;
text-transform: uppercase;
color: #000;
}
.sheet-head h2 {
margin: 0;
font-size: 14px;
font-weight: 900;
letter-spacing: 2px;
text-transform: uppercase;
color: #000;
}
.sheet-mark {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
/* Rein dekorativ, deshalb aria-hidden im Markup. */
.sheet-barcode {
width: 140px;
height: 25px;
background: repeating-linear-gradient(
90deg,
#000,
#000 2px,
#fff 2px,
#fff 5px,
#000 5px,
#000 8px,
#fff 8px,
#fff 10px
);
}
.sheet-classbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 10px 15px;
border-bottom: 4px solid #000;
color: #fff;
font-size: 14px;
font-weight: 900;
text-transform: uppercase;
}
.sheet-grid {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.sheet-grid td {
border: 2px solid #000;
padding: 10px;
vertical-align: top;
word-wrap: break-word;
overflow-wrap: anywhere;
}
.sheet-label {
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
margin-bottom: 4px;
color: #000;
}
.sheet-value {
font-size: 14px;
font-weight: 700;
min-height: 20px;
color: #000;
}
.sheet-value-emphasis {
color: #7f1d1d;
}
.sheet-photo {
width: 120px;
height: 160px;
margin: 0 auto;
border: 2px solid #000;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 10px;
font-weight: 700;
color: #555;
position: relative;
overflow: hidden;
}
.sheet-photo img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.sheet-signature {
border-bottom: 2px solid #000;
min-height: 30px;
display: flex;
align-items: flex-end;
font-family: 'Times New Roman', Times, serif;
font-style: italic;
font-size: 18px;
color: #000;
}
.sheet-foot {
margin-top: auto;
border-top: 4px solid #000;
padding: 10px 15px;
display: flex;
justify-content: space-between;
gap: 1rem;
font-family: var(--font-mono, monospace);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
color: #000;
}
/* Schmale Fenster: das Blatt behaelt seine Proportionen und wird als Ganzes
verkleinert, statt umzubrechen. Ein umgebrochenes Formular waere ein
anderes Formular. */
@media (max-width: 230mm) {
.sheet-scroll {
overflow-x: auto;
}
}
}
/* Drucken: nur das Blatt, ohne Rahmenwerk der Anwendung. Der PDF-Export mit
Wasserzeichen und Deckblatt kommt in Phase 8, siehe docs/08. */
@media print {
.no-print {
display: none !important;
}
body {
background: #fff !important;
}
.sheet {
margin: 0;
padding: 0;
width: auto;
}
.sheet-frame {
min-height: auto;
}
.sheet-classbar {
/* Sonst druckt der Balken weiss auf weiss. */
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
@media (prefers-reduced-motion: reduce) {