Zuletzt geöffnet als Sprungliste, ohne Verlauf

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.
This commit is contained in:
Harlekin
2026-09-03 10:52:23 +02:00
parent b911c1bd9a
commit db40c5d3b2
18 changed files with 621 additions and 10 deletions
@@ -22,6 +22,7 @@ import { DocumentActions } from './DocumentActions'
import { Attachments } from '@/components/files/Attachments'
import { listAttachments, resolvePhotoUrls } from '@/lib/files/service'
import { toAttachmentViews } from '@/lib/files/view'
import { trackOpen } from '@/lib/recent/track'
import type { EffectivePermissions } from '@/lib/auth/permissions'
export const dynamic = 'force-dynamic'
@@ -162,6 +163,8 @@ export default async function DocumentPage({
})
}
await trackOpen(user.id, 'DOCUMENT', doc.id)
const [levels, besitzer, verfasser] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
doc.caseId
@@ -10,6 +10,7 @@ import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
import { CaseActions } from './CaseActions'
import { WatchToggle } from './WatchToggle'
import { isWatching } from '@/lib/cases/watch'
import { trackOpen } from '@/lib/recent/track'
export const dynamic = 'force-dynamic'
@@ -41,6 +42,10 @@ export default async function CaseLayout({
// Kein Zugriff heisst 404, nicht 403: die Existenz ist selbst eine Information.
if (!akte) notFound()
// Für die eigene Sprungliste vormerken. Steht im Layout und nicht auf einem
// Reiter: wer die Beteiligten aufruft, hat die Akte geöffnet.
await trackOpen(user.id, 'CASE', akte.id)
const [levels, beobachtet] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
isWatching(user.permissions, akte.id),
@@ -13,6 +13,7 @@ import {
} from '@/lib/personnel/service'
import { listDocuments, listTemplatesFor } from '@/lib/documents/service'
import { canWritePersonnel } from '@/lib/access/documentWrite'
import { trackOpen } from '@/lib/recent/track'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { SkeletonLine } from '@/components/Skeleton'
@@ -179,6 +180,8 @@ export default async function PersonnelPage({ params }: { params: Promise<{ id:
})
}
await trackOpen(user.id, 'PERSONNEL', akte.id)
const [levels, statusTags] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
listStatusTags(),
+21
View File
@@ -0,0 +1,21 @@
'use server'
import { revalidatePath } from 'next/cache'
import { requireUser } from '@/lib/auth/current'
import { clearRecent } from '@/lib/recent/service'
/**
* Die eigene Sprungliste leeren.
*
* Ohne Rückfrage, ohne Protokolleintrag, ohne Grabstein: die Liste ist eine
* Bequemlichkeit für den Nutzer und keine Aufzeichnung über ihn. Für die
* Nachvollziehbarkeit von Lesezugriffen ist das Zugriffsprotokoll zuständig,
* und das bleibt davon unberührt.
*
* Als Formular-Aktion gebaut, damit die Übersicht dafür kein Skript braucht.
*/
export async function clearRecentAction(): Promise<void> {
const user = await requireUser()
await clearRecent(user.id)
revalidatePath('/uebersicht')
}
+64
View File
@@ -7,6 +7,8 @@ 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'
@@ -50,6 +52,34 @@ export default async function DashboardPage() {
) : 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>
@@ -165,6 +195,40 @@ function Block({
)
}
/**
* 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,
+23 -1
View File
@@ -3,6 +3,7 @@ import { db } from '@/lib/db'
import { getCurrentUser } from '@/lib/auth/current'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
import { listRecent } from '@/lib/recent/service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -10,6 +11,9 @@ export const dynamic = 'force-dynamic'
/** So viele Treffer je Art zeigt die Palette. Mehr passt nicht auf den Schirm. */
const LIMIT = 5
/** So viele Sprünge zeigt die Palette, solange nichts im Feld steht. */
const RECENT_IN_PALETTE = 6
/**
* Schnellsuche für die Kommandopalette.
*
@@ -24,7 +28,25 @@ export async function GET(request: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ cases: [], personnel: [] }, { status: 404 })
const begriff = (new URL(request.url).searchParams.get('q') ?? '').trim().slice(0, 100)
const params = new URL(request.url).searchParams
// Leeres Feld: die eigene Sprungliste. Wer die Palette öffnet, will meistens
// dorthin zurück, wo er eben war — und nicht erst tippen.
if (params.get('recent') === '1') {
const zuletzt = await listRecent(user.permissions, RECENT_IN_PALETTE)
return NextResponse.json({
cases: [],
personnel: [],
recent: zuletzt.map((eintrag) => ({
href: eintrag.href,
primary: eintrag.label,
secondary: eintrag.secondary,
classification: eintrag.classification,
})),
})
}
const begriff = (params.get('q') ?? '').trim().slice(0, 100)
if (begriff.length < 2) return NextResponse.json({ cases: [], personnel: [] })
const enthaelt = { contains: begriff, mode: 'insensitive' as const }