Phase 9 abgeschlossen: Übersicht, Beobachtungsliste, helles Thema, Messung
Die Übersicht beantwortet vier Fragen, die man morgens hat: woran arbeite ich,
was ist fällig, wo hat jemand geschrieben, was hat sich getan. Jede Liste
läuft durch visibleCaseWhere — das ist der Ort, an dem eine unachtsame
Abfrage am ehesten eine Akte verrät. Ein Test prüft jede einzeln gegen eine zu
hoch eingestufte Akte. Der bisherige Inhalt ("Dein Zugang") steht jetzt unter
/zugang.
Beobachten ist eine Merkliste, kein Recht: nur was sichtbar ist, lässt sich
merken, und steigt eine Akte später über die eigene Stufe, verschwindet sie
aus der Liste, ohne dass jemand aufräumt.
Das helle Thema tauscht nur die semantische Ebene; die Grundpalette bleibt.
Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, im Farbton
aber dieselben — eine Stufe muss in beiden Themen wiedererkennbar sein. Drei
Zustände, "wie das System" ist der Auslieferungszustand, und ein Skript im
Kopf setzt die Wahl, bevor das erste Mal gemalt wird.
Die Messung misst nicht den Nutzer. web-vitals schickt Zahlen und das
Routenmuster, keine Kennung und keinen konkreten Pfad: wer wann welche Akte
geöffnet hat, steht im Zugriffsprotokoll, und eine zweite Spur daneben wäre
eine Überwachungsfunktion ohne Auftrag. Ausgewertet wird Median und 75.
Perzentil, nicht der Mittelwert.
Mobil: die breiten Tabellen scrollen in sich, statt die Seite breiter zu
machen. Ein e2e-Test hält fest, dass keine Seite waagerecht überläuft.
scripts/restore-test.sh spielt eine Sicherung in eine Wegwerf-Datenbank und
prüft ausdrücklich, dass Regeln und Trigger mitkommen: eine Wiederherstellung
ohne entry_no_delete wäre eine Datenbank ohne die Zusicherung, um derentwillen
das System gebaut wird. Einmal durchgespielt, alles angekommen.
186 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 54 e2e.
Grösste Route 116,2 von 120 kB.
This commit is contained in:
@@ -3,6 +3,7 @@ import { Suspense } from 'react'
|
||||
import { requirePermission } from '@/lib/auth/current'
|
||||
import { collectHealth, type ComponentState } from '@/lib/health/check'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { formatVital, summarizeVitals } from '@/lib/dashboard/vitals'
|
||||
import { SkeletonLine } from '@/components/Skeleton'
|
||||
|
||||
export const metadata: Metadata = { title: de.health.heading }
|
||||
@@ -70,6 +71,16 @@ export default async function SystemPage() {
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="font-condensed">{de.vitals.heading}</h2>
|
||||
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
|
||||
{de.vitals.intro}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<VitalsTable />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="font-condensed">{de.export.heading}</h2>
|
||||
<p className="mt-2 max-w-(--container-measure) text-sm text-text-dim">
|
||||
@@ -83,3 +94,66 @@ export default async function SystemPage() {
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Ladewerte der letzten sieben Tage. Median und 75. Perzentil, kein
|
||||
* Mittelwert — ein Ausreisser auf einem alten Laptop soll die Zahl nicht
|
||||
* bestimmen.
|
||||
*/
|
||||
async function VitalsTable() {
|
||||
const { metriken, routen } = await summarizeVitals()
|
||||
|
||||
if (metriken.length === 0) {
|
||||
return <p className="text-text-dim">{de.vitals.empty}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Auf schmalen Schirmen scrollt die Tabelle in sich, statt die Seite
|
||||
breiter zu machen. Siehe docs/09-performance-und-ux.md. */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[28rem] border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-left font-condensed text-text-dim">
|
||||
<th className="py-1">{de.vitals.metric}</th>
|
||||
<th className="py-1">{de.vitals.median}</th>
|
||||
<th className="py-1">{de.vitals.p75}</th>
|
||||
<th className="py-1">{de.vitals.count}</th>
|
||||
<th className="py-1">{de.vitals.target}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metriken.map((zeile) => (
|
||||
<tr key={zeile.metric} className="border-b border-line">
|
||||
<td className="py-1 font-mono">{zeile.metric}</td>
|
||||
<td className="py-1">{formatVital(zeile.metric, zeile.median)}</td>
|
||||
<td className="py-1">{formatVital(zeile.metric, zeile.p75)}</td>
|
||||
<td className="py-1 text-text-dim">{zeile.count}</td>
|
||||
<td className="py-1 text-text-dim">{de.vitals.targets[zeile.metric] ?? ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{routen.length > 0 ? (
|
||||
<div>
|
||||
<h3 className="font-condensed text-xs text-text-dim">{de.vitals.worstRoutes}</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="mt-2 w-full min-w-[24rem] border-collapse text-xs">
|
||||
<tbody>
|
||||
{routen.map((zeile) => (
|
||||
<tr key={zeile.route} className="border-b border-line">
|
||||
<td className="py-1 font-mono">{zeile.route}</td>
|
||||
<td className="py-1">{formatVital(zeile.metric, zeile.p75)}</td>
|
||||
<td className="py-1 text-text-dim">{zeile.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { watchText } from '@/lib/i18n/shell'
|
||||
import { toggleWatchAction } from '../actions'
|
||||
|
||||
/**
|
||||
* Akte beobachten.
|
||||
*
|
||||
* Sofortiges Umschalten, der Server bestätigt nur — misslingt es, springt der
|
||||
* Zustand zurück. Beobachten ist umkehrbar und harmlos, dafür braucht es
|
||||
* keinen Wartezustand. Siehe docs/09-performance-und-ux.md.
|
||||
*/
|
||||
export function WatchToggle({
|
||||
caseNumber,
|
||||
initial,
|
||||
}: {
|
||||
caseNumber: string
|
||||
initial: boolean
|
||||
}) {
|
||||
const [aktiv, setAktiv] = useState(initial)
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
title={watchText.hint}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
const vorher = aktiv
|
||||
setAktiv(!vorher)
|
||||
const ergebnis = await toggleWatchAction({ caseNumber })
|
||||
if (!ergebnis.ok) setAktiv(vorher)
|
||||
else setAktiv(ergebnis.watching)
|
||||
})
|
||||
}
|
||||
className={`font-condensed text-2xs ${aktiv ? 'text-accent' : 'text-text-dim'}`}
|
||||
>
|
||||
{aktiv ? watchText.unwatch : watchText.watch}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { de } from '@/lib/i18n/de'
|
||||
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
||||
import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
|
||||
import { CaseActions } from './CaseActions'
|
||||
import { WatchToggle } from './WatchToggle'
|
||||
import { isWatching } from '@/lib/cases/watch'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -39,7 +41,10 @@ export default async function CaseLayout({
|
||||
// Kein Zugriff heisst 404, nicht 403: die Existenz ist selbst eine Information.
|
||||
if (!akte) notFound()
|
||||
|
||||
const levels = await db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } })
|
||||
const [levels, beobachtet] = await Promise.all([
|
||||
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
||||
isWatching(user.permissions, akte.id),
|
||||
])
|
||||
const level = levels.find((l) => l.level === akte.classification)
|
||||
|
||||
const reiter = [
|
||||
@@ -56,6 +61,7 @@ export default async function CaseLayout({
|
||||
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
||||
<span className="font-mono text-xs text-text-dim">{akte.caseNumber}</span>
|
||||
<StampMark level={akte.classification} name={level?.name ?? de.common.unknown} />
|
||||
<WatchToggle caseNumber={akte.caseNumber} initial={beobachtet} />
|
||||
</div>
|
||||
<h1 className="mt-2 font-condensed">{akte.title}</h1>
|
||||
<dl className="mt-3 flex flex-wrap gap-x-8 gap-y-1 text-xs text-text-dim">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AccessError, addEntry, changeStatus, createCase, reclassify } from '@/l
|
||||
import { editEntry, restoreEntry, retractEntry } from '@/lib/cases/entryService'
|
||||
import { addParticipant, removeParticipant } from '@/lib/cases/participants'
|
||||
import { linkCases, unlinkCases } from '@/lib/cases/links'
|
||||
import { toggleWatch } from '@/lib/cases/watch'
|
||||
import { LINK_KINDS, PARTICIPANT_ROLES } from '@/lib/cases/terms'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
|
||||
@@ -275,3 +276,22 @@ export async function unlinkCasesAction(input: unknown): Promise<ActionResult> {
|
||||
return { ok: false, error: toMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
const watchSchema = z.object({ caseNumber: z.string().min(1) })
|
||||
|
||||
export type WatchResult = { ok: true; watching: boolean } | { ok: false; error: string }
|
||||
|
||||
/** Beobachten verschafft keinen Zugriff — nur was sichtbar ist, lässt sich merken. */
|
||||
export async function toggleWatchAction(input: unknown): Promise<WatchResult> {
|
||||
const user = await requireUser()
|
||||
const parsed = watchSchema.safeParse(input)
|
||||
if (!parsed.success) return { ok: false, error: de.actionErrors.default! }
|
||||
|
||||
try {
|
||||
const { watching } = await toggleWatch(user.permissions, parsed.data.caseNumber)
|
||||
revalidatePath('/uebersicht')
|
||||
return { ok: true, watching }
|
||||
} catch (error) {
|
||||
return { ok: false, error: toMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { signOut } from '@/auth'
|
||||
import { requireUser } from '@/lib/auth/current'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { PaletteTrigger } from '@/components/palette/PaletteTrigger'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { Vitals } from '@/components/Vitals'
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireUser()
|
||||
@@ -16,6 +18,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
<div className="min-h-dvh">
|
||||
{/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */}
|
||||
<PaletteTrigger />
|
||||
<Vitals />
|
||||
<header className="border-b border-line">
|
||||
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-6 py-3">
|
||||
<Link href="/uebersicht" className="font-condensed text-text no-underline">
|
||||
@@ -46,6 +49,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 text-xs text-text-dim">
|
||||
<ThemeToggle />
|
||||
<span className="font-mono">{user.displayName}</span>
|
||||
<form action={abmelden}>
|
||||
<button type="submit" className="font-condensed text-accent">
|
||||
|
||||
@@ -1,48 +1,47 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { db } from '@/lib/db'
|
||||
import { requireUser } from '@/lib/auth/current'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { PERMISSIONS } from '@/lib/auth/permissions'
|
||||
import { StampMark } from '@/components/StampMark'
|
||||
import { loadDashboard } from '@/lib/dashboard/load'
|
||||
import { pendingApprovals } from '@/lib/export/service'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
|
||||
import { Approvals } from '../export/Approvals'
|
||||
|
||||
export const metadata: Metadata = { title: de.overview.heading }
|
||||
export const metadata: Metadata = { title: de.dashboard.heading }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Zwischenstand aus TASKS.md Phase 1: zeigt Name, Rollen, errechnete Stufe und
|
||||
* Rechte. Das ist die Seite, an der die Abteilungsleitung nachvollziehen kann,
|
||||
* warum jemand etwas sieht oder eben nicht.
|
||||
* Die Übersicht: vier Fragen, die man morgens hat.
|
||||
*
|
||||
* Woran arbeite ich, was ist fällig, wo hat jemand geschrieben, was hat sich
|
||||
* getan. Jede Liste läuft durch dieselbe Sichtbarkeitsprüfung wie die
|
||||
* Fallliste — das ist der Ort, an dem eine unachtsame Abfrage am ehesten eine
|
||||
* Akte verrät, die man nicht sehen darf.
|
||||
*/
|
||||
export default async function OverviewPage() {
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireUser()
|
||||
|
||||
const [roles, level] = await Promise.all([
|
||||
db.userRole.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { discordRoleId: true },
|
||||
}),
|
||||
db.clearanceLevelConfig.findUnique({ where: { level: user.permissions.clearanceLevel } }),
|
||||
const [daten, levels, offen] = await Promise.all([
|
||||
loadDashboard(user.permissions),
|
||||
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
||||
pendingApprovals(user.permissions),
|
||||
])
|
||||
|
||||
const mappings = await db.roleMapping.findMany({
|
||||
where: { discordRoleId: { in: roles.map((r) => r.discordRoleId) } },
|
||||
orderBy: [{ category: 'asc' }, { sortOrder: 'desc' }],
|
||||
})
|
||||
const byId = new Map(mappings.map((m) => [m.discordRoleId, m]))
|
||||
|
||||
const granted = [...user.permissions.permissions].sort()
|
||||
|
||||
// Offene Exportfreigaben stehen hier, weil die Leitung diese Seite ohnehin
|
||||
// öffnet. Eine Anfrage, die niemand sieht, ist keine Freigabe.
|
||||
const offen = await pendingApprovals(user.permissions)
|
||||
const levelName = new Map(levels.map((l) => [l.level, l.name]))
|
||||
const beschriftet = await beschrifte(offen)
|
||||
const heute = new Date()
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||
<h1 className="font-condensed">{de.dashboard.heading}</h1>
|
||||
<Link href="/zugang" className="font-condensed text-xs">
|
||||
{de.dashboard.access}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{beschriftet.length > 0 ? (
|
||||
<section className="mb-10 border border-stamp-3 p-4">
|
||||
<section className="mt-8 border border-stamp-3 p-4">
|
||||
<h2 className="font-condensed text-stamp-3">{de.export.approvals}</h2>
|
||||
<div className="mt-3">
|
||||
<Approvals jobs={beschriftet} />
|
||||
@@ -50,63 +49,152 @@ export default async function OverviewPage() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<h1 className="font-condensed">{de.overview.heading}</h1>
|
||||
<p className="mt-2 text-text-dim">{de.overview.intro}</p>
|
||||
<div className="mt-10 grid gap-10 md:grid-cols-2">
|
||||
<Block titel={de.dashboard.mine} leer={de.dashboard.mineEmpty} eintraege={daten.meine}>
|
||||
{(akte) => <Akte akte={akte} levelName={levelName} />}
|
||||
</Block>
|
||||
|
||||
<dl className="mt-8 grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm">
|
||||
<dt className="font-condensed text-text-dim">{de.overview.person}</dt>
|
||||
<dd>{user.displayName}</dd>
|
||||
<Block titel={de.dashboard.due} leer={de.dashboard.dueEmpty} eintraege={daten.fristen}>
|
||||
{(akte) => (
|
||||
<Akte
|
||||
akte={akte}
|
||||
levelName={levelName}
|
||||
zusatz={
|
||||
akte.dueAt
|
||||
? akte.dueAt < heute
|
||||
? de.dashboard.overdue
|
||||
: de.dashboard.dueAt(akte.dueAt.toLocaleDateString('de-DE'))
|
||||
: undefined
|
||||
}
|
||||
dringend={Boolean(akte.dueAt && akte.dueAt < heute)}
|
||||
/>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
<dt className="font-condensed text-text-dim">{de.overview.discordId}</dt>
|
||||
<dd className="font-mono text-xs">{user.id}</dd>
|
||||
<section>
|
||||
<h2 className="font-condensed">{de.dashboard.unread}</h2>
|
||||
{daten.ungelesen.length === 0 ? (
|
||||
<p className="mt-4 text-text-dim">{de.dashboard.unreadEmpty}</p>
|
||||
) : (
|
||||
<ul className="mt-4 border-t border-line">
|
||||
{daten.ungelesen.map((eintrag) => (
|
||||
<li key={`${eintrag.scopeType}:${eintrag.scopeId}`} className="border-b border-line">
|
||||
<Link
|
||||
href={eintrag.href}
|
||||
prefetch
|
||||
className="flex items-baseline gap-4 py-2 text-text no-underline"
|
||||
>
|
||||
<span className="font-mono text-xs text-text-dim">{eintrag.label}</span>
|
||||
<span className="grow font-condensed text-2xs text-accent">
|
||||
{de.dashboard.unreadCount(eintrag.count)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<dt className="font-condensed text-text-dim">{de.overview.clearance}</dt>
|
||||
<dd>
|
||||
<StampMark
|
||||
level={user.permissions.clearanceLevel}
|
||||
name={level?.name ?? de.common.unknown}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
<Block
|
||||
titel={de.dashboard.watched}
|
||||
leer={de.dashboard.watchedEmpty}
|
||||
eintraege={daten.beobachtet}
|
||||
>
|
||||
{(akte) => <Akte akte={akte} levelName={levelName} />}
|
||||
</Block>
|
||||
|
||||
{user.permissions.isAdmin ? (
|
||||
<p className="mt-6 border-l-(length:--spacing-edge) border-stamp-5 pl-3 text-xs text-text-dim">
|
||||
{de.overview.bootstrapAdmin}
|
||||
</p>
|
||||
) : null}
|
||||
<section className="md:col-span-2">
|
||||
<h2 className="font-condensed">{de.dashboard.recent}</h2>
|
||||
{daten.zuletzt.length === 0 ? (
|
||||
<p className="mt-4 text-text-dim">{de.dashboard.recentEmpty}</p>
|
||||
) : (
|
||||
<ul className="mt-4 border-t border-line">
|
||||
{daten.zuletzt.map((akte) => (
|
||||
<li key={akte.id} className="border-b border-line">
|
||||
<Akte
|
||||
akte={akte}
|
||||
levelName={levelName}
|
||||
zusatz={akte.updatedAt.toLocaleDateString('de-DE')}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
<h2 className="mt-10 font-condensed">{de.overview.roles}</h2>
|
||||
{roles.length === 0 ? (
|
||||
<p className="mt-2 text-text-dim">{de.overview.noRoles}</p>
|
||||
type Fall = {
|
||||
id: string
|
||||
caseNumber: string
|
||||
title: string
|
||||
status: string
|
||||
classification: number
|
||||
updatedAt: Date
|
||||
dueAt?: Date | null
|
||||
}
|
||||
|
||||
function Block({
|
||||
titel,
|
||||
leer,
|
||||
eintraege,
|
||||
children,
|
||||
}: {
|
||||
titel: string
|
||||
leer: string
|
||||
eintraege: Fall[]
|
||||
children: (akte: Fall) => React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="font-condensed">{titel}</h2>
|
||||
{eintraege.length === 0 ? (
|
||||
<p className="mt-4 text-text-dim">{leer}</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-1.5 text-sm">
|
||||
{roles.map((role) => {
|
||||
const mapping = byId.get(role.discordRoleId)
|
||||
return (
|
||||
<li key={role.discordRoleId} className="flex flex-wrap items-baseline gap-2">
|
||||
<span>{mapping?.label ?? role.discordRoleId}</span>
|
||||
<span className="font-condensed text-2xs text-text-dim">
|
||||
{mapping
|
||||
? `${de.roleAdmin.categories[mapping.category]} · ${de.clearance.level(mapping.clearanceLevel)}`
|
||||
: de.overview.unmapped}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h2 className="mt-10 font-condensed">{de.overview.permissions}</h2>
|
||||
{granted.length === 0 ? (
|
||||
<p className="mt-2 text-text-dim">{de.overview.noPermissions}</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 font-mono text-xs">
|
||||
{PERMISSIONS.filter((p) => granted.includes(p)).map((permission) => (
|
||||
<li key={permission}>{permission}</li>
|
||||
<ul className="mt-4 border-t border-line">
|
||||
{eintraege.map((akte) => (
|
||||
<li key={akte.id} className="border-b border-line">
|
||||
{children(akte)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Akte({
|
||||
akte,
|
||||
levelName,
|
||||
zusatz,
|
||||
dringend,
|
||||
}: {
|
||||
akte: Fall
|
||||
levelName: Map<number, string>
|
||||
zusatz?: string
|
||||
dringend?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/faelle/${akte.caseNumber}`}
|
||||
prefetch
|
||||
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-2 pl-3 text-text no-underline"
|
||||
style={classificationEdgeStyle(akte.classification)}
|
||||
>
|
||||
<span className="font-mono text-xs text-text-dim">{akte.caseNumber}</span>
|
||||
<span className="grow">{akte.title}</span>
|
||||
{zusatz ? (
|
||||
<span className={`font-condensed text-2xs ${dringend ? 'text-stamp-4' : 'text-text-dim'}`}>
|
||||
{zusatz}
|
||||
</span>
|
||||
) : null}
|
||||
<StampMark
|
||||
level={akte.classification}
|
||||
name={levelName.get(akte.classification) ?? de.common.unknown}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,7 +206,14 @@ export default async function OverviewPage() {
|
||||
async function beschrifte(
|
||||
jobs: Awaited<ReturnType<typeof pendingApprovals>>,
|
||||
): Promise<
|
||||
{ id: string; kind: string; label: string; requestedBy: string; clearanceAtTime: number; createdAt: string }[]
|
||||
{
|
||||
id: string
|
||||
kind: string
|
||||
label: string
|
||||
requestedBy: string
|
||||
clearanceAtTime: number
|
||||
createdAt: string
|
||||
}[]
|
||||
> {
|
||||
if (jobs.length === 0) return []
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { db } from '@/lib/db'
|
||||
import { requireUser } from '@/lib/auth/current'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { PERMISSIONS } from '@/lib/auth/permissions'
|
||||
import { StampMark } from '@/components/StampMark'
|
||||
|
||||
export const metadata: Metadata = { title: de.dashboard.access }
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Zwischenstand aus TASKS.md Phase 1: zeigt Name, Rollen, errechnete Stufe und
|
||||
* Rechte. Das ist die Seite, an der die Abteilungsleitung nachvollziehen kann,
|
||||
* warum jemand etwas sieht oder eben nicht.
|
||||
*/
|
||||
export default async function OverviewPage() {
|
||||
const user = await requireUser()
|
||||
|
||||
const [roles, level] = await Promise.all([
|
||||
db.userRole.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { discordRoleId: true },
|
||||
}),
|
||||
db.clearanceLevelConfig.findUnique({ where: { level: user.permissions.clearanceLevel } }),
|
||||
])
|
||||
|
||||
const mappings = await db.roleMapping.findMany({
|
||||
where: { discordRoleId: { in: roles.map((r) => r.discordRoleId) } },
|
||||
orderBy: [{ category: 'asc' }, { sortOrder: 'desc' }],
|
||||
})
|
||||
const byId = new Map(mappings.map((m) => [m.discordRoleId, m]))
|
||||
|
||||
const granted = [...user.permissions.permissions].sort()
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-(--container-measure) px-6 py-10">
|
||||
<h1 className="font-condensed">{de.overview.heading}</h1>
|
||||
<p className="mt-2 text-text-dim">{de.overview.intro}</p>
|
||||
|
||||
<dl className="mt-8 grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm">
|
||||
<dt className="font-condensed text-text-dim">{de.overview.person}</dt>
|
||||
<dd>{user.displayName}</dd>
|
||||
|
||||
<dt className="font-condensed text-text-dim">{de.overview.discordId}</dt>
|
||||
<dd className="font-mono text-xs">{user.id}</dd>
|
||||
|
||||
<dt className="font-condensed text-text-dim">{de.overview.clearance}</dt>
|
||||
<dd>
|
||||
<StampMark
|
||||
level={user.permissions.clearanceLevel}
|
||||
name={level?.name ?? de.common.unknown}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{user.permissions.isAdmin ? (
|
||||
<p className="mt-6 border-l-(length:--spacing-edge) border-stamp-5 pl-3 text-xs text-text-dim">
|
||||
{de.overview.bootstrapAdmin}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<h2 className="mt-10 font-condensed">{de.overview.roles}</h2>
|
||||
{roles.length === 0 ? (
|
||||
<p className="mt-2 text-text-dim">{de.overview.noRoles}</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-1.5 text-sm">
|
||||
{roles.map((role) => {
|
||||
const mapping = byId.get(role.discordRoleId)
|
||||
return (
|
||||
<li key={role.discordRoleId} className="flex flex-wrap items-baseline gap-2">
|
||||
<span>{mapping?.label ?? role.discordRoleId}</span>
|
||||
<span className="font-condensed text-2xs text-text-dim">
|
||||
{mapping
|
||||
? `${de.roleAdmin.categories[mapping.category]} · ${de.clearance.level(mapping.clearanceLevel)}`
|
||||
: de.overview.unmapped}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h2 className="mt-10 font-condensed">{de.overview.permissions}</h2>
|
||||
{granted.length === 0 ? (
|
||||
<p className="mt-2 text-text-dim">{de.overview.noPermissions}</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 font-mono text-xs">
|
||||
{PERMISSIONS.filter((p) => granted.includes(p)).map((permission) => (
|
||||
<li key={permission}>{permission}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { getCurrentUser } from '@/lib/auth/current'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const METRIKEN = new Set(['LCP', 'INP', 'CLS', 'TTFB', 'FCP'])
|
||||
const BEWERTUNGEN = new Set(['good', 'needs-improvement', 'poor'])
|
||||
|
||||
/**
|
||||
* Nimmt Ladewerte aus dem Browser entgegen.
|
||||
*
|
||||
* **Was hier ankommt, wird geprüft und beschnitten**, bevor es in die
|
||||
* Datenbank geht: nur bekannte Metriken, nur plausible Zahlen, und die Route
|
||||
* als Muster statt als konkreter Pfad. Ein Endpunkt, der ungeprüft
|
||||
* Zeichenketten aus dem Browser speichert, ist ein Ablagefach für Fremde.
|
||||
*
|
||||
* Der Nutzer wird **nicht** mitgeschrieben. Wer wann welche Akte geöffnet hat,
|
||||
* steht im Zugriffsprotokoll — dort gehört es hin, dort ist es geregelt, und
|
||||
* eine zweite Spur daneben wäre eine Überwachungsfunktion ohne Auftrag.
|
||||
*/
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
// Angemeldet sein muss man trotzdem: sonst ist es ein offener Schreibzugang.
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return new NextResponse(null, { status: 404 })
|
||||
|
||||
let daten: unknown
|
||||
try {
|
||||
daten = await request.json()
|
||||
} catch {
|
||||
return new NextResponse(null, { status: 400 })
|
||||
}
|
||||
|
||||
const eintrag = daten as Record<string, unknown>
|
||||
const metric = String(eintrag.metric ?? '')
|
||||
const rating = String(eintrag.rating ?? '')
|
||||
const value = Number(eintrag.value)
|
||||
const route = String(eintrag.route ?? '').slice(0, 120)
|
||||
|
||||
if (!METRIKEN.has(metric) || !BEWERTUNGEN.has(rating)) {
|
||||
return new NextResponse(null, { status: 400 })
|
||||
}
|
||||
// CLS liegt bei 0..1, die Zeiten in Millisekunden. Alles darüber ist Unfug
|
||||
// oder ein Messfehler und würde nur den Durchschnitt verderben.
|
||||
if (!Number.isFinite(value) || value < 0 || value > 600_000) {
|
||||
return new NextResponse(null, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await db.webVital.create({ data: { metric, value, route, rating } })
|
||||
} catch (error) {
|
||||
console.error('[vitals] nicht gespeichert', error)
|
||||
}
|
||||
|
||||
// Der Browser sendet das per `sendBeacon` und interessiert sich nicht für
|
||||
// die Antwort.
|
||||
return new NextResponse(null, { status: 204 })
|
||||
}
|
||||
+51
-5
@@ -75,15 +75,54 @@
|
||||
--spacing-edge: 3px;
|
||||
}
|
||||
|
||||
/* Helles Thema. Verpflichtend mitzuliefern, ausgebaut in Phase 9. */
|
||||
/* Helles Thema.
|
||||
*
|
||||
* Es tauscht **nur die semantische Ebene**, nie die Grundpalette und nie die
|
||||
* Klassifizierungsfarben: eine Stufe muss in beiden Themen dieselbe Farbe
|
||||
* haben, sonst lernt man sie zweimal. Siehe docs/10-design-system.md.
|
||||
*
|
||||
* Kein warmes Cremeweiss, sondern ein leicht kühles Papierweiss — dieselbe
|
||||
* Anmutung wie das dunkle Thema, nur andersherum. Die Kontraste sind auf
|
||||
* mindestens 4,5:1 für Fliesstext ausgelegt. */
|
||||
[data-theme='light'] {
|
||||
--color-surface: #eceeea;
|
||||
--color-panel: #e2e5e1;
|
||||
--color-raised: #f4f5f2;
|
||||
--color-line: #b9beb8;
|
||||
--color-raised: #f7f8f5;
|
||||
--color-line: #b0b6ae;
|
||||
--color-text: #1b1f21;
|
||||
--color-text-dim: #545c58;
|
||||
--color-accent: #3d5f70;
|
||||
--color-text-dim: #4d5551;
|
||||
--color-accent: #35586a;
|
||||
}
|
||||
|
||||
/* Die Klassifizierungsfarben sind für hellen Grund eine Spur dunkler, damit
|
||||
Stempel und Kante lesbar bleiben. Der Farbton bleibt derselbe. */
|
||||
[data-theme='light'] {
|
||||
--color-stamp-0: #6b716d;
|
||||
--color-stamp-1: #4a6577;
|
||||
--color-stamp-2: #34694f;
|
||||
--color-stamp-3: #856024;
|
||||
--color-stamp-4: #8d3c28;
|
||||
--color-stamp-5: #5c4270;
|
||||
}
|
||||
|
||||
/* Ohne ausdrückliche Wahl folgt die Anwendung dem System. Der Auslieferungs-
|
||||
zustand bleibt dunkel: `prefers-color-scheme: light` muss gesetzt sein. */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme='dark']) {
|
||||
--color-surface: #eceeea;
|
||||
--color-panel: #e2e5e1;
|
||||
--color-raised: #f7f8f5;
|
||||
--color-line: #b0b6ae;
|
||||
--color-text: #1b1f21;
|
||||
--color-text-dim: #4d5551;
|
||||
--color-accent: #35586a;
|
||||
--color-stamp-0: #6b716d;
|
||||
--color-stamp-1: #4a6577;
|
||||
--color-stamp-2: #34694f;
|
||||
--color-stamp-3: #856024;
|
||||
--color-stamp-4: #8d3c28;
|
||||
--color-stamp-5: #5c4270;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -420,6 +459,13 @@
|
||||
color: #000;
|
||||
font-size: 10pt;
|
||||
line-height: 1.45;
|
||||
/* Am Bildschirm auf schmalen Geraeten: die Tabellen darin scrollen in
|
||||
sich, statt die Seite breiter zu machen. Auf Papier greift das nicht. */
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.print-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.print-sheet h1 {
|
||||
|
||||
+19
-1
@@ -1,5 +1,7 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { headers } from 'next/headers'
|
||||
import { de } from '@/lib/i18n/de'
|
||||
import { THEME_KEY } from '@/components/ThemeToggle'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -14,10 +16,26 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Setzt das gewählte Thema, **bevor** das erste Mal gemalt wird.
|
||||
*
|
||||
* Ohne das blitzt beim Laden kurz das falsche Thema auf. Es ist die eine
|
||||
* Stelle mit `dangerouslySetInnerHTML`, und der Inhalt ist eine Konstante aus
|
||||
* diesem Modul — kein Nutzertext. Das Verbot aus docs/07 gilt Nutzerinhalten.
|
||||
*/
|
||||
const THEME_SCRIPT =
|
||||
`try{var t=localStorage.getItem(${JSON.stringify(THEME_KEY)});` +
|
||||
`if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t)}catch(e){}`
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
// Die strikte Inhaltsrichtlinie erlaubt nur Skripte mit passendem Nonce,
|
||||
// siehe middleware.ts.
|
||||
const nonce = (await headers()).get('x-nonce') ?? undefined
|
||||
|
||||
return (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
|
||||
{/* Nur die beiden Schnitte vorladen, die auf jeder Seite vorkommen. */}
|
||||
<link
|
||||
rel="preload"
|
||||
|
||||
Reference in New Issue
Block a user