feat(vat): Momsdeklaration as Stegen - stepper, flat steps, house toolbar (#1154)

* feat(vat): Momsdeklaration as Stegen (horizontal stepper, one step at a time)

The founder-picked concept variant for Moms: the four pipeline sections
(kontrollera, granska, bokfor, lamna in) become a clickable horizontal
stepper with honest per-step status subs (fel/varningar from the
pre-flight checks, att betala/aterfa from ruta 49, bokford/utkast lifted
from the settlement proposal via a new optional onStatus callback on
VatBookingCard) and one step's content rendered at a time with quiet
Nasta-links. Errors land on step 1, otherwise Granska. SkatteverketPanel
moves inside steg 4 next to the manual filing card when a declaration
exists; the no-data states keep it standalone. A period switch resets the
step choice. All checks, booking, drilldowns, exports and the SKV submit
flow are untouched.

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

* polish(vat): de-box the Stegen step contents to the concept language

Founder review: the stepper was new but the step contents kept the old
card chrome. Now flat on the panel throughout: the period picker is a
quiet toolbar row (no card, no labels), Granska renders as a centered
document column with sans eyebrow group heads and ruta 49 as an
emphasized document foot, the pre-flight checks are hairline rows with
quiet Korrigera links instead of boxed alerts and outline buttons,
VatBookingCard and VatManualFilingCard lose their cards (notes become
flat muted/attn lines), and SkatteverketPanel's card shells become flat
sections with sans uppercase eyebrows. All logic, flows and dialogs
untouched.

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

* polish(vat): black Exportera, no page-level Fraga Anna, clear Nasta buttons

Founder feedback on Stegen: the toolbar keeps only Exportera and it
wears the primary pill (ReportExportMenu gains an optional variant prop,
outline stays the default everywhere else); the AgentSparkleButton
leaves the page (the global assistant bubble remains); the Nasta step
links become white outline pill buttons so the forward path reads as
clearly as the export action. All other buttons on the page already
follow the house variants (primary/outline/ghost/quiet links).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 18:32:53 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 5c42852d7e
commit cb7b31819b
4 changed files with 315 additions and 220 deletions
+4 -1
View File
@@ -30,10 +30,13 @@ export function ReportExportMenu({
items,
children,
size = 'sm',
variant = 'outline',
}: {
items?: ReportExportItem[]
children?: React.ReactNode
size?: 'default' | 'sm'
/** Trigger style; the VAT Stegen page uses the primary pill. */
variant?: 'outline' | 'default'
}) {
const t = useTranslations('reports')
const hasItems = !!items && items.length > 0
@@ -44,7 +47,7 @@ export function ReportExportMenu({
{hasItems && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size={size}>
<Button variant={variant} size={size}>
<Download className="h-4 w-4 mr-2" />
{t('export')}
</Button>
+28 -29
View File
@@ -2,7 +2,6 @@
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
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'
@@ -678,12 +677,12 @@ function SkatteverketPanelInner({
if (loading) {
return (
<Card>
<CardContent className="p-6 space-y-4">
<section>
<div className="space-y-4">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-24" />
</CardContent>
</Card>
</div>
</section>
)
}
@@ -693,14 +692,14 @@ function SkatteverketPanelInner({
// trial sees the upsell instead of action buttons that would 403.
if (!hasSkvCapability) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<section>
<div className="mb-3">
<h3 className="flex items-center gap-2 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
<FileCheck className="h-4 w-4" />
Skicka direkt till Skatteverket (valfritt)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
</h3>
</div>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Med ett abonnemang kan du ansluta med BankID och skicka deklarationen
direkt härifrån, samt validera, spara utkast och signera.
@@ -708,8 +707,8 @@ function SkatteverketPanelInner({
<UpgradeNote>
Direktinlämning till Skatteverket kräver ett abonnemang.
</UpgradeNote>
</CardContent>
</Card>
</div>
</section>
)
}
@@ -719,14 +718,14 @@ function SkatteverketPanelInner({
// directly from Accounted, so frame it that way.
if (!status?.connected) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<section>
<div className="mb-3">
<h3 className="flex items-center gap-2 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
<FileCheck className="h-4 w-4" />
Skicka direkt till Skatteverket (valfritt)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
</h3>
</div>
<div className="space-y-4">
{notice?.kind === 'error' && (
<div
role="alert"
@@ -745,8 +744,8 @@ function SkatteverketPanelInner({
<Link2 className="h-4 w-4" />
Anslut med BankID
</Button>
</CardContent>
</Card>
</div>
</section>
)
}
@@ -754,13 +753,13 @@ function SkatteverketPanelInner({
const hasErrors = kontroller.some(k => k.status === 'ERROR')
return (
<Card>
<CardHeader>
<section>
<div className="mb-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base flex items-center gap-2">
<h3 className="flex items-center gap-2 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
<FileCheck className="h-4 w-4" />
Skicka direkt till Skatteverket
</CardTitle>
</h3>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
@@ -902,8 +901,8 @@ function SkatteverketPanelInner({
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
</div>
<div className="space-y-4">
{/* 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] && (
@@ -1067,8 +1066,8 @@ function SkatteverketPanelInner({
Valideringsfelen ovan måste åtgärdas innan deklarationen kan lämnas in.
</p>
)}
</CardContent>
</div>
<DestructiveConfirmDialog {...dialogProps} />
</Card>
</section>
)
}
+17 -42
View File
@@ -2,8 +2,6 @@
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 {
@@ -39,6 +37,8 @@ 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'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { cn } from '@/lib/utils'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -102,8 +102,6 @@ export function VatChecksCard({
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
@@ -298,24 +296,7 @@ export function VatChecksCard({
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">
<div 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" />
@@ -324,23 +305,19 @@ export function VatChecksCard({
)}
{checks.length > 0 && (
<div className="space-y-2">
<div>
{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'
}`}
className="flex items-start gap-2 border-b border-border/60 py-3 text-[13px] leading-5 last:border-b-0"
>
{c.status === 'ERROR' ? (
<ShieldAlert className="h-4 w-4 mt-1 shrink-0" />
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" aria-hidden="true" />
) : (
<AlertTriangle className="h-4 w-4 mt-1 shrink-0 text-warning" />
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-attn" aria-hidden="true" />
)}
<div>
<span className="font-mono text-xs mr-2">{c.code}</span>
<span className="mr-2 font-mono text-xs text-muted-foreground">{c.code}</span>
{c.message}
</div>
</div>
@@ -461,21 +438,20 @@ export function VatChecksCard({
<span className="text-sm tabular-nums text-muted-foreground">
{formatAmount(gap.expectedBasisAmount)} kr
</span>
<Button
variant="outline"
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleFixOne(gap)
}}
disabled={!canWrite || busy}
className={cn(QUIET_LINK_CLASS, 'inline-flex items-center disabled:cursor-not-allowed disabled:opacity-50')}
>
{fixingId === gap.entryId ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4 mr-2" />
{fixingId === gap.entryId && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden="true" />
)}
Korrigera
</Button>
</button>
<Button
variant="ghost"
size="icon"
@@ -598,16 +574,15 @@ export function VatChecksCard({
</DataList>
{gaps.length > GAP_PREVIEW_COUNT && (
<Button variant="ghost" onClick={() => setShowAll((v) => !v)}>
<button type="button" onClick={() => setShowAll((v) => !v)} className={QUIET_LINK_CLASS}>
{showAll ? 'Visa färre' : `Visa alla ${gaps.length} verifikationer`}
</Button>
</button>
)}
</>
)}
</div>
)}
</CardContent>
<DestructiveConfirmDialog {...dialogProps} />
</Card>
</div>
)
}
+266 -148
View File
@@ -11,8 +11,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { AlertCircle, Check, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import {
@@ -23,7 +22,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { formatDate } from '@/lib/utils'
import { cn, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AccountNumber } from '@/components/ui/account-number'
@@ -1085,12 +1084,15 @@ const SKATTEVERKET_MOMS_URL =
*/
function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: string }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Lämna in själv (utan anslutning)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
<section>
<div className="mb-3 flex items-center gap-3 px-1">
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Lämna in själv, med fil
</h3>
<div className="h-px flex-1 bg-border/60" />
</div>
<div className="space-y-4">
<p className="text-[13px] leading-6 text-muted-foreground">
Du behöver inte vara ansluten till Skatteverket för att lämna in.
</p>
<ol className="list-decimal pl-6 space-y-1 text-sm text-muted-foreground">
@@ -1123,8 +1125,8 @@ function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: s
</a>
</Button>
</div>
</CardContent>
</Card>
</div>
</section>
)
}
@@ -1144,6 +1146,7 @@ function VatBookingCard({
period,
fiscalPeriodId,
checksBlocked,
onStatus,
}: {
periodType: VatPeriodType
year: number
@@ -1155,6 +1158,8 @@ function VatBookingCard({
* 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
}) {
const { canWrite } = useCanWrite()
const [dialogOpen, setDialogOpen] = useState(false)
@@ -1199,6 +1204,12 @@ function VatBookingCard({
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.
const initialLines: FormLine[] = (proposal?.lines ?? []).map((l) => ({
@@ -1209,20 +1220,16 @@ function VatBookingCard({
}))
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Bokför momsrapporten</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
<div className="space-y-4">
<p className="text-[13px] leading-6 text-muted-foreground">
Skapa ett verifikat som nollställer periodens momskonton och bokför
momsen att betala eller få tillbaka på redovisningskontot. Du granskar
förslaget och kan ändra raderna innan verifikatet bokförs.
</p>
{booked && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex items-start gap-2 text-[13px] leading-6 text-muted-foreground">
<AlertCircle className="mt-1 h-4 w-4 shrink-0" aria-hidden="true" />
<p>
Momsen för perioden är redan bokförd:{' '}
<Link
@@ -1236,8 +1243,8 @@ function VatBookingCard({
</div>
)}
{draft && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex items-start gap-2 text-[13px] leading-6 text-muted-foreground">
<AlertCircle className="mt-1 h-4 w-4 shrink-0" aria-hidden="true" />
<p>
Det finns redan ett{' '}
<Link
@@ -1252,13 +1259,10 @@ function VatBookingCard({
)}
{checksBlocked && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<AlertCircle className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
<p>
Det finns fel under 1 · Kontrollera underlaget. Du kan bokföra momsen ändå,
men åtgärda felen innan du lämnar in.
</p>
</div>
<p className="text-[12.5px] leading-5 text-attn">
Det finns fel under steg 1, Kontrollera. Du kan bokföra momsen ändå, men
åtgärda felen innan du lämnar in.
</p>
)}
{failed ? (
@@ -1291,7 +1295,6 @@ function VatBookingCard({
)}
</div>
)}
</CardContent>
{proposal && (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
@@ -1327,7 +1330,123 @@ function VatBookingCard({
</DialogContent>
</Dialog>
)}
</Card>
</div>
)
}
/** The Stegen header (concept Moms C): the filing pipeline as a clickable
* horizontal stepper with honest per-step status subs. Statutory surface,
* Swedish in both locales like the rest of the declaration. */
function VatStepper({
active,
onSelect,
errorCount,
warningCount,
ruta49,
bookingStatus,
}: {
active: number
onSelect: (step: number) => void
errorCount: number
warningCount: number
ruta49: number
bookingStatus: 'booked' | 'draft' | 'none' | null
}) {
const steps = [
{
n: 1,
label: 'Kontrollera',
sub:
errorCount > 0
? `${errorCount} fel`
: warningCount > 0
? `${warningCount} ${warningCount === 1 ? 'varning' : 'varningar'}`
: 'klart',
done: errorCount === 0,
warn: errorCount > 0,
},
{
n: 2,
label: 'Granska',
sub:
ruta49 > 0
? `${formatAmount(ruta49)} kr att betala`
: ruta49 < 0
? `${formatAmount(Math.abs(ruta49))} kr att återfå`
: 'ingen moms',
done: false,
warn: false,
},
{
n: 3,
label: 'Bokför',
sub:
bookingStatus === 'booked'
? 'bokförd'
: bookingStatus === 'draft'
? 'utkast finns'
: 'mot 2650',
done: bookingStatus === 'booked',
warn: false,
},
{ n: 4, label: 'Lämna in', sub: 'till Skatteverket', done: false, warn: false },
]
return (
<div
className="mx-auto flex w-full max-w-3xl items-center gap-3 overflow-x-auto px-1"
role="tablist"
aria-label="Momsdeklarationens steg"
>
{steps.map((step, i) => (
<React.Fragment key={step.n}>
{i > 0 && <span className="h-px min-w-4 flex-1 bg-border" aria-hidden="true" />}
<button
type="button"
role="tab"
aria-selected={active === step.n}
onClick={() => onSelect(step.n)}
className="group flex shrink-0 items-center gap-2 text-left"
>
<span
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums transition-colors duration-150',
step.done
? 'border-success/40 text-success'
: active === step.n
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground group-hover:border-foreground/30',
)}
>
{step.done ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : step.n}
</span>
<span className="leading-tight">
<span
className={cn(
'block text-[12.5px] transition-colors duration-150',
active === step.n
? 'font-medium'
: 'text-muted-foreground group-hover:text-foreground',
)}
>
{step.label}
</span>
{step.sub && (
<span
className={cn(
'block text-[11px] tabular-nums',
step.warn ? 'text-attn' : 'text-muted-foreground',
)}
>
{step.sub}
</span>
)}
</span>
</button>
</React.Fragment>
))}
</div>
)
}
@@ -1358,6 +1477,11 @@ export function VatDeclarationView() {
error?: string
} | null>(null)
const [retryKey, setRetryKey] = useState(0)
// Stegen: which of the four pipeline steps is open. null = automatic
// (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<number | null>(null)
const [bookingStatus, setBookingStatus] = useState<'booked' | 'draft' | 'none' | null>(null)
// Company settings drive both the momsregistrerad gate and the default
// periodicity (moms_period in Inställningar). Applied once per company the
@@ -1452,6 +1576,11 @@ export function VatDeclarationView() {
? null
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}:${retryKey}`
useEffect(() => {
setChosenStep(null)
setBookingStatus(null)
}, [periodType, year, period, fiscalPeriodId])
useEffect(() => {
if (!fetchKey || periodType === null) return
const params = new URLSearchParams({
@@ -1495,6 +1624,9 @@ export function VatDeclarationView() {
// but concern manual filers just as much.
const checks = data ? runVatDeclarationChecks(data.rutor) : []
const checksBlocked = checks.some((c) => c.status === 'ERROR')
const errorCount = checks.filter((c) => c.status === 'ERROR').length
const warningCount = checks.filter((c) => c.status === 'WARNING').length
const activeStep = chosenStep ?? (checksBlocked ? 1 : 2)
// Settings not settled yet — the picker defaults and the gate both depend
// on them, so hold the whole view in a skeleton.
@@ -1542,28 +1674,20 @@ export function VatDeclarationView() {
{/* XML and PDF live in "Lämna in" below: they are filing artifacts, not
report exports, and one home avoids two competing download surfaces. */}
<ReportExportMenu
variant="default"
items={[
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
]}
>
<AgentSparkleButton
intentId="vat.review"
intentArgs={{ period_type: periodType, year, period }}
contextRef={`vat:${year}-${periodType}-${period}`}
/>
</ReportExportMenu>
/>
{/* Period selection — the declaration below follows it automatically */}
<Card>
<CardContent className="p-4">
<div className="flex flex-wrap items-end gap-4">
<div>
<Label>Periodicitet</Label>
{/* Period selection — the declaration below follows it automatically.
Flat toolbar row (concept language), far right like a context picker. */}
<div className="flex flex-wrap items-center justify-end gap-2">
<Select
value={periodType}
onValueChange={(value) => handlePeriodTypeChange(value as VatPeriodType)}
>
<SelectTrigger className="mt-1 w-40">
<SelectTrigger aria-label="Periodicitet" className="h-9 w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1572,7 +1696,6 @@ export function VatDeclarationView() {
<SelectItem value="yearly">Årsvis</SelectItem>
</SelectContent>
</Select>
</div>
{isYearly ? (
// Annual VAT covers a räkenskapsår: picked here, not a
// calendar year.
@@ -1587,10 +1710,8 @@ export function VatDeclarationView() {
/>
) : (
<>
<div>
<Label>År</Label>
<Select value={String(year)} onValueChange={(value) => setYear(parseInt(value))}>
<SelectTrigger className="mt-1 w-28 tabular-nums">
<SelectTrigger aria-label="År" className="h-9 w-24 tabular-nums">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1601,14 +1722,11 @@ export function VatDeclarationView() {
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Period</Label>
<Select
value={String(period)}
onValueChange={(value) => setPeriod(parseInt(value))}
>
<SelectTrigger className="mt-1 w-44">
<SelectTrigger aria-label="Period" className="h-9 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1619,12 +1737,9 @@ export function VatDeclarationView() {
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
</CardContent>
</Card>
</div>
{error && (
<Card>
@@ -1651,14 +1766,21 @@ export function VatDeclarationView() {
<div
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
>
{/* The page follows the filing pipeline: kontrollera, granska,
bokför, lämna in. The checks come first because their errors
invalidate everything below them. */}
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
1 · Kontrollera underlaget
</h2>
<VatChecksCard
{/* 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. */}
<VatStepper
active={activeStep}
onSelect={setChosenStep}
errorCount={errorCount}
warningCount={warningCount}
ruta49={data.rutor.ruta49}
bookingStatus={bookingStatus}
/>
{activeStep === 1 && (
<section className="mx-auto max-w-3xl space-y-3">
<VatChecksCard
checks={checks}
periodType={periodType}
year={year}
@@ -1666,49 +1788,30 @@ export function VatDeclarationView() {
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
onCorrected={() => setRetryKey((k) => k + 1)}
/>
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
2 · Granska deklarationen
</h2>
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<CardTitle>Momsdeklaration - {data.period.start} till {data.period.end}</CardTitle>
<div className="flex items-center gap-3">
<Badge
variant={
data.rutor.ruta49 > 0
? 'warning'
: data.rutor.ruta49 < 0
? 'success'
: 'secondary'
}
>
{data.rutor.ruta49 > 0
? 'Att betala'
: data.rutor.ruta49 < 0
? 'Att återfå'
: 'Ingen moms'}
</Badge>
{data.rutor.ruta49 !== 0 && (
<span className="font-display text-xl tabular-nums">
{formatAmount(Math.abs(data.rutor.ruta49))} kr
</span>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground mb-4">
Baserat på {data.invoiceCount} fakturor och {data.transactionCount} transaktioner
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setChosenStep(2)}>
Nästa: Granska deklarationen →
</Button>
</div>
</section>
)}
<div className="grid md:grid-cols-2 gap-6">
{activeStep === 2 && (
<section className="space-y-3">
<div className="mx-auto max-w-2xl">
<div className="flex flex-wrap items-baseline justify-between gap-3 px-1">
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Momsdeklaration · {data.period.start} till {data.period.end}
</h3>
<span className="text-[11.5px] tabular-nums text-muted-foreground">
{data.invoiceCount} fakturor · {data.transactionCount} transaktioner
</span>
</div>
<div className="mt-4 space-y-8">
<div>
{/* Utgående moms */}
<div>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
<h3 className="mb-3 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Utgående moms (försäljning)
</h3>
<Table>
@@ -1790,7 +1893,7 @@ export function VatDeclarationView() {
{(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) && (
<>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3 mt-6">
<h3 className="mb-3 mt-6 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Omvänd skattskyldighet (inköp)
</h3>
<Table>
@@ -1811,7 +1914,7 @@ export function VatDeclarationView() {
{/* Moms vid import */}
{(data.rutor.ruta50 > 0 || data.rutor.ruta60 > 0 || data.rutor.ruta61 > 0 || data.rutor.ruta62 > 0) && (
<>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3 mt-6">
<h3 className="mb-3 mt-6 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Moms vid import
</h3>
<Table>
@@ -1825,10 +1928,11 @@ export function VatDeclarationView() {
</>
)}
</div>
</div>
<div>
{/* Ingående moms */}
<div>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
<h3 className="mb-3 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Ingående moms (avdragsgill)
</h3>
<Table>
@@ -1868,63 +1972,77 @@ export function VatDeclarationView() {
</Table>
</div>
</div>
</div>
{/* Net result: the tables' sum row. The headline amount lives in
the card header next to the status badge. */}
<div className="mt-6 pt-4 border-t">
<div className="flex justify-between items-center">
<div>
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">49</span>
<span className="text-sm font-medium">
{data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'}
</span>
</div>
<span
className={`font-semibold tabular-nums ${
data.rutor.ruta49 > 0
? 'text-warning'
: data.rutor.ruta49 < 0
? 'text-success'
: ''
}`}
>
{formatAmount(Math.abs(data.rutor.ruta49))} kr
</span>
</div>
{/* Ruta 49 as the emphasized document foot (concept skv-foot). */}
<div className="mt-8 flex items-baseline gap-3 border-t-2 border-foreground/80 pt-3">
<span className="font-mono text-xs text-muted-foreground">49</span>
<span className="flex-1 text-sm font-medium">
{data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'}
</span>
<span className="text-sm font-semibold tabular-nums">
{formatAmount(Math.abs(data.rutor.ruta49))} kr
</span>
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setChosenStep(3)}>
Nästa: Bokför momsen →
</Button>
</div>
</CardContent>
</Card>
</section>
</section>
)}
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
3 · Bokför momsen
</h2>
<VatBookingCard
{activeStep === 3 && (
<section className="mx-auto max-w-3xl space-y-3">
<VatBookingCard
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
checksBlocked={checksBlocked}
onStatus={setBookingStatus}
/>
</section>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setChosenStep(4)}>
Nästa: Lämna in →
</Button>
</div>
</section>
)}
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
4 · Lämna in
</h2>
<VatManualFilingCard
{activeStep === 4 && (
<section className="mx-auto max-w-3xl space-y-8">
<SkatteverketPanel
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
fiscalYearEnd={
isYearly && fiscalPeriodEnd
? {
year: Number(fiscalPeriodEnd.slice(0, 4)),
month: Number(fiscalPeriodEnd.slice(5, 7)),
}
: undefined
}
hasData={data !== null}
localBlocked={checksBlocked}
/>
<VatManualFilingCard
xmlHref={`/api/reports/vat-declaration/eskd?${vatQueryString()}`}
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
/>
</section>
</section>
)}
</div>
)}
{/* Skatteverket integration panel — hidden while the räkenskapsår for
helårsmoms is unresolved, so its actions can never target an
unconfirmed period. */}
{!awaitingFiscalPeriod && (
{/* Skatteverket integration panel for the no-data states (fetch error,
empty period): with data it lives inside steg 4. Hidden while the
räkenskapsår for helårsmoms is unresolved, so its actions can never
target an unconfirmed period. */}
{!awaitingFiscalPeriod && !data && (
<SkatteverketPanel
periodType={periodType}
year={year}