ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
383 lines
9.6 KiB
TypeScript
383 lines
9.6 KiB
TypeScript
import {
|
|
Document,
|
|
Page,
|
|
Text,
|
|
View,
|
|
StyleSheet,
|
|
} from '@react-pdf/renderer'
|
|
import type { CompanySettings } from '@/types'
|
|
|
|
const styles = StyleSheet.create({
|
|
page: {
|
|
paddingTop: 40,
|
|
paddingHorizontal: 40,
|
|
// Leave room for the fixed disclaimer + footer at the bottom of every page.
|
|
paddingBottom: 120,
|
|
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',
|
|
},
|
|
group: {
|
|
marginBottom: 18,
|
|
},
|
|
groupHeading: {
|
|
fontSize: 12,
|
|
fontWeight: 'bold',
|
|
color: '#1a1a1a',
|
|
marginBottom: 8,
|
|
paddingBottom: 4,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: '#1a1a1a',
|
|
},
|
|
section: {
|
|
marginBottom: 8,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: 10,
|
|
fontWeight: 'bold',
|
|
color: '#444',
|
|
marginBottom: 4,
|
|
marginTop: 6,
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
paddingVertical: 2,
|
|
},
|
|
colAccount: {
|
|
width: 48,
|
|
color: '#666',
|
|
fontFamily: 'Courier',
|
|
},
|
|
colName: {
|
|
flex: 1,
|
|
color: '#1a1a1a',
|
|
paddingRight: 12,
|
|
},
|
|
colAmount: {
|
|
width: 110,
|
|
textAlign: 'right',
|
|
fontFamily: 'Courier',
|
|
color: '#1a1a1a',
|
|
},
|
|
sectionSubtotalRow: {
|
|
flexDirection: 'row',
|
|
paddingVertical: 3,
|
|
marginTop: 2,
|
|
borderTopWidth: 0.5,
|
|
borderTopColor: '#d4d4d4',
|
|
},
|
|
sectionSubtotalLabel: {
|
|
flex: 1,
|
|
fontStyle: 'italic',
|
|
color: '#444',
|
|
paddingLeft: 48,
|
|
},
|
|
sectionSubtotalAmount: {
|
|
width: 110,
|
|
textAlign: 'right',
|
|
fontFamily: 'Courier',
|
|
fontStyle: 'italic',
|
|
color: '#444',
|
|
},
|
|
groupTotalRow: {
|
|
flexDirection: 'row',
|
|
paddingVertical: 6,
|
|
marginTop: 6,
|
|
borderTopWidth: 1,
|
|
borderTopColor: '#1a1a1a',
|
|
},
|
|
groupTotalLabel: {
|
|
flex: 1,
|
|
fontWeight: 'bold',
|
|
fontSize: 11,
|
|
},
|
|
groupTotalAmount: {
|
|
width: 110,
|
|
textAlign: 'right',
|
|
fontFamily: 'Courier',
|
|
fontWeight: 'bold',
|
|
fontSize: 11,
|
|
},
|
|
summaryBlock: {
|
|
marginTop: 20,
|
|
paddingTop: 10,
|
|
borderTopWidth: 2,
|
|
borderTopColor: '#1a1a1a',
|
|
},
|
|
summaryRow: {
|
|
flexDirection: 'row',
|
|
paddingVertical: 4,
|
|
},
|
|
summaryLabel: {
|
|
flex: 1,
|
|
color: '#1a1a1a',
|
|
},
|
|
summaryAmount: {
|
|
width: 110,
|
|
textAlign: 'right',
|
|
fontFamily: 'Courier',
|
|
},
|
|
summaryEmphasisLabel: {
|
|
flex: 1,
|
|
fontWeight: 'bold',
|
|
fontSize: 12,
|
|
},
|
|
summaryEmphasisAmount: {
|
|
width: 110,
|
|
textAlign: 'right',
|
|
fontFamily: 'Courier',
|
|
fontWeight: 'bold',
|
|
fontSize: 12,
|
|
},
|
|
disclaimer: {
|
|
position: 'absolute',
|
|
bottom: 52,
|
|
left: 40,
|
|
right: 40,
|
|
paddingTop: 6,
|
|
paddingBottom: 6,
|
|
paddingHorizontal: 10,
|
|
borderWidth: 0.8,
|
|
borderColor: '#b45309',
|
|
backgroundColor: '#fef3c7',
|
|
borderRadius: 3,
|
|
},
|
|
disclaimerTitle: {
|
|
fontSize: 8,
|
|
fontWeight: 'bold',
|
|
color: '#78350f',
|
|
marginBottom: 2,
|
|
},
|
|
disclaimerText: {
|
|
fontSize: 7.5,
|
|
color: '#78350f',
|
|
lineHeight: 1.3,
|
|
},
|
|
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 {
|
|
return new Intl.NumberFormat('sv-SE', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(amount)
|
|
}
|
|
|
|
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')
|
|
}
|
|
|
|
export interface FinancialStatementSection {
|
|
title: string
|
|
rows: { account_number: string; account_name: string; amount: number }[]
|
|
subtotal: number
|
|
}
|
|
|
|
export interface FinancialStatementGroup {
|
|
heading: string
|
|
sections: FinancialStatementSection[]
|
|
totalLabel: string
|
|
total: number
|
|
negate?: boolean
|
|
}
|
|
|
|
export interface FinancialStatementSummaryRow {
|
|
label: string
|
|
amount: number
|
|
emphasis?: boolean
|
|
}
|
|
|
|
interface FinancialStatementPDFProps {
|
|
title: string
|
|
groups: FinancialStatementGroup[]
|
|
summary?: FinancialStatementSummaryRow[]
|
|
period: { start: string; end: string }
|
|
company: CompanySettings
|
|
generatedAt: string
|
|
}
|
|
|
|
export function FinancialStatementPDF({
|
|
title,
|
|
groups,
|
|
summary,
|
|
period,
|
|
company,
|
|
generatedAt,
|
|
}: FinancialStatementPDFProps) {
|
|
const companyDisplayName = company.company_name || ''
|
|
const periodLabel = period.start && period.end
|
|
? `${formatDateSv(period.start)}: ${formatDateSv(period.end)}`
|
|
: ''
|
|
|
|
return (
|
|
<Document>
|
|
<Page size="A4" style={styles.page}>
|
|
<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>
|
|
)}
|
|
</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>
|
|
|
|
{groups.map((group, gi) => (
|
|
<View key={gi} style={styles.group} wrap>
|
|
<Text style={styles.groupHeading}>{group.heading}</Text>
|
|
|
|
{group.sections.length === 0 ? (
|
|
<Text style={{ fontSize: 9, color: '#888', fontStyle: 'italic' }}>
|
|
Inga poster i perioden.
|
|
</Text>
|
|
) : (
|
|
group.sections.map((section, si) => (
|
|
<View key={si} style={styles.section} wrap={false}>
|
|
<Text style={styles.sectionTitle}>{section.title}</Text>
|
|
{section.rows.map((row, ri) => {
|
|
const displayAmount = group.negate ? -row.amount : row.amount
|
|
return (
|
|
<View key={ri} style={styles.row}>
|
|
<Text style={styles.colAccount}>{row.account_number}</Text>
|
|
<Text style={styles.colName}>{row.account_name}</Text>
|
|
<Text style={styles.colAmount}>{formatAmount(displayAmount)}</Text>
|
|
</View>
|
|
)
|
|
})}
|
|
{section.rows.length > 1 && (
|
|
<View style={styles.sectionSubtotalRow}>
|
|
<Text style={styles.sectionSubtotalLabel}>Summa {section.title.toLowerCase()}</Text>
|
|
<Text style={styles.sectionSubtotalAmount}>
|
|
{formatAmount(group.negate ? -section.subtotal : section.subtotal)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
))
|
|
)}
|
|
|
|
<View style={styles.groupTotalRow}>
|
|
<Text style={styles.groupTotalLabel}>{group.totalLabel}</Text>
|
|
<Text style={styles.groupTotalAmount}>
|
|
{formatAmount(group.negate ? -group.total : group.total)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
|
|
{summary && summary.length > 0 && (
|
|
<View style={styles.summaryBlock} wrap={false}>
|
|
{summary.map((row, i) => (
|
|
<View key={i} style={styles.summaryRow}>
|
|
<Text style={row.emphasis ? styles.summaryEmphasisLabel : styles.summaryLabel}>
|
|
{row.label}
|
|
</Text>
|
|
<Text style={row.emphasis ? styles.summaryEmphasisAmount : styles.summaryAmount}>
|
|
{formatAmount(row.amount)}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.disclaimer} fixed>
|
|
<Text style={styles.disclaimerTitle}>Arbetsutkast: ej undertecknat</Text>
|
|
<Text style={styles.disclaimerText}>
|
|
Detta dokument är ett internt arbetsutkast och utgör inte en godkänd
|
|
årsredovisning enligt ÅRL 2 kap 7 §. Den formella årsredovisningen ska
|
|
undertecknas av samtliga styrelseledamöter och, i förekommande fall, VD
|
|
innan den lämnas in till Bolagsverket.
|
|
</Text>
|
|
</View>
|
|
|
|
<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>
|
|
</Page>
|
|
</Document>
|
|
)
|
|
}
|