'use client' // Report view components, extracted verbatim from app/(dashboard)/reports/page.tsx. // Rendered by the focused /reports/[slug] route (see components/reports/FocusedReport.tsx). // The regulated table/figure rendering is unchanged from the original monolith. import React, { useState, useEffect, useRef, useCallback } from 'react' import Link from 'next/link' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react' import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import { formatDate } from '@/lib/utils' import { roundOre } from '@/lib/money' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { AccountNumber } from '@/components/ui/account-number' import { ReportExportMenu } from '@/components/reports/ReportExportMenu' import { useCompanySettings } from '@/components/settings/useSettings' import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart' import { VatCompositionChart } from '@/components/reports/VatCompositionChart' import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel' import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart' import { useReportRowExpansion } from '@/components/reports/ReportRowExpansion' import type { ReportSourceLine, ReportSourceFetcher, } from '@/lib/reports/source-lines' import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart' import type { DateRangeValue } from '@/components/common/ReportDateRange' import type { TrialBalanceRow, IncomeStatementReport, BalanceSheetReport, ResultatrapportReport, BalansrapportReport, VatDeclaration, VatPeriodType, } from '@/types' function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } function reportQuery(periodId: string, range?: DateRangeValue): string { const params = new URLSearchParams({ period_id: periodId }) if (range?.fromDate) params.set('from_date', range.fromDate) if (range?.toDate) params.set('to_date', range.toDate) return params.toString() } export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) { const [data, setData] = useState<{ rows: TrialBalanceRow[] totalDebit: number totalCredit: number isBalanced: boolean } | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [viewMode, setViewMode] = useState<'simplified' | 'detailed'>('simplified') useEffect(() => { setLoading(true) setError(null) fetch(`/api/reports/trial-balance?period_id=${periodId}`) .then((res) => res.json()) .then((result) => { if (result.error) { setError(result.error) } else { setData(result.data) } setLoading(false) }) .catch(() => { setError('Kunde inte hämta saldobalans') setLoading(false) }) }, [periodId]) if (loading) { return ( Laddar saldobalans... ) } if (error) { return ( {error} ) } if (!data || data.rows.length === 0) { return ( Inga bokförda verifikationer i denna period. ) } function getNetBalance(row: TrialBalanceRow, type: 'opening' | 'period' | 'closing'): number { let debit: number, credit: number if (type === 'opening') { debit = row.opening_debit; credit = row.opening_credit } else if (type === 'period') { debit = row.period_debit; credit = row.period_credit } else { debit = row.closing_debit; credit = row.closing_credit } // Credit-normal accounts (liabilities/equity class 2, revenue class 3): positive when credit > debit // Debit-normal accounts (assets class 1, expenses class 4-9): positive when debit > credit const creditNormal = row.account_class === 2 || row.account_class === 3 return roundOre(creditNormal ? credit - debit : debit - credit) } function formatSigned(amount: number): string { if (amount === 0) return '' return amount < 0 ? `−${formatAmount(Math.abs(amount))}` : formatAmount(amount) } return (
Saldobalans
{data.isBalanced ? ( Balanserad ) : ( Ej balanserad )}
{viewMode === 'simplified' ? ( {data.rows.map((row) => ( ))}
Konto Namn Ingående saldo Förändring Utgående saldo
) : ( {data.rows.map((row) => ( ))}
Konto Namn Period debet Period kredit Saldo debet Saldo kredit
Summa {formatAmount(data.rows.reduce((s, r) => s + r.period_debit, 0))} {formatAmount(data.rows.reduce((s, r) => s + r.period_credit, 0))} {formatAmount(data.totalDebit)} {formatAmount(data.totalCredit)}
)}
) } // Lazy fetcher for a TB account's source lines. Memoised at the row level so // repeated toggling never refetches. function makeTrialBalanceFetcher(accountNumber: string, periodId: string): ReportSourceFetcher { return async () => { const res = await fetch( `/api/reports/trial-balance/account/${encodeURIComponent(accountNumber)}/sources?fiscal_period_id=${encodeURIComponent(periodId)}` ) const json = await res.json() if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat') const lines: ReportSourceLine[] = json.data?.lines || [] return { lines, next_cursor: json.data?.next_cursor ?? null } } } function TrialBalanceSimplifiedRow({ row, periodId, onNavigateToAccount, getNetBalance, formatSigned, }: { row: TrialBalanceRow periodId: string onNavigateToAccount: (account: string) => void getNetBalance: (row: TrialBalanceRow, type: 'opening' | 'period' | 'closing') => number formatSigned: (amount: number) => string }) { const fetcher = React.useMemo( () => makeTrialBalanceFetcher(row.account_number, periodId), [row.account_number, periodId] ) const { Toggle, Panel } = useReportRowExpansion(fetcher, `tb-${row.account_number}`) const ob = getNetBalance(row, 'opening') const ch = getNetBalance(row, 'period') const cb = getNetBalance(row, 'closing') return ( <> e.stopPropagation()}> onNavigateToAccount(row.account_number)} > onNavigateToAccount(row.account_number)} > {row.account_name} {formatSigned(ob)} {formatSigned(ch)} {formatSigned(cb)} ) } function TrialBalanceDetailedRow({ row, periodId, onNavigateToAccount, }: { row: TrialBalanceRow periodId: string onNavigateToAccount: (account: string) => void }) { const fetcher = React.useMemo( () => makeTrialBalanceFetcher(row.account_number, periodId), [row.account_number, periodId] ) const { Toggle, Panel } = useReportRowExpansion(fetcher, `tb-det-${row.account_number}`) return ( <> e.stopPropagation()}> onNavigateToAccount(row.account_number)} > onNavigateToAccount(row.account_number)} > {row.account_name} {row.period_debit > 0 ? formatAmount(row.period_debit) : ''} {row.period_credit > 0 ? formatAmount(row.period_credit) : ''} {row.closing_debit > 0 ? formatAmount(row.closing_debit) : ''} {row.closing_credit > 0 ? formatAmount(row.closing_credit) : ''} ) } export function IncomeStatementView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { const [data, setData] = useState(null) const [monthlyData, setMonthlyData] = useState([]) const [monthlyLoading, setMonthlyLoading] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const reportQs = reportQuery(periodId, dateRange) useEffect(() => { setLoading(true) setError(null) setMonthlyLoading(true) fetch(`/api/reports/income-statement?${reportQs}`) .then((res) => res.json()) .then((result) => { if (result.error) { setError(result.error) } else { setData(result.data) } setLoading(false) }) .catch(() => { setError('Kunde inte hämta resultaträkning') setLoading(false) }) // Monthly breakdown is full-period by design (it IS the per-month view), // so the date range only affects the headline numbers above the chart. fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`) .then((res) => res.json()) .then((result) => { if (result.data?.months) { setMonthlyData(result.data.months) } setMonthlyLoading(false) }) .catch(() => { setMonthlyLoading(false) }) }, [periodId, reportQs]) if (loading) { return ( Laddar resultaträkning... ) } if (error) { return ( {error} ) } if (!data) { return ( Ingen data för denna period. ) } return (
{!monthlyLoading && monthlyData.length > 0 && ( )} {/* Revenue */} Rörelseintäkter
Summa rörelseintäkter {formatAmount(data.total_revenue)} kr
{/* Expenses */} Rörelsekostnader
Summa rörelsekostnader -{formatAmount(data.total_expenses)} kr
{/* Operating result */}
Rörelseresultat = 0 ? 'text-success' : 'text-destructive'}> {formatAmount(data.total_revenue - data.total_expenses)} kr
{/* Financial items */} {data.financial_sections.length > 0 && ( Finansiella poster
Summa finansiella poster {formatAmount(data.total_financial)} kr
)} {/* Net result */}
Årets resultat = 0 ? 'text-success' : 'text-destructive'}> {formatAmount(data.net_result)} kr
) } export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const reportQs = reportQuery(periodId, dateRange) useEffect(() => { setLoading(true) setError(null) fetch(`/api/reports/balance-sheet?${reportQs}`) .then((res) => res.json()) .then((result) => { if (result.error) { setError(result.error) } else { setData(result.data) } setLoading(false) }) .catch(() => { setError('Kunde inte hämta balansräkning') setLoading(false) }) }, [periodId, reportQs]) if (loading) { return ( Laddar balansräkning... ) } if (error) { return ( {error} ) } if (!data) { return ( Ingen data för denna period. ) } const isBalanced = Math.abs(data.total_assets - data.total_equity_liabilities) < 0.01 return (
{/* Assets */} Tillgångar
Summa tillgångar {formatAmount(data.total_assets)} kr
{/* Equity and liabilities */} Eget kapital och skulder
Summa eget kapital och skulder {formatAmount(data.total_equity_liabilities)} kr
{/* Balance check */}
Balanscheck {isBalanced ? ( Balanserar ) : (
Balanserar ej

Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr

)}
) } export function ResultatrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const reportQs = reportQuery(periodId, dateRange) useEffect(() => { setLoading(true) setError(null) fetch(`/api/reports/resultatrapport?${reportQs}`) .then((res) => res.json()) .then((result) => { if (result.error) { setError(result.error) } else { setData(result.data) } setLoading(false) }) .catch(() => { setError('Kunde inte hämta resultatrapport') setLoading(false) }) }, [periodId, reportQs]) if (loading) { return ( Laddar resultatrapport... ) } if (error) { return ( {error} ) } if (!data || data.groups.length === 0) { return ( Inga bokförda intäkter eller kostnader i denna period. ) } const hasPrior = data.prior_period !== null const colCount = 4 return (
{data.groups.map((group) => ( {group.rows.map((row) => ( onNavigateToAccount(row.account_number)} > ))} ))}
Konto Kontonamn Innevarande Föregående
{group.class_label}
{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 = 0 ? 'text-success' : 'text-destructive'}`}> {formatAmount(data.net_result_current)} kr {hasPrior ? `${formatAmount(data.net_result_prior)} kr` : '—'}
) } export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const reportQs = reportQuery(periodId, dateRange) useEffect(() => { setLoading(true) setError(null) fetch(`/api/reports/balansrapport?${reportQs}`) .then((res) => res.json()) .then((result) => { if (result.error) { setError(result.error) } else { setData(result.data) } setLoading(false) }) .catch(() => { setError('Kunde inte hämta balansrapport') setLoading(false) }) }, [periodId, reportQs]) if (loading) { return ( Laddar balansrapport... ) } if (error) { return ( {error} ) } if (!data || data.groups.length === 0) { return ( Inga balansposter i denna period. ) } return (
{data.groups.map((group) => ( {group.rows.map((row) => ( onNavigateToAccount(row.account_number)} > ))} ))}
Konto Kontonamn Ingående balans Förändring Utgående balans
{group.class_label}
{row.account_name} {formatAmount(row.ib)} {formatAmount(row.period_change)} {formatAmount(row.ub)}
Summa {formatAmount(group.subtotal_ib)} {formatAmount(group.subtotal_ub - group.subtotal_ib)} {formatAmount(group.subtotal_ub)}
Summa tillgångar {formatAmount(data.total_assets_ub)} kr
Summa eget kapital, reserver, avsättningar och skulder {formatAmount(data.total_equity_liabilities_ub)} kr
Beräknat resultat (ej bokslutsjusterat) {formatAmount(data.beraknat_resultat)} kr
Balanscheck {data.is_balanced ? ( Balanserar ) : ( Balanserar ej )}
) } function ReportSectionTable({ sections, negate, onNavigateToAccount, }: { sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[] negate?: boolean onNavigateToAccount?: (account: string) => void }) { if (sections.length === 0) { return

Inga poster.

} return (
{sections.map((section) => (

{section.title}

{section.rows.map((row) => ( onNavigateToAccount(row.account_number) : undefined} > ))}
{row.account_name} {negate ? `-${formatAmount(row.amount)}` : formatAmount(row.amount)} kr
{section.title} {negate ? `-${formatAmount(section.subtotal)}` : formatAmount(section.subtotal)} kr
))}
) } // Carries the selected fiscal period into the ruta drill-down rows so their // source-verifikat query matches the report's period. Only set for yearly // (räkenskapsår); undefined for monthly/quarterly (calendar periods). const VatDrillContext = React.createContext<{ fiscalPeriodId?: string }>({}) export function VatDeclarationView({ fiscalPeriodId, fiscalPeriodBounds, }: { fiscalPeriodId?: string fiscalPeriodBounds?: { start: string; end: string } | null } = {}) { const currentYear = new Date().getFullYear() const currentMonth = new Date().getMonth() + 1 const currentQuarter = Math.ceil(currentMonth / 3) const [periodType, setPeriodType] = useState('quarterly') const [year, setYear] = useState(currentYear) const [period, setPeriod] = useState(currentQuarter) const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) // Default the periodicity to the company's configured VAT reporting period // (moms_period in Inställningar) so the picker mirrors the setting instead of // always starting on quarterly. Applied once per company the first time its // settings load; a later manual change to the picker is preserved, and a // company switch re-applies the new company's setting. `useCompanySettings` // only refetches when the active company changes, so this never clobbers a // manual selection mid-session. const { settings } = useCompanySettings() const appliedForCompany = useRef(null) useEffect(() => { const momsPeriod = settings?.moms_period const companyId = settings?.company_id if (!momsPeriod || !companyId) return if (appliedForCompany.current === companyId) return appliedForCompany.current = companyId setPeriodType(momsPeriod) // `period` is reset to a sensible value by the periodType effect below. }, [settings]) // Generate year options (last 5 years) const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i) // Generate period options based on type const getPeriodOptions = () => { switch (periodType) { case 'monthly': return [ { value: 1, label: 'Januari' }, { value: 2, label: 'Februari' }, { value: 3, label: 'Mars' }, { value: 4, label: 'April' }, { value: 5, label: 'Maj' }, { value: 6, label: 'Juni' }, { value: 7, label: 'Juli' }, { value: 8, label: 'Augusti' }, { value: 9, label: 'September' }, { value: 10, label: 'Oktober' }, { value: 11, label: 'November' }, { value: 12, label: 'December' }, ] case 'quarterly': return [ { value: 1, label: 'Kvartal 1 (jan-mar)' }, { value: 2, label: 'Kvartal 2 (apr-jun)' }, { value: 3, label: 'Kvartal 3 (jul-sep)' }, { value: 4, label: 'Kvartal 4 (okt-dec)' }, ] case 'yearly': return [{ value: 1, label: 'Helår' }] default: return [] } } // Reset period when type changes useEffect(() => { if (periodType === 'monthly') { setPeriod(currentMonth) } else if (periodType === 'quarterly') { setPeriod(currentQuarter) } else { setPeriod(1) } }, [periodType, currentMonth, currentQuarter]) // Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar year. // For yearly we pass the selected fiscal period so the API uses its actual // bounds (handles extended/shortened years); monthly/quarterly stay calendar. const isYearly = periodType === 'yearly' const vatQueryString = () => { const params = new URLSearchParams({ periodType, year: String(year), period: String(period), }) if (isYearly && fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId) return params.toString() } const fetchDeclaration = async () => { setLoading(true) setError(null) try { const res = await fetch( `/api/reports/vat-declaration?${vatQueryString()}` ) const result = await res.json() if (result.error) { setError(result.error) } else { setData(result.data) } } catch { setError('Kunde inte hämta momsdeklaration') } finally { setLoading(false) } } return (
{/* Period selection */} Välj period
{isYearly ? ( // Annual VAT covers the selected räkenskapsår — driven by the // fiscal-year picker on the report page, not a calendar year.
{fiscalPeriodBounds ? `${formatDate(fiscalPeriodBounds.start)} – ${formatDate(fiscalPeriodBounds.end)}` : '—'}
) : ( <>
)}
{error && ( {error} )} {data && ( <> {/* Summary */}
Momsdeklaration - {data.period.start} till {data.period.end} 0 ? 'warning' : data.rutor.ruta49 < 0 ? 'success' : 'secondary' } > {data.rutor.ruta49 > 0 ? `Att betala: ${formatAmount(data.rutor.ruta49)} kr` : data.rutor.ruta49 < 0 ? `Att återfå: ${formatAmount(Math.abs(data.rutor.ruta49))} kr` : 'Ingen moms'}
Baserat på {data.invoiceCount} fakturor och {data.transactionCount} transaktioner
{/* Utgående moms */}

Utgående moms (försäljning)

{data.rutor.ruta05 > 0 && ( )}
Summa utgående {formatAmount( data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12 + data.rutor.ruta30 + data.rutor.ruta31 + data.rutor.ruta32 )} kr
{/* Omvänd skattskyldighet (inköp) */} {(data.rutor.ruta20 > 0 || data.rutor.ruta21 > 0 || data.rutor.ruta22 > 0 || data.rutor.ruta23 > 0 || data.rutor.ruta24 > 0 || data.rutor.ruta30 > 0 || data.rutor.ruta31 > 0 || data.rutor.ruta32 > 0) && ( <>

Omvänd skattskyldighet (inköp)

)}
{/* Ingående moms */}

Ingående moms (avdragsgill)

{data.breakdown.transactions.ruta48 > 0 && ( )} {data.breakdown.receipts.ruta48 > 0 && ( )}
- från transaktioner {formatAmount(data.breakdown.transactions.ruta48)} kr
- från kvitton {formatAmount(data.breakdown.receipts.ruta48)} kr
Summa ingående {formatAmount(data.rutor.ruta48)} kr
{/* Net result */}
49 {data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'}
0 ? 'text-warning' : data.rutor.ruta49 < 0 ? 'text-success' : '' }`} > {formatAmount(Math.abs(data.rutor.ruta49))} kr
)} {/* Skatteverket integration panel */} {!data && !loading && !error && ( Välj period och klicka "Hämta" för att se momsdeklaration. )}
) } function makeVatFetcher( ruta: string, periodType: VatPeriodType, year: number, period: number, fiscalPeriodId?: string, ): ReportSourceFetcher { return async () => { const params = new URLSearchParams({ periodType, year: String(year), period: String(period), }) // Yearly drill-down resolves against the räkenskapsår, matching the report. if (periodType === 'yearly' && fiscalPeriodId) { params.set('fiscal_period_id', fiscalPeriodId) } const res = await fetch( `/api/reports/vat-declaration/ruta/${encodeURIComponent(ruta)}/sources?${params.toString()}` ) const json = await res.json() if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat') const lines: ReportSourceLine[] = json.data?.lines || [] return { lines, next_cursor: json.data?.next_cursor ?? null } } } function VatRutaRow({ ruta, label, amount, baseAmount, noVat, periodType, year, period, }: { ruta: string label: string amount: number baseAmount: number noVat?: boolean periodType?: VatPeriodType year?: number period?: number }) { const { fiscalPeriodId } = React.useContext(VatDrillContext) const canDrill = periodType !== undefined && year !== undefined && period !== undefined const fetcher = React.useMemo( () => (canDrill ? makeVatFetcher(ruta, periodType!, year!, period!, fiscalPeriodId) : null), [canDrill, ruta, periodType, year, period, fiscalPeriodId] ) // Hooks must be called unconditionally — provide a noop fetcher when drill // is disabled. The early-return for zero rows lives below the hooks. const expansion = useReportRowExpansion( fetcher ?? (async () => ({ lines: [], next_cursor: null })), `vat-${ruta}` ) // Don't show rows with zero values if (baseAmount === 0 && amount === 0) return null return ( <> {canDrill && ( )} {ruta} {label} {noVat ? `${formatAmount(baseAmount)} kr` : `${formatAmount(amount)} kr`} {!noVat && baseAmount > 0 && ( Underlag {formatAmount(baseAmount)} kr )} {canDrill && } ) } interface SupplierLedgerData { ledger: { entries: { supplier_id: string supplier_name: string current: number days_1_30: number days_31_60: number days_61_90: number days_90_plus: number total_outstanding: number }[] total_outstanding: number total_current: number total_overdue: number unpaid_count: number unconverted_fx_count: number } reconciliation: { supplier_ledger_total: number account_2440_balance: number difference: number is_reconciled: boolean unconverted_fx_count: number } | null } export function SupplierLedgerView({ periodId }: { periodId: string }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const fetchData = async () => { setLoading(true) setError(null) try { const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}`) const result = await res.json() if (result.error) { setError(result.error) } else { setData(result.data) } } catch { setError('Kunde inte hämta leverantörsreskontra') } finally { setLoading(false) } } useEffect(() => { if (periodId) fetchData() }, [periodId]) if (loading) { return ( Laddar leverantörsreskontra... ) } if (error) { return ( {error} ) } if (!data || !data.ledger) { return ( Ingen data tillgänglig. ) } const { ledger, reconciliation } = data return (
{/* Summary cards */}
Totalt utestående

{formatAmount(ledger.total_outstanding)} kr

{ledger.unpaid_count} fakturor

{ledger.unconverted_fx_count > 0 && (

{ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen.

)}
Ej förfallet

{formatAmount(ledger.total_current)} kr

Förfallet

{formatAmount(ledger.total_overdue)} kr

{/* Aging table */} {ledger.entries.length > 0 && ( Ålderfördelning per leverantör
{ledger.entries.map((entry) => ( ))}
Leverantör Ej förfallet 1-30 dagar 31-60 dagar 61-90 dagar 90+ dagar Totalt
Summa {formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))} {formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))} {formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))} {formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))} {formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))} {formatAmount(ledger.total_outstanding)}
)} {/* Reconciliation */} {reconciliation && ( Avstämning mot
Leverantörsreskontra (summa utestående) {formatAmount(reconciliation.supplier_ledger_total)} kr
saldo (huvudbok) {formatAmount(reconciliation.account_2440_balance)} kr
Differens {formatAmount(reconciliation.difference)} kr
{reconciliation.is_reconciled ? ( Avstämd ) : ( Ej avstämd - kontrollera bokföring )} {reconciliation.unconverted_fx_count > 0 && (

{reconciliation.unconverted_fx_count} leverantörsfaktura i utländsk valuta saknar växelkurs — differensen kan bero på saknade kursuppgifter snarare än felbokning.

)}
)}
) } function makeSupplierFetcher(supplierId: string): ReportSourceFetcher { return async () => { const res = await fetch( `/api/reports/supplier-ledger/supplier/${encodeURIComponent(supplierId)}/invoices` ) const json = await res.json() if (!res.ok) throw new Error(json.error || 'Kunde inte hämta leverantörsfakturor') const lines: ReportSourceLine[] = json.data?.lines || [] return { lines, next_cursor: json.data?.next_cursor ?? null } } } function SupplierLedgerRow({ entry, }: { entry: { supplier_id: string supplier_name: string current: number days_1_30: number days_31_60: number days_61_90: number days_90_plus: number total_outstanding: number } }) { const fetcher = React.useMemo( () => makeSupplierFetcher(entry.supplier_id), [entry.supplier_id] ) const { Toggle, Panel } = useReportRowExpansion(fetcher, `sup-${entry.supplier_id}`) return ( <> {entry.supplier_name} {entry.current > 0 ? formatAmount(entry.current) : ''} {entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''} {entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''} {entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''} {entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''} {formatAmount(entry.total_outstanding)} ) } // --- General Ledger (Huvudbok) --- interface GeneralLedgerData { accounts: { account_number: string account_name: string opening_balance: number lines: { date: string voucher_series: string voucher_number: number journal_entry_id: string description: string source_type: string debit: number credit: number balance: number }[] closing_balance: number total_debit: number total_credit: number }[] period: { start: string; end: string } } export function GeneralLedgerView({ periodId, initialAccountFilter }: { periodId: string; initialAccountFilter: string | null }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [accountFrom, setAccountFrom] = useState('') const [accountTo, setAccountTo] = useState('') const fetchData = useCallback(async (fromOverride?: string, toOverride?: string) => { const from = fromOverride ?? accountFrom const to = toOverride ?? accountTo setLoading(true) setError(null) try { const params = new URLSearchParams({ period_id: periodId }) if (from) params.set('account_from', from) if (to) params.set('account_to', to) const res = await fetch(`/api/reports/general-ledger?${params}`) const result = await res.json() if (result.error) { setError(result.error) } else { setData(result.data) } } catch { setError('Kunde inte hämta huvudbok') } finally { setLoading(false) } }, [periodId, accountFrom, accountTo]) // When initialAccountFilter changes (drill-down from another report), apply it useEffect(() => { if (initialAccountFilter) { setAccountFrom(initialAccountFilter) setAccountTo(initialAccountFilter) fetchData(initialAccountFilter, initialAccountFilter) } else { fetchData() } }, [periodId, initialAccountFilter]) if (loading) { return ( Laddar huvudbok... ) } if (error) { return ( {error} ) } if (!data || data.accounts.length === 0) { return ( Inga bokförda verifikationer i denna period. ) } return (
{/* Account range filter */}
setAccountFrom(e.target.value)} placeholder="t.ex. 1510" className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm" />
setAccountTo(e.target.value)} placeholder="t.ex. 1519" className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm" />
{data.period.start && (

Period: {data.period.start} — {data.period.end} | {data.accounts.length} konton

)} {data.accounts.map((account) => (
IB: {formatAmount(account.opening_balance)} kr
{account.lines.map((line, i) => ( ))}
Ver.nr Datum Beskrivning Debet Kredit Saldo
{formatVoucher(line)} {line.date} {line.description} {line.debit > 0 ? formatAmount(line.debit) : ''} {line.credit > 0 ? formatAmount(line.credit) : ''} {formatAmount(line.balance)}
Summa / Utgående balans {formatAmount(account.total_debit)} {formatAmount(account.total_credit)} {formatAmount(account.closing_balance)}
))}
) } // --- Journal Register (Grundbok) --- interface JournalRegisterData { entries: { voucher_series: string voucher_number: number date: string description: string source_type: string status: string lines: { account_number: string account_name: string debit: number credit: number }[] total_debit: number total_credit: number }[] total_entries: number total_debit: number total_credit: number period: { start: string; end: string } } export function JournalRegisterView({ periodId }: { periodId: string }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [expandedEntries, setExpandedEntries] = useState>(new Set()) const fetchData = async () => { setLoading(true) setError(null) setExpandedEntries(new Set()) try { const res = await fetch(`/api/reports/journal-register?period_id=${periodId}`) const result = await res.json() if (result.error) { setError(result.error) } else { setData(result.data) } } catch { setError('Kunde inte hämta grundbok') } finally { setLoading(false) } } useEffect(() => { if (periodId) fetchData() }, [periodId]) const toggleEntry = (index: number) => { setExpandedEntries((prev) => { const next = new Set(prev) if (next.has(index)) { next.delete(index) } else { next.add(index) } return next }) } if (loading) { return ( Laddar grundbok... ) } if (error) { return ( {error} ) } if (!data || data.entries.length === 0) { return ( Inga bokförda verifikationer i denna period. ) } return (
{data.period.start && (

Period: {data.period.start} — {data.period.end} | {data.total_entries} verifikationer

)} Grundbok (registreringsordning)
{data.entries.map((entry, index) => { const isExpanded = expandedEntries.has(index) const isReversed = entry.status === 'reversed' return ( toggleEntry(index)} > {isExpanded && entry.lines.map((line, lineIndex) => ( ))} ) })}
Ver.nr Datum Beskrivning Typ Debet Kredit
{isExpanded ? ( ) : ( )} {formatVoucher(entry)} {entry.date} {entry.description} {isReversed && ( Makulerad )} {entry.source_type} {formatAmount(entry.total_debit)} {formatAmount(entry.total_credit)}
{line.account_name} {line.debit > 0 ? formatAmount(line.debit) : ''} {line.credit > 0 ? formatAmount(line.credit) : ''}
Summa {formatAmount(data.total_debit)} {formatAmount(data.total_credit)}
) } // --- AR Ledger (Kundreskontra) --- interface ARLedgerData { ledger: { entries: { customer_id: string customer_name: string invoices: { invoice_id: string invoice_number: string invoice_date: string due_date: string total: number paid_amount: number outstanding: number outstanding_sek: number | null days_overdue: number currency: string }[] current: number days_1_30: number days_31_60: number days_61_90: number days_90_plus: number total_outstanding: number }[] total_outstanding: number total_current: number total_overdue: number unpaid_count: number unconverted_fx_count: number } reconciliation: { ar_ledger_total: number account_1510_balance: number difference: number is_reconciled: boolean unconverted_fx_count: number } | null } // Inner expansion row component for AR ledger. // Fetches per-customer invoices (with journal_entry_id) and renders each as a // link to /bookkeeping/[id] when posted, /invoices/[id] when still draft. function ARCustomerInvoiceRows({ customerId, invoices, }: { customerId: string invoices: { invoice_id: string invoice_number: string invoice_date: string due_date: string total: number paid_amount: number outstanding: number outstanding_sek: number | null days_overdue: number currency: string }[] }) { // ARCustomerInvoiceRows is mounted lazily — only when a customer is // expanded, so initial state matches "still loading" and resets on // unmount. No synchronous setState in the effect is needed. const [enriched, setEnriched] = useState>({}) const [loaded, setLoaded] = useState(false) useEffect(() => { let cancelled = false fetch(`/api/reports/ar-ledger/customer/${encodeURIComponent(customerId)}/invoices`) .then((r) => r.json()) .then((json) => { if (cancelled) return const map: typeof enriched = {} for (const line of json.data?.lines || []) { if (line.invoice_id && line.journal_entry_id) { map[line.invoice_id] = { journal_entry_id: line.journal_entry_id, voucher_series: line.voucher_series, voucher_number: line.voucher_number, } } } setEnriched(map) }) .catch(() => { /* fail silently; rows still render without verifikat link */ }) .finally(() => { if (!cancelled) setLoaded(true) }) return () => { cancelled = true } }, [customerId]) const loading = !loaded return ( <> {invoices.map((inv) => { const entry = enriched[inv.invoice_id] const targetHref = entry?.journal_entry_id ? `/bookkeeping/${entry.journal_entry_id}` : `/invoices/${inv.invoice_id}` return ( {inv.invoice_number || '(utkast)'} {entry && ( {formatVoucher(entry)} )} {formatDate(inv.invoice_date)} förfaller {formatDate(inv.due_date)} {inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'} {inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)}` : ''} {formatAmount(inv.outstanding)} {inv.currency} ) })} {loading && ( Letar verifikat… )} ) } export function ARLedgerView({ periodId }: { periodId: string }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [expandedCustomers, setExpandedCustomers] = useState>(new Set()) const fetchData = async () => { setLoading(true) setError(null) try { const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}`) const result = await res.json() if (result.error) { setError(result.error) } else { setData(result.data) } } catch { setError('Kunde inte hämta kundreskontra') } finally { setLoading(false) } } useEffect(() => { if (periodId) fetchData() }, [periodId]) const toggleCustomer = (customerId: string) => { setExpandedCustomers((prev) => { const next = new Set(prev) if (next.has(customerId)) { next.delete(customerId) } else { next.add(customerId) } return next }) } if (loading) { return ( Laddar kundreskontra... ) } if (error) { return ( {error} ) } if (!data || !data.ledger) { return ( Ingen data tillgänglig. ) } const { ledger, reconciliation } = data return (
{/* Summary cards */}
Totalt utestående

{formatAmount(ledger.total_outstanding)} kr

{ledger.unpaid_count} fakturor

{ledger.unconverted_fx_count > 0 && (

{ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen.

)}
Ej förfallet

{formatAmount(ledger.total_current)} kr

Förfallet

{formatAmount(ledger.total_overdue)} kr

{/* Aging table with expandable invoice details */} {ledger.entries.length > 0 && ( Ålderfördelning per kund
{ledger.entries.map((entry) => { const isExpanded = expandedCustomers.has(entry.customer_id) return ( toggleCustomer(entry.customer_id)} > {isExpanded && ( )} ) })}
Kund Ej förfallet 1-30 dagar 31-60 dagar 61-90 dagar 90+ dagar Totalt
{isExpanded ? ( ) : ( )} {entry.customer_name} {entry.current > 0 ? formatAmount(entry.current) : ''} {entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''} {entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''} {entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''} {entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''} {formatAmount(entry.total_outstanding)}
Summa {formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))} {formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))} {formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))} {formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))} {formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))} {formatAmount(ledger.total_outstanding)}
)} {/* Reconciliation */} {reconciliation && ( Avstämning mot
Kundreskontra (summa utestående) {formatAmount(reconciliation.ar_ledger_total)} kr
Kundfordringar ( + ) saldo {formatAmount(reconciliation.account_1510_balance)} kr
Differens {formatAmount(reconciliation.difference)} kr
{reconciliation.is_reconciled ? ( Avstämd ) : ( Ej avstämd - kontrollera bokföring )} {reconciliation.unconverted_fx_count > 0 && (

{reconciliation.unconverted_fx_count} kundfaktura i utländsk valuta saknar växelkurs — differensen kan bero på saknade kursuppgifter snarare än felbokning.

)}
)}
) }