Die letzte offene Zeile aus TASKS.md. Sie blieb offen, weil eine Spur über
jeden Aktenaufruf schnell etwas anderes wird als eine Bequemlichkeit.
Vier Festlegungen halten sie auf der richtigen Seite:
- Kein Verlauf. Der Primärschlüssel (userId, targetType, targetId) lässt je
Nutzer und Datensatz genau eine Zeile zu, die überschrieben wird. Die
Tabelle kann nicht sagen, wie oft oder in welcher Reihenfolge gearbeitet
wurde. Zwanzig Einträge, älteres fällt hinten heraus.
- Nur die eigene. Keine Ansicht, kein Adminbereich, keine Funktion mit
fremder Kennung. Jede Abfrage nimmt p.userId.
- Jederzeit löschbar, ohne Rückfrage und ohne Grabstein.
- Vorabrufe zählen nicht. Ohne die Prüfung auf next-router-prefetch stünde
dort, worüber jemand mit der Maus gefahren ist.
Gelesen wird über dieselben Sichtbarkeitsfragmente wie überall: eine
vorgemerkte Kennung ist keine Berechtigung. Steigt eine Akte über die
Freigabestufe, verschwindet sie ohne Lücke und ohne Platzhalter; Personalakten
brauchen zusätzlich personnel.read. Das Zugriffsprotokoll bleibt unberührt und
bleibt die einzige Auskunft darüber, wer wann was gelesen hat.
Sie steht auf der Übersicht und im leeren Feld der Kommandopalette. Der zweite
Ort ist der eigentliche.
Nebenbei: NEXT_DIST_DIR, damit ein Build neben einem laufenden
Entwicklungsserver nicht dasselbe .next-Verzeichnis abräumt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
import type { Metadata } from 'next'
|
|
import Link from 'next/link'
|
|
import { db } from '@/lib/db'
|
|
import { requireUser } from '@/lib/auth/current'
|
|
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'
|
|
import { clearRecentAction } from './actions'
|
|
import type { RecentEntry } from '@/lib/recent/service'
|
|
|
|
export const metadata: Metadata = { title: de.dashboard.heading }
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
/**
|
|
* 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 DashboardPage() {
|
|
const user = await requireUser()
|
|
|
|
const [daten, levels, offen] = await Promise.all([
|
|
loadDashboard(user.permissions),
|
|
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
|
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-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="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} />
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
<div className="mt-10 grid gap-10 md:grid-cols-2">
|
|
<section className="md:col-span-2">
|
|
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
|
|
<h2 className="font-condensed">{de.dashboard.opened}</h2>
|
|
{daten.geoeffnet.length > 0 ? (
|
|
<form action={clearRecentAction}>
|
|
<button type="submit" className="font-condensed text-2xs text-text-dim">
|
|
{de.dashboard.openedClear}
|
|
</button>
|
|
</form>
|
|
) : null}
|
|
</div>
|
|
<p className="mt-1 text-2xs text-text-dim">{de.dashboard.openedHint}</p>
|
|
{daten.geoeffnet.length === 0 ? (
|
|
<p className="mt-4 text-text-dim">{de.dashboard.openedEmpty}</p>
|
|
) : (
|
|
<ul className="mt-4 border-t border-line">
|
|
{daten.geoeffnet.map((eintrag) => (
|
|
<li
|
|
key={`${eintrag.targetType}:${eintrag.targetId}`}
|
|
className="border-b border-line"
|
|
>
|
|
<Sprung eintrag={eintrag} levelName={levelName} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<Block titel={de.dashboard.mine} leer={de.dashboard.mineEmpty} eintraege={daten.meine}>
|
|
{(akte) => <Akte akte={akte} levelName={levelName} />}
|
|
</Block>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<Block
|
|
titel={de.dashboard.watched}
|
|
leer={de.dashboard.watchedEmpty}
|
|
eintraege={daten.beobachtet}
|
|
>
|
|
{(akte) => <Akte akte={akte} levelName={levelName} />}
|
|
</Block>
|
|
|
|
<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>
|
|
)
|
|
}
|
|
|
|
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-4 border-t border-line">
|
|
{eintraege.map((akte) => (
|
|
<li key={akte.id} className="border-b border-line">
|
|
{children(akte)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Eine Zeile der Sprungliste. Sie kann eine Akte, eine Person oder ein
|
|
* Dokument sein — deshalb steht die Art nicht dabei, sondern nur das, woran
|
|
* man sie erkennt: Aktenzeichen, Name oder Dokumentnummer.
|
|
*
|
|
* Ohne Vorabruf, anders als die übrigen Listen: die Seiten dahinter merken
|
|
* sich, dass sie geöffnet wurden. Der Vorabruf wird zwar serverseitig erkannt
|
|
* und zählt nicht mit, aber ausgerechnet diese Liste sollte sich nicht selbst
|
|
* am Leben halten können, wenn diese Erkennung einmal nicht greift.
|
|
*/
|
|
function Sprung({
|
|
eintrag,
|
|
levelName,
|
|
}: {
|
|
eintrag: RecentEntry
|
|
levelName: Map<number, string>
|
|
}) {
|
|
return (
|
|
<Link
|
|
href={eintrag.href}
|
|
prefetch={false}
|
|
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-2 pl-3 text-text no-underline"
|
|
style={classificationEdgeStyle(eintrag.classification)}
|
|
>
|
|
<span className="font-mono text-xs text-text-dim">{eintrag.label}</span>
|
|
<span className="grow">{eintrag.secondary}</span>
|
|
<StampMark
|
|
level={eintrag.classification}
|
|
name={levelName.get(eintrag.classification) ?? de.common.unknown}
|
|
/>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Der Freigabeliste fehlen die Namen: der Auftrag kennt nur Kennungen. Sie
|
|
* werden hier nachgeschlagen, damit die Leitung sieht, worum es geht — mehr
|
|
* als Aktenzeichen und Anfragender steht dort bewusst nicht.
|
|
*/
|
|
async function beschrifte(
|
|
jobs: Awaited<ReturnType<typeof pendingApprovals>>,
|
|
): Promise<
|
|
{
|
|
id: string
|
|
kind: string
|
|
label: string
|
|
requestedBy: string
|
|
clearanceAtTime: number
|
|
createdAt: string
|
|
}[]
|
|
> {
|
|
if (jobs.length === 0) return []
|
|
|
|
const [faelle, dokumente, nutzer] = await Promise.all([
|
|
db.case.findMany({
|
|
where: { id: { in: jobs.filter((j) => j.kind === 'CASE').map((j) => j.targetId ?? '') } },
|
|
select: { id: true, caseNumber: true },
|
|
}),
|
|
db.document.findMany({
|
|
where: { id: { in: jobs.filter((j) => j.kind === 'DOCUMENT').map((j) => j.targetId ?? '') } },
|
|
select: { id: true, documentNumber: true },
|
|
}),
|
|
db.user.findMany({
|
|
where: { id: { in: jobs.map((j) => j.requestedById) } },
|
|
select: { id: true, displayName: true },
|
|
}),
|
|
])
|
|
|
|
const nummer = new Map<string, string>([
|
|
...faelle.map((f) => [f.id, f.caseNumber] as const),
|
|
...dokumente.map((d) => [d.id, d.documentNumber] as const),
|
|
])
|
|
const name = new Map(nutzer.map((n) => [n.id, n.displayName]))
|
|
|
|
return jobs.map((job) => ({
|
|
id: job.id,
|
|
kind: job.kind,
|
|
label: nummer.get(job.targetId ?? '') ?? '—',
|
|
requestedBy: name.get(job.requestedById) ?? job.requestedById,
|
|
clearanceAtTime: job.clearanceAtTime,
|
|
createdAt: job.createdAt.toISOString(),
|
|
}))
|
|
}
|