import { Document, Page, Text, View, StyleSheet, } from '@react-pdf/renderer' import type { CompanySettings, LatestVoucherPerSeries, ResultatrapportReport, BalansrapportReport, } from '@/types' import { formatLatestVouchers, LATEST_VOUCHERS_LABEL } from './latest-vouchers' // Operational reports (Resultatrapport / Balansrapport) are löpande // bookkeeping documents, not draft årsredovisning per ÅRL 2:7 §, so this // template intentionally omits the yellow "Arbetsutkast" disclaimer that // FinancialStatementPDF carries. const styles = StyleSheet.create({ page: { paddingTop: 40, paddingHorizontal: 40, paddingBottom: 60, fontSize: 10, fontFamily: 'Helvetica', }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24, paddingBottom: 14, borderBottomWidth: 1, borderBottomColor: '#d4d4d4', }, titleBlock: { flex: 1, }, title: { fontSize: 20, fontWeight: 'bold', color: '#1a1a1a', marginBottom: 4, }, subtitle: { fontSize: 11, color: '#333', marginBottom: 2, }, period: { fontSize: 10, color: '#666', }, companyInfo: { textAlign: 'right', }, companyName: { fontSize: 11, fontWeight: 'bold', marginBottom: 2, }, companyMeta: { fontSize: 9, color: '#666', }, tableHeader: { flexDirection: 'row', paddingVertical: 4, borderBottomWidth: 0.5, borderBottomColor: '#1a1a1a', marginBottom: 2, }, tableHeaderText: { fontSize: 9, fontWeight: 'bold', color: '#444', textTransform: 'uppercase', letterSpacing: 0.5, }, groupHeading: { fontSize: 11, fontWeight: 'bold', color: '#1a1a1a', marginTop: 12, marginBottom: 4, paddingBottom: 3, borderBottomWidth: 0.5, borderBottomColor: '#888', }, row: { flexDirection: 'row', paddingVertical: 2, }, subtotalRow: { flexDirection: 'row', paddingVertical: 4, marginTop: 2, borderTopWidth: 0.5, borderTopColor: '#888', marginBottom: 4, }, colAccount: { width: 48, color: '#666', fontFamily: 'Courier', }, colName: { flex: 1, color: '#1a1a1a', paddingRight: 12, }, colAmount: { width: 90, textAlign: 'right', fontFamily: 'Courier', color: '#1a1a1a', }, colAmountMuted: { width: 90, textAlign: 'right', fontFamily: 'Courier', color: '#666', }, subtotalLabel: { flex: 1, fontStyle: 'italic', color: '#444', paddingLeft: 48, }, subtotalAmount: { width: 90, textAlign: 'right', fontFamily: 'Courier', fontStyle: 'italic', color: '#444', }, summary: { marginTop: 18, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#1a1a1a', }, summaryRow: { flexDirection: 'row', paddingVertical: 3, }, summaryLabel: { flex: 1, color: '#1a1a1a', }, summaryEmphasis: { flex: 1, fontWeight: 'bold', fontSize: 11, color: '#1a1a1a', }, summaryAmount: { width: 90, textAlign: 'right', fontFamily: 'Courier', color: '#1a1a1a', }, summaryAmountMuted: { width: 90, textAlign: 'right', fontFamily: 'Courier', color: '#666', }, summaryAmountEmphasis: { width: 90, textAlign: 'right', fontFamily: 'Courier', fontWeight: 'bold', fontSize: 11, }, balanceVerdict: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 10, marginTop: 8, borderRadius: 3, }, balanceVerdictOk: { backgroundColor: '#ecfdf5', borderWidth: 0.5, borderColor: '#059669', }, balanceVerdictBad: { backgroundColor: '#fef2f2', borderWidth: 0.5, borderColor: '#dc2626', }, balanceVerdictLabel: { fontWeight: 'bold', fontSize: 11, color: '#1a1a1a', }, balanceVerdictOkBadge: { fontWeight: 'bold', fontSize: 10, color: '#065f46', }, balanceVerdictBadBadge: { fontWeight: 'bold', fontSize: 10, color: '#991b1b', }, footer: { position: 'absolute', bottom: 24, left: 40, right: 40, borderTopWidth: 0.5, borderTopColor: '#d4d4d4', paddingTop: 6, flexDirection: 'row', justifyContent: 'space-between', }, footerText: { fontSize: 8, color: '#888', }, }) function formatAmount(amount: number): string { // Intl emits U+2212 (true minus). The bundled @react-pdf/renderer // Helvetica/Courier fonts lack that glyph and silently drop it, so // negatives would render as positives. Map to ASCII hyphen. return new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2, }) .format(amount) .replace(/\u2212/g, '-') } function formatOrgNumber(orgNumber: string): string { const cleaned = orgNumber.replace(/\D/g, '') if (cleaned.length === 10) { return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}` } return orgNumber } function formatDateSv(iso: string): string { if (!iso) return '' return new Date(iso).toLocaleDateString('sv-SE') } interface CommonHeaderProps { title: string company: CompanySettings period: { start: string; end: string } /** Partial-view disclosure (dimension-filtered exports, BFNAR 2013:2). */ filterNote?: string /** Highest posted voucher per series in the window. Reconciliation aid (#1267). */ latestVouchers?: LatestVoucherPerSeries[] } function HeaderBlock({ title, company, period, filterNote, latestVouchers }: CommonHeaderProps) { const companyDisplayName = company.company_name || '' const periodLabel = period.start && period.end ? `${formatDateSv(period.start)}: ${formatDateSv(period.end)}` : '' const vouchersLabel = formatLatestVouchers(latestVouchers) return ( {title} {companyDisplayName && ( {companyDisplayName} )} {periodLabel && ( Period: {periodLabel} )} {vouchersLabel && ( {LATEST_VOUCHERS_LABEL}: {vouchersLabel} )} {filterNote && ( {filterNote} )} {company.company_name && ( {company.company_name} )} {company.org_number && ( Org.nr: {formatOrgNumber(company.org_number)} )} {company.vat_number && ( VAT: {company.vat_number} )} ) } function FooterBlock({ company, generatedAt }: { company: CompanySettings; generatedAt: string }) { const companyDisplayName = company.company_name || '' return ( {companyDisplayName} {company.org_number ? ` · ${formatOrgNumber(company.org_number)}` : ''} `Genererad ${formatDateSv(generatedAt)} · Sida ${pageNumber} av ${totalPages}`} /> ) } interface ResultatrapportPDFProps { report: ResultatrapportReport company: CompanySettings generatedAt: string /** Partial-view disclosure line (dimension-filtered exports). */ filterNote?: string } export function ResultatrapportPDF({ report, company, generatedAt, filterNote }: ResultatrapportPDFProps) { const hasPrior = report.prior_period !== null return ( Konto Kontonamn Innevarande {hasPrior && ( Föregående )} {report.groups.map((group) => ( // No wrap={false} on the outer View: large account classes (80+ // active accounts) would otherwise be silently clipped instead of // flowing onto the next page. {group.class_label} {group.rows.map((row) => ( {row.account_number} {row.account_name} {formatAmount(row.current_period)} {hasPrior && ( {formatAmount(row.prior_period)} )} ))} Summa {formatAmount(group.subtotal_current)} {hasPrior && ( {formatAmount(group.subtotal_prior)} )} ))} Beräknat resultat {formatAmount(report.net_result_current)} {hasPrior && ( {formatAmount(report.net_result_prior)} )} ) } interface BalansrapportPDFProps { report: BalansrapportReport company: CompanySettings generatedAt: string } export function BalansrapportPDF({ report, company, generatedAt }: BalansrapportPDFProps) { return ( Konto Kontonamn Ingående Förändring Utgående {report.groups.map((group) => ( // See ResultatrapportPDF: outer View must wrap so large classes // flow across pages instead of being clipped. {group.class_label} {group.rows.map((row) => ( {row.account_number} {row.account_name} {formatAmount(row.ib)} {formatAmount(row.period_change)} {formatAmount(row.ub)} ))} Summa {formatAmount(group.subtotal_ib)} {formatAmount(group.subtotal_ub - group.subtotal_ib)} {formatAmount(group.subtotal_ub)} ))} Summa tillgångar {formatAmount(report.total_assets_ub)} Summa eget kapital, reserver, avsättningar och skulder {formatAmount(report.total_equity_liabilities_ub)} Beräknat resultat (ej bokslutsjusterat) {formatAmount(report.beraknat_resultat)} Balanscheck {report.is_balanced ? 'Balanserar' : 'Balanserar ej'} {!report.is_balanced && report.imbalance_diagnosis && ( {report.imbalance_diagnosis.message} )} ) }