refactor(reports): restructure declaration pages around the filing pipeline (#992)

* refactor(reports): restructure declaration pages around the filing pipeline

Momsdeklaration (/reports/vat-declaration) becomes the four-step flow the
user actually runs: kontrollera, granska, bokfoer, laemna in.

- NEW VatChecksCard, mounted first and ungated: the local pre-flight
  checks and the RC-basis-gap worklist used to render inside
  SkatteverketPanel BELOW the filing CTAs, and vanished entirely for
  free-tier or not-connected users, exactly the manual filers who must
  not file a declaration the checks would have blocked.
- The gap worklist scales: compact DataList rows (first 8 + visa alla),
  visible shared classification selects, per-row overrides, bulk
  "Korrigera alla" behind a confirm dialog with serial progress, and an
  in-page declaration refetch replacing "Ladda om sidan". The list
  outlives the aggregate RC_BASIS_MISSING check so remaining rows never
  vanish after the first fix.
- Summary card: status Badge + font-display headline amount instead of a
  Badge carrying the number; sanctioned h3 section heads; Table
  primitive; NEW import block (rutor 50/60-62) and the utgaende sum now
  includes 60-62 so it matches ruta 49 arithmetic; drill-downs preserved.
- SkatteverketPanel stops being an API console: three forward buttons
  (Validera, Spara utkast, Laas och signera), six lookup/recovery actions
  in an overflow menu with two-line descriptions, destructive confirms on
  radera/koppla bort, one truthful notice slot (not-found is info, never
  green), visible disabled-reasons instead of title attrs, signing link
  as a real anchor plus auto re-check of inlaemning on tab refocus, and
  per-period state reset so a Q1 signing link can never show Q4's
  kvittens.
- VatCompositionChart deleted (decorative donut mixing in/out VAT);
  export menu keeps xlsx only, XML/PDF live in the Laemna in card.
- Sibling declaration pages adopt the same grammar: PS gets shadcn
  selects, auto-fetch with stale-discard, refresh button, envelope-parse
  fix (message_sv never existed); NE/INK2 auto-fetch on fiscal-year
  change (kills stale-year data), shared keyboard-accessible
  DeclarationRutaRow (fixes the expense sign bug), whole-krona amounts
  matching filed SRU values, neutral info notes instead of bg-primary/10,
  Skeleton loading, accessible download errors.

UI-only: no API, schema, or dependency changes. All strings hardcoded
Swedish (statutory surface). Adversarially reviewed (19-agent pass); all
10 confirmed findings fixed in this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reports): address CodeRabbit review on #992

- formatWholeKronor truncates instead of rounds: NE/INK2 SRU generators
  drop oere with Math.trunc, and the UI must show the filed figures.
- SkatteverketPanel disconnect surfaces non-ok responses instead of
  silently stopping the spinner.
- VatChecksCard distinguishes a failed rc-basis-gaps fetch from a real
  zero-gap result: destructive note + retry instead of the benign
  'Inga verifikationer hittades'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-11 23:22:52 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 650c7be5e1
commit 0ef5593388
9 changed files with 2078 additions and 1304 deletions
+3
View File
@@ -66,3 +66,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-11] Counterparty template learning repair (#865): fixed the dead write path with ALTER COLUMN user_id DROP NOT NULL (kept the column and its data; a column drop is a separate cleanup) instead of re-plumbing user_id through the insert, because scoping is company_id-only since the multi-tenant refactor and RLS never reads user_id. Sign-mismatched matches (refund against an expense-learned template) are MIRRORED + requires_review rather than skipped: the swapped entry (debit bank / credit expense, VAT leg reversed) is the bookkeeping-correct refund shape, and skipping would just fall through to the dumb default; direction_mismatch results and opposite-direction "corrections" never write back into the template so a refund cannot flip the learned accounts. SIE extraction infers the 2641 VAT rate from voucher amounts (snap to 25/12/6% within 1.5pp, else drop the VAT leg) only when the voucher has exactly one deductible-VAT line: with several, each line's base is unknowable and the old 25% hardcode stays.
[2026-07-11] Counterparty template follow-ups from the compliance-bot review of PR #989: RC exclusion set extended with import output-VAT accounts (2615/2625/2635); RC credit notes now mirror both fiktiv legs (credit 2645 / debit 2614, income line-builder nets VAT credits against debits) so Ruta 30/48 net to zero instead of leaving the prior RC output unreversed. The 1.5pp snap tolerance is derived from the smallest gap between legal rates (6pp between 6% and 12%): 1.5pp accepts ore-rounding drift on small vouchers while an ambiguous observed rate (e.g. 9%) snaps to nothing and drops the VAT leg. Livsmedel 12->6% transition (April 2026): templates store the LEARNED rate, so one applied to a backdated pre-transition purchase books the new rate; accepted, the booking is review-visible and re-deriving rate-by-date is out of scope here.
[2026-07-11] Compliance-review round 2 on PR #989: (1) stale reduced_12 templates are review-gated across the livsmedel 12->6% transition (verified via swedish-vat skill: food dropped 2026-04-01, restaurang/hotell stay 12%) only when last_seen_date predates the transition, so actively-confirmed 12% counterparties keep flowing while pre-transition grocery templates get a human look; chose this over the bot's blanket flag-all-reduced_12 (too much friction) and over rate-by-date re-derivation (needs a rates table keyed on statute dates, out of scope). (2) Import-RC credit-note mirroring books the reversal on 2614 rather than 2615 (ruta 30 vs 60 attribution): accepted as-is because the entry balances, is review-gated, and the FORWARD legacy path has the same limitation (legacy fields cannot carry which output account history used); proper fix is persisting the learned RC output account, filed as future work.
[2026-07-11] Momsdeklaration UI overhaul: deleted VatCompositionChart (donut mixed utgående/omvänd/ingående moms as slices of one pie, answering no filing question) and reduced the VAT ReportExportMenu to xlsx-only (XML/PDF are filing artifacts, now owned solely by the "Lämna in" card): both are one-commit reverts if vetoed.
[2026-07-11] Hoisted local VAT checks + RC-gap worklist out of SkatteverketPanel into ungated VatChecksCard: the panel's paywall/not-connected early-returns hid compliance errors from exactly the users who file manually.
[2026-07-11] NE/INK2 amounts display in whole kronor (matches filed SRU values per SFL); momsdeklaration keeps öre (reconciles against ledger and settlement verifikat). Numbered h2 section headers instead of a stepper component on the VAT page: same sequencing legibility, a tenth of the diff.
+119
View File
@@ -0,0 +1,119 @@
'use client'
import { useId, useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
export interface DeclarationAccountLine {
accountNumber: string
accountName: string
amount: number
}
/**
* Default formatter: whole kronor, matching the filed SRU values. Truncation,
* not rounding: SFL "öretal faller bort" and the NE/INK2 SRU generators drop
* öre with Math.trunc, so the UI must agree with the filed figures. Callers
* that need öre pass their own formatter.
*/
export function formatWholeKronor(n: number): string {
// + 0 normalizes -0 (Math.trunc(-0.3) is -0, which sv-SE renders "−0").
return `${(Math.trunc(n) + 0).toLocaleString('sv-SE')} kr`
}
/**
* One expandable declaration row (NE-bilaga, INK2): ruta code chip, label,
* signed amount, and a per-account breakdown behind a keyboard-accessible
* toggle. Replaces the copy-pasted <tr onClick> rows that had no keyboard
* path, no aria-expanded, and double-encoded signs.
*
* `amount` is the SIGNED display value: expense callers pass the negated
* value instead of an isExpense flag, so a credit-balance expense renders
* with its true sign. Composes inside the Table primitive's TableBody.
*/
export function DeclarationRutaRow({
code,
label,
amount,
accounts = [],
hideWhenZero = true,
formatAmount = formatWholeKronor,
}: {
code: string
label: string
amount: number
accounts?: DeclarationAccountLine[]
hideWhenZero?: boolean
formatAmount?: (n: number) => string
}) {
const [expanded, setExpanded] = useState(false)
const panelId = useId()
if (amount === 0 && accounts.length === 0 && hideWhenZero) return null
const hasAccounts = accounts.length > 0
return (
<>
<tr
className={`border-b ${hasAccounts ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}`}
onClick={() => hasAccounts && setExpanded((v) => !v)}
>
<td className="py-2">
{hasAccounts && (
<button
type="button"
aria-expanded={expanded}
aria-controls={panelId}
aria-label={expanded ? `Dölj konton för ${code}` : `Visa konton för ${code}`}
className="mr-1 inline-flex h-6 w-6 items-center justify-center rounded align-middle hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(e) => {
e.stopPropagation()
setExpanded((v) => !v)
}}
>
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
)}
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{code}</span>
{label}
{hasAccounts && (
<span className="text-xs text-muted-foreground ml-2">
({accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right tabular-nums">{formatAmount(amount)}</td>
</tr>
{expanded && hasAccounts && (
<tr id={panelId}>
<td colSpan={2} className="py-2 pl-8 bg-muted/20">
<table className="w-full text-xs">
<tbody>
{accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1">
<AccountNumber
number={acc.accountNumber}
name={acc.accountName}
size="sm"
/>
</td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right tabular-nums">
{formatAmount(acc.amount)}
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
+330 -311
View File
@@ -1,12 +1,16 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Download, AlertCircle, Info } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { formatCurrency } from '@/lib/utils'
import type { INK2Declaration, INK2RSRUCode } from '@/lib/reports/ink2/types'
import { Skeleton } from '@/components/ui/skeleton'
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Download, AlertCircle, AlertTriangle, Info } from 'lucide-react'
import {
DeclarationRutaRow,
formatWholeKronor,
} from '@/components/reports/DeclarationRutaRow'
import type { INK2Declaration } from '@/lib/reports/ink2/types'
import {
INK2R_RUTA_LABELS,
INK2R_ASSET_CODES,
@@ -14,32 +18,65 @@ import {
INK2R_INCOME_CODES,
} from '@/lib/reports/ink2/types'
export function INK2DeclarationView({ periodId }: { periodId: string }) {
const [data, setData] = useState<INK2Declaration | null>(null)
const [loading, setLoading] = useState(false)
const [downloading, setDownloading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchDeclaration = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/ink2?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta INK2-deklaration')
} finally {
setLoading(false)
}
/** API errors may be a plain string or the canonical { code, message } envelope. */
function parseApiError(error: unknown, fallback: string): string {
if (typeof error === 'string') return error
if (error && typeof error === 'object') {
const message = (error as { message?: unknown }).message
if (typeof message === 'string') return message
}
return fallback
}
export function INK2DeclarationView({ periodId }: { periodId: string }) {
// Fetch outcome tagged with the key it was requested under: switching
// fiscal year discards stale responses instead of leaving last year's
// declaration on screen (same pattern as the momsdeklaration view).
const [result, setResult] = useState<{
key: string
data?: INK2Declaration
error?: string
} | null>(null)
const [retryKey, setRetryKey] = useState(0)
const [downloading, setDownloading] = useState(false)
const [downloadError, setDownloadError] = useState<string | null>(null)
const fetchKey = periodId ? `${periodId}:${retryKey}` : null
useEffect(() => {
if (!fetchKey || !periodId) return
let cancelled = false
fetch(`/api/reports/ink2?period_id=${periodId}`)
.then(async (res) => {
const json = await res.json().catch(() => null)
if (cancelled) return
if (!res.ok || json?.error) {
setResult({
key: fetchKey,
error: parseApiError(json?.error, 'Kunde inte hämta INK2-deklaration'),
})
} else {
setResult({ key: fetchKey, data: json.data })
}
})
.catch(() => {
if (!cancelled) {
setResult({ key: fetchKey, error: 'Kunde inte hämta INK2-deklaration' })
}
})
return () => {
cancelled = true
}
}, [fetchKey, periodId])
const upToDate = result !== null && result.key === fetchKey
const data = result?.data ?? null
const error = upToDate ? (result.error ?? null) : null
const loading = fetchKey !== null && !upToDate
const downloadSRU = async () => {
setDownloading(true)
setDownloadError(null)
try {
const res = await fetch(`/api/reports/ink2?period_id=${periodId}&format=sru`)
if (!res.ok) throw new Error('Download failed')
@@ -52,311 +89,293 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
a.click()
URL.revokeObjectURL(url)
} catch {
setError('Kunde inte ladda ner SRU-filer')
setDownloadError('Kunde inte ladda ner SRU-filer')
} finally {
setDownloading(false)
}
}
if (error) {
return (
<Card>
<CardContent className="p-6 flex flex-wrap items-center gap-3 text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span className="text-sm">{error}</span>
<Button variant="outline" onClick={() => setRetryKey((k) => k + 1)}>
Försök igen
</Button>
</CardContent>
</Card>
)
}
if (loading && !data) {
return (
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-64" />
</CardContent>
</Card>
)
}
if (!data) return null
return (
<div className="space-y-4">
{/* Info card */}
<div
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
>
{/* Header: company, year, filing artifact, and how to file it */}
<Card>
<CardHeader>
<CardTitle className="text-lg">INK2 (Aktiebolag)</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-2 mb-4 p-3 bg-primary/10 rounded-md">
<Info className="h-4 w-4 text-primary mt-0.5 shrink-0" />
<div className="text-sm text-primary space-y-1">
<p>
INK2 visar det bokföringsmässiga resultatet baserat på din bokföring.
Skattemässiga justeringar (ej avdragsgilla kostnader, periodiseringsfonder m.m.)
hanteras av din revisor/redovisningskonsult.
</p>
<p>
SRU-filen laddas ner som en ZIP med INFO.SRU och BLANKETTER.SRU.
Ladda upp båda filerna till{' '}
<a
href="https://www1.skatteverket.se/fv/fv_web/start.do"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Skatteverkets filöverföringstjänst
</a>.
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-base">INK2 (Aktiebolag)</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
{data.companyInfo.companyName} · {data.fiscalYear.name}
{data.companyInfo.orgNumber && ` · Org.nr: ${data.companyInfo.orgNumber}`}
</p>
</div>
</div>
<div className="flex gap-2">
<Button onClick={fetchDeclaration} disabled={loading}>
{loading ? 'Laddar...' : 'Hämta INK2'}
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
<Download className="h-4 w-4 mr-2" />
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-filer'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
<Download className="h-4 w-4 mr-2" />
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-filer'}
</Button>
)}
</div>
{downloadError && (
<div role="alert" className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 mt-1 shrink-0" />
{downloadError}
</div>
)}
</CardHeader>
<CardContent>
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Info className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
<div className="text-sm text-muted-foreground space-y-2">
<p>
INK2 visar det bokföringsmässiga resultatet baserat på din bokföring.
</p>
<ol className="list-decimal pl-6 space-y-1">
<li>Ladda ner SRU-filerna (en ZIP med INFO.SRU och BLANKETTER.SRU).</li>
<li>
Ladda upp båda filerna till{' '}
<a
href="https://www1.skatteverket.se/fv/fv_web/start.do"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 text-foreground"
>
Skatteverkets filöverföringstjänst
</a>.
</li>
</ol>
</div>
</div>
</CardContent>
</Card>
{error && (
{/* Warnings */}
{data.warnings.length > 0 && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-border">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-warning mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-foreground">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company info */}
<Card>
<CardHeader>
<CardTitle>
{data.companyInfo.companyName}
</CardTitle>
<p className="text-sm text-muted-foreground">
{data.fiscalYear.name}
{data.companyInfo.orgNumber && ` · Org.nr: ${data.companyInfo.orgNumber}`}
</p>
</CardHeader>
</Card>
{/* Assets section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Tillgångar</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{INK2R_ASSET_CODES.map((code) => (
<INK2DeclarationRow
key={code}
code={code}
label={INK2R_RUTA_LABELS[code]}
amount={data.ink2r[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa tillgångar</td>
<td className="py-2 text-right tabular-nums">
{formatCurrency(data.totals.totalAssets)}
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Equity & Liabilities section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Eget kapital och skulder</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{INK2R_EQUITY_LIABILITY_CODES.map((code) => (
<INK2DeclarationRow
key={code}
code={code}
label={INK2R_RUTA_LABELS[code]}
amount={data.ink2r[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa eget kapital och skulder</td>
<td className="py-2 text-right tabular-nums">
{formatCurrency(data.totals.totalEquityLiabilities)}
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Income Statement section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Resultaträkning</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{INK2R_INCOME_CODES.map((code) => (
<INK2DeclarationRow
key={code}
code={code}
label={INK2R_RUTA_LABELS[code]}
amount={data.ink2r[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
</tbody>
<tfoot>
<tr className="border-t font-medium">
<td className="py-2">Rörelseresultat</td>
<td className={`py-2 text-right ${data.totals.operatingResult >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(data.totals.operatingResult)}
</td>
</tr>
<tr className="border-t-2 font-semibold">
<td className="py-2">Årets resultat</td>
<td className={`py-2 text-right ${data.totals.resultAfterFinancial >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(data.totals.resultAfterFinancial)}
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* INK2S summary */}
<Card>
<CardHeader>
<CardTitle className="text-lg">INK2S: Skattemässiga justeringar</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-2 mb-4 p-3 bg-primary/10 rounded-md">
<Info className="h-4 w-4 text-primary mt-0.5 shrink-0" />
<p className="text-sm text-primary">
Grundläggande justeringar beräknas automatiskt. Manuella justeringar
(periodiseringsfonder, koncernbidrag m.m.) hanteras av din redovisningskonsult.
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-1 shrink-0 text-warning" />
<div className="space-y-1">
<p className="text-sm font-medium">
{data.warnings.length}{' '}
{data.warnings.length === 1 ? 'varning' : 'varningar'}
</p>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-muted-foreground">{warning}</p>
))}
</div>
<table className="w-full text-sm">
<tbody>
<tr className="border-b">
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">4.1</span>
Årets resultat (vinst)
</td>
<td className="py-2 text-right">{formatCurrency(data.ink2s['7650'])}</td>
</tr>
<tr className="border-b">
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">4.2</span>
Årets resultat (förlust)
</td>
<td className="py-2 text-right">{formatCurrency(data.ink2s['7750'])}</td>
</tr>
<tr className="border-b">
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">4.3a</span>
Skatt på årets resultat (ej avdragsgill)
</td>
<td className="py-2 text-right">{formatCurrency(data.ink2s['7651'])}</td>
</tr>
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">
{data.ink2s['8020'] > 0 ? 'Överskott (punkt 1.1)' : 'Underskott (punkt 1.2)'}
</td>
<td className={`py-2 text-right ${data.ink2s['8020'] > 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(data.ink2s['8020'] > 0 ? data.ink2s['8020'] : data.ink2s['8021'])}
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Hämta INK2&quot; för att generera deklarationsunderlaget.
</div>
</CardContent>
</Card>
)}
{/* Assets section */}
<Card>
<CardHeader>
<CardTitle className="text-base">Tillgångar</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-0">Post</TableHead>
<TableHead className="px-0 text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INK2R_ASSET_CODES.map((code) => (
<DeclarationRutaRow
key={code}
code={code}
label={INK2R_RUTA_LABELS[code]}
amount={data.ink2r[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
</TableBody>
<tfoot>
<tr className="border-t font-medium">
<td className="py-2">Summa tillgångar</td>
<td className="py-2 text-right tabular-nums">
{formatWholeKronor(data.totals.totalAssets)}
</td>
</tr>
</tfoot>
</Table>
</CardContent>
</Card>
{/* Equity & Liabilities section */}
<Card>
<CardHeader>
<CardTitle className="text-base">Eget kapital och skulder</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-0">Post</TableHead>
<TableHead className="px-0 text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INK2R_EQUITY_LIABILITY_CODES.map((code) => (
<DeclarationRutaRow
key={code}
code={code}
label={INK2R_RUTA_LABELS[code]}
amount={data.ink2r[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
</TableBody>
<tfoot>
<tr className="border-t font-medium">
<td className="py-2">Summa eget kapital och skulder</td>
<td className="py-2 text-right tabular-nums">
{formatWholeKronor(data.totals.totalEquityLiabilities)}
</td>
</tr>
</tfoot>
</Table>
</CardContent>
</Card>
{/* Income Statement section */}
<Card>
<CardHeader>
<CardTitle className="text-base">Resultaträkning</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-0">Post</TableHead>
<TableHead className="px-0 text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INK2R_INCOME_CODES.map((code) => (
<DeclarationRutaRow
key={code}
code={code}
label={INK2R_RUTA_LABELS[code]}
amount={data.ink2r[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
</TableBody>
<tfoot>
<tr className="border-t font-medium">
<td className="py-2">Rörelseresultat</td>
<td
className={`py-2 text-right tabular-nums ${
data.totals.operatingResult >= 0 ? 'text-success' : 'text-destructive'
}`}
>
{formatWholeKronor(data.totals.operatingResult)}
</td>
</tr>
<tr className="border-t font-medium">
<td className="py-2">Årets resultat</td>
<td
className={`py-2 text-right tabular-nums ${
data.totals.resultAfterFinancial >= 0 ? 'text-success' : 'text-destructive'
}`}
>
{formatWholeKronor(data.totals.resultAfterFinancial)}
</td>
</tr>
</tfoot>
</Table>
</CardContent>
</Card>
{/* INK2S summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">INK2S: Skattemässiga justeringar</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Grundläggande justeringar beräknas automatiskt. Manuella justeringar
(periodiseringsfonder, koncernbidrag m.m.) hanteras av din
redovisningskonsult.
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-0">Post</TableHead>
<TableHead className="px-0 text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* At most one of 4.1/4.2 is nonzero: render only that one. A
zero-result year has both at 0, so both render then, or the
table would be nothing but its total row. */}
<DeclarationRutaRow
code="4.1"
label="Årets resultat (vinst)"
amount={data.ink2s['7650']}
hideWhenZero={data.ink2s['7750'] !== 0}
/>
<DeclarationRutaRow
code="4.2"
label="Årets resultat (förlust)"
amount={data.ink2s['7750']}
hideWhenZero={data.ink2s['7650'] !== 0}
/>
<DeclarationRutaRow
code="4.3a"
label="Skatt på årets resultat (ej avdragsgill)"
amount={data.ink2s['7651']}
/>
</TableBody>
<tfoot>
<tr className="border-t font-medium">
<td className="py-2">
{data.ink2s['8020'] > 0 ? 'Överskott (punkt 1.1)' : 'Underskott (punkt 1.2)'}
</td>
<td
className={`py-2 text-right tabular-nums ${
data.ink2s['8020'] > 0 ? 'text-success' : 'text-destructive'
}`}
>
{formatWholeKronor(
data.ink2s['8020'] > 0 ? data.ink2s['8020'] : data.ink2s['8021']
)}
</td>
</tr>
</tfoot>
</Table>
</CardContent>
</Card>
</div>
)
}
function INK2DeclarationRow({
code,
label,
amount,
accounts,
}: {
code: INK2RSRUCode
label: string
amount: number
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
}) {
const [expanded, setExpanded] = useState(false)
if (amount === 0 && accounts.length === 0) return null
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{code}</span>
{label}
{accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right tabular-nums">
{formatCurrency(amount)}
</td>
</tr>
{expanded && accounts.length > 0 && (
<tr>
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1">
<AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" />
</td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right tabular-nums">
{formatCurrency(acc.amount)}
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
+226 -247
View File
@@ -1,39 +1,74 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { Skeleton } from '@/components/ui/skeleton'
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Download, AlertCircle, AlertTriangle } from 'lucide-react'
import {
DeclarationRutaRow,
formatWholeKronor,
} from '@/components/reports/DeclarationRutaRow'
import type { NEDeclaration } from '@/lib/reports/ne-bilaga/types'
import { formatCurrency } from '@/lib/utils'
/** API errors may be a plain string or the canonical { code, message } envelope. */
function parseApiError(error: unknown, fallback: string): string {
if (typeof error === 'string') return error
if (error && typeof error === 'object') {
const message = (error as { message?: unknown }).message
if (typeof message === 'string') return message
}
return fallback
}
export function NEDeclarationView({ periodId }: { periodId: string }) {
const [data, setData] = useState<NEDeclaration | null>(null)
const [loading, setLoading] = useState(false)
// Fetch outcome tagged with the key it was requested under: switching
// fiscal year discards stale responses instead of leaving last year's
// declaration on screen (same pattern as the momsdeklaration view).
const [result, setResult] = useState<{
key: string
data?: NEDeclaration
error?: string
} | null>(null)
const [retryKey, setRetryKey] = useState(0)
const [downloading, setDownloading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [downloadError, setDownloadError] = useState<string | null>(null)
const fetchDeclaration = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/ne-bilaga?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta NE-bilaga')
} finally {
setLoading(false)
const fetchKey = periodId ? `${periodId}:${retryKey}` : null
useEffect(() => {
if (!fetchKey || !periodId) return
let cancelled = false
fetch(`/api/reports/ne-bilaga?period_id=${periodId}`)
.then(async (res) => {
const json = await res.json().catch(() => null)
if (cancelled) return
if (!res.ok || json?.error) {
setResult({
key: fetchKey,
error: parseApiError(json?.error, 'Kunde inte hämta NE-bilaga'),
})
} else {
setResult({ key: fetchKey, data: json.data })
}
})
.catch(() => {
if (!cancelled) setResult({ key: fetchKey, error: 'Kunde inte hämta NE-bilaga' })
})
return () => {
cancelled = true
}
}
}, [fetchKey, periodId])
const upToDate = result !== null && result.key === fetchKey
const data = result?.data ?? null
const error = upToDate ? (result.error ?? null) : null
const loading = fetchKey !== null && !upToDate
const downloadSRU = async () => {
setDownloading(true)
setDownloadError(null)
try {
const res = await fetch(`/api/reports/ne-bilaga?period_id=${periodId}&format=sru`)
if (!res.ok) throw new Error('Download failed')
@@ -46,7 +81,7 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
a.click()
URL.revokeObjectURL(url)
} catch {
setError('Kunde inte ladda ner SRU-filer')
setDownloadError('Kunde inte ladda ner SRU-filer')
} finally {
setDownloading(false)
}
@@ -71,238 +106,182 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
const revenueRutor = ['R1', 'R2', 'R3', 'R4'] as const
const expenseRutor = ['R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
if (error) {
return (
<Card>
<CardContent className="p-6 flex flex-wrap items-center gap-3 text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span className="text-sm">{error}</span>
<Button variant="outline" onClick={() => setRetryKey((k) => k + 1)}>
Försök igen
</Button>
</CardContent>
</Card>
)
}
if (loading && !data) {
return (
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-64" />
</CardContent>
</Card>
)
}
if (!data) return null
return (
<div className="space-y-4">
{/* Info card */}
<div
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
>
{/* Header: company, year, and the filing artifact */}
<Card>
<CardHeader>
<CardTitle className="text-lg">NE-bilaga (Enskild firma)</CardTitle>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="text-base">NE-bilaga (Enskild firma)</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
{data.companyInfo.companyName} · {data.fiscalYear.name}
{data.companyInfo.orgNumber && ` · Org.nr: ${data.companyInfo.orgNumber}`}
</p>
</div>
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
<Download className="h-4 w-4 mr-2" />
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-fil'}
</Button>
</div>
{downloadError && (
<div role="alert" className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 mt-1 shrink-0" />
{downloadError}
</div>
)}
</CardHeader>
</Card>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card>
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-1 shrink-0 text-warning" />
<div className="space-y-1">
<p className="text-sm font-medium">
{data.warnings.length}{' '}
{data.warnings.length === 1 ? 'varning' : 'varningar'}
</p>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-muted-foreground">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Revenue section */}
<Card>
<CardHeader>
<CardTitle className="text-base">Intäkter</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
NE-bilagan används för att deklarera resultat från enskild näringsverksamhet.
Uppgifterna hämtas från bokföringen för valt räkenskapsår.
</p>
<div className="flex gap-2">
<Button onClick={fetchDeclaration} disabled={loading}>
{loading ? 'Laddar...' : 'Hämta NE-bilaga'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
<Download className="h-4 w-4 mr-2" />
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-fil'}
</Button>
)}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-0">Post</TableHead>
<TableHead className="px-0 text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{revenueRutor.map((ruta) => (
<DeclarationRutaRow
key={ruta}
code={ruta}
label={rutaLabels[ruta]}
amount={data.rutor[ruta]}
accounts={data.breakdown[ruta]?.accounts || []}
/>
))}
</TableBody>
<tfoot>
<tr className="border-t font-medium">
<td className="py-2">Summa intäkter</td>
<td className="py-2 text-right tabular-nums">
{formatWholeKronor(
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
)}
</td>
</tr>
</tfoot>
</Table>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{/* Expenses section */}
<Card>
<CardHeader>
<CardTitle className="text-base">Kostnader</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-0">Post</TableHead>
<TableHead className="px-0 text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{expenseRutor.map((ruta) => (
// Costs display negated: the signed value keeps its true sign
// when an expense ruta carries a credit balance.
<DeclarationRutaRow
key={ruta}
code={ruta}
label={rutaLabels[ruta]}
amount={-data.rutor[ruta]}
accounts={(data.breakdown[ruta]?.accounts || []).map((acc) => ({
...acc,
amount: -acc.amount,
}))}
/>
))}
</TableBody>
<tfoot>
<tr className="border-t font-medium">
<td className="py-2">Summa kostnader</td>
<td className="py-2 text-right tabular-nums">
{formatWholeKronor(
-(data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
data.rutor.R8 + data.rutor.R9 + data.rutor.R10)
)}
</td>
</tr>
</tfoot>
</Table>
</CardContent>
</Card>
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-border">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-warning mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-foreground">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company info */}
<Card>
<CardHeader>
<CardTitle>
{data.companyInfo.companyName}
</CardTitle>
<p className="text-sm text-muted-foreground">
{data.fiscalYear.name}
</p>
{data.companyInfo.orgNumber && (
<p className="text-sm text-muted-foreground">
Org.nr: {data.companyInfo.orgNumber}
</p>
)}
</CardHeader>
</Card>
{/* Revenue section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Intäkter</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{revenueRutor.map((ruta) => {
const value = data.rutor[ruta]
const breakdown = data.breakdown[ruta]
return (
<NEDeclarationRow
key={ruta}
ruta={ruta}
label={rutaLabels[ruta]}
amount={value}
accounts={breakdown?.accounts || []}
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa intäkter</td>
<td className="py-2 text-right tabular-nums">
{formatCurrency(
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
)}
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Expenses section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Kostnader</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{expenseRutor.map((ruta) => {
const value = data.rutor[ruta]
const breakdown = data.breakdown[ruta]
return (
<NEDeclarationRow
key={ruta}
ruta={ruta}
label={rutaLabels[ruta]}
amount={value}
accounts={breakdown?.accounts || []}
isExpense
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa kostnader</td>
<td className="py-2 text-right tabular-nums">
-{formatCurrency(
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
)}
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Result */}
<Card className="border-2">
<CardContent className="py-4">
<div className="flex justify-between items-center">
<div>
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
<span className="font-display text-xl">Årets resultat</span>
</div>
<span
className={`font-display text-2xl tabular-nums ${
data.rutor.R11 >= 0 ? 'text-success' : 'text-destructive'
}`}
>
{formatCurrency(data.rutor.R11)}
</span>
</div>
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Hämta NE-bilaga&quot; för att generera deklarationsunderlaget.
</CardContent>
</Card>
)}
{/* Result */}
<Card>
<CardContent className="py-4">
<div className="flex justify-between items-center">
<div>
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
<span className="font-display text-xl">Årets resultat</span>
</div>
<span
className={`font-display text-2xl tabular-nums ${
data.rutor.R11 >= 0 ? 'text-success' : 'text-destructive'
}`}
>
{formatWholeKronor(data.rutor.R11)}
</span>
</div>
</CardContent>
</Card>
</div>
)
}
function NEDeclarationRow({
ruta,
label,
amount,
accounts,
isExpense,
}: {
ruta: string
label: string
amount: number
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
isExpense?: boolean
}) {
const [expanded, setExpanded] = useState(false)
// Don't show rows with zero values
if (amount === 0 && accounts.length === 0) return null
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
{label}
{accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right tabular-nums">
{isExpense && amount > 0 ? '-' : ''}{formatCurrency(Math.abs(amount))}
</td>
</tr>
{expanded && accounts.length > 0 && (
<tr>
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">
{isExpense && acc.amount > 0 ? '-' : ''}{formatCurrency(Math.abs(acc.amount))}
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -8,6 +8,13 @@ import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableHeader,
@@ -16,7 +23,15 @@ import {
TableCell,
TableBody,
} from '@/components/ui/table'
import { Download, AlertCircle, AlertTriangle, FileText, ExternalLink } from 'lucide-react'
import {
Download,
AlertCircle,
AlertTriangle,
CheckCircle2,
FileText,
ExternalLink,
RefreshCw,
} from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import type {
PeriodiskSammanstallningReport,
@@ -29,6 +44,16 @@ function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { maximumFractionDigits: 0 })
}
/** API errors may be a plain string or the canonical { code, message } envelope. */
function parseApiError(error: unknown, fallback: string): string {
if (typeof error === 'string') return error
if (error && typeof error === 'object') {
const message = (error as { message?: unknown }).message
if (typeof message === 'string') return message
}
return fallback
}
function typeBadge(row: { services: number; goods: number; triangulation: number }) {
const types: string[] = []
if (row.services !== 0) types.push('Tjänster')
@@ -56,9 +81,15 @@ export function PeriodiskSammanstallningView() {
const [periodType, setPeriodType] = useState<PsPeriodType>('quarterly')
const [year, setYear] = useState(currentYear)
const [period, setPeriod] = useState(currentQuarter)
const [data, setData] = useState<PeriodiskSammanstallningReport | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Fetch outcome tagged with the key it was requested under: the report
// follows the picker automatically and stale responses are discarded
// (same pattern as the momsdeklaration view).
const [result, setResult] = useState<{
key: string
data?: PeriodiskSammanstallningReport
error?: string
} | null>(null)
const [retryKey, setRetryKey] = useState(0)
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i)
@@ -78,30 +109,47 @@ export function PeriodiskSammanstallningView() {
{ value: 4, label: 'Kvartal 4 (okt-dec)' },
]
useEffect(() => {
setPeriod(periodType === 'monthly' ? currentMonth : currentQuarter)
}, [periodType, currentMonth, currentQuarter])
const fetchReport = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(
`/api/reports/periodisk-sammanstallning?periodType=${periodType}&year=${year}&period=${period}`,
)
const result = await res.json()
if (result.error) {
setError(typeof result.error === 'string' ? result.error : result.error.message_sv ?? 'Något gick fel.')
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta periodisk sammanställning.')
} finally {
setLoading(false)
}
// Switching periodicity resets the period to "now" in the new unit, in the
// change handler so the auto-fetch never sees an inconsistent pair.
const handlePeriodTypeChange = (value: PsPeriodType) => {
setPeriodType(value)
setPeriod(value === 'monthly' ? currentMonth : currentQuarter)
}
const fetchKey = `${periodType}:${year}:${period}:${retryKey}`
useEffect(() => {
let cancelled = false
fetch(
`/api/reports/periodisk-sammanstallning?periodType=${periodType}&year=${year}&period=${period}`,
)
.then(async (res) => {
const json = await res.json().catch(() => null)
if (cancelled) return
if (!res.ok || json?.error) {
setResult({
key: fetchKey,
error: parseApiError(json?.error, 'Kunde inte hämta periodisk sammanställning.'),
})
} else {
setResult({ key: fetchKey, data: json.data })
}
})
.catch(() => {
if (!cancelled) {
setResult({ key: fetchKey, error: 'Kunde inte hämta periodisk sammanställning.' })
}
})
return () => {
cancelled = true
}
}, [fetchKey, periodType, year, period])
const upToDate = result !== null && result.key === fetchKey
const data = result?.data ?? null
const error = upToDate ? (result.error ?? null) : null
const loading = !upToDate
const downloadCsv = () => {
window.open(
`/api/reports/periodisk-sammanstallning/csv?periodType=${periodType}&year=${year}&period=${period}`,
@@ -114,49 +162,70 @@ export function PeriodiskSammanstallningView() {
const hasBlockingErrors = errors.length > 0
return (
<div className="space-y-6">
{/* Period selection */}
<div className="space-y-8">
{/* Period selection: the report below follows it automatically */}
<Card>
<CardHeader>
<CardTitle className="text-base">Välj period</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="flex flex-wrap items-end gap-4">
<div>
<Label>Periodicitet</Label>
<select
<Label htmlFor="ps-period-type">Periodicitet</Label>
<Select
value={periodType}
onChange={e => setPeriodType(e.target.value as PsPeriodType)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
onValueChange={(value) => handlePeriodTypeChange(value as PsPeriodType)}
>
<option value="monthly">Månadsvis</option>
<option value="quarterly">Kvartalsvis</option>
</select>
<SelectTrigger id="ps-period-type" className="mt-1 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månadsvis</SelectItem>
<SelectItem value="quarterly">Kvartalsvis</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>År</Label>
<select
value={year}
onChange={e => setYear(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{yearOptions.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<Label htmlFor="ps-year">År</Label>
<Select value={String(year)} onValueChange={(value) => setYear(parseInt(value))}>
<SelectTrigger id="ps-year" className="mt-1 w-28 tabular-nums">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((y) => (
<SelectItem key={y} value={String(y)} className="tabular-nums">
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Period</Label>
<select
value={period}
onChange={e => setPeriod(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
<Label htmlFor="ps-period">Period</Label>
<Select
value={String(period)}
onValueChange={(value) => setPeriod(parseInt(value))}
>
{periodOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<SelectTrigger id="ps-period" className="mt-1 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{periodOptions.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={fetchReport} disabled={loading}>
{loading ? 'Laddar…' : 'Hämta'}
{/* Re-runs the report for the SAME period: after fixing VAT
numbers or bookings elsewhere, the blocking checks must be
re-checkable without toggling the period away and back. */}
<Button
variant="ghost"
size="icon"
aria-label="Uppdatera rapporten"
onClick={() => setRetryKey((k) => k + 1)}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardContent>
@@ -164,14 +233,17 @@ export function PeriodiskSammanstallningView() {
{error && (
<Card>
<CardContent className="p-6 flex items-start gap-3 text-destructive">
<AlertCircle className="h-5 w-5 mt-0.5 shrink-0" />
<div>{error}</div>
<CardContent className="p-6 flex flex-wrap items-center gap-3 text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span className="text-sm">{error}</span>
<Button variant="outline" onClick={() => setRetryKey((k) => k + 1)}>
Försök igen
</Button>
</CardContent>
</Card>
)}
{loading && (
{!error && loading && !data && (
<Card>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-5 w-40" />
@@ -180,8 +252,10 @@ export function PeriodiskSammanstallningView() {
</Card>
)}
{data && !loading && (
<>
{data && !error && (
<div
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
>
{/* Summary */}
<Card>
<CardHeader>
@@ -222,18 +296,24 @@ export function PeriodiskSammanstallningView() {
{data.reconciliation.matches !== null && data.totals.rowCount > 0 && (
<div className="text-sm text-muted-foreground border-t pt-3">
{data.reconciliation.matches ? (
<span className="text-foreground">
✓ Stämmer mot momsdeklarationen
{' '}(Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr,
{' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr,
{' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr)
<span className="text-foreground inline-flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-1 shrink-0 text-success" />
<span>
Stämmer mot momsdeklarationen
{' '}(Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr,
{' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr,
{' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr)
</span>
</span>
) : (
<span className="text-destructive">
⚠ Avviker från momsdeklarationen: kontrollera bokföringen.
{' '}Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr,
{' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr,
{' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr.
<span className="text-destructive inline-flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-1 shrink-0" />
<span>
Avviker från momsdeklarationen: kontrollera bokföringen.
{' '}Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr,
{' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr,
{' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr.
</span>
</span>
)}
</div>
@@ -256,7 +336,7 @@ export function PeriodiskSammanstallningView() {
{/* Errors */}
{errors.length > 0 && (
<Card className="border-destructive/50">
<Card>
<CardHeader>
<CardTitle className="text-base text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
@@ -277,7 +357,7 @@ export function PeriodiskSammanstallningView() {
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2 text-foreground">
<AlertTriangle className="h-4 w-4" />
<AlertTriangle className="h-4 w-4 text-warning" />
Varningar ({cautions.length})
</CardTitle>
</CardHeader>
@@ -292,16 +372,21 @@ export function PeriodiskSammanstallningView() {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Rader</CardTitle>
<Button
variant="outline"
size="sm"
onClick={downloadCsv}
disabled={hasBlockingErrors}
title={hasBlockingErrors ? 'Åtgärda blockerande fel innan CSV kan laddas ner' : undefined}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner CSV (SKV 5740)
</Button>
<div className="flex flex-wrap items-center gap-3">
{hasBlockingErrors && (
<p className="text-xs text-muted-foreground">
Åtgärda felen ovan innan CSV kan laddas ner.
</p>
)}
<Button
variant="outline"
onClick={downloadCsv}
disabled={hasBlockingErrors}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner CSV (SKV 5740)
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
@@ -367,7 +452,7 @@ export function PeriodiskSammanstallningView() {
</a>
</EmptyState>
)}
</>
</div>
)}
</div>
)
@@ -380,7 +465,7 @@ function WarningItem({ warning }: { warning: PsWarning }) {
<div className="flex-1">
<div>{warning.message}</div>
{(warning.customerId || warning.invoiceId) && (
<div className="text-xs text-muted-foreground mt-0.5 flex gap-3">
<div className="text-xs text-muted-foreground mt-1 flex gap-3">
{warning.customerId && (
<Link href={`/customers/${warning.customerId}`} className="underline-offset-4 hover:underline">
Öppna kund
File diff suppressed because it is too large Load Diff
+613
View File
@@ -0,0 +1,613 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
DataList,
DataListMeta,
DataListMetaSeparator,
DataListPrimary,
DataListRow,
} from '@/components/ui/data-list'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronRight,
Loader2,
ShieldAlert,
} from 'lucide-react'
import type { VatDeclarationCheck } from '@/lib/reports/vat-declaration-checks'
import type { RcBasisGap } from '@/lib/reports/rc-basis-gaps'
import type { VatPeriodType } from '@/types'
import { formatDate } from '@/lib/utils'
import { useCanWrite } from '@/lib/hooks/use-can-write'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
/** API responses use the canonical envelope; error may be a string or an object. */
function apiErrorMessage(json: unknown, fallback: string): string {
const err = (json as { error?: unknown } | null)?.error
if (typeof err === 'string') return err
if (err && typeof err === 'object') {
const message = (err as { message?: unknown }).message
if (typeof message === 'string') return message
}
return fallback
}
type SupplierType = 'eu_business' | 'non_eu_business' | 'swedish_business'
type SupplyType = 'service' | 'goods'
interface GapClassification {
supplierType: SupplierType
supplyType: SupplyType
}
const SUPPLIER_LABELS: Record<SupplierType, string> = {
eu_business: 'EU-leverantör',
non_eu_business: 'Utanför EU',
swedish_business: 'Svensk omvänd skattskyldighet',
}
const SUPPLY_LABELS: Record<SupplyType, string> = {
service: 'tjänst',
goods: 'vara',
}
/** How many gap rows render before the "Visa alla" toggle. */
const GAP_PREVIEW_COUNT = 8
/**
* "Kontroll av underlaget": the local pre-flight checks for the
* momsdeklaration plus the per-voucher RC-basis-gap worklist with single and
* bulk Korrigera. Hoisted out of SkatteverketPanel so EVERY user sees it,
* paying or not, connected or not: manual filers are exactly the users who
* must not file a declaration these checks would have blocked.
*/
export function VatChecksCard({
checks,
periodType,
year,
period,
fiscalPeriodId,
onCorrected,
}: {
checks: VatDeclarationCheck[]
periodType: VatPeriodType
year: number
period: number
fiscalPeriodId?: string
onCorrected: () => void
}) {
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const { dialogProps, confirm } = useDestructiveConfirm()
const errorCount = checks.filter((c) => c.status === 'ERROR').length
const warningCount = checks.length - errorCount
const hasRcBasisGaps = checks.some((c) => c.code === 'RC_BASIS_MISSING')
// Gap fetch tagged with the key it was requested under: loading is derived
// by comparing tags, so the effect never sets state synchronously. Fixed
// rows are removed via removedIds (the fetch itself is not re-run after a
// korrigering: the period key is unchanged). The key is period-only, NOT
// gated on RC_BASIS_MISSING: the aggregate check clears as soon as ONE
// voucher is corrected, and the remaining unfixed rows must survive the
// declaration refetch that follows each korrigering.
const gapsKey = `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}`
const [gapsResult, setGapsResult] = useState<{
key: string
gaps: RcBasisGap[]
failed?: boolean
} | null>(null)
const [removedIds, setRemovedIds] = useState<Set<string>>(new Set())
const [showAll, setShowAll] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
// Shared classification applied by "Korrigera alla" and any row without an
// override. Visible in the toolbar so a bulk fix never runs on a hidden guess.
const [sharedSel, setSharedSel] = useState<GapClassification>({
supplierType: 'eu_business',
supplyType: 'service',
})
const [overrides, setOverrides] = useState<Record<string, GapClassification>>({})
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
const [fixingId, setFixingId] = useState<string | null>(null)
const [bulkProgress, setBulkProgress] = useState<{ done: number; total: number } | null>(null)
// Render-phase adjustment: a new period resets the per-row working state.
const [appliedGapsKey, setAppliedGapsKey] = useState<string | null>(null)
if (gapsKey !== appliedGapsKey) {
setAppliedGapsKey(gapsKey)
setRemovedIds(new Set())
setOverrides({})
setRowErrors({})
setExpandedId(null)
setShowAll(false)
}
const gapsFetched = gapsResult !== null && gapsResult.key === gapsKey
useEffect(() => {
// Fetch once per period, and only while the aggregate check flags a gap:
// the fetched list then outlives the check, which clears after the first
// correction even though other vouchers may remain broken.
if (!hasRcBasisGaps || gapsFetched) return
let cancelled = false
const params = new URLSearchParams({
periodType,
year: String(year),
period: String(period),
})
// Forward-compatible: the route reads calendar params today, but yearly
// (helårsmoms) declarations resolve against the räkenskapsår.
if (fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId)
fetch(`/api/reports/vat-declaration/rc-basis-gaps?${params.toString()}`)
.then(async (r) => {
const j = await r.json().catch(() => null)
if (cancelled) return
// A failed fetch must not masquerade as "no gaps found": the check
// above says there ARE gaps, so an empty list here would mislead.
if (!r.ok || j?.error) setGapsResult({ key: gapsKey, gaps: [], failed: true })
else setGapsResult({ key: gapsKey, gaps: j?.data?.gaps || [] })
})
.catch(() => {
if (!cancelled) setGapsResult({ key: gapsKey, gaps: [], failed: true })
})
return () => {
cancelled = true
}
}, [hasRcBasisGaps, gapsFetched, gapsKey, periodType, year, period, fiscalPeriodId])
const gaps = gapsFetched
? gapsResult.gaps.filter((g) => !removedIds.has(g.entryId))
: []
const gapsLoading = hasRcBasisGaps && !gapsFetched
const gapsFailed = gapsFetched && !!gapsResult.failed
// The worklist stays mounted while unfixed rows remain, even after the
// aggregate check has cleared: hiding them mid-session would strand the
// user with silently understated rutor 20-24.
const showGapWorklist = hasRcBasisGaps || gaps.length > 0
const busy = fixingId !== null || bulkProgress !== null
const classificationFor = (gap: RcBasisGap): GapClassification =>
overrides[gap.entryId] ?? sharedSel
const postFix = async (
gap: RcBasisGap,
sel: GapClassification,
): Promise<{ ok: true; correctedId?: string } | { ok: false; message: string }> => {
try {
const res = await fetch('/api/reports/vat-declaration/rc-basis-gaps/fix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entryId: gap.entryId,
supplierType: sel.supplierType,
supplyType: sel.supplyType,
}),
})
const json = await res.json().catch(() => null)
if (!res.ok || json?.error) {
return { ok: false, message: apiErrorMessage(json, 'Kunde inte korrigera verifikationen') }
}
return { ok: true, correctedId: json?.data?.correctedId }
} catch {
return { ok: false, message: 'Kunde inte korrigera verifikationen' }
}
}
const handleFixOne = async (gap: RcBasisGap) => {
setFixingId(gap.entryId)
setRowErrors((prev) => {
const next = { ...prev }
delete next[gap.entryId]
return next
})
const result = await postFix(gap, classificationFor(gap))
setFixingId(null)
if (!result.ok) {
setRowErrors((prev) => ({ ...prev, [gap.entryId]: result.message }))
return
}
setRemovedIds((prev) => new Set(prev).add(gap.entryId))
const correctedId = result.correctedId
toast({
title: `Verifikation ${gap.voucherSeries}-${gap.voucherNumber} korrigerad`,
description: 'Storno + ny verifikation skapad.',
action: correctedId ? (
<ToastAction
altText="Visa verifikat"
onClick={() => router.push(`/bookkeeping/${correctedId}`)}
>
Visa verifikat
</ToastAction>
) : undefined,
})
onCorrected()
}
const handleFixAll = async () => {
const targets = [...gaps]
if (targets.length === 0) return
const overriddenCount = targets.filter((g) => overrides[g.entryId]).length
const ok = await confirm({
title: `Korrigera ${targets.length} verifikationer?`,
description:
`Detta skapar ${targets.length} stornon och ${targets.length} nya verifikationer ` +
`klassificerade som ${SUPPLIER_LABELS[sharedSel.supplierType].toLowerCase()}, ` +
`${SUPPLY_LABELS[sharedSel.supplyType]}.` +
(overriddenCount > 0
? ` ${overriddenCount} rader använder sina egna val i stället.`
: ' Rader du inte ändrat använder valen ovan.'),
variant: 'warning',
confirmLabel: 'Korrigera alla',
})
if (!ok) return
setBulkProgress({ done: 0, total: targets.length })
const failures: Record<string, string> = {}
for (const [index, gap] of targets.entries()) {
const result = await postFix(gap, classificationFor(gap))
if (!result.ok) failures[gap.entryId] = result.message
setBulkProgress({ done: index + 1, total: targets.length })
}
// Fixed rows leave the list; failures stay with their inline error note.
setRemovedIds((prev) => {
const next = new Set(prev)
for (const gap of targets) {
if (!failures[gap.entryId]) next.add(gap.entryId)
}
return next
})
setRowErrors(failures)
setBulkProgress(null)
const failureCount = Object.keys(failures).length
const fixedCount = targets.length - failureCount
toast({
title: `${fixedCount} av ${targets.length} verifikationer korrigerade`,
description:
failureCount > 0
? `${failureCount} kunde inte korrigeras och ligger kvar i listan.`
: 'Storno + nya verifikationer skapade.',
})
// One refetch at the end: per-row refetches would remount the page once
// per verifikat.
onCorrected()
}
const visibleGaps = showAll ? gaps : gaps.slice(0, GAP_PREVIEW_COUNT)
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base">Kontroll av underlaget</CardTitle>
{errorCount > 0 ? (
<Badge variant="destructive">
{errorCount} {errorCount === 1 ? 'fel' : 'fel'}
</Badge>
) : warningCount > 0 ? (
<Badge variant="warning">
{warningCount} {warningCount === 1 ? 'varning' : 'varningar'}
</Badge>
) : (
<Badge variant="success">Inga anmärkningar</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{checks.length === 0 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-success" />
Inga fel hittades i underlaget för perioden.
</div>
)}
{checks.length > 0 && (
<div className="space-y-2">
{checks.map((c, i) => (
<div
key={`${c.code}-${i}`}
className={`flex items-start gap-2 rounded-lg p-3 text-sm ${
c.status === 'ERROR'
? 'bg-destructive/5 text-destructive'
: 'border border-border bg-muted/30'
}`}
>
{c.status === 'ERROR' ? (
<ShieldAlert className="h-4 w-4 mt-1 shrink-0" />
) : (
<AlertTriangle className="h-4 w-4 mt-1 shrink-0 text-warning" />
)}
<div>
<span className="font-mono text-xs mr-2">{c.code}</span>
{c.message}
</div>
</div>
))}
</div>
)}
{showGapWorklist && (
<div className="space-y-3">
<h3
className="text-sm font-medium uppercase tracking-wider text-muted-foreground"
aria-live="polite"
>
Verifikationer som saknar basbelopp ({gaps.length})
</h3>
{gapsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Söker berörda verifikationer...
</div>
) : gapsFailed ? (
<div className="flex flex-wrap items-center gap-3">
<p role="alert" className="text-sm text-destructive">
Kunde inte hämta verifikationslistan.
</p>
<Button variant="outline" onClick={() => setGapsResult(null)}>
Försök igen
</Button>
</div>
) : gaps.length === 0 ? (
<p className="text-sm text-muted-foreground">
Inga verifikationer hittades. Bristen kan ligga utanför perioden eller i
bokföring som inte är bokförd.
</p>
) : (
<>
<div className="flex flex-wrap items-end gap-4">
<div>
<Label htmlFor="rc-supplier-type">Leverantörstyp</Label>
<Select
value={sharedSel.supplierType}
onValueChange={(value) => {
const supplierType = value as SupplierType
setSharedSel((prev) => ({
supplierType,
// Non-EU + goods is import VAT, not reverse charge:
// coerce back to service so an invalid combo can't
// be mass-applied.
supplyType:
supplierType === 'non_eu_business' ? 'service' : prev.supplyType,
}))
}}
disabled={busy}
>
<SelectTrigger id="rc-supplier-type" className="mt-1 w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="eu_business">EU-leverantör</SelectItem>
<SelectItem value="non_eu_business">Utanför EU</SelectItem>
<SelectItem value="swedish_business">Svensk omvänd skattskyldighet</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="rc-supply-type">Typ av inköp</Label>
<Select
value={sharedSel.supplyType}
onValueChange={(value) =>
setSharedSel((prev) => ({ ...prev, supplyType: value as SupplyType }))
}
disabled={busy}
>
<SelectTrigger id="rc-supply-type" className="mt-1 w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="service">Tjänst</SelectItem>
{sharedSel.supplierType !== 'non_eu_business' && (
<SelectItem value="goods">Vara</SelectItem>
)}
</SelectContent>
</Select>
</div>
<Button onClick={handleFixAll} disabled={!canWrite || busy}>
{bulkProgress ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4 mr-2" />
)}
Korrigera alla ({gaps.length})
</Button>
</div>
{bulkProgress && (
<p role="status" className="text-sm text-muted-foreground">
Korrigerar {Math.min(bulkProgress.done + 1, bulkProgress.total)} av{' '}
{bulkProgress.total}...
</p>
)}
{!canWrite && (
<p className="text-xs text-muted-foreground">Kräver skrivbehörighet.</p>
)}
<DataList>
{visibleGaps.map((gap) => {
const sel = classificationFor(gap)
const expanded = expandedId === gap.entryId
const rowError = rowErrors[gap.entryId]
return (
<DataListRow
key={gap.entryId}
expanded={expanded}
onClick={() => setExpandedId(expanded ? null : gap.entryId)}
trailing={
<>
<span className="text-sm tabular-nums text-muted-foreground">
{formatAmount(gap.expectedBasisAmount)} kr
</span>
<Button
variant="outline"
onClick={(e) => {
e.stopPropagation()
handleFixOne(gap)
}}
disabled={!canWrite || busy}
>
{fixingId === gap.entryId ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4 mr-2" />
)}
Korrigera
</Button>
<Button
variant="ghost"
size="icon"
aria-expanded={expanded}
aria-label={
expanded
? `Dölj detaljer för verifikation ${gap.voucherSeries}-${gap.voucherNumber}`
: `Visa detaljer för verifikation ${gap.voucherSeries}-${gap.voucherNumber}`
}
onClick={(e) => {
e.stopPropagation()
setExpandedId(expanded ? null : gap.entryId)
}}
>
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</>
}
expandedContent={
<div className="space-y-3">
<p className="text-sm tabular-nums">
{gap.rcOutputAccount} har {formatAmount(gap.rcOutputAmount)} kr
fiktiv moms: saknar basbelopp{' '}
{formatAmount(gap.expectedBasisAmount)} kr
</p>
<div className="flex flex-wrap items-end gap-4">
<div>
<Label htmlFor={`rc-supplier-${gap.entryId}`}>
Leverantörstyp
</Label>
<Select
value={sel.supplierType}
onValueChange={(value) => {
const supplierType = value as SupplierType
setOverrides((prev) => ({
...prev,
[gap.entryId]: {
supplierType,
supplyType:
supplierType === 'non_eu_business'
? 'service'
: sel.supplyType,
},
}))
}}
disabled={busy}
>
<SelectTrigger
id={`rc-supplier-${gap.entryId}`}
className="mt-1 w-56"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="eu_business">EU-leverantör</SelectItem>
<SelectItem value="non_eu_business">Utanför EU</SelectItem>
<SelectItem value="swedish_business">Svensk omvänd skattskyldighet</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor={`rc-supply-${gap.entryId}`}>Typ av inköp</Label>
<Select
value={sel.supplyType}
onValueChange={(value) =>
setOverrides((prev) => ({
...prev,
[gap.entryId]: {
...sel,
supplyType: value as SupplyType,
},
}))
}
disabled={busy}
>
<SelectTrigger
id={`rc-supply-${gap.entryId}`}
className="mt-1 w-32"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="service">Tjänst</SelectItem>
{sel.supplierType !== 'non_eu_business' && (
<SelectItem value="goods">Vara</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
</div>
}
>
<DataListPrimary>
Verifikation {gap.voucherSeries}-{gap.voucherNumber}
</DataListPrimary>
<DataListMeta>
<span className="tabular-nums">{formatDate(gap.entryDate)}</span>
{gap.description && (
<>
<DataListMetaSeparator />
<span className="truncate">{gap.description}</span>
</>
)}
</DataListMeta>
{/* Always visible: a failed korrigering must not hide
its reason behind the collapsed expansion. */}
{rowError && (
<p role="alert" className="mt-1 text-xs text-destructive">
{rowError}
</p>
)}
</DataListRow>
)
})}
</DataList>
{gaps.length > GAP_PREVIEW_COUNT && (
<Button variant="ghost" onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Visa färre' : `Visa alla ${gaps.length} verifikationer`}
</Button>
)}
</>
)}
</div>
)}
</CardContent>
<DestructiveConfirmDialog {...dialogProps} />
</Card>
)
}
@@ -1,71 +0,0 @@
'use client'
import { useMemo } from 'react'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCurrency } from '@/lib/utils'
import type { VatDeclarationRutor } from '@/types'
interface VatCompositionChartProps {
rutor: VatDeclarationRutor
}
const COLORS = [
'hsl(var(--chart-1))',
'hsl(var(--chart-2))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
'hsl(var(--chart-1) / 0.6)',
'hsl(var(--chart-2) / 0.6)',
'hsl(var(--chart-3) / 0.6)',
]
export function VatCompositionChart({ rutor }: VatCompositionChartProps) {
const chartData = useMemo(() => {
const segments = [
{ name: 'Utgående 25%', value: rutor.ruta10 },
{ name: 'Utgående 12%', value: rutor.ruta11 },
{ name: 'Utgående 6%', value: rutor.ruta12 },
{ name: 'Omvänd 25%', value: rutor.ruta30 },
{ name: 'Omvänd 12%', value: rutor.ruta31 },
{ name: 'Omvänd 6%', value: rutor.ruta32 },
{ name: 'Ingående moms', value: rutor.ruta48 },
]
return segments.filter((s) => s.value > 0)
}, [rutor])
if (chartData.length === 0) return null
return (
<Card className="mb-4">
<CardHeader className="pb-2">
<CardTitle className="text-base">Momsfördelning</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
dataKey="value"
>
{chartData.map((_, index) => (
<Cell key={index} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(value) => [
formatCurrency(Number(value)),
]}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}
+180 -85
View File
@@ -27,6 +27,9 @@ import { roundOre } from '@/lib/money'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AccountNumber } from '@/components/ui/account-number'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { VatChecksCard } from '@/components/reports/VatChecksCard'
import { runVatDeclarationChecks } from '@/lib/reports/vat-declaration-checks'
import { Table, TableBody } from '@/components/ui/table'
import { useCompanySettings } from '@/components/settings/useSettings'
import dynamic from 'next/dynamic'
import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel'
@@ -42,10 +45,6 @@ const TrialBalanceChart = dynamic(
() => import('@/components/reports/TrialBalanceChart').then((m) => m.TrialBalanceChart),
{ ssr: false, loading: chartFallback },
)
const VatCompositionChart = dynamic(
() => import('@/components/reports/VatCompositionChart').then((m) => m.VatCompositionChart),
{ ssr: false, loading: chartFallback },
)
const IncomeExpenseChart = dynamic(
() => import('@/components/reports/IncomeExpenseChart').then((m) => m.IncomeExpenseChart),
{ ssr: false, loading: chartFallback },
@@ -1077,33 +1076,36 @@ function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: s
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Lämna in din momsdeklaration</CardTitle>
<CardTitle className="text-base">Lämna in själv (utan anslutning)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Deklarationen ovan är klar. Du behöver inte vara ansluten till
Skatteverket för att lämna in den.
Du behöver inte vara ansluten till Skatteverket för att lämna in.
</p>
<p className="text-sm text-muted-foreground">
Enklast: ladda ner filen (XML), logga in på skatteverket.se med BankID,
öppna Moms- och arbetsgivardeklarationer och välj Deklarera via fil.
Ladda upp filen, granska och signera. Vill du hellre fylla i rutorna
för hand laddar du ner PDF:en och skriver av beloppen (i hela kronor).
<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" size="sm" asChild className="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" size="sm" asChild className="gap-2">
<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" size="sm" asChild className="gap-2">
<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
@@ -1130,11 +1132,18 @@ function VatBookingCard({
year,
period,
fiscalPeriodId,
checksBlocked,
}: {
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
}) {
const { canWrite } = useCanWrite()
const [dialogOpen, setDialogOpen] = useState(false)
@@ -1231,27 +1240,45 @@ function VatBookingCard({
</div>
)}
{checksBlocked && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<AlertCircle className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
<p>
Det finns fel under 1 · Kontrollera underlaget. Du kan bokföra momsen ändå,
men åtgärda felen innan du lämnar in.
</p>
</div>
)}
{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" size="sm" onClick={() => setRefreshKey((k) => k + 1)}>
<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>
) : (
<Button
size="sm"
// 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>
<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>
)}
</CardContent>
@@ -1449,6 +1476,12 @@ export function VatDeclarationView() {
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.
const checks = data ? runVatDeclarationChecks(data.rutor) : []
const checksBlocked = checks.some((c) => c.status === 'ERROR')
// Settings not settled yet — the picker defaults and the gate both depend
// on them, so hold the whole view in a skeleton.
if (settingsLoading || periodType === null) {
@@ -1491,11 +1524,11 @@ export function VatDeclarationView() {
return (
<VatDrillContext.Provider value={{ fiscalPeriodId: isYearly ? fiscalPeriodId : undefined }}>
<div className="space-y-4">
<div className="space-y-8">
{/* XML and PDF live in "Lämna in" below: they are filing artifacts, not
report exports, and one home avoids two competing download surfaces. */}
<ReportExportMenu
items={[
{ format: 'xml', href: `/api/reports/vat-declaration/eskd?${vatQueryString()}` },
{ format: 'pdf', href: `/api/reports/vat-declaration/pdf?${vatQueryString()}` },
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
]}
>
@@ -1599,30 +1632,55 @@ export function VatDeclarationView() {
{data && !awaitingFiscalPeriod && (
<div
className={`space-y-4 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
>
<VatCompositionChart rutor={data.rutor} />
{/* The page follows the filing pipeline: kontrollera, granska,
bokför, lämna in. The checks come first because their errors
invalidate everything below them. */}
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
1 · Kontrollera underlaget
</h2>
<VatChecksCard
checks={checks}
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
onCorrected={() => setRetryKey((k) => k + 1)}
/>
</section>
{/* Summary */}
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
2 · Granska deklarationen
</h2>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-start justify-between gap-3">
<CardTitle>Momsdeklaration - {data.period.start} till {data.period.end}</CardTitle>
<Badge
variant={
data.rutor.ruta49 > 0
? 'warning'
<div className="flex items-center gap-3">
<Badge
variant={
data.rutor.ruta49 > 0
? 'warning'
: data.rutor.ruta49 < 0
? 'success'
: 'secondary'
}
>
{data.rutor.ruta49 > 0
? 'Att betala'
: data.rutor.ruta49 < 0
? 'success'
: 'secondary'
}
>
{data.rutor.ruta49 > 0
? `Att betala: ${formatAmount(data.rutor.ruta49)} kr`
: data.rutor.ruta49 < 0
? `Att återfå: ${formatAmount(Math.abs(data.rutor.ruta49))} kr`
: 'Ingen moms'}
</Badge>
? 'Att återfå'
: 'Ingen moms'}
</Badge>
{data.rutor.ruta49 !== 0 && (
<span className="font-display text-xl tabular-nums">
{formatAmount(Math.abs(data.rutor.ruta49))} kr
</span>
)}
</div>
</div>
</CardHeader>
<CardContent>
@@ -1633,9 +1691,11 @@ export function VatDeclarationView() {
<div className="grid md:grid-cols-2 gap-6">
{/* Utgående moms */}
<div>
<h4 className="font-semibold mb-3">Utgående moms (försäljning)</h4>
<div><table className="w-full text-sm">
<tbody>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
Utgående moms (försäljning)
</h3>
<Table>
<TableBody>
{data.rutor.ruta05 > 0 && (
<VatRutaRow
ruta="05"
@@ -1694,27 +1754,30 @@ export function VatDeclarationView() {
year={year}
period={period}
/>
</tbody>
</TableBody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa utgående</td>
<td className="py-2 text-right">
<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.ruta30 + data.rutor.ruta31 + data.rutor.ruta32 +
data.rutor.ruta60 + data.rutor.ruta61 + data.rutor.ruta62
)} kr
</td>
</tr>
</tfoot>
</table></div>
</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) && (
<>
<h4 className="font-semibold mb-3 mt-6">Omvänd skattskyldighet (inköp)</h4>
<div><table className="w-full text-sm">
<tbody>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3 mt-6">
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} />
@@ -1723,17 +1786,36 @@ export function VatDeclarationView() {
<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} />
</tbody>
</table></div>
</TableBody>
</Table>
</>
)}
{/* Moms vid import */}
{(data.rutor.ruta50 > 0 || data.rutor.ruta60 > 0 || data.rutor.ruta61 > 0 || data.rutor.ruta62 > 0) && (
<>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3 mt-6">
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>
{/* Ingående moms */}
<div>
<h4 className="font-semibold mb-3">Ingående moms (avdragsgill)</h4>
<div><table className="w-full text-sm">
<tbody>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
Ingående moms (avdragsgill)
</h3>
<Table>
<TableBody>
<VatRutaRow
ruta="48"
label="Ingående moms att dra av"
@@ -1759,28 +1841,29 @@ export function VatDeclarationView() {
</td>
</tr>
)}
</tbody>
</TableBody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa ingående</td>
<td className="py-2 text-right">{formatAmount(data.rutor.ruta48)} kr</td>
<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>
</Table>
</div>
</div>
{/* Net result */}
<div className="mt-6 pt-4 border-t-2">
{/* Net result: the tables' sum row. The headline amount lives in
the card header next to the status badge. */}
<div className="mt-6 pt-4 border-t">
<div className="flex justify-between items-center">
<div>
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">49</span>
<span className="font-bold text-lg">
<span className="text-sm font-medium">
{data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'}
</span>
</div>
<span
className={`text-xl font-bold ${
className={`font-semibold tabular-nums ${
data.rutor.ruta49 > 0
? 'text-warning'
: data.rutor.ruta49 < 0
@@ -1794,18 +1877,30 @@ export function VatDeclarationView() {
</div>
</CardContent>
</Card>
</section>
<VatBookingCard
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
/>
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
3 · Bokför momsen
</h2>
<VatBookingCard
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
checksBlocked={checksBlocked}
/>
</section>
<VatManualFilingCard
xmlHref={`/api/reports/vat-declaration/eskd?${vatQueryString()}`}
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
/>
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
4 · Lämna in
</h2>
<VatManualFilingCard
xmlHref={`/api/reports/vat-declaration/eskd?${vatQueryString()}`}
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
/>
</section>
</div>
)}
@@ -1818,7 +1913,7 @@ export function VatDeclarationView() {
year={year}
period={period}
hasData={data !== null}
rutor={data?.rutor ?? null}
localBlocked={checksBlocked}
/>
)}
</div>