Files
accounted/lib/reports/financial-statement-pdf-template.tsx
T
MattssonandClaude Opus 4.7 3fa871c742 Bug/accounting suggestion (#456)
* feat: add bike benefit handling and optional vacation accrual

- Introduced bike benefit (cykelförmån) with calculations for annual market value and monthly taxable value.
- Updated schemas to include new benefit types and validation rules.
- Implemented API routes for creating, updating, and deleting employee benefits.
- Enhanced salary calculation logic to accommodate new vacation rule options, including a 'none' option for no accrual.
- Added UI components for managing employee benefits, including input for bike benefit specifics.
- Created database migrations for employee benefits and updated salary line items to support new benefit types.

* chore: remove Langfuse env var checks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: enhance OAuth callback URL handling and update default scopes for Visma integration

* feat: remove trade_name field and simplify company naming in invoices

* refactor: destructure canWrite from useCanWrite for consistency across components

* feat: enhance PATCH endpoint to validate existing benefits and handle bike benefit updates

* feat: add missing label for bike benefit in salary line item types

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:26:04 +02:00

383 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}