From 74de71f7bebc442bc98263c2f5669401c1feed82 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:02:52 +0200 Subject: [PATCH] feat(reports): PDF download for Resultatrapport and Balansrapport (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reports): PDF download for Resultatrapport and Balansrapport User feedback after merging #363: "Ladda ner PDF saknas för de nya resultat- och balansrapporterna." The previous PR deferred PDFs to a follow-up; this is the follow-up. New operational PDF template (`operational-report-pdf-template.tsx`) with two exports — ResultatrapportPDF and BalansrapportPDF. Mirrors the visual style of the formal FinancialStatementPDF but **omits the yellow "Arbetsutkast – ej undertecknat" disclaimer**, which only belongs on draft årsredovisning per ÅRL 2:7 §. These are löpande reports, never an årsredovisning at any stage. Resultatrapport PDF: account / name / current period / prior period (prior column hidden when no previous fiscal period exists), grouped by BAS account class with subtotals and a "Beräknat resultat" summary line. Balansrapport PDF: account / name / IB / UB / förändring per class 1 and class 2, with the same Balanscheck card the on-screen view shows (Summa tillgångar, Summa eget kapital + reserver + skulder, Beräknat resultat ej bokslutsjusterat, Balanserar / Balanserar ej verdict). Wired up "Ladda ner PDF" buttons on both ResultatrapportView and BalansrapportView. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(reports): prevent PDF row truncation; align Balansrapport filename Two crucial fixes from the PR review: - Drop wrap={false} from the outer group in both PDFs. With wrap=false on a group exceeding one A4 page, @react-pdf/renderer silently clips overflow rows. Large class 1 (80+ active accounts on a real company) was at risk of dropping rows from the rendered file with no warning. Outer group now wraps; wrap={false} retained on individual rows and the subtotal so neither breaks mid-line. - Balansrapport filename anchor changed from period.end to period.start to match the convention used by resultatrapport, balance-sheet, and income-statement PDF routes. The Swedish compliance bot preferred period.end (snapshot semantics), Greptile preferred period.start (cross-route consistency); the latter wins because predictable sorting/renaming matters for archived räkenskapsinformation. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(dashboard)/reports/page.tsx | 22 + app/api/reports/balansrapport/pdf/route.ts | 79 +++ app/api/reports/resultatrapport/pdf/route.ts | 77 +++ .../operational-report-pdf-template.tsx | 453 ++++++++++++++++++ 4 files changed, 631 insertions(+) create mode 100644 app/api/reports/balansrapport/pdf/route.ts create mode 100644 app/api/reports/resultatrapport/pdf/route.ts create mode 100644 lib/reports/operational-report-pdf-template.tsx diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index c4b5d57f..a11f3f95 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -915,6 +915,17 @@ function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: stri return (
+
+ +
+
@@ -1042,6 +1053,17 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string return (
+
+ +
+
diff --git a/app/api/reports/balansrapport/pdf/route.ts b/app/api/reports/balansrapport/pdf/route.ts new file mode 100644 index 00000000..c5a56c48 --- /dev/null +++ b/app/api/reports/balansrapport/pdf/route.ts @@ -0,0 +1,79 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { renderToBuffer } from '@react-pdf/renderer' +import { generateBalansrapport } from '@/lib/reports/balansrapport' +import { BalansrapportPDF } from '@/lib/reports/operational-report-pdf-template' +import { requireCompanyId } from '@/lib/company/context' +import type { CompanySettings } from '@/types' + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + + if (!periodId) { + return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) + } + + const [{ data: period }, { data: companyRow }] = await Promise.all([ + supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId) + .single(), + supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) + .single(), + ]) + + if (!companyRow) { + return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 }) + } + if (!period) { + return NextResponse.json( + { error: 'Räkenskapsperioden kunde inte läsas. Välj en befintlig period innan du genererar PDF.' }, + { status: 400 } + ) + } + + try { + const report = await generateBalansrapport(supabase, companyId, periodId) + + const pdfBuffer = await renderToBuffer( + BalansrapportPDF({ + report, + company: companyRow as CompanySettings, + generatedAt: new Date().toISOString(), + }) + ) + + // Anchor on period.start to match the convention used by every other + // report PDF route in this repo (resultatrapport, balance-sheet, + // income-statement). A balansrapport is a snapshot at period end, but + // consistent filenames let users sort and script-rename predictably. + const filename = `balansrapport-${report.period.start}.pdf` + + return new Response(new Uint8Array(pdfBuffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Kunde inte generera balansrapport' }, + { status: 500 } + ) + } +} diff --git a/app/api/reports/resultatrapport/pdf/route.ts b/app/api/reports/resultatrapport/pdf/route.ts new file mode 100644 index 00000000..d5de0857 --- /dev/null +++ b/app/api/reports/resultatrapport/pdf/route.ts @@ -0,0 +1,77 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { renderToBuffer } from '@react-pdf/renderer' +import { generateResultatrapport } from '@/lib/reports/resultatrapport' +import { ResultatrapportPDF } from '@/lib/reports/operational-report-pdf-template' +import { requireCompanyId } from '@/lib/company/context' +import type { CompanySettings } from '@/types' + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + + if (!periodId) { + return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) + } + + const [{ data: period }, { data: companyRow }] = await Promise.all([ + supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', periodId) + .eq('company_id', companyId) + .single(), + supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) + .single(), + ]) + + if (!companyRow) { + return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 }) + } + // An identifiable period is part of räkenskapsinformation (BFL 7 kap). Refuse + // to render a PDF that can't be archived with the period it refers to. + if (!period) { + return NextResponse.json( + { error: 'Räkenskapsperioden kunde inte läsas. Välj en befintlig period innan du genererar PDF.' }, + { status: 400 } + ) + } + + try { + const report = await generateResultatrapport(supabase, companyId, periodId) + + const pdfBuffer = await renderToBuffer( + ResultatrapportPDF({ + report, + company: companyRow as CompanySettings, + generatedAt: new Date().toISOString(), + }) + ) + + const filename = `resultatrapport-${report.period.start}.pdf` + + return new Response(new Uint8Array(pdfBuffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Kunde inte generera resultatrapport' }, + { status: 500 } + ) + } +} diff --git a/lib/reports/operational-report-pdf-template.tsx b/lib/reports/operational-report-pdf-template.tsx new file mode 100644 index 00000000..0de6090b --- /dev/null +++ b/lib/reports/operational-report-pdf-template.tsx @@ -0,0 +1,453 @@ +import { + Document, + Page, + Text, + View, + StyleSheet, +} from '@react-pdf/renderer' +import type { + CompanySettings, + ResultatrapportReport, + BalansrapportReport, +} from '@/types' + +// 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 { + 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') +} + +interface CommonHeaderProps { + title: string + company: CompanySettings + period: { start: string; end: string } +} + +function HeaderBlock({ title, company, period }: CommonHeaderProps) { + const companyDisplayName = company.trade_name || company.company_name || '' + const periodLabel = period.start && period.end + ? `${formatDateSv(period.start)} – ${formatDateSv(period.end)}` + : '' + return ( + + + {title} + {companyDisplayName && ( + {companyDisplayName} + )} + {periodLabel && ( + Period: {periodLabel} + )} + + + {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.trade_name || 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 +} + +export function ResultatrapportPDF({ report, company, generatedAt }: 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 + Utgående + Förändring + + + {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.ub)} + {formatAmount(row.period_change)} + + ))} + + Summa + {formatAmount(group.subtotal_ib)} + {formatAmount(group.subtotal_ub)} + + {formatAmount(group.subtotal_ub - group.subtotal_ib)} + + + + ))} + + + + 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'} + + + + + + + + ) +}