3590 lines
139 KiB
TypeScript
3590 lines
139 KiB
TypeScript
'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 = () => <Skeleton className="h-64 w-full" />
|
||
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: () => <Skeleton className="h-64 w-full" />,
|
||
})
|
||
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<string | null>(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 (
|
||
<Card>
|
||
<CardContent className="p-6 space-y-2">
|
||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||
<Skeleton key={i} className="h-4 w-full" />
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || data.rows.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga bokförda verifikationer i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/trial-balance/xlsx?period_id=${periodId}` }]} />
|
||
<TrialBalanceChart rows={data.rows} />
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle>Saldobalans</CardTitle>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-0.5 rounded-md border border-border bg-card p-0.5">
|
||
<button
|
||
onClick={() => setViewMode('simplified')}
|
||
className={`px-3 py-1 text-xs rounded-sm transition-colors ${
|
||
viewMode === 'simplified'
|
||
? 'bg-secondary text-foreground'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
>
|
||
Förenklad
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode('detailed')}
|
||
className={`px-3 py-1 text-xs rounded-sm transition-colors ${
|
||
viewMode === 'detailed'
|
||
? 'bg-secondary text-foreground'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
>
|
||
Detaljerad
|
||
</button>
|
||
</div>
|
||
{data.isBalanced ? (
|
||
<Badge variant="success">Balanserad</Badge>
|
||
) : (
|
||
<Badge variant="destructive">Ej balanserad</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto -mx-2 px-2">
|
||
{viewMode === 'simplified' ? (
|
||
<table className="w-full text-sm min-w-[500px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-8"></th>
|
||
<th className="py-2 w-20">Konto</th>
|
||
<th className="py-2">Namn</th>
|
||
<th className="py-2 w-32 text-right">Ingående saldo</th>
|
||
<th className="py-2 w-32 text-right">Förändring</th>
|
||
<th className="py-2 w-32 text-right">Utgående saldo</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.rows.map((row) => (
|
||
<TrialBalanceSimplifiedRow
|
||
key={row.account_number}
|
||
row={row}
|
||
periodId={periodId}
|
||
onNavigateToAccount={onNavigateToAccount}
|
||
getNetBalance={getNetBalance}
|
||
formatSigned={formatSigned}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<table className="w-full text-sm min-w-[600px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-8"></th>
|
||
<th className="py-2 w-20">Konto</th>
|
||
<th className="py-2">Namn</th>
|
||
<th className="py-2 w-28 text-right">Period debet</th>
|
||
<th className="py-2 w-28 text-right">Period kredit</th>
|
||
<th className="py-2 w-28 text-right">Saldo debet</th>
|
||
<th className="py-2 w-28 text-right">Saldo kredit</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.rows.map((row) => (
|
||
<TrialBalanceDetailedRow
|
||
key={row.account_number}
|
||
row={row}
|
||
periodId={periodId}
|
||
onNavigateToAccount={onNavigateToAccount}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="font-semibold border-t-2">
|
||
<td className="py-2"></td>
|
||
<td colSpan={2} className="py-2">Summa</td>
|
||
<td className="py-2 text-right">
|
||
{formatAmount(data.rows.reduce((s, r) => s + r.period_debit, 0))}
|
||
</td>
|
||
<td className="py-2 text-right">
|
||
{formatAmount(data.rows.reduce((s, r) => s + r.period_credit, 0))}
|
||
</td>
|
||
<td className={`py-2 text-right ${data.isBalanced ? 'text-success' : 'text-destructive'}`}>
|
||
{formatAmount(data.totalDebit)}
|
||
</td>
|
||
<td className={`py-2 text-right ${data.isBalanced ? 'text-success' : 'text-destructive'}`}>
|
||
{formatAmount(data.totalCredit)}
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<>
|
||
<tr className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||
<td className="py-2" onClick={(e) => e.stopPropagation()}>
|
||
<Toggle />
|
||
</td>
|
||
<td
|
||
className="py-2 cursor-pointer"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||
</td>
|
||
<td
|
||
className="py-2 cursor-pointer"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
{row.account_name}
|
||
</td>
|
||
<td className={`py-2 text-right tabular-nums ${ob < 0 ? 'text-destructive' : ''}`}>
|
||
{formatSigned(ob)}
|
||
</td>
|
||
<td className={`py-2 text-right tabular-nums ${ch < 0 ? 'text-destructive' : ''}`}>
|
||
{formatSigned(ch)}
|
||
</td>
|
||
<td className={`py-2 text-right tabular-nums font-medium ${cb < 0 ? 'text-destructive' : ''}`}>
|
||
{formatSigned(cb)}
|
||
</td>
|
||
</tr>
|
||
<Panel colSpan={6} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<tr className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||
<td className="py-2" onClick={(e) => e.stopPropagation()}>
|
||
<Toggle />
|
||
</td>
|
||
<td
|
||
className="py-2 cursor-pointer"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||
</td>
|
||
<td
|
||
className="py-2 cursor-pointer"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
{row.account_name}
|
||
</td>
|
||
<td className="py-2 text-right">
|
||
{row.period_debit > 0 ? formatAmount(row.period_debit) : ''}
|
||
</td>
|
||
<td className="py-2 text-right">
|
||
{row.period_credit > 0 ? formatAmount(row.period_credit) : ''}
|
||
</td>
|
||
<td className="py-2 text-right">
|
||
{row.closing_debit > 0 ? formatAmount(row.closing_debit) : ''}
|
||
</td>
|
||
<td className="py-2 text-right">
|
||
{row.closing_credit > 0 ? formatAmount(row.closing_credit) : ''}
|
||
</td>
|
||
</tr>
|
||
<Panel colSpan={7} />
|
||
</>
|
||
)
|
||
}
|
||
export function IncomeStatementView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) {
|
||
const [data, setData] = useState<IncomeStatementReport | null>(null)
|
||
const [monthlyData, setMonthlyData] = useState<MonthlyDataPoint[]>([])
|
||
const [monthlyLoading, setMonthlyLoading] = useState(false)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(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 (
|
||
<Card>
|
||
<CardContent className="p-6 space-y-2">
|
||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||
<Skeleton key={i} className="h-4 w-full" />
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Ingen data för denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu
|
||
items={[
|
||
{ format: 'pdf', href: `/api/reports/income-statement/pdf?${reportQs}` },
|
||
{ format: 'xlsx', href: `/api/reports/income-statement/xlsx?${reportQs}` },
|
||
]}
|
||
/>
|
||
|
||
{!monthlyLoading && monthlyData.length > 0 && (
|
||
<IncomeExpenseChart months={monthlyData} />
|
||
)}
|
||
|
||
{/* Revenue */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Rörelseintäkter</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ReportSectionTable sections={data.revenue_sections} onNavigateToAccount={onNavigateToAccount} />
|
||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||
<span>Summa rörelseintäkter</span>
|
||
<span>{formatAmount(data.total_revenue)} kr</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Expenses */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Rörelsekostnader</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ReportSectionTable sections={data.expense_sections} negate onNavigateToAccount={onNavigateToAccount} />
|
||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||
<span>Summa rörelsekostnader</span>
|
||
<span>-{formatAmount(data.total_expenses)} kr</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Operating result */}
|
||
<Card>
|
||
<CardContent className="py-4">
|
||
<div className="flex justify-between font-bold text-lg">
|
||
<span>Rörelseresultat</span>
|
||
<span className={data.total_revenue - data.total_expenses >= 0 ? 'text-success' : 'text-destructive'}>
|
||
{formatAmount(data.total_revenue - data.total_expenses)} kr
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Financial items */}
|
||
{data.financial_sections.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Finansiella poster</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ReportSectionTable sections={data.financial_sections} onNavigateToAccount={onNavigateToAccount} />
|
||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||
<span>Summa finansiella poster</span>
|
||
<span>{formatAmount(data.total_financial)} kr</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Net result */}
|
||
<Card className="border-2">
|
||
<CardContent className="py-4">
|
||
<div className="flex justify-between font-bold text-xl">
|
||
<span>Årets resultat</span>
|
||
<span className={data.net_result >= 0 ? 'text-success' : 'text-destructive'}>
|
||
{formatAmount(data.net_result)} kr
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
|
||
const [data, setData] = useState<BalanceSheetReport | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(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 (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar balansräkning...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Ingen data för denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const isBalanced = Math.abs(data.total_assets - data.total_equity_liabilities) < 0.01
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu
|
||
items={[
|
||
{ format: 'pdf', href: `/api/reports/balance-sheet/pdf?${reportQs}` },
|
||
{ format: 'xlsx', href: `/api/reports/balance-sheet/xlsx?${reportQs}` },
|
||
]}
|
||
/>
|
||
|
||
{/* Assets */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Tillgångar</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ReportSectionTable sections={data.asset_sections} onNavigateToAccount={onNavigateToAccount} />
|
||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||
<span>Summa tillgångar</span>
|
||
<span>{formatAmount(data.total_assets)} kr</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Equity and liabilities */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Eget kapital och skulder</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ReportSectionTable sections={data.equity_liability_sections} onNavigateToAccount={onNavigateToAccount} />
|
||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||
<span>Summa eget kapital och skulder</span>
|
||
<span>{formatAmount(data.total_equity_liabilities)} kr</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Balance check */}
|
||
<Card className="border-2">
|
||
<CardContent className="py-4">
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-bold text-lg">Balanscheck</span>
|
||
{isBalanced ? (
|
||
<Badge variant="success" className="text-base px-3 py-1">
|
||
Balanserar
|
||
</Badge>
|
||
) : (
|
||
<div className="text-right">
|
||
<Badge variant="destructive" className="text-base px-3 py-1">
|
||
Balanserar ej
|
||
</Badge>
|
||
<p className="text-sm text-destructive mt-1">
|
||
Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!isBalanced && data.imbalance_diagnosis && (
|
||
<p className="text-sm text-muted-foreground mt-3 pt-3 border-t">
|
||
{data.imbalance_diagnosis.message}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<ResultatrapportReport | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(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 (
|
||
<Card>
|
||
<CardContent className="p-6 space-y-2">
|
||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||
<Skeleton key={i} className="h-4 w-full" />
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || data.groups.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga bokförda intäkter eller kostnader i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const hasPrior = data.prior_period !== null
|
||
const colCount = 4
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu
|
||
items={[
|
||
{ format: 'pdf', href: `/api/reports/resultatrapport/pdf?${reportQs}` },
|
||
{ format: 'xlsx', href: `/api/reports/resultatrapport/xlsx?${reportQs}` },
|
||
]}
|
||
/>
|
||
{latestVouchers && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{t('latest_posted_vouchers')}: {latestVouchers}
|
||
</p>
|
||
)}
|
||
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
|
||
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
|
||
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
|
||
<th
|
||
className="text-right font-medium px-4 py-2 w-32 tabular-nums"
|
||
title={`${data.period.start} till ${data.period.end}`}
|
||
>
|
||
Innevarande
|
||
</th>
|
||
<th
|
||
className="text-right font-medium px-4 py-2 w-32 tabular-nums"
|
||
title={hasPrior ? `${data.prior_period!.start} till ${data.prior_period!.end}` : undefined}
|
||
>
|
||
Föregående
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.groups.map((group) => (
|
||
<React.Fragment key={group.class}>
|
||
<tr className="bg-muted/30">
|
||
<td colSpan={colCount} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
|
||
{group.class_label}
|
||
</td>
|
||
</tr>
|
||
{group.rows.map((row) => (
|
||
<tr
|
||
key={row.account_number}
|
||
className="border-b last:border-0 cursor-pointer hover:bg-muted/50 transition-colors"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
<td className="px-4 py-1.5">
|
||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||
</td>
|
||
<td className="px-4 py-1.5">{row.account_name}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(row.current_period)}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">
|
||
{hasPrior ? formatAmount(row.prior_period) : '-'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
<tr className="border-b font-medium">
|
||
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
|
||
Summa
|
||
</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_current)}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">
|
||
{hasPrior ? formatAmount(group.subtotal_prior) : '-'}
|
||
</td>
|
||
</tr>
|
||
</React.Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-2">
|
||
<CardContent className="py-4">
|
||
<div className="grid gap-x-6 items-baseline grid-cols-[1fr_auto_auto]">
|
||
<span className="font-bold text-lg">Beräknat resultat</span>
|
||
<span className={`tabular-nums font-bold text-lg w-32 text-right ${data.net_result_current >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||
{formatAmount(data.net_result_current)} kr
|
||
</span>
|
||
<span className="tabular-nums text-base text-muted-foreground w-32 text-right">
|
||
{hasPrior ? `${formatAmount(data.net_result_prior)} kr` : '-'}
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
|
||
const t = useTranslations('reports')
|
||
const [data, setData] = useState<BalansrapportReport | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(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 (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar balansrapport...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || data.groups.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga balansposter i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu
|
||
items={[
|
||
{ format: 'pdf', href: `/api/reports/balansrapport/pdf?${reportQs}` },
|
||
{ format: 'xlsx', href: `/api/reports/balansrapport/xlsx?${reportQs}` },
|
||
]}
|
||
/>
|
||
{latestVouchers && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{t('latest_posted_vouchers')}: {latestVouchers}
|
||
</p>
|
||
)}
|
||
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
|
||
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
|
||
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
|
||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Ingående balans</th>
|
||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Förändring</th>
|
||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Utgående balans</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.groups.map((group) => (
|
||
<React.Fragment key={group.class}>
|
||
<tr className="bg-muted/30">
|
||
<td colSpan={5} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
|
||
{group.class_label}
|
||
</td>
|
||
</tr>
|
||
{group.rows.map((row) => (
|
||
<tr
|
||
key={row.account_number}
|
||
className="border-b last:border-0 cursor-pointer hover:bg-muted/50 transition-colors"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
<td className="px-4 py-1.5">
|
||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||
</td>
|
||
<td className="px-4 py-1.5">{row.account_name}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(row.ib)}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(row.period_change)}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(row.ub)}</td>
|
||
</tr>
|
||
))}
|
||
<tr className="border-b font-medium">
|
||
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
|
||
Summa
|
||
</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(group.subtotal_ib)}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">
|
||
{formatAmount(group.subtotal_ub - group.subtotal_ib)}
|
||
</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_ub)}</td>
|
||
</tr>
|
||
</React.Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-2">
|
||
<CardContent className="py-4 space-y-2">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Summa tillgångar</span>
|
||
<span className="tabular-nums">{formatAmount(data.total_assets_ub)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Summa eget kapital, reserver, avsättningar och skulder</span>
|
||
<span className="tabular-nums">{formatAmount(data.total_equity_liabilities_ub)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Beräknat resultat (ej bokslutsjusterat)</span>
|
||
<span className="tabular-nums">{formatAmount(data.beraknat_resultat)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between items-center pt-2 border-t">
|
||
<span className="font-bold text-lg">Balanscheck</span>
|
||
{data.is_balanced ? (
|
||
<Badge variant="success" className="text-base px-3 py-1">
|
||
Balanserar
|
||
</Badge>
|
||
) : (
|
||
<div className="text-right">
|
||
<Badge variant="destructive" className="text-base px-3 py-1">
|
||
Balanserar ej
|
||
</Badge>
|
||
{data.imbalance_diagnosis && (
|
||
<p className="text-sm text-destructive mt-1">
|
||
Differens: {formatAmount(Math.abs(data.imbalance_diagnosis.differens))} kr
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!data.is_balanced && data.imbalance_diagnosis && (
|
||
<p className="text-sm text-muted-foreground pt-2 border-t">
|
||
{data.imbalance_diagnosis.message}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 <p className="text-sm text-muted-foreground">Inga poster.</p>
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{sections.map((section) => (
|
||
<div key={section.title}>
|
||
<h4 className="text-sm font-semibold text-muted-foreground mb-1">{section.title}</h4>
|
||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
|
||
<tbody>
|
||
{section.rows.map((row) => (
|
||
<tr
|
||
key={row.account_number}
|
||
className={`border-b last:border-0 ${onNavigateToAccount ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}`}
|
||
onClick={onNavigateToAccount ? () => onNavigateToAccount(row.account_number) : undefined}
|
||
>
|
||
<td className="py-1 w-16"><AccountNumber number={row.account_number} name={row.account_name} /></td>
|
||
<td className="py-1">{row.account_name}</td>
|
||
<td className="py-1 text-right w-28">
|
||
{negate ? `-${formatAmount(row.amount)}` : formatAmount(row.amount)} kr
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table></div>
|
||
<div className="flex justify-between text-sm font-medium border-t pt-1 mt-1">
|
||
<span>{section.title}</span>
|
||
<span>
|
||
{negate ? `-${formatAmount(section.subtotal)}` : formatAmount(section.subtotal)} kr
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<section>
|
||
<div className="mb-3 flex items-center gap-3 px-1">
|
||
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
Lämna in själv, med fil
|
||
</h3>
|
||
<div className="h-px flex-1 bg-border/60" />
|
||
</div>
|
||
<div className="space-y-4">
|
||
<p className="text-[13px] leading-6 text-muted-foreground">
|
||
Du behöver inte vara ansluten till Skatteverket för att lämna in.
|
||
</p>
|
||
<ol className="list-decimal pl-6 space-y-1 text-sm text-muted-foreground">
|
||
<li>Ladda ner XML-filen nedan.</li>
|
||
<li>Logga in på skatteverket.se med BankID.</li>
|
||
<li>Öppna Moms- och arbetsgivardeklarationer och välj Deklarera via fil.</li>
|
||
<li>Ladda upp filen, granska och signera.</li>
|
||
</ol>
|
||
<p className="text-xs text-muted-foreground">
|
||
Vill du hellre fylla i rutorna för hand laddar du ner PDF:en och skriver av
|
||
beloppen (i hela kronor).
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" asChild className="gap-2">
|
||
<a href={xmlHref} target="_blank" rel="noopener noreferrer">
|
||
<FileCode className="h-4 w-4" />
|
||
Ladda ner fil för uppladdning (XML)
|
||
</a>
|
||
</Button>
|
||
<Button variant="outline" asChild className="gap-2">
|
||
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
|
||
<FileDown className="h-4 w-4" />
|
||
Ladda ner momsdeklaration (PDF)
|
||
</a>
|
||
</Button>
|
||
<Button variant="outline" asChild className="gap-2">
|
||
<a href={SKATTEVERKET_MOMS_URL} target="_blank" rel="noopener noreferrer">
|
||
<ExternalLink className="h-4 w-4" />
|
||
Öppna skatteverket.se
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* "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 (
|
||
<div className="space-y-4">
|
||
<p className="text-[13px] leading-6 text-muted-foreground">
|
||
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.
|
||
</p>
|
||
|
||
{booked && (
|
||
<div className="flex items-start gap-2 text-[13px] leading-6 text-muted-foreground">
|
||
<AlertCircle className="mt-1 h-4 w-4 shrink-0" aria-hidden="true" />
|
||
<p>
|
||
Momsen för perioden är redan bokförd:{' '}
|
||
<Link
|
||
href={`/bookkeeping/${booked.id}`}
|
||
className="underline underline-offset-2 hover:text-foreground"
|
||
>
|
||
verifikat {formatVoucher(booked)} ({formatDate(booked.entry_date)})
|
||
</Link>
|
||
. Annullera det verifikatet först om perioden behöver bokföras om.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{draft && (
|
||
<div className="flex items-start gap-2 text-[13px] leading-6 text-muted-foreground">
|
||
<AlertCircle className="mt-1 h-4 w-4 shrink-0" aria-hidden="true" />
|
||
<p>
|
||
Det finns redan ett{' '}
|
||
<Link
|
||
href={`/bookkeeping/${draft.id}`}
|
||
className="underline underline-offset-2 hover:text-foreground"
|
||
>
|
||
utkast för momsen i perioden
|
||
</Link>
|
||
.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{checksBlocked && (
|
||
<p className="text-[12.5px] leading-5 text-attn">
|
||
Det finns fel under steg 1, Kontrollera. Du kan bokföra momsen ändå, men
|
||
åtgärda felen innan du lämnar in.
|
||
</p>
|
||
)}
|
||
|
||
{failed ? (
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<p className="text-sm text-destructive">Kunde inte hämta verifikatförslaget.</p>
|
||
<Button variant="outline" onClick={() => setRefreshKey((k) => k + 1)}>
|
||
Försök igen
|
||
</Button>
|
||
</div>
|
||
) : !upToDate ? (
|
||
<Skeleton className="h-10 w-40" />
|
||
) : proposal?.is_empty ? (
|
||
<p className="text-sm text-muted-foreground">Ingen moms att bokföra för perioden.</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<Button
|
||
// A posted settlement blocks re-booking: the proposal re-clears the
|
||
// FULL period (it is not delta-aware), so booking twice would
|
||
// corrupt the 26xx balances. Annulling the verifikat restores them
|
||
// and re-enables the button.
|
||
disabled={!proposal || !canWrite || !!booked}
|
||
onClick={() => setDialogOpen(true)}
|
||
>
|
||
Skapa verifikat
|
||
</Button>
|
||
{!canWrite && (
|
||
<p className="text-xs text-muted-foreground">
|
||
Du har läsbehörighet och kan inte bokföra.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{proposal && (
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent
|
||
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||
// A reviewed-but-unbooked proposal must survive an accidental
|
||
// backdrop click or stray Escape (same rationale as
|
||
// NewJournalEntryDialog): closing is explicit via the header X.
|
||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||
onPointerDownOutside={(e) => e.preventDefault()}
|
||
onInteractOutside={(e) => e.preventDefault()}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>Bokför momsrapport</DialogTitle>
|
||
</DialogHeader>
|
||
<p className="text-sm text-muted-foreground">
|
||
Förslaget bygger på momsrapporten för {proposal.period_label}. Justera
|
||
datum, konton eller belopp vid behov och bokför sedan verifikatet.
|
||
</p>
|
||
{dialogOpen && (
|
||
<JournalEntryForm
|
||
bare
|
||
sourceType="vat_settlement"
|
||
initialDate={proposal.entry_date}
|
||
initialDescription={proposal.description}
|
||
initialLines={initialLines}
|
||
onCreated={() => {
|
||
setDialogOpen(false)
|
||
setRefreshKey((k) => k + 1)
|
||
}}
|
||
/>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
/** 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 (
|
||
<div
|
||
className="mx-auto flex w-full max-w-3xl items-center gap-3 overflow-x-auto px-1"
|
||
role="tablist"
|
||
aria-label="Momsdeklarationens steg"
|
||
>
|
||
{steps.map((step, i) => (
|
||
<React.Fragment key={step.n}>
|
||
{i > 0 && <span className="h-px min-w-4 flex-1 bg-border" aria-hidden="true" />}
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active === step.n}
|
||
onClick={() => onSelect(step.n)}
|
||
className="group flex shrink-0 items-center gap-2 text-left"
|
||
>
|
||
<span
|
||
className={cn(
|
||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums transition-colors duration-150',
|
||
step.done
|
||
? 'border-success/40 text-success'
|
||
: active === step.n
|
||
? 'border-foreground bg-foreground text-background'
|
||
: 'border-border text-muted-foreground group-hover:border-foreground/30',
|
||
)}
|
||
>
|
||
{step.done ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : step.n}
|
||
</span>
|
||
<span className="leading-tight">
|
||
<span
|
||
className={cn(
|
||
'block text-[12.5px] transition-colors duration-150',
|
||
active === step.n
|
||
? 'font-medium'
|
||
: 'text-muted-foreground group-hover:text-foreground',
|
||
)}
|
||
>
|
||
{step.label}
|
||
</span>
|
||
{step.sub && (
|
||
<span
|
||
className={cn(
|
||
'block text-[11px] tabular-nums',
|
||
step.warn ? 'text-attn' : 'text-muted-foreground',
|
||
)}
|
||
>
|
||
{step.sub}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<VatPeriodType | null>(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<string | null>(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<number | null>(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<string | null>(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 ? <PageHeader title={pageTitle} /> : null
|
||
|
||
if (settingsLoading || periodType === null) {
|
||
return (
|
||
<div className="space-y-8">
|
||
{bareHeader}
|
||
<Card>
|
||
<CardContent className="p-6 space-y-4">
|
||
<Skeleton className="h-5 w-32" />
|
||
<Skeleton className="h-64" />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (notVatRegistered) {
|
||
return (
|
||
<div className="space-y-8">
|
||
{bareHeader}
|
||
<EmptyState
|
||
icon={Percent}
|
||
title="Företaget är inte momsregistrerat"
|
||
description="Momsdeklarationen bygger på företagets skatteinställningar. Om företaget är momsregistrerat anger du momsregistrering och redovisningsperiod i inställningarna, så visas deklarationen här."
|
||
actionLabel="Öppna skatteinställningar"
|
||
actionHref="/settings/tax"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<div className="space-y-8">
|
||
{bareHeader}
|
||
<EmptyState
|
||
icon={Percent}
|
||
title="Redovisningsperiod för moms saknas"
|
||
description="Företaget är momsregistrerat men ingen redovisningsperiod (månad, kvartal eller helår) är vald. Ange den i skatteinställningarna så visas deklarationen för rätt period."
|
||
actionLabel="Öppna skatteinställningar"
|
||
actionHref="/settings/tax"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<VatDrillContext.Provider value={{ fiscalPeriodId: isYearly ? fiscalPeriodId : undefined }}>
|
||
<div className="space-y-8">
|
||
{/* 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 && (
|
||
<PageHeader
|
||
title={pageTitle}
|
||
action={
|
||
<ReportExportMenu
|
||
variant="default"
|
||
items={[
|
||
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
)}
|
||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||
{/* 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). */}
|
||
<ContextPicker
|
||
items={[
|
||
{ id: 'monthly', label: 'Månadsvis' },
|
||
{ id: 'quarterly', label: 'Kvartalsvis' },
|
||
{ id: 'yearly', label: 'Årsvis' },
|
||
]}
|
||
value={periodType}
|
||
onChange={(value) => handlePeriodTypeChange(value as VatPeriodType)}
|
||
triggerLabel="Period"
|
||
ariaLabel="Periodicitet"
|
||
/>
|
||
{isYearly ? (
|
||
// Annual VAT covers a räkenskapsår: picked here, not a
|
||
// calendar year.
|
||
<FyPicker
|
||
value={fiscalPeriodId || null}
|
||
onChange={(id, fp) => {
|
||
setFiscalPeriodId(id || '')
|
||
setFiscalPeriodEnd(fp?.period_end ?? null)
|
||
}}
|
||
includeAllOption={false}
|
||
hideFuturePeriods
|
||
/>
|
||
) : (
|
||
<ContextPicker
|
||
items={fusedPeriodItems}
|
||
value={`${year}:${period}`}
|
||
onChange={(id) => {
|
||
const [y, p] = id.split(':').map(Number)
|
||
setYear(y)
|
||
setPeriod(p)
|
||
}}
|
||
triggerLabel={fusedLabel}
|
||
ariaLabel="Redovisningsperiod"
|
||
/>
|
||
)}
|
||
{!pageTitle && (
|
||
<ReportExportMenu
|
||
variant="default"
|
||
items={[
|
||
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
|
||
]}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center p-8 text-center">
|
||
<AlertCircle className="mb-2 h-6 w-6 text-destructive" />
|
||
<p className="mb-4 text-sm text-destructive">{error}</p>
|
||
<Button variant="outline" size="sm" onClick={() => setRetryKey((k) => k + 1)}>
|
||
Försök igen
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{!error && (awaitingFiscalPeriod || (loading && !data)) && (
|
||
<Card>
|
||
<CardContent className="p-6 space-y-4">
|
||
<Skeleton className="h-5 w-48" />
|
||
<Skeleton className="h-64" />
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{data && !awaitingFiscalPeriod && (
|
||
<div
|
||
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
|
||
>
|
||
{/* 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. */}
|
||
<VatStepper
|
||
active={activeStep}
|
||
onSelect={setChosenStep}
|
||
errorCount={errorCount}
|
||
warningCount={warningCount}
|
||
ruta49={data.rutor.ruta49}
|
||
bookingStatus={bookingStatus}
|
||
/>
|
||
|
||
{activeStep === 1 && (
|
||
<section className="mx-auto max-w-3xl space-y-3">
|
||
<VatChecksCard
|
||
checks={checks}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||
onCorrected={() => setRetryKey((k) => k + 1)}
|
||
/>
|
||
<div className="flex justify-end">
|
||
<Button variant="outline" size="sm" onClick={() => setChosenStep(2)}>
|
||
Nästa: Granska deklarationen →
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeStep === 2 && (
|
||
<section className="space-y-3">
|
||
<div className="mx-auto max-w-2xl">
|
||
<div className="flex flex-wrap items-baseline justify-between gap-3 px-1">
|
||
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
Momsdeklaration · {data.period.start} till {data.period.end}
|
||
</h3>
|
||
<span className="text-[11.5px] tabular-nums text-muted-foreground">
|
||
{data.invoiceCount} fakturor · {data.transactionCount} transaktioner
|
||
</span>
|
||
</div>
|
||
<div className="mt-4 space-y-8">
|
||
<div>
|
||
{/* Utgående moms */}
|
||
<div>
|
||
<h3 className="mb-3 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
Utgående moms (försäljning)
|
||
</h3>
|
||
<Table>
|
||
<TableBody>
|
||
{data.rutor.ruta05 > 0 && (
|
||
<VatRutaRow
|
||
ruta="05"
|
||
label="Momspliktig försäljning"
|
||
amount={data.rutor.ruta05}
|
||
baseAmount={0}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
)}
|
||
<VatRutaRow
|
||
ruta="10"
|
||
label="Utgående moms 25%"
|
||
amount={data.rutor.ruta10}
|
||
baseAmount={data.breakdown.invoices.base25}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
<VatRutaRow
|
||
ruta="11"
|
||
label="Utgående moms 12%"
|
||
amount={data.rutor.ruta11}
|
||
baseAmount={data.breakdown.invoices.base12}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
<VatRutaRow
|
||
ruta="12"
|
||
label="Utgående moms 6%"
|
||
amount={data.rutor.ruta12}
|
||
baseAmount={data.breakdown.invoices.base6}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
<VatRutaRow
|
||
ruta="39"
|
||
label="Tjänster EU (omvänd skattskyldighet)"
|
||
amount={0}
|
||
baseAmount={data.rutor.ruta39}
|
||
noVat
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
<VatRutaRow
|
||
ruta="40"
|
||
label="Export utanför EU"
|
||
amount={0}
|
||
baseAmount={data.rutor.ruta40}
|
||
noVat
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
</TableBody>
|
||
<tfoot>
|
||
<tr className="border-t font-medium">
|
||
<td className="py-2">Summa utgående moms</td>
|
||
<td className="py-2 text-right tabular-nums">
|
||
{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
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</Table>
|
||
|
||
{/* 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) && (
|
||
<>
|
||
<h3 className="mb-3 mt-6 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
Omvänd skattskyldighet (inköp)
|
||
</h3>
|
||
<Table>
|
||
<TableBody>
|
||
<VatRutaRow ruta="20" label="Inköp av varor från annat EU-land" amount={0} baseAmount={data.rutor.ruta20} noVat periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="21" label="Inköp av tjänster från annat EU-land" amount={0} baseAmount={data.rutor.ruta21} noVat periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="22" label="Inköp av tjänster utanför EU" amount={0} baseAmount={data.rutor.ruta22} noVat periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="23" label="Inköp av varor i Sverige" amount={0} baseAmount={data.rutor.ruta23} noVat periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="24" label="Övriga inköp av tjänster i Sverige" amount={0} baseAmount={data.rutor.ruta24} noVat periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="30" label="Utgående moms 25% (omvänd)" amount={data.rutor.ruta30} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="31" label="Utgående moms 12% (omvänd)" amount={data.rutor.ruta31} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="32" label="Utgående moms 6% (omvänd)" amount={data.rutor.ruta32} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||
</TableBody>
|
||
</Table>
|
||
</>
|
||
)}
|
||
|
||
{/* Moms vid import */}
|
||
{(data.rutor.ruta50 > 0 || data.rutor.ruta60 > 0 || data.rutor.ruta61 > 0 || data.rutor.ruta62 > 0) && (
|
||
<>
|
||
<h3 className="mb-3 mt-6 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
Moms vid import
|
||
</h3>
|
||
<Table>
|
||
<TableBody>
|
||
<VatRutaRow ruta="50" label="Beskattningsunderlag vid import" amount={0} baseAmount={data.rutor.ruta50} noVat periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="60" label="Utgående moms 25% import" amount={data.rutor.ruta60} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="61" label="Utgående moms 12% import" amount={data.rutor.ruta61} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="62" label="Utgående moms 6% import" amount={data.rutor.ruta62} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||
</TableBody>
|
||
</Table>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
{/* Ingående moms */}
|
||
<div>
|
||
<h3 className="mb-3 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
Ingående moms (avdragsgill)
|
||
</h3>
|
||
<Table>
|
||
<TableBody>
|
||
<VatRutaRow
|
||
ruta="48"
|
||
label="Ingående moms att dra av"
|
||
amount={data.rutor.ruta48}
|
||
baseAmount={0}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
{data.breakdown.transactions.ruta48 > 0 && (
|
||
<tr className="text-muted-foreground">
|
||
<td className="py-1 pl-6 text-xs">- från transaktioner</td>
|
||
<td className="py-1 text-right text-xs">
|
||
{formatAmount(data.breakdown.transactions.ruta48)} kr
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{data.breakdown.receipts.ruta48 > 0 && (
|
||
<tr className="text-muted-foreground">
|
||
<td className="py-1 pl-6 text-xs">- från kvitton</td>
|
||
<td className="py-1 text-right text-xs">
|
||
{formatAmount(data.breakdown.receipts.ruta48)} kr
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</TableBody>
|
||
<tfoot>
|
||
<tr className="border-t font-medium">
|
||
<td className="py-2">Summa ingående moms</td>
|
||
<td className="py-2 text-right tabular-nums">{formatAmount(data.rutor.ruta48)} kr</td>
|
||
</tr>
|
||
</tfoot>
|
||
</Table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Ruta 49 as the emphasized document foot (concept skv-foot). */}
|
||
<div className="mt-8 flex items-baseline gap-3 border-t-2 border-foreground/80 pt-3">
|
||
<span className="font-mono text-xs text-muted-foreground">49</span>
|
||
<span className="flex-1 text-sm font-medium">
|
||
{data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'}
|
||
</span>
|
||
<span className="text-sm font-semibold tabular-nums">
|
||
{formatAmount(Math.abs(data.rutor.ruta49))} kr
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end">
|
||
<Button variant="outline" size="sm" onClick={() => setChosenStep(3)}>
|
||
Nästa: Bokför momsen →
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeStep === 3 && (
|
||
<section className="mx-auto max-w-3xl space-y-3">
|
||
<VatBookingCard
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||
checksBlocked={checksBlocked}
|
||
onStatus={setBookingStatus}
|
||
/>
|
||
<div className="flex justify-end">
|
||
<Button variant="outline" size="sm" onClick={() => setChosenStep(4)}>
|
||
Nästa: Lämna in →
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeStep === 4 && (
|
||
<section className="mx-auto max-w-3xl space-y-8">
|
||
<SkatteverketPanel
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||
fiscalYearEnd={
|
||
isYearly && fiscalPeriodEnd
|
||
? {
|
||
year: Number(fiscalPeriodEnd.slice(0, 4)),
|
||
month: Number(fiscalPeriodEnd.slice(5, 7)),
|
||
}
|
||
: undefined
|
||
}
|
||
hasData={data !== null}
|
||
localBlocked={checksBlocked}
|
||
/>
|
||
<VatManualFilingCard
|
||
xmlHref={`/api/reports/vat-declaration/eskd?${vatQueryString()}`}
|
||
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
|
||
/>
|
||
</section>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 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 && (
|
||
<SkatteverketPanel
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||
fiscalYearEnd={
|
||
isYearly && fiscalPeriodEnd
|
||
? {
|
||
year: Number(fiscalPeriodEnd.slice(0, 4)),
|
||
month: Number(fiscalPeriodEnd.slice(5, 7)),
|
||
}
|
||
: undefined
|
||
}
|
||
hasData={data !== null}
|
||
localBlocked={checksBlocked}
|
||
/>
|
||
)}
|
||
</div>
|
||
</VatDrillContext.Provider>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<tr className="border-b">
|
||
<td className="py-2">
|
||
{canDrill && (
|
||
<span className="inline-block align-middle mr-1">
|
||
<expansion.Toggle />
|
||
</span>
|
||
)}
|
||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
|
||
{label}
|
||
</td>
|
||
<td className="py-2 text-right tabular-nums">{noVat ? `${formatAmount(baseAmount)} kr` : `${formatAmount(amount)} kr`}</td>
|
||
</tr>
|
||
{!noVat && baseAmount > 0 && (
|
||
<tr className="text-muted-foreground">
|
||
<td className="py-1 pl-6 text-xs">Underlag</td>
|
||
<td className="py-1 text-right text-xs tabular-nums">{formatAmount(baseAmount)} kr</td>
|
||
</tr>
|
||
)}
|
||
{canDrill && <expansion.Panel colSpan={2} />}
|
||
</>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||
<div className="space-y-1">
|
||
<Label htmlFor={inputId} className="text-xs text-muted-foreground">
|
||
Per datum
|
||
</Label>
|
||
<Input
|
||
id={inputId}
|
||
type="date"
|
||
value={asOfDate}
|
||
onChange={(e) => {
|
||
if (e.target.value) onAsOfDateChange(e.target.value)
|
||
}}
|
||
className="w-40"
|
||
/>
|
||
</div>
|
||
<ReportExportMenu
|
||
items={[
|
||
{ format: 'pdf', href: `${exportBase}/pdf?as_of_date=${asOfDate}` },
|
||
{ format: 'xlsx', href: `${exportBase}/xlsx?as_of_date=${asOfDate}` },
|
||
]}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||
const [data, setData] = useState<SupplierLedgerData | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(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 (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar leverantörsreskontra...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || !data.ledger) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Ingen data tillgänglig.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const { ledger, reconciliation } = data
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReskontraToolbar
|
||
asOfDate={asOfDate}
|
||
onAsOfDateChange={setAsOfDate}
|
||
inputId="supplier-ledger-as-of"
|
||
exportBase="/api/reports/supplier-ledger"
|
||
/>
|
||
{/* Summary cards */}
|
||
<div className="grid md:grid-cols-3 gap-4">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
|
||
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
|
||
{ledger.unconverted_fx_count > 0 && (
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen.
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums text-success">{formatAmount(ledger.total_current)} kr</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Aging table */}
|
||
{ledger.entries.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Ålderfördelning per leverantör</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-8"></th>
|
||
<th className="py-2">Leverantör</th>
|
||
<th className="py-2 text-right">Ej förfallet</th>
|
||
<th className="py-2 text-right">1-30 dagar</th>
|
||
<th className="py-2 text-right">31-60 dagar</th>
|
||
<th className="py-2 text-right">61-90 dagar</th>
|
||
<th className="py-2 text-right">90+ dagar</th>
|
||
<th className="py-2 text-right font-semibold">Totalt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{ledger.entries.map((entry) => (
|
||
<SupplierLedgerRow key={entry.supplier_id} entry={entry} />
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="font-semibold border-t-2">
|
||
<td className="py-2"></td>
|
||
<td className="py-2">Summa</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
|
||
<td className="py-2 text-right text-destructive">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table></div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Reconciliation */}
|
||
{reconciliation && (
|
||
<Card className="border-2">
|
||
<CardHeader>
|
||
<CardTitle>Avstämning mot <AccountNumber number="2440" /></CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span>Leverantörsreskontra (summa utestående)</span>
|
||
<span className="font-mono">{formatAmount(reconciliation.supplier_ledger_total)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span><AccountNumber number="2440" /> saldo (huvudbok)</span>
|
||
<span className="font-mono">{formatAmount(reconciliation.account_2440_balance)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||
<span>Differens</span>
|
||
<span className={reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}>
|
||
{formatAmount(reconciliation.difference)} kr
|
||
</span>
|
||
</div>
|
||
<div className="pt-2 space-y-2">
|
||
{reconciliation.is_reconciled ? (
|
||
<Badge variant="success">Avstämd</Badge>
|
||
) : (
|
||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||
)}
|
||
{reconciliation.unconverted_fx_count > 0 && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{reconciliation.unconverted_fx_count} leverantörsfaktura i utländsk valuta saknar växelkurs: differensen kan bero på saknade kursuppgifter snarare än felbokning.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<>
|
||
<tr className="border-b last:border-0 hover:bg-muted/30 transition-colors">
|
||
<td className="py-2"><Toggle /></td>
|
||
<td className="py-2">{entry.supplier_name}</td>
|
||
<td className="py-2 text-right tabular-nums">{entry.current > 0 ? formatAmount(entry.current) : ''}</td>
|
||
<td className="py-2 text-right tabular-nums">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
|
||
<td className="py-2 text-right tabular-nums">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
|
||
<td className="py-2 text-right tabular-nums">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
|
||
<td className="py-2 text-right tabular-nums text-destructive">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
|
||
<td className="py-2 text-right tabular-nums font-semibold">{formatAmount(entry.total_outstanding)}</td>
|
||
</tr>
|
||
<Panel colSpan={8} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
// --- 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<string, string>
|
||
}[]
|
||
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<GeneralLedgerData | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(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 (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar huvudbok...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || data.accounts.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga bokförda verifikationer i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?${reportQuery(periodId, dateRange, dimensionFilter)}` }]} />
|
||
{/* Account range filter */}
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex flex-wrap items-end gap-4">
|
||
<div>
|
||
<Label>Konto från</Label>
|
||
<input
|
||
type="text"
|
||
value={accountFrom}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Konto till</Label>
|
||
<input
|
||
type="text"
|
||
value={accountTo}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<Button onClick={() => fetchData()} variant="outline">
|
||
Filtrera
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{data.period.start && (
|
||
<p className="text-sm text-muted-foreground">
|
||
Period: {data.period.start}: {data.period.end} | {data.accounts.length} konton
|
||
</p>
|
||
)}
|
||
|
||
{data.accounts.map((account) => (
|
||
<Card key={account.account_number}>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-base">
|
||
<AccountNumber number={account.account_number} name={account.account_name} showName />
|
||
</CardTitle>
|
||
<span className="text-sm text-muted-foreground">
|
||
IB: {formatAmount(account.opening_balance)} kr
|
||
</span>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-16">Ver.nr</th>
|
||
<th className="py-2 w-24">Datum</th>
|
||
<th className="py-2">Beskrivning</th>
|
||
<th className="py-2 w-24 text-right">Debet</th>
|
||
<th className="py-2 w-24 text-right">Kredit</th>
|
||
<th className="py-2 w-28 text-right">Saldo</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{account.lines.map((line, i) => (
|
||
<tr key={i} className="border-b last:border-0">
|
||
<td className="py-1.5 font-mono text-xs">
|
||
<Link
|
||
href={`/bookkeeping/${line.journal_entry_id}`}
|
||
className="text-foreground underline underline-offset-4 decoration-muted-foreground/40 hover:decoration-foreground transition-colors"
|
||
>
|
||
{formatVoucher(line)}
|
||
</Link>
|
||
</td>
|
||
<td className="py-1.5">{formatDate(line.date)}</td>
|
||
<td className="py-1.5 max-w-[240px]">
|
||
<span className="truncate block">{line.description}</span>
|
||
{line.dimensions && Object.keys(line.dimensions).length > 0 && (
|
||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||
{Object.entries(line.dimensions)
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([, code]) => code)
|
||
.join(' · ')}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="py-1.5 text-right tabular-nums">
|
||
{line.debit > 0 ? formatAmount(line.debit) : ''}
|
||
</td>
|
||
<td className="py-1.5 text-right tabular-nums">
|
||
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
||
</td>
|
||
<td className="py-1.5 text-right font-mono tabular-nums">{formatAmount(line.balance)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="font-semibold border-t-2">
|
||
<td colSpan={3} className="py-2">Summa / Utgående balans</td>
|
||
<td className="py-2 text-right">{formatAmount(account.total_debit)}</td>
|
||
<td className="py-2 text-right">{formatAmount(account.total_credit)}</td>
|
||
<td className="py-2 text-right font-mono">{formatAmount(account.closing_balance)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table></div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- 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<JournalRegisterData | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [expandedEntries, setExpandedEntries] = useState<Set<number>>(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 (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar grundbok...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || data.entries.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga bokförda verifikationer i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/journal-register/xlsx?period_id=${periodId}` }]} />
|
||
{data.period.start && (
|
||
<p className="text-sm text-muted-foreground">
|
||
Period: {data.period.start}: {data.period.end} | {data.total_entries} verifikationer
|
||
</p>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Grundbok (registreringsordning)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-8"></th>
|
||
<th className="py-2 w-16">Ver.nr</th>
|
||
<th className="py-2 w-24">Datum</th>
|
||
<th className="py-2">Beskrivning</th>
|
||
<th className="py-2 w-24">Typ</th>
|
||
<th className="py-2 w-24 text-right">Debet</th>
|
||
<th className="py-2 w-24 text-right">Kredit</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.entries.map((entry, index) => {
|
||
const isExpanded = expandedEntries.has(index)
|
||
const isReversed = entry.status === 'reversed'
|
||
|
||
return (
|
||
<React.Fragment key={index}>
|
||
<tr
|
||
className={`border-b cursor-pointer hover:bg-muted/50 ${isReversed ? 'line-through opacity-60' : ''}`}
|
||
onClick={() => toggleEntry(index)}
|
||
>
|
||
<td className="py-2">
|
||
{isExpanded ? (
|
||
<ChevronDown className="h-4 w-4" />
|
||
) : (
|
||
<ChevronRight className="h-4 w-4" />
|
||
)}
|
||
</td>
|
||
<td className="py-2 font-mono text-xs">
|
||
{formatVoucher(entry)}
|
||
</td>
|
||
<td className="py-2">{formatDate(entry.date)}</td>
|
||
<td className="py-2">
|
||
{entry.description}
|
||
{isReversed && (
|
||
<Badge variant="outline" className="ml-2 text-xs">Makulerad</Badge>
|
||
)}
|
||
</td>
|
||
<td className="py-2 text-xs text-muted-foreground">{entry.source_type}</td>
|
||
<td className="py-2 text-right tabular-nums">{formatAmount(entry.total_debit)}</td>
|
||
<td className="py-2 text-right tabular-nums">{formatAmount(entry.total_credit)}</td>
|
||
</tr>
|
||
{isExpanded && entry.lines.map((line, lineIndex) => (
|
||
<tr key={`${index}-${lineIndex}`} className="bg-muted/30 border-b last:border-0">
|
||
<td></td>
|
||
<td></td>
|
||
<td className="py-1"><AccountNumber number={line.account_number} name={line.account_name} size="sm" /></td>
|
||
<td className="py-1 text-muted-foreground">{line.account_name}</td>
|
||
<td></td>
|
||
<td className="py-1 text-right">
|
||
{line.debit > 0 ? formatAmount(line.debit) : ''}
|
||
</td>
|
||
<td className="py-1 text-right">
|
||
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</React.Fragment>
|
||
)
|
||
})}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="font-semibold border-t-2">
|
||
<td colSpan={5} className="py-2">Summa</td>
|
||
<td className="py-2 text-right">{formatAmount(data.total_debit)}</td>
|
||
<td className="py-2 text-right">{formatAmount(data.total_credit)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table></div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- 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<Record<string, { journal_entry_id: string; voucher_series: string; voucher_number: number } | undefined>>({})
|
||
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 (
|
||
<tr key={inv.invoice_id} className="bg-muted/30 border-b last:border-0">
|
||
<td></td>
|
||
<td className="py-1 text-xs" colSpan={2}>
|
||
<Link href={targetHref} className="font-mono hover:underline underline-offset-4">
|
||
{inv.invoice_number || '(utkast)'}
|
||
</Link>
|
||
{entry && (
|
||
<span className="ml-2 text-muted-foreground font-mono">
|
||
{formatVoucher(entry)}
|
||
</span>
|
||
)}
|
||
<span className="text-muted-foreground ml-2 tabular-nums">{formatDate(inv.invoice_date)}</span>
|
||
<span className="text-muted-foreground ml-2 tabular-nums">förfaller {formatDate(inv.due_date)}</span>
|
||
</td>
|
||
<td className="py-1 text-right text-xs text-muted-foreground" colSpan={2}>
|
||
{inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'}
|
||
</td>
|
||
<td className="py-1 text-right text-xs text-muted-foreground">
|
||
{inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)} ${inv.currency}` : ''}
|
||
</td>
|
||
<td></td>
|
||
<td className="py-1 text-right text-xs font-medium tabular-nums">
|
||
{formatAmount(inv.outstanding)} {inv.currency}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
{loading && (
|
||
<tr className="bg-muted/30">
|
||
<td></td>
|
||
<td colSpan={7} className="py-1 text-[10px] text-muted-foreground">Letar verifikat…</td>
|
||
</tr>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
export function ARLedgerView({ periodId }: { periodId: string }) {
|
||
const [data, setData] = useState<ARLedgerData | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(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 (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar kundreskontra...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || !data.ledger) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Ingen data tillgänglig.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const { ledger, reconciliation } = data
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReskontraToolbar
|
||
asOfDate={asOfDate}
|
||
onAsOfDateChange={setAsOfDate}
|
||
inputId="ar-ledger-as-of"
|
||
exportBase="/api/reports/ar-ledger"
|
||
/>
|
||
{/* Summary cards */}
|
||
<div className="grid md:grid-cols-3 gap-4">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
|
||
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
|
||
{ledger.unconverted_fx_count > 0 && (
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen.
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums text-success">{formatAmount(ledger.total_current)} kr</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Aging table with expandable invoice details */}
|
||
{ledger.entries.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Ålderfördelning per kund</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-8"></th>
|
||
<th className="py-2">Kund</th>
|
||
<th className="py-2 text-right">Ej förfallet</th>
|
||
<th className="py-2 text-right">1-30 dagar</th>
|
||
<th className="py-2 text-right">31-60 dagar</th>
|
||
<th className="py-2 text-right">61-90 dagar</th>
|
||
<th className="py-2 text-right">90+ dagar</th>
|
||
<th className="py-2 text-right font-semibold">Totalt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{ledger.entries.map((entry) => {
|
||
const isExpanded = expandedCustomers.has(entry.customer_id)
|
||
return (
|
||
<React.Fragment key={entry.customer_id}>
|
||
<tr
|
||
className="border-b cursor-pointer hover:bg-muted/50"
|
||
onClick={() => toggleCustomer(entry.customer_id)}
|
||
>
|
||
<td className="py-2">
|
||
{isExpanded ? (
|
||
<ChevronDown className="h-4 w-4" />
|
||
) : (
|
||
<ChevronRight className="h-4 w-4" />
|
||
)}
|
||
</td>
|
||
<td className="py-2">{entry.customer_name}</td>
|
||
<td className="py-2 text-right">{entry.current > 0 ? formatAmount(entry.current) : ''}</td>
|
||
<td className="py-2 text-right">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
|
||
<td className="py-2 text-right">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
|
||
<td className="py-2 text-right">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
|
||
<td className="py-2 text-right text-destructive">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
|
||
<td className="py-2 text-right font-semibold">{formatAmount(entry.total_outstanding)}</td>
|
||
</tr>
|
||
{isExpanded && (
|
||
<ARCustomerInvoiceRows
|
||
customerId={entry.customer_id}
|
||
invoices={entry.invoices}
|
||
/>
|
||
)}
|
||
</React.Fragment>
|
||
)
|
||
})}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="font-semibold border-t-2">
|
||
<td className="py-2"></td>
|
||
<td className="py-2">Summa</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
|
||
<td className="py-2 text-right text-destructive">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table></div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Reconciliation */}
|
||
{reconciliation && (
|
||
<Card className="border-2">
|
||
<CardHeader>
|
||
<CardTitle>Avstämning mot <AccountNumber number="1510" /></CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span>Kundreskontra (summa utestående)</span>
|
||
<span className="font-mono">{formatAmount(reconciliation.ar_ledger_total)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Kundfordringar (<AccountNumber number="1510" /> + <AccountNumber number="1513" />) saldo</span>
|
||
<span className="font-mono">{formatAmount(reconciliation.account_1510_balance)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||
<span>Differens</span>
|
||
<span className={reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}>
|
||
{formatAmount(reconciliation.difference)} kr
|
||
</span>
|
||
</div>
|
||
<div className="pt-2 space-y-2">
|
||
{reconciliation.is_reconciled ? (
|
||
<Badge variant="success">Avstämd</Badge>
|
||
) : (
|
||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||
)}
|
||
{reconciliation.unconverted_fx_count > 0 && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{reconciliation.unconverted_fx_count} kundfaktura i utländsk valuta saknar växelkurs: differensen kan bero på saknade kursuppgifter snarare än felbokning.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- 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 && (
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{dims.map((d) => {
|
||
const active = String(d.sie_dim_no) === dimNo
|
||
return (
|
||
<button
|
||
key={d.sie_dim_no}
|
||
type="button"
|
||
onClick={() => setDimNo(String(d.sie_dim_no))}
|
||
className={
|
||
active
|
||
? 'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150 bg-secondary border-border text-foreground'
|
||
: 'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150 bg-transparent border-border text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
|
||
}
|
||
>
|
||
{d.name}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-4">
|
||
{pivotPicker}
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar rapport...
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="space-y-4">
|
||
{pivotPicker}
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!data || data.groups.length === 0) {
|
||
return (
|
||
<div className="space-y-4">
|
||
{pivotPicker}
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga taggade intäkter eller kostnader i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const columnLabel = (c: DimensionPnlReport['columns'][number]) =>
|
||
c.code === null ? '(Utan dimension)' : c.code
|
||
|
||
const colCount = 2 + data.columns.length + 1
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
{pivotPicker || <span />}
|
||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/dimension-pnl/xlsx?${reportQs}` }]} />
|
||
</div>
|
||
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
|
||
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
|
||
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
|
||
{data.columns.map((c, i) => (
|
||
<th key={i} className="text-right font-medium px-4 py-2 w-32 tabular-nums" title={c.name ?? undefined}>
|
||
{columnLabel(c)}
|
||
</th>
|
||
))}
|
||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Totalt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.groups.map((group) => (
|
||
<React.Fragment key={group.class}>
|
||
<tr className="bg-muted/30">
|
||
<td colSpan={colCount} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
|
||
{group.class_label}
|
||
</td>
|
||
</tr>
|
||
{group.rows.map((row) => (
|
||
<tr key={row.account_number} className="border-b last:border-0">
|
||
<td className="px-4 py-1.5">
|
||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||
</td>
|
||
<td className="px-4 py-1.5">{row.account_name}</td>
|
||
{row.values.map((v, i) => (
|
||
<td key={i} className="px-4 py-1.5 text-right tabular-nums">
|
||
{Math.abs(v) >= 0.005 ? formatAmount(v) : ''}
|
||
</td>
|
||
))}
|
||
<td className="px-4 py-1.5 text-right tabular-nums font-medium">{formatAmount(row.total)}</td>
|
||
</tr>
|
||
))}
|
||
<tr className="border-b font-medium">
|
||
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
|
||
Summa
|
||
</td>
|
||
{group.subtotals.map((v, i) => (
|
||
<td key={i} className="px-4 py-1.5 text-right tabular-nums">{formatAmount(v)}</td>
|
||
))}
|
||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_total)}</td>
|
||
</tr>
|
||
</React.Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-2">
|
||
<CardContent className="py-4">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<tbody>
|
||
<tr>
|
||
<td className="px-4 font-bold text-lg">Beräknat resultat</td>
|
||
<td className="px-4" />
|
||
{data.net_per_column.map((v, i) => (
|
||
<td key={i} className={`px-4 text-right tabular-nums font-semibold w-32 ${v >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||
{formatAmount(v)}
|
||
</td>
|
||
))}
|
||
<td className={`px-4 text-right tabular-nums font-bold text-lg w-32 ${data.net_total >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||
{formatAmount(data.net_total)} kr
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|