diff --git a/components/reports/VatAlreadyBookedBanner.tsx b/components/reports/VatAlreadyBookedBanner.tsx new file mode 100644 index 00000000..27f73c6e --- /dev/null +++ b/components/reports/VatAlreadyBookedBanner.tsx @@ -0,0 +1,53 @@ +'use client' + +import Link from 'next/link' +import { CheckCircle2 } from 'lucide-react' +import { formatDate } from '@/lib/utils' +import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' +import type { VatSettlementExistingEntry } from '@/lib/reports/vat-settlement' + +/** + * Top-of-page signal that this momsperiod already has a posted settlement. + * Detection is the same as steg 3 (tagged vat_settlement or shape-detected + * momsomföring). Read-only: does not claim Skatteverket submission. + */ +export function VatAlreadyBookedBanner({ + entry, + deadlineCompleted, +}: { + entry: VatSettlementExistingEntry + /** Calendar deadline marked klar — not the same as SKV kvittens. */ + deadlineCompleted?: boolean +}) { + const voucher = formatVoucher(entry) + + return ( +
+
+ ) +} diff --git a/components/reports/use-vat-settlement-proposal.ts b/components/reports/use-vat-settlement-proposal.ts new file mode 100644 index 00000000..7ace86fe --- /dev/null +++ b/components/reports/use-vat-settlement-proposal.ts @@ -0,0 +1,72 @@ +'use client' + +import { useEffect, useState } from 'react' +import type { VatPeriodType } from '@/types' +import type { VatSettlementProposal } from '@/lib/reports/vat-settlement' +import { + findDraftVatSettlement, + findPostedVatSettlement, + vatSettlementBookingStatus, +} from '@/lib/reports/vat-settlement' + +/** + * Loads the settlement proposal for the open momsperiod so Granska can show + * the already-booked banner without waiting for steg 3. Same endpoint as + * VatBookingCard; tagged by fetch key so a period switch never flashes the + * previous period's voucher. + */ +export function useVatSettlementProposal(opts: { + periodType: VatPeriodType | null + year: number + period: number + fiscalPeriodId?: string + enabled: boolean + refreshKey?: number +}) { + const { periodType, year, period, fiscalPeriodId, enabled, refreshKey = 0 } = opts + const fetchKey = + enabled && periodType + ? `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}:${refreshKey}` + : null + + const [result, setResult] = useState<{ + key: string + proposal?: VatSettlementProposal + failed?: boolean + } | null>(null) + + useEffect(() => { + if (!fetchKey || !periodType) return + const params = new URLSearchParams({ + periodType, + year: String(year), + period: String(period), + }) + if (fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId) + let cancelled = false + fetch(`/api/reports/vat-declaration/settlement-proposal?${params.toString()}`) + .then(async (res) => { + const json = await res.json().catch(() => null) + if (cancelled) return + if (!res.ok || !json?.data) setResult({ key: fetchKey, failed: true }) + else setResult({ key: fetchKey, proposal: json.data }) + }) + .catch(() => { + if (!cancelled) setResult({ key: fetchKey, failed: true }) + }) + return () => { + cancelled = true + } + }, [fetchKey, periodType, year, period, fiscalPeriodId]) + + const upToDate = result !== null && result.key === fetchKey + const proposal = upToDate ? (result.proposal ?? null) : null + const failed = upToDate && !!result.failed + const booked = findPostedVatSettlement(proposal?.existing_entries) + const draft = findDraftVatSettlement(proposal?.existing_entries) + const bookingStatus = proposal + ? vatSettlementBookingStatus(proposal.existing_entries) + : null + + return { upToDate, proposal, failed, booked, draft, bookingStatus } +} diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index b77ee6c3..a326912c 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -42,7 +42,13 @@ import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useCanWrite } from '@/lib/hooks/use-can-write' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' -import type { VatSettlementProposal } from '@/lib/reports/vat-settlement' +import { + vatDeadlineTaxPeriod, + type VatSettlementExistingEntry, + type VatSettlementProposal, +} from '@/lib/reports/vat-settlement' +import { VatAlreadyBookedBanner } from '@/components/reports/VatAlreadyBookedBanner' +import { useVatSettlementProposal } from '@/components/reports/use-vat-settlement-proposal' // Recharts is ~180KB: defer the chart components so report tables (the // regulated content) render without waiting for the charting bundle. @@ -1124,74 +1130,30 @@ function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: s * showing the declared figures after booking. */ function VatBookingCard({ - periodType, - year, - period, - fiscalPeriodId, checksBlocked, - onStatus, + proposal, + failed, + upToDate, + booked, + draft, + onRetry, }: { - periodType: VatPeriodType - year: number - period: number - fiscalPeriodId?: string /** * True when the local pre-flight checks found ERRORs. Booking stays * possible (the RC-basis fixes only touch 44xx/45xx pairs, never the 26xx * accounts the settlement clears), but the user should know before filing. */ checksBlocked?: boolean - /** Lets the surrounding stepper mirror the booking state on its dot. */ - onStatus?: (status: 'booked' | 'draft' | 'none') => void + /** Settlement proposal loaded by the parent so Granska can reuse it. */ + proposal: VatSettlementProposal | null + failed: boolean + upToDate: boolean + booked?: VatSettlementExistingEntry + draft?: VatSettlementExistingEntry + onRetry: () => void }) { const { canWrite } = useCanWrite() const [dialogOpen, setDialogOpen] = useState(false) - const [refreshKey, setRefreshKey] = useState(0) - // Fetch outcome tagged with the key it was requested under; proposal/failed - // are derived by comparing that tag with the current key, so the effect - // never sets state synchronously (same pattern as VatDeclarationView). - const [result, setResult] = useState<{ - key: string - proposal?: VatSettlementProposal - failed?: boolean - } | null>(null) - const fetchKey = `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}:${refreshKey}` - - useEffect(() => { - const params = new URLSearchParams({ - periodType, - year: String(year), - period: String(period), - }) - if (fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId) - let cancelled = false - fetch(`/api/reports/vat-declaration/settlement-proposal?${params.toString()}`) - .then(async (res) => { - const json = await res.json().catch(() => null) - if (cancelled) return - if (!res.ok || !json?.data) setResult({ key: fetchKey, failed: true }) - else setResult({ key: fetchKey, proposal: json.data }) - }) - .catch(() => { - if (!cancelled) setResult({ key: fetchKey, failed: true }) - }) - return () => { - cancelled = true - } - }, [fetchKey, periodType, year, period, fiscalPeriodId]) - - const upToDate = result !== null && result.key === fetchKey - const proposal = upToDate ? (result.proposal ?? null) : null - const failed = upToDate && !!result.failed - - const booked = proposal?.existing_entries.find((e) => e.status === 'posted') - const draft = booked ? undefined : proposal?.existing_entries.find((e) => e.status === 'draft') - - const bookingStatus = booked ? 'booked' : draft ? 'draft' : 'none' - useEffect(() => { - if (upToDate && proposal) onStatus?.(bookingStatus) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [upToDate, bookingStatus]) // FormLine amounts are input strings; the proposal's numbers are already // öre-rounded server-side, so this is display formatting, not money math. @@ -1251,7 +1213,7 @@ function VatBookingCard({ {failed ? (

Kunde inte hämta verifikatförslaget.

-
@@ -1306,7 +1268,7 @@ function VatBookingCard({ initialLines={initialLines} onCreated={() => { setDialogOpen(false) - setRefreshKey((k) => k + 1) + onRetry() }} /> )} @@ -1541,7 +1503,8 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) { // (errors land on Kontrollera, otherwise Granska). A period switch resets // to automatic so stale step choices never survive a context change. const [chosenStep, setChosenStep] = useState(null) - const [bookingStatus, setBookingStatus] = useState<'booked' | 'draft' | 'none' | null>(null) + const [settlementRefreshKey, setSettlementRefreshKey] = useState(0) + const [deadlineResult, setDeadlineResult] = useState<{ key: string; completed: boolean } | null>(null) // Per-verifikat RC-basis scan, fetched here (not only inside VatChecksCard) // because the filing gate lives here and the worklist unmounts as soon as // the user leaves steg 1. Tagged with the PERIOD it was requested for (see @@ -1643,11 +1606,52 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) { ? null : `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}` + const settlement = useVatSettlementProposal({ + periodType, + year, + period, + fiscalPeriodId: isYearly ? fiscalPeriodId : undefined, + enabled: fetchKey != null, + refreshKey: settlementRefreshKey, + }) + const bookingStatus = settlement.upToDate ? settlement.bookingStatus : null + const taxPeriodKey = periodType ? vatDeadlineTaxPeriod(periodType, year, period) : null + const deadlineCompleted = + !!settlement.booked && + taxPeriodKey != null && + deadlineResult?.key === taxPeriodKey && + deadlineResult.completed + useEffect(() => { setChosenStep(null) - setBookingStatus(null) }, [periodType, year, period, fiscalPeriodId]) + useEffect(() => { + if (!settlement.booked || !taxPeriodKey) return + const key = taxPeriodKey + let cancelled = false + fetch('/api/deadlines?status=completed') + .then(async (res) => { + const json = await res.json().catch(() => null) + if (cancelled) return + const rows = Array.isArray(json?.data) ? json.data : [] + const match = rows.some( + (d: { tax_period?: string | null; tax_deadline_type?: string | null }) => + d.tax_period === key && + (d.tax_deadline_type === 'moms_monthly' || + d.tax_deadline_type === 'moms_quarterly' || + d.tax_deadline_type === 'moms_yearly'), + ) + setDeadlineResult({ key, completed: match }) + }) + .catch(() => { + if (!cancelled) setDeadlineResult({ key, completed: false }) + }) + return () => { + cancelled = true + } + }, [settlement.booked, taxPeriodKey]) + useEffect(() => { if (!fetchKey || periodType === null) return const params = new URLSearchParams({ @@ -1983,6 +1987,13 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
+ {settlement.booked && ( + + )} + {/* Stegen (concept): the filing pipeline as a horizontal stepper — kontrollera, granska, bokför, lämna in — showing one step's content at a time. Errors land on step 1, otherwise Granska. */} @@ -2213,12 +2224,13 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) { {activeStep === 3 && (
setSettlementRefreshKey((k) => k + 1)} />