diff --git a/DECISIONS.md b/DECISIONS.md index a10c4d08..5c0495a3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -66,3 +66,6 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/components/reports/DeclarationRutaRow.tsx b/components/reports/DeclarationRutaRow.tsx new file mode 100644 index 00000000..f10b3aca --- /dev/null +++ b/components/reports/DeclarationRutaRow.tsx @@ -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 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 ( + <> + hasAccounts && setExpanded((v) => !v)} + > + + {hasAccounts && ( + + )} + {code} + {label} + {hasAccounts && ( + + ({accounts.length} konton) + + )} + + {formatAmount(amount)} + + {expanded && hasAccounts && ( + + + + + {accounts.map((acc) => ( + + + + + + ))} + +
+ + {acc.accountName} + {formatAmount(acc.amount)} +
+ + + )} + + ) +} diff --git a/components/reports/INK2DeclarationView.tsx b/components/reports/INK2DeclarationView.tsx index 3a14fdd2..090ee9e4 100644 --- a/components/reports/INK2DeclarationView.tsx +++ b/components/reports/INK2DeclarationView.tsx @@ -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(null) - const [loading, setLoading] = useState(false) - const [downloading, setDownloading] = useState(false) - const [error, setError] = useState(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(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 ( + + + + {error} + + + + ) + } + + if (loading && !data) { + return ( + + + + + + + ) + } + + if (!data) return null + return ( -
- {/* Info card */} +
+ {/* Header: company, year, filing artifact, and how to file it */} - INK2 (Aktiebolag) - - -
- -
-

- 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. -

-

- SRU-filen laddas ner som en ZIP med INFO.SRU och BLANKETTER.SRU. - Ladda upp båda filerna till{' '} - - Skatteverkets filöverföringstjänst - . +

+
+ INK2 (Aktiebolag) +

+ {data.companyInfo.companyName} · {data.fiscalYear.name} + {data.companyInfo.orgNumber && ` · Org.nr: ${data.companyInfo.orgNumber}`}

-
-
- - {data && ( - - )} +
+ {downloadError && ( +
+ + {downloadError} +
+ )} + + +
+ +
+

+ INK2 visar det bokföringsmässiga resultatet baserat på din bokföring. +

+
    +
  1. Ladda ner SRU-filerna (en ZIP med INFO.SRU och BLANKETTER.SRU).
  2. +
  3. + Ladda upp båda filerna till{' '} + + Skatteverkets filöverföringstjänst + . +
  4. +
+
- {error && ( + {/* Warnings */} + {data.warnings.length > 0 && ( - - - {error} - - - )} - - {data && ( - <> - {/* Warnings */} - {data.warnings.length > 0 && ( - - -
- -
- {data.warnings.map((warning, i) => ( -

{warning}

- ))} -
-
-
-
- )} - - {/* Company info */} - - - - {data.companyInfo.companyName} - -

- {data.fiscalYear.name} - {data.companyInfo.orgNumber && ` · Org.nr: ${data.companyInfo.orgNumber}`} -

-
-
- - {/* Assets section */} - - - Tillgångar - - - - - {INK2R_ASSET_CODES.map((code) => ( - - ))} - - - - - - - -
Summa tillgångar - {formatCurrency(data.totals.totalAssets)} -
-
-
- - {/* Equity & Liabilities section */} - - - Eget kapital och skulder - - - - - {INK2R_EQUITY_LIABILITY_CODES.map((code) => ( - - ))} - - - - - - - -
Summa eget kapital och skulder - {formatCurrency(data.totals.totalEquityLiabilities)} -
-
-
- - {/* Income Statement section */} - - - Resultaträkning - - - - - {INK2R_INCOME_CODES.map((code) => ( - - ))} - - - - - - - - - - - -
Rörelseresultat= 0 ? 'text-success' : 'text-destructive'}`}> - {formatCurrency(data.totals.operatingResult)} -
Årets resultat= 0 ? 'text-success' : 'text-destructive'}`}> - {formatCurrency(data.totals.resultAfterFinancial)} -
-
-
- - {/* INK2S summary */} - - - INK2S: Skattemässiga justeringar - - -
- -

- Grundläggande justeringar beräknas automatiskt. Manuella justeringar - (periodiseringsfonder, koncernbidrag m.m.) hanteras av din redovisningskonsult. + +

+ +
+

+ {data.warnings.length}{' '} + {data.warnings.length === 1 ? 'varning' : 'varningar'}

+ {data.warnings.map((warning, i) => ( +

{warning}

+ ))}
- - - - - - - - - - - - - - - - - - - - - -
- 4.1 - Årets resultat (vinst) - {formatCurrency(data.ink2s['7650'])}
- 4.2 - Årets resultat (förlust) - {formatCurrency(data.ink2s['7750'])}
- 4.3a - Skatt på årets resultat (ej avdragsgill) - {formatCurrency(data.ink2s['7651'])}
- {data.ink2s['8020'] > 0 ? 'Överskott (punkt 1.1)' : 'Underskott (punkt 1.2)'} - 0 ? 'text-success' : 'text-destructive'}`}> - {formatCurrency(data.ink2s['8020'] > 0 ? data.ink2s['8020'] : data.ink2s['8021'])} -
- - - - )} - - {!data && !loading && !error && ( - - - Klicka "Hämta INK2" för att generera deklarationsunderlaget. +
)} + + {/* Assets section */} + + + Tillgångar + + + + + + Post + Belopp + + + + {INK2R_ASSET_CODES.map((code) => ( + + ))} + + + + + + + +
Summa tillgångar + {formatWholeKronor(data.totals.totalAssets)} +
+
+
+ + {/* Equity & Liabilities section */} + + + Eget kapital och skulder + + + + + + Post + Belopp + + + + {INK2R_EQUITY_LIABILITY_CODES.map((code) => ( + + ))} + + + + + + + +
Summa eget kapital och skulder + {formatWholeKronor(data.totals.totalEquityLiabilities)} +
+
+
+ + {/* Income Statement section */} + + + Resultaträkning + + + + + + Post + Belopp + + + + {INK2R_INCOME_CODES.map((code) => ( + + ))} + + + + + + + + + + + +
Rörelseresultat= 0 ? 'text-success' : 'text-destructive' + }`} + > + {formatWholeKronor(data.totals.operatingResult)} +
Årets resultat= 0 ? 'text-success' : 'text-destructive' + }`} + > + {formatWholeKronor(data.totals.resultAfterFinancial)} +
+
+
+ + {/* INK2S summary */} + + + INK2S: Skattemässiga justeringar + + +

+ Grundläggande justeringar beräknas automatiskt. Manuella justeringar + (periodiseringsfonder, koncernbidrag m.m.) hanteras av din + redovisningskonsult. +

+ + + + Post + Belopp + + + + {/* 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. */} + + + + + + + + + + +
+ {data.ink2s['8020'] > 0 ? 'Överskott (punkt 1.1)' : 'Underskott (punkt 1.2)'} + 0 ? 'text-success' : 'text-destructive' + }`} + > + {formatWholeKronor( + data.ink2s['8020'] > 0 ? data.ink2s['8020'] : data.ink2s['8021'] + )} +
+
+
) } - -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 ( - <> - accounts.length > 0 && setExpanded(!expanded)} - > - - {code} - {label} - {accounts.length > 0 && ( - - ({accounts.length} konton) - - )} - - - {formatCurrency(amount)} - - - {expanded && accounts.length > 0 && ( - - - - - {accounts.map((acc) => ( - - - - - - ))} - -
- - {acc.accountName} - {formatCurrency(acc.amount)} -
- - - )} - - ) -} diff --git a/components/reports/NEDeclarationView.tsx b/components/reports/NEDeclarationView.tsx index 7d4c3c16..f6537ac2 100644 --- a/components/reports/NEDeclarationView.tsx +++ b/components/reports/NEDeclarationView.tsx @@ -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(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(null) + const [downloadError, setDownloadError] = useState(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 ( + + + + {error} + + + + ) + } + + if (loading && !data) { + return ( + + + + + + + ) + } + + if (!data) return null + return ( -
- {/* Info card */} +
+ {/* Header: company, year, and the filing artifact */} - NE-bilaga (Enskild firma) +
+
+ NE-bilaga (Enskild firma) +

+ {data.companyInfo.companyName} · {data.fiscalYear.name} + {data.companyInfo.orgNumber && ` · Org.nr: ${data.companyInfo.orgNumber}`} +

+
+ +
+ {downloadError && ( +
+ + {downloadError} +
+ )} +
+
+ + {/* Warnings */} + {data.warnings.length > 0 && ( + + +
+ +
+

+ {data.warnings.length}{' '} + {data.warnings.length === 1 ? 'varning' : 'varningar'} +

+ {data.warnings.map((warning, i) => ( +

{warning}

+ ))} +
+
+
+
+ )} + + {/* Revenue section */} + + + Intäkter -

- 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. -

-
- - {data && ( - - )} -
+ + + + Post + Belopp + + + + {revenueRutor.map((ruta) => ( + + ))} + + + + + + + +
Summa intäkter + {formatWholeKronor( + data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4 + )} +
- {error && ( - - - - {error} - - - )} + {/* Expenses section */} + + + Kostnader + + + + + + Post + Belopp + + + + {expenseRutor.map((ruta) => ( + // Costs display negated: the signed value keeps its true sign + // when an expense ruta carries a credit balance. + ({ + ...acc, + amount: -acc.amount, + }))} + /> + ))} + + + + + + + +
Summa kostnader + {formatWholeKronor( + -(data.rutor.R5 + data.rutor.R6 + data.rutor.R7 + + data.rutor.R8 + data.rutor.R9 + data.rutor.R10) + )} +
+
+
- {data && ( - <> - {/* Warnings */} - {data.warnings.length > 0 && ( - - -
- -
- {data.warnings.map((warning, i) => ( -

{warning}

- ))} -
-
-
-
- )} - - {/* Company info */} - - - - {data.companyInfo.companyName} - -

- {data.fiscalYear.name} -

- {data.companyInfo.orgNumber && ( -

- Org.nr: {data.companyInfo.orgNumber} -

- )} -
-
- - {/* Revenue section */} - - - Intäkter - - - - - {revenueRutor.map((ruta) => { - const value = data.rutor[ruta] - const breakdown = data.breakdown[ruta] - return ( - - ) - })} - - - - - - - -
Summa intäkter - {formatCurrency( - data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4 - )} -
-
-
- - {/* Expenses section */} - - - Kostnader - - - - - {expenseRutor.map((ruta) => { - const value = data.rutor[ruta] - const breakdown = data.breakdown[ruta] - return ( - - ) - })} - - - - - - - -
Summa kostnader - -{formatCurrency( - data.rutor.R5 + data.rutor.R6 + data.rutor.R7 + - data.rutor.R8 + data.rutor.R9 + data.rutor.R10 - )} -
-
-
- - {/* Result */} - - -
-
- R11 - Årets resultat -
- = 0 ? 'text-success' : 'text-destructive' - }`} - > - {formatCurrency(data.rutor.R11)} - -
-
-
- - )} - - {!data && !loading && !error && ( - - - Klicka "Hämta NE-bilaga" för att generera deklarationsunderlaget. - - - )} + {/* Result */} + + +
+
+ R11 + Årets resultat +
+ = 0 ? 'text-success' : 'text-destructive' + }`} + > + {formatWholeKronor(data.rutor.R11)} + +
+
+
) } - -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 ( - <> - accounts.length > 0 && setExpanded(!expanded)} - > - - {ruta} - {label} - {accounts.length > 0 && ( - - ({accounts.length} konton) - - )} - - - {isExpense && amount > 0 ? '-' : ''}{formatCurrency(Math.abs(amount))} - - - {expanded && accounts.length > 0 && ( - - - - - {accounts.map((acc) => ( - - - - - - ))} - -
{acc.accountName} - {isExpense && acc.amount > 0 ? '-' : ''}{formatCurrency(Math.abs(acc.amount))} -
- - - )} - - ) -} diff --git a/components/reports/PeriodiskSammanstallningView.tsx b/components/reports/PeriodiskSammanstallningView.tsx index 0d7a0dc6..63aa6dbd 100644 --- a/components/reports/PeriodiskSammanstallningView.tsx +++ b/components/reports/PeriodiskSammanstallningView.tsx @@ -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('quarterly') const [year, setYear] = useState(currentYear) const [period, setPeriod] = useState(currentQuarter) - const [data, setData] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) + // 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 ( -
- {/* Period selection */} +
+ {/* Period selection: the report below follows it automatically */} - - Välj period - - +
- - 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)} > - - - + + + + + Månadsvis + Kvartalsvis + +
- - + +
- - setPeriod(parseInt(value))} > - {periodOptions.map(opt => ( - - ))} - + + + + + {periodOptions.map((opt) => ( + + {opt.label} + + ))} + +
-
@@ -164,14 +233,17 @@ export function PeriodiskSammanstallningView() { {error && ( - - -
{error}
+ + + {error} +
)} - {loading && ( + {!error && loading && !data && ( @@ -180,8 +252,10 @@ export function PeriodiskSammanstallningView() { )} - {data && !loading && ( - <> + {data && !error && ( +
{/* Summary */} @@ -222,18 +296,24 @@ export function PeriodiskSammanstallningView() { {data.reconciliation.matches !== null && data.totals.rowCount > 0 && (
{data.reconciliation.matches ? ( - - ✓ 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) + + + + 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) + ) : ( - - ⚠ 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. + + + + 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. + )}
@@ -256,7 +336,7 @@ export function PeriodiskSammanstallningView() { {/* Errors */} {errors.length > 0 && ( - + @@ -277,7 +357,7 @@ export function PeriodiskSammanstallningView() { - + Varningar ({cautions.length}) @@ -292,16 +372,21 @@ export function PeriodiskSammanstallningView() { Rader - +
+ {hasBlockingErrors && ( +

+ Åtgärda felen ovan innan CSV kan laddas ner. +

+ )} + +
@@ -367,7 +452,7 @@ export function PeriodiskSammanstallningView() { )} - + )} ) @@ -380,7 +465,7 @@ function WarningItem({ warning }: { warning: PsWarning }) {
{warning.message}
{(warning.customerId || warning.invoiceId) && ( -
+
{warning.customerId && ( Öppna kund diff --git a/components/reports/SkatteverketPanel.tsx b/components/reports/SkatteverketPanel.tsx index cc7af76a..1d8c5c53 100644 --- a/components/reports/SkatteverketPanel.tsx +++ b/components/reports/SkatteverketPanel.tsx @@ -5,30 +5,35 @@ import React, { useState, useEffect, useCallback } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + DestructiveConfirmDialog, + useDestructiveConfirm, +} from '@/components/ui/destructive-confirm-dialog' import { AlertCircle, + AlertTriangle, CheckCircle2, - Download, ExternalLink, - Gavel, - Link2, - Link2Off, - Loader2, FileCheck, + Info, + Link2, + Loader2, Lock, - Unlock, + MoreHorizontal, Send, ShieldAlert, - Trash2, } from 'lucide-react' -import type { VatDeclarationRutor, VatPeriodType } from '@/types' +import type { VatPeriodType } from '@/types' import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format' -import { - runVatDeclarationChecks, - type VatDeclarationCheck, -} from '@/lib/reports/vat-declaration-checks' -import type { RcBasisGap } from '@/lib/reports/rc-basis-gaps' -import { formatDate } from '@/lib/utils' import { useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { UpgradeNote } from '@/components/billing/UpgradeNote' @@ -71,18 +76,51 @@ interface SkatteverketPanelProps { period: number hasData: boolean /** - * Calculated rutor for the current period. Used to run local pre-flight - * checks before Skatteverket sees the payload: SKV only validates internal - * arithmetic consistency, so we have to catch "ruta 30-32 present but - * 20-24 empty" locally before letting the user submit. + * True when the local pre-flight checks (VatChecksCard, section 1 of the + * page) found ERRORs. Blocks validate/submit: SKV only validates internal + * arithmetic, so a locally broken declaration would pass their checks and + * still be materially wrong. */ - rutor?: VatDeclarationRutor | null + localBlocked: boolean +} + +/** + * One feedback slot: exactly one message at a time, replaced at the end of + * each action. 'info' is for neutral lookups ("inget utkast hittades"): + * rendering those as success taught users that green means nothing. + */ +interface Notice { + kind: 'error' | 'success' | 'info' + text: string } function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } +const ORG_NUMBER_MISSING_NOTICE: Notice = { + kind: 'error', + text: 'Organisationsnummer saknas. Ange det under Inställningar innan du använder Skatteverket-kopplingen.', +} + +function isOrgNumberMissing(err: unknown): boolean { + return err instanceof Error && err.message === 'Organisationsnummer saknas' +} + +/** + * In-flight labels for actions with no visible button while running (the + * overflow-menu actions close the menu on select): rendered as a status row + * so a slow SKV round-trip is never silent. + */ +const ACTION_IN_FLIGHT_LABELS: Record = { + fetchDraft: 'Hämtar utkast...', + check: 'Kontrollerar inlämning...', + fetchDecided: 'Hämtar beslut...', + unlock: 'Låser upp...', + delete: 'Raderar utkast...', + disconnect: 'Kopplar bort Skatteverket...', +} + const SKV_ENABLED = ENABLED_EXTENSION_IDS.has('skatteverket') export function SkatteverketPanel(props: SkatteverketPanelProps) { @@ -90,13 +128,19 @@ export function SkatteverketPanel(props: SkatteverketPanelProps) { return } -function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: SkatteverketPanelProps) { +function SkatteverketPanelInner({ + periodType, + year, + period, + hasData, + localBlocked, +}: SkatteverketPanelProps) { const hasSkvCapability = useCapability(CAPABILITY.skatteverket) + const { dialogProps, confirm } = useDestructiveConfirm() const [status, setStatus] = useState(null) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) - const [error, setError] = useState(null) - const [success, setSuccess] = useState(null) + const [notice, setNotice] = useState(null) const [kontroller, setKontroller] = useState([]) const [signeringslank, setSigneringslank] = useState(null) const [submitted, setSubmitted] = useState<{ @@ -104,81 +148,19 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk tidpunkt?: string } | null>(null) - // Local sanity checks against the calculated declaration, run before any - // SKV call. SKV's "OK" only confirms arithmetic: these checks confirm - // the declaration looks plausible (no orphaned RC output, no missing - // basis, no summaMoms drift). - const localChecks: VatDeclarationCheck[] = rutor ? runVatDeclarationChecks(rutor) : [] - const localErrors = localChecks.filter((c) => c.status === 'ERROR') - const localBlocked = localErrors.length > 0 - - // Per-voucher RC basis gap detection: fetched whenever a RC_BASIS_MISSING - // warning fires so we can show the user exactly which verifikationer are - // missing the basbelopp pair and offer a one-click correction. - const hasRcBasisWarning = localChecks.some((c) => c.code === 'RC_BASIS_MISSING') - const [gaps, setGaps] = useState([]) - const [gapsLoading, setGapsLoading] = useState(false) - const [fixingId, setFixingId] = useState(null) - const [gapSelections, setGapSelections] = useState< - Record - >({}) - - useEffect(() => { - if (!hasRcBasisWarning) { - setGaps([]) - return - } - let cancelled = false - setGapsLoading(true) - fetch( - `/api/reports/vat-declaration/rc-basis-gaps?periodType=${periodType}&year=${year}&period=${period}`, - ) - .then((r) => r.json()) - .then((j) => { - if (cancelled) return - setGaps(j?.data?.gaps || []) - }) - .catch(() => { - if (cancelled) return - setGaps([]) - }) - .finally(() => { - if (cancelled) return - setGapsLoading(false) - }) - return () => { - cancelled = true - } - }, [hasRcBasisWarning, periodType, year, period]) - - const handleFixGap = async (gap: RcBasisGap) => { - const sel = gapSelections[gap.entryId] ?? { supplierType: 'eu_business', supplyType: 'service' as const } - setFixingId(gap.entryId) - setError(null) - 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 result = await res.json() - if (!res.ok) { - setError(result?.error || 'Kunde inte korrigera verifikationen') - } else { - setGaps((prev) => prev.filter((g) => g.entryId !== gap.entryId)) - setSuccess( - `Verifikation ${gap.voucherSeries}-${gap.voucherNumber} korrigerad. Storno + ny verifikation skapad. Ladda om sidan för att uppdatera rutorna.`, - ) - } - } catch { - setError('Kunde inte korrigera verifikationen') - } finally { - setFixingId(null) - } + // Per-period SKV state resets when the picker changes (render-phase + // adjustment): a signing link, kvittens, or kontrollresultat fetched for + // one period must never render as if it belonged to another. Without this, + // the visibilitychange auto-check could stamp "Deklarationen har lämnats + // in" for a different period than the one being signed. + const periodKey = `${periodType}:${year}:${period}` + const [appliedPeriodKey, setAppliedPeriodKey] = useState(periodKey) + if (appliedPeriodKey !== periodKey) { + setAppliedPeriodKey(periodKey) + setKontroller([]) + setSigneringslank(null) + setSubmitted(null) + setNotice(null) } /** @@ -186,10 +168,12 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk * has expired/been revoked/lost scope, immediately reflect that in the * local status so the "Förnya session" CTA appears next to the message: * the user shouldn't have to wait for /status to catch up. + * Extension routes return a FLAT { error: string, code } shape, unlike the + * core routes' nested envelope: do not unify the parsers. */ const applyApiError = useCallback((result: { error?: string; code?: string } | null) => { if (!result?.error) return false - setError(result.error) + setNotice({ kind: 'error', text: result.error }) if (result.code && AUTH_RECONNECT_CODES.has(result.code)) { setStatus((prev) => prev ? { ...prev, expired: true } : prev) } @@ -216,7 +200,7 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk // Check URL params for OAuth callback results const params = new URLSearchParams(window.location.search) if (params.get('skv_connected') === 'true') { - setSuccess('Ansluten till Skatteverket') + setNotice({ kind: 'success', text: 'Ansluten till Skatteverket' }) fetchStatus() // Clean URL const url = new URL(window.location.href) @@ -225,7 +209,7 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk } const skvError = params.get('skv_error') if (skvError) { - setError(decodeURIComponent(skvError)) + setNotice({ kind: 'error', text: decodeURIComponent(skvError) }) const url = new URL(window.location.href) url.searchParams.delete('skv_error') window.history.replaceState({}, '', url.toString()) @@ -243,20 +227,25 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk const handleDisconnect = async () => { setActionLoading('disconnect') - setError(null) + setNotice(null) try { const res = await fetch('/api/extensions/ext/skatteverket/disconnect', { method: 'POST', }) if (res.ok) { setStatus({ connected: false }) - setSuccess(null) + setNotice(null) setKontroller([]) setSigneringslank(null) setSubmitted(null) + } else { + const result = await res.json().catch(() => ({})) + if (!applyApiError(result)) { + setNotice({ kind: 'error', text: `Kunde inte koppla bort (${res.status})` }) + } } } catch { - setError('Kunde inte koppla bort') + setNotice({ kind: 'error', text: 'Kunde inte koppla bort' }) } finally { setActionLoading(null) } @@ -264,14 +253,16 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk const handleValidate = async () => { if (localBlocked) { - setError( - 'Lokala kontroller hittade fel i bokföringen. Åtgärda dessa innan ' + - 'du skickar till Skatteverket.', - ) + setNotice({ + kind: 'error', + text: + 'Åtgärda felen under Kontroll av underlaget högst upp på sidan innan ' + + 'du skickar till Skatteverket.', + }) return } setActionLoading('validate') - setError(null) + setNotice(null) setKontroller([]) try { const res = await fetch('/api/extensions/ext/skatteverket/declaration/validate', { @@ -289,21 +280,26 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk // SKV's OK only confirms arithmetic: it does NOT confirm that the // declaration is materially correct. We say so explicitly so the // user doesn't read this as a green light for actual filing. - setSuccess( - 'Skatteverket har inga tekniska invändningar mot deklarationen. ' + - 'Kontrollera siffrorna i förhandsgranskningen innan du skickar in.', - ) + setNotice({ + kind: 'success', + text: + 'Skatteverket har inga tekniska invändningar mot deklarationen. ' + + 'Kontrollera siffrorna i förhandsgranskningen innan du skickar in.', + }) } else { const errors = controls.filter(k => k.status === 'ERROR') if (errors.length > 0) { - setError(`${errors.length} valideringsfel hittades`) + setNotice({ kind: 'error', text: `${errors.length} valideringsfel hittades` }) } else { - setSuccess('Skatteverket har inga tekniska invändningar (med varningar)') + setNotice({ + kind: 'success', + text: 'Skatteverket har inga tekniska invändningar (med varningar)', + }) } } } } catch { - setError('Kunde inte validera deklarationen') + setNotice({ kind: 'error', text: 'Kunde inte validera deklarationen' }) } finally { setActionLoading(null) } @@ -311,14 +307,16 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk const handleSaveDraft = async () => { if (localBlocked) { - setError( - 'Lokala kontroller hittade fel i bokföringen. Åtgärda dessa innan ' + - 'du sparar utkastet hos Skatteverket.', - ) + setNotice({ + kind: 'error', + text: + 'Åtgärda felen under Kontroll av underlaget högst upp på sidan innan ' + + 'du sparar utkastet hos Skatteverket.', + }) return } setActionLoading('draft') - setError(null) + setNotice(null) try { const res = await fetch('/api/extensions/ext/skatteverket/declaration/draft', { method: 'POST', @@ -333,21 +331,36 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk setKontroller(controls) const errors = controls.filter(k => k.status === 'ERROR') if (errors.length === 0) { - setSuccess('Utkast sparat i Eget utrymme hos Skatteverket') + setNotice({ kind: 'success', text: 'Utkast sparat i Eget utrymme hos Skatteverket' }) } else { - setError(`Utkastet sparades men har ${errors.length} valideringsfel`) + setNotice({ + kind: 'error', + text: `Utkastet sparades men har ${errors.length} valideringsfel`, + }) } } } catch { - setError('Kunde inte spara utkast') + setNotice({ kind: 'error', text: 'Kunde inte spara utkast' }) } finally { setActionLoading(null) } } + // Helper to get redovisare from settings + const getRedovisare = useCallback(async (): Promise => { + const res = await fetch('/api/settings') + const { data } = await res.json() + if (!data?.org_number) throw new Error('Organisationsnummer saknas') + return formatRedovisare(data.org_number, data.entity_type) + }, []) + + const getRedovisningsperiod = useCallback((): string => { + return formatRedovisningsperiod(periodType, year, period) + }, [periodType, year, period]) + const handleLock = async () => { setActionLoading('lock') - setError(null) + setNotice(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/lock?redovisare=${encodeURIComponent( @@ -360,10 +373,17 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk // surfaced + status updated; nothing more to do } else if (result.data?.signeringsLank) { setSigneringslank(result.data.signeringsLank) - setSuccess('Utkastet är låst. Öppna signeringslänken för att signera med BankID.') + setNotice({ + kind: 'success', + text: 'Utkastet är låst. Öppna signeringslänken för att signera med BankID.', + }) } - } catch { - setError('Kunde inte låsa utkastet') + } catch (err) { + setNotice( + isOrgNumberMissing(err) + ? ORG_NUMBER_MISSING_NOTICE + : { kind: 'error', text: 'Kunde inte låsa utkastet' }, + ) } finally { setActionLoading(null) } @@ -371,7 +391,7 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk const handleUnlock = async () => { setActionLoading('unlock') - setError(null) + setNotice(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/lock?redovisare=${encodeURIComponent( @@ -384,18 +404,27 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk // surfaced + status updated; nothing more to do } else { setSigneringslank(null) - setSuccess('Utkastet har låsts upp') + setNotice({ kind: 'success', text: 'Utkastet har låsts upp' }) } - } catch { - setError('Kunde inte låsa upp utkastet') + } catch (err) { + setNotice( + isOrgNumberMissing(err) + ? ORG_NUMBER_MISSING_NOTICE + : { kind: 'error', text: 'Kunde inte låsa upp utkastet' }, + ) } finally { setActionLoading(null) } } - const handleCheckSubmitted = async () => { + /** + * `silent` suppresses the "inget hittades" notice: used by the automatic + * re-check when the tab regains focus after the user signed at SKV, where + * a recurring "nothing found" message would just be noise. + */ + const handleCheckSubmitted = useCallback(async (silent = false) => { setActionLoading('check') - setError(null) + setNotice(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/submitted?redovisare=${encodeURIComponent( @@ -407,20 +436,38 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk // surfaced + status updated; nothing more to do } else if (result.data) { setSubmitted(result.data) - setSuccess('Deklarationen har lämnats in') - } else { - setSuccess('Ingen inlämnad deklaration hittades för denna period') + setNotice({ kind: 'success', text: 'Deklarationen har lämnats in' }) + } else if (!silent) { + setNotice({ kind: 'info', text: 'Ingen inlämnad deklaration hittades för denna period' }) + } + } catch (err) { + if (isOrgNumberMissing(err)) { + setNotice(ORG_NUMBER_MISSING_NOTICE) + } else if (!silent) { + setNotice({ kind: 'error', text: 'Kunde inte kontrollera inlämningsstatus' }) } - } catch { - setError('Kunde inte kontrollera inlämningsstatus') } finally { setActionLoading(null) } - } + }, [applyApiError, getRedovisare, getRedovisningsperiod]) + + // While a signing link is outstanding, re-check submission status when the + // user returns to this tab: signing happens on Skatteverket's site, so the + // return trip is the natural moment for the kvittens to appear. + useEffect(() => { + if (!signeringslank || submitted) return + const onVisibilityChange = () => { + if (document.visibilityState === 'visible' && actionLoading === null) { + handleCheckSubmitted(true) + } + } + document.addEventListener('visibilitychange', onVisibilityChange) + return () => document.removeEventListener('visibilitychange', onVisibilityChange) + }, [signeringslank, submitted, actionLoading, handleCheckSubmitted]) const handleDeleteDraft = async () => { setActionLoading('delete') - setError(null) + setNotice(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/draft?redovisare=${encodeURIComponent( @@ -431,15 +478,19 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk if (res.status === 204 || res.ok) { setKontroller([]) setSigneringslank(null) - setSuccess('Utkastet har raderats från Eget utrymme') + setNotice({ kind: 'success', text: 'Utkastet har raderats från Eget utrymme' }) } else { const result = await res.json().catch(() => ({})) if (!applyApiError(result)) { - setError(`Kunde inte radera utkast (${res.status})`) + setNotice({ kind: 'error', text: `Kunde inte radera utkast (${res.status})` }) } } - } catch { - setError('Kunde inte radera utkast') + } catch (err) { + setNotice( + isOrgNumberMissing(err) + ? ORG_NUMBER_MISSING_NOTICE + : { kind: 'error', text: 'Kunde inte radera utkast' }, + ) } finally { setActionLoading(null) } @@ -447,7 +498,7 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk const handleFetchDraft = async () => { setActionLoading('fetchDraft') - setError(null) + setNotice(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/draft?redovisare=${encodeURIComponent( @@ -458,15 +509,19 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk if (applyApiError(result)) { // surfaced + status updated; nothing more to do } else if (!result.data) { - setSuccess('Inget sparat utkast hittades för perioden') + setNotice({ kind: 'info', text: 'Inget sparat utkast hittades för perioden' }) } else { const locked = result.data?.locked ? ' (låst)' : '' const summa = result.data?.momsuppgift?.summaMoms const summaLabel = summa !== undefined ? `, summaMoms = ${formatAmount(summa)}` : '' - setSuccess(`Sparat utkast hittades${locked}${summaLabel}`) + setNotice({ kind: 'success', text: `Sparat utkast hittades${locked}${summaLabel}` }) } - } catch { - setError('Kunde inte hämta utkast') + } catch (err) { + setNotice( + isOrgNumberMissing(err) + ? ORG_NUMBER_MISSING_NOTICE + : { kind: 'error', text: 'Kunde inte hämta utkast' }, + ) } finally { setActionLoading(null) } @@ -474,7 +529,7 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk const handleFetchDecided = async () => { setActionLoading('fetchDecided') - setError(null) + setNotice(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/declaration/decided?redovisare=${encodeURIComponent( @@ -485,60 +540,73 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk if (applyApiError(result)) { // surfaced + status updated; nothing more to do } else if (!result.data) { - setSuccess('Inget beslut hittades för perioden') + setNotice({ kind: 'info', text: 'Inget beslut hittades för perioden' }) } else { const tid = result.data?.beslutadTidpunkt const tidLabel = tid ? ` (beslutad ${new Date(tid).toLocaleDateString('sv-SE')})` : '' - setSuccess(`Beslut hittades${tidLabel}`) + setNotice({ kind: 'success', text: `Beslut hittades${tidLabel}` }) } - } catch { - setError('Kunde inte hämta beslutade uppgifter') + } catch (err) { + setNotice( + isOrgNumberMissing(err) + ? ORG_NUMBER_MISSING_NOTICE + : { kind: 'error', text: 'Kunde inte hämta beslutade uppgifter' }, + ) } finally { setActionLoading(null) } } - // Helper to get redovisare from settings - const getRedovisare = async (): Promise => { - const res = await fetch('/api/settings') - const { data } = await res.json() - if (!data?.org_number) throw new Error('Organisationsnummer saknas') - return formatRedovisare(data.org_number, data.entity_type) + const handleDeleteDraftConfirmed = async () => { + const ok = await confirm({ + title: 'Radera utkastet hos Skatteverket?', + description: + 'Utkastet tas bort från Eget utrymme hos Skatteverket. Detta går inte att ångra.', + confirmLabel: 'Radera utkast', + }) + if (!ok) return + await handleDeleteDraft() } - const getRedovisningsperiod = (): string => { - return formatRedovisningsperiod(periodType, year, period) + const handleDisconnectConfirmed = async () => { + const ok = await confirm({ + title: 'Koppla bort Skatteverket?', + description: + 'Anslutningen tas bort och du behöver ansluta med BankID igen för att kunna skicka direkt.', + confirmLabel: 'Koppla bort', + }) + if (!ok) return + await handleDisconnect() } if (loading) { return ( - - - Kontrollerar Skatteverket-anslutning... + + + ) } // Paywall: direct API submission is the paid convenience; manual filing at - // skatteverket.se stays free and is described in the card above this panel. + // skatteverket.se stays free and is owned by the "Lämna in" card above. // Rendered BEFORE the connected check so a company that connected during // trial sees the upsell instead of action buttons that would 403. if (!hasSkvCapability) { return ( - - + + Skicka direkt till Skatteverket (valfritt)

- Deklarationen är redan klar att lämnas in manuellt hos Skatteverket - (se ovan). Med ett abonnemang kan du ansluta med BankID och skicka - den direkt härifrån, samt validera, spara utkast och signera. + Med ett abonnemang kan du ansluta med BankID och skicka deklarationen + direkt härifrån, samt validera, spara utkast och signera.

Direktinlämning till Skatteverket kräver ett abonnemang. @@ -549,30 +617,31 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk } // Not connected. The momsdeklaration is already complete and can be filed - // manually at skatteverket.se with no connection (see the manual-filing card - // rendered above this panel). Connecting is an optional convenience for - // submitting directly from Accounted, so frame it that way: users must not - // read the connection as a prerequisite for producing or filing the report. + // manually at skatteverket.se with no connection (the "Lämna in" card above + // owns that path). Connecting is an optional convenience for submitting + // directly from Accounted, so frame it that way. if (!status?.connected) { return ( - - + + Skicka direkt till Skatteverket (valfritt) - {error && ( -
- - {error} + {notice?.kind === 'error' && ( +
+ + {notice.text}
)}

- Deklarationen är redan klar att lämnas in manuellt hos Skatteverket - (se ovan). Vill du slippa skriva in siffrorna själv kan du ansluta med - BankID och skicka den direkt härifrån, samt validera, spara utkast och + Vill du slippa skriva in siffrorna själv kan du ansluta med BankID och + skicka deklarationen direkt härifrån, samt validera, spara utkast och signera.

)} + {/* Read-only lookups and recovery actions live in the overflow + menu: the visible surface stays the forward path (validera, + spara utkast, lås och signera). */} + + + + + + Status hos Skatteverket + handleFetchDraft()} + > +
+

Hämta utkast

+

+ Hämta sparat utkast från Eget utrymme +

+
+
+ handleCheckSubmitted()} + > +
+

Kontrollera inlämning

+

+ Kontrollera om en signerad deklaration har lämnats in +

+
+
+ handleFetchDecided()} + > +
+

Hämta beslut

+

+ Hämta Skatteverkets beslut för perioden +

+
+
+ + Återställning + handleUnlock()} + > +
+

Lås upp

+

+ Lås upp en låst period så att utkastet kan ändras +

+
+
+ handleDeleteDraftConfirmed()} + > +
+

Radera utkast...

+

+ Radera sparat utkast från Eget utrymme +

+
+
+ + handleDisconnectConfirmed()} + className="text-destructive focus:text-destructive" + > +
+

Koppla bort Skatteverket...

+

+ Kräver ny BankID-anslutning för direktinlämning +

+
+
+
+
- {/* Messages */} - {error && ( -
- - {error} -
- )} - {success && !error && ( -
- - {success} + {/* In-flight status for overflow-menu actions: their menu closes on + select, so this row is the only visible sign of work. */} + {actionLoading && ACTION_IN_FLIGHT_LABELS[actionLoading] && ( +
+ + {ACTION_IN_FLIGHT_LABELS[actionLoading]}
)} - {/* Local pre-flight check results: surfaced separately from SKV's - kontroller so the user knows these are Accounted's own sanity checks, - not Skatteverket's. ERRORs block the submit/validate buttons. */} - {localChecks.length > 0 && ( -
-

- Lokala kontroller -

- {localChecks.map((c, i) => ( -
- {c.status === 'ERROR' ? ( - - ) : ( - - )} -
- {c.code} - {c.message} -
-
- ))} -
- )} - - {/* Per-voucher RC basis gaps: concrete list of verifikationer that - triggered RC_BASIS_MISSING, with a one-click korrigera action. */} - {hasRcBasisWarning && ( -
-

- Verifikationer som saknar basbelopp -

- {gapsLoading ? ( -
- - Söker berörda verifikationer... -
- ) : gaps.length === 0 ? ( -

- Inga verifikationer hittades. Bristen kan ligga utanför perioden - eller i bokföring som inte är posted. -

- ) : ( -
- {gaps.map((gap) => { - const sel = gapSelections[gap.entryId] ?? { - supplierType: 'eu_business' as const, - supplyType: 'service' as const, - } - return ( -
-
-
-

- Verifikation {gap.voucherSeries}-{gap.voucherNumber} - - {' · '} - {formatDate(gap.entryDate)} - -

-

- {gap.description} -

-

- {gap.rcOutputAccount} har{' '} - {gap.rcOutputAmount.toLocaleString('sv-SE', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}{' '} - kr fiktiv moms: saknar basbelopp{' '} - {gap.expectedBasisAmount.toLocaleString('sv-SE', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}{' '} - kr -

-
-
-
- - - -
-
- ) - })} -
- )} -
+ {/* Single feedback slot: errors are assertive alerts, success/info are + polite status messages on the neutral surface. */} + {notice && ( + notice.kind === 'error' ? ( +
+ + {notice.text} +
+ ) : ( +
+ {notice.kind === 'success' ? ( + + ) : ( + + )} + {notice.text} +
+ ) )} {/* Validation results from Skatteverket */} {kontroller.length > 0 && ( -
-

+

+

Skatteverkets valideringsresultat -

+

{kontroller.map((k, i) => (
{k.status === 'ERROR' ? ( - + ) : ( - + )}
- {k.kod} + {k.kod} {k.beskrivning}
@@ -821,8 +839,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk {/* Submitted confirmation */} {submitted && ( -
-

+

+

Inlämnad

@@ -844,178 +862,92 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk

Utkastet är låst och redo att signeras

- Öppna länken nedan och signera med BankID på Skatteverkets sida. + Öppna länken nedan och signera med BankID på Skatteverkets sida. När du + kommer tillbaka hit kontrolleras inlämningen automatiskt.

- +
+ + +
)} - {/* Forward-lifecycle buttons */} + {/* Forward lifecycle: the only always-visible action row. */}
- {/* Read-only fetches from Skatteverket. */} -
- - - - - -
- - {/* Recovery / cleanup buttons. Always visible when connected so - the user can back out of a locked or stale draft state without - depending on local UI state surviving a reload. SKV returns - 404/409 if the action isn't applicable; we surface that as an - error message rather than hiding the button. */} -
- - - -
- - {/* Disconnect */} -
- -
+ {/* Visible disabled-state explanations: title attributes never show + on disabled buttons. */} + {localBlocked && ( +

+ Åtgärda felen under Kontroll av underlaget högst upp på sidan innan du + validerar eller skickar in. +

+ )} + {hasErrors && !localBlocked && ( +

+ Valideringsfelen ovan måste åtgärdas innan utkastet kan låsas. +

+ )} + ) } diff --git a/components/reports/VatChecksCard.tsx b/components/reports/VatChecksCard.tsx new file mode 100644 index 00000000..a82693b8 --- /dev/null +++ b/components/reports/VatChecksCard.tsx @@ -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 = { + eu_business: 'EU-leverantör', + non_eu_business: 'Utanför EU', + swedish_business: 'Svensk omvänd skattskyldighet', +} +const SUPPLY_LABELS: Record = { + 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>(new Set()) + const [showAll, setShowAll] = useState(false) + const [expandedId, setExpandedId] = useState(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({ + supplierType: 'eu_business', + supplyType: 'service', + }) + const [overrides, setOverrides] = useState>({}) + const [rowErrors, setRowErrors] = useState>({}) + const [fixingId, setFixingId] = useState(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(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 ? ( + router.push(`/bookkeeping/${correctedId}`)} + > + Visa verifikat + + ) : 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 = {} + 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 ( + + +
+ Kontroll av underlaget + {errorCount > 0 ? ( + + {errorCount} {errorCount === 1 ? 'fel' : 'fel'} + + ) : warningCount > 0 ? ( + + {warningCount} {warningCount === 1 ? 'varning' : 'varningar'} + + ) : ( + Inga anmärkningar + )} +
+
+ + {checks.length === 0 && ( +
+ + Inga fel hittades i underlaget för perioden. +
+ )} + + {checks.length > 0 && ( +
+ {checks.map((c, i) => ( +
+ {c.status === 'ERROR' ? ( + + ) : ( + + )} +
+ {c.code} + {c.message} +
+
+ ))} +
+ )} + + {showGapWorklist && ( +
+

+ Verifikationer som saknar basbelopp ({gaps.length}) +

+ + {gapsLoading ? ( +
+ + Söker berörda verifikationer... +
+ ) : gapsFailed ? ( +
+

+ Kunde inte hämta verifikationslistan. +

+ +
+ ) : gaps.length === 0 ? ( +

+ Inga verifikationer hittades. Bristen kan ligga utanför perioden eller i + bokföring som inte är bokförd. +

+ ) : ( + <> +
+
+ + +
+
+ + +
+ +
+ + {bulkProgress && ( +

+ Korrigerar {Math.min(bulkProgress.done + 1, bulkProgress.total)} av{' '} + {bulkProgress.total}... +

+ )} + {!canWrite && ( +

Kräver skrivbehörighet.

+ )} + + + {visibleGaps.map((gap) => { + const sel = classificationFor(gap) + const expanded = expandedId === gap.entryId + const rowError = rowErrors[gap.entryId] + return ( + setExpandedId(expanded ? null : gap.entryId)} + trailing={ + <> + + {formatAmount(gap.expectedBasisAmount)} kr + + + + + } + expandedContent={ +
+

+ {gap.rcOutputAccount} har {formatAmount(gap.rcOutputAmount)} kr + fiktiv moms: saknar basbelopp{' '} + {formatAmount(gap.expectedBasisAmount)} kr +

+
+
+ + +
+
+ + +
+
+
+ } + > + + Verifikation {gap.voucherSeries}-{gap.voucherNumber} + + + {formatDate(gap.entryDate)} + {gap.description && ( + <> + + {gap.description} + + )} + + {/* Always visible: a failed korrigering must not hide + its reason behind the collapsed expansion. */} + {rowError && ( +

+ {rowError} +

+ )} +
+ ) + })} +
+ + {gaps.length > GAP_PREVIEW_COUNT && ( + + )} + + )} +
+ )} +
+ +
+ ) +} diff --git a/components/reports/VatCompositionChart.tsx b/components/reports/VatCompositionChart.tsx deleted file mode 100644 index 0750c626..00000000 --- a/components/reports/VatCompositionChart.tsx +++ /dev/null @@ -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 ( - - - Momsfördelning - - - - - - {chartData.map((_, index) => ( - - ))} - - [ - formatCurrency(Number(value)), - ]} - /> - - - - - - ) -} diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 232342cd..fb56ab86 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -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 ( - Lämna in din momsdeklaration + Lämna in själv (utan anslutning)

- 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.

-

- 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). +

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

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

)} + {checksBlocked && ( +
+ +

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

+
+ )} + {failed ? (

Kunde inte hämta verifikatförslaget.

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

Ingen moms att bokföra för perioden.

) : ( - +
+ + {!canWrite && ( +

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

+ )} +
)}
@@ -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 ( -
+
+ {/* XML and PDF live in "Lämna in" below: they are filing artifacts, not + report exports, and one home avoids two competing download surfaces. */} @@ -1599,30 +1632,55 @@ export function VatDeclarationView() { {data && !awaitingFiscalPeriod && (
- + {/* The page follows the filing pipeline: kontrollera, granska, + bokför, lämna in. The checks come first because their errors + invalidate everything below them. */} +
+

+ 1 · Kontrollera underlaget +

+ setRetryKey((k) => k + 1)} + /> +
- {/* Summary */} +
+

+ 2 · Granska deklarationen +

-
+
Momsdeklaration - {data.period.start} till {data.period.end} - 0 - ? 'warning' +
+ 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'} - + ? 'Att återfå' + : 'Ingen moms'} + + {data.rutor.ruta49 !== 0 && ( + + {formatAmount(Math.abs(data.rutor.ruta49))} kr + + )} +
@@ -1633,9 +1691,11 @@ export function VatDeclarationView() {
{/* Utgående moms */}
-

Utgående moms (försäljning)

-
- +

+ Utgående moms (försäljning) +

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

Omvänd skattskyldighet (inköp)

-
- +

+ Omvänd skattskyldighet (inköp) +

+
+ @@ -1723,17 +1786,36 @@ export function VatDeclarationView() { - -
+ + + + )} + + {/* Moms vid import */} + {(data.rutor.ruta50 > 0 || data.rutor.ruta60 > 0 || data.rutor.ruta61 > 0 || data.rutor.ruta62 > 0) && ( + <> +

+ Moms vid import +

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

Ingående moms (avdragsgill)

-
- +

+ Ingående moms (avdragsgill) +

+
+ )} - + - - - + + + -
Summa ingående{formatAmount(data.rutor.ruta48)} kr
Summa ingående moms{formatAmount(data.rutor.ruta48)} kr
+
- {/* Net result */} -
+ {/* Net result: the tables' sum row. The headline amount lives in + the card header next to the status badge. */} +
49 - + {data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'}
0 ? 'text-warning' : data.rutor.ruta49 < 0 @@ -1794,18 +1877,30 @@ export function VatDeclarationView() {
+ - +
+

+ 3 · Bokför momsen +

+ +
- +
+

+ 4 · Lämna in +

+ +
)} @@ -1818,7 +1913,7 @@ export function VatDeclarationView() { year={year} period={period} hasData={data !== null} - rutor={data?.rutor ?? null} + localBlocked={checksBlocked} /> )}