Files
accounted/components/reports/VatChecksCard.tsx
T
Jakob Wennberg 0ef5593388 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>
2026-07-11 23:22:52 +02:00

614 lines
25 KiB
TypeScript

'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>
)
}