Files
AfiS-Aktensystem/web/app/(print)/faelle/[caseNumber]/druck/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

268 lines
9.2 KiB
TypeScript

import type { Metadata } from 'next'
import { db } from '@/lib/db'
import { visibleEntryWhere } from '@/lib/access/caseFilter'
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
import { loadVisibleCase } from '@/lib/cases/service'
import { loadSections } from '@/lib/cases/sectionService'
import { permissionsForPrint } from '@/lib/export/printAccess'
import { isInputField, fillPlaceholders } from '@/lib/templates/fields'
import { de } from '@/lib/i18n/de'
import { NotAvailable } from '../../../NotAvailable'
import type { RenderedSection } from '@/lib/sections/render'
export const dynamic = 'force-dynamic'
export async function generateMetadata({
params,
}: {
params: Promise<{ caseNumber: string }>
}): Promise<Metadata> {
const { caseNumber } = await params
return { title: caseNumber, robots: { index: false, follow: false } }
}
/**
* Druckansicht einer Fallakte.
*
* Sie ist die einzige Vorlage für den PDF-Export: kein zweites Layout von Hand
* in einer PDF-Bibliothek. Der Playwright-Dienst ruft genau diese Seite auf.
*
* **Ein Export darf nie sauberer aussehen als die Akte ist.** Deshalb:
* gesperrte Felder erscheinen als Balken mit Vermerk statt zu fehlen,
* geänderte Einträge sind als geändert gekennzeichnet, zurückgezogene stehen
* als Grabstein mit Begründung. Siehe docs/08-suche-und-export.md.
*/
export default async function PrintCasePage({
params,
searchParams,
}: {
params: Promise<{ caseNumber: string }>
searchParams: Promise<{ token?: string }>
}) {
const { caseNumber } = await params
const { token } = await searchParams
const zugang = await permissionsForPrint(token)
if (!zugang) return <NotAvailable />
const { permissions, displayName } = zugang
const akte = await loadVisibleCase(permissions, caseNumber)
if (!akte) return <NotAvailable />
const [sections, entries, anhaenge, levels, autoren] = await Promise.all([
loadSections(permissions, akte.id, akte.templateId),
db.caseEntry.findMany({
where: { AND: [visibleEntryWhere(permissions), { caseId: akte.id }] },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
select: {
id: true,
type: true,
body: true,
authorId: true,
createdAt: true,
version: true,
editedAt: true,
editVisible: true,
retractedAt: true,
retractionReason: true,
},
}),
db.attachment.findMany({
where: { AND: [visibleAttachmentWhere(permissions), { caseId: akte.id }] },
orderBy: { createdAt: 'asc' },
select: { id: true, originalName: true, sha256: true, sizeBytes: true, scanStatus: true },
}),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
db.user.findMany({ select: { id: true, displayName: true } }),
])
const levelName = new Map(levels.map((l) => [l.level, l.name]))
const nameVon = new Map(autoren.map((a) => [a.id, a.displayName]))
const stand = new Date()
return (
<main className="print-sheet">
<section className="print-cover">
<span className="print-stamp">
{akte.classification} · {levelName.get(akte.classification) ?? de.common.unknown}
</span>
<h1 style={{ marginTop: '4mm' }}>{akte.title}</h1>
<p style={{ fontFamily: 'var(--font-mono, monospace)', margin: 0 }}>{akte.caseNumber}</p>
<table className="print-meta">
<tbody>
<Zeile label={de.case.status} value={de.case.statusNames[akte.status] ?? akte.status} />
<Zeile label={de.case.type} value={akte.templateKey} />
<Zeile
label={de.case.createdBy}
value={nameVon.get(akte.createdById) ?? akte.createdById}
/>
<Zeile
label={de.case.dueAt}
value={akte.dueAt ? akte.dueAt.toLocaleDateString('de-DE') : '-'}
/>
<Zeile label={de.export.retrievedBy} value={displayName} />
<Zeile
label={de.export.asOf}
value={`${stand.toLocaleString('de-DE')} · ${de.clearance.label} ${permissions.clearanceLevel}`}
/>
</tbody>
</table>
<p className="print-note" style={{ marginTop: '4mm' }}>
{de.export.coverNote}
</p>
</section>
<h2>{de.section.heading}</h2>
<PrintSections sections={sections} levelName={levelName} />
<h2>{de.case.timeline}</h2>
{entries.length === 0 ? (
<p className="print-note">{de.entry.empty}</p>
) : (
entries.map((eintrag) => (
<div key={eintrag.id} className="print-entry">
<p className="print-label">
{de.entry.types[eintrag.type] ?? eintrag.type} ·{' '}
{nameVon.get(eintrag.authorId) ?? eintrag.authorId} ·{' '}
{eintrag.createdAt.toLocaleString('de-DE')}
{eintrag.editVisible && eintrag.editedAt
? ` · ${de.export.editedAt(eintrag.version, eintrag.editedAt.toLocaleDateString('de-DE'))}`
: ''}
</p>
{eintrag.retractedAt ? (
<p style={{ margin: '1mm 0', fontStyle: 'italic' }}>
{de.entry.retracted(
eintrag.retractedAt.toLocaleDateString('de-DE'),
nameVon.get(eintrag.authorId) ?? eintrag.authorId,
eintrag.retractionReason ?? '',
)}
</p>
) : (
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>{eintrag.body}</p>
)}
</div>
))
)}
<h2>{de.attachment.heading}</h2>
{anhaenge.length === 0 ? (
<p className="print-note">{de.attachment.empty}</p>
) : (
<table className="print-meta">
<thead>
<tr>
<th>{de.export.fileName}</th>
<th>{de.export.fileSize}</th>
<th>{de.export.fileHash}</th>
</tr>
</thead>
<tbody>
{anhaenge.map((anhang) => (
<tr key={anhang.id}>
<td>{anhang.originalName}</td>
<td>{anhang.sizeBytes}</td>
{/* Der Hash macht nachprüfbar, dass die Datei zum Ausdruck
gehört - deshalb steht er im Verzeichnis. */}
<td style={{ fontFamily: 'monospace', fontSize: '7pt', wordBreak: 'break-all' }}>
{anhang.sha256}
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
)
}
function Zeile({ label, value }: { label: string; value: string }) {
return (
<tr>
<th>{label}</th>
<td>{value}</td>
</tr>
)
}
/**
* Die Abschnitte im Druck. Ein gesperrtes Feld wird als Balken ausgegeben, mit
* dem Vermerk, ab welcher Stufe es lesbar wäre - sonst merkt der Empfänger
* nicht, dass er eine gekürzte Fassung in der Hand hält.
*/
function PrintSections({
sections,
levelName,
}: {
sections: RenderedSection[]
levelName: Map<number, string>
}) {
const werte = Object.fromEntries(
sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
)
return (
<div>
{sections.map((section) => {
const field = section.field
if (!isInputField(field)) {
if (field.type === 'pagebreak') {
return <div key={field.key} style={{ breakAfter: 'page' }} />
}
const text = fillPlaceholders(field.text ?? '', werte)
return field.type === 'heading' ? (
<h3 key={field.key}>{text || field.label}</h3>
) : (
<p key={field.key} style={{ whiteSpace: 'pre-wrap' }}>
{text}
</p>
)
}
return (
<div key={field.key} style={{ marginBottom: '3mm', breakInside: 'avoid' }}>
<p className="print-label">{field.label}</p>
{!section.visible ? (
<p style={{ margin: '1mm 0' }}>
<span className="print-redacted">&nbsp;</span>{' '}
<span className="print-note">
{de.export.redactedAt(
section.value.requiredLevel,
levelName.get(section.value.requiredLevel) ?? '',
)}
</span>
</p>
) : section.segments ? (
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
{section.segments.map((segment, index) =>
segment.kind === 'text' ? (
<span key={index}>{segment.text}</span>
) : (
// Balken statt Auslassung, mit der Stufe daneben: der
// Empfaenger soll sehen, dass hier etwas fehlt.
<span key={index}>
<span
className="print-redacted"
style={{ width: `${segment.blockLength}ch` }}
>
&nbsp;
</span>
<span className="print-note"> [{segment.requiredLevel}]</span>
</span>
),
)}
</p>
) : (
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
{String(section.value ?? '-')}
</p>
)}
</div>
)
})}
</div>
)
}