Files
AfiS-Aktensystem/web/app/(app)/uebersicht/page.tsx
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

289 lines
9.2 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 { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
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] = await Promise.all([
loadDashboard(user.permissions),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
])
const levelName = new Map(levels.map((l) => [l.level, l.name]))
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>
<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="btn btn-sm">
{de.dashboard.openedClear}
</button>
</form>
) : null}
</div>
{daten.geoeffnet.length === 0 ? (
<EmptyMark />
) : (
<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="record-key">{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>
)
}
/**
* Ein leerer Zustand ohne Satz.
*
* „Noch nichts geöffnet" zu schreiben erklärt nichts, was das leere Feld nicht
* schon zeigt, und nimmt Platz weg, sobald etwas darin steht. Ein ruhiger
* Rahmen mit einem Strich sagt dasselbe und verschwindet von selbst, wenn die
* Liste sich füllt.
*/
function EmptyMark() {
return (
<div
aria-hidden
className="mt-4 flex h-16 items-center justify-center border border-dashed border-line"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-line)">
<rect x="3.5" y="5" width="17" height="14" rx="2" strokeWidth="1.5" />
<path d="M8 11h8" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
)
}
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.
*
* Eine Akte und ein Dokument tragen eine **Kennung**, eine Person nicht: ihr
* Name ist der Titel. Stünde er in der Kennungsspalte, stünde er in Mono, wäre
* abgeschnitten, und der Dienstgrad rutschte dorthin, wo sonst der Titel
* steht - untereinander ergäbe das keine Spalte mehr.
*
* 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="record-row py-2 pl-3 text-text no-underline"
style={classificationEdgeStyle(eintrag.classification)}
>
{eintrag.targetType === 'PERSONNEL' ? (
<>
{/* Kein Aktenzeichen, aber auch keine Leerstelle: dort steht, was es
ist. Eine leere Spalte lässt die Zeile wie einen Fehler aussehen. */}
<span className="record-key">{de.personnel.one}</span>
<span className="record-rank">
{eintrag.secondary ? `[${eintrag.secondary}]` : ''}
</span>
<span className="record-title">{eintrag.label}</span>
</>
) : (
<>
<span className="record-key">{eintrag.label}</span>
<span className="record-title">{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="record-row py-2 pl-3 text-text no-underline"
style={classificationEdgeStyle(akte.classification)}
>
<span className="record-key">{akte.caseNumber}</span>
<span className="record-title">{akte.title}</span>
<span className={`record-meta ${dringend ? 'text-stamp-4' : ''}`}>{zusatz}</span>
<StampMark
level={akte.classification}
name={levelName.get(akte.classification) ?? de.common.unknown}
/>
</Link>
)
}