feat(skatt): setup gates + auto-loading momsdeklaration across Skatt & bokslut tabs (#885)

* feat(skatt): setup gates + auto-loading momsdeklaration across Skatt & bokslut tabs

Every tab in the Skatt & bokslut nav group now tells an unconfigured user
what is missing and where to fix it, instead of dead-ending or rendering
zeros:

- Momsdeklaration: gates on vat_registered with a settings CTA; auto-fetches
  the configured period on load and on every period change (no more "Hämta"
  button); one period control (räkenskapsår picked inline for helårsmoms,
  shell selector + back link dropped via new ReportDescriptor.standalone);
  raw <select>s replaced with the Select primitive; banner when moms_period
  is missing; BankID connect returns to the page instead of the report
  library.
- Deadlines: callout (sv+en) when no system-generated tax deadlines exist —
  they are derived from tax settings, so point at /settings/tax rather than
  presenting an empty manual todo list.
- Årsbokslut: "no räkenskapsår yet" (CTA to bookkeeping settings) is now
  distinguished from "nothing to close yet".
- Skattekonto: non-auth fetch failures render an error card with retry
  instead of the misleading "inget saldo hämtat ännu" empty state.

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

* fix(skatt): address bot review — res.ok guard, blocking moms_period gate, panel hidden while räkenskapsår unresolved

- Auto-fetch treats non-2xx or unparsable responses as errors (with retry)
  instead of rendering undefined data.
- momsPeriodMissing now blocks the declaration (EmptyState + settings CTA)
  rather than fetching a guessed quarterly period behind a banner — a
  declaration submittable for the wrong period type is a hazard, not a
  convenience.
- SkatteverketPanel is not rendered while yearly mode awaits a fiscal
  period, so its actions can never target an unconfirmed period.

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-03 16:12:51 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 100a4d1291
commit 9f7c842a33
10 changed files with 359 additions and 157 deletions
+20 -6
View File
@@ -15,7 +15,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Lock } from 'lucide-react'
import { ArrowLeft, CalendarPlus, Lock } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
@@ -55,6 +55,9 @@ export default function YearEndPage() {
// ---- Period selection ----
const [periods, setPeriods] = useState<PeriodOption[] | null>(null)
// Whether the company has ANY fiscal periods (eligible or not) — separates
// "inget att stänga ännu" from "inget räkenskapsår har skapats".
const [hasAnyPeriods, setHasAnyPeriods] = useState(true)
const [periodsError, setPeriodsError] = useState<string | null>(null)
const [selectedPeriodId, setSelectedPeriodId] = useState<string | null>(
searchParams.get('period') ?? null,
@@ -91,6 +94,7 @@ export default function YearEndPage() {
eligible.sort((a, b) => a.period_start.localeCompare(b.period_start))
if (cancelled) return
setPeriods(eligible)
setHasAnyPeriods((data ?? []).length > 0)
if (!selectedPeriodId && eligible.length > 0) {
setSelectedPeriodId(eligible[0].id)
}
@@ -235,11 +239,21 @@ export default function YearEndPage() {
)}
{periods !== null && periods.length === 0 && (
<EmptyState
icon={Lock}
title="Inga perioder att stänga"
description="Det finns ingen öppen räkenskapsperiod vars slutdatum redan har passerat. Bokslut görs efter att periodens slutdatum är passerat."
/>
hasAnyPeriods ? (
<EmptyState
icon={Lock}
title="Inga perioder att stänga"
description="Det finns ingen öppen räkenskapsperiod vars slutdatum redan har passerat. Bokslut görs efter att periodens slutdatum är passerat."
/>
) : (
<EmptyState
icon={CalendarPlus}
title="Inget räkenskapsår ännu"
description="Bokslut görs per räkenskapsår. Skapa företagets räkenskapsår i bokföringsinställningarna för att komma igång."
actionLabel="Öppna bokföringsinställningar"
actionHref="/settings/bookkeeping"
/>
)
)}
{showWizard && periods && periods.length > 1 && step !== 'result' && (
+24 -1
View File
@@ -8,7 +8,7 @@ import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { DeadlineList } from '@/components/deadlines/DeadlineList'
import { PageHeader } from '@/components/ui/page-header'
import { AlertTriangle, ArrowRight } from 'lucide-react'
import { AlertTriangle, ArrowRight, CalendarClock } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
import { formatCurrency } from '@/lib/utils'
import type { Deadline } from '@/types'
@@ -224,10 +224,33 @@ export default function DeadlinesPage() {
)
}
// Statutory deadlines (moms, arbetsgivardeklaration, F-skatt) are generated
// from the company's tax settings — none present usually means those
// settings were never filled in, so point there instead of letting the page
// read as an empty manual todo list.
const hasSystemDeadlines = deadlines.some((d) => d.source === 'system')
return (
<div className="space-y-8">
<PageHeader title={t('title')} />
{!hasSystemDeadlines && (
<Link href="/settings/tax" className="group block">
<div className="flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors hover:bg-secondary/60">
<div className="flex items-center gap-3">
<CalendarClock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<p className="text-sm">
<span className="font-medium">{t('no_system_deadlines_title')}</span>
<span className="text-muted-foreground ml-1.5">
{t('no_system_deadlines_description')}
</span>
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:translate-x-0.5 transition-transform flex-shrink-0" />
</div>
</Link>
)}
{/* Overdue invoices alert */}
{overdueInvoices.count > 0 && (
<Link href="/invoices?status=unpaid" className="group block">
+35
View File
@@ -19,6 +19,7 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import {
AlertCircle,
Copy,
ExternalLink,
FileCheck,
@@ -65,6 +66,7 @@ export default function SkattekontoPage() {
const [syncing, setSyncing] = useState(false)
const [bookingId, setBookingId] = useState<string | null>(null)
const [notConnected, setNotConnected] = useState(false)
const [loadError, setLoadError] = useState(false)
const [matchOpenFor, setMatchOpenFor] = useState<StoredSkattekontoTransaction | null>(
null,
)
@@ -74,6 +76,7 @@ export default function SkattekontoPage() {
const reload = useCallback(async () => {
setLoading(true)
setLoadError(false)
try {
const [saldoRes, txRes] = await Promise.all([
fetch('/api/extensions/ext/skatteverket/skattekonto/saldo'),
@@ -85,6 +88,14 @@ export default function SkattekontoPage() {
return
}
// A non-auth failure must NOT fall through to the "inget saldo hämtat
// ännu"-tomvy — that reads as "not configured" when the truth is "the
// fetch broke". Surface it as an error with a retry instead.
if (!saldoRes.ok) {
setLoadError(true)
return
}
const saldoJson = (await saldoRes.json()) as SaldoEnvelope
setSaldo(saldoJson)
@@ -92,6 +103,8 @@ export default function SkattekontoPage() {
const txJson = (await txRes.json()) as TransaktionerEnvelope
setTx(txJson.data)
}
} catch {
setLoadError(true)
} finally {
setLoading(false)
}
@@ -246,6 +259,28 @@ export default function SkattekontoPage() {
)
}
if (loadError) {
return (
<div className="space-y-6">
<PageHeading />
<Card>
<CardContent className="flex flex-col items-center py-12 text-center">
<AlertCircle className="mb-4 h-10 w-10 text-muted-foreground/40" />
<p className="mb-1 font-medium">Kunde inte hämta skattekontot</p>
<p className="mb-4 max-w-md text-sm text-muted-foreground">
Något gick fel när saldo och transaktioner skulle hämtas. Försök
igen om en stund.
</p>
<Button variant="outline" onClick={() => void reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Försök igen
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<PageHeading