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:
@@ -60,7 +60,7 @@ export default async function DepartmentPage({ params }: { params: Promise<{ key
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
<header>
|
||||
<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>
|
||||
{abteilung.isArchived ? (
|
||||
<span className="font-condensed text-2xs text-text-dim">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { editEntry, restoreEntry, retractEntry } from '@/lib/cases/entryService'
|
||||
import { addParticipant, removeParticipant } from '@/lib/cases/participants'
|
||||
import { linkCases, unlinkCases } from '@/lib/cases/links'
|
||||
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'
|
||||
|
||||
export type ActionResult = { ok: true } | { ok: false; error: string }
|
||||
@@ -46,22 +46,28 @@ 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! }
|
||||
|
||||
// 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
|
||||
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) {
|
||||
// Die Beteiligten gleich mitverknüpfen, damit sie nicht in einem zweiten
|
||||
// Schritt vergessen werden. Scheitert einer, steht die Akte trotzdem — sie
|
||||
// ist das Wichtigere, und nachtragen kann man auf der Aktenseite.
|
||||
for (const eintrag of beteiligte) {
|
||||
await addParticipant(user.permissions, {
|
||||
caseNumber,
|
||||
role: 'SUBJECT',
|
||||
personnelFileId: parsed.data.personnelFileId,
|
||||
role: eintrag.role,
|
||||
...(eintrag.kind === 'user'
|
||||
? { userId: eintrag.id }
|
||||
: { personnelFileId: eintrag.id }),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { caseText } from '@/lib/i18n/cases'
|
||||
import { useActionState, useState } from 'react'
|
||||
import { caseText, participantText } from '@/lib/i18n/cases'
|
||||
import { clearanceText } from '@/lib/i18n/common'
|
||||
import { PARTICIPANT_ROLES } from '@/lib/cases/terms'
|
||||
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({
|
||||
templates,
|
||||
levels,
|
||||
people,
|
||||
members,
|
||||
defaultLevel,
|
||||
}: {
|
||||
templates: { key: string; name: string }[]
|
||||
levels: { level: number; name: string }[]
|
||||
people: { id: string; displayName: string }[]
|
||||
members: { id: string; displayName: string }[]
|
||||
defaultLevel: number
|
||||
}) {
|
||||
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 (
|
||||
<form action={action} className="mt-6 space-y-5">
|
||||
<div>
|
||||
@@ -71,28 +96,79 @@ export function NewCaseForm({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{people.length > 0 ? (
|
||||
<div>
|
||||
<label htmlFor="personnelFileId" className="field-label">
|
||||
{caseText.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 field"
|
||||
>
|
||||
<option value="">{caseText.subjectNone}</option>
|
||||
{people.map((person) => (
|
||||
<option key={person.id} value={person.id}>
|
||||
{person.displayName}
|
||||
</option>
|
||||
<fieldset>
|
||||
<legend className="field-label">{participantText.heading}</legend>
|
||||
<p className="field-hint">{participantText.hint}</p>
|
||||
|
||||
{beteiligte.length === 0 ? (
|
||||
<p className="mt-3 text-2xs text-text-dim">{participantText.empty}</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{beteiligte.map((eintrag) => (
|
||||
<li key={eintrag.schluessel} className="flex flex-wrap items-center gap-3">
|
||||
{/* Ein verstecktes Feld je Zeile: die Server Action liest sie
|
||||
mit getAll und braucht keine Zählung im Namen. */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="participants"
|
||||
value={`${eintrag.ziel}|${eintrag.rolle}`}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={hinzufuegen} className="mt-3 btn btn-sm">
|
||||
{participantText.add}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
|
||||
@@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic'
|
||||
export default async function NewCasePage() {
|
||||
const user = await requirePermission('case.create')
|
||||
|
||||
const [templates, levels, people] = await Promise.all([
|
||||
const [templates, levels, people, members] = await Promise.all([
|
||||
db.documentTemplate.findMany({
|
||||
// Nur Fallarten. Eine Vorlage für Dokumente legt keine Akte an.
|
||||
where: { isActive: true, uses: { has: 'CASE' } },
|
||||
@@ -30,6 +30,14 @@ export default async function NewCasePage() {
|
||||
select: { id: true, displayName: true },
|
||||
})
|
||||
: 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 (
|
||||
@@ -39,6 +47,7 @@ export default async function NewCasePage() {
|
||||
templates={templates}
|
||||
levels={levels.filter((level) => level.level <= user.permissions.clearanceLevel)}
|
||||
people={people}
|
||||
members={members}
|
||||
defaultLevel={Math.min(1, user.permissions.clearanceLevel)}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { db } from '@/lib/db'
|
||||
import { requireUser } from '@/lib/auth/current'
|
||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { CASE_STATUSES } from '@/lib/cases/terms'
|
||||
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
||||
|
||||
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">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||
<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>
|
||||
|
||||
<nav className="mt-4 flex flex-wrap gap-3 font-condensed text-xs">
|
||||
<Link href="/faelle">{de.case.filterAll}</Link>
|
||||
{(['OPEN', 'REVIEW', 'DORMANT', 'CLOSED', 'ARCHIVED'] as const).map((value) => (
|
||||
<Link key={value} href={`/faelle?status=${value}`}>
|
||||
<nav className="mt-4 flex flex-wrap gap-2">
|
||||
<Link href="/faelle" aria-current={status ? undefined : 'page'} className="btn btn-sm">
|
||||
{de.case.filterAll}
|
||||
</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]}
|
||||
</Link>
|
||||
))}
|
||||
{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}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
@@ -59,15 +59,20 @@ 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'}>
|
||||
<nav className="mt-4 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={q ? `/personalakten?q=${encodeURIComponent(q)}` : '/personalakten'}
|
||||
aria-current={status ? undefined : 'page'}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{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}
|
||||
aria-current={tag.key === status ? 'page' : undefined}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
|
||||
@@ -226,7 +226,9 @@ function Sprung({
|
||||
>
|
||||
{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">
|
||||
{eintrag.label}
|
||||
{eintrag.secondary ? (
|
||||
|
||||
Reference in New Issue
Block a user