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