diff --git a/TASKS.md b/TASKS.md
index 75a0fe3..31ff158 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -109,7 +109,7 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau.
- [x] Optimistisches Senden mit Dedupe über Client-ID
- [x] Aktenzeichen-Erkennung mit serverseitig aufgelöster Vorschaukarte
- [x] Ungelesen-Markierung und Trennlinie
-- [x] Freie Kanäle mit eigener Mindeststufe
+- [x] ~~Freie Kanäle mit eigener Mindeststufe~~ — entfallen, der Chat sitzt in der Akte
- [x] „Als Vermerk übernehmen"
- [x] Discord-Benachrichtigungen über den Worker, ohne Inhalte
- [x] Benachrichtigungseinstellung je Nutzer: alles / meine Fälle / Erwähnungen / aus
diff --git a/docs/06-chat-und-realtime.md b/docs/06-chat-und-realtime.md
index c76148c..238a383 100644
--- a/docs/06-chat-und-realtime.md
+++ b/docs/06-chat-und-realtime.md
@@ -4,7 +4,10 @@
- **Aktenchat**: hängt an einer Akte, formlos, ausdrücklich kein Aktenbestandteil.
Optisch klar vom Verlauf abgesetzt (siehe Design-System).
-- **Kanäle**: frei angelegte Räume für allgemeine Absprachen, mit eigener Mindeststufe.
+- **Personalaktenchat**: dasselbe an einer Personalakte. Erbt deren Sichtbarkeit und
+ setzt zusätzlich `personnel.read` voraus.
+
+ Freie Kanäle gab es einmal. Sie sind entfallen: siehe docs/13-entscheidungen.md.
## Kernfunktion: „Als Vermerk übernehmen"
diff --git a/docs/10-design-system.md b/docs/10-design-system.md
index 5218673..48c08ed 100644
--- a/docs/10-design-system.md
+++ b/docs/10-design-system.md
@@ -63,8 +63,13 @@ in Überschriften.
## Struktur
-- Eckenradius 2 px, überall gleich. Akten sind nicht abgerundet.
-- Rahmen 1 px in `--ink-500`. Keine Schatten außer dem Fokusring.
+- **Flächen bleiben eckig, Bedienelemente sind rund.** Eine Akte ist ein Blatt und hat
+ scharfe Kanten: Eckenradius 2 px für Panels, Tabellen, Listen. Was man drücken oder
+ ausfüllen kann, ist rund — Knöpfe, Suchbalken, Marken und Profilbilder voll (`--radius-control`,
+ 999 px), Eingabefelder weich (`--radius-field`, 10 px). Daran erkennt man ein Bedienelement,
+ bevor man es berührt.
+- Rahmen 1 px in `--ink-500`. Keine Schatten außer dem Fokusring und dem inneren Ring, den
+ ein gedrückter Knopf zeigt.
- Trennung durch Linien und Weißraum, nicht durch gestapelte Karten.
- Die Klassifizierung erscheint als 3 px breite Kante links am Objekt plus einer
Stempelmarke oben rechts. Beides in derselben Stufenfarbe.
@@ -82,6 +87,28 @@ Oberfläche nichts von selbst.
Alle übrige Bewegung ist Antwort auf eine Handlung: Aufklappen, Bestätigen, Einfügen.
Keine Einblend-Animationen beim Scrollen, keine Hover-Effekte auf jeder Fläche.
+## Knöpfe
+
+Jeder Knopf trägt Rahmen und eigene Grundfläche, auch der unscheinbarste. Ein Text, der sich
+erst beim Überfahren als Knopf entpuppt, wird nicht gefunden. Die Klasse dafür ist `.btn`,
+mit `.btn-primary` für den einen Knopf, der die Sache erledigt, und `.btn-sm` für dichte
+Zeilen. Kein eigenes Knopf-Aussehen je Seite.
+
+Drei Zustände, die man sehen muss:
+
+| Zustand | Was passiert |
+|---|---|
+| Überfahren | Fläche eine Stufe heller, Rahmen und Schrift in `--steel` |
+| Gedrückt | Knopf zieht sich leicht zusammen und bekommt einen inneren Ring — er wirkt kurz dicker, ohne dass die Rahmenbreite sich ändert und das Umfeld springt |
+| Läuft | `aria-busy`, gestrichelter Rahmen, nimmt keine zweite Eingabe an |
+
+Die Druckrückmeldung setzt **ohne Verzögerung** ein und klingt langsam ab. Andersherum
+bliebe ein kurzer Klick unsichtbar, und genau darum geht es: der Nutzer soll wissen, dass er
+diesen Knopf wirklich getroffen hat.
+
+Eine Rückfrage vor dem Ausführen ist damit nicht gemeint und steht nur dort, wo sie ohnehin
+schon steht.
+
## Schreibweise in der Oberfläche
- Aktive Verben, die sagen, was passiert: „Vermerk speichern", nicht „Absenden"
diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md
index 7e3065e..2bb4672 100644
--- a/docs/13-entscheidungen.md
+++ b/docs/13-entscheidungen.md
@@ -933,3 +933,73 @@ derentwillen das System gebaut wird.
Einmal durchgespielt: 42 Akten, 51 Verlaufseinträge, 97 Protokollzeilen, 8 Regeln und 20
Trigger kamen an.
+
+## Oberfläche, zweite Runde
+
+### Flächen bleiben eckig, Bedienelemente werden rund
+
+Der Auslieferungszustand hatte überall 2 px Radius, mit der Begründung „Akten sind nicht
+abgerundet". Das stimmt für Akten und stimmte nicht für Knöpfe: ein Bedienelement, das
+aussieht wie ein Ausschnitt der Fläche, auf der es liegt, wird nicht als Bedienelement
+gelesen.
+
+Die Regel heisst jetzt: **Flächen bleiben eckig, alles Bedienbare ist rund.** Knöpfe,
+Suchbalken, Marken und Profilbilder voll (999 px), Eingabefelder weich (10 px), Panels,
+Tabellen und Listen unverändert bei 2 px. Damit ist die Rundung eine Auskunft und keine
+Verzierung: rund heisst, man kann es anfassen.
+
+### Jeder Knopf hat einen Rahmen und meldet den Druck zurück
+
+Vorher waren rund vierzig der siebzig Knöpfe blosser Text in Akzentfarbe — nicht von einem
+Link zu unterscheiden und auf einem vollen Bildschirm schlicht nicht zu finden. Jetzt trägt
+jeder Knopf Rahmen und Grundfläche, über die Klasse `.btn` an einer Stelle definiert statt
+siebzigmal als Klassenkette.
+
+Beim Drücken zieht der Knopf sich leicht zusammen und bekommt einen **inneren** Ring: er
+wirkt kurz dicker, ohne dass die Rahmenbreite sich ändert und das Umfeld springt. Die
+Rückmeldung setzt ohne Verzögerung ein und klingt über 240 ms ab — andersherum bliebe ein
+kurzer Klick unsichtbar, und genau darum geht es.
+
+Ausdrücklich **keine Rückfrage vor dem Ausführen**. „Bestätigung" heisst hier: der Nutzer
+soll wissen, dass er getroffen hat, nicht ein zweites Mal gefragt werden. Wo eine Rückfrage
+schon stand — Umklassifizieren, Änderungsrichtlinie —, bleibt sie.
+
+### Die Verwaltung ist ein Eintrag, nicht drei
+
+Rollenverwaltung, Vorlagen und Änderungsrichtlinie standen als drei eigene Einträge in der
+Kopfzeile, für etwas, das man selten und dann meist zusammenhängend braucht. Sie liegen
+jetzt unter `/admin` mit einer Reiterleiste, wie die Fallakte. Die Sichtbarkeit eines
+Reiters richtet sich nach dem Recht, aber **jede Seite prüft weiterhin selbst** — eine
+versteckte Kachel ist kein Schutz.
+
+### Freie Kanäle sind entfallen
+
+Der Chat sollte von Anfang an dort stehen, wo der Vorgang steht. Ein eigener Reiter mit
+freien Räumen war der Rückweg nach Discord: dort redet man über eine Akte, statt in ihr.
+Die Fallakte hatte ihren Chat bereits, die Personalakte hat ihn jetzt auch.
+
+`Channel` ist mitsamt Tabelle entfernt, `ChatMessage.channelId` heisst `personnelFileId`,
+und ein CHECK erzwingt, dass eine Nachricht zu **genau einem** Ort gehört — vorher hing das
+allein an der Anwendung. Zum Zeitpunkt der Migration stand in keinem Kanal eine Nachricht.
+
+Zwei Dinge sind am Personalaktenchat anders als am Aktenchat, beide aus demselben Grund —
+dass es zu jemandem eine Personalakte gibt, ist selbst eine Auskunft:
+
+- **Kein „als Vermerk übernehmen".** Dafür braucht es einen Verlauf, und den führt nur die
+ Fallakte.
+- **Meldungen gehen nur an ausdrücklich Erwähnte**, nie in den allgemeinen Discord-Kanal,
+ und sie nennen weder den Namen der Person noch den Text.
+
+### Suchbalken statt Suchknopf, Profilbild statt Textzeile
+
+Die Suche ist der häufigste Weg in eine Akte und war ein Link zwischen anderen Links. Sie
+ist jetzt ein Feld in der Kopfzeile — ein gewöhnliches Formular an `/suche`, ohne Skript und
+ohne ein Byte im Bündel. Strg+K bleibt daneben: der Balken ist zum Recherchieren da, die
+Palette zum Springen.
+
+Thema, Benachrichtigungen, „Dein Zugang" und Abmelden standen zwischen den Aktenbereichen
+und nahmen dort den Platz weg, den die Arbeit braucht. Sie sitzen jetzt hinter dem
+Profilbild. Das Bild kommt von Discord — dort liegt es ohnehin, die Anmeldung läuft über
+dieselbe Stelle, und ein eigener Zwischenspeicher wäre ein Dienst mehr im Stack für ein Bild
+von 64 Pixeln. Dafür steht `cdn.discordapp.com` in der Inhaltsrichtlinie. Wer kein Bild hat,
+bekommt seine Initialen; ein Platzhaltergesicht lädt nichts nach und sagt nichts.
diff --git a/packages/db/prisma/migrations/20260903230000_chat_in_der_akte/migration.sql b/packages/db/prisma/migrations/20260903230000_chat_in_der_akte/migration.sql
new file mode 100644
index 0000000..2602af8
--- /dev/null
+++ b/packages/db/prisma/migrations/20260903230000_chat_in_der_akte/migration.sql
@@ -0,0 +1,61 @@
+-- Freie Kanäle entfallen, der Chat zieht in die Personalakte.
+--
+-- Der Gedanke hinter dem Chat war von Anfang an, dass er dort steht, wo der
+-- Vorgang steht. Ein eigener Reiter mit freien Räumen war der Rückweg nach
+-- Discord: dort redet man über eine Akte, statt in ihr. Die Fallakte hatte
+-- ihren Chat bereits, die Personalakte bekommt ihn hier.
+--
+-- Die Kanäle werden mitsamt Tabelle entfernt. Das ist unumkehrbar; zum
+-- Zeitpunkt dieser Migration stand in keinem Kanal eine Nachricht.
+
+-- Die Nachrichtentabelle ist gegen DELETE gesperrt. Für das einmalige
+-- Aufräumen wird die Regel abgenommen und danach wieder gesetzt — sie ist die
+-- Zusicherung, dass niemand im Betrieb Nachrichten verschwinden lässt.
+DROP RULE chat_no_delete ON "ChatMessage";
+
+DELETE FROM "ChatReaction"
+ WHERE "messageId" IN (SELECT "id" FROM "ChatMessage" WHERE "channelId" IS NOT NULL);
+DELETE FROM "ChatMessage" WHERE "channelId" IS NOT NULL;
+
+CREATE RULE chat_no_delete AS ON DELETE TO "ChatMessage" DO INSTEAD NOTHING;
+
+DELETE FROM "ReadMarker" WHERE "scopeType" = 'channel';
+DELETE FROM "NotificationOutbox" WHERE "channelId" IS NOT NULL;
+
+-- Der Ort einer Nachricht heisst jetzt anders.
+DROP INDEX IF EXISTS "ChatMessage_channelId_createdAt_idx";
+ALTER TABLE "ChatMessage" DROP CONSTRAINT IF EXISTS "ChatMessage_channelId_fkey";
+ALTER TABLE "ChatMessage" DROP COLUMN "channelId";
+ALTER TABLE "ChatMessage" ADD COLUMN "personnelFileId" TEXT;
+
+ALTER TABLE "ChatMessage"
+ ADD CONSTRAINT "ChatMessage_personnelFileId_fkey"
+ FOREIGN KEY ("personnelFileId") REFERENCES "PersonnelFile"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+
+CREATE INDEX "ChatMessage_personnelFileId_createdAt_idx"
+ ON "ChatMessage"("personnelFileId", "createdAt" DESC);
+
+-- Eine Nachricht gehört zu genau einem Ort. Vorher hing das an der
+-- Anwendung; als CHECK kann es keine Abkürzung mehr umgehen.
+ALTER TABLE "ChatMessage"
+ ADD CONSTRAINT "ChatMessage_genau_ein_ort"
+ CHECK (num_nonnulls("caseId", "personnelFileId") = 1);
+
+-- Der Wächter über Autor, Zeitpunkt und Ort kennt den neuen Namen.
+CREATE OR REPLACE FUNCTION chat_immutable_guard() RETURNS trigger AS $$
+BEGIN
+ IF NEW."authorId" IS DISTINCT FROM OLD."authorId"
+ OR NEW."createdAt" IS DISTINCT FROM OLD."createdAt"
+ OR NEW."caseId" IS DISTINCT FROM OLD."caseId"
+ OR NEW."personnelFileId" IS DISTINCT FROM OLD."personnelFileId" THEN
+ RAISE EXCEPTION 'authorId, createdAt und der Ort einer ChatMessage sind unveränderlich';
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+ALTER TABLE "NotificationOutbox" DROP COLUMN "channelId";
+ALTER TABLE "NotificationOutbox" ADD COLUMN "personnelFileId" TEXT;
+
+DROP TABLE "Channel";
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index f743662..eb31e44 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -484,6 +484,7 @@ model PersonnelFile {
searchVector Unsupported("tsvector")?
user User? @relation(fields: [userId], references: [id])
+ messages ChatMessage[]
participants CaseParticipant[]
documents Document[]
assignments PersonnelAssignment[]
@@ -703,25 +704,14 @@ enum ScanStatus {
// ---------------------------------------------------------------------------
/// Frei angelegter Raum fuer allgemeine Absprachen, mit eigener Mindeststufe.
-model Channel {
- id String @id @default(uuid(7))
- key String @unique
- name String
- description String?
- minClearance Int @default(0)
- isArchived Boolean @default(false)
- createdById String
- createdAt DateTime @default(now())
-
- messages ChatMessage[]
-
- @@index([isArchived, name])
-}
-
+/// Eine Chatnachricht haengt immer an genau einer Akte: an einer Fallakte
+/// oder an einer Personalakte. Freie Kanaele gab es einmal; sie sind
+/// entfallen, weil das Gespraech dorthin gehoert, wo der Vorgang steht.
+/// Siehe docs/13-entscheidungen.md.
model ChatMessage {
- id String @id // clientseitig erzeugt, UUIDv7
- caseId String?
- channelId String?
+ id String @id // clientseitig erzeugt, UUIDv7
+ caseId String?
+ personnelFileId String?
authorId String
body String
replyToId String?
@@ -735,14 +725,14 @@ model ChatMessage {
mentions String[] @default([])
createdAt DateTime @default(now())
- case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
- channel Channel? @relation(fields: [channelId], references: [id], onDelete: Cascade)
+ case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
+ personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Cascade)
replyTo ChatMessage? @relation("replies", fields: [replyToId], references: [id])
replies ChatMessage[] @relation("replies")
reactions ChatReaction[]
@@index([caseId, createdAt(sort: Desc)])
- @@index([channelId, createdAt(sort: Desc)])
+ @@index([personnelFileId, createdAt(sort: Desc)])
@@index([authorId])
@@index([replyToId])
@@index([mentions], type: Gin)
@@ -761,7 +751,7 @@ model ChatReaction {
model ReadMarker {
userId String
- scopeType String // "case" | "channel"
+ scopeType String // "case" | "personnel"
scopeId String
lastReadAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -822,9 +812,9 @@ model RecentView {
model NotificationOutbox {
id BigInt @id @default(autoincrement())
kind String // "entry.new", "chat.new", "chat.mention", "case.status"
- caseId String?
- channelId String?
- /// Leer heisst: in den allgemeinen Kanal. Sonst als Direktnachricht.
+ caseId String?
+ personnelFileId String?
+ /// Leer heisst: in den allgemeinen Discord-Kanal. Sonst als Direktnachricht.
recipientId String?
actorId String
/// Nur Kennungen und Zahlen, nie Text aus einer Akte.
diff --git a/web/app/(app)/admin/layout.tsx b/web/app/(app)/admin/layout.tsx
new file mode 100644
index 0000000..e3c1ff0
--- /dev/null
+++ b/web/app/(app)/admin/layout.tsx
@@ -0,0 +1,35 @@
+import type { ReactNode } from 'react'
+import { notFound } from 'next/navigation'
+import { requireUser } from '@/lib/auth/current'
+import { de } from '@/lib/i18n/de'
+import { adminSections, hasAdminAccess } from './sections'
+import { Tabs } from '@/components/Tabs'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * Kopf und Leiste der Verwaltung.
+ *
+ * Wie bei der Fallakte sind die Bereiche **eigene Seiten** unter einem
+ * gemeinsamen Layout, keine umgeschalteten Blöcke. Wer gar keinen Bereich
+ * sehen darf, bekommt 404 — die Verwaltung ist für ihn nicht vorhanden.
+ */
+export default async function AdminLayout({ children }: { children: ReactNode }) {
+ const user = await requireUser()
+ if (!hasAdminAccess(user.permissions)) notFound()
+
+ const bereiche = adminSections(user.permissions)
+
+ return (
+
+
+
+
+ )
+}
diff --git a/web/app/(app)/admin/page.tsx b/web/app/(app)/admin/page.tsx
new file mode 100644
index 0000000..8bf6985
--- /dev/null
+++ b/web/app/(app)/admin/page.tsx
@@ -0,0 +1,33 @@
+import type { Metadata } from 'next'
+import Link from 'next/link'
+import { requireUser } from '@/lib/auth/current'
+import { de } from '@/lib/i18n/de'
+import { adminSections } from './sections'
+
+export const metadata: Metadata = { title: de.admin.heading }
+export const dynamic = 'force-dynamic'
+
+/**
+ * Die Startseite der Verwaltung: was es gibt und wofür es da ist.
+ *
+ * Kein Weiterleiten auf den ersten Bereich. Wer die Verwaltung öffnet, weiss
+ * nicht immer, wo das Gesuchte liegt — eine Liste mit einem Satz je Bereich
+ * beantwortet das schneller als das Durchklicken der Leiste.
+ */
+export default async function AdminIndexPage() {
+ const user = await requireUser()
+ const bereiche = adminSections(user.permissions)
+
+ return (
+