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}
)}
)
}