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 ( +
+
+

{de.admin.heading}

+

{de.admin.intro}

+
+ + ({ href: bereich.href, label: bereich.label }))} /> + +
{children}
+
+ ) +} 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 ( + + ) +} diff --git a/web/app/(app)/admin/richtlinie/PolicyForm.tsx b/web/app/(app)/admin/richtlinie/PolicyForm.tsx index 6162a49..ad758bd 100644 --- a/web/app/(app)/admin/richtlinie/PolicyForm.tsx +++ b/web/app/(app)/admin/richtlinie/PolicyForm.tsx @@ -74,7 +74,7 @@ export function PolicyForm({ setGrace(Number(event.target.value)) setSaved(false) }} - className="mt-1.5 block w-24 border border-line bg-raised px-3 py-2 font-mono text-sm disabled:text-text-dim" + className="mt-1.5 block w-24 field font-mono disabled:text-text-dim" /> @@ -95,7 +95,7 @@ export function PolicyForm({ type="button" onClick={submit} disabled={pending} - className="border border-line bg-raised px-4 py-2 font-condensed text-xs text-accent disabled:text-text-dim" + className="btn btn-primary" > {pending ? caseText.saving : settingsText.save} diff --git a/web/app/(app)/admin/richtlinie/page.tsx b/web/app/(app)/admin/richtlinie/page.tsx index 6e78957..90c0d91 100644 --- a/web/app/(app)/admin/richtlinie/page.tsx +++ b/web/app/(app)/admin/richtlinie/page.tsx @@ -13,8 +13,8 @@ export default async function PolicyPage() { const setting = await db.appSetting.findUnique({ where: { id: 1 } }) return ( -
-

{de.settings.heading}

+
+

{de.settings.heading}

{de.settings.intro}

-
+ ) } diff --git a/web/app/(app)/admin/rollen/RoleRow.tsx b/web/app/(app)/admin/rollen/RoleRow.tsx index 065ceff..c4458d6 100644 --- a/web/app/(app)/admin/rollen/RoleRow.tsx +++ b/web/app/(app)/admin/rollen/RoleRow.tsx @@ -91,7 +91,7 @@ export function RoleRow({ setCategory(event.target.value) setSaved(false) }} - className="border border-line bg-raised px-2 py-1" + className="field" > {CATEGORIES.map((value) => (