Files
accounted/lib/reports/behandlingshistorik-pdf-template.tsx
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* 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>
2026-09-02 11:51:16 +02:00

260 lines
9.9 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 { formatOrgNumber } from '@/lib/utils'
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
import {
BEHANDLINGSHISTORIK_CATEGORIES,
BEHANDLINGSHISTORIK_CATEGORY_LABELS,
type BehandlingshistorikEvent,
type BehandlingshistorikReport,
} from '@/lib/reports/behandlingshistorik-types'
import { formatStockholmTimestamp } from '@/lib/reports/behandlingshistorik'
/**
* Behandlingshistorik as a printable document (BFL 5 kap. 11 §, BFNAR 2013:2
* punkt 9.16). Two sections in the order the law lists them in reverse of how
* a reader scans: the system changes first (short, the context), then the
* bokföringsposter in registreringsordning (long, the body).
*
* Layout rules shared with the other report PDFs: Helvetica/Courier (bundled
* standard fonts, so no Font.register), header + footer + table header
* `fixed` so they repeat on every page, every row `wrap={false}` so a row is
* never split across pages, and no `break` props (they deadlock multi-page
* renders in @react-pdf/renderer 4). Landscape: the details column needs the
* width.
*/
const INK = '#1a1a1a'
const MUTED = '#666'
const HAIRLINE = '#d4d4d4'
const styles = StyleSheet.create({
page: {
paddingTop: 36,
paddingHorizontal: 36,
paddingBottom: 54,
fontSize: 8.5,
fontFamily: 'Helvetica',
color: INK,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 10,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: HAIRLINE,
},
titleBlock: { flex: 1 },
title: { fontSize: 18, fontWeight: 'bold', marginBottom: 3 },
subtitle: { fontSize: 9.5, color: '#333', marginBottom: 2 },
legal: { fontSize: 8, color: MUTED },
companyInfo: { textAlign: 'right' },
companyName: { fontSize: 10, fontWeight: 'bold', marginBottom: 2 },
companyMeta: { fontSize: 8.5, color: MUTED },
meta: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 10,
},
metaItem: { width: '25%', paddingRight: 10, marginBottom: 4 },
metaLabel: { fontSize: 7, color: MUTED, textTransform: 'uppercase', letterSpacing: 0.4 },
metaValue: { fontSize: 9 },
summary: { fontSize: 8.5, color: '#333', marginBottom: 10 },
tableHeader: {
flexDirection: 'row',
paddingVertical: 4,
borderBottomWidth: 0.5,
borderBottomColor: INK,
marginBottom: 2,
},
tableHeaderText: {
fontSize: 7.5,
fontWeight: 'bold',
color: '#444',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
sectionHeading: {
fontSize: 10.5,
fontWeight: 'bold',
marginTop: 12,
marginBottom: 4,
paddingBottom: 3,
borderBottomWidth: 0.5,
borderBottomColor: '#888',
},
sectionNote: { fontSize: 8, color: MUTED, marginBottom: 4 },
row: {
flexDirection: 'row',
paddingVertical: 2.5,
borderBottomWidth: 0.5,
borderBottomColor: '#ececec',
},
// Column widths live in one place so the header and the rows line up; the
// time column gets Courier only in the body (the header stays Helvetica).
hdrTime: { width: 100, paddingRight: 6 },
hdrEvent: { width: 190, paddingRight: 8 },
hdrActor: { width: 140, paddingRight: 8 },
hdrDetails: { flex: 1 },
colTime: { width: 100, fontFamily: 'Courier', fontSize: 7.5, color: MUTED, paddingRight: 6 },
colEvent: { width: 190, paddingRight: 8 },
colActor: { width: 140, paddingRight: 8, color: '#333' },
colDetails: { flex: 1 },
eventLabel: { fontSize: 8.5 },
objectLabel: { fontFamily: 'Courier', fontSize: 7.5, color: MUTED, marginTop: 1 },
detail: { fontSize: 7.5, color: '#444', marginBottom: 1 },
empty: { fontSize: 8.5, color: MUTED, fontStyle: 'italic', paddingVertical: 6 },
footer: {
position: 'absolute',
bottom: 22,
left: 36,
right: 36,
borderTopWidth: 0.5,
borderTopColor: HAIRLINE,
paddingTop: 5,
flexDirection: 'row',
justifyContent: 'space-between',
},
footerText: { fontSize: 7.5, color: '#888' },
})
/**
* The bundled Helvetica/Courier AFM fonts only carry WinAnsi glyphs: an arrow
* or a true minus would be dropped silently. Map them to ASCII for print.
*/
export function pdfText(value: string): string {
return value.replace(/→/g, '->').replace(/−/g, '-')
}
function EventRow({ event }: { event: BehandlingshistorikEvent }) {
return (
<View style={styles.row} wrap={false}>
<Text style={styles.colTime}>{formatStockholmTimestamp(event.occurred_at)}</Text>
<View style={styles.colEvent}>
<Text style={styles.eventLabel}>{pdfText(event.event)}</Text>
{event.object ? <Text style={styles.objectLabel}>{pdfText(event.object)}</Text> : null}
</View>
<Text style={styles.colActor}>{pdfText(event.actor.label)}</Text>
<View style={styles.colDetails}>
{event.details.length > 0 ? (
// One wrapped paragraph rather than one Text per line: half the
// layout nodes (render time) and roughly twice the rows per page.
<Text style={styles.detail}>{pdfText(event.details.join(' · '))}</Text>
) : null}
</View>
</View>
)
}
function Section({ title, note, events }: { title: string; note: string; events: BehandlingshistorikEvent[] }) {
return (
<View>
<Text style={styles.sectionHeading}>{title}</Text>
<Text style={styles.sectionNote}>{note}</Text>
{events.length === 0 ? (
<Text style={styles.empty}>Inga händelser i urvalet.</Text>
) : (
events.map((event) => <EventRow key={event.id} event={event} />)
)}
</View>
)
}
export interface BehandlingshistorikPDFProps {
report: BehandlingshistorikReport
}
export function BehandlingshistorikPDF({ report }: BehandlingshistorikPDFProps) {
const systemEvents = report.events.filter((e) => e.category !== 'verifikation')
const voucherEvents = report.events.filter((e) => e.category === 'verifikation')
const scope =
report.mode === 'fiscal_year'
? 'Hela räkenskapsåret'
: `${report.range.from} till ${report.range.to}`
const categoryFilter =
report.category_filter && report.category_filter.length > 0
? report.category_filter.map((c) => BEHANDLINGSHISTORIK_CATEGORY_LABELS[c]).join(', ')
: null
const summary = BEHANDLINGSHISTORIK_CATEGORIES.filter((c) => report.by_category[c] > 0)
.map((c) => `${BEHANDLINGSHISTORIK_CATEGORY_LABELS[c]} ${report.by_category[c]}`)
.join(' · ')
const generated = formatStockholmTimestamp(report.generated_at)
return (
<Document
title={`Behandlingshistorik ${report.period.name}`}
author={report.company.name}
subject="Behandlingshistorik enligt BFL 5 kap. 11 §"
>
<Page size="A4" orientation="landscape" style={styles.page}>
<View style={styles.header} fixed>
<View style={styles.titleBlock}>
<Text style={styles.title}>Behandlingshistorik</Text>
<Text style={styles.subtitle}>
{report.period.name} ({report.period.start} till {report.period.end}) · Urval: {scope}
{categoryFilter ? ` · Kategori: ${categoryFilter}` : ''}
</Text>
<Text style={styles.legal}>BFL 5 kap. 11 § · BFNAR 2013:2 punkt 9.16 · Tider i Europe/Stockholm</Text>
</View>
<View style={styles.companyInfo}>
{report.company.name ? <Text style={styles.companyName}>{report.company.name}</Text> : null}
{report.company.org_number ? (
<Text style={styles.companyMeta}>Org.nr: {formatOrgNumber(report.company.org_number)}</Text>
) : null}
</View>
</View>
<View style={styles.meta}>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Genererad</Text>
<Text style={styles.metaValue}>{generated}</Text>
</View>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Programversion</Text>
<Text style={styles.metaValue}>{report.app_version ?? 'okänd'}</Text>
</View>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Antal händelser</Text>
<Text style={styles.metaValue}>{String(report.total_events)}</Text>
</View>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Källor</Text>
<Text style={styles.metaValue}>Verifikationer, oföränderlig ändringslogg, rättelselogg, importer</Text>
</View>
</View>
{summary ? <Text style={styles.summary}>{summary}</Text> : null}
<View style={styles.tableHeader} fixed>
<Text style={[styles.tableHeaderText, styles.hdrTime]}>Tidpunkt</Text>
<Text style={[styles.tableHeaderText, styles.hdrEvent]}>Händelse</Text>
<Text style={[styles.tableHeaderText, styles.hdrActor]}>Utförd av</Text>
<Text style={[styles.tableHeaderText, styles.hdrDetails]}>Detaljer</Text>
</View>
<Section
title="Ändringar i bokföringssystemet"
note="Kontoplan, inställningar som styr bokföringen, räkenskapsår, importer och åtkomst, med tidpunkt och utförare (BFNAR 2013:2 punkt 9.16 andra stycket)."
events={systemEvents}
/>
<Section
title="Bokföringsposter i registreringsordning"
note="Varje bokförd verifikation med registreringstidpunkt och utförare, samt makuleringar, rättelser och raderingar (BFNAR 2013:2 punkt 9.16 första stycket)."
events={voucherEvents}
/>
<View style={styles.footer} fixed>
<Text style={styles.footerText}>
{report.company.name}
{report.company.org_number ? ` · ${formatOrgNumber(report.company.org_number)}` : ''}
{' · Behandlingshistorik'}
</Text>
<Text
style={styles.footerText}
render={({ pageNumber, totalPages }) => `Genererad ${generated} · Sida ${pageNumber} av ${totalPages}`}
/>
</View>
</Page>
</Document>
)
}