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 (
+
+ {bereiche.map((bereich) => (
+
+
+ {bereich.label}
+ {bereich.description}
+
+
+ ))}
+
+ )
+}
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) => (
@@ -109,7 +109,7 @@ export function RoleRow({
setClearanceLevel(Number(event.target.value))
setSaved(false)
}}
- className="border border-line bg-raised px-2 py-1"
+ className="field"
>
{levels.map((level) => (
@@ -160,7 +160,7 @@ export function RoleRow({
type="button"
onClick={submit}
disabled={pending || !dirty}
- className="border border-line bg-raised px-3 py-1.5 font-condensed text-xs text-accent disabled:text-text-dim"
+ className="btn btn-primary"
>
{pending ? roleAdminText.saving : roleAdminText.save}
diff --git a/web/app/(app)/admin/rollen/page.tsx b/web/app/(app)/admin/rollen/page.tsx
index 531410a..aa10c9a 100644
--- a/web/app/(app)/admin/rollen/page.tsx
+++ b/web/app/(app)/admin/rollen/page.tsx
@@ -27,8 +27,8 @@ export default async function RoleAdminPage() {
])
return (
-
- {de.roleAdmin.heading}
+
+ {de.roleAdmin.heading}
{de.roleAdmin.intro}
{mappings.length === 0 ? (
@@ -73,6 +73,6 @@ export default async function RoleAdminPage() {
})}
>
)}
-
+
)
}
diff --git a/web/app/(app)/admin/sections.ts b/web/app/(app)/admin/sections.ts
new file mode 100644
index 0000000..df95958
--- /dev/null
+++ b/web/app/(app)/admin/sections.ts
@@ -0,0 +1,53 @@
+import type { EffectivePermissions } from '@/lib/auth/permissions'
+import { de } from '@/lib/i18n/de'
+
+/**
+ * Die Bereiche der Verwaltung, an einer Stelle.
+ *
+ * Vorher standen Rollenverwaltung, Vorlagen und Änderungsrichtlinie als drei
+ * eigene Einträge in der Kopfzeile — drei Einträge für etwas, das man selten
+ * und dann meist zusammenhängend braucht. Jetzt ist es ein Eintrag mit einer
+ * Leiste darunter.
+ *
+ * Die Rechteprüfung steht hier **nicht** als Berechtigung, sondern nur für die
+ * Anzeige: jede Seite prüft selbst noch einmal mit `requirePermission`. Eine
+ * versteckte Kachel ist kein Schutz.
+ */
+export type AdminSection = {
+ key: string
+ href: string
+ label: string
+ description: string
+}
+
+export function adminSections(p: EffectivePermissions): AdminSection[] {
+ const darf = (recht: 'roles.manage' | 'template.manage' | 'admin'): boolean =>
+ p.isAdmin || (recht !== 'admin' && p.permissions.has(recht))
+
+ const alle: (AdminSection & { sichtbar: boolean })[] = [
+ { key: 'roles', href: '/admin/rollen', sichtbar: darf('roles.manage'), ...text('roles') },
+ {
+ key: 'templates',
+ href: '/admin/vorlagen',
+ sichtbar: darf('template.manage'),
+ ...text('templates'),
+ },
+ { key: 'policy', href: '/admin/richtlinie', sichtbar: darf('admin'), ...text('policy') },
+ { key: 'log', href: '/admin/protokoll', sichtbar: darf('admin'), ...text('log') },
+ { key: 'system', href: '/admin/system', sichtbar: darf('admin'), ...text('system') },
+ ]
+
+ return alle.filter((bereich) => bereich.sichtbar).map(({ sichtbar: _, ...rest }) => rest)
+}
+
+function text(key: string): { label: string; description: string } {
+ return {
+ label: de.admin.sections[key] ?? key,
+ description: de.admin.descriptions[key] ?? '',
+ }
+}
+
+/** Ob jemand die Verwaltung überhaupt sieht. Für die Kopfzeile. */
+export function hasAdminAccess(p: EffectivePermissions): boolean {
+ return p.isAdmin || p.permissions.has('roles.manage') || p.permissions.has('template.manage')
+}
diff --git a/web/app/(app)/admin/system/page.tsx b/web/app/(app)/admin/system/page.tsx
index 9b5e2ea..080bdf5 100644
--- a/web/app/(app)/admin/system/page.tsx
+++ b/web/app/(app)/admin/system/page.tsx
@@ -54,8 +54,8 @@ export default async function SystemPage() {
await requirePermission('admin')
return (
-
- {de.health.heading}
+
+ {de.health.heading}
@@ -91,7 +91,7 @@ export default async function SystemPage() {
{de.export.bundle}
-
+
)
}
diff --git a/web/app/(app)/admin/vorlagen/TemplateEditor.tsx b/web/app/(app)/admin/vorlagen/TemplateEditor.tsx
index 85c6c7e..49d14be 100644
--- a/web/app/(app)/admin/vorlagen/TemplateEditor.tsx
+++ b/web/app/(app)/admin/vorlagen/TemplateEditor.tsx
@@ -85,7 +85,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
setName(event.target.value)}
- className="mt-1 block w-full border border-line bg-raised px-2 py-1 text-sm"
+ className="mt-1 block w-full field"
/>
@@ -94,7 +94,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
setDescription(event.target.value)}
- className="mt-1 block w-full border border-line bg-raised px-2 py-1 text-sm"
+ className="mt-1 block w-full field"
/>
@@ -104,7 +104,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
setNumberPrefix(event.target.value)}
- className="mt-1 block w-32 border border-line bg-raised px-2 py-1 font-mono text-sm"
+ className="mt-1 block w-32 field font-mono"
/>
@@ -115,7 +115,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
max={8}
value={numberPadding}
onChange={(event) => setNumberPadding(Number(event.target.value))}
- className="mt-1 block w-20 border border-line bg-raised px-2 py-1 font-mono text-sm"
+ className="mt-1 block w-20 field font-mono"
/>
@@ -127,7 +127,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
onChange={(event) => setJson(event.target.value)}
rows={16}
spellCheck={false}
- className="mt-1 block w-full border border-line bg-raised px-2 py-1 font-mono text-2xs"
+ className="mt-1 block w-full field font-mono text-2xs"
/>
{templateAdminText.fieldsHelp}
@@ -137,7 +137,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
type="button"
onClick={submit}
disabled={pending || !geparst.ok}
- className="border border-line bg-raised px-3 py-1.5 font-condensed text-accent disabled:text-text-dim"
+ className="btn btn-primary"
>
{pending ? caseText.saving : templateAdminText.save}
diff --git a/web/app/(app)/admin/vorlagen/page.tsx b/web/app/(app)/admin/vorlagen/page.tsx
index 19ff524..b8fe3d0 100644
--- a/web/app/(app)/admin/vorlagen/page.tsx
+++ b/web/app/(app)/admin/vorlagen/page.tsx
@@ -17,8 +17,8 @@ export default async function TemplateAdminPage() {
})
return (
-
- {de.templateAdmin.heading}
+
+ {de.templateAdmin.heading}
{de.templateAdmin.intro}
{vorlagen.length === 0 ? (
@@ -41,6 +41,6 @@ export default async function TemplateAdminPage() {
))}
)}
-
+
)
}
diff --git a/web/app/(app)/chat/actions.ts b/web/app/(app)/chat/actions.ts
index 7fa3ec2..e54caba 100644
--- a/web/app/(app)/chat/actions.ts
+++ b/web/app/(app)/chat/actions.ts
@@ -27,7 +27,7 @@ function toMessage(error: unknown): string {
const scope = z.union([
z.object({ kind: z.literal('case'), caseNumber: z.string().min(1) }),
- z.object({ kind: z.literal('channel'), key: z.string().min(1) }),
+ z.object({ kind: z.literal('personnel'), fileId: z.string().min(1) }),
])
const sendSchema = z.object({
@@ -114,7 +114,7 @@ export async function adoptAsEntryAction(input: unknown): Promise
}
const readSchema = z.object({
- scopeType: z.enum(['case', 'channel']),
+ scopeType: z.enum(['case', 'personnel']),
scopeId: z.string().min(1),
})
diff --git a/web/app/(app)/dokumente/[documentNumber]/DocumentActions.tsx b/web/app/(app)/dokumente/[documentNumber]/DocumentActions.tsx
index 94b073f..6be5409 100644
--- a/web/app/(app)/dokumente/[documentNumber]/DocumentActions.tsx
+++ b/web/app/(app)/dokumente/[documentNumber]/DocumentActions.tsx
@@ -63,7 +63,7 @@ export function DocumentActions({
if (!confirm(documentText.finalizeHint)) return
run(() => finalizeDocumentAction({ documentNumber }))
}}
- className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
+ className="btn btn-primary"
>
{documentText.finalize}
@@ -78,7 +78,7 @@ export function DocumentActions({
value={nextLevel}
onChange={(event) => setNextLevel(Number(event.target.value))}
aria-label={clearanceText.classification}
- className="border border-line bg-raised px-2 py-1"
+ className="field"
>
{levels.map((level) => (
@@ -98,7 +98,7 @@ export function DocumentActions({
}),
)
}
- className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
+ className="btn btn-primary"
>
{commonText.save}
@@ -110,7 +110,7 @@ export function DocumentActions({
rows={2}
placeholder={caseText.reclassifyReason}
aria-label={caseText.reclassifyReason}
- className="mt-2 block w-full max-w-sm border border-line bg-raised px-3 py-2 text-2xs"
+ className="mt-2 block w-full max-w-sm field text-2xs"
/>
) : null}
@@ -126,21 +126,21 @@ export function DocumentActions({
rows={2}
placeholder={documentText.retractReason}
aria-label={documentText.retractReason}
- className="block w-full max-w-sm border border-line bg-raised px-3 py-2 text-2xs"
+ className="block w-full max-w-sm field text-2xs"
/>
run(() => retractDocumentAction({ documentNumber, reason }))}
- className="border border-line bg-raised px-3 py-1 font-condensed text-stamp-4 disabled:text-text-dim"
+ className="btn btn-sm text-stamp-4"
>
{documentText.retract}
setRetracting(false)}
- className="font-condensed text-text-dim"
+ className="btn btn-sm"
>
{commonText.cancel}
@@ -150,7 +150,7 @@ export function DocumentActions({
setRetracting(true)}
- className="font-condensed text-text-dim"
+ className="btn btn-sm"
>
{documentText.retract}
diff --git a/web/app/(app)/einstellungen/NotifyForm.tsx b/web/app/(app)/einstellungen/NotifyForm.tsx
index 521dcb7..98e390c 100644
--- a/web/app/(app)/einstellungen/NotifyForm.tsx
+++ b/web/app/(app)/einstellungen/NotifyForm.tsx
@@ -30,7 +30,7 @@ export function NotifyForm({ current }: { current: string }) {
{notifyText.save}
diff --git a/web/app/(app)/export/Approvals.tsx b/web/app/(app)/export/Approvals.tsx
index bd7df12..40c6abd 100644
--- a/web/app/(app)/export/Approvals.tsx
+++ b/web/app/(app)/export/Approvals.tsx
@@ -53,14 +53,14 @@ export function Approvals({ jobs }: { jobs: Auftrag[] }) {
if (ergebnis.error) setFehler(ergebnis.error)
})
}
- className="border border-line bg-raised px-2 py-0.5 font-condensed text-accent"
+ className="btn btn-sm btn-primary"
>
{exportText.approve}
setAblehnung(auftrag.id)}
- className="font-condensed text-text-dim"
+ className="btn btn-sm"
>
{exportText.reject}
@@ -74,7 +74,7 @@ export function Approvals({ jobs }: { jobs: Auftrag[] }) {
rows={2}
placeholder={exportText.rejectReason}
aria-label={exportText.rejectReason}
- className="block w-full max-w-sm border border-line bg-raised px-3 py-2 text-2xs"
+ className="block w-full max-w-sm field text-2xs"
/>
{exportText.reject}
setAblehnung(null)}
- className="font-condensed text-text-dim"
+ className="btn btn-sm"
>
{commonText.cancel}
diff --git a/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx b/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx
index 7a7edfb..726d02f 100644
--- a/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/CaseActions.tsx
@@ -52,7 +52,7 @@ export function CaseActions({
setNextStatus(event.target.value)}
- className="border border-line bg-raised px-2 py-1"
+ className="field"
>
{statusTargets.map((target) => (
@@ -64,7 +64,7 @@ export function CaseActions({
value={statusReason}
onChange={(event) => setStatusReason(event.target.value)}
placeholder={entryText.changeReason}
- className="w-56 border border-line bg-raised px-2 py-1"
+ className="w-56 field"
/>
{pending ? caseText.saving : commonText.save}
@@ -94,7 +94,7 @@ export function CaseActions({
setNextLevel(Number(event.target.value))}
- className="border border-line bg-raised px-2 py-1"
+ className="field"
>
{levels
.filter((level) => level.level <= maxLevel)
@@ -111,7 +111,7 @@ export function CaseActions({
value={levelReason}
onChange={(event) => setLevelReason(event.target.value)}
placeholder={caseText.reclassifyReason}
- className="w-64 border border-line bg-raised px-2 py-1"
+ className="w-64 field"
/>
) : null}
{pending ? caseText.saving : commonText.save}
diff --git a/web/app/(app)/faelle/[caseNumber]/CaseLinks.tsx b/web/app/(app)/faelle/[caseNumber]/CaseLinks.tsx
index 6df303b..918026c 100644
--- a/web/app/(app)/faelle/[caseNumber]/CaseLinks.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/CaseLinks.tsx
@@ -72,7 +72,7 @@ export function CaseLinks({
if (!ergebnis.ok) setFehler(ergebnis.error)
})
}
- className="font-condensed text-2xs text-text-dim"
+ className="btn btn-sm"
>
{linkText.remove}
@@ -110,7 +110,7 @@ export function CaseLinks({
onChange={(event) => setTarget(event.target.value)}
placeholder={linkText.target}
aria-label={linkText.target}
- className="border border-line bg-raised px-2 py-1 font-mono text-2xs"
+ className="field font-mono text-2xs"
/>
) : null}
diff --git a/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx b/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx
index 0c065e9..ed5eb05 100644
--- a/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/EntryActions.tsx
@@ -53,13 +53,13 @@ export function EntryActions({
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={entryText.retractReason}
- className="w-64 border border-line bg-raised px-2 py-1"
+ className="w-64 field"
/>
run(() => restoreEntryAction({ entryId, caseNumber, reason }))}
- className="font-condensed text-accent disabled:text-text-dim"
+ className="btn btn-sm btn-primary"
>
{entryText.restore}
@@ -73,7 +73,7 @@ export function EntryActions({
return (
{canEdit ? (
-
setMode('edit')} className="font-condensed text-accent">
+ setMode('edit')} className="btn btn-sm btn-primary">
{entryText.edit}
) : null}
@@ -81,7 +81,7 @@ export function EntryActions({
setMode('retract')}
- className="font-condensed text-accent"
+ className="btn btn-sm btn-primary"
>
{entryText.retract}
@@ -97,7 +97,7 @@ export function EntryActions({
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={4}
- className="block w-full border border-line bg-raised px-3 py-2 text-sm"
+ className="block w-full field"
/>
) : null}
@@ -105,7 +105,7 @@ export function EntryActions({
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={mode === 'edit' ? entryText.changeReason : entryText.retractReason}
- className="mt-2 block w-full border border-line bg-raised px-2 py-1"
+ className="mt-2 block w-full field"
/>
@@ -119,7 +119,7 @@ export function EntryActions({
: retractEntryAction({ entryId, caseNumber, reason }),
)
}
- className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
+ className="btn btn-primary"
>
{pending ? caseText.saving : mode === 'edit' ? entryText.editSave : entryText.retract}
@@ -131,7 +131,7 @@ export function EntryActions({
setReason('')
setError(null)
}}
- className="font-condensed text-text-dim"
+ className="btn btn-sm"
>
{entryText.cancel}
diff --git a/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx b/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx
index eb7158d..e6bcd9f 100644
--- a/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/EntryComposer.tsx
@@ -67,12 +67,12 @@ export function EntryComposer({ caseNumber }: { caseNumber: string }) {
}}
rows={3}
placeholder={caseText.entryPlaceholder}
- className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1.5 block w-full field"
/>
{pending ? caseText.saving : entryText.create}
diff --git a/web/app/(app)/faelle/[caseNumber]/Participants.tsx b/web/app/(app)/faelle/[caseNumber]/Participants.tsx
index 4ee4a6f..1f49375 100644
--- a/web/app/(app)/faelle/[caseNumber]/Participants.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/Participants.tsx
@@ -77,7 +77,7 @@ export function Participants({
if (!ergebnis.ok) setFehler(ergebnis.error)
})
}
- className="font-condensed text-2xs text-text-dim"
+ className="btn btn-sm"
>
{participantText.remove}
diff --git a/web/app/(app)/faelle/[caseNumber]/WatchToggle.tsx b/web/app/(app)/faelle/[caseNumber]/WatchToggle.tsx
index f7e75ba..f477b54 100644
--- a/web/app/(app)/faelle/[caseNumber]/WatchToggle.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/WatchToggle.tsx
@@ -35,7 +35,8 @@ export function WatchToggle({
else setAktiv(ergebnis.watching)
})
}
- className={`font-condensed text-2xs ${aktiv ? 'text-accent' : 'text-text-dim'}`}
+ aria-pressed={aktiv}
+ className="btn btn-sm"
>
{aktiv ? watchText.unwatch : watchText.watch}
diff --git a/web/app/(app)/faelle/[caseNumber]/layout.tsx b/web/app/(app)/faelle/[caseNumber]/layout.tsx
index 8139ed9..718acb7 100644
--- a/web/app/(app)/faelle/[caseNumber]/layout.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/layout.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
-import Link from 'next/link'
import { notFound } from 'next/navigation'
import { db } from '@/lib/db'
+import { Tabs } from '@/components/Tabs'
import { requireUser } from '@/lib/auth/current'
import { loadVisibleCase } from '@/lib/cases/service'
import { de } from '@/lib/i18n/de'
@@ -52,12 +52,13 @@ export default async function CaseLayout({
])
const level = levels.find((l) => l.level === akte.classification)
+ const basis = `/faelle/${akte.caseNumber}`
const reiter = [
- { href: '', label: de.case.tabFile },
- { href: '/beteiligte', label: de.case.tabRelations },
- { href: '/dokumente', label: de.case.tabDocuments },
- { href: '/aenderungen', label: de.case.changes },
- { href: '/export', label: de.export.heading },
+ { href: basis, label: de.case.tabFile },
+ { href: `${basis}/beteiligte`, label: de.case.tabRelations },
+ { href: `${basis}/dokumente`, label: de.case.tabDocuments },
+ { href: `${basis}/aenderungen`, label: de.case.changes },
+ { href: `${basis}/export`, label: de.export.heading },
]
return (
@@ -97,13 +98,7 @@ export default async function CaseLayout({
/>
-
- {reiter.map((eintrag) => (
-
- {eintrag.label}
-
- ))}
-
+
{children}
diff --git a/web/app/(app)/faelle/neu/NewCaseForm.tsx b/web/app/(app)/faelle/neu/NewCaseForm.tsx
index f726a73..d7d3a54 100644
--- a/web/app/(app)/faelle/neu/NewCaseForm.tsx
+++ b/web/app/(app)/faelle/neu/NewCaseForm.tsx
@@ -30,7 +30,7 @@ export function NewCaseForm({
required
minLength={3}
maxLength={200}
- className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1.5 block w-full field"
/>
@@ -42,7 +42,7 @@ export function NewCaseForm({
id="templateKey"
name="templateKey"
required
- className="mt-1.5 block border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1.5 block field"
>
{templates.map((template) => (
@@ -61,7 +61,7 @@ export function NewCaseForm({
id="classification"
name="classification"
defaultValue={defaultLevel}
- className="mt-1.5 block border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1.5 block field"
>
{levels.map((level) => (
@@ -82,7 +82,7 @@ export function NewCaseForm({
id="personnelFileId"
name="personnelFileId"
defaultValue=""
- className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1.5 block w-full field"
>
{caseText.subjectNone}
{people.map((person) => (
@@ -98,7 +98,7 @@ export function NewCaseForm({
{pending ? caseText.saving : caseText.create}
diff --git a/web/app/(app)/kanaele/NewChannelForm.tsx b/web/app/(app)/kanaele/NewChannelForm.tsx
deleted file mode 100644
index 032fcba..0000000
--- a/web/app/(app)/kanaele/NewChannelForm.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-'use client'
-
-import { useActionState, useState } from 'react'
-import { channelText } from '@/lib/i18n/chat'
-import { clearanceText, commonText } from '@/lib/i18n/common'
-import { createChannelAction, type ActionResult } from './actions'
-
-export function NewChannelForm({ levels }: { levels: { level: number; name: string }[] }) {
- const [offen, setOffen] = useState(false)
- const [state, action, pending] = useActionState(
- createChannelAction,
- null,
- )
-
- if (!offen) {
- return (
- setOffen(true)}
- className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
- >
- {channelText.create}
-
- )
- }
-
- return (
-
- )
-}
diff --git a/web/app/(app)/kanaele/[key]/page.tsx b/web/app/(app)/kanaele/[key]/page.tsx
deleted file mode 100644
index dd57315..0000000
--- a/web/app/(app)/kanaele/[key]/page.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import type { Metadata } from 'next'
-import { notFound } from 'next/navigation'
-import { requireUser } from '@/lib/auth/current'
-import { AccessError } from '@/lib/errors'
-import { loadChatView } from '@/lib/chat/load'
-import { db } from '@/lib/db'
-import { visibleChannelWhere } from '@/lib/access/chatFilter'
-import { de } from '@/lib/i18n/de'
-import { Chat } from '@/components/chat/Chat'
-
-export const dynamic = 'force-dynamic'
-
-export async function generateMetadata({
- params,
-}: {
- params: Promise<{ key: string }>
-}): Promise {
- const { key } = await params
- return { title: `#${key}` }
-}
-
-export default async function ChannelPage({ params }: { params: Promise<{ key: string }> }) {
- const user = await requireUser()
- const { key } = await params
-
- const kanal = await db.channel.findFirst({
- where: { AND: [visibleChannelWhere(user.permissions), { key, isArchived: false }] },
- select: { id: true, key: true, name: true, description: true },
- })
- // Kein Zugriff heisst 404.
- if (!kanal) notFound()
-
- let ansicht
- try {
- ansicht = await loadChatView(user.permissions, { kind: 'channel', key: kanal.key })
- } catch (error) {
- if (error instanceof AccessError) notFound()
- throw error
- }
-
- return (
-
-
- #{kanal.key}
- {kanal.name}
- {kanal.description ? (
-
- {kanal.description}
-
- ) : null}
-
-
-
-
-
-
- {de.chat.hint}
-
- )
-}
diff --git a/web/app/(app)/kanaele/actions.ts b/web/app/(app)/kanaele/actions.ts
deleted file mode 100644
index b083ffc..0000000
--- a/web/app/(app)/kanaele/actions.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-'use server'
-
-import { redirect } from 'next/navigation'
-import { z } from 'zod'
-import { requireUser } from '@/lib/auth/current'
-import { AccessError } from '@/lib/errors'
-import { createChannel } from '@/lib/chat/service'
-import { de } from '@/lib/i18n/de'
-
-export type ActionResult = { ok: true } | { ok: false; error: string }
-
-const schema = z.object({
- key: z.string().min(2).max(40),
- name: z.string().min(2).max(120),
- description: z.string().max(500).optional(),
- minClearance: z.coerce.number().int().min(0).max(5),
-})
-
-export async function createChannelAction(
- _prev: ActionResult | null,
- formData: FormData,
-): Promise {
- const user = await requireUser()
- const parsed = schema.safeParse(Object.fromEntries(formData))
- if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
-
- let key: string
- try {
- const kanal = await createChannel(user.permissions, parsed.data)
- key = kanal.key
- } catch (error) {
- if (error instanceof AccessError) {
- return { ok: false, error: de.actionErrors[error.code] ?? de.actionErrors.default! }
- }
- console.error('[kanaele] unerwartet', error)
- return { ok: false, error: de.actionErrors.default! }
- }
-
- redirect(`/kanaele/${key}`)
-}
diff --git a/web/app/(app)/kanaele/page.tsx b/web/app/(app)/kanaele/page.tsx
deleted file mode 100644
index d950ef5..0000000
--- a/web/app/(app)/kanaele/page.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import type { Metadata } from 'next'
-import Link from 'next/link'
-import { db } from '@/lib/db'
-import { requireUser } from '@/lib/auth/current'
-import { listChannels } from '@/lib/chat/service'
-import { de } from '@/lib/i18n/de'
-import { StampMark } from '@/components/StampMark'
-import { NewChannelForm } from './NewChannelForm'
-
-export const metadata: Metadata = { title: de.channel.heading }
-export const dynamic = 'force-dynamic'
-
-/**
- * Freie Kanäle: Räume für Absprachen, die zu keiner Akte gehören.
- *
- * Ein Kanal über der Stufe des Lesers taucht nicht auf. Anders als bei Akten
- * ist die Existenz eines Kanals aber kein Geheimnis für die, die seine Stufe
- * haben — ein Raum, den man erraten müsste, wäre keiner.
- */
-export default async function ChannelListPage() {
- const user = await requireUser()
-
- const [kanaele, levels] = await Promise.all([
- listChannels(user.permissions),
- db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
- ])
- const levelName = new Map(levels.map((l) => [l.level, l.name]))
- const darfAnlegen = user.permissions.isAdmin || user.permissions.permissions.has('case.create')
-
- return (
-
- {de.channel.heading}
-
- {de.channel.intro}
-
-
- {kanaele.length === 0 ? (
- {de.channel.empty}
- ) : (
-
- {kanaele.map((kanal) => (
-
-
- #{kanal.key}
- {kanal.name}
- {kanal.minClearance > 0 ? (
-
- ) : null}
-
-
- ))}
-
- )}
-
- {darfAnlegen ? (
-
- l.level <= user.permissions.clearanceLevel)}
- />
-
- ) : null}
-
- )
-}
diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx
index 9657c23..a350314 100644
--- a/web/app/(app)/layout.tsx
+++ b/web/app/(app)/layout.tsx
@@ -3,9 +3,21 @@ import { signOut } from '@/auth'
import { requireUser } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de'
import { PaletteTrigger } from '@/components/palette/PaletteTrigger'
-import { ThemeToggle } from '@/components/ThemeToggle'
+import { ProfileMenu } from '@/components/ProfileMenu'
+import { Avatar } from '@/components/Avatar'
+import { SearchBar } from '@/components/SearchBar'
import { Vitals } from '@/components/Vitals'
+import { hasAdminAccess } from './admin/sections'
+/**
+ * Das Rahmenwerk der angemeldeten Anwendung.
+ *
+ * Die Kopfzeile trägt links die Bereiche der Arbeit — Übersicht, Fallakten,
+ * Personalakten, Verwaltung — und rechts alles, was den Nutzer selbst
+ * betrifft: Suchbalken und Profilbild. Thema, Benachrichtigungen und Abmelden
+ * standen früher zwischen den Aktenbereichen und nahmen dort den Platz weg,
+ * den die Arbeit braucht; sie sitzen jetzt hinter dem Profilbild.
+ */
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser()
@@ -14,48 +26,43 @@ export default async function AppLayout({ children }: { children: React.ReactNod
await signOut({ redirectTo: '/anmelden' })
}
+ const darfPersonal =
+ user.permissions.isAdmin || user.permissions.permissions.has('personnel.read')
+
return (
{/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */}
-
+
{de.app.shortName}
-
+
{de.nav.dashboard}
{de.nav.cases}
- {user.permissions.isAdmin || user.permissions.permissions.has('personnel.read') ? (
- {de.nav.personnel}
- ) : null}
- {de.nav.search}
- {de.channel.heading}
- {de.notify.heading}
- {user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
- <>
- {de.nav.admin}
- {user.permissions.isAdmin ||
- user.permissions.permissions.has('template.manage') ? (
- {de.templateAdmin.heading}
- ) : null}
- {user.permissions.isAdmin ? (
- {de.settings.heading}
- ) : null}
- >
+ {darfPersonal ? {de.nav.personnel} : null}
+ {hasAdminAccess(user.permissions) ? (
+ {de.nav.admin}
) : null}
-
-
-
{user.displayName}
-
+
diff --git a/web/app/(app)/personalakten/[id]/Assignments.tsx b/web/app/(app)/personalakten/[id]/Assignments.tsx
index 36ac0f1..07c8829 100644
--- a/web/app/(app)/personalakten/[id]/Assignments.tsx
+++ b/web/app/(app)/personalakten/[id]/Assignments.tsx
@@ -63,7 +63,7 @@ export function Assignments({
if (!ergebnis.ok) setFehler(ergebnis.error)
})
}
- className="font-condensed text-2xs text-text-dim"
+ className="btn btn-sm"
>
{assignmentText.remove}
diff --git a/web/app/(app)/personalakten/[id]/LinkCase.tsx b/web/app/(app)/personalakten/[id]/LinkCase.tsx
index 8f388d2..755037f 100644
--- a/web/app/(app)/personalakten/[id]/LinkCase.tsx
+++ b/web/app/(app)/personalakten/[id]/LinkCase.tsx
@@ -42,7 +42,7 @@ export function LinkCase({ personnelFileId }: { personnelFileId: string }) {
onChange={(event) => setCaseNumber(event.target.value)}
placeholder={linkText.target}
aria-label={linkText.target}
- className="border border-line bg-raised px-2 py-1 font-mono text-2xs"
+ className="field font-mono text-2xs"
/>
)
diff --git a/web/app/(app)/personalakten/[id]/PersonnelHead.tsx b/web/app/(app)/personalakten/[id]/PersonnelHead.tsx
index 510a4d1..45bc469 100644
--- a/web/app/(app)/personalakten/[id]/PersonnelHead.tsx
+++ b/web/app/(app)/personalakten/[id]/PersonnelHead.tsx
@@ -79,7 +79,7 @@ export function PersonnelHead({
setEditing(true)}
- className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
+ className="btn btn-sm btn-primary"
>
{personnelText.edit}
@@ -129,7 +129,7 @@ export function PersonnelHead({
name="note"
rows={3}
defaultValue={file.note ?? ''}
- className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1 block w-full field"
/>
@@ -141,14 +141,14 @@ export function PersonnelHead({
{pending ? commonText.loading : commonText.save}
setEditing(false)}
- className="font-condensed text-2xs text-text-dim"
+ className="btn btn-sm"
>
{commonText.cancel}
@@ -179,7 +179,7 @@ function Reclassify({
setOpen(true)}
- className="font-condensed text-2xs text-text-dim"
+ className="btn btn-sm"
>
{personnelText.reclassify}
@@ -193,7 +193,7 @@ function Reclassify({
value={to}
onChange={(event) => setTo(Number(event.target.value))}
aria-label={clearanceText.classification}
- className="border border-line bg-raised px-2 py-1 text-2xs"
+ className="field text-2xs"
>
{levels.map((level) => (
@@ -211,14 +211,14 @@ function Reclassify({
else setError(result.error)
})
}
- className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
+ className="btn btn-sm btn-primary"
>
{commonText.save}
setOpen(false)}
- className="font-condensed text-2xs text-text-dim"
+ className="btn btn-sm"
>
{commonText.cancel}
@@ -233,7 +233,7 @@ function Reclassify({
rows={2}
placeholder={caseText.reclassifyReason}
aria-label={caseText.reclassifyReason}
- className="mt-2 block w-full border border-line bg-raised px-3 py-2 text-2xs"
+ className="mt-2 block w-full field text-2xs"
/>
) : null}
@@ -282,7 +282,7 @@ function Feld({
minLength={required ? 2 : undefined}
maxLength={120}
defaultValue={defaultValue ?? ''}
- className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1 block w-full field"
/>
)
diff --git a/web/app/(app)/personalakten/[id]/page.tsx b/web/app/(app)/personalakten/[id]/page.tsx
index 44b76cb..3a1a299 100644
--- a/web/app/(app)/personalakten/[id]/page.tsx
+++ b/web/app/(app)/personalakten/[id]/page.tsx
@@ -14,6 +14,8 @@ import {
import { listDocuments, listTemplatesFor } from '@/lib/documents/service'
import { canWritePersonnel } from '@/lib/access/documentWrite'
import { trackOpen } from '@/lib/recent/track'
+import { loadChatView } from '@/lib/chat/load'
+import { ChatLazy } from '@/components/chat/ChatLazy'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { SkeletonLine } from '@/components/Skeleton'
@@ -160,6 +162,40 @@ async function AssignmentBlock({
)
}
+/**
+ * Der Chat einer Personalakte.
+ *
+ * Denselben formlosen Teil hat die Fallakte auch. Er steht bewusst **in** der
+ * Akte und nicht in einem freien Raum daneben: über eine Person zu reden, ohne
+ * dass das Gespräch bei ihrer Akte steht, war genau das, was vorher auf
+ * Discord passiert ist. Siehe docs/13-entscheidungen.md.
+ *
+ * Übernehmen als Vermerk gibt es hier nicht — dafür braucht es einen Verlauf,
+ * und den führt nur die Fallakte.
+ */
+async function ChatBlock({
+ file,
+ user,
+}: {
+ file: { id: string }
+ user: { id: string; permissions: EffectivePermissions }
+}) {
+ const ansicht = await loadChatView(user.permissions, { kind: 'personnel', fileId: file.id })
+
+ return (
+
+ )
+}
+
export default async function PersonnelPage({ params }: { params: Promise<{ id: string }> }) {
const user = await requirePermission('personnel.read')
const { id } = await params
@@ -249,6 +285,15 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id:
+
+
+ {de.chat.heading}
+
+ }>
+
+
+
+
)
}
diff --git a/web/app/(app)/personalakten/neu/NewPersonnelForm.tsx b/web/app/(app)/personalakten/neu/NewPersonnelForm.tsx
index ac47ded..12c74f3 100644
--- a/web/app/(app)/personalakten/neu/NewPersonnelForm.tsx
+++ b/web/app/(app)/personalakten/neu/NewPersonnelForm.tsx
@@ -40,7 +40,7 @@ export function NewPersonnelForm({
id="userId"
name="userId"
defaultValue=""
- className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1.5 block w-full field"
>
{personnelText.noAccount}
{members.map((member) => (
@@ -79,7 +79,7 @@ export function NewPersonnelForm({
id="note"
name="note"
rows={3}
- className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1.5 block w-full field"
/>
{personnelText.noteHelp}
@@ -93,7 +93,7 @@ export function NewPersonnelForm({
id="classification"
name="classification"
defaultValue={defaultLevel}
- className="mt-1.5 block border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1.5 block field"
>
{levels.map((level) => (
@@ -110,7 +110,7 @@ export function NewPersonnelForm({
{pending ? commonText.loading : personnelText.create}
@@ -140,7 +140,7 @@ function Feld({
required={required}
minLength={minLength}
maxLength={120}
- className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
+ className="mt-1.5 block w-full field"
/>
)
diff --git a/web/app/(app)/personalakten/page.tsx b/web/app/(app)/personalakten/page.tsx
index 6cc17ec..f8f3bef 100644
--- a/web/app/(app)/personalakten/page.tsx
+++ b/web/app/(app)/personalakten/page.tsx
@@ -49,7 +49,7 @@ export default async function PersonnelListPage({
defaultValue={q ?? ''}
placeholder={de.personnel.search}
aria-label={de.personnel.search}
- className="w-full max-w-sm border border-line bg-raised px-3 py-2 text-sm"
+ className="w-full max-w-sm field"
/>
{status ?
: null}
{darfAnlegen ? (
diff --git a/web/app/(app)/suche/SavedViews.tsx b/web/app/(app)/suche/SavedViews.tsx
index 7bc3136..2a55e94 100644
--- a/web/app/(app)/suche/SavedViews.tsx
+++ b/web/app/(app)/suche/SavedViews.tsx
@@ -47,7 +47,7 @@ export function SavedViews({
disabled={pending}
title={searchText.deleteView}
onClick={() => startTransition(async () => void (await deleteViewAction({ id: view.id })))}
- className="text-text-dim"
+ className="btn btn-sm"
>
×
@@ -62,7 +62,7 @@ export function SavedViews({
placeholder={searchText.saveViewName}
aria-label={searchText.saveViewName}
maxLength={60}
- className="border border-line bg-raised px-2 py-1"
+ className="field"
/>
{commonText.save}
setOffen(false)}
- className="font-condensed text-text-dim"
+ className="btn btn-sm"
>
{commonText.cancel}
@@ -94,7 +94,7 @@ export function SavedViews({
setOffen(true)}
- className="ml-auto font-condensed text-text-dim"
+ className="ml-auto btn btn-sm"
>
{searchText.saveView}
diff --git a/web/app/(app)/suche/SearchBox.tsx b/web/app/(app)/suche/SearchBox.tsx
index 2949163..ebddd5e 100644
--- a/web/app/(app)/suche/SearchBox.tsx
+++ b/web/app/(app)/suche/SearchBox.tsx
@@ -50,7 +50,7 @@ export function SearchBox({ filters }: { filters: SearchFilters }) {
placeholder={searchText.placeholder}
aria-label={searchText.placeholder}
autoFocus
- className="w-full max-w-xl border border-line bg-raised px-3 py-2 text-sm"
+ className="w-full max-w-xl field"
/>
)
diff --git a/web/app/(app)/uebersicht/page.tsx b/web/app/(app)/uebersicht/page.tsx
index c7637c8..02a2369 100644
--- a/web/app/(app)/uebersicht/page.tsx
+++ b/web/app/(app)/uebersicht/page.tsx
@@ -57,7 +57,7 @@ export default async function DashboardPage() {
{de.dashboard.opened}
{daten.geoeffnet.length > 0 ? (
diff --git a/web/app/(auth)/anmelden/page.tsx b/web/app/(auth)/anmelden/page.tsx
index c8b1ea0..13dfe10 100644
--- a/web/app/(auth)/anmelden/page.tsx
+++ b/web/app/(auth)/anmelden/page.tsx
@@ -23,7 +23,7 @@ export default async function SignInPage() {