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.
259 lines
7.8 KiB
TypeScript
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)
|
|
}
|