feat(reports): PDF download for Resultatrapport and Balansrapport (#366)
* 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) <noreply@anthropic.com> * fix(reports): prevent PDF row truncation; align Balansrapport filename Two crucial fixes from the PR review: - Drop wrap={false} from the outer group <View> 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
fd1db89603
commit
74de71f7be
@@ -915,6 +915,17 @@ function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: stri
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/api/reports/resultatrapport/pdf?period_id=${periodId}`, '_blank')}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Ladda ner PDF
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -1042,6 +1053,17 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/api/reports/balansrapport/pdf?period_id=${periodId}`, '_blank')}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Ladda ner PDF
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterBlock({ company, generatedAt }: { company: CompanySettings; generatedAt: string }) {
|
||||
const companyDisplayName = company.trade_name || 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
|
||||
}
|
||||
|
||||
export function ResultatrapportPDF({ report, company, generatedAt }: ResultatrapportPDFProps) {
|
||||
const hasPrior = report.prior_period !== null
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<HeaderBlock title="Resultatrapport" company={company} period={report.period} />
|
||||
|
||||
<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} />
|
||||
|
||||
<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.colAmount]}>Utgående</Text>
|
||||
<Text style={[styles.tableHeaderText, styles.colAmountMuted]}>Förändring</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.colAmount}>{formatAmount(row.ub)}</Text>
|
||||
<Text style={styles.colAmountMuted}>{formatAmount(row.period_change)}</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}>{formatAmount(group.subtotal_ub)}</Text>
|
||||
<Text style={[styles.subtotalAmount, { color: '#666' }]}>
|
||||
{formatAmount(group.subtotal_ub - group.subtotal_ib)}
|
||||
</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>
|
||||
</View>
|
||||
|
||||
<FooterBlock company={company} generatedAt={generatedAt} />
|
||||
</Page>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user