import { formatOrgNumber } from '@/lib/utils' import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer' import type { BilagaAccount, BilagaChecklistItem, BokslutsbilagorReport } from '@/lib/reports/bokslutsbilagor-types' import { formatStockholmTimestamp } from '@/lib/reports/behandlingshistorik' /** * Bokslutsbilagor as a printable pärm: the checklist first, then one bilaga * per balance account with the balances, what it was reconciled against, the * sign-off and the underlag files with their hashes. Same layout rules as the * other report PDFs: bundled Helvetica/Courier (no Font.register), header and * footer `fixed`, every row `wrap={false}`, and no `break` props (they * deadlock multi-page renders in @react-pdf/renderer 4). */ const INK = '#1a1a1a' const MUTED = '#666' const HAIRLINE = '#d4d4d4' const styles = StyleSheet.create({ page: { paddingTop: 36, paddingHorizontal: 40, paddingBottom: 54, fontSize: 8.5, fontFamily: 'Helvetica', color: INK }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 10, paddingBottom: 10, borderBottomWidth: 1, borderBottomColor: HAIRLINE, }, titleBlock: { flex: 1 }, title: { fontSize: 18, fontWeight: 'bold', marginBottom: 3 }, subtitle: { fontSize: 9.5, color: '#333', marginBottom: 2 }, legal: { fontSize: 8, color: MUTED }, companyInfo: { textAlign: 'right' }, companyName: { fontSize: 10, fontWeight: 'bold', marginBottom: 2 }, companyMeta: { fontSize: 8.5, color: MUTED }, meta: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 8 }, metaItem: { width: '25%', paddingRight: 10, marginBottom: 4 }, metaLabel: { fontSize: 7, color: MUTED, textTransform: 'uppercase', letterSpacing: 0.4 }, metaValue: { fontSize: 9 }, sectionHeading: { fontSize: 10.5, fontWeight: 'bold', marginTop: 12, marginBottom: 4, paddingBottom: 3, borderBottomWidth: 0.5, borderBottomColor: '#888' }, sectionNote: { fontSize: 8, color: MUTED, marginBottom: 4 }, row: { flexDirection: 'row', paddingVertical: 2.5, borderBottomWidth: 0.5, borderBottomColor: '#ececec' }, checkState: { width: 70, fontFamily: 'Courier', fontSize: 7.5 }, checkLabel: { flex: 1, paddingRight: 8 }, checkWho: { width: 150, fontSize: 7.5, color: MUTED }, bilaga: { marginTop: 8, paddingTop: 6, borderTopWidth: 0.5, borderTopColor: '#bbb' }, bilagaHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }, bilagaTitle: { fontSize: 9.5, fontWeight: 'bold' }, bilagaKey: { fontFamily: 'Courier', fontSize: 7.5, color: MUTED }, numbers: { flexDirection: 'row', marginBottom: 3 }, numCell: { width: '20%', paddingRight: 8 }, numLabel: { fontSize: 6.8, color: MUTED, textTransform: 'uppercase', letterSpacing: 0.3 }, numValue: { fontFamily: 'Courier', fontSize: 8.5 }, line: { fontSize: 8, color: '#333', marginBottom: 1.5 }, lineMuted: { fontSize: 7.5, color: MUTED, marginBottom: 1.5 }, fileRow: { flexDirection: 'row', paddingVertical: 1.5 }, fileName: { flex: 1, fontSize: 7.8, paddingRight: 6 }, fileMeta: { width: 200, fontFamily: 'Courier', fontSize: 6.8, color: MUTED }, empty: { fontSize: 8.5, color: MUTED, fontStyle: 'italic', paddingVertical: 6 }, footer: { position: 'absolute', bottom: 22, left: 40, right: 40, borderTopWidth: 0.5, borderTopColor: HAIRLINE, paddingTop: 5, flexDirection: 'row', justifyContent: 'space-between' }, footerText: { fontSize: 7.5, color: '#888' }, }) /** WinAnsi only: arrows, true minus and narrow spaces would drop silently. */ function pdfText(value: string): string { return value.replace(/→/g, '->').replace(/−/g, '-').replace(/[   ]/g, ' ') } const NUMBER = new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) function amount(n: number | null): string { return n == null ? '-' : pdfText(NUMBER.format(n)) } const STATE_LABEL: Record = { done: '[x] Klart', not_applicable: '[-] Ej tillämpl.', open: '[ ] Öppet', } const GROUP_LABEL: Record = { avstamning: 'Avstämningar', periodisering: 'Periodiseringar', vardering: 'Värdering', dispositioner: 'Dispositioner och skatt', kontroll: 'Kontroller', rapportering: 'Rapportering', } function ChecklistRow({ item }: { item: BilagaChecklistItem }) { const who = item.done_at ? `${item.done_by_label ?? ''} ${formatStockholmTimestamp(item.done_at)}`.trim() : '' return ( {STATE_LABEL[item.state]} {pdfText(item.label_sv)} {item.note ? ` (${pdfText(item.note)})` : ''} {pdfText(who)} ) } function Bilaga({ account, balansdag }: { account: BilagaAccount; balansdag: string }) { const s = account.signoff const signLine = !s ? 'Ej signerad.' : s.on_balansdag ? `Signerad per ${s.through_date} av ${s.signed_by_label} (${formatStockholmTimestamp(s.signed_at)})${s.note ? `: ${s.note}` : ''}` : `Senast signerad t.o.m. ${s.through_date} av ${s.signed_by_label} (${formatStockholmTimestamp(s.signed_at)}), inte per balansdagen ${balansdag}${s.note ? `: ${s.note}` : ''}` const active = account.attachments.filter((a) => !a.removed_at) const removed = account.attachments.filter((a) => a.removed_at) return ( {account.account_number} {pdfText(account.name)} {account.account_key} Ingående balans {amount(account.opening_balance)} Förändring {amount(account.movement)} Utgående balans {amount(account.closing_balance)} Enligt underlag {amount(account.external_balance)} Differens {amount(account.difference)} Underlag: {pdfText(account.external_label_sv)} {pdfText(signLine)} {active.length === 0 ? ( Inga bifogade filer. ) : ( active.map((a) => ( {pdfText(a.file_name)} {a.note ? ` (${pdfText(a.note)})` : ''} {a.through_date !== balansdag ? ` per ${a.through_date}` : ''} sha256 {a.sha256.slice(0, 16)} · {formatStockholmTimestamp(a.uploaded_at)} )) )} {removed.map((a) => ( Borttagen: {pdfText(a.file_name)} {a.removed_reason ? ` (${pdfText(a.removed_reason)})` : ''} {a.removed_at ? formatStockholmTimestamp(a.removed_at) : ''} ))} ) } export interface BokslutsbilagorPDFProps { report: BokslutsbilagorReport } export function BokslutsbilagorPDF({ report }: BokslutsbilagorPDFProps) { const generated = formatStockholmTimestamp(report.generated_at) const groups = [...new Set(report.checklist.items.map((i) => i.group))] return ( Bokslutsbilagor {report.period.name} ({report.period.start} till {report.period.end}) · Balansdag {report.period.end} Avstämning och dokumentation per balanspost · Tider i Europe/Stockholm {report.company.name ? {pdfText(report.company.name)} : null} {report.company.org_number ? Org.nr: {formatOrgNumber(report.company.org_number)} : null} Genererad {generated} Programversion {report.app_version ?? 'okänd'} Balanskonton {String(report.summary.accounts)} · signerade per balansdagen {String(report.summary.signed_on_balansdag)} · annan dag{' '} {String(report.summary.signed_other_date)} · osignerade {String(report.summary.unsigned)} Bifogade filer {String(report.summary.attachments)} Bokslutschecklista {report.checklist.summary.done} klara, {report.checklist.summary.not_applicable} ej tillämpliga, {report.checklist.summary.open} öppna av{' '} {report.checklist.summary.total}. Steg som systemet bedömer själv visas som de stod när pärmen genererades. {groups.map((group) => ( {GROUP_LABEL[group] ?? group} {report.checklist.items.filter((i) => i.group === group).map((item) => )} ))} Bilagor per balanskonto Bokfört enligt saldobalansen per balansdagen. Enligt underlag: reskontra eller beräkning där systemet har en, annars det saldo som angavs eller hämtades vid signeringen. Filer identifieras med SHA-256; borttagna filer finns kvar i arkivet. {report.accounts.length === 0 ? ( Inga balanskonton med saldo eller rörelse i perioden. ) : ( report.accounts.map((account) => ) )} {pdfText(report.company.name)} {report.company.org_number ? ` · ${formatOrgNumber(report.company.org_number)}` : ''} {' · Bokslutsbilagor '} {report.period.name} `Genererad ${generated} · Sida ${pageNumber} av ${totalPages}`} /> ) }