diff --git a/packages/db/prisma/migrations/20260902190000_personalakte_aktenblatt/migration.sql b/packages/db/prisma/migrations/20260902190000_personalakte_aktenblatt/migration.sql new file mode 100644 index 0000000..a1318a6 --- /dev/null +++ b/packages/db/prisma/migrations/20260902190000_personalakte_aktenblatt/migration.sql @@ -0,0 +1,128 @@ +-- Die Personalakte, wie die Abteilung sie wirklich führt. +-- +-- Drei Änderungen: die Dienstnummer entfällt (es gibt sie nicht), der Status +-- wird zu mehreren Etiketten statt eines Wertes, und AfiS-Mitglieder lassen +-- sich an eine Person hängen. + +-- =========================================================================== +-- 1. Dienstnummer entfällt +-- +-- Sie steckt im Suchvektor und in einem Trigram-Index, beide müssen vor +-- dem Löschen der Spalte weg. Der Trigger wird danach ohne sie neu gesetzt. +-- =========================================================================== + +DROP TRIGGER IF EXISTS personnel_search_trg ON "PersonnelFile"; +DROP INDEX IF EXISTS "PersonnelFile_serviceNumber_key"; +ALTER TABLE "PersonnelFile" DROP COLUMN "serviceNumber"; + +-- =========================================================================== +-- 2. Status als Etiketten +-- +-- "Gekündigt" und "Verhaftet" schließen einander nicht aus, ein einzelnes +-- Feld zwingt aber zur Wahl. Der bisherige Wert wandert als Freitext in die +-- Anmerkung, damit nichts verloren geht: er lässt sich nicht verlässlich +-- auf die neuen Schlüssel abbilden. +-- =========================================================================== + +ALTER TABLE "PersonnelFile" + ADD COLUMN "statusTags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; + +UPDATE "PersonnelFile" + SET "note" = COALESCE(NULLIF("note", '') || E'\n', '') || 'Früherer Status: ' || "status" + WHERE "status" IS NOT NULL AND "status" <> ''; + +ALTER TABLE "PersonnelFile" DROP COLUMN "status"; + +CREATE INDEX "PersonnelFile_statusTags_idx" ON "PersonnelFile" USING GIN ("statusTags"); + +CREATE TABLE "PersonnelStatusTag" ( + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "colorToken" TEXT NOT NULL DEFAULT 'stamp-0', + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "PersonnelStatusTag_pkey" PRIMARY KEY ("key") +); + +CREATE INDEX "PersonnelStatusTag_isActive_sortOrder_idx" + ON "PersonnelStatusTag"("isActive", "sortOrder"); + +-- =========================================================================== +-- 3. Suchvektor ohne Dienstnummer, dafür mit den Etiketten +-- =========================================================================== + +CREATE OR REPLACE FUNCTION personnel_refresh_search_vector() RETURNS trigger AS $$ +BEGIN + NEW."searchVector" := + setweight(to_tsvector('german', coalesce(NEW."displayName", '')), 'A') + || + setweight(to_tsvector('german', + coalesce(NEW."rank", '') || ' ' || coalesce(NEW."department", '') || ' ' || + coalesce(array_to_string(NEW."statusTags", ' '), '')), 'B'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER personnel_search_trg + BEFORE INSERT OR UPDATE OF "displayName", "rank", "department", "statusTags" + ON "PersonnelFile" + FOR EACH ROW EXECUTE FUNCTION personnel_refresh_search_vector(); + +-- Bestehende Zeilen einmal nachziehen, sonst tragen sie den alten Vektor. +UPDATE "PersonnelFile" SET "displayName" = "displayName"; + +-- =========================================================================== +-- 4. AfiS-Mitglieder an einer Personalakte +-- +-- Wie CaseParticipant bei der Fallakte zugleich der Weg zum ausnahmsweisen +-- Zugang: wer hier steht, liest die Akte unabhängig von seiner Stufe. +-- =========================================================================== + +CREATE TYPE "AssignmentRole" AS ENUM ('HANDLER', 'INVESTIGATOR', 'CONTACT'); + +CREATE TABLE "PersonnelAssignment" ( + "id" TEXT NOT NULL, + "personnelFileId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "AssignmentRole" NOT NULL, + "addedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PersonnelAssignment_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PersonnelAssignment_personnelFileId_userId_role_key" + ON "PersonnelAssignment"("personnelFileId", "userId", "role"); +CREATE INDEX "PersonnelAssignment_userId_idx" ON "PersonnelAssignment"("userId"); + +ALTER TABLE "PersonnelAssignment" ADD CONSTRAINT "PersonnelAssignment_personnelFileId_fkey" + FOREIGN KEY ("personnelFileId") REFERENCES "PersonnelFile"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "PersonnelAssignment" ADD CONSTRAINT "PersonnelAssignment_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- =========================================================================== +-- 5. Freigabestufen der Foundation +-- +-- Stehen im Kopf eines Dokuments und steuern **keinen** Zugriff. Getrennte +-- Tabelle, weil sie im Adminbereich pflegbar sein sollen wie die internen. +-- =========================================================================== + +CREATE TABLE "FoundationClearance" ( + "level" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "colorHex" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "FoundationClearance_pkey" PRIMARY KEY ("level") +); + +-- =========================================================================== +-- 6. Kopfzeilen des Aktenblatts an der Vorlage +-- =========================================================================== + +ALTER TYPE "DocumentLayout" ADD VALUE 'DOSSIER'; + +ALTER TABLE "DocumentTemplate" + ADD COLUMN "sheetTitle" TEXT, + ADD COLUMN "sheetSubtitle" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 712fe92..70a16b1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -47,6 +47,7 @@ model User { sessions Session[] personnelFiles PersonnelFile[] participations CaseParticipant[] + assignments PersonnelAssignment[] exclusions CaseExclusion[] watches Watch[] readMarkers ReadMarker[] @@ -157,6 +158,12 @@ model DocumentTemplate { /// Bestimmt allein das Aussehen, nicht den Zugriff. Siehe docs/08. layout DocumentLayout @default(FORM) + /// Kopfzeilen des Aktenblatts (layout = DOSSIER). Stehen in der Vorlage, + /// damit eine weitere Dokumentart dieselbe Bauform mit eigener Beschriftung + /// verwenden kann. + sheetTitle String? + sheetSubtitle String? + /// Muster des Aktenzeichens, siehe docs/13-entscheidungen.md. Die Abteilung /// fuehrt gewachsene Praefixe wie "AKT-PA"; der Nummernkreis laeuft je /// Praefix und Jahr, damit zwei Fallarten sich nicht in die Quere kommen. @@ -192,6 +199,9 @@ enum DocumentLayout { PROSE /// ein einzelnes Freitextfeld, dargestellt wie ein Vermerk NOTE + /// Aktenblatt der Foundation: schwarzer Rahmen, Kopf mit Kennung, + /// Klassifizierungsbalken, Feldraster, Unterschriftenzeilen, Fusszeile + DOSSIER } // --------------------------------------------------------------------------- @@ -439,16 +449,22 @@ enum LinkKind { // Personalakten // --------------------------------------------------------------------------- +/// Akte zu einer Person. Ueberwiegend zu **Externen** — Sicherheitsdienst, +/// Zeugen, Vertragspartner — die kein Discord-Konto haben. `userId` bleibt +/// deshalb der Ausnahmefall, nicht die Regel. model PersonnelFile { - id String @id @default(uuid(7)) - userId String? // null bei Personen ohne Discord-Konto - displayName String - serviceNumber String? @unique - rank String? - department String? - status String? - classification Int @default(1) - fields Json @default("{}") + id String @id @default(uuid(7)) + userId String? // null bei Personen ohne Discord-Konto + displayName String + rank String? + department String? + + /// Mehrere Etiketten zugleich: "Gekuendigt" und "Verhaftet" schliessen + /// einander nicht aus. Die Werte sind Schluessel aus PersonnelStatusTag. + statusTags String[] @default([]) + + classification Int @default(1) + fields Json @default("{}") /// Freitext im Kopf der Akte, etwa "kein Discord-Konto, Aktenlage aus 2025". note String? @@ -458,17 +474,71 @@ model PersonnelFile { searchVector Unsupported("tsvector")? - user User? @relation(fields: [userId], references: [id]) + user User? @relation(fields: [userId], references: [id]) participants CaseParticipant[] documents Document[] + assignments PersonnelAssignment[] @@index([displayName]) @@index([userId]) @@index([classification]) + @@index([statusTags], type: Gin) @@index([searchVector], map: "personnel_search_idx", type: Gin) @@index([displayName(ops: raw("gin_trgm_ops"))], map: "personnel_name_trgm_idx", type: Gin) } +/// Auswahl der Status-Etiketten, im Adminbereich pflegbar. Die Namen sind +/// Daten, keine Oberflaechentexte — siehe CLAUDE.md, Sprachkonvention. +model PersonnelStatusTag { + key String @id // "tot", "gekuendigt" + name String // "Tot", "Gekuendigt" + colorToken String @default("stamp-0") + sortOrder Int @default(0) + isActive Boolean @default(true) + + @@index([isActive, sortOrder]) +} + +/// Ein AfiS-Mitglied, das an dieser Personalakte haengt: Bearbeiter, +/// ermittelnder Beamter, Ansprechpartner. +/// +/// Wie `CaseParticipant` bei der Fallakte ist das zugleich der Weg zum +/// **ausnahmsweisen Zugang**: wer hier eingetragen ist, liest die Akte +/// unabhaengig von seiner Freigabestufe. Siehe docs/13-entscheidungen.md. +model PersonnelAssignment { + id String @id @default(uuid(7)) + personnelFileId String + userId String + role AssignmentRole + addedById String? + createdAt DateTime @default(now()) + + personnelFile PersonnelFile @relation(fields: [personnelFileId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([personnelFileId, userId, role]) + @@index([userId]) +} + +enum AssignmentRole { + /// fuehrt die Akte + HANDLER + /// ermittelt gegen oder ueber die Person + INVESTIGATOR + /// Ansprechpartner, ohne eigene Ermittlung + CONTACT +} + +/// Die Freigabestufen der Foundation, wie sie im Kopf eines Dokuments stehen. +/// **Sie steuern keinen Zugriff** — das tut allein ClearanceLevelConfig. +/// Siehe docs/13-entscheidungen.md, "Zwei Freigaben im Kopf". +model FoundationClearance { + level Int @id // 1..5 + name String // "Streng Geheim" + colorHex String // Balkenfarbe im Aktenblatt + sortOrder Int @default(0) +} + // --------------------------------------------------------------------------- // Dokumente // --------------------------------------------------------------------------- diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index 8b34313..c6ce4e2 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -41,7 +41,9 @@ type Vorlage = { name: string description: string uses?: ('CASE' | 'CASE_DOCUMENT' | 'PERSON_DOCUMENT' | 'STANDALONE')[] - layout?: 'FORM' | 'PROSE' | 'NOTE' + layout?: 'FORM' | 'PROSE' | 'NOTE' | 'DOSSIER' + sheetTitle?: string + sheetSubtitle?: string numberPrefix?: string numberPadding?: number fields?: unknown[] @@ -114,6 +116,88 @@ const TEMPLATE_FIELDS = [ }, ] +/** + * Status einer Person. Mehrere zugleich möglich: "Gekündigt" und "Verhaftet" + * schließen einander nicht aus. Im Adminbereich erweiterbar. + */ +const STATUS_TAGS = [ + { key: 'lebt', name: 'Lebt', colorToken: 'stamp-2', sortOrder: 10 }, + { key: 'im_dienst', name: 'Im Dienst', colorToken: 'stamp-2', sortOrder: 20 }, + { key: 'beurlaubt', name: 'Beurlaubt', colorToken: 'stamp-1', sortOrder: 30 }, + { key: 'suspendiert', name: 'Suspendiert', colorToken: 'stamp-3', sortOrder: 40 }, + { key: 'gekuendigt', name: 'Gekündigt', colorToken: 'stamp-3', sortOrder: 50 }, + { key: 'verhaftet', name: 'Verhaftet', colorToken: 'stamp-4', sortOrder: 60 }, + { key: 'vermisst', name: 'Vermisst', colorToken: 'stamp-4', sortOrder: 70 }, + { key: 'amnestiert', name: 'Amnestiert', colorToken: 'stamp-5', sortOrder: 80 }, + { key: 'tot', name: 'Tot', colorToken: 'stamp-4', sortOrder: 90 }, +] + +/** + * Die Freigabestufen der Foundation, wie sie im Kopf eines Aktenblatts stehen. + * **Sie steuern keinen Zugriff.** Das tut allein die interne Stufe. + * Siehe docs/13-entscheidungen.md, "Zwei Freigaben im Kopf". + */ +const FOUNDATION_CLEARANCES = [ + { level: 1, name: 'Vertraulich', colorHex: '#1e3a5f', sortOrder: 10 }, + { level: 2, name: 'Eingeschränkt', colorHex: '#1a4d2e', sortOrder: 20 }, + { level: 3, name: 'Geheim', colorHex: '#7c2d12', sortOrder: 30 }, + { level: 4, name: 'Streng Geheim', colorHex: '#450a0a', sortOrder: 40 }, + { level: 5, name: 'Thaumiel', colorHex: '#2d0060', sortOrder: 50 }, +] + +/** + * Das Aktenblatt der Personalabteilung, nachgebaut nach dem Formular, das die + * Abteilung bislang von Hand ausgefüllt hat. Reihenfolge und Breiten (`span`, + * vier Spalten) ergeben genau dessen Raster. + */ +const PERSONALAKTE_FIELDS = [ + { + key: 'foundation_klassifikation', + label: 'Klassifikation', + type: 'select', + options: ['1', '2', '3', '4', '5'], + slot: 'classbar', + help: 'Steht im Balken des Blattes. Steuert keinen Zugriff im Aktensystem.', + }, + { key: 'lichtbild', label: 'Lichtbild', type: 'photo', rows: 4 }, + { key: 'name', label: 'Name', type: 'shorttext', span: 2, required: true }, + { key: 'vorname', label: 'Vorname', type: 'shorttext', span: 2, required: true }, + { key: 'alter', label: 'Alter', type: 'shorttext', span: 2 }, + { key: 'geschlecht', label: 'Geschlecht', type: 'shorttext', span: 2 }, + { key: 'groesse', label: 'Größe', type: 'shorttext', span: 4 }, + { key: 'abteilung', label: 'Abteilung', type: 'shorttext', span: 2 }, + { key: 'dienstgrad', label: 'Dienstgrad', type: 'shorttext', span: 2 }, + { + key: 'sicherheitsfreigabe', + label: 'Sicherheitsfreigabe', + type: 'select', + options: ['0', '1', '2', '3', '4', '5'], + span: 3, + emphasis: true, + help: 'Steht im Kopf des Blattes und steuert keinen Zugriff im Aktensystem.', + }, + { key: 'wissensfreigabe', label: 'Wissensfreigabe', type: 'shorttext', span: 2, emphasis: true }, + { key: 'konfession', label: 'Konfession', type: 'shorttext', span: 2 }, + { key: 'adresse', label: 'Adresse', type: 'shorttext', span: 3 }, + { key: 'anmerkungen', label: 'Anmerkungen / Bemerkungen', type: 'longtext', span: 5 }, + { + key: 'personalarbeiter', + label: 'Personalarbeiter', + type: 'signature', + span: 3, + dateKey: 'personalarbeiter_datum', + }, + { key: 'personalarbeiter_datum', label: 'Datum Personalarbeiter', type: 'date' }, + { + key: 'standortleiter', + label: 'Standortleiter', + type: 'signature', + span: 2, + dateKey: 'standortleiter_datum', + }, + { key: 'standortleiter_datum', label: 'Datum Standortleiter', type: 'date' }, +] + /** * Dokumentarten. Sie hängen an einer Fallakte, an einer Personalakte oder an * nichts — was davon, steht in `uses`. Eine weitere Art anzulegen ist ein @@ -121,6 +205,17 @@ const TEMPLATE_FIELDS = [ * Siehe docs/13-entscheidungen.md. */ const DOCUMENT_TEMPLATES: Vorlage[] = [ + { + key: 'personalakte', + name: 'Personalakte', + description: 'Das Aktenblatt der Personalabteilung, meist der erste Eintrag zu einer Person', + uses: ['PERSON_DOCUMENT'], + layout: 'DOSSIER', + sheetTitle: 'SCP Foundation', + sheetSubtitle: 'Personalakte // Personalabteilung', + numberPrefix: 'PA', + fields: PERSONALAKTE_FIELDS, + }, { key: 'vermerk', name: 'Vermerk', @@ -233,6 +328,24 @@ async function main(): Promise { }) console.log('[seed] Änderungsrichtlinie GRACE_WINDOW, 15 Minuten') + for (const tag of STATUS_TAGS) { + await db.personnelStatusTag.upsert({ + where: { key: tag.key }, + create: tag, + update: { name: tag.name, colorToken: tag.colorToken, sortOrder: tag.sortOrder }, + }) + } + console.log(`[seed] ${STATUS_TAGS.length} Status-Etiketten`) + + for (const stufe of FOUNDATION_CLEARANCES) { + await db.foundationClearance.upsert({ + where: { level: stufe.level }, + create: stufe, + update: { name: stufe.name, colorHex: stufe.colorHex, sortOrder: stufe.sortOrder }, + }) + } + console.log(`[seed] ${FOUNDATION_CLEARANCES.length} Foundation-Freigabestufen`) + const alle = [...TEMPLATES, ...DOCUMENT_TEMPLATES] for (const template of alle) { const fields = (template.fields ?? TEMPLATE_FIELDS) as object[] @@ -245,6 +358,8 @@ async function main(): Promise { description: template.description, uses, layout: template.layout ?? 'FORM', + sheetTitle: template.sheetTitle ?? null, + sheetSubtitle: template.sheetSubtitle ?? null, numberPrefix: template.numberPrefix ?? 'AfiS', numberPadding: template.numberPadding ?? 4, version: 1, @@ -259,6 +374,8 @@ async function main(): Promise { description: template.description, uses, layout: template.layout ?? 'FORM', + sheetTitle: template.sheetTitle ?? null, + sheetSubtitle: template.sheetSubtitle ?? null, fields, }, }) diff --git a/web/app/(app)/dokumente/[documentNumber]/page.tsx b/web/app/(app)/dokumente/[documentNumber]/page.tsx index 79eb09b..0ea972b 100644 --- a/web/app/(app)/dokumente/[documentNumber]/page.tsx +++ b/web/app/(app)/dokumente/[documentNumber]/page.tsx @@ -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 = ( ) + + 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 ( + + } + /> + ) } /** diff --git a/web/app/(app)/faelle/actions.ts b/web/app/(app)/faelle/actions.ts index 2a328c5..fd86333 100644 --- a/web/app/(app)/faelle/actions.ts +++ b/web/app/(app)/faelle/actions.ts @@ -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) } } diff --git a/web/app/(app)/faelle/neu/NewCaseForm.tsx b/web/app/(app)/faelle/neu/NewCaseForm.tsx index 02b65a1..3136f57 100644 --- a/web/app/(app)/faelle/neu/NewCaseForm.tsx +++ b/web/app/(app)/faelle/neu/NewCaseForm.tsx @@ -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(createCaseAction, {}) @@ -68,6 +70,29 @@ export function NewCaseForm({ + {people.length > 0 ? ( +
+ + {/* Gleich beim Anlegen verknüpfen erspart den zweiten Schritt, den + man sonst vergisst. Nachträglich geht es genauso. */} + +
+ ) : null} +
+ ) : null} + + ))} + + )} + + {error ?

{error}

: null} + + {canEdit ? ( + open ? ( +
+

+ {de.assignment.hint} +

+
+ + + + +
+
+ ) : ( + + ) + ) : null} +
+ ) +} diff --git a/web/app/(app)/personalakten/[id]/LinkCase.tsx b/web/app/(app)/personalakten/[id]/LinkCase.tsx new file mode 100644 index 0000000..84668f8 --- /dev/null +++ b/web/app/(app)/personalakten/[id]/LinkCase.tsx @@ -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('SUBJECT') + const [error, setError] = useState(null) + const [pending, startTransition] = useTransition() + + if (!open) { + return ( + + ) + } + + return ( +
+

+ {de.personnel.linkCaseHint} +

+
+ + 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" + /> + + +
+ {error ?

{error}

: null} +
+ ) +} diff --git a/web/app/(app)/personalakten/[id]/PersonnelHead.tsx b/web/app/(app)/personalakten/[id]/PersonnelHead.tsx index 6d1c330..48ccdb0 100644 --- a/web/app/(app)/personalakten/[id]/PersonnelHead.tsx +++ b/web/app/(app)/personalakten/[id]/PersonnelHead.tsx @@ -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( @@ -46,10 +48,8 @@ export function PersonnelHead({ return (
- -
+
+ {de.personnel.status} + {file.statusTags.length > 0 ? ( + + ) : ( + {de.personnel.statusNone} + )} +
+ {file.note ? (

{file.note} @@ -88,15 +97,27 @@ export function PersonnelHead({

- -
+ +
+ {de.personnel.status} +
+ {statusTags.map((tag) => ( + + ))} +
+

{de.personnel.statusHint}

+
))} + {canLink ? : null}
) } @@ -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 ( + + ) +} + 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} /> +
+

{de.assignment.heading}

+
+ }> + + +
+
+

{de.document.heading}

@@ -179,7 +242,7 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id:
} > - +
diff --git a/web/app/(app)/personalakten/actions.ts b/web/app/(app)/personalakten/actions.ts index bd47ed1..cdcb34a 100644 --- a/web/app/(app)/personalakten/actions.ts +++ b/web/app/(app)/personalakten/actions.ts @@ -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>['permissions'], + personnelFileId: string, + displayName: string, +): Promise { + 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 { + 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 { + 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 { + 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) } + } +} diff --git a/web/app/(app)/personalakten/neu/NewPersonnelForm.tsx b/web/app/(app)/personalakten/neu/NewPersonnelForm.tsx index 93dd704..8767ff2 100644 --- a/web/app/(app)/personalakten/neu/NewPersonnelForm.tsx +++ b/web/app/(app)/personalakten/neu/NewPersonnelForm.tsx @@ -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( @@ -49,12 +51,25 @@ export function NewPersonnelForm({
- -
+
+ {de.personnel.status} + {/* Kästchen statt Auswahlliste: mehrere Etiketten sind der Normalfall, + und eine Mehrfachauswahl mit Strg-Klick findet niemand. */} +
+ {statusTags.map((tag) => ( + + ))} +
+

{de.personnel.statusHint}

+
+