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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
289 lines
9.2 KiB
TypeScript
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>
|
|
)
|
|
}
|
|
|