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.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
-- Freie Kanäle entfallen, der Chat zieht in die Personalakte.
|
||||
--
|
||||
-- Der Gedanke hinter dem Chat war von Anfang an, dass er dort steht, wo der
|
||||
-- Vorgang steht. Ein eigener Reiter mit freien Räumen war der Rückweg nach
|
||||
-- Discord: dort redet man über eine Akte, statt in ihr. Die Fallakte hatte
|
||||
-- ihren Chat bereits, die Personalakte bekommt ihn hier.
|
||||
--
|
||||
-- Die Kanäle werden mitsamt Tabelle entfernt. Das ist unumkehrbar; zum
|
||||
-- Zeitpunkt dieser Migration stand in keinem Kanal eine Nachricht.
|
||||
|
||||
-- Die Nachrichtentabelle ist gegen DELETE gesperrt. Für das einmalige
|
||||
-- Aufräumen wird die Regel abgenommen und danach wieder gesetzt — sie ist die
|
||||
-- Zusicherung, dass niemand im Betrieb Nachrichten verschwinden lässt.
|
||||
DROP RULE chat_no_delete ON "ChatMessage";
|
||||
|
||||
DELETE FROM "ChatReaction"
|
||||
WHERE "messageId" IN (SELECT "id" FROM "ChatMessage" WHERE "channelId" IS NOT NULL);
|
||||
DELETE FROM "ChatMessage" WHERE "channelId" IS NOT NULL;
|
||||
|
||||
CREATE RULE chat_no_delete AS ON DELETE TO "ChatMessage" DO INSTEAD NOTHING;
|
||||
|
||||
DELETE FROM "ReadMarker" WHERE "scopeType" = 'channel';
|
||||
DELETE FROM "NotificationOutbox" WHERE "channelId" IS NOT NULL;
|
||||
|
||||
-- Der Ort einer Nachricht heisst jetzt anders.
|
||||
DROP INDEX IF EXISTS "ChatMessage_channelId_createdAt_idx";
|
||||
ALTER TABLE "ChatMessage" DROP CONSTRAINT IF EXISTS "ChatMessage_channelId_fkey";
|
||||
ALTER TABLE "ChatMessage" DROP COLUMN "channelId";
|
||||
ALTER TABLE "ChatMessage" ADD COLUMN "personnelFileId" TEXT;
|
||||
|
||||
ALTER TABLE "ChatMessage"
|
||||
ADD CONSTRAINT "ChatMessage_personnelFileId_fkey"
|
||||
FOREIGN KEY ("personnelFileId") REFERENCES "PersonnelFile"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "ChatMessage_personnelFileId_createdAt_idx"
|
||||
ON "ChatMessage"("personnelFileId", "createdAt" DESC);
|
||||
|
||||
-- Eine Nachricht gehört zu genau einem Ort. Vorher hing das an der
|
||||
-- Anwendung; als CHECK kann es keine Abkürzung mehr umgehen.
|
||||
ALTER TABLE "ChatMessage"
|
||||
ADD CONSTRAINT "ChatMessage_genau_ein_ort"
|
||||
CHECK (num_nonnulls("caseId", "personnelFileId") = 1);
|
||||
|
||||
-- Der Wächter über Autor, Zeitpunkt und Ort kennt den neuen Namen.
|
||||
CREATE OR REPLACE FUNCTION chat_immutable_guard() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW."authorId" IS DISTINCT FROM OLD."authorId"
|
||||
OR NEW."createdAt" IS DISTINCT FROM OLD."createdAt"
|
||||
OR NEW."caseId" IS DISTINCT FROM OLD."caseId"
|
||||
OR NEW."personnelFileId" IS DISTINCT FROM OLD."personnelFileId" THEN
|
||||
RAISE EXCEPTION 'authorId, createdAt und der Ort einer ChatMessage sind unveränderlich';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
ALTER TABLE "NotificationOutbox" DROP COLUMN "channelId";
|
||||
ALTER TABLE "NotificationOutbox" ADD COLUMN "personnelFileId" TEXT;
|
||||
|
||||
DROP TABLE "Channel";
|
||||
@@ -484,6 +484,7 @@ model PersonnelFile {
|
||||
searchVector Unsupported("tsvector")?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
messages ChatMessage[]
|
||||
participants CaseParticipant[]
|
||||
documents Document[]
|
||||
assignments PersonnelAssignment[]
|
||||
@@ -703,25 +704,14 @@ enum ScanStatus {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Frei angelegter Raum fuer allgemeine Absprachen, mit eigener Mindeststufe.
|
||||
model Channel {
|
||||
id String @id @default(uuid(7))
|
||||
key String @unique
|
||||
name String
|
||||
description String?
|
||||
minClearance Int @default(0)
|
||||
isArchived Boolean @default(false)
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
messages ChatMessage[]
|
||||
|
||||
@@index([isArchived, name])
|
||||
}
|
||||
|
||||
/// Eine Chatnachricht haengt immer an genau einer Akte: an einer Fallakte
|
||||
/// oder an einer Personalakte. Freie Kanaele gab es einmal; sie sind
|
||||
/// entfallen, weil das Gespraech dorthin gehoert, wo der Vorgang steht.
|
||||
/// Siehe docs/13-entscheidungen.md.
|
||||
model ChatMessage {
|
||||
id String @id // clientseitig erzeugt, UUIDv7
|
||||
caseId String?
|
||||
channelId String?
|
||||
personnelFileId String?
|
||||
authorId String
|
||||
body String
|
||||
replyToId String?
|
||||
@@ -736,13 +726,13 @@ model ChatMessage {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
||||
channel Channel? @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Cascade)
|
||||
replyTo ChatMessage? @relation("replies", fields: [replyToId], references: [id])
|
||||
replies ChatMessage[] @relation("replies")
|
||||
reactions ChatReaction[]
|
||||
|
||||
@@index([caseId, createdAt(sort: Desc)])
|
||||
@@index([channelId, createdAt(sort: Desc)])
|
||||
@@index([personnelFileId, createdAt(sort: Desc)])
|
||||
@@index([authorId])
|
||||
@@index([replyToId])
|
||||
@@index([mentions], type: Gin)
|
||||
@@ -761,7 +751,7 @@ model ChatReaction {
|
||||
|
||||
model ReadMarker {
|
||||
userId String
|
||||
scopeType String // "case" | "channel"
|
||||
scopeType String // "case" | "personnel"
|
||||
scopeId String
|
||||
lastReadAt DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -823,8 +813,8 @@ model NotificationOutbox {
|
||||
id BigInt @id @default(autoincrement())
|
||||
kind String // "entry.new", "chat.new", "chat.mention", "case.status"
|
||||
caseId String?
|
||||
channelId String?
|
||||
/// Leer heisst: in den allgemeinen Kanal. Sonst als Direktnachricht.
|
||||
personnelFileId String?
|
||||
/// Leer heisst: in den allgemeinen Discord-Kanal. Sonst als Direktnachricht.
|
||||
recipientId String?
|
||||
actorId String
|
||||
/// Nur Kennungen und Zahlen, nie Text aus einer Akte.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
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) } })
|
||||
}
|
||||
|
||||
export function visibleMessageWhere(p: EffectivePermissions): Prisma.ChatMessageWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{ case: { is: visibleCaseWhere(p) } },
|
||||
{ channel: { is: visibleChannelWhere(p) } },
|
||||
],
|
||||
}
|
||||
return { OR: orte }
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ export type AuditAction =
|
||||
| 'attachment.read'
|
||||
| 'attachment.release'
|
||||
| 'chat.adopt'
|
||||
| 'channel.create'
|
||||
| 'roles.update'
|
||||
| 'export.pdf'
|
||||
| 'export.approve'
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+37
-100
@@ -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,
|
||||
} 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,
|
||||
recipientId: user.id,
|
||||
meta: { messageId: nachricht.id, channelKey: ort.label, actorName: absenderName },
|
||||
})),
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
+24
-17
@@ -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({
|
||||
darfPersonal
|
||||
? db.personnelFile.findMany({
|
||||
where: {
|
||||
isArchived: false,
|
||||
...(p.isAdmin ? {} : { minClearance: { lte: p.clearanceLevel } }),
|
||||
AND: [
|
||||
visiblePersonnelWhere(p),
|
||||
{ assignments: { some: { userId: p.userId } } },
|
||||
],
|
||||
},
|
||||
select: { id: true, key: true },
|
||||
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 []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'`,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -25,7 +25,7 @@ type Meta = {
|
||||
caseNumber?: string
|
||||
actorName?: string
|
||||
title?: string | null
|
||||
channelKey?: string
|
||||
personnelFileId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,9 +52,11 @@ export function composeMessage(
|
||||
return null
|
||||
}
|
||||
|
||||
if (meta.channelKey && kind === 'chat.mention') {
|
||||
const link = `${appUrl.replace(/\/$/, '')}/kanaele/${meta.channelKey}`
|
||||
return `${wer} hat dich in #${meta.channelKey} erwähnt · ${link}`
|
||||
if (meta.personnelFileId && kind === 'chat.mention') {
|
||||
// Der Name der Person steht bewusst nicht in der Meldung: dass es zu
|
||||
// jemandem eine Personalakte gibt, ist selbst eine Auskunft.
|
||||
const link = `${appUrl.replace(/\/$/, '')}/personalakten/${meta.personnelFileId}`
|
||||
return `${wer} hat dich im Chat einer Personalakte erwähnt · ${link}`
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user