diff --git a/TASKS.md b/TASKS.md index c3d9c87..e9eba11 100644 --- a/TASKS.md +++ b/TASKS.md @@ -85,6 +85,10 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. - [x] Rückverweise auf der Personalakte, durch die Sichtbarkeitsprüfung gefiltert - [x] Verknüpfungen zwischen Akten - [x] Test: Stufe-1-Nutzer sieht auf einer Personalakte keinen Hinweis auf Stufe-3-Fälle +- [x] Aktenblatt der Personalabteilung als Vorlage, Aufbau und Aussehen übernommen +- [x] Status als mehrere Etiketten, im Adminbereich pflegbar +- [x] AfiS-Mitglieder an einer Personalakte, mit ausnahmsweisem Zugang +- [x] Fall und Person von beiden Seiten verknüpfbar, auch beim Anlegen ## Phase 5 — Dateien @@ -95,6 +99,8 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. - [ ] Auslieferung über `/api/files/[id]` mit Rechteprüfung und harten Headern - [ ] Upload-UI: lokale Vorschau, Fortschrittsbalken, Zustand „Wird geprüft" - [ ] SSE-Ereignis `attachment.scanned` aktualisiert die Anzeige +- [ ] Lichtbild im Aktenblatt: Feldtyp `photo` füllen, Wert ist die Kennung + eines geprüften Anhangs — das Feld und sein Platz stehen seit Phase 4 ## Phase 6 — Chat und Echtzeit diff --git a/docs/04-datenmodell.md b/docs/04-datenmodell.md index ac3813f..aefabc4 100644 --- a/docs/04-datenmodell.md +++ b/docs/04-datenmodell.md @@ -209,6 +209,8 @@ model CaseLink { enum LinkKind { RELATED SUCCESSOR MERGED_INTO } +// Ueberwiegend zu Externen — Sicherheitsdienst, Zeugen, Vertragspartner — +// die kein Discord-Konto haben. userId ist der Ausnahmefall, nicht die Regel. model PersonnelFile { id String @id @default(uuid()) userId String? // null bei Personen ohne Discord-Konto @@ -216,14 +218,47 @@ model PersonnelFile { note String? // Freitext im Kopf der Akte createdById String? documents Document[] - serviceNumber String? @unique + assignments PersonnelAssignment[] rank String? department String? - status String? + statusTags String[] @default([]) // Schluessel aus PersonnelStatusTag classification Int @default(1) fields Json searchVector Unsupported("tsvector")? @@index([displayName]) + @@index([statusTags], type: Gin) +} + +// Mehrere Etiketten zugleich: "Gekuendigt" und "Verhaftet" schliessen einander +// nicht aus. Im Adminbereich pflegbar, deshalb eine Tabelle. +model PersonnelStatusTag { + key String @id // "tot", "gekuendigt" + name String + colorToken String @default("stamp-0") + sortOrder Int @default(0) + isActive Boolean @default(true) +} + +// Ein AfiS-Mitglied an einer Personalakte. Wie CaseParticipant bei der Fallakte +// zugleich der Weg zum ausnahmsweisen Zugang. +model PersonnelAssignment { + id String @id @default(uuid(7)) + personnelFileId String + userId String + role AssignmentRole + addedById String? + @@unique([personnelFileId, userId, role]) +} + +enum AssignmentRole { HANDLER INVESTIGATOR CONTACT } + +// Die Freigabestufen der Foundation, wie sie im Kopf eines Aktenblatts stehen. +// Sie steuern KEINEN Zugriff — das tut allein ClearanceLevelConfig. +model FoundationClearance { + level Int @id // 1..5 + name String // "Streng Geheim" + colorHex String // Balkenfarbe im Aktenblatt + sortOrder Int @default(0) } // --------------------------------------------------------------------------- @@ -390,3 +425,5 @@ Doppelungen entstehen. |---|---| | `CaseTemplate` | `DocumentTemplate`, plus `uses` und `layout` | | `CaseCounter(year, lastNumber)` | `NumberSeries(prefix, year, lastNumber)` — ein Nummernkreis je Präfix, für Aktenzeichen und Dokumentnummern | +| `PersonnelFile.serviceNumber` | entfallen, es gibt keine Dienstnummern | +| `PersonnelFile.status` (ein Wert) | `statusTags` (mehrere), Auswahl aus `PersonnelStatusTag` | diff --git a/docs/05-akten-und-vorlagen.md b/docs/05-akten-und-vorlagen.md index a688fe2..d03c771 100644 --- a/docs/05-akten-und-vorlagen.md +++ b/docs/05-akten-und-vorlagen.md @@ -215,11 +215,16 @@ Jede Akte ist ein Behälter, der Inhalt sind Beiträge. Eine Fallakte hat vier E | Verlauf | Kurzvermerke, chronologisch | `CaseEntry` | | Dokumente | aus einer Vorlage erzeugt, versioniert, einzeln exportierbar | `Document` | -Eine Personalakte hat Kopfdaten, Dokumente und Rückverweise, aber keine Abschnitte und keinen -eigenen Verlauf: was über eine Person aktenkundig ist, steht in Dokumenten an ihr oder in den -Fällen, in denen sie vorkommt. Ein kurzer Vermerk an einer Person ist ein Dokument aus der -Vorlage `vermerk` — dieselbe Versionierung, dieselbe Feldstufe, dasselbe Protokoll wie bei -allem anderen. Siehe `docs/13-entscheidungen.md`. +Eine Personalakte hat Kopfdaten, Zuständige, Dokumente und Rückverweise, aber keine Abschnitte +und keinen eigenen Verlauf: was über eine Person aktenkundig ist, steht in Dokumenten an ihr +oder in den Fällen, in denen sie vorkommt. Ein kurzer Vermerk an einer Person ist ein Dokument +aus der Vorlage `vermerk` — dieselbe Versionierung, dieselbe Feldstufe, dasselbe Protokoll wie +bei allem anderen. Siehe `docs/13-entscheidungen.md`. + +Das erste Dokument entsteht mit der Akte: die Vorlage `personalakte` ist das **Aktenblatt** der +Abteilung, übernommen aus dem Formular, das sie bislang von Hand ausgefüllt hat. Es hat +`layout = DOSSIER` und wird gedruckt, nicht nur gelesen. Was darauf steht und in welcher Zelle, +bestimmt allein die Vorlage. Ein Dokument hat drei Zustände: `DRAFT` in Arbeit, `FINAL` ausgefertigt, `RETRACTED` zurückgezogen. Es gibt kein Löschen. Ein zurückgezogenes Dokument bleibt als Grabstein diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md index 3e6ebe9..5bffbfd 100644 --- a/docs/13-entscheidungen.md +++ b/docs/13-entscheidungen.md @@ -299,15 +299,21 @@ müssen: der Besitzer muss lesbar sein, und das Dokument selbst muss es sein. Ei Stufe 1 an einer Personalakte der Stufe 4 bleibt verborgen — sonst verriete die Dokumentenliste die Existenz der Person. -## Die eigene Personalakte ist keine Ausnahme - -Eine Fallakte kennt den ausnahmsweisen Zugang über `CaseParticipant`. Eine Personalakte hat -nichts Vergleichbares, und insbesondere darf **die betroffene Person ihre eigene Akte nicht -allein deshalb lesen**, weil sie es ist. +## Betroffen zu sein gibt keinen Zugang, zugeordnet zu sein schon +**Die betroffene Person darf ihre eigene Akte nicht allein deshalb lesen**, weil sie es ist. Der Gedanke lag nahe: die eigene Akte kennt man doch. Er ist trotzdem falsch. Eine Beurteilung auf Stufe 4 über einen Mitarbeiter der Stufe 1 wäre sonst genau für den einen Menschen -lesbar, vor dem sie geschützt werden soll. Es zählt allein die Freigabestufe. +lesbar, vor dem sie geschützt werden soll. + +Den **ausnahmsweisen Zugang** gibt es trotzdem, nur über einen anderen Weg: `PersonnelAssignment` +trägt ein AfiS-Mitglied als Aktenführung, Ermittler oder Ansprechpartner an einer Person ein, +und wer dort steht, liest die Akte unabhängig von seiner Stufe. Das ist dieselbe Bauart wie +`CaseParticipant` bei der Fallakte: er entsteht nur durch eine bewusste Eintragung von jemandem, +der `personnel.edit` hat, und wird protokolliert. + +Die beiden Wege dürfen nicht verwechselt werden. Der eine ist eine Eigenschaft der Person, der +andere eine Entscheidung der Abteilung. ## Rückverweise zählen nur, was sichtbar ist @@ -386,3 +392,80 @@ Eintragsrechten. Gemeinsames Merkmal: sie greifen in bereits Geschriebenes ein. Im Auslieferungszustand hat keine Rolle diese Rechte. Bis der Leiter sie im Adminbereich verteilt, legt nur der Administrator Dokumente an. + +## Die Personalakte ist ein Aktenblatt, kein Formular der Anwendung + +Die Abteilung führt Personalakten bislang als ausgefülltes Blatt: schwarzer Rahmen, Kopf mit +Kennung und Klassifizierungsbalken, ein Raster aus fünf Spalten, Unterschriftenzeilen, +Fußzeile. Dieses Blatt ist übernommen worden, nicht durch eine Bildschirmansicht ersetzt. + +Es steht damit bewusst **außerhalb des Design-Systems** aus `docs/10-design-system.md`. Kein +Farb-Token der Oberfläche kommt darin vor, die Maße stehen in Millimetern, und die Farben sind +fest. Der Grund ist einfach: das Blatt wird gedruckt und weitergereicht, und auf Papier muss es +aussehen wie das Blatt daneben. Ein Formular, das sich dem Bildschirmthema anpasst, ist kein +Formular mehr. + +### Es ist trotzdem datengetrieben + +Kein einziges Feld steht im Programm. Das Blatt entsteht aus `DocumentTemplate` wie jedes +andere Dokument: + +| Was | Woher | +|---|---| +| Überschriften im Kopf | `sheetTitle`, `sheetSubtitle` an der Vorlage | +| Welche Felder, in welcher Reihenfolge | `fields` | +| Breite einer Zelle im Raster | `span`, 1 bis 5 Spalten | +| Zeilen, über die das Lichtbild reicht | `rows` am Feld vom Typ `photo` | +| Datum unter einer Unterschrift | `dateKey` am Feld vom Typ `signature` | +| Inhalt des Klassifizierungsbalkens | Feld mit `slot: 'classbar'` | + +Damit trägt dieselbe Bauform später einen Vorfallsbericht oder einen Bußgeldbescheid: eine +neue Vorlage mit `layout = DOSSIER` und passenden Beschriftungen genügt. + +Der Umbruch in Zeilen ist keine freie Anordnung, sondern folgt der Reihenfolge der Felder: +solange die Lichtbildzelle mitläuft, ist eine Zeile vier Spalten breit, danach fünf. Genau so +verhält sich das Blatt der Abteilung. `web/tests/aktenblatt.test.ts` schreibt das Raster fest. + +### Der Klassifizierungsbalken ist Inhalt, kein Zugriff + +Er zeigt die **Foundation-Stufe** (Vertraulich bis Thaumiel) aus `FoundationClearance`, ein +Feld der Vorlage. Was jemand sehen darf, entscheidet allein `Document.classification` gegen die +interne Stufe des Lesers. Das ist die Fortschreibung von „Zwei Freigaben im Kopf, aber nur eine +sperrt" auf das Blatt: die Stufe im Balken ist eine Aussage der Akte über sich selbst, keine +Zugriffsregel. Ohne gesetzte Stufe bleibt der Balken neutral dunkel, statt eine Einstufung zu +behaupten, die niemand vorgenommen hat. + +### Das Lichtbild fehlt noch + +`photo` ist als Feldtyp vorhanden und das Blatt hat seinen Platz dafür, aber hochladen lässt +sich noch nichts. Der Wert eines Lichtbildfeldes soll die Kennung eines **geprüften** Anhangs +sein, und die Prüfkette — Magic Bytes, ClamAV, Ablage außerhalb der Datenbank — ist Phase 5. +Ein Bild als Data-URL in einem Abschnittswert abzulegen, wie es die Vorlage der Abteilung tut, +verstößt gegen harte Regel 4 und wäre nachträglich schwer wieder herauszubekommen. + +## Status als Etiketten, nicht als einzelner Wert + +„Gekündigt" und „Verhaftet" schließen einander nicht aus, ein einzelnes Statusfeld zwingt aber +zur Wahl. `PersonnelFile.statusTags` ist deshalb eine Liste von Schlüsseln aus +`PersonnelStatusTag`, einer im Adminbereich pflegbaren Tabelle. + +Zwei Eigenschaften sind nicht offensichtlich und deshalb festgeschrieben: + +- **Unbekannte Schlüssel werden verworfen.** Ein manipuliertes Formular kann keinen erfundenen + Status setzen, sonst wäre die Liste im Adminbereich nur eine Empfehlung. +- **Die Reihenfolge kommt aus der Konfiguration**, nicht aus der Eingabe. Auf jeder Akte steht + dieselbe Abfolge, und zwei Akten mit denselben Etiketten sehen gleich aus. + +Die **Dienstnummer ist entfallen**: es gibt sie in der Abteilung nicht. Sie steckte im +Suchvektor und in einem Trigram-Index, beide sind mit ihr verschwunden. + +## Verknüpfen von beiden Seiten + +Eine Person an einen Fall zu hängen ist ein Vorgang, aber zwei Blickrichtungen. Auf der +Fallakte sucht man die Person, auf der Personalakte den Fall, und beim Anlegen einer Fallakte +steht die betroffene Person gleich im Formular. Alle drei Wege laufen in `addParticipant` +zusammen — dieselbe Prüfung, derselbe Protokolleintrag. + +Der Unterschied liegt in der Auswahl: auf der Fallakte eine Liste der sichtbaren Personen, auf +der Personalakte ein Eingabefeld für das Aktenzeichen. Eine Liste aller sichtbaren Fälle auf +einer Personalakte wäre am falschen Ort — sie beantwortet nicht die Frage, die man dort hat. 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}

+
+