Files
AfiS-Aktensystem/web/components/documents/Sheet.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

259 lines
7.8 KiB
TypeScript

import { de } from '@/lib/i18n/de'
import {
dossierRows,
dossierSpan,
fillPlaceholders,
isInputField,
type TemplateField,
} from '@/lib/templates/fields'
import { RedactedText } from '@/components/Redacted'
import { FoundationMark } from './FoundationMark'
import type { RenderedSection } from '@/lib/sections/render'
/**
* Das Aktenblatt: ein Formular, kein Bildschirmelement.
*
* Nachgebaut nach dem Blatt, das die Personalabteilung bislang von Hand
* ausgefüllt hat - Raster, Rahmenstärken und Beschriftungen entsprechen ihm.
* Es steht bewusst außerhalb des Design-Systems der Oberfläche, weil es
* gedruckt weitergereicht wird und auf Papier gleich aussehen muss.
*
* Welche Felder wo stehen, kommt aus der Vorlage, nicht von hier. Damit trägt
* dieselbe Bauform später einen Vorfallsbericht oder einen Bußgeldbescheid.
* Siehe docs/13-entscheidungen.md.
*/
export function Sheet({
sections,
title,
subtitle,
documentNumber,
issuedAt,
foundationLevels,
footerNote,
photoUrls,
}: {
sections: RenderedSection[]
title: string
subtitle: string
documentNumber: string
issuedAt: Date
/** Aus FoundationClearance. Steuert keinen Zugriff, nur den Balken. */
foundationLevels: { level: number; name: string; colorHex: string }[]
footerNote: string
/** Nur geprüfte Lichtbilder, je Feldschlüssel. Serverseitig aufgelöst. */
photoUrls?: Record<string, string>
}) {
const fields = sections.map((section) => section.field)
const byKey = new Map(sections.map((section) => [section.fieldKey, section]))
const werte = Object.fromEntries(
sections.filter((s) => s.visible).map((s) => [s.fieldKey, s.value]),
)
const { photo, photoRows, rows } = dossierRows(fields)
const klassifikation = sections.find(
(section) => isInputField(section.field) && section.field.slot === 'classbar',
)
const stufe = klassifikation?.visible ? Number(klassifikation.value) : NaN
const balken = foundationLevels.find((l) => l.level === stufe)
return (
<div className="sheet-scroll">
<article className="sheet">
<div className="sheet-frame">
<header className="sheet-head">
<div>
<h1>{title}</h1>
<h2>{subtitle}</h2>
</div>
<div className="sheet-mark">
<FoundationMark />
<span className="sheet-barcode" aria-hidden />
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: 10, fontWeight: 700 }}>
{de.document.number}: {documentNumber}
</span>
</div>
</header>
<div
className="sheet-classbar"
// Ohne gesetzte Stufe bleibt der Balken neutral dunkel, statt zu
// behaupten, das Blatt sei eingestuft.
style={{ backgroundColor: balken?.colorHex ?? '#333333' }}
>
<span>
{subtitle} - {balken ? `${balken.name} [Level ${balken.level}]` : de.document.unclassified}
</span>
<span>
{de.document.issuedAt}: {formatiereDatum(issuedAt)}
</span>
</div>
<table className="sheet-grid">
<tbody>
{rows.map((row, rowIndex) => {
const inPhotoRows = rowIndex < photoRows
return (
<tr key={rowIndex}>
{photo && rowIndex === 0 ? (
<td rowSpan={photoRows} style={{ width: 150, textAlign: 'center' }}>
<div className="sheet-label" style={{ textAlign: 'center' }}>
{photo.label}
</div>
<PhotoBox
section={byKey.get(photo.key)}
url={photoUrls?.[photo.key] ?? null}
/>
</td>
) : null}
{row.map((field) => (
<Cell
key={field.key}
field={field}
section={byKey.get(field.key)}
dateSection={
isInputField(field) && field.dateKey
? byKey.get(field.dateKey)
: undefined
}
span={dossierSpan(field, inPhotoRows)}
werte={werte}
/>
))}
</tr>
)
})}
</tbody>
</table>
<footer className="sheet-foot">
<span>{footerNote}</span>
<span>{documentNumber}</span>
</footer>
</div>
</article>
</div>
)
}
function PhotoBox({
section,
url,
}: {
section: RenderedSection | undefined
url: string | null
}) {
if (section && !section.visible) {
return (
<div className="sheet-photo">
<span>{de.section.redacted}</span>
</div>
)
}
// Der Wert eines Lichtbildfeldes ist die Kennung eines geprüften Anhangs,
// nie das Bild selbst. Die Adresse dazu ist serverseitig aufgelöst worden,
// und nur für einen Anhang, der die Prüfkette bestanden hat.
return (
<div className="sheet-photo">
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={section?.field.label ?? ''} />
) : (
<span>{de.document.photoEmpty}</span>
)}
</div>
)
}
function Cell({
field,
section,
dateSection,
span,
werte,
}: {
field: TemplateField
section: RenderedSection | undefined
dateSection: RenderedSection | undefined
span: number
werte: Record<string, unknown>
}) {
if (!isInputField(field)) {
const text = fillPlaceholders(field.text ?? '', werte)
return (
<td colSpan={span}>
{field.type === 'heading' ? (
<div className="sheet-label">{text || field.label}</div>
) : (
<div className="sheet-value" style={{ whiteSpace: 'pre-wrap' }}>
{text}
</div>
)}
</td>
)
}
// Serverseitig ersetzt: der echte Wert ist gar nicht erst hier.
if (section && !section.visible) {
return (
<td colSpan={span}>
<div className="sheet-label">{field.label}</div>
<div className="sheet-value">
<span className="redaction inline-block h-[1.1rem] w-40" aria-hidden />
</div>
</td>
)
}
if (field.type === 'signature') {
return (
<td colSpan={span} style={{ paddingBottom: 20 }}>
<div className="sheet-label">{fillPlaceholders(field.label, werte)}</div>
<div className="sheet-signature">{asText(section?.value)}</div>
{field.dateKey ? (
<div className="sheet-label" style={{ marginTop: 5 }}>
{de.document.signedOn}: {asText(dateSection?.value) || '-'}
</div>
) : null}
</td>
)
}
const lang = field.type === 'longtext'
return (
<td colSpan={span}>
<div className="sheet-label">{field.label}</div>
<div
className={`sheet-value${field.emphasis ? ' sheet-value-emphasis' : ''}`}
style={lang ? { minHeight: 220, whiteSpace: 'pre-wrap' } : undefined}
>
{section?.segments ? (
<RedactedText segments={section.segments} />
) : (
asText(section?.value) || '---'
)}
</div>
</td>
)
}
/**
* Zweistellig, wie auf dem Formular: `14.03.2026`, nicht `14.3.2026`. Die
* deutsche Voreinstellung kürzt, ein Formular tut das nicht.
*/
function formatiereDatum(value: Date): string {
return value.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
function asText(value: unknown): string {
if (value === null || value === undefined) return ''
if (Array.isArray(value)) return value.join(', ')
return String(value)
}