Vollständiger Statusfilter, Beteiligte beim Anlegen, Knöpfe in den Listen

Vier Sachen aus der Durchsicht.

**Der Statusfilter zeigte fünf von sieben Zuständen.** DRAFT und SEALED
fehlten - ausgerechnet die beiden, die in der Liste am häufigsten standen; man
konnte nach dem, was man sah, gerade nicht filtern. Das erklärt auch die Frage,
was „In Prüfung" und „Ruhend" bedeuten: die Zustände standen nirgends in der
Oberfläche. Jetzt alle sieben in der Reihenfolge des Lebenszyklus, mit einer
Erklärung als title, und Versiegelt nur für den, der Versiegeltes lesen darf.
Ein Test verlangt zu jedem Zustand einen Namen und eine Erklärung.

**Beliebig viele Beteiligte beim Anlegen.** Das Formular kannte genau eine
„betroffene Person", und die Rolle war auf SUBJECT festgelegt. Jetzt beliebig
viele Zeilen mit je einer Rolle, Konten und Personalakten in einer Auswahl, wie
auf der Aktenseite. Die Zerlegung steht als parseParticipantDrafts in
lib/cases/terms.ts und nicht in der Server Action: seit die Rolle über den
Zugriff entscheidet, darf eine erfundene Rolle nicht durchrutschen.

**Die Filterleisten sind Knöpfe** wie überall sonst, mit gefülltem aktivem
Filter.

**Keine leere Kennungsspalte mehr.** In „Zuletzt geöffnet" stand bei einer
Person nichts, wo sonst das Aktenzeichen steht - jetzt steht dort
„Personalakte". Nebenbei zwei Stellen zurückgenommen, an denen die
Spaltenklasse im Seitenkopf gelandet war statt in einer Zeile.
This commit is contained in:
Harlekin
2026-09-03 23:38:56 +02:00
parent 9c24858d75
commit 738330cee5
10 changed files with 295 additions and 42 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ export default async function DepartmentPage({ params }: { params: Promise<{ key
<main className="mx-auto max-w-5xl px-6 py-10"> <main className="mx-auto max-w-5xl px-6 py-10">
<header> <header>
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2"> <div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
<span className="record-key">{abteilung.key}</span> <span className="font-mono text-xs text-text-dim">{abteilung.key}</span>
<span className="font-condensed text-2xs text-text-dim">{de.department.one}</span> <span className="font-condensed text-2xs text-text-dim">{de.department.one}</span>
{abteilung.isArchived ? ( {abteilung.isArchived ? (
<span className="font-condensed text-2xs text-text-dim"> <span className="font-condensed text-2xs text-text-dim">
+13 -7
View File
@@ -9,7 +9,7 @@ import { editEntry, restoreEntry, retractEntry } from '@/lib/cases/entryService'
import { addParticipant, removeParticipant } from '@/lib/cases/participants' import { addParticipant, removeParticipant } from '@/lib/cases/participants'
import { linkCases, unlinkCases } from '@/lib/cases/links' import { linkCases, unlinkCases } from '@/lib/cases/links'
import { toggleWatch } from '@/lib/cases/watch' import { toggleWatch } from '@/lib/cases/watch'
import { LINK_KINDS, PARTICIPANT_ROLES } from '@/lib/cases/terms' import { LINK_KINDS, PARTICIPANT_ROLES, parseParticipantDrafts } from '@/lib/cases/terms'
import { de } from '@/lib/i18n/de' import { de } from '@/lib/i18n/de'
export type ActionResult = { ok: true } | { ok: false; error: string } export type ActionResult = { ok: true } | { ok: false; error: string }
@@ -46,22 +46,28 @@ export async function createCaseAction(
title: formData.get('title'), title: formData.get('title'),
classification: formData.get('classification'), classification: formData.get('classification'),
templateKey: formData.get('templateKey'), templateKey: formData.get('templateKey'),
personnelFileId: formData.get('personnelFileId') || undefined,
}) })
if (!parsed.success) return { error: de.actionErrors.default! } if (!parsed.success) return { error: de.actionErrors.default! }
// Beliebig viele Beteiligte. Was nicht sauber ankommt, fällt weg: die Akte
// soll nicht an einer krummen Zeile scheitern. Siehe lib/cases/terms.ts.
const beteiligte = parseParticipantDrafts(formData.getAll('participants'))
let caseNumber: string let caseNumber: string
try { try {
const created = await createCase(user.permissions, parsed.data) const created = await createCase(user.permissions, parsed.data)
caseNumber = created.caseNumber caseNumber = created.caseNumber
// Die betroffene Person gleich mitverknüpfen, damit sie nicht in einem // Die Beteiligten gleich mitverknüpfen, damit sie nicht in einem zweiten
// zweiten Schritt vergessen wird. Scheitert das, steht die Akte trotzdem. // Schritt vergessen werden. Scheitert einer, steht die Akte trotzdem — sie
if (parsed.data.personnelFileId) { // ist das Wichtigere, und nachtragen kann man auf der Aktenseite.
for (const eintrag of beteiligte) {
await addParticipant(user.permissions, { await addParticipant(user.permissions, {
caseNumber, caseNumber,
role: 'SUBJECT', role: eintrag.role,
personnelFileId: parsed.data.personnelFileId, ...(eintrag.kind === 'user'
? { userId: eintrag.id }
: { personnelFileId: eintrag.id }),
}) })
} }
} catch (error) { } catch (error) {
+99 -23
View File
@@ -1,23 +1,48 @@
'use client' 'use client'
import { useActionState } from 'react' import { useActionState, useState } from 'react'
import { caseText } from '@/lib/i18n/cases' import { caseText, participantText } from '@/lib/i18n/cases'
import { clearanceText } from '@/lib/i18n/common' import { clearanceText } from '@/lib/i18n/common'
import { PARTICIPANT_ROLES } from '@/lib/cases/terms'
import { createCaseAction, type CreateState } from '../actions' import { createCaseAction, type CreateState } from '../actions'
/** Ein Beteiligter im Formular, bevor die Akte existiert. */
type Entwurf = { schluessel: number; ziel: string; rolle: string }
export function NewCaseForm({ export function NewCaseForm({
templates, templates,
levels, levels,
people, people,
members,
defaultLevel, defaultLevel,
}: { }: {
templates: { key: string; name: string }[] templates: { key: string; name: string }[]
levels: { level: number; name: string }[] levels: { level: number; name: string }[]
people: { id: string; displayName: string }[] people: { id: string; displayName: string }[]
members: { id: string; displayName: string }[]
defaultLevel: number defaultLevel: number
}) { }) {
const [state, action, pending] = useActionState<CreateState, FormData>(createCaseAction, {}) const [state, action, pending] = useActionState<CreateState, FormData>(createCaseAction, {})
// Beliebig viele Beteiligte, jeder mit seiner Rolle. Vorher gab es genau
// eine „betroffene Person" — wer einen Zeugen und einen Täter eintragen
// wollte, musste die Akte erst anlegen und dann noch einmal hinein.
const [beteiligte, setBeteiligte] = useState<Entwurf[]>([])
const [naechster, setNaechster] = useState(1)
function hinzufuegen() {
setBeteiligte((bisher) => [...bisher, { schluessel: naechster, ziel: '', rolle: 'SUBJECT' }])
setNaechster((n) => n + 1)
}
function aendern(schluessel: number, teil: Partial<Entwurf>) {
setBeteiligte((bisher) =>
bisher.map((eintrag) =>
eintrag.schluessel === schluessel ? { ...eintrag, ...teil } : eintrag,
),
)
}
return ( return (
<form action={action} className="mt-6 space-y-5"> <form action={action} className="mt-6 space-y-5">
<div> <div>
@@ -71,28 +96,79 @@ export function NewCaseForm({
</select> </select>
</div> </div>
{people.length > 0 ? ( <fieldset>
<div> <legend className="field-label">{participantText.heading}</legend>
<label htmlFor="personnelFileId" className="field-label"> <p className="field-hint">{participantText.hint}</p>
{caseText.subject}
</label> {beteiligte.length === 0 ? (
{/* Gleich beim Anlegen verknüpfen erspart den zweiten Schritt, den <p className="mt-3 text-2xs text-text-dim">{participantText.empty}</p>
man sonst vergisst. Nachträglich geht es genauso. */} ) : (
<select <ul className="mt-3 space-y-2">
id="personnelFileId" {beteiligte.map((eintrag) => (
name="personnelFileId" <li key={eintrag.schluessel} className="flex flex-wrap items-center gap-3">
defaultValue="" {/* Ein verstecktes Feld je Zeile: die Server Action liest sie
className="mt-1.5 block w-full field" mit getAll und braucht keine Zählung im Namen. */}
> <input
<option value="">{caseText.subjectNone}</option> type="hidden"
{people.map((person) => ( name="participants"
<option key={person.id} value={person.id}> value={`${eintrag.ziel}|${eintrag.rolle}`}
{person.displayName} />
</option> <select
aria-label={participantText.role}
value={eintrag.rolle}
onChange={(event) =>
aendern(eintrag.schluessel, { rolle: event.target.value })
}
className="field w-48"
>
{PARTICIPANT_ROLES.map((value) => (
<option key={value} value={value}>
{participantText.roles[value] ?? value}
</option>
))}
</select>
<select
aria-label={participantText.person}
value={eintrag.ziel}
onChange={(event) => aendern(eintrag.schluessel, { ziel: event.target.value })}
className="field grow"
>
<option value="">—</option>
<optgroup label={participantText.pickUser}>
{members.map((member) => (
<option key={member.id} value={`user:${member.id}`}>
{member.displayName}
</option>
))}
</optgroup>
<optgroup label={participantText.pickPersonnel}>
{people.map((person) => (
<option key={person.id} value={`file:${person.id}`}>
{person.displayName}
</option>
))}
</optgroup>
</select>
<button
type="button"
onClick={() =>
setBeteiligte((bisher) =>
bisher.filter((e) => e.schluessel !== eintrag.schluessel),
)
}
className="btn btn-sm"
>
{participantText.remove}
</button>
</li>
))} ))}
</select> </ul>
</div> )}
) : null}
<button type="button" onClick={hinzufuegen} className="mt-3 btn btn-sm">
{participantText.add}
</button>
</fieldset>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
+10 -1
View File
@@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic'
export default async function NewCasePage() { export default async function NewCasePage() {
const user = await requirePermission('case.create') const user = await requirePermission('case.create')
const [templates, levels, people] = await Promise.all([ const [templates, levels, people, members] = await Promise.all([
db.documentTemplate.findMany({ db.documentTemplate.findMany({
// Nur Fallarten. Eine Vorlage für Dokumente legt keine Akte an. // Nur Fallarten. Eine Vorlage für Dokumente legt keine Akte an.
where: { isActive: true, uses: { has: 'CASE' } }, where: { isActive: true, uses: { has: 'CASE' } },
@@ -30,6 +30,14 @@ export default async function NewCasePage() {
select: { id: true, displayName: true }, select: { id: true, displayName: true },
}) })
: Promise.resolve([]), : Promise.resolve([]),
// Die Mitglieder der Abteilung. Ein Ermittler ist genauso ein Beteiligter
// wie ein Betroffener, nur in anderer Rolle.
db.user.findMany({
where: { isActive: true },
orderBy: { displayName: 'asc' },
take: 200,
select: { id: true, displayName: true },
}),
]) ])
return ( return (
@@ -39,6 +47,7 @@ export default async function NewCasePage() {
templates={templates} templates={templates}
levels={levels.filter((level) => level.level <= user.permissions.clearanceLevel)} levels={levels.filter((level) => level.level <= user.permissions.clearanceLevel)}
people={people} people={people}
members={members}
defaultLevel={Math.min(1, user.permissions.clearanceLevel)} defaultLevel={Math.min(1, user.permissions.clearanceLevel)}
/> />
</main> </main>
+24 -6
View File
@@ -4,6 +4,7 @@ import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current' import { requireUser } from '@/lib/auth/current'
import { visibleCaseWhere } from '@/lib/access/caseFilter' import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { de } from '@/lib/i18n/de' import { de } from '@/lib/i18n/de'
import { CASE_STATUSES } from '@/lib/cases/terms'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark' import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
export const metadata: Metadata = { title: de.case.listHeading } export const metadata: Metadata = { title: de.case.listHeading }
@@ -72,18 +73,35 @@ export default async function CaseListPage({
<main className="mx-auto max-w-5xl px-6 py-10"> <main className="mx-auto max-w-5xl px-6 py-10">
<div className="flex flex-wrap items-baseline justify-between gap-4"> <div className="flex flex-wrap items-baseline justify-between gap-4">
<h1 className="font-condensed">{de.case.listHeading}</h1> <h1 className="font-condensed">{de.case.listHeading}</h1>
<span className="record-key">{de.case.countLabel(total)}</span> <span className="font-mono text-xs text-text-dim">{de.case.countLabel(total)}</span>
</div> </div>
<nav className="mt-4 flex flex-wrap gap-3 font-condensed text-xs"> <nav className="mt-4 flex flex-wrap gap-2">
<Link href="/faelle">{de.case.filterAll}</Link> <Link href="/faelle" aria-current={status ? undefined : 'page'} className="btn btn-sm">
{(['OPEN', 'REVIEW', 'DORMANT', 'CLOSED', 'ARCHIVED'] as const).map((value) => ( {de.case.filterAll}
<Link key={value} href={`/faelle?status=${value}`}> </Link>
{/* Alle sieben Zustände, nicht fünf: „Entwurf" und „Versiegelt" fehlten,
und das waren ausgerechnet die beiden, die in der Liste am häufigsten
standen — man konnte nach dem filtern, was man sah, gerade nicht.
Versiegelt nur für den, der Versiegeltes überhaupt lesen darf. */}
{CASE_STATUSES.filter(
(value) =>
value !== 'SEALED' ||
user.permissions.isAdmin ||
user.permissions.clearanceLevel >= 5,
).map((value) => (
<Link
key={value}
href={`/faelle?status=${value}`}
aria-current={status === value ? 'page' : undefined}
title={de.case.statusHints[value]}
className="btn btn-sm"
>
{de.case.statusNames[value]} {de.case.statusNames[value]}
</Link> </Link>
))} ))}
{user.permissions.isAdmin || user.permissions.permissions.has('case.create') ? ( {user.permissions.isAdmin || user.permissions.permissions.has('case.create') ? (
<Link href="/faelle/neu" className="ml-auto"> <Link href="/faelle/neu" className="ml-auto btn btn-sm btn-primary">
{de.case.create} {de.case.create}
</Link> </Link>
) : null} ) : null}
+8 -3
View File
@@ -59,15 +59,20 @@ export default async function PersonnelListPage({
) : null} ) : null}
</form> </form>
<nav className="mt-4 flex flex-wrap gap-3 font-condensed text-xs"> <nav className="mt-4 flex flex-wrap gap-2">
<Link href={q ? `/personalakten?q=${encodeURIComponent(q)}` : '/personalakten'}> <Link
href={q ? `/personalakten?q=${encodeURIComponent(q)}` : '/personalakten'}
aria-current={status ? undefined : 'page'}
className="btn btn-sm"
>
{de.personnel.filterAll} {de.personnel.filterAll}
</Link> </Link>
{statusTags.map((tag) => ( {statusTags.map((tag) => (
<Link <Link
key={tag.key} key={tag.key}
href={`/personalakten?${new URLSearchParams({ ...(q ? { q } : {}), status: tag.key })}`} href={`/personalakten?${new URLSearchParams({ ...(q ? { q } : {}), status: tag.key })}`}
className={tag.key === status ? 'text-accent' : undefined} aria-current={tag.key === status ? 'page' : undefined}
className="btn btn-sm"
> >
{tag.name} {tag.name}
</Link> </Link>
+3 -1
View File
@@ -226,7 +226,9 @@ function Sprung({
> >
{eintrag.targetType === 'PERSONNEL' ? ( {eintrag.targetType === 'PERSONNEL' ? (
<> <>
<span className="record-key" /> {/* Kein Aktenzeichen, aber auch keine Leerstelle: dort steht, was es
ist. Eine leere Spalte lässt die Zeile wie einen Fehler aussehen. */}
<span className="record-key">{de.personnel.one}</span>
<span className="record-title"> <span className="record-title">
{eintrag.label} {eintrag.label}
{eintrag.secondary ? ( {eintrag.secondary ? (
+56
View File
@@ -50,6 +50,21 @@ export function grantsCaseAccess(role: string): boolean {
return (WORKING_ROLES as readonly string[]).includes(role) return (WORKING_ROLES as readonly string[]).includes(role)
} }
/**
* Die Zustände einer Fallakte in der Reihenfolge des Lebenszyklus aus
* docs/05-akten-und-vorlagen.md. `SEALED` steht am Ende, weil es aus jedem
* Zustand erreichbar ist und keine Stufe im Ablauf bildet.
*/
export const CASE_STATUSES = [
'DRAFT',
'OPEN',
'REVIEW',
'DORMANT',
'CLOSED',
'ARCHIVED',
'SEALED',
] as const
export const LINK_KINDS = ['RELATED', 'SUCCESSOR', 'MERGED_INTO'] as const export const LINK_KINDS = ['RELATED', 'SUCCESSOR', 'MERGED_INTO'] as const
export type LinkKindName = (typeof LINK_KINDS)[number] export type LinkKindName = (typeof LINK_KINDS)[number]
@@ -57,3 +72,44 @@ export type LinkKindName = (typeof LINK_KINDS)[number]
export function isLinkKind(value: string): value is LinkKindName { export function isLinkKind(value: string): value is LinkKindName {
return (LINK_KINDS as readonly string[]).includes(value) return (LINK_KINDS as readonly string[]).includes(value)
} }
/**
* Ein Beteiligter, wie ihn das Formular „Neue Fallakte" mitschickt.
*
* Je Zeile ein verstecktes Feld der Form `user:<id>|ROLLE` oder
* `file:<id>|ROLLE`. Die Zerlegung steht hier und nicht in der Server Action,
* damit sie prüfbar ist: sie entscheidet, wer mit welcher Rolle in eine Akte
* kommt, und eine unbekannte Rolle darf nicht durchrutschen.
*/
export type ParticipantDraft =
| { kind: 'user'; id: string; role: ParticipantRoleName }
| { kind: 'file'; id: string; role: ParticipantRoleName }
/** So viele Beteiligte nimmt das Formular höchstens an. */
export const MAX_PARTICIPANT_DRAFTS = 50
export function parseParticipantDrafts(werte: readonly unknown[]): ParticipantDraft[] {
const gesehen = new Set<string>()
const ergebnis: ParticipantDraft[] = []
for (const wert of werte) {
if (typeof wert !== 'string') continue
const [ziel, rolle] = wert.split('|')
const [art, id] = (ziel ?? '').split(':')
if (art !== 'user' && art !== 'file') continue
if (!id) continue
if (!rolle || !isParticipantRole(rolle)) continue
// Dieselbe Person in derselben Rolle nur einmal. In einer anderen Rolle
// darf sie durchaus zweimal stehen: Zeuge und Betroffener zugleich kommt vor.
const schluessel = `${art}:${id}|${rolle}`
if (gesehen.has(schluessel)) continue
gesehen.add(schluessel)
ergebnis.push({ kind: art, id, role: rolle })
if (ergebnis.length >= MAX_PARTICIPANT_DRAFTS) break
}
return ergebnis
}
+14
View File
@@ -58,6 +58,20 @@ export const caseText = {
ARCHIVED: 'Archiviert', ARCHIVED: 'Archiviert',
SEALED: 'Versiegelt', SEALED: 'Versiegelt',
} as Record<string, string>, } as Record<string, string>,
/**
* Was die Zustände bedeuten. Sie stehen im Lebenszyklus in
* docs/05-akten-und-vorlagen.md, standen aber nirgends in der Oberfläche —
* und ein Filter, dessen Bezeichnung man nicht versteht, ist keiner.
*/
statusHints: {
DRAFT: 'Angelegt, noch nicht in Bearbeitung. Wird erst mit dem Öffnen zur laufenden Akte.',
OPEN: 'In Bearbeitung.',
REVIEW: 'Der Sachstand liegt der Leitung zur Prüfung vor. Zurück in Bearbeitung oder abgeschlossen.',
DORMANT: 'Vorübergehend liegen gelassen, etwa weil eine Auskunft aussteht. Jederzeit wieder zu öffnen.',
CLOSED: 'Abgeschlossen. Wiedereröffnen geht, verlangt aber eine Begründung im Verlauf.',
ARCHIVED: 'Abgelegt. Bleibt lesbar, wird aber nicht mehr bearbeitet.',
SEALED: 'Versiegelt. Nur mit Stufe 5 oder dem Recht „admin" lesbar, aus jedem Zustand erreichbar.',
} as Record<string, string>,
} as const } as const
export const sectionText = { export const sectionText = {
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
MAX_PARTICIPANT_DRAFTS,
parseParticipantDrafts,
CASE_STATUSES,
} from '@/lib/cases/terms'
import { caseText } from '@/lib/i18n/cases'
/**
* Die Zerlegung der Beteiligtenzeilen aus dem Formular „Neue Fallakte".
*
* Sie entscheidet, wer mit welcher Rolle in eine Akte kommt — und seit die
* Rolle über den Zugriff entscheidet (nur Arbeitsrollen öffnen die Akte, siehe
* lib/cases/terms.ts), darf eine erfundene Rolle hier nicht durchrutschen.
*/
describe('Beteiligte beim Anlegen', () => {
it('nimmt Konten und Personalakten mit ihrer Rolle', () => {
expect(
parseParticipantDrafts(['user:123|LEAD', 'file:abc|SUSPECT']),
).toEqual([
{ kind: 'user', id: '123', role: 'LEAD' },
{ kind: 'file', id: 'abc', role: 'SUSPECT' },
])
})
it('lässt Unsinn weg, statt die Akte scheitern zu lassen', () => {
expect(
parseParticipantDrafts([
'user:123|ERFUNDEN', // Rolle gibt es nicht
'irgendwas:1|LEAD', // Art gibt es nicht
'user:|LEAD', // keine Kennung
'user:1', // keine Rolle
'',
42,
null,
]),
).toEqual([])
})
it('nimmt dieselbe Person in zwei Rollen, aber nicht zweimal in derselben', () => {
// Zeuge und Betroffener zugleich kommt vor. Zweimal Zeuge ist ein Versehen.
expect(
parseParticipantDrafts(['file:a|WITNESS', 'file:a|SUBJECT', 'file:a|WITNESS']),
).toEqual([
{ kind: 'file', id: 'a', role: 'WITNESS' },
{ kind: 'file', id: 'a', role: 'SUBJECT' },
])
})
it('nimmt nicht beliebig viele', () => {
const viele = Array.from({ length: 80 }, (_, i) => `file:p${i}|WITNESS`)
expect(parseParticipantDrafts(viele)).toHaveLength(MAX_PARTICIPANT_DRAFTS)
})
})
describe('Zustände einer Fallakte', () => {
it('hat für jeden Zustand einen Namen und eine Erklärung', () => {
// Der Filter zeigte fünf von sieben Zuständen — ausgerechnet „Entwurf" und
// „Versiegelt" fehlten, also die beiden häufigsten in der Liste. Diese
// Prüfung fällt auf, wenn wieder einer vergessen wird.
for (const status of CASE_STATUSES) {
expect(caseText.statusNames[status], status).toBeTruthy()
expect(caseText.statusHints[status], status).toBeTruthy()
}
expect(CASE_STATUSES).toHaveLength(7)
})
})