'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, useMemo } from 'react' import { useDimensions } from '@/lib/reference-data/hooks' 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, FileText, Percent } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton' import { SegmentedControl } from '@/components/ui/segmented-control' import { EmptyState } from '@/components/ui/empty-state' import { FyPicker } from '@/components/common/FyPicker' import { mostRecentEndedVatPeriod } from '@/lib/vat/period-defaults' import { resolveInitialVatPeriodSelection } from '@/lib/vat/period-selection' import { ContextPicker } from '@/components/common/ContextPicker' import { cn, formatAmount, formatDate } from '@/lib/utils' import { formatDateISO } from '@/lib/calendar/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 { vatDeadlineTaxPeriod, type VatSettlementExistingEntry, type VatSettlementProposal, } from '@/lib/reports/vat-settlement' import { VatAlreadyBookedBanner } from '@/components/reports/VatAlreadyBookedBanner' import { useVatSettlementProposal } from '@/components/reports/use-vat-settlement-proposal' // 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' // Shared shells for the report bodies, so all views read as the same // instrument: Skeleton while loading, EmptyState when the period has no data, // a quiet destructive card on fetch errors (design.md primitives). function ReportLoadingCard() { return ( {[1, 2, 3, 4, 5, 6].map((i) => ( ))} ) } function ReportErrorCard({ message }: { message: string }) { return ( {message} ) } function ReportEmptyState({ title, description }: { title: string; description: string }) { return } 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 } if (error) { return } if (!data || data.rows.length === 0) { return ( ) } 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 } if (error) { return } if (!data) { return ( ) } return (
{!monthlyLoading && monthlyData.length > 0 && ( )} {/* Revenue */} Rörelseintäkter {/* Expenses */} Rörelsekostnader {/* 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 )} {/* 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 } if (error) { return } if (!data) { return ( ) } const isBalanced = Math.abs(data.total_assets - data.total_equity_liabilities) < 0.01 return (
{/* Assets */} Tillgångar {/* Equity and liabilities */} Eget kapital och skulder {/* 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 } if (error) { return } if (!data || data.groups.length === 0) { return ( ) } 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 } if (error) { return } if (!data || data.groups.length === 0) { return ( ) } 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, footer, }: { sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[] negate?: boolean onNavigateToAccount?: (account: string) => void /** Report total, rendered as the table's tfoot (e.g. "Summa tillgångar"). */ footer?: { label: string; amount: number; negate?: boolean } }) { const fmt = (amount: number, neg?: boolean) => neg ? `-${formatAmount(amount)}` : formatAmount(amount) if (sections.length === 0) { return

Inga poster.

} // One table with group-band rows (design.md tabular rules), matching the // Resultatrapport/Balansrapport idiom, instead of one boxed sub-table per // section with a repeated title line. return (
{sections.map((section) => ( {section.rows.map((row) => ( onNavigateToAccount(row.account_number) : undefined} > ))} ))} {footer && ( )}
{section.title}
{row.account_name} {fmt(row.amount, negate)} kr
Summa {fmt(section.subtotal, negate)} kr
{footer.label} {fmt(footer.amount, footer.negate)} 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({ checksBlocked, proposal, failed, upToDate, booked, draft, onRetry, }: { /** * 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 /** Settlement proposal loaded by the parent so Granska can reuse it. */ proposal: VatSettlementProposal | null failed: boolean upToDate: boolean booked?: VatSettlementExistingEntry draft?: VatSettlementExistingEntry onRetry: () => void }) { const { canWrite } = useCanWrite() const [dialogOpen, setDialogOpen] = useState(false) // 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) onRetry() }} /> )}
)}
) } /** 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'] // Inline momsperiod setup for the "registered but no period picked" state. // Writes through the same PUT /api/settings validation as the tax settings // form (SFL 26 kap coherence rules included), so this is a shortcut, not a // second write path. Until a period exists the deadline engine generates NO // VAT deadlines at all, silently, which is why this state answers inline // instead of bouncing to settings. function MomsPeriodInlineSetup({ onSaved, }: { onSaved: (value: 'monthly' | 'quarterly' | 'yearly') => Promise | void }) { const [saving, setSaving] = useState(null) const [error, setError] = useState(null) const choose = async (value: 'monthly' | 'quarterly' | 'yearly') => { if (saving) return setSaving(value) setError(null) try { const res = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ moms_period: value }), }) if (!res.ok) { const json = await res.json().catch(() => ({})) setError(getErrorMessage(json, { statusCode: res.status })) return } await onSaved(value) } catch (err) { setError(getErrorMessage(err)) } finally { setSaving(null) } } const options: { value: 'monthly' | 'quarterly' | 'yearly'; label: string }[] = [ { value: 'quarterly', label: 'Varje kvartal' }, { value: 'monthly', label: 'Varje månad' }, { value: 'yearly', label: 'Helår' }, ] return (
{options.map((opt) => ( ))}
{error && (

{error}

)}
) } 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 [settlementRefreshKey, setSettlementRefreshKey] = useState(0) const [deadlineResult, setDeadlineResult] = useState<{ key: string; completed: boolean } | 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 for the session, // and a company switch re-applies the new company's setting. The cadence is // deliberately NOT persisted across visits: the redovisningsperiod is fixed // by the company's Skatteverket registration, so this mount-time re-seed is // the control that self-heals an in-session detour to the wrong period // type (see lib/vat/period-selection.ts). `useCompanySettings` only // refetches when the active company changes, so this never clobbers a // manual selection mid-session. const { settings, isLoading: settingsLoading, refetch: refetchSettings } = useCompanySettings() const [appliedCompany, setAppliedCompany] = useState(null) const companyKey = settingsLoading ? null : (settings?.company_id ?? 'none') if (companyKey !== null && appliedCompany !== companyKey) { setAppliedCompany(companyKey) const initial = resolveInitialVatPeriodSelection({ momsPeriod: settings?.moms_period ?? null, over40m: settings?.vat_taxable_base_over_40m === true, }) setPeriodType(initial.periodType) setYear(initial.year) setPeriod(initial.period) } // 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 // No company_settings row at all (company created outside onboarding): // VAT registration AND periodicity are both unknown. This used to fall // through every gate and render a silently guessed quarterly declaration; // the wrong period type for an årsmoms company is a compliance hazard, so // it now gates like the other unknowns. const settingsRowMissing = !settingsLoading && settings === null // 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 the most recently ended one in // the new unit (same default as first load: the current period can never be // filed). 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) if (value === 'monthly' || value === 'quarterly') { const ended = mostRecentEndedVatPeriod(value, new Date(), { over40m: settings?.vat_taxable_base_over_40m === true, }) setYear(ended.year) setPeriod(ended.period) } else { setPeriod(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 || settingsRowMissing || 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 : ''}` const settlement = useVatSettlementProposal({ periodType, year, period, fiscalPeriodId: isYearly ? fiscalPeriodId : undefined, enabled: fetchKey != null, refreshKey: settlementRefreshKey, }) const bookingStatus = settlement.upToDate ? settlement.bookingStatus : null const taxPeriodKey = periodType ? vatDeadlineTaxPeriod(periodType, year, period) : null const deadlineCompleted = !!settlement.booked && taxPeriodKey != null && deadlineResult?.key === taxPeriodKey && deadlineResult.completed useEffect(() => { setChosenStep(null) }, [periodType, year, period, fiscalPeriodId]) useEffect(() => { if (!settlement.booked || !taxPeriodKey) return const key = taxPeriodKey let cancelled = false fetch('/api/deadlines?status=completed') .then(async (res) => { const json = await res.json().catch(() => null) if (cancelled) return const rows = Array.isArray(json?.data) ? json.data : [] const match = rows.some( (d: { tax_period?: string | null; tax_deadline_type?: string | null }) => d.tax_period === key && (d.tax_deadline_type === 'moms_monthly' || d.tax_deadline_type === 'moms_quarterly' || d.tax_deadline_type === 'moms_yearly'), ) setDeadlineResult({ key, completed: match }) }) .catch(() => { if (!cancelled) setDeadlineResult({ key, completed: false }) }) return () => { cancelled = true } }, [settlement.booked, taxPeriodKey]) 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. // The gap-downgrade evidence (per-momssats 44xx/45xx balances) travels on // the declaration payload. Absent on responses from an older deploy: then // the gaps keep their blocking ERROR tier rather than guessing. const checks = data ? withRcBasisGapFindings( runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data)), rcBasisScan, data.rcBasisByRate ? { rutor: data.rutor, rcBasisByRate: data.rcBasisByRate } : undefined, ) : [] 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 (settingsRowMissing) { 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. But the answer is // collected HERE, inline: until it exists the deadline engine generates no // VAT deadlines at all (silently), so bouncing the user to settings left a // compliance hole open longer than it needed to be. When vat_number is ALSO // missing, the inline save would 400 on the vat_number coherence rule in // PUT /api/settings (momsregistrerad requires a registreringsnummer), so // that (rarer) state keeps the settings bounce, which has both fields. if (momsPeriodMissing) { if (!settings?.vat_number) { return (
{bareHeader}
) } return (
{bareHeader}
{ await refetchSettings() // The first-settle seeding above only runs once per company, // so re-apply the fresh periodicity (and its most-recent- // ended default period) by hand. handlePeriodTypeChange(value) }} />

Du kan alltid ändra den i{' '} skatteinställningarna .

) } // 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 preferLatestEnded /> ) : ( { const [y, p] = id.split(':').map(Number) setYear(y) setPeriod(p) }} triggerLabel={fusedLabel} ariaLabel="Redovisningsperiod" /> )} {!pageTitle && ( )}
{error && (

{error}

)} {!error && (awaitingFiscalPeriod || (loading && !data)) && ( )} {data && !awaitingFiscalPeriod && (
{settlement.booked && ( )} {/* 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 && (
setSettlementRefreshKey((k) => k + 1)} />
)} {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. // 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(() => formatDateISO(new Date())) 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 } if (error) { return } if (!data || !data.ledger) { return ( ) } 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 } if (error) { return } if (!data || data.accounts.length === 0) { return ( ) } return (
{/* Account range filter: flat toolbar on the panel, no box */}
setAccountFrom(e.target.value)} placeholder="t.ex. 1510" className="mt-1 w-32" />
setAccountTo(e.target.value)} placeholder="t.ex. 1519" className="mt-1 w-32" />
{data.period.start && (

Period {data.period.start} till {data.period.end} · {data.accounts.length} konton

)} {data.accounts.map((account) => (
{/* data-ph-mask: account number and name are user chart data */} 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 } if (error) { return } if (!data || data.entries.length === 0) { return ( ) } return (
{data.period.start && (

Period {data.period.start} till {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(() => formatDateISO(new Date())) 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 } if (error) { return } if (!data || !data.ledger) { return ( ) } 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 [dimNo, setDimNo] = useState('6') const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}` // Registered dimensions for the pivot picker, from the session cache // (lib/reference-data); best-effort, the report defaults to projekt while // the registry is unavailable. const { dimensions } = useDimensions() const dims = useMemo( () => dimensions.map((d) => ({ sie_dim_no: d.sie_dim_no, name: d.name })), [dimensions], ) 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}
) } if (error) { return (
{pivotPicker}
) } if (!data || data.groups.length === 0) { return (
{pivotPicker}
) } 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-ph-mask: the pivot column header is a user dimension value. No title attribute: replay masking covers text nodes, not attributes. */ ))} {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
) }