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")?
|
searchVector Unsupported("tsvector")?
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
messages ChatMessage[]
|
||||||
participants CaseParticipant[]
|
participants CaseParticipant[]
|
||||||
documents Document[]
|
documents Document[]
|
||||||
assignments PersonnelAssignment[]
|
assignments PersonnelAssignment[]
|
||||||
@@ -703,25 +704,14 @@ enum ScanStatus {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Frei angelegter Raum fuer allgemeine Absprachen, mit eigener Mindeststufe.
|
/// Frei angelegter Raum fuer allgemeine Absprachen, mit eigener Mindeststufe.
|
||||||
model Channel {
|
/// Eine Chatnachricht haengt immer an genau einer Akte: an einer Fallakte
|
||||||
id String @id @default(uuid(7))
|
/// oder an einer Personalakte. Freie Kanaele gab es einmal; sie sind
|
||||||
key String @unique
|
/// entfallen, weil das Gespraech dorthin gehoert, wo der Vorgang steht.
|
||||||
name String
|
/// Siehe docs/13-entscheidungen.md.
|
||||||
description String?
|
|
||||||
minClearance Int @default(0)
|
|
||||||
isArchived Boolean @default(false)
|
|
||||||
createdById String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
messages ChatMessage[]
|
|
||||||
|
|
||||||
@@index([isArchived, name])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ChatMessage {
|
model ChatMessage {
|
||||||
id String @id // clientseitig erzeugt, UUIDv7
|
id String @id // clientseitig erzeugt, UUIDv7
|
||||||
caseId String?
|
caseId String?
|
||||||
channelId String?
|
personnelFileId String?
|
||||||
authorId String
|
authorId String
|
||||||
body String
|
body String
|
||||||
replyToId String?
|
replyToId String?
|
||||||
@@ -736,13 +726,13 @@ model ChatMessage {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade)
|
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])
|
replyTo ChatMessage? @relation("replies", fields: [replyToId], references: [id])
|
||||||
replies ChatMessage[] @relation("replies")
|
replies ChatMessage[] @relation("replies")
|
||||||
reactions ChatReaction[]
|
reactions ChatReaction[]
|
||||||
|
|
||||||
@@index([caseId, createdAt(sort: Desc)])
|
@@index([caseId, createdAt(sort: Desc)])
|
||||||
@@index([channelId, createdAt(sort: Desc)])
|
@@index([personnelFileId, createdAt(sort: Desc)])
|
||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
@@index([replyToId])
|
@@index([replyToId])
|
||||||
@@index([mentions], type: Gin)
|
@@index([mentions], type: Gin)
|
||||||
@@ -761,7 +751,7 @@ model ChatReaction {
|
|||||||
|
|
||||||
model ReadMarker {
|
model ReadMarker {
|
||||||
userId String
|
userId String
|
||||||
scopeType String // "case" | "channel"
|
scopeType String // "case" | "personnel"
|
||||||
scopeId String
|
scopeId String
|
||||||
lastReadAt DateTime
|
lastReadAt DateTime
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -823,8 +813,8 @@ model NotificationOutbox {
|
|||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
kind String // "entry.new", "chat.new", "chat.mention", "case.status"
|
kind String // "entry.new", "chat.new", "chat.mention", "case.status"
|
||||||
caseId String?
|
caseId String?
|
||||||
channelId String?
|
personnelFileId String?
|
||||||
/// Leer heisst: in den allgemeinen Kanal. Sonst als Direktnachricht.
|
/// Leer heisst: in den allgemeinen Discord-Kanal. Sonst als Direktnachricht.
|
||||||
recipientId String?
|
recipientId String?
|
||||||
actorId String
|
actorId String
|
||||||
/// Nur Kennungen und Zahlen, nie Text aus einer Akte.
|
/// 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))
|
setGrace(Number(event.target.value))
|
||||||
setSaved(false)
|
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>
|
</label>
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export function PolicyForm({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={pending}
|
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}
|
{pending ? caseText.saving : settingsText.save}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export default async function PolicyPage() {
|
|||||||
const setting = await db.appSetting.findUnique({ where: { id: 1 } })
|
const setting = await db.appSetting.findUnique({ where: { id: 1 } })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
|
<section>
|
||||||
<h1 className="font-condensed">{de.settings.heading}</h1>
|
<h2 className="font-condensed">{de.settings.heading}</h2>
|
||||||
<p className="mt-2 text-text-dim">{de.settings.intro}</p>
|
<p className="mt-2 text-text-dim">{de.settings.intro}</p>
|
||||||
<PolicyForm
|
<PolicyForm
|
||||||
initial={{
|
initial={{
|
||||||
@@ -23,6 +23,6 @@ export default async function PolicyPage() {
|
|||||||
entryEditMarkGrace: setting?.entryEditMarkGrace ?? false,
|
entryEditMarkGrace: setting?.entryEditMarkGrace ?? false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</main>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function RoleRow({
|
|||||||
setCategory(event.target.value)
|
setCategory(event.target.value)
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
}}
|
}}
|
||||||
className="border border-line bg-raised px-2 py-1"
|
className="field"
|
||||||
>
|
>
|
||||||
{CATEGORIES.map((value) => (
|
{CATEGORIES.map((value) => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
@@ -109,7 +109,7 @@ export function RoleRow({
|
|||||||
setClearanceLevel(Number(event.target.value))
|
setClearanceLevel(Number(event.target.value))
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
}}
|
}}
|
||||||
className="border border-line bg-raised px-2 py-1"
|
className="field"
|
||||||
>
|
>
|
||||||
{levels.map((level) => (
|
{levels.map((level) => (
|
||||||
<option key={level.level} value={level.level}>
|
<option key={level.level} value={level.level}>
|
||||||
@@ -160,7 +160,7 @@ export function RoleRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={pending || !dirty}
|
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}
|
{pending ? roleAdminText.saving : roleAdminText.save}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export default async function RoleAdminPage() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
<section>
|
||||||
<h1 className="font-condensed">{de.roleAdmin.heading}</h1>
|
<h2 className="font-condensed">{de.roleAdmin.heading}</h2>
|
||||||
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.roleAdmin.intro}</p>
|
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.roleAdmin.intro}</p>
|
||||||
|
|
||||||
{mappings.length === 0 ? (
|
{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')
|
await requirePermission('admin')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
|
<section>
|
||||||
<h1 className="font-condensed">{de.health.heading}</h1>
|
<h2 className="font-condensed">{de.health.heading}</h2>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -91,7 +91,7 @@ export default async function SystemPage() {
|
|||||||
<a href="/api/admin/export?kind=bundle">{de.export.bundle}</a>
|
<a href="/api/admin/export?kind=bundle">{de.export.bundle}</a>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
|
|||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
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>
|
</label>
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
|
|||||||
<input
|
<input
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(event) => setDescription(event.target.value)}
|
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>
|
</label>
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
|
|||||||
<input
|
<input
|
||||||
value={numberPrefix}
|
value={numberPrefix}
|
||||||
onChange={(event) => setNumberPrefix(event.target.value)}
|
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>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@@ -115,7 +115,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
|
|||||||
max={8}
|
max={8}
|
||||||
value={numberPadding}
|
value={numberPadding}
|
||||||
onChange={(event) => setNumberPadding(Number(event.target.value))}
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +127,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
|
|||||||
onChange={(event) => setJson(event.target.value)}
|
onChange={(event) => setJson(event.target.value)}
|
||||||
rows={16}
|
rows={16}
|
||||||
spellCheck={false}
|
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>
|
<span className="mt-1 block text-2xs text-text-dim">{templateAdminText.fieldsHelp}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -137,7 +137,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={pending || !geparst.ok}
|
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}
|
{pending ? caseText.saving : templateAdminText.save}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export default async function TemplateAdminPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
<section>
|
||||||
<h1 className="font-condensed">{de.templateAdmin.heading}</h1>
|
<h2 className="font-condensed">{de.templateAdmin.heading}</h2>
|
||||||
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.templateAdmin.intro}</p>
|
<p className="mt-2 max-w-(--container-measure) text-text-dim">{de.templateAdmin.intro}</p>
|
||||||
|
|
||||||
{vorlagen.length === 0 ? (
|
{vorlagen.length === 0 ? (
|
||||||
@@ -41,6 +41,6 @@ export default async function TemplateAdminPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function toMessage(error: unknown): string {
|
|||||||
|
|
||||||
const scope = z.union([
|
const scope = z.union([
|
||||||
z.object({ kind: z.literal('case'), caseNumber: z.string().min(1) }),
|
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({
|
const sendSchema = z.object({
|
||||||
@@ -114,7 +114,7 @@ export async function adoptAsEntryAction(input: unknown): Promise<ActionResult>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const readSchema = z.object({
|
const readSchema = z.object({
|
||||||
scopeType: z.enum(['case', 'channel']),
|
scopeType: z.enum(['case', 'personnel']),
|
||||||
scopeId: z.string().min(1),
|
scopeId: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function DocumentActions({
|
|||||||
if (!confirm(documentText.finalizeHint)) return
|
if (!confirm(documentText.finalizeHint)) return
|
||||||
run(() => finalizeDocumentAction({ documentNumber }))
|
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}
|
{documentText.finalize}
|
||||||
</button>
|
</button>
|
||||||
@@ -78,7 +78,7 @@ export function DocumentActions({
|
|||||||
value={nextLevel}
|
value={nextLevel}
|
||||||
onChange={(event) => setNextLevel(Number(event.target.value))}
|
onChange={(event) => setNextLevel(Number(event.target.value))}
|
||||||
aria-label={clearanceText.classification}
|
aria-label={clearanceText.classification}
|
||||||
className="border border-line bg-raised px-2 py-1"
|
className="field"
|
||||||
>
|
>
|
||||||
{levels.map((level) => (
|
{levels.map((level) => (
|
||||||
<option key={level.level} value={level.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}
|
{commonText.save}
|
||||||
</button>
|
</button>
|
||||||
@@ -110,7 +110,7 @@ export function DocumentActions({
|
|||||||
rows={2}
|
rows={2}
|
||||||
placeholder={caseText.reclassifyReason}
|
placeholder={caseText.reclassifyReason}
|
||||||
aria-label={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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -126,21 +126,21 @@ export function DocumentActions({
|
|||||||
rows={2}
|
rows={2}
|
||||||
placeholder={documentText.retractReason}
|
placeholder={documentText.retractReason}
|
||||||
aria-label={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">
|
<div className="mt-2 flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={pending || reason.trim().length < 10}
|
disabled={pending || reason.trim().length < 10}
|
||||||
onClick={() => run(() => retractDocumentAction({ documentNumber, reason }))}
|
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}
|
{documentText.retract}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRetracting(false)}
|
onClick={() => setRetracting(false)}
|
||||||
className="font-condensed text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{commonText.cancel}
|
{commonText.cancel}
|
||||||
</button>
|
</button>
|
||||||
@@ -150,7 +150,7 @@ export function DocumentActions({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRetracting(true)}
|
onClick={() => setRetracting(true)}
|
||||||
className="font-condensed text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{documentText.retract}
|
{documentText.retract}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function NotifyForm({ current }: { current: string }) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
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}
|
{notifyText.save}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -53,14 +53,14 @@ export function Approvals({ jobs }: { jobs: Auftrag[] }) {
|
|||||||
if (ergebnis.error) setFehler(ergebnis.error)
|
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}
|
{exportText.approve}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAblehnung(auftrag.id)}
|
onClick={() => setAblehnung(auftrag.id)}
|
||||||
className="font-condensed text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{exportText.reject}
|
{exportText.reject}
|
||||||
</button>
|
</button>
|
||||||
@@ -74,7 +74,7 @@ export function Approvals({ jobs }: { jobs: Auftrag[] }) {
|
|||||||
rows={2}
|
rows={2}
|
||||||
placeholder={exportText.rejectReason}
|
placeholder={exportText.rejectReason}
|
||||||
aria-label={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">
|
<div className="mt-1 flex gap-3 text-2xs">
|
||||||
<button
|
<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}
|
{exportText.reject}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAblehnung(null)}
|
onClick={() => setAblehnung(null)}
|
||||||
className="font-condensed text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{commonText.cancel}
|
{commonText.cancel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function CaseActions({
|
|||||||
<select
|
<select
|
||||||
value={nextStatus}
|
value={nextStatus}
|
||||||
onChange={(event) => setNextStatus(event.target.value)}
|
onChange={(event) => setNextStatus(event.target.value)}
|
||||||
className="border border-line bg-raised px-2 py-1"
|
className="field"
|
||||||
>
|
>
|
||||||
{statusTargets.map((target) => (
|
{statusTargets.map((target) => (
|
||||||
<option key={target} value={target}>
|
<option key={target} value={target}>
|
||||||
@@ -64,7 +64,7 @@ export function CaseActions({
|
|||||||
value={statusReason}
|
value={statusReason}
|
||||||
onChange={(event) => setStatusReason(event.target.value)}
|
onChange={(event) => setStatusReason(event.target.value)}
|
||||||
placeholder={entryText.changeReason}
|
placeholder={entryText.changeReason}
|
||||||
className="w-56 border border-line bg-raised px-2 py-1"
|
className="w-56 field"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -80,7 +80,7 @@ export function CaseActions({
|
|||||||
`${caseText.statusChange}: ${caseText.statusNames[nextStatus] ?? nextStatus}?`,
|
`${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}
|
{pending ? caseText.saving : commonText.save}
|
||||||
</button>
|
</button>
|
||||||
@@ -94,7 +94,7 @@ export function CaseActions({
|
|||||||
<select
|
<select
|
||||||
value={nextLevel}
|
value={nextLevel}
|
||||||
onChange={(event) => setNextLevel(Number(event.target.value))}
|
onChange={(event) => setNextLevel(Number(event.target.value))}
|
||||||
className="border border-line bg-raised px-2 py-1"
|
className="field"
|
||||||
>
|
>
|
||||||
{levels
|
{levels
|
||||||
.filter((level) => level.level <= maxLevel)
|
.filter((level) => level.level <= maxLevel)
|
||||||
@@ -111,7 +111,7 @@ export function CaseActions({
|
|||||||
value={levelReason}
|
value={levelReason}
|
||||||
onChange={(event) => setLevelReason(event.target.value)}
|
onChange={(event) => setLevelReason(event.target.value)}
|
||||||
placeholder={caseText.reclassifyReason}
|
placeholder={caseText.reclassifyReason}
|
||||||
className="w-64 border border-line bg-raised px-2 py-1"
|
className="w-64 field"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
@@ -128,7 +128,7 @@ export function CaseActions({
|
|||||||
`${caseText.reclassify}: ${nextLevel}?`,
|
`${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}
|
{pending ? caseText.saving : commonText.save}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function CaseLinks({
|
|||||||
if (!ergebnis.ok) setFehler(ergebnis.error)
|
if (!ergebnis.ok) setFehler(ergebnis.error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="font-condensed text-2xs text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{linkText.remove}
|
{linkText.remove}
|
||||||
</button>
|
</button>
|
||||||
@@ -110,7 +110,7 @@ export function CaseLinks({
|
|||||||
onChange={(event) => setTarget(event.target.value)}
|
onChange={(event) => setTarget(event.target.value)}
|
||||||
placeholder={linkText.target}
|
placeholder={linkText.target}
|
||||||
aria-label={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>
|
</AddPanel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ export function EntryActions({
|
|||||||
value={reason}
|
value={reason}
|
||||||
onChange={(event) => setReason(event.target.value)}
|
onChange={(event) => setReason(event.target.value)}
|
||||||
placeholder={entryText.retractReason}
|
placeholder={entryText.retractReason}
|
||||||
className="w-64 border border-line bg-raised px-2 py-1"
|
className="w-64 field"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onClick={() => run(() => restoreEntryAction({ entryId, caseNumber, reason }))}
|
onClick={() => run(() => restoreEntryAction({ entryId, caseNumber, reason }))}
|
||||||
className="font-condensed text-accent disabled:text-text-dim"
|
className="btn btn-sm btn-primary"
|
||||||
>
|
>
|
||||||
{entryText.restore}
|
{entryText.restore}
|
||||||
</button>
|
</button>
|
||||||
@@ -73,7 +73,7 @@ export function EntryActions({
|
|||||||
return (
|
return (
|
||||||
<div className="mt-1.5 flex gap-3 text-2xs">
|
<div className="mt-1.5 flex gap-3 text-2xs">
|
||||||
{canEdit ? (
|
{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}
|
{entryText.edit}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -81,7 +81,7 @@ export function EntryActions({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode('retract')}
|
onClick={() => setMode('retract')}
|
||||||
className="font-condensed text-accent"
|
className="btn btn-sm btn-primary"
|
||||||
>
|
>
|
||||||
{entryText.retract}
|
{entryText.retract}
|
||||||
</button>
|
</button>
|
||||||
@@ -97,7 +97,7 @@ export function EntryActions({
|
|||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="block w-full border border-line bg-raised px-3 py-2 text-sm"
|
className="block w-full field"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ export function EntryActions({
|
|||||||
value={reason}
|
value={reason}
|
||||||
onChange={(event) => setReason(event.target.value)}
|
onChange={(event) => setReason(event.target.value)}
|
||||||
placeholder={mode === 'edit' ? entryText.changeReason : entryText.retractReason}
|
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">
|
<div className="mt-2 flex items-center gap-3">
|
||||||
@@ -119,7 +119,7 @@ export function EntryActions({
|
|||||||
: retractEntryAction({ entryId, caseNumber, reason }),
|
: 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}
|
{pending ? caseText.saving : mode === 'edit' ? entryText.editSave : entryText.retract}
|
||||||
</button>
|
</button>
|
||||||
@@ -131,7 +131,7 @@ export function EntryActions({
|
|||||||
setReason('')
|
setReason('')
|
||||||
setError(null)
|
setError(null)
|
||||||
}}
|
}}
|
||||||
className="font-condensed text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{entryText.cancel}
|
{entryText.cancel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -67,12 +67,12 @@ export function EntryComposer({ caseNumber }: { caseNumber: string }) {
|
|||||||
}}
|
}}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={caseText.entryPlaceholder}
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending || draft.trim().length === 0}
|
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}
|
{pending ? caseText.saving : entryText.create}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function Participants({
|
|||||||
if (!ergebnis.ok) setFehler(ergebnis.error)
|
if (!ergebnis.ok) setFehler(ergebnis.error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="font-condensed text-2xs text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{participantText.remove}
|
{participantText.remove}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export function WatchToggle({
|
|||||||
else setAktiv(ergebnis.watching)
|
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}
|
{aktiv ? watchText.unwatch : watchText.watch}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import Link from 'next/link'
|
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
|
import { Tabs } from '@/components/Tabs'
|
||||||
import { requireUser } from '@/lib/auth/current'
|
import { requireUser } from '@/lib/auth/current'
|
||||||
import { loadVisibleCase } from '@/lib/cases/service'
|
import { loadVisibleCase } from '@/lib/cases/service'
|
||||||
import { de } from '@/lib/i18n/de'
|
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 level = levels.find((l) => l.level === akte.classification)
|
||||||
|
|
||||||
|
const basis = `/faelle/${akte.caseNumber}`
|
||||||
const reiter = [
|
const reiter = [
|
||||||
{ href: '', label: de.case.tabFile },
|
{ href: basis, label: de.case.tabFile },
|
||||||
{ href: '/beteiligte', label: de.case.tabRelations },
|
{ href: `${basis}/beteiligte`, label: de.case.tabRelations },
|
||||||
{ href: '/dokumente', label: de.case.tabDocuments },
|
{ href: `${basis}/dokumente`, label: de.case.tabDocuments },
|
||||||
{ href: '/aenderungen', label: de.case.changes },
|
{ href: `${basis}/aenderungen`, label: de.case.changes },
|
||||||
{ href: '/export', label: de.export.heading },
|
{ href: `${basis}/export`, label: de.export.heading },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,13 +98,7 @@ export default async function CaseLayout({
|
|||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="mt-8 flex flex-wrap gap-4 border-b border-line pb-2 font-condensed text-xs">
|
<Tabs tabs={reiter} />
|
||||||
{reiter.map((eintrag) => (
|
|
||||||
<Link key={eintrag.href} href={`/faelle/${akte.caseNumber}${eintrag.href}`}>
|
|
||||||
{eintrag.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="mt-8">{children}</div>
|
<div className="mt-8">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function NewCaseForm({
|
|||||||
required
|
required
|
||||||
minLength={3}
|
minLength={3}
|
||||||
maxLength={200}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export function NewCaseForm({
|
|||||||
id="templateKey"
|
id="templateKey"
|
||||||
name="templateKey"
|
name="templateKey"
|
||||||
required
|
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) => (
|
{templates.map((template) => (
|
||||||
<option key={template.key} value={template.key}>
|
<option key={template.key} value={template.key}>
|
||||||
@@ -61,7 +61,7 @@ export function NewCaseForm({
|
|||||||
id="classification"
|
id="classification"
|
||||||
name="classification"
|
name="classification"
|
||||||
defaultValue={defaultLevel}
|
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) => (
|
{levels.map((level) => (
|
||||||
<option key={level.level} value={level.level}>
|
<option key={level.level} value={level.level}>
|
||||||
@@ -82,7 +82,7 @@ export function NewCaseForm({
|
|||||||
id="personnelFileId"
|
id="personnelFileId"
|
||||||
name="personnelFileId"
|
name="personnelFileId"
|
||||||
defaultValue=""
|
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>
|
<option value="">{caseText.subjectNone}</option>
|
||||||
{people.map((person) => (
|
{people.map((person) => (
|
||||||
@@ -98,7 +98,7 @@ export function NewCaseForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
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}
|
{pending ? caseText.saving : caseText.create}
|
||||||
</button>
|
</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 { requireUser } from '@/lib/auth/current'
|
||||||
import { de } from '@/lib/i18n/de'
|
import { de } from '@/lib/i18n/de'
|
||||||
import { PaletteTrigger } from '@/components/palette/PaletteTrigger'
|
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 { 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 }) {
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
const user = await requireUser()
|
const user = await requireUser()
|
||||||
|
|
||||||
@@ -14,48 +26,43 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||||||
await signOut({ redirectTo: '/anmelden' })
|
await signOut({ redirectTo: '/anmelden' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const darfPersonal =
|
||||||
|
user.permissions.isAdmin || user.permissions.permissions.has('personnel.read')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh">
|
<div className="min-h-dvh">
|
||||||
{/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */}
|
{/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */}
|
||||||
<PaletteTrigger />
|
<PaletteTrigger />
|
||||||
<Vitals />
|
<Vitals />
|
||||||
<header className="border-b border-line">
|
<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">
|
<Link href="/uebersicht" className="font-condensed text-text no-underline">
|
||||||
{de.app.shortName}
|
{de.app.shortName}
|
||||||
</Link>
|
</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="/uebersicht">{de.nav.dashboard}</Link>
|
||||||
<Link href="/faelle">{de.nav.cases}</Link>
|
<Link href="/faelle">{de.nav.cases}</Link>
|
||||||
{user.permissions.isAdmin || user.permissions.permissions.has('personnel.read') ? (
|
{darfPersonal ? <Link href="/personalakten">{de.nav.personnel}</Link> : null}
|
||||||
<Link href="/personalakten">{de.nav.personnel}</Link>
|
{hasAdminAccess(user.permissions) ? (
|
||||||
) : null}
|
<Link href="/admin">{de.nav.admin}</Link>
|
||||||
<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}
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3 text-xs text-text-dim">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<ThemeToggle />
|
<SearchBar />
|
||||||
<span className="font-mono">{user.displayName}</span>
|
<ProfileMenu
|
||||||
<form action={abmelden}>
|
displayName={user.displayName}
|
||||||
<button type="submit" className="font-condensed text-accent">
|
username={user.username}
|
||||||
{de.auth.signOut}
|
signOutAction={abmelden}
|
||||||
</button>
|
avatar={
|
||||||
</form>
|
<Avatar
|
||||||
|
userId={user.id}
|
||||||
|
avatarHash={user.avatarHash}
|
||||||
|
displayName={user.displayName}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function Assignments({
|
|||||||
if (!ergebnis.ok) setFehler(ergebnis.error)
|
if (!ergebnis.ok) setFehler(ergebnis.error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="font-condensed text-2xs text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{assignmentText.remove}
|
{assignmentText.remove}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function LinkCase({ personnelFileId }: { personnelFileId: string }) {
|
|||||||
onChange={(event) => setCaseNumber(event.target.value)}
|
onChange={(event) => setCaseNumber(event.target.value)}
|
||||||
placeholder={linkText.target}
|
placeholder={linkText.target}
|
||||||
aria-label={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>
|
</AddPanel>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function PersonnelHead({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(true)}
|
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}
|
{personnelText.edit}
|
||||||
</button>
|
</button>
|
||||||
@@ -129,7 +129,7 @@ export function PersonnelHead({
|
|||||||
name="note"
|
name="note"
|
||||||
rows={3}
|
rows={3}
|
||||||
defaultValue={file.note ?? ''}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -141,14 +141,14 @@ export function PersonnelHead({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
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}
|
{pending ? commonText.loading : commonText.save}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(false)}
|
onClick={() => setEditing(false)}
|
||||||
className="font-condensed text-2xs text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{commonText.cancel}
|
{commonText.cancel}
|
||||||
</button>
|
</button>
|
||||||
@@ -179,7 +179,7 @@ function Reclassify({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="font-condensed text-2xs text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{personnelText.reclassify}
|
{personnelText.reclassify}
|
||||||
</button>
|
</button>
|
||||||
@@ -193,7 +193,7 @@ function Reclassify({
|
|||||||
value={to}
|
value={to}
|
||||||
onChange={(event) => setTo(Number(event.target.value))}
|
onChange={(event) => setTo(Number(event.target.value))}
|
||||||
aria-label={clearanceText.classification}
|
aria-label={clearanceText.classification}
|
||||||
className="border border-line bg-raised px-2 py-1 text-2xs"
|
className="field text-2xs"
|
||||||
>
|
>
|
||||||
{levels.map((level) => (
|
{levels.map((level) => (
|
||||||
<option key={level.level} value={level.level}>
|
<option key={level.level} value={level.level}>
|
||||||
@@ -211,14 +211,14 @@ function Reclassify({
|
|||||||
else setError(result.error)
|
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}
|
{commonText.save}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="font-condensed text-2xs text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{commonText.cancel}
|
{commonText.cancel}
|
||||||
</button>
|
</button>
|
||||||
@@ -233,7 +233,7 @@ function Reclassify({
|
|||||||
rows={2}
|
rows={2}
|
||||||
placeholder={caseText.reclassifyReason}
|
placeholder={caseText.reclassifyReason}
|
||||||
aria-label={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}
|
) : null}
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ function Feld({
|
|||||||
minLength={required ? 2 : undefined}
|
minLength={required ? 2 : undefined}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
defaultValue={defaultValue ?? ''}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import { listDocuments, listTemplatesFor } from '@/lib/documents/service'
|
import { listDocuments, listTemplatesFor } from '@/lib/documents/service'
|
||||||
import { canWritePersonnel } from '@/lib/access/documentWrite'
|
import { canWritePersonnel } from '@/lib/access/documentWrite'
|
||||||
import { trackOpen } from '@/lib/recent/track'
|
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 { de } from '@/lib/i18n/de'
|
||||||
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
||||||
import { SkeletonLine } from '@/components/Skeleton'
|
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 }> }) {
|
export default async function PersonnelPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const user = await requirePermission('personnel.read')
|
const user = await requirePermission('personnel.read')
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
@@ -249,6 +285,15 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id:
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function NewPersonnelForm({
|
|||||||
id="userId"
|
id="userId"
|
||||||
name="userId"
|
name="userId"
|
||||||
defaultValue=""
|
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>
|
<option value="">{personnelText.noAccount}</option>
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
@@ -79,7 +79,7 @@ export function NewPersonnelForm({
|
|||||||
id="note"
|
id="note"
|
||||||
name="note"
|
name="note"
|
||||||
rows={3}
|
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>
|
<p className="mt-1 text-2xs text-text-dim">{personnelText.noteHelp}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +93,7 @@ export function NewPersonnelForm({
|
|||||||
id="classification"
|
id="classification"
|
||||||
name="classification"
|
name="classification"
|
||||||
defaultValue={defaultLevel}
|
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) => (
|
{levels.map((level) => (
|
||||||
<option key={level.level} value={level.level}>
|
<option key={level.level} value={level.level}>
|
||||||
@@ -110,7 +110,7 @@ export function NewPersonnelForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
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}
|
{pending ? commonText.loading : personnelText.create}
|
||||||
</button>
|
</button>
|
||||||
@@ -140,7 +140,7 @@ function Feld({
|
|||||||
required={required}
|
required={required}
|
||||||
minLength={minLength}
|
minLength={minLength}
|
||||||
maxLength={120}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default async function PersonnelListPage({
|
|||||||
defaultValue={q ?? ''}
|
defaultValue={q ?? ''}
|
||||||
placeholder={de.personnel.search}
|
placeholder={de.personnel.search}
|
||||||
aria-label={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}
|
{status ? <input type="hidden" name="status" value={status} /> : null}
|
||||||
{darfAnlegen ? (
|
{darfAnlegen ? (
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function SavedViews({
|
|||||||
disabled={pending}
|
disabled={pending}
|
||||||
title={searchText.deleteView}
|
title={searchText.deleteView}
|
||||||
onClick={() => startTransition(async () => void (await deleteViewAction({ id: view.id })))}
|
onClick={() => startTransition(async () => void (await deleteViewAction({ id: view.id })))}
|
||||||
className="text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -62,7 +62,7 @@ export function SavedViews({
|
|||||||
placeholder={searchText.saveViewName}
|
placeholder={searchText.saveViewName}
|
||||||
aria-label={searchText.saveViewName}
|
aria-label={searchText.saveViewName}
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
className="border border-line bg-raised px-2 py-1"
|
className="field"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="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}
|
{commonText.save}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOffen(false)}
|
onClick={() => setOffen(false)}
|
||||||
className="font-condensed text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{commonText.cancel}
|
{commonText.cancel}
|
||||||
</button>
|
</button>
|
||||||
@@ -94,7 +94,7 @@ export function SavedViews({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOffen(true)}
|
onClick={() => setOffen(true)}
|
||||||
className="ml-auto font-condensed text-text-dim"
|
className="ml-auto btn btn-sm"
|
||||||
>
|
>
|
||||||
{searchText.saveView}
|
{searchText.saveView}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function SearchBox({ filters }: { filters: SearchFilters }) {
|
|||||||
placeholder={searchText.placeholder}
|
placeholder={searchText.placeholder}
|
||||||
aria-label={searchText.placeholder}
|
aria-label={searchText.placeholder}
|
||||||
autoFocus
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default async function DashboardPage() {
|
|||||||
<h2 className="font-condensed">{de.dashboard.opened}</h2>
|
<h2 className="font-condensed">{de.dashboard.opened}</h2>
|
||||||
{daten.geoeffnet.length > 0 ? (
|
{daten.geoeffnet.length > 0 ? (
|
||||||
<form action={clearRecentAction}>
|
<form action={clearRecentAction}>
|
||||||
<button type="submit" className="font-condensed text-2xs text-text-dim">
|
<button type="submit" className="btn btn-sm">
|
||||||
{de.dashboard.openedClear}
|
{de.dashboard.openedClear}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default async function SignInPage() {
|
|||||||
<form action={anmelden} className="mt-8">
|
<form action={anmelden} className="mt-8">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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}
|
{de.auth.signIn}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+17
-13
@@ -1,7 +1,8 @@
|
|||||||
import { getCurrentUser, type CurrentUser } from '@/lib/auth/current'
|
import { getCurrentUser, type CurrentUser } from '@/lib/auth/current'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
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 { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
|
||||||
import { resolveCasePreviews, type CasePreview } from '@/lib/chat/preview'
|
import { resolveCasePreviews, type CasePreview } from '@/lib/chat/preview'
|
||||||
import { extractCaseNumbers, type ChatMessageView } from '@/lib/chat/view'
|
import { extractCaseNumbers, type ChatMessageView } from '@/lib/chat/view'
|
||||||
@@ -17,7 +18,7 @@ const HEARTBEAT_MS = 25_000
|
|||||||
/**
|
/**
|
||||||
* Ereignisstrom für offene Ansichten.
|
* 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
|
* Server-Sent Events und kein WebSocket: die Richtung ist einseitig, es geht
|
||||||
* durch jeden Tunnel, und der Browser baut die Verbindung von selbst wieder
|
* 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:'))
|
.filter((t) => t.startsWith('case:'))
|
||||||
.map((t) => t.slice(5))
|
.map((t) => t.slice(5))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const kanalIds = gewuenscht
|
const personalIds = gewuenscht
|
||||||
.filter((t) => t.startsWith('channel:'))
|
.filter((t) => t.startsWith('personnel:'))
|
||||||
.map((t) => t.slice(8))
|
.map((t) => t.slice(10))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
if (fallIds.length > 0) {
|
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))
|
themen.push(...sichtbar.map((akte) => `case:${akte.id}` as Topic))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kanalIds.length > 0) {
|
// Personalakten setzen neben der Stufe ein eigenes Recht voraus.
|
||||||
const sichtbar = await db.channel.findMany({
|
const darfPersonal =
|
||||||
where: { AND: [visibleChannelWhere(user.permissions), { id: { in: kanalIds } }] },
|
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 },
|
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
|
return themen
|
||||||
@@ -163,8 +167,8 @@ async function nachliefern(
|
|||||||
send: Send,
|
send: Send,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fallIds = themen.filter((t) => t.startsWith('case:')).map((t) => t.slice(5))
|
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))
|
const personalIds = themen.filter((t) => t.startsWith('personnel:')).map((t) => t.slice(10))
|
||||||
if (fallIds.length === 0 && kanalIds.length === 0) return
|
if (fallIds.length === 0 && personalIds.length === 0) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const verpasst = await db.chatMessage.findMany({
|
const verpasst = await db.chatMessage.findMany({
|
||||||
@@ -172,7 +176,7 @@ async function nachliefern(
|
|||||||
AND: [
|
AND: [
|
||||||
visibleMessageWhere(user.permissions),
|
visibleMessageWhere(user.permissions),
|
||||||
{ id: { gt: seit } },
|
{ id: { gt: seit } },
|
||||||
{ OR: [{ caseId: { in: fallIds } }, { channelId: { in: kanalIds } }] },
|
{ OR: [{ caseId: { in: fallIds } }, { personnelFileId: { in: personalIds } }] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
@@ -199,7 +203,7 @@ const NACHRICHT = {
|
|||||||
mentions: true,
|
mentions: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
caseId: true,
|
caseId: true,
|
||||||
channelId: true,
|
personnelFileId: true,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ export default function Error({ reset }: { error: Error; reset: () => void }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={reset}
|
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}
|
{commonText.retry}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+162
-1
@@ -54,13 +54,19 @@
|
|||||||
--text-lg--line-height: 1.45;
|
--text-lg--line-height: 1.45;
|
||||||
--text-xl--line-height: 1.3;
|
--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-none: 0px;
|
||||||
--radius-sm: 2px;
|
--radius-sm: 2px;
|
||||||
--radius-DEFAULT: 2px;
|
--radius-DEFAULT: 2px;
|
||||||
--radius-md: 2px;
|
--radius-md: 2px;
|
||||||
--radius-lg: 2px;
|
--radius-lg: 2px;
|
||||||
--radius-xl: 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. */
|
/* Keine Schatten ausser dem Fokusring. */
|
||||||
--shadow-sm: none;
|
--shadow-sm: none;
|
||||||
@@ -189,6 +195,12 @@
|
|||||||
text-underline-offset: 2px;
|
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. */
|
/* Sichtbarer Fokusring in --steel, der einzige Schatten im System. */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-accent);
|
outline: 2px solid var(--color-accent);
|
||||||
@@ -202,6 +214,155 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@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
|
/* Der eine mutige Moment: die Schwaerzung. Ein massiver Balken mit feiner
|
||||||
Rahmenlinie, der beim ersten Erscheinen einmal von links nach rechts
|
Rahmenlinie, der beim ersten Erscheinen einmal von links nach rechts
|
||||||
durchwischt. Sonst bewegt sich in der Oberflaeche nichts von selbst. */
|
durchwischt. Sonst bewegt sich in der Oberflaeche nichts von selbst. */
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function AddPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOffen(true)}
|
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}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
@@ -63,14 +63,14 @@ export function AddPanel({
|
|||||||
else setFehler(ergebnis.error ?? commonText.retry)
|
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}
|
{submitLabel ?? label}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOffen(false)}
|
onClick={() => setOffen(false)}
|
||||||
className="font-condensed text-2xs text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{commonText.cancel}
|
{commonText.cancel}
|
||||||
</button>
|
</button>
|
||||||
@@ -98,7 +98,7 @@ export function PanelSelect({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="border border-line bg-raised px-2 py-1 text-2xs"
|
className="field text-2xs"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</select>
|
</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}
|
value={thema}
|
||||||
onChange={(event) => waehlen(event.target.value as Thema)}
|
onChange={(event) => waehlen(event.target.value as Thema)}
|
||||||
aria-label={themeText.label}
|
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="system">{themeText.system}</option>
|
||||||
<option value="dark">{themeText.dark}</option>
|
<option value="dark">{themeText.dark}</option>
|
||||||
|
|||||||
@@ -75,6 +75,5 @@ function routenMuster(pfad: string): string {
|
|||||||
.replace(/\/faelle\/[^/]+/, '/faelle/[caseNumber]')
|
.replace(/\/faelle\/[^/]+/, '/faelle/[caseNumber]')
|
||||||
.replace(/\/dokumente\/[^/]+/, '/dokumente/[documentNumber]')
|
.replace(/\/dokumente\/[^/]+/, '/dokumente/[documentNumber]')
|
||||||
.replace(/\/personalakten\/[^/]+/, '/personalakten/[id]')
|
.replace(/\/personalakten\/[^/]+/, '/personalakten/[id]')
|
||||||
.replace(/\/kanaele\/[^/]+/, '/kanaele/[key]')
|
|
||||||
.slice(0, 120)
|
.slice(0, 120)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import type { ChatMessageView } from '@/lib/chat/view'
|
|||||||
|
|
||||||
export type ChatScopeView =
|
export type ChatScopeView =
|
||||||
| { kind: 'case'; caseNumber: string; caseId: string }
|
| { kind: 'case'; caseNumber: string; caseId: string }
|
||||||
| { kind: 'channel'; key: string; channelId: string }
|
| { kind: 'personnel'; fileId: string }
|
||||||
|
|
||||||
type Reaktion = { messageId: string; userId: string; key: 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:
|
* Optisch klar vom Verlauf abgesetzt, weil er inhaltlich etwas anderes ist:
|
||||||
* formlos, kein Aktenbestandteil. Die Zeitachse des Verlaufs hat eine
|
* 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
|
// Beim Verlassen der Seite die Lesemarke setzen, nicht beim Öffnen: sonst
|
||||||
// wäre die Trennlinie beim Neuladen sofort weg.
|
// wäre die Trennlinie beim Neuladen sofort weg.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scopeId = scope.kind === 'case' ? scope.caseId : scope.channelId
|
const scopeId = scope.kind === 'case' ? scope.caseId : scope.fileId
|
||||||
return () => {
|
return () => {
|
||||||
void markReadAction({ scopeType: scope.kind, scopeId })
|
void markReadAction({ scopeType: scope.kind, scopeId })
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,10 @@ export function Chat({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const ergebnis = await sendMessageAction({
|
const ergebnis = await sendMessageAction({
|
||||||
id,
|
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,
|
body: inhalt,
|
||||||
replyToId: optimistisch.replyToId,
|
replyToId: optimistisch.replyToId,
|
||||||
})
|
})
|
||||||
@@ -207,7 +210,7 @@ export function Chat({
|
|||||||
value={entwurf}
|
value={entwurf}
|
||||||
onChange={(event) => setEntwurf(event.target.value)}
|
onChange={(event) => setEntwurf(event.target.value)}
|
||||||
rows={2}
|
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">
|
<div className="mt-1 flex gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -223,14 +226,14 @@ export function Chat({
|
|||||||
else setFehler(ergebnis.error)
|
else setFehler(ergebnis.error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="font-condensed text-2xs text-accent"
|
className="btn btn-sm btn-primary"
|
||||||
>
|
>
|
||||||
{chatText.editSave}
|
{chatText.editSave}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setBearbeitet(null)}
|
onClick={() => setBearbeitet(null)}
|
||||||
className="font-condensed text-2xs text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{commonText.cancel}
|
{commonText.cancel}
|
||||||
</button>
|
</button>
|
||||||
@@ -273,9 +276,8 @@ export function Chat({
|
|||||||
await toggleReactionAction({ id: nachricht.id, key })
|
await toggleReactionAction({ id: nachricht.id, key })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={`border px-1.5 py-0.5 font-mono ${
|
aria-pressed={eigene}
|
||||||
eigene ? 'border-accent text-accent' : 'border-line text-text-dim'
|
className="btn btn-sm font-mono"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{chatText.reactionSymbols[key]}
|
{chatText.reactionSymbols[key]}
|
||||||
{anzahl > 0 ? ` ${anzahl}` : ''}
|
{anzahl > 0 ? ` ${anzahl}` : ''}
|
||||||
@@ -287,7 +289,7 @@ export function Chat({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAntwortAuf(nachricht.id)}
|
onClick={() => setAntwortAuf(nachricht.id)}
|
||||||
className="font-condensed text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{chatText.reply}
|
{chatText.reply}
|
||||||
</button>
|
</button>
|
||||||
@@ -305,7 +307,7 @@ export function Chat({
|
|||||||
if (!ergebnis.ok) setFehler(ergebnis.error)
|
if (!ergebnis.ok) setFehler(ergebnis.error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="font-condensed text-accent"
|
className="btn btn-sm btn-primary"
|
||||||
>
|
>
|
||||||
{chatText.adopt}
|
{chatText.adopt}
|
||||||
</button>
|
</button>
|
||||||
@@ -319,7 +321,7 @@ export function Chat({
|
|||||||
setBearbeitet(nachricht.id)
|
setBearbeitet(nachricht.id)
|
||||||
setEntwurf(nachricht.body)
|
setEntwurf(nachricht.body)
|
||||||
}}
|
}}
|
||||||
className="font-condensed text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{chatText.edit}
|
{chatText.edit}
|
||||||
</button>
|
</button>
|
||||||
@@ -331,7 +333,7 @@ export function Chat({
|
|||||||
await withdrawMessageAction({ id: nachricht.id })
|
await withdrawMessageAction({ id: nachricht.id })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="font-condensed text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{chatText.withdraw}
|
{chatText.withdraw}
|
||||||
</button>
|
</button>
|
||||||
@@ -351,7 +353,7 @@ export function Chat({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAntwortAuf(null)}
|
onClick={() => setAntwortAuf(null)}
|
||||||
className="font-condensed"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{chatText.cancelReply}
|
{chatText.cancelReply}
|
||||||
</button>
|
</button>
|
||||||
@@ -371,7 +373,7 @@ export function Chat({
|
|||||||
rows={2}
|
rows={2}
|
||||||
placeholder={chatText.placeholder}
|
placeholder={chatText.placeholder}
|
||||||
aria-label={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">
|
<div className="mt-2 flex items-center gap-3">
|
||||||
@@ -379,7 +381,7 @@ export function Chat({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={senden}
|
onClick={senden}
|
||||||
disabled={text.trim() === ''}
|
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}
|
{chatText.send}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function NewDocument({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
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}
|
{documentText.create}
|
||||||
</button>
|
</button>
|
||||||
@@ -63,7 +63,7 @@ export function NewDocument({
|
|||||||
id="templateKey"
|
id="templateKey"
|
||||||
name="templateKey"
|
name="templateKey"
|
||||||
required
|
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) => (
|
{templates.map((template) => (
|
||||||
<option key={template.key} value={template.key}>
|
<option key={template.key} value={template.key}>
|
||||||
@@ -81,7 +81,7 @@ export function NewDocument({
|
|||||||
id="classification"
|
id="classification"
|
||||||
name="classification"
|
name="classification"
|
||||||
defaultValue={defaultLevel}
|
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) => (
|
{levels.map((level) => (
|
||||||
<option key={level.level} value={level.level}>
|
<option key={level.level} value={level.level}>
|
||||||
@@ -99,7 +99,7 @@ export function NewDocument({
|
|||||||
id="doc-title"
|
id="doc-title"
|
||||||
name="title"
|
name="title"
|
||||||
maxLength={200}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,14 +114,14 @@ export function NewDocument({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
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}
|
{pending ? commonText.loading : documentText.create}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="font-condensed text-2xs text-text-dim"
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{commonText.cancel}
|
{commonText.cancel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export function SheetSwitch({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setModus('blatt')}
|
onClick={() => setModus('blatt')}
|
||||||
aria-current={modus === 'blatt'}
|
aria-pressed={modus === 'blatt'}
|
||||||
className={modus === 'blatt' ? 'text-accent' : 'text-text-dim'}
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{documentText.sheet}
|
{documentText.sheet}
|
||||||
</button>
|
</button>
|
||||||
@@ -36,8 +36,8 @@ export function SheetSwitch({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setModus('bearbeiten')}
|
onClick={() => setModus('bearbeiten')}
|
||||||
aria-current={modus === 'bearbeiten'}
|
aria-pressed={modus === 'bearbeiten'}
|
||||||
className={modus === 'bearbeiten' ? 'text-accent' : 'text-text-dim'}
|
className="btn btn-sm"
|
||||||
>
|
>
|
||||||
{documentText.edit}
|
{documentText.edit}
|
||||||
</button>
|
</button>
|
||||||
@@ -50,7 +50,7 @@ export function SheetSwitch({
|
|||||||
// Wasserzeichen und Protokolleintrag kommt in Phase 8.
|
// Wasserzeichen und Protokolleintrag kommt in Phase 8.
|
||||||
requestAnimationFrame(() => window.print())
|
requestAnimationFrame(() => window.print())
|
||||||
}}
|
}}
|
||||||
className="ml-auto text-text-dim"
|
className="ml-auto btn btn-sm"
|
||||||
>
|
>
|
||||||
{documentText.print}
|
{documentText.print}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function ExportPanel({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending || Boolean(state?.jobId)}
|
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}
|
{pending ? exportText.preparing : exportText.asPdf}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function Attachments({
|
|||||||
await releaseAttachmentAction({ id: datei.id })
|
await releaseAttachmentAction({ id: datei.id })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="font-condensed text-2xs text-stamp-3"
|
className="btn btn-sm text-stamp-3"
|
||||||
>
|
>
|
||||||
{attachmentText.release}
|
{attachmentText.release}
|
||||||
</button>
|
</button>
|
||||||
@@ -241,7 +241,7 @@ export function Attachments({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => eingabe.current?.click()}
|
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}
|
{attachmentText.add}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function PhotoField({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={fortschritt !== null}
|
disabled={fortschritt !== null}
|
||||||
onClick={() => eingabe.current?.click()}
|
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
|
{fortschritt === null
|
||||||
? attachmentText.choose
|
? attachmentText.choose
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function SectionEditor(props: Props) {
|
|||||||
<select
|
<select
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => change(event.target.value)}
|
onChange={(event) => change(event.target.value)}
|
||||||
className="border border-line bg-raised px-3 py-2 text-sm"
|
className="field"
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{props.options?.map((option) => (
|
{props.options?.map((option) => (
|
||||||
@@ -127,14 +127,14 @@ export function SectionEditor(props: Props) {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => change(event.target.value)}
|
onChange={(event) => change(event.target.value)}
|
||||||
rows={6}
|
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
|
<input
|
||||||
type={props.type === 'date' ? 'date' : props.type === 'number' ? 'number' : 'text'}
|
type={props.type === 'date' ? 'date' : props.type === 'number' ? 'number' : 'text'}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => change(event.target.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"
|
type="button"
|
||||||
onClick={() => submit()}
|
onClick={() => submit()}
|
||||||
disabled={pending}
|
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}
|
{pending ? sectionText.saving : sectionText.save}
|
||||||
</button>
|
</button>
|
||||||
@@ -169,7 +169,7 @@ export function SectionEditor(props: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit(parse(value, props.type), conflict.version)}
|
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}
|
{sectionText.takeMine}
|
||||||
</button>
|
</button>
|
||||||
@@ -186,7 +186,7 @@ export function SectionEditor(props: Props) {
|
|||||||
setVersion(conflict.version)
|
setVersion(conflict.version)
|
||||||
setConflict(null)
|
setConflict(null)
|
||||||
}}
|
}}
|
||||||
className="mt-2 font-condensed text-2xs text-accent"
|
className="mt-2 btn btn-sm btn-primary"
|
||||||
>
|
>
|
||||||
{sectionText.takeTheirs}
|
{sectionText.takeTheirs}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ test.describe('Geschuetzte Bereiche', () => {
|
|||||||
'/faelle/neu',
|
'/faelle/neu',
|
||||||
'/personalakten',
|
'/personalakten',
|
||||||
'/personalakten/neu',
|
'/personalakten/neu',
|
||||||
'/kanaele',
|
'/personalakten/irgendeine',
|
||||||
|
'/admin',
|
||||||
'/suche',
|
'/suche',
|
||||||
'/faelle/AfiS-2026-0001/export',
|
'/faelle/AfiS-2026-0001/export',
|
||||||
'/faelle/AfiS-2026-0001/beteiligte',
|
'/faelle/AfiS-2026-0001/beteiligte',
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
import type { Prisma } from '@afis/db'
|
import type { Prisma } from '@afis/db'
|
||||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
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
|
* Es gibt keine freien Räume mehr. Ein Chat gehört zu einer Fallakte oder zu
|
||||||
* darf, sieht auch nicht, dass darüber geredet wird. Ein Kanal hat eine eigene
|
* einer Personalakte und erbt deren Sichtbarkeit — wer die Akte nicht lesen
|
||||||
* Mindeststufe.
|
* 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
|
||||||
* Ein Kanal ist kein Geheimnis für sich: dass es ihn gibt, darf jeder sehen,
|
* Gespräch beim Vorgang steht. Siehe docs/13-entscheidungen.md.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function visibleChannelWhere(p: EffectivePermissions): Prisma.ChannelWhereInput {
|
|
||||||
if (p.isAdmin) return {}
|
|
||||||
return { minClearance: { lte: p.clearanceLevel } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function visibleMessageWhere(p: EffectivePermissions): Prisma.ChatMessageWhereInput {
|
export function visibleMessageWhere(p: EffectivePermissions): Prisma.ChatMessageWhereInput {
|
||||||
return {
|
const orte: Prisma.ChatMessageWhereInput[] = [{ case: { is: visibleCaseWhere(p) } }]
|
||||||
OR: [
|
|
||||||
{ case: { is: visibleCaseWhere(p) } },
|
// Personalakten setzen zusätzlich zur Stufe ein eigenes Recht voraus. Ohne
|
||||||
{ channel: { is: visibleChannelWhere(p) } },
|
// das Recht gibt es die Akte für diesen Nutzer nicht, also auch ihren Chat
|
||||||
],
|
// nicht.
|
||||||
|
if (p.isAdmin || p.permissions.has('personnel.read')) {
|
||||||
|
orte.push({ personnelFile: { is: visiblePersonnelWhere(p) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { OR: orte }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export type AuditAction =
|
|||||||
| 'attachment.read'
|
| 'attachment.read'
|
||||||
| 'attachment.release'
|
| 'attachment.release'
|
||||||
| 'chat.adopt'
|
| 'chat.adopt'
|
||||||
| 'channel.create'
|
|
||||||
| 'roles.update'
|
| 'roles.update'
|
||||||
| 'export.pdf'
|
| 'export.pdf'
|
||||||
| 'export.approve'
|
| 'export.approve'
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { EffectivePermissions } from '@/lib/auth/permissions'
|
|||||||
*/
|
*/
|
||||||
export async function loadChatView(p: EffectivePermissions, scope: ChatScope) {
|
export async function loadChatView(p: EffectivePermissions, scope: ChatScope) {
|
||||||
const { ort, nachrichten } = await listMessages(p, scope)
|
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([
|
const [autoren, vorschauen, reaktionen, lastReadAt, mitglieder] = await Promise.all([
|
||||||
db.user.findMany({
|
db.user.findMany({
|
||||||
@@ -22,7 +22,7 @@ export async function loadChatView(p: EffectivePermissions, scope: ChatScope) {
|
|||||||
}),
|
}),
|
||||||
resolveCasePreviews(p, nachrichten.flatMap((n) => extractCaseNumbers(n.body))),
|
resolveCasePreviews(p, nachrichten.flatMap((n) => extractCaseNumbers(n.body))),
|
||||||
listReactions(p, nachrichten.map((n) => n.id)),
|
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.
|
// Für die Anzeige der Erwähnungen: Benutzername auf Anzeigename.
|
||||||
db.user.findMany({
|
db.user.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
|
|||||||
+37
-100
@@ -2,28 +2,32 @@ import { db } from '@/lib/db'
|
|||||||
import { writeAudit } from '@/lib/audit'
|
import { writeAudit } from '@/lib/audit'
|
||||||
import { AccessError, must } from '@/lib/errors'
|
import { AccessError, must } from '@/lib/errors'
|
||||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
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 { canEditCase } from '@/lib/access/caseWrite'
|
||||||
import { extractMentionNames } from '@/lib/chat/mentions'
|
import { extractMentionNames } from '@/lib/chat/mentions'
|
||||||
import type { ReactionKey } from '@/lib/chat/terms'
|
import type { ReactionKey } from '@/lib/chat/terms'
|
||||||
import { caseTopic, channelTopic, publish, type Topic } from '@/lib/realtime/publish'
|
import { caseTopic, personnelTopic, publish, type Topic } from '@/lib/realtime/publish'
|
||||||
import { enqueueCaseNotification } from '@/lib/notify/enqueue'
|
import { enqueueCaseNotification, enqueuePersonnelNotification } from '@/lib/notify/enqueue'
|
||||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||||
import type { Prisma } from '@afis/db'
|
import type { Prisma } from '@afis/db'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aktenchat und freie Kanäle.
|
* Der Aktenchat.
|
||||||
*
|
*
|
||||||
* Der Chat ist **ausdrücklich kein Aktenbestandteil** — er ist der formlose
|
* Er hängt immer an einer Akte — einer Fallakte oder einer Personalakte — und
|
||||||
* Teil, den die Abteilung bisher auf Discord hatte. Deshalb darf hier
|
* ist **ausdrücklich kein Aktenbestandteil**: er ist der formlose Teil, den
|
||||||
* bearbeitet und zurückgezogen werden, ohne dass eine Version entsteht.
|
* 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
|
* Die Brücke zum Aktenbestand ist `adoptAsEntry`: eine Nachricht wird per
|
||||||
* Klick zu einem Vermerk. Ab da gelten die harten Regeln der Akte.
|
* Klick zu einem Vermerk. Ab da gelten die harten Regeln der Akte.
|
||||||
* Siehe docs/06-chat-und-realtime.md.
|
* 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 = {
|
const NACHRICHT = {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -36,7 +40,7 @@ const NACHRICHT = {
|
|||||||
mentions: true,
|
mentions: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
caseId: true,
|
caseId: true,
|
||||||
channelId: true,
|
personnelFileId: true,
|
||||||
} satisfies Prisma.ChatMessageSelect
|
} satisfies Prisma.ChatMessageSelect
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +51,7 @@ const NACHRICHT = {
|
|||||||
export async function resolveScope(
|
export async function resolveScope(
|
||||||
p: EffectivePermissions,
|
p: EffectivePermissions,
|
||||||
scope: ChatScope,
|
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') {
|
if (scope.kind === 'case') {
|
||||||
const akte = await db.case.findFirst({
|
const akte = await db.case.findFirst({
|
||||||
where: { AND: [visibleCaseWhere(p), { caseNumber: scope.caseNumber }] },
|
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
|
return akte ? { caseId: akte.id, topic: caseTopic(akte.id), label: akte.caseNumber } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const kanal = await db.channel.findFirst({
|
// Personalakten setzen neben der Stufe ein eigenes Recht voraus.
|
||||||
where: { AND: [visibleChannelWhere(p), { key: scope.key, isArchived: false }] },
|
if (!p.isAdmin && !p.permissions.has('personnel.read')) return null
|
||||||
select: { id: true, key: true },
|
|
||||||
|
const akte = await db.personnelFile.findFirst({
|
||||||
|
where: { AND: [visiblePersonnelWhere(p), { id: scope.fileId }] },
|
||||||
|
select: { id: true, displayName: true },
|
||||||
})
|
})
|
||||||
return kanal
|
return akte
|
||||||
? { channelId: kanal.id, topic: channelTopic(kanal.id), label: kanal.key }
|
? { personnelFileId: akte.id, topic: personnelTopic(akte.id), label: akte.displayName }
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +89,7 @@ export async function listMessages(
|
|||||||
|
|
||||||
const wo: Prisma.ChatMessageWhereInput = ort.caseId
|
const wo: Prisma.ChatMessageWhereInput = ort.caseId
|
||||||
? { caseId: ort.caseId }
|
? { caseId: ort.caseId }
|
||||||
: { channelId: ort.channelId }
|
: { personnelFileId: ort.personnelFileId }
|
||||||
|
|
||||||
const nachrichten = await db.chatMessage.findMany({
|
const nachrichten = await db.chatMessage.findMany({
|
||||||
where: { AND: [wo, ...(options.since ? [{ id: { gt: options.since } }] : [])] },
|
where: { AND: [wo, ...(options.since ? [{ id: { gt: options.since } }] : [])] },
|
||||||
@@ -149,7 +156,7 @@ export async function sendMessage(
|
|||||||
const ziel = await db.chatMessage.findFirst({
|
const ziel = await db.chatMessage.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: input.replyToId,
|
id: input.replyToId,
|
||||||
...(ort.caseId ? { caseId: ort.caseId } : { channelId: ort.channelId }),
|
...(ort.caseId ? { caseId: ort.caseId } : { personnelFileId: ort.personnelFileId }),
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
@@ -161,7 +168,7 @@ export async function sendMessage(
|
|||||||
data: {
|
data: {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
caseId: ort.caseId ?? null,
|
caseId: ort.caseId ?? null,
|
||||||
channelId: ort.channelId ?? null,
|
personnelFileId: ort.personnelFileId ?? null,
|
||||||
authorId: p.userId,
|
authorId: p.userId,
|
||||||
body: text,
|
body: text,
|
||||||
replyToId,
|
replyToId,
|
||||||
@@ -188,18 +195,15 @@ export async function sendMessage(
|
|||||||
onlyFor: erwaehnt.length > 0 ? erwaehnt.map((u) => u.id) : undefined,
|
onlyFor: erwaehnt.length > 0 ? erwaehnt.map((u) => u.id) : undefined,
|
||||||
meta: { messageId: nachricht.id },
|
meta: { messageId: nachricht.id },
|
||||||
})
|
})
|
||||||
} else if (erwaehnt.length > 0) {
|
} else if (ort.personnelFileId) {
|
||||||
// In einem Kanal gibt es keine Akte, deren Sichtbarkeit zu prüfen wäre.
|
// Im Chat einer Personalakte geht nur an ausdrücklich Erwähnte etwas
|
||||||
// Die Mindeststufe des Kanals hat der Absender schon passiert, und
|
// hinaus, und auch das nur an die, die die Akte lesen dürfen.
|
||||||
// erwähnt wird nur, wer sie ebenfalls hat.
|
await enqueuePersonnelNotification(tx, {
|
||||||
await tx.notificationOutbox.createMany({
|
personnelFileId: ort.personnelFileId,
|
||||||
data: erwaehnt.map((user) => ({
|
|
||||||
kind: 'chat.mention',
|
|
||||||
channelId: ort.channelId,
|
|
||||||
actorId: p.userId,
|
actorId: p.userId,
|
||||||
recipientId: user.id,
|
actorName: absenderName,
|
||||||
meta: { messageId: nachricht.id, channelKey: ort.label, actorName: absenderName },
|
mentioned: erwaehnt.map((user) => user.id),
|
||||||
})),
|
meta: { messageId: nachricht.id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,9 +382,9 @@ export async function adoptAsEntry(
|
|||||||
return { entryId }
|
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)
|
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(
|
export async function loadReadMarker(
|
||||||
p: EffectivePermissions,
|
p: EffectivePermissions,
|
||||||
scopeType: 'case' | 'channel',
|
scopeType: 'case' | 'personnel',
|
||||||
scopeId: string,
|
scopeId: string,
|
||||||
): Promise<Date | null> {
|
): Promise<Date | null> {
|
||||||
const marker = await db.readMarker.findUnique({
|
const marker = await db.readMarker.findUnique({
|
||||||
@@ -408,7 +412,7 @@ export async function loadReadMarker(
|
|||||||
|
|
||||||
export async function markRead(
|
export async function markRead(
|
||||||
p: EffectivePermissions,
|
p: EffectivePermissions,
|
||||||
scopeType: 'case' | 'channel',
|
scopeType: 'case' | 'personnel',
|
||||||
scopeId: string,
|
scopeId: string,
|
||||||
at: Date = new Date(),
|
at: Date = new Date(),
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -418,70 +422,3 @@ export async function markRead(
|
|||||||
update: { lastReadAt: at },
|
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 { db } from '@/lib/db'
|
||||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||||
import { visibleMessageWhere } from '@/lib/access/chatFilter'
|
import { visibleMessageWhere } from '@/lib/access/chatFilter'
|
||||||
|
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
||||||
import { listWatched } from '@/lib/cases/watch'
|
import { listWatched } from '@/lib/cases/watch'
|
||||||
import { listRecent } from '@/lib/recent/service'
|
import { listRecent } from '@/lib/recent/service'
|
||||||
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
||||||
@@ -73,7 +74,7 @@ export async function loadDashboard(p: EffectivePermissions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type UnreadChat = {
|
export type UnreadChat = {
|
||||||
scopeType: 'case' | 'channel'
|
scopeType: 'case' | 'personnel'
|
||||||
scopeId: string
|
scopeId: string
|
||||||
label: string
|
label: string
|
||||||
href: string
|
href: string
|
||||||
@@ -83,10 +84,10 @@ export type UnreadChat = {
|
|||||||
/**
|
/**
|
||||||
* Wo seit dem letzten Besuch geschrieben wurde.
|
* Wo seit dem letzten Besuch geschrieben wurde.
|
||||||
*
|
*
|
||||||
* Gezählt wird über `ReadMarker`. Nur an Orten, an denen der Nutzer beteiligt
|
* Gezählt wird über `ReadMarker`. Nur an Orten, die den Nutzer angehen: Akten,
|
||||||
* ist, die er beobachtet, oder in Kanälen, die er sehen darf — eine Zählung
|
* an denen er beteiligt ist oder die er beobachtet, und Personalakten, denen
|
||||||
* über alle sichtbaren Akten wäre auf der Übersicht nur Lärm. Eigene
|
* er zugeordnet ist. Eine Zählung über alles Sichtbare wäre auf der Übersicht
|
||||||
* Nachrichten zählen nicht mit.
|
* nur Lärm. Eigene Nachrichten zählen nicht mit.
|
||||||
*/
|
*/
|
||||||
async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
|
async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
|
||||||
const marken = await db.readMarker.findMany({
|
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 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({
|
db.case.findMany({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
@@ -111,14 +114,18 @@ async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
|
|||||||
select: { id: true, caseNumber: true },
|
select: { id: true, caseNumber: true },
|
||||||
take: 50,
|
take: 50,
|
||||||
}),
|
}),
|
||||||
db.channel.findMany({
|
darfPersonal
|
||||||
|
? db.personnelFile.findMany({
|
||||||
where: {
|
where: {
|
||||||
isArchived: false,
|
AND: [
|
||||||
...(p.isAdmin ? {} : { minClearance: { lte: p.clearanceLevel } }),
|
visiblePersonnelWhere(p),
|
||||||
|
{ assignments: { some: { userId: p.userId } } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
select: { id: true, key: true },
|
select: { id: true, displayName: true },
|
||||||
take: 50,
|
take: 50,
|
||||||
}),
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
])
|
])
|
||||||
|
|
||||||
const orte = [
|
const orte = [
|
||||||
@@ -129,12 +136,12 @@ async function ungeleseneChats(p: EffectivePermissions): Promise<UnreadChat[]> {
|
|||||||
href: `/faelle/${akte.caseNumber}`,
|
href: `/faelle/${akte.caseNumber}`,
|
||||||
wo: { caseId: akte.id } as Prisma.ChatMessageWhereInput,
|
wo: { caseId: akte.id } as Prisma.ChatMessageWhereInput,
|
||||||
})),
|
})),
|
||||||
...kanaele.map((kanal) => ({
|
...personalakten.map((akte) => ({
|
||||||
scopeType: 'channel' as const,
|
scopeType: 'personnel' as const,
|
||||||
scopeId: kanal.id,
|
scopeId: akte.id,
|
||||||
label: `#${kanal.key}`,
|
label: akte.displayName,
|
||||||
href: `/kanaele/${kanal.key}`,
|
href: `/personalakten/${akte.id}`,
|
||||||
wo: { channelId: kanal.id } as Prisma.ChatMessageWhereInput,
|
wo: { personnelFileId: akte.id } as Prisma.ChatMessageWhereInput,
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
if (orte.length === 0) return []
|
if (orte.length === 0) return []
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export async function exportDatabase(p: EffectivePermissions): Promise<Uint8Arra
|
|||||||
documentSections,
|
documentSections,
|
||||||
documentSectionVersions,
|
documentSectionVersions,
|
||||||
attachments,
|
attachments,
|
||||||
channels,
|
|
||||||
messages,
|
messages,
|
||||||
reactions,
|
reactions,
|
||||||
auditLog,
|
auditLog,
|
||||||
@@ -64,7 +63,6 @@ export async function exportDatabase(p: EffectivePermissions): Promise<Uint8Arra
|
|||||||
db.documentSection.findMany(),
|
db.documentSection.findMany(),
|
||||||
db.documentSectionVersion.findMany(),
|
db.documentSectionVersion.findMany(),
|
||||||
db.attachment.findMany(),
|
db.attachment.findMany(),
|
||||||
db.channel.findMany(),
|
|
||||||
db.chatMessage.findMany(),
|
db.chatMessage.findMany(),
|
||||||
db.chatReaction.findMany(),
|
db.chatReaction.findMany(),
|
||||||
// Das Protokoll gehört dazu: ein Abzug ohne die Zugriffsspur wäre als
|
// 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,
|
documentSections,
|
||||||
documentSectionVersions,
|
documentSectionVersions,
|
||||||
attachments,
|
attachments,
|
||||||
channels,
|
|
||||||
messages,
|
messages,
|
||||||
reactions,
|
reactions,
|
||||||
auditLog,
|
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.
|
* Der Adminbereich: Rollen, Vorlagen, Einstellungen.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -44,21 +44,3 @@ export const chatText = {
|
|||||||
wichtig: '▲',
|
wichtig: '▲',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
} as const
|
} 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-too-long': 'Die Nachricht ist länger als 4000 Zeichen.',
|
||||||
'message-withdrawn': 'Die Nachricht wurde zurückgezogen.',
|
'message-withdrawn': 'Die Nachricht wurde zurückgezogen.',
|
||||||
'adopt-needs-case': 'Nur eine Nachricht aus einem Aktenchat wird zum Vermerk.',
|
'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.',
|
'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.',
|
default: 'Nicht gespeichert. Erneut versuchen.',
|
||||||
} as Record<string, string>
|
} as Record<string, string>
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -24,6 +24,8 @@ import {
|
|||||||
searchText,
|
searchText,
|
||||||
paletteText,
|
paletteText,
|
||||||
themeText,
|
themeText,
|
||||||
|
profileText,
|
||||||
|
searchBarText,
|
||||||
dashboardText,
|
dashboardText,
|
||||||
watchText,
|
watchText,
|
||||||
} from '@/lib/i18n/shell'
|
} from '@/lib/i18n/shell'
|
||||||
@@ -35,17 +37,24 @@ import {
|
|||||||
participantText,
|
participantText,
|
||||||
linkText,
|
linkText,
|
||||||
} from '@/lib/i18n/cases'
|
} 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 { personnelText, assignmentText } from '@/lib/i18n/people'
|
||||||
import { documentText, attachmentText } from '@/lib/i18n/documents'
|
import { documentText, attachmentText } from '@/lib/i18n/documents'
|
||||||
import { exportText } from '@/lib/i18n/export'
|
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 = {
|
export const de = {
|
||||||
app: appText,
|
app: appText,
|
||||||
nav: navText,
|
nav: navText,
|
||||||
auth: authText,
|
auth: authText,
|
||||||
overview: overviewText,
|
overview: overviewText,
|
||||||
|
admin: adminText,
|
||||||
roleAdmin: roleAdminText,
|
roleAdmin: roleAdminText,
|
||||||
clearance: clearanceText,
|
clearance: clearanceText,
|
||||||
case: caseText,
|
case: caseText,
|
||||||
@@ -56,7 +65,6 @@ export const de = {
|
|||||||
participant: participantText,
|
participant: participantText,
|
||||||
link: linkText,
|
link: linkText,
|
||||||
chat: chatText,
|
chat: chatText,
|
||||||
channel: channelText,
|
|
||||||
document: documentText,
|
document: documentText,
|
||||||
attachment: attachmentText,
|
attachment: attachmentText,
|
||||||
export: exportText,
|
export: exportText,
|
||||||
@@ -71,6 +79,8 @@ export const de = {
|
|||||||
search: searchText,
|
search: searchText,
|
||||||
palette: paletteText,
|
palette: paletteText,
|
||||||
theme: themeText,
|
theme: themeText,
|
||||||
|
profile: profileText,
|
||||||
|
searchBar: searchBarText,
|
||||||
dashboard: dashboardText,
|
dashboard: dashboardText,
|
||||||
watch: watchText,
|
watch: watchText,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
+11
-2
@@ -21,7 +21,7 @@ export const navText = {
|
|||||||
cases: 'Fallakten',
|
cases: 'Fallakten',
|
||||||
personnel: 'Personalakten',
|
personnel: 'Personalakten',
|
||||||
search: 'Suche',
|
search: 'Suche',
|
||||||
admin: 'Verwaltung',
|
admin: 'Admin',
|
||||||
skipToContent: 'Zum Inhalt springen',
|
skipToContent: 'Zum Inhalt springen',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -178,12 +178,21 @@ export const paletteText = {
|
|||||||
{ href: '/faelle', label: 'Fallakten' },
|
{ href: '/faelle', label: 'Fallakten' },
|
||||||
{ href: '/faelle/neu', label: 'Neue Fallakte' },
|
{ href: '/faelle/neu', label: 'Neue Fallakte' },
|
||||||
{ href: '/personalakten', label: 'Personalakten' },
|
{ href: '/personalakten', label: 'Personalakten' },
|
||||||
{ href: '/kanaele', label: 'Kanäle' },
|
|
||||||
{ href: '/suche', label: 'Suche' },
|
{ href: '/suche', label: 'Suche' },
|
||||||
{ href: '/einstellungen', label: 'Benachrichtigungen' },
|
{ href: '/einstellungen', label: 'Benachrichtigungen' },
|
||||||
],
|
],
|
||||||
} as const
|
} 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 = {
|
export const themeText = {
|
||||||
label: 'Thema',
|
label: 'Thema',
|
||||||
system: 'Wie das System',
|
system: 'Wie das System',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
|
import { READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
|
||||||
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
import { visibleCaseWhere } from '@/lib/access/caseFilter'
|
||||||
|
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
|
||||||
import { resolvePermissions } from '@/lib/auth/effective'
|
import { resolvePermissions } from '@/lib/auth/effective'
|
||||||
import type { Prisma } from '@afis/db'
|
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'
|
| 'export.ready'
|
||||||
|
|
||||||
/** Wer das Ereignis angeht. */
|
/** 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 {
|
export function caseTopic(caseId: string): Topic {
|
||||||
return `case:${caseId}`
|
return `case:${caseId}`
|
||||||
}
|
}
|
||||||
export function channelTopic(channelId: string): Topic {
|
export function personnelTopic(fileId: string): Topic {
|
||||||
return `channel:${channelId}`
|
return `personnel:${fileId}`
|
||||||
}
|
}
|
||||||
export function userTopic(userId: string): Topic {
|
export function userTopic(userId: string): Topic {
|
||||||
return `user:${userId}`
|
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
|
// Tailwind erzeugt zur Laufzeit keine Styles, aber Next setzt inline-Styles
|
||||||
// fuer den Bildplatzhalter. Style-Nonces unterstuetzt Next nicht.
|
// fuer den Bildplatzhalter. Style-Nonces unterstuetzt Next nicht.
|
||||||
`style-src 'self' 'unsafe-inline'`,
|
`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'`,
|
`font-src 'self'`,
|
||||||
// Der SSE-Endpunkt laeuft ueber dieselbe Herkunft.
|
// Der SSE-Endpunkt laeuft ueber dieselbe Herkunft.
|
||||||
`connect-src 'self'`,
|
`connect-src 'self'`,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ suite('Benachrichtigungen', () => {
|
|||||||
|
|
||||||
const akte = await db.case.create({
|
const akte = await db.case.create({
|
||||||
data: {
|
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',
|
title: 'Streng vertraulicher Vorgang',
|
||||||
templateId: vorlage.id,
|
templateId: vorlage.id,
|
||||||
templateKey: vorlage.key,
|
templateKey: vorlage.key,
|
||||||
@@ -142,7 +142,7 @@ suite('Benachrichtigungen', () => {
|
|||||||
it('nimmt bei niedriger Stufe den Titel mit', async () => {
|
it('nimmt bei niedriger Stufe den Titel mit', async () => {
|
||||||
const offen = await db.case.create({
|
const offen = await db.case.create({
|
||||||
data: {
|
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',
|
title: 'Harmloser Vorgang',
|
||||||
templateId: (await db.documentTemplate.findFirstOrThrow({
|
templateId: (await db.documentTemplate.findFirstOrThrow({
|
||||||
where: { key: `${marke}-vorlage` },
|
where: { key: `${marke}-vorlage` },
|
||||||
|
|||||||
+52
-36
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
import { db } from '@/lib/db'
|
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 { adoptAsEntry, listMessages, sendMessage } from '@/lib/chat/service'
|
||||||
import { resolveCasePreviews } from '@/lib/chat/preview'
|
import { resolveCasePreviews } from '@/lib/chat/preview'
|
||||||
import { AccessError } from '@/lib/errors'
|
import { AccessError } from '@/lib/errors'
|
||||||
@@ -37,8 +37,8 @@ suite('Aktenchat und Kanäle', () => {
|
|||||||
let geheimNummer = ''
|
let geheimNummer = ''
|
||||||
let offenId = ''
|
let offenId = ''
|
||||||
let geheimId = ''
|
let geheimId = ''
|
||||||
let kanalOffen = ''
|
let personOffen = ''
|
||||||
let kanalGeheim = ''
|
let personGeheim = ''
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const vorlage = await db.documentTemplate.create({
|
const vorlage = await db.documentTemplate.create({
|
||||||
@@ -56,7 +56,7 @@ suite('Aktenchat und Kanäle', () => {
|
|||||||
const offen = await db.case.create({
|
const offen = await db.case.create({
|
||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...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',
|
title: 'Offener Vorgang',
|
||||||
classification: 1,
|
classification: 1,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
@@ -65,7 +65,7 @@ suite('Aktenchat und Kanäle', () => {
|
|||||||
const geheim = await db.case.create({
|
const geheim = await db.case.create({
|
||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...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',
|
title: 'Streng vertraulich',
|
||||||
classification: 4,
|
classification: 4,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
@@ -76,24 +76,14 @@ suite('Aktenchat und Kanäle', () => {
|
|||||||
offenId = offen.id
|
offenId = offen.id
|
||||||
geheimId = geheim.id
|
geheimId = geheim.id
|
||||||
|
|
||||||
const k1 = await db.channel.create({
|
const p1 = await db.personnelFile.create({
|
||||||
data: {
|
data: { displayName: `${marke}-offen`, classification: 1, createdById: nutzer.stufe4 },
|
||||||
key: `${marke}-allgemein`.slice(0, 40),
|
|
||||||
name: 'Allgemein',
|
|
||||||
minClearance: 0,
|
|
||||||
createdById: nutzer.stufe4,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
const k2 = await db.channel.create({
|
const p2 = await db.personnelFile.create({
|
||||||
data: {
|
data: { displayName: `${marke}-geheim`, classification: 4, createdById: nutzer.stufe4 },
|
||||||
key: `${marke}-leitung`.slice(0, 40),
|
|
||||||
name: 'Leitung',
|
|
||||||
minClearance: 4,
|
|
||||||
createdById: nutzer.stufe4,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
kanalOffen = k1.key
|
personOffen = p1.id
|
||||||
kanalGeheim = k2.key
|
personGeheim = p2.id
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -119,17 +109,37 @@ suite('Aktenchat und Kanäle', () => {
|
|||||||
expect(sichtbar).toHaveLength(0)
|
expect(sichtbar).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('verbirgt einen Kanal über der eigenen Stufe', async () => {
|
it('verbirgt den Chat einer zu hoch eingestuften Personalakte', async () => {
|
||||||
const kanaele = await db.channel.findMany({
|
const leiter = perms(nutzer.stufe4, 4, { permissions: ['personnel.read'] })
|
||||||
where: {
|
await sendMessage(leiter, {
|
||||||
AND: [
|
id: `${marke}-person-geheim`,
|
||||||
visibleChannelWhere(perms(nutzer.stufe1, 1)),
|
scope: { kind: 'personnel', fileId: personGeheim },
|
||||||
{ key: { in: [kanalOffen, kanalGeheim] } },
|
body: 'Bewertung liegt vor',
|
||||||
],
|
|
||||||
},
|
|
||||||
select: { key: true },
|
|
||||||
})
|
})
|
||||||
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 () => {
|
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)
|
expect((await adoptAsEntry(p, id)).entryId).toBe(entryId)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('macht aus einer Kanalnachricht keinen Vermerk', async () => {
|
it('macht aus einer Nachricht der Personalakte keinen Vermerk', async () => {
|
||||||
const id = `${marke}-kanal`
|
const id = `${marke}-person`
|
||||||
const p = perms(nutzer.stufe4, 4, { permissions: ['entry.create', 'case.edit'] })
|
const p = perms(nutzer.stufe4, 4, {
|
||||||
await sendMessage(p, { id, scope: { kind: 'channel', key: kanalOffen }, body: 'Absprache' })
|
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)
|
await expect(adoptAsEntry(p, id)).rejects.toThrow(AccessError)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,10 +17,33 @@ describe('Design-Tokens', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('setzt den Eckenradius durchgehend auf 2 px', () => {
|
it('haelt Flaechen eckig und Bedienelemente rund', () => {
|
||||||
const radii = [...css.matchAll(/--radius-[\w-]+:\s*([^;]+);/g)].map((m) => m[1]!.trim())
|
// Die Flaechenradien bleiben bei 2 px: eine Akte ist ein Blatt.
|
||||||
expect(radii.length).toBeGreaterThan(0)
|
const flaechen = [...css.matchAll(/--radius-(?!field|control)[\w-]+:\s*([^;]+);/g)].map((m) =>
|
||||||
for (const value of radii) expect(['0px', '2px']).toContain(value)
|
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', () => {
|
it('kennt keine Schatten ausser dem Fokusring', () => {
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ suite('Freigabeverfahren', () => {
|
|||||||
const offen = await db.case.create({
|
const offen = await db.case.create({
|
||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...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',
|
title: 'Harmlos',
|
||||||
classification: 1,
|
classification: 1,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
@@ -156,7 +156,7 @@ suite('Freigabeverfahren', () => {
|
|||||||
const geheim = await db.case.create({
|
const geheim = await db.case.create({
|
||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...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',
|
title: 'Streng',
|
||||||
classification: APPROVAL_FROM_LEVEL,
|
classification: APPROVAL_FROM_LEVEL,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ suite('Volltextsuche', () => {
|
|||||||
const offen = await db.case.create({
|
const offen = await db.case.create({
|
||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...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}`,
|
title: `Vorgang ${wort.offen}`,
|
||||||
classification: 1,
|
classification: 1,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
@@ -75,7 +75,7 @@ suite('Volltextsuche', () => {
|
|||||||
const geheim = await db.case.create({
|
const geheim = await db.case.create({
|
||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...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',
|
title: 'Unauffälliger Titel',
|
||||||
classification: 4,
|
classification: 4,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ suite('Übersicht und Beobachtungsliste', () => {
|
|||||||
const offen = await db.case.create({
|
const offen = await db.case.create({
|
||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...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',
|
title: 'Offener Vorgang',
|
||||||
classification: 1,
|
classification: 1,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
@@ -63,7 +63,7 @@ suite('Übersicht und Beobachtungsliste', () => {
|
|||||||
const geheim = await db.case.create({
|
const geheim = await db.case.create({
|
||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...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',
|
title: 'Streng vertraulich',
|
||||||
classification: 4,
|
classification: 4,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ suite('Sichtbarkeit von Akten', () => {
|
|||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...gemeinsam,
|
||||||
id: ids.offen,
|
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',
|
title: 'Offener Vorgang',
|
||||||
classification: 1,
|
classification: 1,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
@@ -74,7 +74,7 @@ suite('Sichtbarkeit von Akten', () => {
|
|||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...gemeinsam,
|
||||||
id: ids.geheim,
|
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',
|
title: 'Geheimer Vorgang',
|
||||||
classification: 4,
|
classification: 4,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
@@ -85,7 +85,7 @@ suite('Sichtbarkeit von Akten', () => {
|
|||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...gemeinsam,
|
||||||
id: ids.versiegelt,
|
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',
|
title: 'Versiegelter Vorgang',
|
||||||
classification: 1,
|
classification: 1,
|
||||||
status: 'SEALED',
|
status: 'SEALED',
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ suite('Zuletzt geöffnet', () => {
|
|||||||
const offen = await db.case.create({
|
const offen = await db.case.create({
|
||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...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',
|
title: 'Offener Vorgang',
|
||||||
classification: 1,
|
classification: 1,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
@@ -61,7 +61,7 @@ suite('Zuletzt geöffnet', () => {
|
|||||||
const geheim = await db.case.create({
|
const geheim = await db.case.create({
|
||||||
data: {
|
data: {
|
||||||
...gemeinsam,
|
...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',
|
title: 'Streng vertraulich',
|
||||||
classification: 4,
|
classification: 4,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type Meta = {
|
|||||||
caseNumber?: string
|
caseNumber?: string
|
||||||
actorName?: string
|
actorName?: string
|
||||||
title?: string | null
|
title?: string | null
|
||||||
channelKey?: string
|
personnelFileId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,9 +52,11 @@ export function composeMessage(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta.channelKey && kind === 'chat.mention') {
|
if (meta.personnelFileId && kind === 'chat.mention') {
|
||||||
const link = `${appUrl.replace(/\/$/, '')}/kanaele/${meta.channelKey}`
|
// Der Name der Person steht bewusst nicht in der Meldung: dass es zu
|
||||||
return `${wer} hat dich in #${meta.channelKey} erwähnt · ${link}`
|
// 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
|
return null
|
||||||
|
|||||||
Reference in New Issue
Block a user