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
Bisher gab es ueberhaupt keinen Weg, einen Anhang wieder loszuwerden. Wer die falsche Datei hochlud, musste damit leben. Das ist keine Strenge, sondern eine Luecke: die Regel des Hauses lautet nicht "nichts geht wieder weg", sondern "nichts geht unbemerkt weg". Also derselbe Weg wie beim Verlaufseintrag und beim Dokument. Zurueckziehen mit Pflichtbegruendung, der Anhang bleibt als Grabstein an seiner Stelle stehen - durchgestrichen, mit Zeitpunkt und Grund, fuer jeden sichtbar, der ihn sehen darf. Aufheben laesst es sich, beides steht im Protokoll. Aus dem Umlauf heisst wirklich aus dem Umlauf: die Auslieferung verweigert die Datei, sie verschwindet aus Lichtbildfeldern, und das Vorschaubild faellt weg. Ein Grabstein, unter dem der Download weiterlaeuft, waere eine Beschriftung und keine Ruecknahme. Fuer `audit.read` bleibt sie abrufbar - wie der Text eines zurueckgezogenen Eintrags, sonst waere es doch ein Loeschen. Im Ausdruck steht der Anhang weiter im Verzeichnis, mit dem Grund daneben. Ihn dort wegzulassen hiesse, den Ausdruck sauberer zu machen als die Akte. Die Datei auf der Platte bleibt liegen. Sie zu entfernen waere das Loeschen, das hier gerade nicht stattfindet - und ein Anhang, dessen Pruefsumme nicht mehr nachrechenbar ist, waere als Beweismittel wertlos. Dazu eine Regel in der Datenbank, die DELETE auf Attachment verbietet, und eine Bedingung, die einen Grabstein ohne Begruendung ausschliesst. Zuruecknehmen darf, wer die Datei hochgeladen hat - der haeufige Fall, und ohne das bliebe jeder Fehlgriff liegen, weil dafuer die Leitung zu rufen waere - sowie jeder, der am Besitzer schreiben darf.
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import { db } from '@/lib/db'
|
|
import { visibleEntryWhere } from '@/lib/access/caseFilter'
|
|
import { visibleAttachmentWhere } from '@/lib/access/attachmentFilter'
|
|
import { loadSections } from '@/lib/cases/sectionService'
|
|
import { isInputField, fillPlaceholders } from '@/lib/templates/fields'
|
|
import { de } from '@/lib/i18n/de'
|
|
import type { RenderedSection } from '@/lib/sections/render'
|
|
import type { EffectivePermissions } from '@/lib/auth/permissions'
|
|
|
|
/**
|
|
* Der Druckbogen einer Fallakte, ohne die Seite drumherum.
|
|
*
|
|
* Ausgelagert, weil er an drei Stellen gebraucht wird: in der Druckansicht der
|
|
* Akte selbst, im Dossier einer Person und im Bogen einer Abteilung. Stünde
|
|
* das Layout dort jeweils noch einmal, liefe es auseinander - und ein Export,
|
|
* der je nach Weg anders aussieht, ist keiner.
|
|
*
|
|
* **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 type CaseForPrint = {
|
|
id: string
|
|
caseNumber: string
|
|
title: string
|
|
status: string
|
|
templateId: string
|
|
templateKey: string
|
|
classification: number
|
|
createdById: string
|
|
dueAt: Date | null
|
|
}
|
|
|
|
export async function CaseSheet({
|
|
permissions,
|
|
displayName,
|
|
akte,
|
|
/** Im Dossier steht der Deckel schon vorn; dann nur der Inhalt. */
|
|
withCover = true,
|
|
authors,
|
|
}: {
|
|
permissions: EffectivePermissions
|
|
displayName: string
|
|
akte: CaseForPrint
|
|
withCover?: boolean
|
|
/** Einmal geladen und durchgereicht, statt je Akte erneut. */
|
|
authors?: Map<string, string>
|
|
}) {
|
|
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,
|
|
retractedAt: true,
|
|
retractionReason: true,
|
|
},
|
|
}),
|
|
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
|
authors ? [] : db.user.findMany({ select: { id: true, displayName: true } }),
|
|
])
|
|
|
|
const levelName = new Map(levels.map((l) => [l.level, l.name]))
|
|
const nameVon = authors ?? new Map(autoren.map((a) => [a.id, a.displayName]))
|
|
const stand = new Date()
|
|
|
|
return (
|
|
<>
|
|
{withCover ? (
|
|
<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>
|
|
) : (
|
|
<header className="print-runin">
|
|
<span className="print-stamp">
|
|
{akte.classification} · {levelName.get(akte.classification) ?? de.common.unknown}
|
|
</span>
|
|
<h2 style={{ margin: '2mm 0 0' }}>{akte.title}</h2>
|
|
<p className="print-note" style={{ margin: 0 }}>
|
|
{akte.caseNumber} · {de.case.statusNames[akte.status] ?? akte.status}
|
|
</p>
|
|
</header>
|
|
)}
|
|
|
|
<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}>
|
|
{/* Ein zurückgezogener Anhang bleibt im Verzeichnis stehen,
|
|
mit Grund. Ihn wegzulassen hiesse, den Ausdruck sauberer zu
|
|
machen als die Akte - siehe docs/08. */}
|
|
<td>
|
|
{anhang.originalName}
|
|
{anhang.retractedAt ? (
|
|
<span className="print-note">
|
|
{' '}
|
|
{de.attachment.retractedBy(
|
|
anhang.retractedAt.toLocaleDateString('de-DE'),
|
|
anhang.retractionReason ?? '',
|
|
)}
|
|
</span>
|
|
) : null}
|
|
</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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
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"> </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` }}
|
|
>
|
|
|
|
</span>
|
|
<span className="print-note"> [{segment.requiredLevel}]</span>
|
|
</span>
|
|
),
|
|
)}
|
|
</p>
|
|
) : (
|
|
<p style={{ margin: '1mm 0', whiteSpace: 'pre-wrap' }}>
|
|
{String(section.value ?? '-')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|