* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
import { formatOrgNumber } from '@/lib/utils'
|
|
import {
|
|
Document,
|
|
Page,
|
|
Text,
|
|
View,
|
|
StyleSheet,
|
|
} from '@react-pdf/renderer'
|
|
import { pdfNumberText, formatDateSv } from '@/lib/pdf/number-text'
|
|
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 {
|
|
// pdfNumberText: Intl emits U+2212 (true minus), which the bundled
|
|
// Helvetica/Courier fonts lack, so negatives would render as positives.
|
|
return pdfNumberText(
|
|
new Intl.NumberFormat('sv-SE', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(amount),
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<View style={styles.header} fixed>
|
|
<View style={styles.titleBlock}>
|
|
<Text style={styles.title}>{title}</Text>
|
|
{companyDisplayName && (
|
|
<Text style={styles.subtitle}>{companyDisplayName}</Text>
|
|
)}
|
|
{periodLabel && (
|
|
<Text style={styles.period}>Period: {periodLabel}</Text>
|
|
)}
|
|
{vouchersLabel && (
|
|
<Text style={styles.period}>{LATEST_VOUCHERS_LABEL}: {vouchersLabel}</Text>
|
|
)}
|
|
{filterNote && (
|
|
<Text style={styles.period}>{filterNote}</Text>
|
|
)}
|
|
</View>
|
|
<View style={styles.companyInfo}>
|
|
{company.company_name && (
|
|
<Text style={styles.companyName}>{company.company_name}</Text>
|
|
)}
|
|
{company.org_number && (
|
|
<Text style={styles.companyMeta}>
|
|
Org.nr: {formatOrgNumber(company.org_number)}
|
|
</Text>
|
|
)}
|
|
{company.vat_number && (
|
|
<Text style={styles.companyMeta}>VAT: {company.vat_number}</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function FooterBlock({ company, generatedAt }: { company: CompanySettings; generatedAt: string }) {
|
|
const companyDisplayName = company.company_name || ''
|
|
return (
|
|
<View style={styles.footer} fixed>
|
|
<Text style={styles.footerText}>
|
|
{companyDisplayName}
|
|
{company.org_number ? ` · ${formatOrgNumber(company.org_number)}` : ''}
|
|
</Text>
|
|
<Text
|
|
style={styles.footerText}
|
|
render={({ pageNumber, totalPages }) => `Genererad ${formatDateSv(generatedAt)} · Sida ${pageNumber} av ${totalPages}`}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Document>
|
|
<Page size="A4" style={styles.page}>
|
|
<HeaderBlock
|
|
title="Resultatrapport"
|
|
company={company}
|
|
period={report.period}
|
|
filterNote={filterNote}
|
|
latestVouchers={report.latest_vouchers}
|
|
/>
|
|
|
|
<View style={styles.tableHeader}>
|
|
<Text style={[styles.tableHeaderText, styles.colAccount]}>Konto</Text>
|
|
<Text style={[styles.tableHeaderText, styles.colName]}>Kontonamn</Text>
|
|
<Text style={[styles.tableHeaderText, styles.colAmount]}>Innevarande</Text>
|
|
{hasPrior && (
|
|
<Text style={[styles.tableHeaderText, styles.colAmountMuted]}>Föregående</Text>
|
|
)}
|
|
</View>
|
|
|
|
{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.
|
|
<View key={group.class}>
|
|
<Text style={styles.groupHeading}>{group.class_label}</Text>
|
|
{group.rows.map((row) => (
|
|
<View key={row.account_number} style={styles.row} wrap={false}>
|
|
<Text style={styles.colAccount}>{row.account_number}</Text>
|
|
<Text style={styles.colName}>{row.account_name}</Text>
|
|
<Text style={styles.colAmount}>{formatAmount(row.current_period)}</Text>
|
|
{hasPrior && (
|
|
<Text style={styles.colAmountMuted}>{formatAmount(row.prior_period)}</Text>
|
|
)}
|
|
</View>
|
|
))}
|
|
<View style={styles.subtotalRow} wrap={false}>
|
|
<Text style={styles.subtotalLabel}>Summa</Text>
|
|
<Text style={styles.subtotalAmount}>{formatAmount(group.subtotal_current)}</Text>
|
|
{hasPrior && (
|
|
<Text style={[styles.subtotalAmount, { color: '#666' }]}>
|
|
{formatAmount(group.subtotal_prior)}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
))}
|
|
|
|
<View style={styles.summary} wrap={false}>
|
|
<View style={styles.summaryRow}>
|
|
<Text style={styles.summaryEmphasis}>Beräknat resultat</Text>
|
|
<Text style={styles.summaryAmountEmphasis}>
|
|
{formatAmount(report.net_result_current)}
|
|
</Text>
|
|
{hasPrior && (
|
|
<Text style={[styles.summaryAmountEmphasis, { color: '#666' }]}>
|
|
{formatAmount(report.net_result_prior)}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
<FooterBlock company={company} generatedAt={generatedAt} />
|
|
</Page>
|
|
</Document>
|
|
)
|
|
}
|
|
|
|
interface BalansrapportPDFProps {
|
|
report: BalansrapportReport
|
|
company: CompanySettings
|
|
generatedAt: string
|
|
}
|
|
|
|
export function BalansrapportPDF({ report, company, generatedAt }: BalansrapportPDFProps) {
|
|
return (
|
|
<Document>
|
|
<Page size="A4" style={styles.page}>
|
|
<HeaderBlock
|
|
title="Balansrapport"
|
|
company={company}
|
|
period={report.period}
|
|
latestVouchers={report.latest_vouchers}
|
|
/>
|
|
|
|
<View style={styles.tableHeader}>
|
|
<Text style={[styles.tableHeaderText, styles.colAccount]}>Konto</Text>
|
|
<Text style={[styles.tableHeaderText, styles.colName]}>Kontonamn</Text>
|
|
<Text style={[styles.tableHeaderText, styles.colAmountMuted]}>Ingående</Text>
|
|
<Text style={[styles.tableHeaderText, styles.colAmountMuted]}>Förändring</Text>
|
|
<Text style={[styles.tableHeaderText, styles.colAmount]}>Utgående</Text>
|
|
</View>
|
|
|
|
{report.groups.map((group) => (
|
|
// See ResultatrapportPDF: outer View must wrap so large classes
|
|
// flow across pages instead of being clipped.
|
|
<View key={group.class}>
|
|
<Text style={styles.groupHeading}>{group.class_label}</Text>
|
|
{group.rows.map((row) => (
|
|
<View key={row.account_number} style={styles.row} wrap={false}>
|
|
<Text style={styles.colAccount}>{row.account_number}</Text>
|
|
<Text style={styles.colName}>{row.account_name}</Text>
|
|
<Text style={styles.colAmountMuted}>{formatAmount(row.ib)}</Text>
|
|
<Text style={styles.colAmountMuted}>{formatAmount(row.period_change)}</Text>
|
|
<Text style={styles.colAmount}>{formatAmount(row.ub)}</Text>
|
|
</View>
|
|
))}
|
|
<View style={styles.subtotalRow} wrap={false}>
|
|
<Text style={styles.subtotalLabel}>Summa</Text>
|
|
<Text style={[styles.subtotalAmount, { color: '#666' }]}>{formatAmount(group.subtotal_ib)}</Text>
|
|
<Text style={[styles.subtotalAmount, { color: '#666' }]}>
|
|
{formatAmount(group.subtotal_ub - group.subtotal_ib)}
|
|
</Text>
|
|
<Text style={styles.subtotalAmount}>{formatAmount(group.subtotal_ub)}</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
|
|
<View style={styles.summary} wrap={false}>
|
|
<View style={styles.summaryRow}>
|
|
<Text style={styles.summaryLabel}>Summa tillgångar</Text>
|
|
<Text style={styles.summaryAmount}>{formatAmount(report.total_assets_ub)}</Text>
|
|
</View>
|
|
<View style={styles.summaryRow}>
|
|
<Text style={styles.summaryLabel}>Summa eget kapital, reserver, avsättningar och skulder</Text>
|
|
<Text style={styles.summaryAmount}>{formatAmount(report.total_equity_liabilities_ub)}</Text>
|
|
</View>
|
|
<View style={styles.summaryRow}>
|
|
<Text style={styles.summaryLabel}>Beräknat resultat (ej bokslutsjusterat)</Text>
|
|
<Text style={styles.summaryAmount}>{formatAmount(report.beraknat_resultat)}</Text>
|
|
</View>
|
|
<View
|
|
style={[
|
|
styles.balanceVerdict,
|
|
report.is_balanced ? styles.balanceVerdictOk : styles.balanceVerdictBad,
|
|
]}
|
|
>
|
|
<Text style={styles.balanceVerdictLabel}>Balanscheck</Text>
|
|
<Text
|
|
style={
|
|
report.is_balanced
|
|
? styles.balanceVerdictOkBadge
|
|
: styles.balanceVerdictBadBadge
|
|
}
|
|
>
|
|
{report.is_balanced ? 'Balanserar' : 'Balanserar ej'}
|
|
</Text>
|
|
</View>
|
|
{!report.is_balanced && report.imbalance_diagnosis && (
|
|
<Text style={{ fontSize: 8, color: '#666', marginTop: 4 }}>
|
|
{report.imbalance_diagnosis.message}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
<FooterBlock company={company} generatedAt={generatedAt} />
|
|
</Page>
|
|
</Document>
|
|
)
|
|
}
|