Runde Bedienelemente, Admin zusammengelegt, Kanäle entfallen

Vier der acht Änderungswünsche.

**Knöpfe.** Rund vierzig der siebzig Knöpfe waren blosser Text in Akzentfarbe,
von einem Link nicht zu unterscheiden. Jeder trägt jetzt Rahmen und
Grundfläche, über die Klasse .btn an einer Stelle definiert statt siebzigmal
als Klassenkette. Beim Drücken zieht der Knopf sich 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
langsam ab, sonst bliebe ein kurzer Klick unsichtbar.

Dazu die neue Regel: Flächen bleiben eckig, alles Bedienbare ist rund. Damit
ist die Rundung eine Auskunft und keine Verzierung.

**Verwaltung.** Rollen, Vorlagen und Änderungsrichtlinie waren drei Einträge in
der Kopfzeile für etwas, das man selten und dann zusammenhängend braucht. Jetzt
ein Eintrag /admin mit Reiterleiste, dazu eine gemeinsame Tabs-Komponente, die
auch der Fallakte zeigt, welcher Reiter offen ist.

**Kanäle.** Ersatzlos entfernt, mitsamt Tabelle. Der Chat gehört dorthin, wo
der Vorgang steht; ein eigener Reiter mit freien Räumen war der Rückweg nach
Discord. Die Personalakte hat jetzt denselben Chat wie die Fallakte — ohne
„als Vermerk übernehmen", denn dafür braucht es einen Verlauf, und Meldungen
gehen nur an Erwähnte, nie in den allgemeinen Kanal: dass es zu jemandem eine
Personalakte gibt, ist selbst eine Auskunft. Ein CHECK erzwingt jetzt, dass
eine Nachricht zu genau einem Ort gehört.

**Kopfzeile.** Suchbalken statt Suchlink, als gewöhnliches Formular ohne ein
Byte im Bündel. Thema, Benachrichtigungen und Abmelden sitzen hinter dem
Profilbild, das von Discord kommt; wer keins hat, bekommt seine Initialen.

Nebenbei: Aktenzeichen in Tests waren nur auf vier Stellen eindeutig und
kollidierten zwischen Läufen, weil Akten nicht löschbar sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-03 15:07:43 +02:00
co-authored by Claude Opus 5
parent bbae8546a0
commit ef377f0123
84 changed files with 1234 additions and 749 deletions
+35
View File
@@ -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 (
<main className="mx-auto max-w-5xl px-6 py-10">
<header>
<h1 className="font-condensed">{de.admin.heading}</h1>
<p className="mt-1 text-2xs text-text-dim">{de.admin.intro}</p>
</header>
<Tabs tabs={bereiche.map((bereich) => ({ href: bereich.href, label: bereich.label }))} />
<div className="mt-8">{children}</div>
</main>
)
}
+33
View File
@@ -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 (
<ul className="border-t border-line">
{bereiche.map((bereich) => (
<li key={bereich.key} className="border-b border-line">
<Link href={bereich.href} className="block py-3 no-underline">
<span className="font-condensed text-text">{bereich.label}</span>
<span className="mt-0.5 block text-2xs text-text-dim">{bereich.description}</span>
</Link>
</li>
))}
</ul>
)
}
@@ -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"
/>
</label>
@@ -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}
</button>
+3 -3
View File
@@ -13,8 +13,8 @@ export default async function PolicyPage() {
const setting = await db.appSetting.findUnique({ where: { id: 1 } })
return (
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
<h1 className="font-condensed">{de.settings.heading}</h1>
<section>
<h2 className="font-condensed">{de.settings.heading}</h2>
<p className="mt-2 text-text-dim">{de.settings.intro}</p>
<PolicyForm
initial={{
@@ -23,6 +23,6 @@ export default async function PolicyPage() {
entryEditMarkGrace: setting?.entryEditMarkGrace ?? false,
}}
/>
</main>
</section>
)
}
+3 -3
View File
@@ -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) => (
<option key={value} value={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) => (
<option key={level.level} value={level.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}
</button>
+3 -3
View File
@@ -27,8 +27,8 @@ export default async function RoleAdminPage() {
])
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<h1 className="font-condensed">{de.roleAdmin.heading}</h1>
<section>
<h2 className="font-condensed">{de.roleAdmin.heading}</h2>
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.roleAdmin.intro}</p>
{mappings.length === 0 ? (
@@ -73,6 +73,6 @@ export default async function RoleAdminPage() {
})}
</>
)}
</main>
</section>
)
}
+53
View File
@@ -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')
}
+3 -3
View File
@@ -54,8 +54,8 @@ export default async function SystemPage() {
await requirePermission('admin')
return (
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
<h1 className="font-condensed">{de.health.heading}</h1>
<section>
<h2 className="font-condensed">{de.health.heading}</h2>
<Suspense
fallback={
<div className="mt-6">
@@ -91,7 +91,7 @@ export default async function SystemPage() {
<a href="/api/admin/export?kind=bundle">{de.export.bundle}</a>
</p>
</section>
</main>
</section>
)
}
@@ -85,7 +85,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
<input
value={name}
onChange={(event) => 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"
/>
</label>
@@ -94,7 +94,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
<input
value={description}
onChange={(event) => 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"
/>
</label>
@@ -104,7 +104,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
<input
value={numberPrefix}
onChange={(event) => 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"
/>
</label>
<label className="block">
@@ -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"
/>
</label>
</div>
@@ -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"
/>
<span className="mt-1 block text-2xs text-text-dim">{templateAdminText.fieldsHelp}</span>
</label>
@@ -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}
</button>
+3 -3
View File
@@ -17,8 +17,8 @@ export default async function TemplateAdminPage() {
})
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<h1 className="font-condensed">{de.templateAdmin.heading}</h1>
<section>
<h2 className="font-condensed">{de.templateAdmin.heading}</h2>
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.templateAdmin.intro}</p>
{vorlagen.length === 0 ? (
@@ -41,6 +41,6 @@ export default async function TemplateAdminPage() {
))}
</div>
)}
</main>
</section>
)
}
+2 -2
View File
@@ -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<ActionResult>
}
const readSchema = z.object({
scopeType: z.enum(['case', 'channel']),
scopeType: z.enum(['case', 'personnel']),
scopeId: z.string().min(1),
})
@@ -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}
</button>
@@ -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) => (
<option key={level.level} value={level.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}
</button>
@@ -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}
</div>
@@ -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"
/>
<div className="mt-2 flex gap-3">
<button
type="button"
disabled={pending || reason.trim().length < 10}
onClick={() => 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}
</button>
<button
type="button"
onClick={() => setRetracting(false)}
className="font-condensed text-text-dim"
className="btn btn-sm"
>
{commonText.cancel}
</button>
@@ -150,7 +150,7 @@ export function DocumentActions({
<button
type="button"
onClick={() => setRetracting(true)}
className="font-condensed text-text-dim"
className="btn btn-sm"
>
{documentText.retract}
</button>
+1 -1
View File
@@ -30,7 +30,7 @@ export function NotifyForm({ current }: { current: string }) {
<button
type="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"
>
{notifyText.save}
</button>
+5 -5
View File
@@ -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}
</button>
<button
type="button"
onClick={() => setAblehnung(auftrag.id)}
className="font-condensed text-text-dim"
className="btn btn-sm"
>
{exportText.reject}
</button>
@@ -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"
/>
<div className="mt-1 flex gap-3 text-2xs">
<button
@@ -93,14 +93,14 @@ export function Approvals({ jobs }: { jobs: Auftrag[] }) {
}
})
}
className="font-condensed text-stamp-4 disabled:text-text-dim"
className="btn btn-sm text-stamp-4"
>
{exportText.reject}
</button>
<button
type="button"
onClick={() => setAblehnung(null)}
className="font-condensed text-text-dim"
className="btn btn-sm"
>
{commonText.cancel}
</button>
@@ -52,7 +52,7 @@ export function CaseActions({
<select
value={nextStatus}
onChange={(event) => setNextStatus(event.target.value)}
className="border border-line bg-raised px-2 py-1"
className="field"
>
{statusTargets.map((target) => (
<option key={target} value={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"
/>
<button
type="button"
@@ -80,7 +80,7 @@ export function CaseActions({
`${caseText.statusChange}: ${caseText.statusNames[nextStatus] ?? nextStatus}?`,
)
}
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 : commonText.save}
</button>
@@ -94,7 +94,7 @@ export function CaseActions({
<select
value={nextLevel}
onChange={(event) => 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}
<button
@@ -128,7 +128,7 @@ export function CaseActions({
`${caseText.reclassify}: ${nextLevel}?`,
)
}
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 : commonText.save}
</button>
@@ -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}
</button>
@@ -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"
/>
</AddPanel>
) : null}
@@ -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"
/>
<button
type="button"
disabled={pending}
onClick={() => run(() => restoreEntryAction({ entryId, caseNumber, reason }))}
className="font-condensed text-accent disabled:text-text-dim"
className="btn btn-sm btn-primary"
>
{entryText.restore}
</button>
@@ -73,7 +73,7 @@ export function EntryActions({
return (
<div className="mt-1.5 flex gap-3 text-2xs">
{canEdit ? (
<button type="button" onClick={() => setMode('edit')} className="font-condensed text-accent">
<button type="button" onClick={() => setMode('edit')} className="btn btn-sm btn-primary">
{entryText.edit}
</button>
) : null}
@@ -81,7 +81,7 @@ export function EntryActions({
<button
type="button"
onClick={() => setMode('retract')}
className="font-condensed text-accent"
className="btn btn-sm btn-primary"
>
{entryText.retract}
</button>
@@ -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"
/>
<div className="mt-2 flex items-center gap-3">
@@ -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}
</button>
@@ -131,7 +131,7 @@ export function EntryActions({
setReason('')
setError(null)
}}
className="font-condensed text-text-dim"
className="btn btn-sm"
>
{entryText.cancel}
</button>
@@ -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"
/>
<button
type="submit"
disabled={pending || draft.trim().length === 0}
className="mt-2 border border-line bg-raised px-3 py-1.5 font-condensed text-xs text-accent disabled:text-text-dim"
className="mt-2 btn btn-primary"
>
{pending ? caseText.saving : entryText.create}
</button>
@@ -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}
</button>
@@ -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}
</button>
+8 -13
View File
@@ -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({
/>
</header>
<nav className="mt-8 flex flex-wrap gap-4 border-b border-line pb-2 font-condensed text-xs">
{reiter.map((eintrag) => (
<Link key={eintrag.href} href={`/faelle/${akte.caseNumber}${eintrag.href}`}>
{eintrag.label}
</Link>
))}
</nav>
<Tabs tabs={reiter} />
<div className="mt-8">{children}</div>
</main>
+5 -5
View File
@@ -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"
/>
</div>
@@ -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) => (
<option key={template.key} value={template.key}>
@@ -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) => (
<option key={level.level} value={level.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"
>
<option value="">{caseText.subjectNone}</option>
{people.map((person) => (
@@ -98,7 +98,7 @@ export function NewCaseForm({
<button
type="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 : caseText.create}
</button>
-116
View File
@@ -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<ActionResult | null, FormData>(
createChannelAction,
null,
)
if (!offen) {
return (
<button
type="button"
onClick={() => setOffen(true)}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
>
{channelText.create}
</button>
)
}
return (
<form action={action} className="max-w-(--container-measure) border border-line p-4">
<p className="font-condensed text-xs text-text-dim">{channelText.createTitle}</p>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="name" className="font-condensed text-2xs text-text-dim">
{channelText.name}
</label>
<input
id="name"
name="name"
required
minLength={2}
maxLength={120}
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="key" className="font-condensed text-2xs text-text-dim">
{channelText.key}
</label>
<input
id="key"
name="key"
required
pattern="[a-z0-9][a-z0-9-]{1,40}"
className="mt-1 block w-full border border-line bg-raised px-3 py-2 font-mono text-sm"
/>
<p className="mt-1 text-2xs text-text-dim">{channelText.keyHint}</p>
</div>
<div className="sm:col-span-2">
<label htmlFor="description" className="font-condensed text-2xs text-text-dim">
{channelText.description}
</label>
<input
id="description"
name="description"
maxLength={500}
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="minClearance" className="font-condensed text-2xs text-text-dim">
{channelText.minClearance}
</label>
<select
id="minClearance"
name="minClearance"
defaultValue={0}
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
>
{levels.map((level) => (
<option key={level.level} value={level.level}>
{level.level} · {level.name}
</option>
))}
</select>
<p className="mt-1 text-2xs text-text-dim">
{channelText.minClearanceHint} {clearanceText.label}
</p>
</div>
</div>
{state && !state.ok ? (
<p className="mt-3 font-condensed text-2xs text-stamp-4">{state.error}</p>
) : null}
<div className="mt-4 flex gap-3">
<button
type="submit"
disabled={pending}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent disabled:text-text-dim"
>
{pending ? commonText.loading : channelText.create}
</button>
<button
type="button"
onClick={() => setOffen(false)}
className="font-condensed text-2xs text-text-dim"
>
{commonText.cancel}
</button>
</div>
</form>
)
}
-70
View File
@@ -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<Metadata> {
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 (
<main className="mx-auto max-w-5xl px-6 py-10">
<header>
<span className="font-mono text-xs text-text-dim">#{kanal.key}</span>
<h1 className="mt-1 font-condensed">{kanal.name}</h1>
{kanal.description ? (
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
{kanal.description}
</p>
) : null}
</header>
<div className="mt-8">
<Chat
scope={{ kind: 'channel', key: kanal.key, channelId: kanal.id }}
topic={`channel:${kanal.id}`}
initial={ansicht.messages}
initialReactions={ansicht.reactions}
currentUserId={user.id}
lastReadAt={ansicht.lastReadAt}
// Ein Vermerk gehört in eine Akte. Ein Kanal hat keine.
canAdopt={false}
mentionNames={ansicht.mentionNames}
/>
</div>
<p className="mt-4 text-2xs text-text-dim">{de.chat.hint}</p>
</main>
)
}
-40
View File
@@ -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<ActionResult> {
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}`)
}
-71
View File
@@ -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 (
<main className="mx-auto max-w-5xl px-6 py-10">
<h1 className="font-condensed">{de.channel.heading}</h1>
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
{de.channel.intro}
</p>
{kanaele.length === 0 ? (
<p className="mt-10 text-text-dim">{de.channel.empty}</p>
) : (
<ul className="mt-6 border-t border-line">
{kanaele.map((kanal) => (
<li key={kanal.id} className="border-b border-line">
<Link
href={`/kanaele/${kanal.key}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 text-text no-underline"
>
<span className="font-mono text-xs text-text-dim">#{kanal.key}</span>
<span className="grow">{kanal.name}</span>
{kanal.minClearance > 0 ? (
<StampMark
level={kanal.minClearance}
name={levelName.get(kanal.minClearance) ?? de.common.unknown}
/>
) : null}
</Link>
</li>
))}
</ul>
)}
{darfAnlegen ? (
<div className="mt-8">
<NewChannelForm
levels={levels.filter((l) => l.level <= user.permissions.clearanceLevel)}
/>
</div>
) : null}
</main>
)
}
+35 -28
View File
@@ -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 (
<div className="min-h-dvh">
{/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */}
<PaletteTrigger />
<Vitals />
<header className="border-b border-line">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-6 py-3">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-5 gap-y-3 px-6 py-3">
<Link href="/uebersicht" className="font-condensed text-text no-underline">
{de.app.shortName}
</Link>
<nav className="flex gap-4 font-condensed text-xs">
<nav className="flex flex-wrap gap-4 font-condensed text-xs">
<Link href="/uebersicht">{de.nav.dashboard}</Link>
<Link href="/faelle">{de.nav.cases}</Link>
{user.permissions.isAdmin || user.permissions.permissions.has('personnel.read') ? (
<Link href="/personalakten">{de.nav.personnel}</Link>
) : null}
<Link href="/suche">{de.nav.search}</Link>
<Link href="/kanaele">{de.channel.heading}</Link>
<Link href="/einstellungen">{de.notify.heading}</Link>
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
<>
<Link href="/admin/rollen">{de.nav.admin}</Link>
{user.permissions.isAdmin ||
user.permissions.permissions.has('template.manage') ? (
<Link href="/admin/vorlagen">{de.templateAdmin.heading}</Link>
) : null}
{user.permissions.isAdmin ? (
<Link href="/admin/richtlinie">{de.settings.heading}</Link>
) : null}
</>
{darfPersonal ? <Link href="/personalakten">{de.nav.personnel}</Link> : null}
{hasAdminAccess(user.permissions) ? (
<Link href="/admin">{de.nav.admin}</Link>
) : null}
</nav>
<div className="ml-auto flex items-center gap-3 text-xs text-text-dim">
<ThemeToggle />
<span className="font-mono">{user.displayName}</span>
<form action={abmelden}>
<button type="submit" className="font-condensed text-accent">
{de.auth.signOut}
</button>
</form>
<div className="ml-auto flex items-center gap-3">
<SearchBar />
<ProfileMenu
displayName={user.displayName}
username={user.username}
signOutAction={abmelden}
avatar={
<Avatar
userId={user.id}
avatarHash={user.avatarHash}
displayName={user.displayName}
/>
}
/>
</div>
</div>
</header>
@@ -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}
</button>
@@ -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"
/>
</AddPanel>
)
@@ -79,7 +79,7 @@ export function PersonnelHead({
<button
type="button"
onClick={() => 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}
</button>
@@ -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"
/>
</div>
@@ -141,14 +141,14 @@ export function PersonnelHead({
<button
type="submit"
disabled={pending}
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"
>
{pending ? commonText.loading : commonText.save}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="font-condensed text-2xs text-text-dim"
className="btn btn-sm"
>
{commonText.cancel}
</button>
@@ -179,7 +179,7 @@ function Reclassify({
<button
type="button"
onClick={() => setOpen(true)}
className="font-condensed text-2xs text-text-dim"
className="btn btn-sm"
>
{personnelText.reclassify}
</button>
@@ -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) => (
<option key={level.level} value={level.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}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
className="btn btn-sm"
>
{commonText.cancel}
</button>
@@ -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"
/>
</div>
)
+45
View File
@@ -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 (
<ChatLazy
scope={{ kind: 'personnel', fileId: file.id }}
topic={`personnel:${file.id}`}
initial={ansicht.messages}
initialReactions={ansicht.reactions}
currentUserId={user.id}
lastReadAt={ansicht.lastReadAt}
canAdopt={false}
mentionNames={ansicht.mentionNames}
/>
)
}
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:
</Suspense>
</div>
</section>
<section className="mt-10">
<h2 className="font-condensed">{de.chat.heading}</h2>
<div className="mt-4">
<Suspense fallback={<SkeletonLine width="60%" />}>
<ChatBlock file={akte} user={user} />
</Suspense>
</div>
</section>
</main>
)
}
@@ -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"
>
<option value="">{personnelText.noAccount}</option>
{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"
/>
<p className="mt-1 text-2xs text-text-dim">{personnelText.noteHelp}</p>
</div>
@@ -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) => (
<option key={level.level} value={level.level}>
@@ -110,7 +110,7 @@ export function NewPersonnelForm({
<button
type="submit"
disabled={pending}
className="border border-line bg-raised px-4 py-2 font-condensed text-sm text-accent disabled:text-text-dim"
className="btn btn-primary"
>
{pending ? commonText.loading : personnelText.create}
</button>
@@ -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"
/>
</div>
)
+1 -1
View File
@@ -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 ? <input type="hidden" name="status" value={status} /> : null}
{darfAnlegen ? (
+5 -5
View File
@@ -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"
>
×
</button>
@@ -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"
/>
<button
type="button"
@@ -78,14 +78,14 @@ export function SavedViews({
}
})
}
className="border border-line bg-raised px-2 py-1 font-condensed text-accent disabled:text-text-dim"
className="btn btn-sm btn-primary"
>
{commonText.save}
</button>
<button
type="button"
onClick={() => setOffen(false)}
className="font-condensed text-text-dim"
className="btn btn-sm"
>
{commonText.cancel}
</button>
@@ -94,7 +94,7 @@ export function SavedViews({
<button
type="button"
onClick={() => setOffen(true)}
className="ml-auto font-condensed text-text-dim"
className="ml-auto btn btn-sm"
>
{searchText.saveView}
</button>
+1 -1
View File
@@ -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"
/>
</div>
)
+1 -1
View File
@@ -57,7 +57,7 @@ export default async function DashboardPage() {
<h2 className="font-condensed">{de.dashboard.opened}</h2>
{daten.geoeffnet.length > 0 ? (
<form action={clearRecentAction}>
<button type="submit" className="font-condensed text-2xs text-text-dim">
<button type="submit" className="btn btn-sm">
{de.dashboard.openedClear}
</button>
</form>
+1 -1
View File
@@ -23,7 +23,7 @@ export default async function SignInPage() {
<form action={anmelden} className="mt-8">
<button
type="submit"
className="border border-line bg-raised px-4 py-2 font-condensed text-sm text-accent"
className="btn btn-primary"
>
{de.auth.signIn}
</button>
+17 -13
View File
@@ -1,7 +1,8 @@
import { getCurrentUser, type CurrentUser } from '@/lib/auth/current'
import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visibleChannelWhere, visibleMessageWhere } from '@/lib/access/chatFilter'
import { visibleMessageWhere } from '@/lib/access/chatFilter'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
import { resolveCasePreviews, type CasePreview } from '@/lib/chat/preview'
import { extractCaseNumbers, type ChatMessageView } from '@/lib/chat/view'
@@ -17,7 +18,7 @@ const HEARTBEAT_MS = 25_000
/**
* Ereignisstrom für offene Ansichten.
*
* GET /api/stream?topics=case:<id>,channel:<id>
* GET /api/stream?topics=case:<id>,personnel:<id>
*
* Server-Sent Events und kein WebSocket: die Richtung ist einseitig, es geht
* durch jeden Tunnel, und der Browser baut die Verbindung von selbst wieder
@@ -129,9 +130,9 @@ async function erlaubteThemen(user: CurrentUser, gewuenscht: string[]): Promise<
.filter((t) => t.startsWith('case:'))
.map((t) => t.slice(5))
.filter(Boolean)
const kanalIds = gewuenscht
.filter((t) => t.startsWith('channel:'))
.map((t) => t.slice(8))
const personalIds = gewuenscht
.filter((t) => t.startsWith('personnel:'))
.map((t) => t.slice(10))
.filter(Boolean)
if (fallIds.length > 0) {
@@ -142,12 +143,15 @@ async function erlaubteThemen(user: CurrentUser, gewuenscht: string[]): Promise<
themen.push(...sichtbar.map((akte) => `case:${akte.id}` as Topic))
}
if (kanalIds.length > 0) {
const sichtbar = await db.channel.findMany({
where: { AND: [visibleChannelWhere(user.permissions), { id: { in: kanalIds } }] },
// Personalakten setzen neben der Stufe ein eigenes Recht voraus.
const darfPersonal =
user.permissions.isAdmin || user.permissions.permissions.has('personnel.read')
if (personalIds.length > 0 && darfPersonal) {
const sichtbar = await db.personnelFile.findMany({
where: { AND: [visiblePersonnelWhere(user.permissions), { id: { in: personalIds } }] },
select: { id: true },
})
themen.push(...sichtbar.map((kanal) => `channel:${kanal.id}` as Topic))
themen.push(...sichtbar.map((akte) => `personnel:${akte.id}` as Topic))
}
return themen
@@ -163,8 +167,8 @@ async function nachliefern(
send: Send,
): Promise<void> {
const fallIds = themen.filter((t) => t.startsWith('case:')).map((t) => t.slice(5))
const kanalIds = themen.filter((t) => t.startsWith('channel:')).map((t) => t.slice(8))
if (fallIds.length === 0 && kanalIds.length === 0) return
const personalIds = themen.filter((t) => t.startsWith('personnel:')).map((t) => t.slice(10))
if (fallIds.length === 0 && personalIds.length === 0) return
try {
const verpasst = await db.chatMessage.findMany({
@@ -172,7 +176,7 @@ async function nachliefern(
AND: [
visibleMessageWhere(user.permissions),
{ id: { gt: seit } },
{ OR: [{ caseId: { in: fallIds } }, { channelId: { in: kanalIds } }] },
{ OR: [{ caseId: { in: fallIds } }, { personnelFileId: { in: personalIds } }] },
],
},
orderBy: { id: 'asc' },
@@ -199,7 +203,7 @@ const NACHRICHT = {
mentions: true,
createdAt: true,
caseId: true,
channelId: true,
personnelFileId: true,
} as const
/**
+1 -1
View File
@@ -10,7 +10,7 @@ export default function Error({ reset }: { error: Error; reset: () => void }) {
<button
type="button"
onClick={reset}
className="mt-4 border border-line bg-raised px-3 py-1.5 font-condensed text-xs"
className="mt-4 btn"
>
{commonText.retry}
</button>
+162 -1
View File
@@ -54,13 +54,19 @@
--text-lg--line-height: 1.45;
--text-xl--line-height: 1.3;
/* Struktur: Eckenradius 2 px, ueberall gleich. Akten sind nicht abgerundet. */
/* Struktur: Flaechen bleiben eckig, Bedienelemente sind rund.
Eine Akte ist ein Blatt und hat scharfe Kanten. Was man druecken oder
ausfuellen kann, ist rund — daran erkennt man es, bevor man es beruehrt. */
--radius-none: 0px;
--radius-sm: 2px;
--radius-DEFAULT: 2px;
--radius-md: 2px;
--radius-lg: 2px;
--radius-xl: 2px;
/* Eingabefelder: weich gerundet. Ein mehrzeiliges Feld kann keine Pille sein. */
--radius-field: 10px;
/* Knoepfe, Suchbalken, Marken, Profilbild: voll gerundet. */
--radius-control: 999px;
/* Keine Schatten ausser dem Fokusring. */
--shadow-sm: none;
@@ -189,6 +195,12 @@
text-underline-offset: 2px;
}
/* Auch Kaestchen und Schalter gehoeren zur interaktiven Ebene. */
input[type='checkbox'],
input[type='radio'] {
accent-color: var(--color-accent);
}
/* Sichtbarer Fokusring in --steel, der einzige Schatten im System. */
:focus-visible {
outline: 2px solid var(--color-accent);
@@ -202,6 +214,155 @@
}
@layer components {
/* ---------------------------------------------------------------------
Bedienelemente.
Zwei Regeln, beide aus dem Betrieb heraus:
**Ein Knopf muss vor der Beruehrung als Knopf erkennbar sein.** Deshalb
traegt jeder einen Rahmen und eine eigene Grundflaeche, auch der
unscheinbarste. Ein Text, der sich erst beim Ueberfahren als Knopf
entpuppt, wird nicht gefunden.
**Und er muss beim Druecken zurueckmelden, dass er getroffen wurde.**
Beim Druecken zieht sich der Knopf leicht zusammen und bekommt einen
inneren Ring — er wirkt kurz dicker, ohne dass sich die Rahmenbreite
wirklich aendert, sonst wuerde das Umfeld springen. Die Rueckmeldung
setzt ohne Verzoegerung ein und klingt langsam ab: ein kurzer Klick
bliebe sonst unsichtbar. Siehe docs/10-design-system.md.
--------------------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 1px solid var(--color-line);
border-radius: var(--radius-control);
background-color: var(--color-raised);
color: var(--color-text);
padding: 0.3rem 0.85rem;
font-family: var(--font-condensed);
font-size: var(--text-xs);
line-height: 1.45;
text-align: center;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
/* Abklingen langsam, damit der Druck sichtbar bleibt. */
transition:
background-color 240ms ease-out,
border-color 240ms ease-out,
color 240ms ease-out,
transform 240ms ease-out,
box-shadow 240ms ease-out;
}
.btn:hover:not(:disabled):not([aria-disabled='true']) {
background-color: var(--color-panel);
border-color: var(--color-accent);
color: var(--color-accent);
transition-duration: 90ms;
}
.btn:active:not(:disabled):not([aria-disabled='true']) {
transform: scale(0.96);
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1.5px var(--color-accent);
/* Sofort einsetzen, damit auch ein schneller Klick sichtbar wird. */
transition-duration: 0ms;
}
.btn:disabled,
.btn[aria-disabled='true'] {
color: var(--color-text-dim);
border-style: dashed;
cursor: not-allowed;
}
/* Der Knopf, der die Sache erledigt. Einer je Formular, nie zwei. */
.btn-primary {
color: var(--color-accent);
border-color: var(--color-accent);
}
.btn-primary:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--color-accent) 14%, var(--color-raised));
}
/* Fuer dichte Zeilen: Etikettenleisten, Werkzeuge an einem Eintrag. */
.btn-sm {
padding: 0.15rem 0.6rem;
font-size: var(--text-2xs);
}
/* Ein Knopf, dessen Zustand er selbst ist: Etikett an oder aus,
Reaktion gesetzt oder nicht, Reiter offen oder nicht. */
.btn[aria-current='page'],
.btn[aria-pressed='true'] {
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-raised));
border-color: var(--color-accent);
color: var(--color-accent);
}
/* Waehrend eine Aktion laeuft. Kein Spinner, nur ein ruhiger Zustand:
der Knopf bleibt stehen, wo er ist, und nimmt keine zweite Eingabe an. */
.btn[aria-busy='true'] {
color: var(--color-text-dim);
border-style: dashed;
cursor: progress;
}
/* Eingabefelder. Weich gerundet statt voll: ein mehrzeiliges Feld als
Pille waere unlesbar. */
.field {
border: 1px solid var(--color-line);
border-radius: var(--radius-field);
background-color: var(--color-raised);
color: var(--color-text);
padding: 0.4rem 0.7rem;
font-family: inherit;
font-size: var(--text-sm);
line-height: 1.5;
transition:
border-color 120ms ease-out,
background-color 120ms ease-out;
}
.field:hover:not(:disabled) {
border-color: var(--color-accent);
}
.field:disabled {
color: var(--color-text-dim);
cursor: not-allowed;
}
/* Einzeilige Felder, die wie ein Knopf aussehen duerfen: Suchbalken,
Schnellfilter. */
.field-pill {
border-radius: var(--radius-control);
padding-inline: 0.9rem;
}
select.field {
padding-block: 0.35rem;
}
/* Das Profilbild oben rechts und ueberall sonst, wo eine Person auftaucht. */
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
border: 1px solid var(--color-line);
border-radius: var(--radius-control);
background-color: var(--color-raised);
color: var(--color-text-dim);
font-family: var(--font-condensed);
overflow: hidden;
object-fit: cover;
}
/* Der eine mutige Moment: die Schwaerzung. Ein massiver Balken mit feiner
Rahmenlinie, der beim ersten Erscheinen einmal von links nach rechts
durchwischt. Sonst bewegt sich in der Oberflaeche nichts von selbst. */
+4 -4
View File
@@ -39,7 +39,7 @@ export function AddPanel({
<button
type="button"
onClick={() => setOffen(true)}
className="mt-4 border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
className="mt-4 btn btn-sm btn-primary"
>
{label}
</button>
@@ -63,14 +63,14 @@ export function AddPanel({
else setFehler(ergebnis.error ?? commonText.retry)
})
}
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"
>
{submitLabel ?? label}
</button>
<button
type="button"
onClick={() => setOffen(false)}
className="font-condensed text-2xs text-text-dim"
className="btn btn-sm"
>
{commonText.cancel}
</button>
@@ -98,7 +98,7 @@ export function PanelSelect({
value={value}
onChange={(event) => onChange(event.target.value)}
aria-label={label}
className="border border-line bg-raised px-2 py-1 text-2xs"
className="field text-2xs"
>
{children}
</select>
+52
View File
@@ -0,0 +1,52 @@
/**
* Profilbild einer Person.
*
* Die Quelle ist Discord: dort liegt das Bild 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, siehe middleware.ts.
*
* Ohne Bild — und das ist der Normalfall bei Konten ohne eigenes Profilbild —
* stehen die Anfangsbuchstaben da. Kein Platzhaltergesicht: ein leerer Kreis
* mit Initialen ist ehrlicher und lädt nichts nach.
*/
export function Avatar({
userId,
avatarHash,
displayName,
size = 28,
}: {
userId: string
avatarHash: string | null
displayName: string
size?: number
}) {
const kanten = { width: size, height: size, fontSize: Math.round(size * 0.4) }
if (!avatarHash) {
return (
<span aria-hidden className="avatar" style={kanten}>
{initialen(displayName)}
</span>
)
}
return (
// eslint-disable-next-line @next/next/no-img-element -- fremde Quelle, kein Layout-Nutzen durch next/image
<img
src={`https://cdn.discordapp.com/avatars/${userId}/${avatarHash}.png?size=64`}
alt=""
width={size}
height={size}
className="avatar"
style={kanten}
/>
)
}
function initialen(name: string): string {
const teile = name.trim().split(/\s+/).filter(Boolean)
if (teile.length === 0) return '?'
if (teile.length === 1) return teile[0]!.slice(0, 2).toUpperCase()
return (teile[0]![0]! + teile[teile.length - 1]![0]!).toUpperCase()
}
+101
View File
@@ -0,0 +1,101 @@
'use client'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import Link from 'next/link'
import {
authText,
notifyText,
overviewText,
profileText,
themeText,
} from '@/lib/i18n/shell'
import { ThemeToggle } from '@/components/ThemeToggle'
/**
* Konto und Einstellungen, oben rechts.
*
* Alles, was den einzelnen Nutzer betrifft und nicht die Akten, sammelt sich
* hinter dem Profilbild: das Thema, die Benachrichtigungen, der eigene Zugang,
* das Abmelden. In der Kopfzeile standen diese Dinge vorher zwischen den
* Aktenbereichen und nahmen dort Platz weg, den die Arbeit braucht.
*
* Das Abmelden kommt als Server Action von aussen herein — dieses Fenster darf
* die Sitzung nicht selbst beenden können.
*/
export function ProfileMenu({
displayName,
username,
avatar,
signOutAction,
}: {
displayName: string
username: string
avatar: ReactNode
signOutAction: () => Promise<void>
}) {
const [offen, setOffen] = useState(false)
const huelle = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!offen) return
function daneben(event: PointerEvent) {
if (!huelle.current?.contains(event.target as Node)) setOffen(false)
}
function fluchttaste(event: KeyboardEvent) {
if (event.key === 'Escape') setOffen(false)
}
document.addEventListener('pointerdown', daneben)
document.addEventListener('keydown', fluchttaste)
return () => {
document.removeEventListener('pointerdown', daneben)
document.removeEventListener('keydown', fluchttaste)
}
}, [offen])
return (
<div ref={huelle} className="relative">
<button
type="button"
onClick={() => setOffen((vorher) => !vorher)}
aria-expanded={offen}
aria-haspopup="menu"
aria-label={profileText.open}
className="btn btn-sm p-1"
>
{avatar}
</button>
{offen ? (
<div
role="menu"
className="absolute top-full right-0 z-40 mt-2 w-64 border border-line bg-panel p-4"
>
<p className="font-condensed text-xs text-text">{displayName}</p>
<p className="font-mono text-2xs text-text-dim">@{username}</p>
<div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
<span className="font-condensed text-2xs text-text-dim">{themeText.label}</span>
<ThemeToggle />
</div>
<nav className="mt-3 flex flex-col items-start gap-2 border-t border-line pt-3">
<Link href="/einstellungen" className="btn btn-sm" onClick={() => setOffen(false)}>
{notifyText.heading}
</Link>
<Link href="/zugang" className="btn btn-sm" onClick={() => setOffen(false)}>
{overviewText.heading}
</Link>
</nav>
<form action={signOutAction} className="mt-3 border-t border-line pt-3">
<button type="submit" className="btn btn-sm btn-primary">
{authText.signOut}
</button>
</form>
</div>
) : null}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { searchBarText } from '@/lib/i18n/shell'
/**
* Der Suchbalken in der Kopfzeile.
*
* Ein gewöhnliches Formular, das die Anfrage an `/suche` übergibt — kein
* Skript, kein Zustand, keine Bytes im Bündel. Die eigentliche Suche mit
* Filtern, Facetten und Abbruch laufender Anfragen sitzt auf der Suchseite,
* hier steht nur das Eingangstor.
*
* Strg+K bleibt daneben bestehen: der Balken ist zum Recherchieren da, die
* Palette zum Springen.
*/
export function SearchBar({ defaultValue }: { defaultValue?: string }) {
return (
<form action="/suche" method="get" role="search" className="relative">
<label htmlFor="kopfsuche" className="sr-only">
{searchBarText.label}
</label>
<span aria-hidden className="absolute top-1/2 left-3 -translate-y-1/2 text-text-dim">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor">
<circle cx="7" cy="7" r="4.5" strokeWidth="1.5" />
<path d="M10.5 10.5 14 14" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</span>
<input
id="kopfsuche"
type="search"
name="q"
defaultValue={defaultValue}
placeholder={searchBarText.placeholder}
className="field field-pill w-56 py-1 pl-8 text-xs"
/>
</form>
)
}
+45
View File
@@ -0,0 +1,45 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
/**
* Eine Reiterleiste.
*
* Sie hebt hervor, wo man gerade ist. Vorher taten das weder die Reiter der
* Fallakte noch die der Verwaltung — fünf gleich aussehende Links, und man
* musste an der Seite selbst ablesen, welcher davon offen war.
*
* Der Vergleich läuft über den Pfad, nicht über die vollständige Adresse:
* Suchparameter gehören zur Ansicht, nicht zum Reiter.
*/
export function Tabs({ tabs }: { tabs: { href: string; label: string }[] }) {
const pfad = usePathname()
return (
<nav className="mt-8 flex flex-wrap gap-2 border-b border-line pb-3">
{tabs.map((reiter) => {
// Der längste passende Eintrag gewinnt, damit „/admin" nicht jeden
// Unterbereich für sich beansprucht.
const passt = pfad === reiter.href || pfad.startsWith(reiter.href + '/')
const laenger = tabs.some(
(anderer) =>
anderer.href.length > reiter.href.length &&
(pfad === anderer.href || pfad.startsWith(anderer.href + '/')),
)
const aktiv = passt && !laenger
return (
<Link
key={reiter.href}
href={reiter.href}
aria-current={aktiv ? 'page' : undefined}
className="btn btn-sm"
>
{reiter.label}
</Link>
)
})}
</nav>
)
}
+1 -1
View File
@@ -49,7 +49,7 @@ export function ThemeToggle() {
value={thema}
onChange={(event) => waehlen(event.target.value as Thema)}
aria-label={themeText.label}
className="border border-line bg-raised px-2 py-0.5 text-2xs"
className="field text-2xs"
>
<option value="system">{themeText.system}</option>
<option value="dark">{themeText.dark}</option>
-1
View File
@@ -75,6 +75,5 @@ function routenMuster(pfad: string): string {
.replace(/\/faelle\/[^/]+/, '/faelle/[caseNumber]')
.replace(/\/dokumente\/[^/]+/, '/dokumente/[documentNumber]')
.replace(/\/personalakten\/[^/]+/, '/personalakten/[id]')
.replace(/\/kanaele\/[^/]+/, '/kanaele/[key]')
.slice(0, 120)
}
+19 -17
View File
@@ -19,12 +19,12 @@ import type { ChatMessageView } from '@/lib/chat/view'
export type ChatScopeView =
| { kind: 'case'; caseNumber: string; caseId: string }
| { kind: 'channel'; key: string; channelId: string }
| { kind: 'personnel'; fileId: string }
type Reaktion = { messageId: string; userId: string; key: string }
/**
* Aktenchat und Kanalchat.
* Der Chat einer Akte — Fallakte oder Personalakte.
*
* Optisch klar vom Verlauf abgesetzt, weil er inhaltlich etwas anderes ist:
* formlos, kein Aktenbestandteil. Die Zeitachse des Verlaufs hat eine
@@ -104,7 +104,7 @@ export function Chat({
// Beim Verlassen der Seite die Lesemarke setzen, nicht beim Öffnen: sonst
// wäre die Trennlinie beim Neuladen sofort weg.
useEffect(() => {
const scopeId = scope.kind === 'case' ? scope.caseId : scope.channelId
const scopeId = scope.kind === 'case' ? scope.caseId : scope.fileId
return () => {
void markReadAction({ scopeType: scope.kind, scopeId })
}
@@ -136,7 +136,10 @@ export function Chat({
startTransition(async () => {
const ergebnis = await sendMessageAction({
id,
scope: scope.kind === 'case' ? { kind: 'case', caseNumber: scope.caseNumber } : { kind: 'channel', key: scope.key },
scope:
scope.kind === 'case'
? { kind: 'case', caseNumber: scope.caseNumber }
: { kind: 'personnel', fileId: scope.fileId },
body: inhalt,
replyToId: optimistisch.replyToId,
})
@@ -207,7 +210,7 @@ export function Chat({
value={entwurf}
onChange={(event) => setEntwurf(event.target.value)}
rows={2}
className="block w-full border border-line bg-raised px-3 py-2 text-sm"
className="block w-full field"
/>
<div className="mt-1 flex gap-3">
<button
@@ -223,14 +226,14 @@ export function Chat({
else setFehler(ergebnis.error)
})
}
className="font-condensed text-2xs text-accent"
className="btn btn-sm btn-primary"
>
{chatText.editSave}
</button>
<button
type="button"
onClick={() => setBearbeitet(null)}
className="font-condensed text-2xs text-text-dim"
className="btn btn-sm"
>
{commonText.cancel}
</button>
@@ -273,9 +276,8 @@ export function Chat({
await toggleReactionAction({ id: nachricht.id, key })
})
}
className={`border px-1.5 py-0.5 font-mono ${
eigene ? 'border-accent text-accent' : 'border-line text-text-dim'
}`}
aria-pressed={eigene}
className="btn btn-sm font-mono"
>
{chatText.reactionSymbols[key]}
{anzahl > 0 ? ` ${anzahl}` : ''}
@@ -287,7 +289,7 @@ export function Chat({
<button
type="button"
onClick={() => setAntwortAuf(nachricht.id)}
className="font-condensed text-text-dim"
className="btn btn-sm"
>
{chatText.reply}
</button>
@@ -305,7 +307,7 @@ export function Chat({
if (!ergebnis.ok) setFehler(ergebnis.error)
})
}
className="font-condensed text-accent"
className="btn btn-sm btn-primary"
>
{chatText.adopt}
</button>
@@ -319,7 +321,7 @@ export function Chat({
setBearbeitet(nachricht.id)
setEntwurf(nachricht.body)
}}
className="font-condensed text-text-dim"
className="btn btn-sm"
>
{chatText.edit}
</button>
@@ -331,7 +333,7 @@ export function Chat({
await withdrawMessageAction({ id: nachricht.id })
})
}
className="font-condensed text-text-dim"
className="btn btn-sm"
>
{chatText.withdraw}
</button>
@@ -351,7 +353,7 @@ export function Chat({
<button
type="button"
onClick={() => setAntwortAuf(null)}
className="font-condensed"
className="btn btn-sm"
>
{chatText.cancelReply}
</button>
@@ -371,7 +373,7 @@ export function Chat({
rows={2}
placeholder={chatText.placeholder}
aria-label={chatText.placeholder}
className="block w-full border border-line bg-raised px-3 py-2 text-sm"
className="block w-full field"
/>
<div className="mt-2 flex items-center gap-3">
@@ -379,7 +381,7 @@ export function Chat({
type="button"
onClick={senden}
disabled={text.trim() === ''}
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"
>
{chatText.send}
</button>
+6 -6
View File
@@ -38,7 +38,7 @@ export function NewDocument({
<button
type="button"
onClick={() => setOpen(true)}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
className="btn btn-sm btn-primary"
>
{documentText.create}
</button>
@@ -63,7 +63,7 @@ export function NewDocument({
id="templateKey"
name="templateKey"
required
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
className="mt-1 block w-full field"
>
{templates.map((template) => (
<option key={template.key} value={template.key}>
@@ -81,7 +81,7 @@ export function NewDocument({
id="classification"
name="classification"
defaultValue={defaultLevel}
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
className="mt-1 block w-full field"
>
{levels.map((level) => (
<option key={level.level} value={level.level}>
@@ -99,7 +99,7 @@ export function NewDocument({
id="doc-title"
name="title"
maxLength={200}
className="mt-1 block w-full border border-line bg-raised px-3 py-2 text-sm"
className="mt-1 block w-full field"
/>
</div>
</div>
@@ -114,14 +114,14 @@ export function NewDocument({
<button
type="submit"
disabled={pending}
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"
>
{pending ? commonText.loading : documentText.create}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="font-condensed text-2xs text-text-dim"
className="btn btn-sm"
>
{commonText.cancel}
</button>
+5 -5
View File
@@ -27,8 +27,8 @@ export function SheetSwitch({
<button
type="button"
onClick={() => setModus('blatt')}
aria-current={modus === 'blatt'}
className={modus === 'blatt' ? 'text-accent' : 'text-text-dim'}
aria-pressed={modus === 'blatt'}
className="btn btn-sm"
>
{documentText.sheet}
</button>
@@ -36,8 +36,8 @@ export function SheetSwitch({
<button
type="button"
onClick={() => setModus('bearbeiten')}
aria-current={modus === 'bearbeiten'}
className={modus === 'bearbeiten' ? 'text-accent' : 'text-text-dim'}
aria-pressed={modus === 'bearbeiten'}
className="btn btn-sm"
>
{documentText.edit}
</button>
@@ -50,7 +50,7 @@ export function SheetSwitch({
// Wasserzeichen und Protokolleintrag kommt in Phase 8.
requestAnimationFrame(() => window.print())
}}
className="ml-auto text-text-dim"
className="ml-auto btn btn-sm"
>
{documentText.print}
</button>
+1 -1
View File
@@ -58,7 +58,7 @@ export function ExportPanel({
<button
type="submit"
disabled={pending || Boolean(state?.jobId)}
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"
>
{pending ? exportText.preparing : exportText.asPdf}
</button>
+2 -2
View File
@@ -192,7 +192,7 @@ export function Attachments({
await releaseAttachmentAction({ id: datei.id })
})
}
className="font-condensed text-2xs text-stamp-3"
className="btn btn-sm text-stamp-3"
>
{attachmentText.release}
</button>
@@ -241,7 +241,7 @@ export function Attachments({
<button
type="button"
onClick={() => eingabe.current?.click()}
className="border border-line bg-raised px-3 py-1 font-condensed text-2xs text-accent"
className="btn btn-sm btn-primary"
>
{attachmentText.add}
</button>
+1 -1
View File
@@ -155,7 +155,7 @@ export function PhotoField({
type="button"
disabled={fortschritt !== null}
onClick={() => eingabe.current?.click()}
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"
>
{fortschritt === null
? attachmentText.choose
+6 -6
View File
@@ -113,7 +113,7 @@ export function SectionEditor(props: Props) {
<select
value={value}
onChange={(event) => change(event.target.value)}
className="border border-line bg-raised px-3 py-2 text-sm"
className="field"
>
<option value="">—</option>
{props.options?.map((option) => (
@@ -127,14 +127,14 @@ export function SectionEditor(props: Props) {
value={value}
onChange={(event) => change(event.target.value)}
rows={6}
className="block w-full max-w-(--container-measure) border border-line bg-raised px-3 py-2 text-sm"
className="block w-full max-w-(--container-measure) field"
/>
) : (
<input
type={props.type === 'date' ? 'date' : props.type === 'number' ? 'number' : 'text'}
value={value}
onChange={(event) => change(event.target.value)}
className="block w-full max-w-(--container-measure) border border-line bg-raised px-3 py-2 text-sm"
className="block w-full max-w-(--container-measure) field"
/>
)}
@@ -143,7 +143,7 @@ export function SectionEditor(props: Props) {
type="button"
onClick={() => submit()}
disabled={pending}
className="border border-line bg-raised px-3 py-1 font-condensed text-accent disabled:text-text-dim"
className="btn btn-primary"
>
{pending ? sectionText.saving : sectionText.save}
</button>
@@ -169,7 +169,7 @@ export function SectionEditor(props: Props) {
<button
type="button"
onClick={() => submit(parse(value, props.type), conflict.version)}
className="mt-2 font-condensed text-2xs text-accent"
className="mt-2 btn btn-sm btn-primary"
>
{sectionText.takeMine}
</button>
@@ -186,7 +186,7 @@ export function SectionEditor(props: Props) {
setVersion(conflict.version)
setConflict(null)
}}
className="mt-2 font-condensed text-2xs text-accent"
className="mt-2 btn btn-sm btn-primary"
>
{sectionText.takeTheirs}
</button>
+2 -1
View File
@@ -33,7 +33,8 @@ test.describe('Geschuetzte Bereiche', () => {
'/faelle/neu',
'/personalakten',
'/personalakten/neu',
'/kanaele',
'/personalakten/irgendeine',
'/admin',
'/suche',
'/faelle/AfiS-2026-0001/export',
'/faelle/AfiS-2026-0001/beteiligte',
+16 -19
View File
@@ -1,29 +1,26 @@
import type { Prisma } from '@afis/db'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
/**
* Chat: Aktenchat und freie Kanäle.
* Chat: der formlose Teil, der **an einer Akte hängt**.
*
* Der Aktenchat erbt die Sichtbarkeit seiner Akte — wer die Akte nicht lesen
* darf, sieht auch nicht, dass darüber geredet wird. Ein Kanal hat eine eigene
* Mindeststufe.
*
* Ein Kanal ist kein Geheimnis für sich: dass es ihn gibt, darf jeder sehen,
* der seine Stufe hat. Anders als bei Akten gibt es hier keine „Existenz ist
* auch eine Information" — ein Raum ohne Zugang wäre sonst nicht auffindbar,
* und jemand müsste ihn erraten, um Zugang zu erbitten.
* Es gibt keine freien Räume mehr. Ein Chat gehört zu einer Fallakte oder zu
* einer Personalakte und erbt deren Sichtbarkeit — wer die Akte nicht lesen
* darf, sieht auch nicht, dass darüber geredet wird. Damit gibt es keinen Ort
* im System, an dem über einen Vorgang gesprochen werden kann, ohne dass das
* Gespräch beim Vorgang steht. Siehe docs/13-entscheidungen.md.
*/
export function visibleChannelWhere(p: EffectivePermissions): Prisma.ChannelWhereInput {
if (p.isAdmin) return {}
return { minClearance: { lte: p.clearanceLevel } }
}
export function visibleMessageWhere(p: EffectivePermissions): Prisma.ChatMessageWhereInput {
return {
OR: [
{ case: { is: visibleCaseWhere(p) } },
{ channel: { is: visibleChannelWhere(p) } },
],
const orte: Prisma.ChatMessageWhereInput[] = [{ case: { is: visibleCaseWhere(p) } }]
// Personalakten setzen zusätzlich zur Stufe ein eigenes Recht voraus. Ohne
// das Recht gibt es die Akte für diesen Nutzer nicht, also auch ihren Chat
// nicht.
if (p.isAdmin || p.permissions.has('personnel.read')) {
orte.push({ personnelFile: { is: visiblePersonnelWhere(p) } })
}
return { OR: orte }
}
-1
View File
@@ -44,7 +44,6 @@ export type AuditAction =
| 'attachment.read'
| 'attachment.release'
| 'chat.adopt'
| 'channel.create'
| 'roles.update'
| 'export.pdf'
| 'export.approve'
+2 -2
View File
@@ -13,7 +13,7 @@ import type { EffectivePermissions } from '@/lib/auth/permissions'
*/
export async function loadChatView(p: EffectivePermissions, scope: ChatScope) {
const { ort, nachrichten } = await listMessages(p, scope)
const scopeId = ort.caseId ?? ort.channelId!
const scopeId = ort.caseId ?? ort.personnelFileId!
const [autoren, vorschauen, reaktionen, lastReadAt, mitglieder] = await Promise.all([
db.user.findMany({
@@ -22,7 +22,7 @@ export async function loadChatView(p: EffectivePermissions, scope: ChatScope) {
}),
resolveCasePreviews(p, nachrichten.flatMap((n) => extractCaseNumbers(n.body))),
listReactions(p, nachrichten.map((n) => n.id)),
loadReadMarker(p, ort.caseId ? 'case' : 'channel', scopeId),
loadReadMarker(p, ort.caseId ? 'case' : 'personnel', scopeId),
// Für die Anzeige der Erwähnungen: Benutzername auf Anzeigename.
db.user.findMany({
where: { isActive: true },
+38 -101
View File
@@ -2,28 +2,32 @@ import { db } from '@/lib/db'
import { writeAudit } from '@/lib/audit'
import { AccessError, must } from '@/lib/errors'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visibleChannelWhere, visibleMessageWhere } from '@/lib/access/chatFilter'
import { visibleMessageWhere } from '@/lib/access/chatFilter'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { canEditCase } from '@/lib/access/caseWrite'
import { extractMentionNames } from '@/lib/chat/mentions'
import type { ReactionKey } from '@/lib/chat/terms'
import { caseTopic, channelTopic, publish, type Topic } from '@/lib/realtime/publish'
import { enqueueCaseNotification } from '@/lib/notify/enqueue'
import { caseTopic, personnelTopic, publish, type Topic } from '@/lib/realtime/publish'
import { enqueueCaseNotification, enqueuePersonnelNotification } from '@/lib/notify/enqueue'
import type { EffectivePermissions } from '@/lib/auth/permissions'
import type { Prisma } from '@afis/db'
/**
* Aktenchat und freie Kanäle.
* Der Aktenchat.
*
* Der Chat ist **ausdrücklich kein Aktenbestandteil** — er ist der formlose
* Teil, den die Abteilung bisher auf Discord hatte. Deshalb darf hier
* bearbeitet und zurückgezogen werden, ohne dass eine Version entsteht.
* Er hängt immer an einer Akte — einer Fallakte oder einer Personalakte — und
* ist **ausdrücklich kein Aktenbestandteil**: er ist der formlose Teil, den
* die Abteilung bisher auf Discord hatte. Deshalb darf hier bearbeitet und
* zurückgezogen werden, ohne dass eine Version entsteht.
*
* Die Brücke zum Aktenbestand ist `adoptAsEntry`: eine Nachricht wird per
* Klick zu einem Vermerk. Ab da gelten die harten Regeln der Akte.
* Siehe docs/06-chat-und-realtime.md.
*/
export type ChatScope = { kind: 'case'; caseNumber: string } | { kind: 'channel'; key: string }
export type ChatScope =
| { kind: 'case'; caseNumber: string }
| { kind: 'personnel'; fileId: string }
const NACHRICHT = {
id: true,
@@ -36,7 +40,7 @@ const NACHRICHT = {
mentions: true,
createdAt: true,
caseId: true,
channelId: true,
personnelFileId: true,
} satisfies Prisma.ChatMessageSelect
/**
@@ -47,7 +51,7 @@ const NACHRICHT = {
export async function resolveScope(
p: EffectivePermissions,
scope: ChatScope,
): Promise<{ caseId?: string; channelId?: string; topic: Topic; label: string } | null> {
): Promise<{ caseId?: string; personnelFileId?: string; topic: Topic; label: string } | null> {
if (scope.kind === 'case') {
const akte = await db.case.findFirst({
where: { AND: [visibleCaseWhere(p), { caseNumber: scope.caseNumber }] },
@@ -56,12 +60,15 @@ export async function resolveScope(
return akte ? { caseId: akte.id, topic: caseTopic(akte.id), label: akte.caseNumber } : null
}
const kanal = await db.channel.findFirst({
where: { AND: [visibleChannelWhere(p), { key: scope.key, isArchived: false }] },
select: { id: true, key: true },
// Personalakten setzen neben der Stufe ein eigenes Recht voraus.
if (!p.isAdmin && !p.permissions.has('personnel.read')) return null
const akte = await db.personnelFile.findFirst({
where: { AND: [visiblePersonnelWhere(p), { id: scope.fileId }] },
select: { id: true, displayName: true },
})
return kanal
? { channelId: kanal.id, topic: channelTopic(kanal.id), label: kanal.key }
return akte
? { personnelFileId: akte.id, topic: personnelTopic(akte.id), label: akte.displayName }
: null
}
@@ -82,7 +89,7 @@ export async function listMessages(
const wo: Prisma.ChatMessageWhereInput = ort.caseId
? { caseId: ort.caseId }
: { channelId: ort.channelId }
: { personnelFileId: ort.personnelFileId }
const nachrichten = await db.chatMessage.findMany({
where: { AND: [wo, ...(options.since ? [{ id: { gt: options.since } }] : [])] },
@@ -149,7 +156,7 @@ export async function sendMessage(
const ziel = await db.chatMessage.findFirst({
where: {
id: input.replyToId,
...(ort.caseId ? { caseId: ort.caseId } : { channelId: ort.channelId }),
...(ort.caseId ? { caseId: ort.caseId } : { personnelFileId: ort.personnelFileId }),
},
select: { id: true },
})
@@ -161,7 +168,7 @@ export async function sendMessage(
data: {
id: input.id,
caseId: ort.caseId ?? null,
channelId: ort.channelId ?? null,
personnelFileId: ort.personnelFileId ?? null,
authorId: p.userId,
body: text,
replyToId,
@@ -188,18 +195,15 @@ export async function sendMessage(
onlyFor: erwaehnt.length > 0 ? erwaehnt.map((u) => u.id) : undefined,
meta: { messageId: nachricht.id },
})
} else if (erwaehnt.length > 0) {
// In einem Kanal gibt es keine Akte, deren Sichtbarkeit zu prüfen wäre.
// Die Mindeststufe des Kanals hat der Absender schon passiert, und
// erwähnt wird nur, wer sie ebenfalls hat.
await tx.notificationOutbox.createMany({
data: erwaehnt.map((user) => ({
kind: 'chat.mention',
channelId: ort.channelId,
actorId: p.userId,
recipientId: user.id,
meta: { messageId: nachricht.id, channelKey: ort.label, actorName: absenderName },
})),
} else if (ort.personnelFileId) {
// Im Chat einer Personalakte geht nur an ausdrücklich Erwähnte etwas
// hinaus, und auch das nur an die, die die Akte lesen dürfen.
await enqueuePersonnelNotification(tx, {
personnelFileId: ort.personnelFileId,
actorId: p.userId,
actorName: absenderName,
mentioned: erwaehnt.map((user) => user.id),
meta: { messageId: nachricht.id },
})
}
@@ -378,9 +382,9 @@ export async function adoptAsEntry(
return { entryId }
}
function themaVon(nachricht: { caseId: string | null; channelId: string | null }): Topic {
function themaVon(nachricht: { caseId: string | null; personnelFileId: string | null }): Topic {
if (nachricht.caseId) return caseTopic(nachricht.caseId)
return channelTopic(nachricht.channelId!)
return personnelTopic(nachricht.personnelFileId!)
}
// ---------------------------------------------------------------------------
@@ -396,7 +400,7 @@ function themaVon(nachricht: { caseId: string | null; channelId: string | null }
*/
export async function loadReadMarker(
p: EffectivePermissions,
scopeType: 'case' | 'channel',
scopeType: 'case' | 'personnel',
scopeId: string,
): Promise<Date | null> {
const marker = await db.readMarker.findUnique({
@@ -408,7 +412,7 @@ export async function loadReadMarker(
export async function markRead(
p: EffectivePermissions,
scopeType: 'case' | 'channel',
scopeType: 'case' | 'personnel',
scopeId: string,
at: Date = new Date(),
): Promise<void> {
@@ -418,70 +422,3 @@ export async function markRead(
update: { lastReadAt: at },
})
}
// ---------------------------------------------------------------------------
// Kanäle
// ---------------------------------------------------------------------------
/** Die Kanäle, die dieser Nutzer sehen darf. */
export async function listChannels(p: EffectivePermissions) {
return db.channel.findMany({
where: { AND: [visibleChannelWhere(p), { isArchived: false }] },
orderBy: { name: 'asc' },
select: {
id: true,
key: true,
name: true,
description: true,
minClearance: true,
createdAt: true,
},
})
}
/**
* Kanal anlegen.
*
* Die Mindeststufe lässt sich nicht über die eigene heben — sonst legte
* jemand einen Raum an, den er selbst nicht mehr betreten kann, und dieselbe
* Regel gilt schon bei Akten und Dokumenten.
*/
export async function createChannel(
p: EffectivePermissions,
input: { key: string; name: string; description?: string | null; minClearance: number },
): Promise<{ key: string }> {
if (!p.isAdmin && !p.permissions.has('case.create')) throw new AccessError('no-permission')
if (input.minClearance > p.clearanceLevel && !p.isAdmin) {
throw new AccessError('clearance-too-low')
}
const key = input.key.trim().toLowerCase()
if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(key)) throw new AccessError('channel-key-invalid')
const vorhanden = await db.channel.findUnique({ where: { key }, select: { id: true } })
if (vorhanden) throw new AccessError('channel-key-taken')
return db.$transaction(async (tx) => {
const kanal = await tx.channel.create({
data: {
key,
name: input.name.trim().slice(0, 120),
description: input.description?.trim() || null,
minClearance: input.minClearance,
createdById: p.userId,
},
select: { key: true },
})
await writeAudit(tx, {
actorId: p.userId,
action: 'channel.create',
targetType: 'Channel',
targetId: kanal.key,
clearanceAtTime: p.clearanceLevel,
meta: { key: kanal.key, minClearance: input.minClearance },
})
return kanal
})
}
+27 -20
View File
@@ -1,6 +1,7 @@
import { db } from '@/lib/db'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visibleMessageWhere } from '@/lib/access/chatFilter'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { listWatched } from '@/lib/cases/watch'
import { listRecent } from '@/lib/recent/service'
import type { EffectivePermissions } from '@/lib/auth/permissions'
@@ -73,7 +74,7 @@ export async function loadDashboard(p: EffectivePermissions) {
}
export type UnreadChat = {
scopeType: 'case' | 'channel'
scopeType: 'case' | 'personnel'
scopeId: string
label: string
href: string
@@ -83,10 +84,10 @@ export type UnreadChat = {
/**
* Wo seit dem letzten Besuch geschrieben wurde.
*
* Gezählt wird über `ReadMarker`. Nur an Orten, an denen der Nutzer beteiligt
* ist, die er beobachtet, oder in Kanälen, die er sehen darf — eine Zählung
* über alle sichtbaren Akten wäre auf der Übersicht nur Lärm. Eigene
* Nachrichten zählen nicht mit.
* Gezählt wird über `ReadMarker`. Nur an Orten, die den Nutzer angehen: Akten,
* an denen er beteiligt ist oder die er beobachtet, und Personalakten, denen
* er zugeordnet ist. Eine Zählung über alles Sichtbare wäre auf der Übersicht
* nur Lärm. Eigene Nachrichten zählen nicht mit.
*/
async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
const marken = await db.readMarker.findMany({
@@ -95,7 +96,9 @@ async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
})
const markeVon = new Map(marken.map((m) => [`${m.scopeType}:${m.scopeId}`, m.lastReadAt]))
const [meineAkten, kanaele] = await Promise.all([
const darfPersonal = p.isAdmin || p.permissions.has('personnel.read')
const [meineAkten, personalakten] = await Promise.all([
db.case.findMany({
where: {
AND: [
@@ -111,14 +114,18 @@ async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
select: { id: true, caseNumber: true },
take: 50,
}),
db.channel.findMany({
where: {
isArchived: false,
...(p.isAdmin ? {} : { minClearance: { lte: p.clearanceLevel } }),
},
select: { id: true, key: true },
take: 50,
}),
darfPersonal
? db.personnelFile.findMany({
where: {
AND: [
visiblePersonnelWhere(p),
{ assignments: { some: { userId: p.userId } } },
],
},
select: { id: true, displayName: true },
take: 50,
})
: Promise.resolve([]),
])
const orte = [
@@ -129,12 +136,12 @@ async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
href: `/faelle/${akte.caseNumber}`,
wo: { caseId: akte.id } as Prisma.ChatMessageWhereInput,
})),
...kanaele.map((kanal) => ({
scopeType: 'channel' as const,
scopeId: kanal.id,
label: `#${kanal.key}`,
href: `/kanaele/${kanal.key}`,
wo: { channelId: kanal.id } as Prisma.ChatMessageWhereInput,
...personalakten.map((akte) => ({
scopeType: 'personnel' as const,
scopeId: akte.id,
label: akte.displayName,
href: `/personalakten/${akte.id}`,
wo: { personnelFileId: akte.id } as Prisma.ChatMessageWhereInput,
})),
]
if (orte.length === 0) return []
-3
View File
@@ -39,7 +39,6 @@ export async function exportDatabase(p: EffectivePermissions): Promise<Uint8Arra
documentSections,
documentSectionVersions,
attachments,
channels,
messages,
reactions,
auditLog,
@@ -64,7 +63,6 @@ export async function exportDatabase(p: EffectivePermissions): Promise<Uint8Arra
db.documentSection.findMany(),
db.documentSectionVersion.findMany(),
db.attachment.findMany(),
db.channel.findMany(),
db.chatMessage.findMany(),
db.chatReaction.findMany(),
// Das Protokoll gehört dazu: ein Abzug ohne die Zugriffsspur wäre als
@@ -96,7 +94,6 @@ export async function exportDatabase(p: EffectivePermissions): Promise<Uint8Arra
documentSections,
documentSectionVersions,
attachments,
channels,
messages,
reactions,
auditLog,
+20
View File
@@ -1,3 +1,23 @@
export const adminText = {
heading: 'Admin',
intro: 'Rollen, Vorlagen, Regeln und das Protokoll der Abteilung.',
sections: {
roles: 'Rollen',
templates: 'Vorlagen',
policy: 'Änderungsrichtlinie',
log: 'Protokoll',
system: 'System',
} as Record<string, string>,
descriptions: {
roles: 'Welche Discord-Rolle welche Freigabestufe und welche Rechte trägt.',
templates: 'Aufbau der Fallakten und Dokumente, Bausteine und Fassungen.',
policy: 'Ob und wie lange Verlaufseinträge nachträglich geändert werden dürfen.',
log: 'Wer was wo getan hat. Vollständig und nicht änderbar.',
system: 'Zustand der Dienste, Ladewerte, Nummernkreise.',
} as Record<string, string>,
empty: 'Für die Verwaltung fehlen dir die Rechte. Die Abteilungsleitung vergibt sie.',
} as const
/**
* Der Adminbereich: Rollen, Vorlagen, Einstellungen.
*
-18
View File
@@ -44,21 +44,3 @@ export const chatText = {
wichtig: '▲',
} as Record<string, string>,
} as const
export const channelText = {
one: 'Kanal',
many: 'Kanäle',
heading: 'Kanäle',
intro: 'Freie Räume für Absprachen, die zu keiner Akte gehören.',
empty: 'Noch kein Kanal. Ersten Kanal anlegen.',
create: 'Kanal anlegen',
createTitle: 'Neuer Kanal',
name: 'Name',
key: 'Kürzel',
keyHint: 'Kleinbuchstaben, Ziffern und Bindestrich. Steht später in der Adresse.',
description: 'Beschreibung',
minClearance: 'Mindeststufe',
minClearanceHint: 'Wer diese Stufe nicht hat, sieht den Kanal nicht.',
archived: 'Archiviert',
open: 'Öffnen',
} as const
-2
View File
@@ -43,9 +43,7 @@ export const actionErrorsText = {
'message-too-long': 'Die Nachricht ist länger als 4000 Zeichen.',
'message-withdrawn': 'Die Nachricht wurde zurückgezogen.',
'adopt-needs-case': 'Nur eine Nachricht aus einem Aktenchat wird zum Vermerk.',
'channel-key-taken': 'Dieses Kürzel gibt es schon.',
'too-many-views': 'Mehr als dreissig Ansichten werden unübersichtlich. Lösche eine.',
'channel-key-invalid': 'Kürzel: Kleinbuchstaben, Ziffern und Bindestrich, mindestens zwei Zeichen.',
default: 'Nicht gespeichert. Erneut versuchen.',
} as Record<string, string>
+13 -3
View File
@@ -24,6 +24,8 @@ import {
searchText,
paletteText,
themeText,
profileText,
searchBarText,
dashboardText,
watchText,
} from '@/lib/i18n/shell'
@@ -35,17 +37,24 @@ import {
participantText,
linkText,
} from '@/lib/i18n/cases'
import { chatText, channelText } from '@/lib/i18n/chat'
import { chatText } from '@/lib/i18n/chat'
import { personnelText, assignmentText } from '@/lib/i18n/people'
import { documentText, attachmentText } from '@/lib/i18n/documents'
import { exportText } from '@/lib/i18n/export'
import { roleAdminText, templateAdminText, settingsText, vitalsText } from '@/lib/i18n/admin'
import {
adminText,
roleAdminText,
templateAdminText,
settingsText,
vitalsText,
} from '@/lib/i18n/admin'
export const de = {
app: appText,
nav: navText,
auth: authText,
overview: overviewText,
admin: adminText,
roleAdmin: roleAdminText,
clearance: clearanceText,
case: caseText,
@@ -56,7 +65,6 @@ export const de = {
participant: participantText,
link: linkText,
chat: chatText,
channel: channelText,
document: documentText,
attachment: attachmentText,
export: exportText,
@@ -71,6 +79,8 @@ export const de = {
search: searchText,
palette: paletteText,
theme: themeText,
profile: profileText,
searchBar: searchBarText,
dashboard: dashboardText,
watch: watchText,
} as const
+11 -2
View File
@@ -21,7 +21,7 @@ export const navText = {
cases: 'Fallakten',
personnel: 'Personalakten',
search: 'Suche',
admin: 'Verwaltung',
admin: 'Admin',
skipToContent: 'Zum Inhalt springen',
} as const
@@ -178,12 +178,21 @@ export const paletteText = {
{ href: '/faelle', label: 'Fallakten' },
{ href: '/faelle/neu', label: 'Neue Fallakte' },
{ href: '/personalakten', label: 'Personalakten' },
{ href: '/kanaele', label: 'Kanäle' },
{ href: '/suche', label: 'Suche' },
{ href: '/einstellungen', label: 'Benachrichtigungen' },
],
} as const
export const profileText = {
open: 'Konto und Einstellungen',
signedInAs: 'Angemeldet als',
} as const
export const searchBarText = {
label: 'Suchen',
placeholder: 'Aktenzeichen, Titel, Name',
} as const
export const themeText = {
label: 'Thema',
system: 'Wie das System',
+61
View File
@@ -1,6 +1,7 @@
import { db } from '@/lib/db'
import { READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { resolvePermissions } from '@/lib/auth/effective'
import type { Prisma } from '@afis/db'
@@ -122,3 +123,63 @@ export async function enqueueCaseNotification(
],
})
}
/**
* Die Meldung aus dem Chat einer Personalakte.
*
* Zwei Unterschiede zur Fallakte, beide bewusst:
*
* 1. **Nur an ausdrücklich Erwähnte**, nie in den allgemeinen Kanal. Dass es
* zu einer bestimmten Person eine Akte gibt, ist selbst eine Auskunft, und
* die gehört nicht in einen Raum, in dem alle mitlesen.
* 2. **Kein Name in der Meldung.** Weder der der Person noch der Text. Der
* Link führt in die Anwendung, und die prüft selbst.
*
* Geprüft wird je Empfänger mit derselben Abfrage wie beim Lesen, plus dem
* Recht `personnel.read` — ohne das gibt es die Akte für ihn nicht.
*/
export async function enqueuePersonnelNotification(
tx: Prisma.TransactionClient,
input: {
personnelFileId: string
actorId: string
actorName: string
mentioned: string[]
meta?: Record<string, unknown>
},
): Promise<void> {
if (input.mentioned.length === 0) return
const kandidaten = await db.user.findMany({
where: {
isActive: true,
notifyMode: { not: 'OFF' },
id: { in: input.mentioned, not: input.actorId },
},
select: { id: true },
})
const ziele: string[] = []
for (const nutzer of kandidaten) {
const rechte = await resolvePermissions(nutzer.id)
if (!rechte.isAdmin && !rechte.permissions.has('personnel.read')) continue
const sichtbar = await db.personnelFile.findFirst({
where: { AND: [visiblePersonnelWhere(rechte), { id: input.personnelFileId }] },
select: { id: true },
})
if (sichtbar) ziele.push(nutzer.id)
}
if (ziele.length === 0) return
await tx.notificationOutbox.createMany({
data: ziele.map((ziel) => ({
kind: 'chat.mention',
personnelFileId: input.personnelFileId,
actorId: input.actorId,
recipientId: ziel,
meta: { actorName: input.actorName, ...input.meta } as Prisma.InputJsonValue,
})),
})
}
+3 -3
View File
@@ -29,13 +29,13 @@ export type EventKind =
| 'export.ready'
/** Wer das Ereignis angeht. */
export type Topic = `case:${string}` | `channel:${string}` | `user:${string}`
export type Topic = `case:${string}` | `personnel:${string}` | `user:${string}`
export function caseTopic(caseId: string): Topic {
return `case:${caseId}`
}
export function channelTopic(channelId: string): Topic {
return `channel:${channelId}`
export function personnelTopic(fileId: string): Topic {
return `personnel:${fileId}`
}
export function userTopic(userId: string): Topic {
return `user:${userId}`
+4 -1
View File
@@ -17,7 +17,10 @@ export function middleware(request: NextRequest): NextResponse {
// Tailwind erzeugt zur Laufzeit keine Styles, aber Next setzt inline-Styles
// fuer den Bildplatzhalter. Style-Nonces unterstuetzt Next nicht.
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' blob: data:`,
// Profilbilder kommen von Discord. Sie liegen dort ohnehin, die Anmeldung
// laeuft ueber dieselbe Quelle, und ein eigener Zwischenspeicher waere ein
// Dienst mehr im Stack fuer ein Bild von 64 Pixeln.
`img-src 'self' blob: data: https://cdn.discordapp.com`,
`font-src 'self'`,
// Der SSE-Endpunkt laeuft ueber dieselbe Herkunft.
`connect-src 'self'`,
+2 -2
View File
@@ -71,7 +71,7 @@ suite('Benachrichtigungen', () => {
const akte = await db.case.create({
data: {
caseNumber: `Meld-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Meld-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Streng vertraulicher Vorgang',
templateId: vorlage.id,
templateKey: vorlage.key,
@@ -142,7 +142,7 @@ suite('Benachrichtigungen', () => {
it('nimmt bei niedriger Stufe den Titel mit', async () => {
const offen = await db.case.create({
data: {
caseNumber: `Offn-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Offn-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Harmloser Vorgang',
templateId: (await db.documentTemplate.findFirstOrThrow({
where: { key: `${marke}-vorlage` },
+52 -36
View File
@@ -1,7 +1,7 @@
// @vitest-environment node
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { db } from '@/lib/db'
import { visibleChannelWhere, visibleMessageWhere } from '@/lib/access/chatFilter'
import { visibleMessageWhere } from '@/lib/access/chatFilter'
import { adoptAsEntry, listMessages, sendMessage } from '@/lib/chat/service'
import { resolveCasePreviews } from '@/lib/chat/preview'
import { AccessError } from '@/lib/errors'
@@ -37,8 +37,8 @@ suite('Aktenchat und Kanäle', () => {
let geheimNummer = ''
let offenId = ''
let geheimId = ''
let kanalOffen = ''
let kanalGeheim = ''
let personOffen = ''
let personGeheim = ''
beforeAll(async () => {
const vorlage = await db.documentTemplate.create({
@@ -56,7 +56,7 @@ suite('Aktenchat und Kanäle', () => {
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Chat-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Chat-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Offener Vorgang',
classification: 1,
status: 'OPEN',
@@ -65,7 +65,7 @@ suite('Aktenchat und Kanäle', () => {
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Gehm-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Gehm-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Streng vertraulich',
classification: 4,
status: 'OPEN',
@@ -76,24 +76,14 @@ suite('Aktenchat und Kanäle', () => {
offenId = offen.id
geheimId = geheim.id
const k1 = await db.channel.create({
data: {
key: `${marke}-allgemein`.slice(0, 40),
name: 'Allgemein',
minClearance: 0,
createdById: nutzer.stufe4,
},
const p1 = await db.personnelFile.create({
data: { displayName: `${marke}-offen`, classification: 1, createdById: nutzer.stufe4 },
})
const k2 = await db.channel.create({
data: {
key: `${marke}-leitung`.slice(0, 40),
name: 'Leitung',
minClearance: 4,
createdById: nutzer.stufe4,
},
const p2 = await db.personnelFile.create({
data: { displayName: `${marke}-geheim`, classification: 4, createdById: nutzer.stufe4 },
})
kanalOffen = k1.key
kanalGeheim = k2.key
personOffen = p1.id
personGeheim = p2.id
})
afterAll(async () => {
@@ -119,17 +109,37 @@ suite('Aktenchat und Kanäle', () => {
expect(sichtbar).toHaveLength(0)
})
it('verbirgt einen Kanal über der eigenen Stufe', async () => {
const kanaele = await db.channel.findMany({
where: {
AND: [
visibleChannelWhere(perms(nutzer.stufe1, 1)),
{ key: { in: [kanalOffen, kanalGeheim] } },
],
},
select: { key: true },
it('verbirgt den Chat einer zu hoch eingestuften Personalakte', async () => {
const leiter = perms(nutzer.stufe4, 4, { permissions: ['personnel.read'] })
await sendMessage(leiter, {
id: `${marke}-person-geheim`,
scope: { kind: 'personnel', fileId: personGeheim },
body: 'Bewertung liegt vor',
})
expect(kanaele.map((k) => k.key)).toEqual([kanalOffen])
// Zu niedrige Stufe: den Ort gibt es für ihn nicht.
await expect(
listMessages(perms(nutzer.stufe1, 1, { permissions: ['personnel.read'] }), {
kind: 'personnel',
fileId: personGeheim,
}),
).rejects.toThrow(AccessError)
})
it('lässt ohne das Recht auf Personalakten auch deren Chat nicht zu', async () => {
// Die Stufe allein reicht nicht: Personalakten setzen personnel.read
// zusätzlich voraus, und das gilt für ihren Chat genauso.
await expect(
listMessages(perms(nutzer.stufe4, 4), { kind: 'personnel', fileId: personOffen }),
).rejects.toThrow(AccessError)
const ohneRecht = await db.chatMessage.findMany({
where: {
AND: [visibleMessageWhere(perms(nutzer.stufe4, 4)), { personnelFileId: personGeheim }],
},
select: { id: true },
})
expect(ohneRecht).toHaveLength(0)
})
it('verrät über die Vorschaukarte nichts über eine verborgene Akte', async () => {
@@ -214,12 +224,18 @@ suite('Aktenchat und Kanäle', () => {
expect((await adoptAsEntry(p, id)).entryId).toBe(entryId)
})
it('macht aus einer Kanalnachricht keinen Vermerk', async () => {
const id = `${marke}-kanal`
const p = perms(nutzer.stufe4, 4, { permissions: ['entry.create', 'case.edit'] })
await sendMessage(p, { id, scope: { kind: 'channel', key: kanalOffen }, body: 'Absprache' })
it('macht aus einer Nachricht der Personalakte keinen Vermerk', async () => {
const id = `${marke}-person`
const p = perms(nutzer.stufe4, 4, {
permissions: ['entry.create', 'case.edit', 'personnel.read'],
})
await sendMessage(p, {
id,
scope: { kind: 'personnel', fileId: personOffen },
body: 'Absprache',
})
// Ein Vermerk gehört in eine Akte. Ein Kanal hat keine.
// Ein Vermerk gehört in den Verlauf, und den führt nur die Fallakte.
await expect(adoptAsEntry(p, id)).rejects.toThrow(AccessError)
})
})
+27 -4
View File
@@ -17,10 +17,33 @@ describe('Design-Tokens', () => {
}
})
it('setzt den Eckenradius durchgehend auf 2 px', () => {
const radii = [...css.matchAll(/--radius-[\w-]+:\s*([^;]+);/g)].map((m) => m[1]!.trim())
expect(radii.length).toBeGreaterThan(0)
for (const value of radii) expect(['0px', '2px']).toContain(value)
it('haelt Flaechen eckig und Bedienelemente rund', () => {
// Die Flaechenradien bleiben bei 2 px: eine Akte ist ein Blatt.
const flaechen = [...css.matchAll(/--radius-(?!field|control)[\w-]+:\s*([^;]+);/g)].map((m) =>
m[1]!.trim(),
)
expect(flaechen.length).toBeGreaterThan(0)
for (const value of flaechen) expect(['0px', '2px']).toContain(value)
// Und daneben genau zwei Radien fuer Bedienelemente.
expect(css).toMatch(/--radius-field:\s*10px;/)
expect(css).toMatch(/--radius-control:\s*999px;/)
})
it('gibt jedem Knopf Rahmen, Ueberfahren und Druckrueckmeldung', () => {
const knopf = css.slice(css.indexOf('.btn {'), css.indexOf('.btn-primary'))
expect(knopf).toContain('border: 1px solid var(--color-line)')
expect(knopf).toContain('border-radius: var(--radius-control)')
// Ueberfahren und Druecken sind beide beschrieben ...
expect(css).toMatch(/\.btn:hover/)
expect(css).toMatch(/\.btn:active/)
// ... und der Druck setzt ohne Verzoegerung ein, sonst bliebe ein
// schneller Klick unsichtbar.
const gedrueckt = css.slice(css.indexOf('.btn:active'))
expect(gedrueckt.slice(0, 400)).toContain('transition-duration: 0ms')
expect(gedrueckt.slice(0, 400)).toContain('inset 0 0 0')
})
it('kennt keine Schatten ausser dem Fokusring', () => {
+2 -2
View File
@@ -147,7 +147,7 @@ suite('Freigabeverfahren', () => {
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Exp-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Exp-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Harmlos',
classification: 1,
status: 'OPEN',
@@ -156,7 +156,7 @@ suite('Freigabeverfahren', () => {
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Exg-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Exg-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Streng',
classification: APPROVAL_FROM_LEVEL,
status: 'OPEN',
+2 -2
View File
@@ -64,7 +64,7 @@ suite('Volltextsuche', () => {
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Such-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Such-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: `Vorgang ${wort.offen}`,
classification: 1,
status: 'OPEN',
@@ -75,7 +75,7 @@ suite('Volltextsuche', () => {
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Ghm-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Ghm-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Unauffälliger Titel',
classification: 4,
status: 'OPEN',
+2 -2
View File
@@ -53,7 +53,7 @@ suite('Übersicht und Beobachtungsliste', () => {
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Dash-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Dash-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Offener Vorgang',
classification: 1,
status: 'OPEN',
@@ -63,7 +63,7 @@ suite('Übersicht und Beobachtungsliste', () => {
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Dghm-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Dghm-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Streng vertraulich',
classification: 4,
status: 'OPEN',
+3 -3
View File
@@ -63,7 +63,7 @@ suite('Sichtbarkeit von Akten', () => {
data: {
...gemeinsam,
id: ids.offen,
caseNumber: `AfiS-9001-${String(Date.now()).slice(-4)}`,
caseNumber: `AfiS-9001-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Offener Vorgang',
classification: 1,
status: 'OPEN',
@@ -74,7 +74,7 @@ suite('Sichtbarkeit von Akten', () => {
data: {
...gemeinsam,
id: ids.geheim,
caseNumber: `AfiS-9002-${String(Date.now()).slice(-4)}`,
caseNumber: `AfiS-9002-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Geheimer Vorgang',
classification: 4,
status: 'OPEN',
@@ -85,7 +85,7 @@ suite('Sichtbarkeit von Akten', () => {
data: {
...gemeinsam,
id: ids.versiegelt,
caseNumber: `AfiS-9003-${String(Date.now()).slice(-4)}`,
caseNumber: `AfiS-9003-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Versiegelter Vorgang',
classification: 1,
status: 'SEALED',
+2 -2
View File
@@ -52,7 +52,7 @@ suite('Zuletzt geöffnet', () => {
const offen = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Rez-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Rez-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Offener Vorgang',
classification: 1,
status: 'OPEN',
@@ -61,7 +61,7 @@ suite('Zuletzt geöffnet', () => {
const geheim = await db.case.create({
data: {
...gemeinsam,
caseNumber: `Rgh-2026-${String(Date.now()).slice(-4)}`,
caseNumber: `Rgh-2026-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
title: 'Streng vertraulich',
classification: 4,
status: 'OPEN',