From cb7b31819b614a3957cc5245f71f476c392cdc13 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:32:53 +0200 Subject: [PATCH] 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- components/reports/ReportExportMenu.tsx | 5 +- components/reports/SkatteverketPanel.tsx | 57 ++-- components/reports/VatChecksCard.tsx | 59 +--- components/reports/views/index.tsx | 414 +++++++++++++++-------- 4 files changed, 315 insertions(+), 220 deletions(-) diff --git a/components/reports/ReportExportMenu.tsx b/components/reports/ReportExportMenu.tsx index 2e9b153f..3903b6bc 100644 --- a/components/reports/ReportExportMenu.tsx +++ b/components/reports/ReportExportMenu.tsx @@ -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 && ( - diff --git a/components/reports/SkatteverketPanel.tsx b/components/reports/SkatteverketPanel.tsx index 20c2bd32..ea321465 100644 --- a/components/reports/SkatteverketPanel.tsx +++ b/components/reports/SkatteverketPanel.tsx @@ -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 ( - - +
+
- - +
+
) } @@ -693,14 +692,14 @@ function SkatteverketPanelInner({ // trial sees the upsell instead of action buttons that would 403. if (!hasSkvCapability) { return ( - - - +
+
+

Skicka direkt till Skatteverket (valfritt) - - - +

+
+

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({ Direktinlämning till Skatteverket kräver ett abonnemang. - - +

+
) } @@ -719,14 +718,14 @@ function SkatteverketPanelInner({ // directly from Accounted, so frame it that way. if (!status?.connected) { return ( - - - +
+
+

Skicka direkt till Skatteverket (valfritt) - - - +

+
+
{notice?.kind === 'error' && (
Anslut med BankID - - +
+
) } @@ -754,13 +753,13 @@ function SkatteverketPanelInner({ const hasErrors = kontroller.some(k => k.status === 'ERROR') return ( - - +
+
- +

Skicka direkt till Skatteverket - +

@@ -902,8 +901,8 @@ function SkatteverketPanelInner({
- - +
+
{/* 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.

)} - +
- +
) } diff --git a/components/reports/VatChecksCard.tsx b/components/reports/VatChecksCard.tsx index a82693b8..8ce361f7 100644 --- a/components/reports/VatChecksCard.tsx +++ b/components/reports/VatChecksCard.tsx @@ -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 ( - - -
- Kontroll av underlaget - {errorCount > 0 ? ( - - {errorCount} {errorCount === 1 ? 'fel' : 'fel'} - - ) : warningCount > 0 ? ( - - {warningCount} {warningCount === 1 ? 'varning' : 'varningar'} - - ) : ( - Inga anmärkningar - )} -
-
- +
{checks.length === 0 && (
@@ -324,23 +305,19 @@ export function VatChecksCard({ )} {checks.length > 0 && ( -
+
{checks.map((c, i) => (
{c.status === 'ERROR' ? ( - +
@@ -461,21 +438,20 @@ export function VatChecksCard({ {formatAmount(gap.expectedBasisAmount)} kr - + + )} )}
)} - - +
) } diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 405b1226..c7cf0080 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -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 ( - - - Lämna in själv (utan anslutning) - - -

+

+
+

+ Lämna in själv, med fil +

+
+
+
+

Du behöver inte vara ansluten till Skatteverket för att lämna in.

    @@ -1123,8 +1125,8 @@ function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: s
- - +
+
) } @@ -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 ( - - - Bokför momsrapporten - - -

+

+

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.

{booked && ( -
- +
+
+ ) +} + + +/** 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 ( +
+ {steps.map((step, i) => ( + + {i > 0 && + ))} +
) } @@ -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(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. */} - - + /> - {/* Period selection — the declaration below follows it automatically */} - - -
-
- + {/* Period selection — the declaration below follows it automatically. + Flat toolbar row (concept language), far right like a context picker. */} +
-
{isYearly ? ( // Annual VAT covers a räkenskapsår: picked here, not a // calendar year. @@ -1587,10 +1710,8 @@ export function VatDeclarationView() { /> ) : ( <> -
- -
-
- -
)} -
- - +
{error && ( @@ -1651,14 +1766,21 @@ export function VatDeclarationView() {
- {/* 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 -

- + + {activeStep === 1 && ( +
+ setRetryKey((k) => k + 1)} /> -
- -
-

- 2 · Granska deklarationen -

- - -
- Momsdeklaration - {data.period.start} till {data.period.end} -
- 0 - ? 'warning' - : data.rutor.ruta49 < 0 - ? 'success' - : 'secondary' - } - > - {data.rutor.ruta49 > 0 - ? 'Att betala' - : data.rutor.ruta49 < 0 - ? 'Att återfå' - : 'Ingen moms'} - - {data.rutor.ruta49 !== 0 && ( - - {formatAmount(Math.abs(data.rutor.ruta49))} kr - - )} -
-
-
- -
- Baserat på {data.invoiceCount} fakturor och {data.transactionCount} transaktioner +
+
+
+ )} -
+ {activeStep === 2 && ( +
+
+
+

+ Momsdeklaration · {data.period.start} till {data.period.end} +

+ + {data.invoiceCount} fakturor · {data.transactionCount} transaktioner + +
+
+
{/* Utgående moms */}
-

+

Utgående moms (försäljning)

@@ -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) && ( <> -

+

Omvänd skattskyldighet (inköp)

@@ -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) && ( <> -

+

Moms vid import

@@ -1825,10 +1928,11 @@ export function VatDeclarationView() { )} - + +
{/* Ingående moms */}
-

+

Ingående moms (avdragsgill)

@@ -1868,63 +1972,77 @@ export function VatDeclarationView() {
+
- {/* 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 - ? 'text-success' - : '' - }`} - > - {formatAmount(Math.abs(data.rutor.ruta49))} kr - -
+ {/* Ruta 49 as the emphasized document foot (concept skv-foot). */} +
+ 49 + + {data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'} + + + {formatAmount(Math.abs(data.rutor.ruta49))} kr + +
+
+
+
- - -
+
+ )} -
-

- 3 · Bokför momsen -

- + -
+
+ +
+ + )} -
-

- 4 · Lämna in -

- + + -
+ + )}
)} - {/* 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 && (