feat: Dokumentenablage, OCR, Volltextsuche und Belegmappe
Etappe 3. Dazu die geteilte Infrastruktur, auf der die Berichte der
folgenden Etappen aufsetzen: Speichertreiber und PDF-Grundgeruest.
Dateiablage hinter einer Schnittstelle, zwei Treiber - lokales
Verzeichnis fuer die Entwicklung, S3-kompatibel fuer den Betrieb. Der
Schluessel wird erzeugt, nie aus dem Dateinamen abgeleitet; ein
Pfadausbruch ueber den Namen ist damit ausgeschlossen.
Der Dateityp wird ausschliesslich an den Magic Bytes geprueft, und ein
PDF muss bei Offset 0 stehen - damit fallen auch Polyglot-Dateien durch,
die gleichzeitig gueltiges PDF und gueltiges HTML sind. Ausgeliefert
wird nur ueber kurzlebige signierte URLs, immer als Anhang und mit
nosniff. Die Route prueft Signatur, Anmeldung UND Zugriff auf die
betreuteId aus dem Schluessel; jeder Abruf steht im Audit-Log.
Beim Dateitreiber liegt die Datei zusaetzlich AES-256-GCM-verschluesselt.
Beim S3-Treiber geht das nicht, weil signierte URLs am Server vorbei
ausgeliefert werden - das ist in der Datei dokumentiert, nicht
stillschweigend anders gemacht.
@react-pdf/renderer konnte in diesem Abhaengigkeitsbaum keinen einzigen
Buchstaben setzen: die Unterabhaengigkeiten sind weitergewandert und
liefern fuer Standardschriften kein Schriftobjekt mehr.
src/lib/pdf/standardschrift.ts baut es aus den Metriken, die pdfkit
ohnehin mitbringt - kein neues Paket, kein Netz - und haengt sich nur
ein, wenn die Aufloesung tatsaechlich kaputt ist.
Fristerkennung im OCR-Text ("innerhalb von vier Wochen", "binnen eines
Monats") wird immer nur VORGESCHLAGEN, nie automatisch angelegt. Die
Textstelle steht daneben, damit man nachprueft, bevor man uebernimmt.
Gerechnet wird auf lokaler Mittagszeit: in UTC verschoebe die Sommerzeit
"vier Wochen ab 12.03." um einen Tag.
57 Tests.
Etappe: 3
Phase: Papier
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { FileCheck2, Fingerprint, ScanText } from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SeitenKopf } from '@/components/seiten-kopf'
|
||||
import {
|
||||
ArtAbzeichen,
|
||||
OcrAbzeichen,
|
||||
PosteingangAbzeichen,
|
||||
RichtungAbzeichen,
|
||||
TagAbzeichen,
|
||||
} from '@/components/dokumente/abzeichen'
|
||||
import { DateiAktionen } from '@/components/dokumente/datei-aktionen'
|
||||
import { ErkennungPanel } from '@/components/dokumente/erkennung-panel'
|
||||
import { MetadatenFormular } from '@/components/dokumente/metadaten-formular'
|
||||
import { PosteingangStufen } from '@/components/dokumente/posteingang-stufen'
|
||||
import { Verknuepfungen } from '@/components/dokumente/verknuepfungen'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { oeffnen } from '@/lib/crypto/feld'
|
||||
import { dokumentenkontext, tagsDerAkte, vorgaengeDerAkte } from '@/lib/dokumente/kontext'
|
||||
import { VERKNUEPFUNG_INCLUDE, verknuepfungAnzeigen, zieleDerAkte } from '@/lib/dokumente/ziele'
|
||||
import { formatBytes } from '@/lib/speicher/dateityp'
|
||||
import { formatDatumZeit } from '@/lib/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Ein Dokument in allen Einzelheiten.
|
||||
*
|
||||
* Neben den Metadaten stehen hier zwei Dinge, die es sonst nirgends gibt:
|
||||
* der **Ablageort des Originals in Papier** und die **SHA-256-Pruefsumme**.
|
||||
* Die Pruefsumme ist der Nachweis gegenueber dem Betreuungsgericht und
|
||||
* moeglichen Miterben, dass die Datei seit der Aufnahme in die Akte nicht
|
||||
* veraendert wurde.
|
||||
*/
|
||||
export default async function DokumentSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const { betreuteId, darfSchreiben } = await dokumentenkontext('lesen')
|
||||
const schreibrecht = darfSchreiben('DOKUMENTE')
|
||||
|
||||
const roh = await db.dokument.findFirst({
|
||||
where: { id, betreuteId, ...nichtGeloescht },
|
||||
include: {
|
||||
tags: { select: { id: true, name: true } },
|
||||
verknuepfungen: { include: VERKNUEPFUNG_INCLUDE, orderBy: { erstelltAm: 'asc' } },
|
||||
},
|
||||
})
|
||||
if (!roh) notFound()
|
||||
|
||||
const dokument = oeffnen('Dokument', roh)
|
||||
|
||||
const [tags, ziele, vorgaenge] = await Promise.all([
|
||||
tagsDerAkte(betreuteId),
|
||||
zieleDerAkte(betreuteId),
|
||||
vorgaengeDerAkte(betreuteId),
|
||||
])
|
||||
|
||||
const verknuepfungen = roh.verknuepfungen
|
||||
.map(verknuepfungAnzeigen)
|
||||
.filter((eintrag): eintrag is NonNullable<typeof eintrag> => eintrag !== null)
|
||||
|
||||
const istBild = Boolean(roh.mimeTyp?.startsWith('image/'))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SeitenKopf
|
||||
titel={roh.titel}
|
||||
beschreibung={
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<ArtAbzeichen art={roh.art} />
|
||||
<RichtungAbzeichen richtung={roh.richtung} />
|
||||
<PosteingangAbzeichen status={roh.posteingangStatus} />
|
||||
<OcrAbzeichen status={roh.ocrStatus} />
|
||||
{roh.tags.map((tag) => (
|
||||
<TagAbzeichen key={tag.id} name={tag.name} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
pfad={[{ titel: 'Dokumente', href: '/dokumente' }, { titel: roh.titel }]}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stand im Posteingang</CardTitle>
|
||||
<CardDescription>
|
||||
Wo steht dieses Schreiben gerade? Gesichtet heißt gelesen, zugeordnet heißt: es hängt an
|
||||
einem Vorgang.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PosteingangStufen
|
||||
dokumentId={roh.id}
|
||||
status={roh.posteingangStatus}
|
||||
schreibrecht={schreibrecht}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Angaben zum Dokument</CardTitle>
|
||||
<CardDescription>
|
||||
Was hier steht, findest du später über die Suche wieder.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<MetadatenFormular
|
||||
schreibrecht={schreibrecht}
|
||||
tags={tags}
|
||||
werte={{
|
||||
id: roh.id,
|
||||
titel: roh.titel,
|
||||
art: roh.art,
|
||||
richtung: roh.richtung,
|
||||
schreibenVom: alsFeldwert(roh.schreibenVom),
|
||||
eingangAm: alsFeldwert(roh.eingangAm),
|
||||
versandAm: alsFeldwert(roh.versandAm),
|
||||
absender: roh.absender ?? '',
|
||||
empfaenger: roh.empfaenger ?? '',
|
||||
aktenzeichen: roh.aktenzeichen ?? '',
|
||||
ablageortPapier: roh.ablageortPapier ?? '',
|
||||
wiedervorlageAm: alsFeldwert(roh.wiedervorlageAm),
|
||||
ablaufAm: alsFeldwert(roh.ablaufAm),
|
||||
posteingangStatus: roh.posteingangStatus,
|
||||
notizen: dokument.notizen ?? '',
|
||||
tagIds: roh.tags.map((tag) => tag.id),
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Die Datei</CardTitle>
|
||||
<CardDescription>
|
||||
Wird nur über eine kurzlebige, signierte Adresse ausgeliefert – jeder Abruf steht im
|
||||
Protokoll.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<DateiAktionen
|
||||
dokumentId={roh.id}
|
||||
istBild={istBild}
|
||||
hatDatei={Boolean(roh.objektSchluessel)}
|
||||
schreibrecht={schreibrecht}
|
||||
/>
|
||||
|
||||
{roh.objektSchluessel && (
|
||||
<>
|
||||
<Separator />
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Dateiname</dt>
|
||||
<dd className="break-all font-medium">{roh.dateiname ?? '–'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Größe und Umfang</dt>
|
||||
<dd className="font-medium">
|
||||
{formatBytes(roh.groesseBytes ?? 0)}
|
||||
{roh.seitenzahl ? ` · ${roh.seitenzahl} Seiten` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">In der Akte seit</dt>
|
||||
<dd className="font-medium">{formatDatumZeit(roh.erstelltAm)}</dd>
|
||||
</div>
|
||||
{roh.pruefsumme && (
|
||||
<div>
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Fingerprint className="size-3.5" aria-hidden="true" />
|
||||
Prüfsumme (SHA-256)
|
||||
</dt>
|
||||
<dd className="break-all font-mono text-xs">{roh.pruefsumme}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
|
||||
<FileCheck2 className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
||||
Mit dieser Prüfsumme lässt sich belegen, dass die Datei seit der Aufnahme in die
|
||||
Akte unverändert ist.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{roh.ablageortPapier && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Original in Papier</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-medium">{roh.ablageortPapier}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Dorthin greifen, wenn jemand das Original sehen will.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verknüpfungen</CardTitle>
|
||||
<CardDescription>
|
||||
Wozu gehört dieses Schreiben? Über die Verknüpfung taucht es auch beim Vorgang, bei der
|
||||
Frist oder bei der Buchung auf.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Verknuepfungen
|
||||
dokumentId={roh.id}
|
||||
vorhandene={verknuepfungen}
|
||||
auswahl={ziele}
|
||||
schreibrecht={schreibrecht}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{schreibrecht && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Erkannter Text und Vorschläge</CardTitle>
|
||||
<CardDescription>
|
||||
Aus dem gelesenen Text abgeleitet. Nichts davon ist gespeichert, bis du es übernimmst.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<ErkennungPanel dokumentId={roh.id} vorgaenge={vorgaenge} />
|
||||
|
||||
{roh.ocrText && (
|
||||
<details className="rounded-lg border">
|
||||
<summary className="cursor-pointer px-4 py-3 font-medium">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ScanText className="size-4" aria-hidden="true" />
|
||||
Gelesenen Text anzeigen
|
||||
</span>
|
||||
</summary>
|
||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap border-t px-4 py-3 text-sm">
|
||||
{roh.ocrText}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Date -> "2026-03-12" fuer <input type="date">. */
|
||||
function alsFeldwert(datum: Date | null): string {
|
||||
return datum ? datum.toISOString().slice(0, 10) : ''
|
||||
}
|
||||
Reference in New Issue
Block a user