'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, useCallback } from 'react' import Link from 'next/link' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { AlertCircle, Check, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton' import { EmptyState } from '@/components/ui/empty-state' import { FyPicker } from '@/components/common/FyPicker' import { ContextPicker } from '@/components/common/ContextPicker' import { cn, formatDate } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' import { roundOre } from '@/lib/money' import { formatLatestVouchers } from '@/lib/reports/latest-vouchers-format' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { AccountNumber } from '@/components/ui/account-number' import { ReportExportMenu } from '@/components/reports/ReportExportMenu' import { PageHeader } from '@/components/ui/page-header' import { VatChecksCard } from '@/components/reports/VatChecksCard' import { runVatDeclarationChecks } from '@/lib/reports/vat-declaration-checks' import { rcInputTotalsFromDeclaration } from '@/lib/reports/vat-declaration' import { isFilingBlocked, withRcBasisGapFindings, type RcBasisGapScan, } from '@/lib/reports/vat-filing-gate' import { Table, TableBody } from '@/components/ui/table' import { useCompanySettings } from '@/components/settings/useSettings' import dynamic from 'next/dynamic' import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useCanWrite } from '@/lib/hooks/use-can-write' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' import type { VatSettlementProposal } from '@/lib/reports/vat-settlement' // Recharts is ~180KB: defer the chart components so report tables (the // regulated content) render without waiting for the charting bundle. const chartFallback = () => const TrialBalanceChart = dynamic( () => import('@/components/reports/TrialBalanceChart').then((m) => m.TrialBalanceChart), { ssr: false, loading: chartFallback }, ) const IncomeExpenseChart = dynamic( () => import('@/components/reports/IncomeExpenseChart').then((m) => m.IncomeExpenseChart), { ssr: false, loading: chartFallback }, ) // The full journal entry editor is heavy (BAS catalogue, comboboxes, review // dialogs): defer it until the user opens the momsverifikat dialog. const JournalEntryForm = dynamic(() => import('@/components/bookkeeping/JournalEntryForm'), { ssr: false, loading: () => , }) 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 { DimensionFilterValue } from '@/components/reports/DimensionFilter' import type { TrialBalanceRow, IncomeStatementReport, BalanceSheetReport, ResultatrapportReport, BalansrapportReport, DimensionPnlReport, VatDeclaration, VatPeriodType, } from '@/types' function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } function reportQuery( periodId: string, range?: DateRangeValue, dimensionFilter?: DimensionFilterValue | null, ): 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) if (dimensionFilter) { params.set('dim_no', dimensionFilter.dimNo) params.set('dim_code', dimensionFilter.code) } 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) { // `result.error` is the canonical envelope OBJECT; assigning it to a // string state and rendering it bare threw "Objects are not valid as // a React child" and blanked the report page. setError(getErrorMessage(result)) } else { setData(result.data) } setLoading(false) }) .catch(() => { setError('Kunde inte hämta saldobalans') setLoading(false) }) }, [periodId]) if (loading) { return ( {[1, 2, 3, 4, 5, 6].map((i) => ( ))} ) } 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, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; 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, dimensionFilter) useEffect(() => { setLoading(true) setError(null) setMonthlyLoading(true) fetch(`/api/reports/income-statement?${reportQs}`) .then((res) => res.json()) .then((result) => { if (result.error) { // `result.error` is the canonical envelope OBJECT; assigning it to a // string state and rendering it bare threw "Objects are not valid as // a React child" and blanked the report page. setError(getErrorMessage(result)) } 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. // The dimension filter DOES apply: a dimension-scoped view must not // silently chart company-wide months. fetch(`/api/reports/monthly-breakdown?${reportQuery(periodId, undefined, dimensionFilter)}`) .then((res) => res.json()) .then((result) => { if (result.data?.months) { setMonthlyData(result.data.months) } setMonthlyLoading(false) }) .catch(() => { setMonthlyLoading(false) }) }, [periodId, reportQs]) if (loading) { return ( {[1, 2, 3, 4, 5, 6].map((i) => ( ))} ) } 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) { // `result.error` is the canonical envelope OBJECT; assigning it to a // string state and rendering it bare threw "Objects are not valid as // a React child" and blanked the report page. setError(getErrorMessage(result)) } 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

)}
{!isBalanced && data.imbalance_diagnosis && (

{data.imbalance_diagnosis.message}

)}
) } export function ResultatrapportView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) { const t = useTranslations('reports') const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const reportQs = reportQuery(periodId, dateRange, dimensionFilter) const latestVouchers = formatLatestVouchers(data?.latest_vouchers) useEffect(() => { setLoading(true) setError(null) fetch(`/api/reports/resultatrapport?${reportQs}`) .then((res) => res.json()) .then((result) => { if (result.error) { // `result.error` is the canonical envelope OBJECT; assigning it to a // string state and rendering it bare threw "Objects are not valid as // a React child" and blanked the report page. setError(getErrorMessage(result)) } else { setData(result.data) } setLoading(false) }) .catch(() => { setError('Kunde inte hämta resultatrapport') setLoading(false) }) }, [periodId, reportQs]) if (loading) { return ( {[1, 2, 3, 4, 5, 6].map((i) => ( ))} ) } 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 (
{latestVouchers && (

{t('latest_posted_vouchers')}: {latestVouchers}

)}
{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 t = useTranslations('reports') const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const reportQs = reportQuery(periodId, dateRange) const latestVouchers = formatLatestVouchers(data?.latest_vouchers) useEffect(() => { setLoading(true) setError(null) fetch(`/api/reports/balansrapport?${reportQs}`) .then((res) => res.json()) .then((result) => { if (result.error) { // `result.error` is the canonical envelope OBJECT; assigning it to a // string state and rendering it bare threw "Objects are not valid as // a React child" and blanked the report page. setError(getErrorMessage(result)) } 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 (
{latestVouchers && (

{t('latest_posted_vouchers')}: {latestVouchers}

)}
{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 {data.imbalance_diagnosis && (

Differens: {formatAmount(Math.abs(data.imbalance_diagnosis.differens))} kr

)}
)}
{!data.is_balanced && data.imbalance_diagnosis && (

{data.imbalance_diagnosis.message}

)}
) } 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 }>({}) // Skatteverket's e-service entry point for manually filing the momsdeklaration. // Manual filing needs no connection, so this link is the default path for // anyone who hasn't set up (or doesn't want) the direct-submission integration. const SKATTEVERKET_MOMS_URL = 'https://www.skatteverket.se/foretag/etjansterochblanketter/etjanster/momsocharbetsgivardeklarationer' /** * Manual-filing affordance shown directly under the calculated momsdeklaration. * The report is generated purely from the bookkeeping and never depends on the * Skatteverket connection, so every user (including core builds with the * skatteverket extension disabled) can file manually. Two paths are offered: * an eSKD XML file to upload directly under "Deklarera via fil" (the fast path), * and a PDF (in hela kronor) to read off if the user would rather type the * boxes into the form. A PDF cannot be uploaded to Skatteverket, only the XML. */ function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: string }) { return (

Lämna in själv, med fil

Du behöver inte vara ansluten till Skatteverket för att lämna in.

  1. Ladda ner XML-filen nedan.
  2. Logga in på skatteverket.se med BankID.
  3. Öppna Moms- och arbetsgivardeklarationer och välj Deklarera via fil.
  4. Ladda upp filen, granska och signera.

Vill du hellre fylla i rutorna för hand laddar du ner PDF:en och skriver av beloppen (i hela kronor).

) } /** * "Bokför momsrapport" (issue #980): builds an editable verifikat proposal * from the momsrapport (clearing the period's 26xx accounts to 2650/1650, * öre gap on 3740) and books it through the ordinary journal entry form, so * every line can be adjusted before committing. The proposal comes from * /api/reports/vat-declaration/settlement-proposal; booking goes through * POST /api/bookkeeping/journal-entries with source_type 'vat_settlement', * which the declaration projection excludes, so the report above keeps * showing the declared figures after booking. */ function VatBookingCard({ periodType, year, period, fiscalPeriodId, checksBlocked, onStatus, }: { periodType: VatPeriodType year: number period: number fiscalPeriodId?: string /** * True when the local pre-flight checks found ERRORs. Booking stays * possible (the RC-basis fixes only touch 44xx/45xx pairs, never the 26xx * accounts the settlement clears), but the user should know before filing. */ checksBlocked?: boolean /** Lets the surrounding stepper mirror the booking state on its dot. */ onStatus?: (status: 'booked' | 'draft' | 'none') => void }) { const { canWrite } = useCanWrite() const [dialogOpen, setDialogOpen] = useState(false) const [refreshKey, setRefreshKey] = useState(0) // Fetch outcome tagged with the key it was requested under; proposal/failed // are derived by comparing that tag with the current key, so the effect // never sets state synchronously (same pattern as VatDeclarationView). const [result, setResult] = useState<{ key: string proposal?: VatSettlementProposal failed?: boolean } | null>(null) const fetchKey = `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}:${refreshKey}` useEffect(() => { const params = new URLSearchParams({ periodType, year: String(year), period: String(period), }) if (fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId) let cancelled = false fetch(`/api/reports/vat-declaration/settlement-proposal?${params.toString()}`) .then(async (res) => { const json = await res.json().catch(() => null) if (cancelled) return if (!res.ok || !json?.data) setResult({ key: fetchKey, failed: true }) else setResult({ key: fetchKey, proposal: json.data }) }) .catch(() => { if (!cancelled) setResult({ key: fetchKey, failed: true }) }) return () => { cancelled = true } }, [fetchKey, periodType, year, period, fiscalPeriodId]) const upToDate = result !== null && result.key === fetchKey const proposal = upToDate ? (result.proposal ?? null) : null const failed = upToDate && !!result.failed const booked = proposal?.existing_entries.find((e) => e.status === 'posted') const draft = booked ? undefined : proposal?.existing_entries.find((e) => e.status === 'draft') const bookingStatus = booked ? 'booked' : draft ? 'draft' : 'none' useEffect(() => { if (upToDate && proposal) onStatus?.(bookingStatus) // eslint-disable-next-line react-hooks/exhaustive-deps }, [upToDate, bookingStatus]) // FormLine amounts are input strings; the proposal's numbers are already // öre-rounded server-side, so this is display formatting, not money math. const initialLines: FormLine[] = (proposal?.lines ?? []).map((l) => ({ account_number: l.account_number, debit_amount: l.debit_amount > 0 ? l.debit_amount.toFixed(2) : '', credit_amount: l.credit_amount > 0 ? l.credit_amount.toFixed(2) : '', line_description: l.line_description ?? '', })) return (

Skapa ett verifikat som nollställer periodens momskonton och bokför momsen att betala eller få tillbaka på redovisningskontot. Du granskar förslaget och kan ändra raderna innan verifikatet bokförs.

{booked && (
)} {draft && (
)} {checksBlocked && (

Det finns fel under steg 1, Kontrollera. Du kan bokföra momsen ändå, men åtgärda felen innan du lämnar in.

)} {failed ? (

Kunde inte hämta verifikatförslaget.

) : !upToDate ? ( ) : proposal?.is_empty ? (

Ingen moms att bokföra för perioden.

) : (
{!canWrite && (

Du har läsbehörighet och kan inte bokföra.

)}
)} {proposal && ( e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} > Bokför momsrapport

Förslaget bygger på momsrapporten för {proposal.period_label}. Justera datum, konton eller belopp vid behov och bokför sedan verifikatet.

{dialogOpen && ( { setDialogOpen(false) setRefreshKey((k) => k + 1) }} /> )}
)}
) } /** The Stegen header (concept Moms C): the filing pipeline as a clickable * horizontal stepper with honest per-step status subs. Statutory surface, * Swedish in both locales like the rest of the declaration. */ function VatStepper({ active, onSelect, errorCount, warningCount, ruta49, bookingStatus, }: { active: number onSelect: (step: number) => void errorCount: number warningCount: number ruta49: number bookingStatus: 'booked' | 'draft' | 'none' | null }) { const steps = [ { n: 1, label: 'Kontrollera', sub: errorCount > 0 ? `${errorCount} fel` : warningCount > 0 ? `${warningCount} ${warningCount === 1 ? 'varning' : 'varningar'}` : 'klart', done: errorCount === 0, warn: errorCount > 0, }, { n: 2, label: 'Granska', sub: ruta49 > 0 ? `${formatAmount(ruta49)} kr att betala` : ruta49 < 0 ? `${formatAmount(Math.abs(ruta49))} kr att återfå` : 'ingen moms', done: false, warn: false, }, { n: 3, label: 'Bokför', sub: bookingStatus === 'booked' ? 'bokförd' : bookingStatus === 'draft' ? 'utkast finns' : 'mot 2650', done: bookingStatus === 'booked', warn: false, }, { n: 4, label: 'Lämna in', sub: 'till Skatteverket', done: false, warn: false }, ] return (
{steps.map((step, i) => ( {i > 0 && ))}
) } const MONTH_NAMES = [ 'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December', ] const QUARTER_SPANS = ['jan-mar', 'apr-jun', 'jul-sep', 'okt-dec'] export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) { const currentYear = new Date().getFullYear() const currentMonth = new Date().getMonth() + 1 const currentQuarter = Math.ceil(currentMonth / 3) // periodType stays null until the company's VAT settings have settled, so // the first (automatic) fetch runs against the configured momsperiod instead // of a guessed default. const [periodType, setPeriodType] = useState(null) const [year, setYear] = useState(currentYear) const [period, setPeriod] = useState(currentQuarter) // Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar // year — picked inline in yearly mode. Monthly/quarterly are calendar // periods and need no fiscal year. The period's end date rides along so // the Skatteverket panel can target the FY-end month (broken fiscal years // do not end in December). const [fiscalPeriodId, setFiscalPeriodId] = useState('') const [fiscalPeriodEnd, setFiscalPeriodEnd] = useState(null) // Latest fetch outcome, tagged with the fetch key it was requested under. // loading / error / data are all derived by comparing that tag with the // current key, so the fetch effect never sets state synchronously. const [result, setResult] = useState<{ key: string declaration?: VatDeclaration error?: string } | null>(null) const [retryKey, setRetryKey] = useState(0) // Stegen: which of the four pipeline steps is open. null = automatic // (errors land on Kontrollera, otherwise Granska). A period switch resets // to automatic so stale step choices never survive a context change. const [chosenStep, setChosenStep] = useState(null) const [bookingStatus, setBookingStatus] = useState<'booked' | 'draft' | 'none' | null>(null) // Per-verifikat RC-basis scan, fetched here (not only inside VatChecksCard) // because the filing gate lives here and the worklist unmounts as soon as // the user leaves steg 1. Tagged with the PERIOD it was requested for (see // gapScanPeriodKey), so a korrigering refetches without the gate falling // open in between; count === null means "not known" (scan failed), which is // deliberately NOT the same as zero. const [gapScan, setGapScan] = useState<{ key: string; count: number | null } | null>(null) // Company settings drive both the momsregistrerad gate and the default // periodicity (moms_period in Inställningar). Applied once per company the // first time its settings settle — as a render-phase adjustment, not an // effect. 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, isLoading: settingsLoading } = useCompanySettings() const [appliedCompany, setAppliedCompany] = useState(null) const companyKey = settingsLoading ? null : (settings?.company_id ?? 'none') if (companyKey !== null && appliedCompany !== companyKey) { setAppliedCompany(companyKey) const configured = settings?.moms_period ?? 'quarterly' setPeriodType(configured) setPeriod( configured === 'monthly' ? currentMonth : configured === 'quarterly' ? currentQuarter : 1, ) } // Settings row present and the company answered "not VAT-registered" — // the declaration is meaningless, so the whole view is gated below. const notVatRegistered = !settingsLoading && settings !== null && !settings.vat_registered // Registered but never picked a redovisningsperiod (rare — onboarding // requires it, but companies created outside that flow can miss it). const momsPeriodMissing = settings?.vat_registered === true && !settings.moms_period // Switching periodicity resets the period to "now" in the new unit. Done in // the change handler (not an effect) so the auto-fetch below never sees an // inconsistent periodType/period pair. const handlePeriodTypeChange = (value: VatPeriodType) => { setPeriodType(value) setPeriod(value === 'monthly' ? currentMonth : value === 'quarterly' ? currentQuarter : 1) } // 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 awaitingFiscalPeriod = isYearly && !fiscalPeriodId const vatQueryString = () => { const params = new URLSearchParams({ periodType: periodType ?? 'quarterly', year: String(year), period: String(period), }) if (isYearly && fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId) return params.toString() } // The declaration loads as soon as the period is known — no manual "Hämta" // step. fetchKey is null while a prerequisite is missing (settings pending, // gated, or no redovisningsperiod configured); any change to it triggers a // refetch and stale responses are discarded. const fetchKey = periodType === null || notVatRegistered || momsPeriodMissing || awaitingFiscalPeriod ? null : `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}:${retryKey}` // Period identity WITHOUT the retry counter, used to decide whether the gap // scan below still speaks for what is on screen. The scan re-runs on every // retryKey bump (each korrigering), but the last settled count stays in // force until the new one lands: falling back to `pending` mid-korrigering // would drop the gap finding out of `checks` for one round trip, and with // the aggregate check silent (the tolerance case this gate exists for) the // green "Inga fel hittades i underlaget för perioden" would render right // above the vouchers still left in the worklist, with Skicka enabled. That // is the exact regression being fixed, and steg 1 stays mounted across a // korrigering, so the user would be looking straight at it. Stale-but-closed // is the safe direction, and it matches the declaration itself, which stays // on screen (dimmed) while the next one loads. const gapScanPeriodKey = periodType === null ? null : `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}` useEffect(() => { setChosenStep(null) setBookingStatus(null) }, [periodType, year, period, fiscalPeriodId]) useEffect(() => { if (!fetchKey || periodType === null) return const params = new URLSearchParams({ periodType, year: String(year), period: String(period), }) if (periodType === 'yearly') params.set('fiscal_period_id', fiscalPeriodId) let cancelled = false fetch(`/api/reports/vat-declaration?${params.toString()}`) .then(async (res) => { const json = await res.json().catch(() => null) if (cancelled) return if (!res.ok || json?.error) { setResult({ key: fetchKey, error: typeof json?.error === 'string' ? json.error : 'Kunde inte hämta momsdeklaration', }) } else { setResult({ key: fetchKey, declaration: json.data }) } }) .catch(() => { if (!cancelled) setResult({ key: fetchKey, error: 'Kunde inte hämta momsdeklaration' }) }) return () => { cancelled = true } }, [fetchKey, periodType, year, period, fiscalPeriodId]) // The per-verifikat gap scan runs on the same key as the declaration, so a // korrigering (which bumps retryKey via onCorrected) re-verifies the gate // instead of leaving it stuck on the pre-correction count. useEffect(() => { if (!fetchKey || periodType === null || !gapScanPeriodKey) return const params = new URLSearchParams({ periodType, year: String(year), period: String(period), }) if (periodType === 'yearly') params.set('fiscal_period_id', fiscalPeriodId) let cancelled = false fetch(`/api/reports/vat-declaration/rc-basis-gaps?${params.toString()}`) .then(async (res) => { const json = await res.json().catch(() => null) if (cancelled) return // count === null is a settled FAILURE, and unknown is not "inga // brister": it becomes a visible warning row (so the banner cannot // claim all-clear on a check that never ran) but does not block, or a // network hiccup would lock the user out of a statutory deadline. if (!res.ok || json?.error) setGapScan({ key: gapScanPeriodKey, count: null }) else setGapScan({ key: gapScanPeriodKey, count: (json?.data?.gaps ?? []).length }) }) .catch(() => { if (!cancelled) setGapScan({ key: gapScanPeriodKey, count: null }) }) return () => { cancelled = true } }, [fetchKey, gapScanPeriodKey, periodType, year, period, fiscalPeriodId]) // Derived fetch state: the previous declaration stays visible (dimmed) // while the next period loads. const upToDate = result !== null && result.key === fetchKey const data = result?.declaration ?? null const error = upToDate ? (result.error ?? null) : null const loading = fetchKey !== null && !upToDate // Local pre-flight checks on the calculated declaration. Computed here (not // in SkatteverketPanel) so every user sees them: they gate direct submission // but concern manual filers just as much. // // The per-verifikat gap scan is folded in BEFORE anything derives from the // list. The aggregate checks compare period totals with a tolerance that // scales with period size, so they can stay silent while individual // verifikat still miss their basbelopp: reading them alone let the green // "Inga fel hittades i underlaget för perioden" render directly above the // worklist of those verifikat, with Skicka enabled. Banner, stegen and the // send gate now all read this ONE array. The three scan states are kept // apart: only a settled count of zero is allowed to mean "inga brister". const gapScanSettled = gapScan !== null && gapScan.key === gapScanPeriodKey const rcBasisScan: RcBasisGapScan = !gapScanSettled ? { status: 'pending' } : gapScan.count === null ? { status: 'unavailable' } : { status: 'scanned', gapCount: gapScan.count } // The declaration carries the 2645/2647 totals (rcInputAccountTotals), so // RC_INPUT_VAT_MISMATCH compares rutor 30-32 against the reverse-charge INPUT // accounts instead of the ruta 48 aggregate. Without them, 50 000 kr of // fiktiv utgående moms with nothing on 2645 sits silently behind 60 000 kr of // ordinary 2641 and the user pays in moms they were entitled to deduct. // rcInputTotalsFromDeclaration returns undefined (not an empty map) when a // response predates the field, which keeps the fallback honest. const checks = data ? withRcBasisGapFindings( runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data)), rcBasisScan, ) : [] const checksBlocked = isFilingBlocked(checks) const errorCount = checks.filter((c) => c.status === 'ERROR').length const warningCount = checks.filter((c) => c.status === 'WARNING').length // Latch the automatic landing step once per period, as a render-phase // adjustment when the period's declaration first settles. Deriving it live // from checksBlocked navigated the user away mid-work: the refetch after a // korrigering can clear the aggregate error while the Kontrollera worklist // still holds broken vouchers, and the view would jump to Granska under // their feet. The period-change effect below resets chosenStep to null, // which re-arms this latch for the next period. It also waits for the gap // scan: latching on the aggregate alone landed the user on Granska while // steg 1 still held a blocking worklist. if (chosenStep === null && upToDate && data && !error && gapScanSettled) { setChosenStep(checksBlocked ? 1 : 2) } const activeStep = chosenStep ?? (checksBlocked ? 1 : 2) // Settings not settled yet — the picker defaults and the gate both depend // on them, so hold the whole view in a skeleton. // The standalone page renders its own PageHeader (FocusedReport passes the // title and skips its own), so the H1 must survive the gated/loading // states too: each early return carries the action-less header. const bareHeader = pageTitle ? : null if (settingsLoading || periodType === null) { return (
{bareHeader}
) } if (notVatRegistered) { return (
{bareHeader}
) } // Registered but no redovisningsperiod picked: block instead of guessing. // A declaration rendered (and submittable via panelen) for the wrong // period type is a compliance hazard, not a convenience. if (momsPeriodMissing) { return (
{bareHeader}
) } // Year and concrete period fused into ONE chip: reverse-chronological // "Kvartal 2 2026", "Kvartal 1 2026", ... across the last five years, so // switching period never needs two pickers. Yearly keeps the FyPicker, // which is already a fused räkenskapsår chip. const fusedPeriodItems: { id: string; label: string; annotation?: string }[] = [] if (!isYearly) { for (let y = currentYear; y > currentYear - 5; y--) { if (periodType === 'quarterly') { for (let q = 4; q >= 1; q--) { fusedPeriodItems.push({ id: `${y}:${q}`, label: `Kvartal ${q} ${y}`, annotation: QUARTER_SPANS[q - 1], }) } } else { for (let m = 12; m >= 1; m--) { fusedPeriodItems.push({ id: `${y}:${m}`, label: `${MONTH_NAMES[m - 1]} ${y}` }) } } } } const fusedLabel = periodType === 'quarterly' ? `Kvartal ${period} ${year}` : `${MONTH_NAMES[period - 1]} ${year}` return (
{/* Standalone page: the title row carries the primary action (locked convention 9), so Exportera sits beside the H1 and the period chips get their own row below. XML and PDF live in "Lämna in": they are filing artifacts, not report exports. */} {pageTitle && ( } /> )}
{/* Cadence lives behind a settings-style "Period" chip: the concrete period chip next to it already shows the cadence ("Kvartal 2" implies quarterly, "Räkenskapsår ..." yearly). */} handlePeriodTypeChange(value as VatPeriodType)} triggerLabel="Period" ariaLabel="Periodicitet" /> {isYearly ? ( // Annual VAT covers a räkenskapsår: picked here, not a // calendar year. { setFiscalPeriodId(id || '') setFiscalPeriodEnd(fp?.period_end ?? null) }} includeAllOption={false} hideFuturePeriods /> ) : ( { const [y, p] = id.split(':').map(Number) setYear(y) setPeriod(p) }} triggerLabel={fusedLabel} ariaLabel="Redovisningsperiod" /> )} {!pageTitle && ( )}
{error && (

{error}

)} {!error && (awaitingFiscalPeriod || (loading && !data)) && ( )} {data && !awaitingFiscalPeriod && (
{/* Stegen (concept): the filing pipeline as a horizontal stepper — kontrollera, granska, bokför, lämna in — showing one step's content at a time. Errors land on step 1, otherwise Granska. */} {activeStep === 1 && (
setRetryKey((k) => k + 1)} />
)} {activeStep === 2 && (

Momsdeklaration · {data.period.start} till {data.period.end}

{data.invoiceCount} fakturor · {data.transactionCount} transaktioner
{/* Utgående moms */}

Utgående moms (försäljning)

{data.rutor.ruta05 > 0 && ( )}
Summa utgående moms {formatAmount( data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12 + data.rutor.ruta30 + data.rutor.ruta31 + data.rutor.ruta32 + data.rutor.ruta60 + data.rutor.ruta61 + data.rutor.ruta62 )} 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)

)} {/* Moms vid import */} {(data.rutor.ruta50 > 0 || data.rutor.ruta60 > 0 || data.rutor.ruta61 > 0 || data.rutor.ruta62 > 0) && ( <>

Moms vid import

)}
{/* 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 moms {formatAmount(data.rutor.ruta48)} kr
{/* Ruta 49 as the emphasized document foot (concept skv-foot). */}
49 {data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'} {formatAmount(Math.abs(data.rutor.ruta49))} kr
)} {activeStep === 3 && (
)} {activeStep === 4 && (
)}
)} {/* Skatteverket integration panel for the no-data states (fetch error, empty period): with data it lives inside steg 4. Hidden while the räkenskapsår for helårsmoms is unresolved, so its actions can never target an unconfirmed period. */} {!awaitingFiscalPeriod && !data && ( )}
) } 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 } // Local calendar date (YYYY-MM-DD) for the reskontra "per datum" default: // toISOString() is UTC and rolls the date over an hour early in Sweden. function localIsoDate(): string { const now = new Date() return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` } // Shared "Per datum" control + export menu header for the two reskontra views // (#1020/#1021): pick an arbitrary as-of date and export PDF/Excel for it. function ReskontraToolbar({ asOfDate, onAsOfDateChange, inputId, exportBase, }: { asOfDate: string onAsOfDateChange: (date: string) => void inputId: string exportBase: string }) { return (
{ if (e.target.value) onAsOfDateChange(e.target.value) }} className="w-40" />
) } export function SupplierLedgerView({ periodId }: { periodId: string }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [asOfDate, setAsOfDate] = useState(localIsoDate) const fetchData = async () => { setLoading(true) setError(null) try { const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}&as_of_date=${asOfDate}`) const result = await res.json() if (result.error) { // Envelope object, not a string: see the note on the other report // fetches. Rendering it bare blanks the page. setError(getErrorMessage(result)) } else { setData(result.data) } } catch { setError('Kunde inte hämta leverantörsreskontra') } finally { setLoading(false) } } useEffect(() => { if (periodId) fetchData() }, [periodId, asOfDate]) 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 dimensions?: Record }[] closing_balance: number total_debit: number total_credit: number }[] period: { start: string; end: string } } // Stable default: an inline `= {}` would change identity every render and // re-trigger the fetch effect for callers that omit the prop. const EMPTY_DATE_RANGE: DateRangeValue = {} export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFilter = null, dateRange = EMPTY_DATE_RANGE }: { periodId: string; initialAccountFilter: string | null; dimensionFilter?: DimensionFilterValue | null; dateRange?: DateRangeValue }) { 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) if (dateRange.fromDate) params.set('from_date', dateRange.fromDate) if (dateRange.toDate) params.set('to_date', dateRange.toDate) if (dimensionFilter) { params.set('dim_no', dimensionFilter.dimNo) params.set('dim_code', dimensionFilter.code) } const res = await fetch(`/api/reports/general-ledger?${params}`) const result = await res.json() if (result.error) { // Envelope object, not a string: see the note on the other report // fetches. Rendering it bare blanks the page. setError(getErrorMessage(result)) } else { setData(result.data) } } catch { setError('Kunde inte hämta huvudbok') } finally { setLoading(false) } }, [periodId, accountFrom, accountTo, dimensionFilter, dateRange]) // When initialAccountFilter changes (drill-down from another report), apply it useEffect(() => { if (initialAccountFilter) { setAccountFrom(initialAccountFilter) setAccountTo(initialAccountFilter) fetchData(initialAccountFilter, initialAccountFilter) } else { fetchData() } }, [periodId, initialAccountFilter, dimensionFilter, dateRange]) 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)} {formatDate(line.date)} {line.description} {line.dimensions && Object.keys(line.dimensions).length > 0 && ( {Object.entries(line.dimensions) .sort(([a], [b]) => Number(a) - Number(b)) .map(([, code]) => code) .join(' · ')} )} {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) { // Envelope object, not a string: see the note on the other report // fetches. Rendering it bare blanks the page. setError(getErrorMessage(result)) } 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)} {formatDate(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)} ${inv.currency}` : ''} {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 [asOfDate, setAsOfDate] = useState(localIsoDate) const fetchData = async () => { setLoading(true) setError(null) try { const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}&as_of_date=${asOfDate}`) const result = await res.json() if (result.error) { // Envelope object, not a string: see the note on the other report // fetches. Rendering it bare blanks the page. setError(getErrorMessage(result)) } else { setData(result.data) } } catch { setError('Kunde inte hämta kundreskontra') } finally { setLoading(false) } } useEffect(() => { if (periodId) fetchData() }, [periodId, asOfDate]) 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.

)}
)}
) } // --- Resultat per projekt/kostnadsställe (dimension P&L matrix) --- export function DimensionPnlView({ periodId, dateRange }: { periodId: string; dateRange: DateRangeValue }) { // Loading is DERIVED (result key ≠ current query string) instead of a // setState at effect start: keeps react-hooks/set-state-in-effect clean // and is race-safe when the pivot/date changes mid-flight. const [result, setResult] = useState<{ qs: string data: DimensionPnlReport | null error: string | null } | null>(null) const [dims, setDims] = useState<{ sie_dim_no: number; name: string }[]>([]) const [dimNo, setDimNo] = useState('6') const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}` // Registered dimensions for the pivot picker (best-effort; the report // defaults to projekt if the registry read fails). useEffect(() => { fetch('/api/dimensions') .then((res) => res.json()) .then((payload) => { if (Array.isArray(payload.data)) { setDims(payload.data.map((d: { sie_dim_no: number; name: string }) => ({ sie_dim_no: d.sie_dim_no, name: d.name }))) } }) .catch(() => {}) }, []) useEffect(() => { let cancelled = false fetch(`/api/reports/dimension-pnl?${reportQs}`) .then((res) => res.json()) .then((payload) => { if (cancelled) return if (payload.error) { setResult({ qs: reportQs, data: null, error: typeof payload.error === 'string' ? payload.error : 'Kunde inte hämta rapporten', }) } else { setResult({ qs: reportQs, data: payload.data, error: null }) } }) .catch(() => { if (!cancelled) setResult({ qs: reportQs, data: null, error: 'Kunde inte hämta rapporten' }) }) return () => { cancelled = true } }, [reportQs]) const loading = result?.qs !== reportQs const error = loading ? null : result?.error ?? null const data = loading ? null : result?.data ?? null const pivotPicker = dims.length > 1 && (
{dims.map((d) => { const active = String(d.sie_dim_no) === dimNo return ( ) })}
) if (loading) { return (
{pivotPicker} Laddar rapport...
) } if (error) { return (
{pivotPicker} {error}
) } if (!data || data.groups.length === 0) { return (
{pivotPicker} Inga taggade intäkter eller kostnader i denna period.
) } const columnLabel = (c: DimensionPnlReport['columns'][number]) => c.code === null ? '(Utan dimension)' : c.code const colCount = 2 + data.columns.length + 1 return (
{pivotPicker || }
{data.columns.map((c, i) => ( ))} {data.groups.map((group) => ( {group.rows.map((row) => ( {row.values.map((v, i) => ( ))} ))} {group.subtotals.map((v, i) => ( ))} ))}
Konto Kontonamn {columnLabel(c)} Totalt
{group.class_label}
{row.account_name} {Math.abs(v) >= 0.005 ? formatAmount(v) : ''} {formatAmount(row.total)}
Summa {formatAmount(v)}{formatAmount(group.subtotal_total)}
))}
Beräknat resultat {data.net_per_column.map((v, i) => ( = 0 ? 'text-success' : 'text-destructive'}`}> {formatAmount(v)} = 0 ? 'text-success' : 'text-destructive'}`}> {formatAmount(data.net_total)} kr
) }