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
+4
View File
@@ -7,3 +7,7 @@ One line per decision: `[YYYY-MM-DD] <decision> — <why>`. Appended by agents a
[2026-07-03] Archived 4 completed/superseded plans to dev_docs/archive/ (dimensions_implementation_plan, specialized-agent-plan, api_ai_architecture/PLAN, mcp-apps-architecture-reference) — moved, not deleted, because dev_docs is gitignored (no git history to recover from). Live remnants relocated first: PR10 backlog → dimensions_architecture.md; eval-harness spec → claude_surface_plan.md §2.1. agent_first_vision.md §8 marked superseded by claude_surface_plan.md (Skatteverket filing is BUILT, contra its P0 item 6).
[2026-07-03] Moved this log from dev_docs/DECISIONS.md to repo root — dev_docs/ is gitignored, so the log was invisible to other developers; root matches the existing convention (CONTRIBUTING.md, SECURITY.md).
[2026-07-03] Converted the last three full-page create flows (salary run, employee, recurring schedule) to ?new=1 URL-driven modals matching the verifikat/invoice pattern (#861); old /new routes survive as redirects for bookmarks/agent intents. Moved forms keep their existing hardcoded-Swedish strings — translating them is out of scope for the modal conversion.
[2026-07-03] Momsdeklaration hard-gates on vat_registered === false (EmptyState + settings CTA), not a soft banner — onboarding Step 4 asks the question explicitly, so false is a deliberate answer rather than "unconfigured" (DB default only matters for pre-onboarding companies, which the gate copy points to settings anyway).
[2026-07-03] VAT view auto-fetches on period change and drops the "Hämta" button; fetch state is derived from a key-tagged result object instead of setLoading/setError in the effect — keeps react-hooks/set-state-in-effect ratchet at baseline (repo gate is per-rule count).
[2026-07-03] Added ReportDescriptor.standalone (only vat-declaration) to hide the report-shell back link + fiscal-year selector, instead of changing behavior for all params:'calendar' reports — periodisk-sammanstallning keeps its current shell; scoped diff.
[2026-07-03] New user-facing strings on skattekonto follow that file's existing hardcoded-Swedish convention; the deadlines callout uses next-intl (page already translated). Year-end stays Swedish per .claude/rules/i18n.md.
+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
+28 -21
View File
@@ -54,6 +54,9 @@ function FocusedReportInner({ slug }: { slug: string }) {
const report = getReport(slug)
// Calendar (VAT family) and param-less reports don't need a fiscal period.
const isPeriodless = report?.params === 'calendar' || report?.params === 'none'
// Nav-promoted pages (Momsdeklaration) drop the library chrome: no back
// link, no shell fiscal-year selector — the view owns its period controls.
const isStandalone = !!report?.standalone
const reportName = report ? t(report.labelKey) : slug
const accountFilter = searchParams.get('account')
@@ -68,30 +71,34 @@ function FocusedReportInner({ slug }: { slug: string }) {
return (
<div className="space-y-8">
<Link
href="/reports"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
{t('back_to_library')}
</Link>
{!isStandalone && (
<Link
href="/reports"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
{t('back_to_library')}
</Link>
)}
<PageHeader
title={reportName}
action={
<FiscalYearSelector
value={selectedPeriod || null}
onChange={(id, period) => {
setSelectedPeriod(id || '')
setSelectedPeriodBounds(
period ? { start: period.period_start, end: period.period_end } : null,
)
setDateRange({})
}}
includeAllOption={false}
hideFuturePeriods
onReady={() => setIsReady(true)}
/>
isStandalone ? undefined : (
<FiscalYearSelector
value={selectedPeriod || null}
onChange={(id, period) => {
setSelectedPeriod(id || '')
setSelectedPeriodBounds(
period ? { start: period.period_start, end: period.period_end } : null,
)
setDateRange({})
}}
includeAllOption={false}
hideFuturePeriods
onReady={() => setIsReady(true)}
/>
)
}
/>
@@ -174,7 +181,7 @@ function FocusedView({
case 'balance-sheet':
return <BalanceSheetView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
case 'vat-declaration':
return <VatDeclarationView fiscalPeriodId={periodId} fiscalPeriodBounds={periodBounds} />
return <VatDeclarationView />
case 'periodisk-sammanstallning':
return <PeriodiskSammanstallningView />
case 'ne-declaration':
+6 -1
View File
@@ -229,7 +229,12 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
}, [fetchStatus])
const handleConnect = () => {
window.location.href = '/api/extensions/ext/skatteverket/authorize'
// return_to brings the user back to the momsdeklaration after the BankID
// round-trip; the authorize route's default otherwise lands on the report
// library. The callback appends skv_connected/skv_error itself.
window.location.href =
'/api/extensions/ext/skatteverket/authorize?return_to=' +
encodeURIComponent('/reports/vat-declaration')
}
const handleDisconnect = async () => {
+227 -126
View File
@@ -4,14 +4,24 @@
// Rendered by the focused /reports/[slug] route (see components/reports/FocusedReport.tsx).
// The regulated table/figure rendering is unchanged from the original monolith.
import React, { useState, useEffect, useRef, useCallback } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'
import { AlertCircle, ChevronDown, ChevronRight, Percent } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
@@ -1001,42 +1011,56 @@ function ReportSectionTable({
// (räkenskapsår); undefined for monthly/quarterly (calendar periods).
const VatDrillContext = React.createContext<{ fiscalPeriodId?: string }>({})
export function VatDeclarationView({
fiscalPeriodId,
fiscalPeriodBounds,
}: {
fiscalPeriodId?: string
fiscalPeriodBounds?: { start: string; end: string } | null
} = {}) {
export function VatDeclarationView() {
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
const currentQuarter = Math.ceil(currentMonth / 3)
const [periodType, setPeriodType] = useState<VatPeriodType>('quarterly')
// periodType stays null until the company's VAT settings have settled, so
// the first (automatic) fetch runs against the configured momsperiod instead
// of a guessed default.
const [periodType, setPeriodType] = useState<VatPeriodType | null>(null)
const [year, setYear] = useState(currentYear)
const [period, setPeriod] = useState(currentQuarter)
const [data, setData] = useState<VatDeclaration | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar
// year — picked inline in yearly mode. Monthly/quarterly are calendar
// periods and need no fiscal year.
const [fiscalPeriodId, setFiscalPeriodId] = useState('')
// Latest fetch outcome, tagged with the fetch key it was requested under.
// loading / error / data are all derived by comparing that tag with the
// current key, so the fetch effect never sets state synchronously.
const [result, setResult] = useState<{
key: string
declaration?: VatDeclaration
error?: string
} | null>(null)
const [retryKey, setRetryKey] = useState(0)
// Default the periodicity to the company's configured VAT reporting period
// (moms_period in Inställningar) so the picker mirrors the setting instead of
// always starting on quarterly. Applied once per company the first time its
// settings load; a later manual change to the picker is preserved, and a
// company switch re-applies the new company's setting. `useCompanySettings`
// only refetches when the active company changes, so this never clobbers a
// Company settings drive both the momsregistrerad gate and the default
// periodicity (moms_period in Inställningar). Applied once per company the
// first time its settings settle — as a render-phase adjustment, not an
// effect. A later manual change to the picker is preserved, and a company
// switch re-applies the new company's setting. `useCompanySettings` only
// refetches when the active company changes, so this never clobbers a
// manual selection mid-session.
const { settings } = useCompanySettings()
const appliedForCompany = useRef<string | null>(null)
useEffect(() => {
const momsPeriod = settings?.moms_period
const companyId = settings?.company_id
if (!momsPeriod || !companyId) return
if (appliedForCompany.current === companyId) return
appliedForCompany.current = companyId
setPeriodType(momsPeriod)
// `period` is reset to a sensible value by the periodType effect below.
}, [settings])
const { settings, isLoading: settingsLoading } = useCompanySettings()
const [appliedCompany, setAppliedCompany] = useState<string | null>(null)
const companyKey = settingsLoading ? null : (settings?.company_id ?? 'none')
if (companyKey !== null && appliedCompany !== companyKey) {
setAppliedCompany(companyKey)
const configured = settings?.moms_period ?? 'quarterly'
setPeriodType(configured)
setPeriod(
configured === 'monthly' ? currentMonth : configured === 'quarterly' ? currentQuarter : 1,
)
}
// Settings row present and the company answered "not VAT-registered" —
// the declaration is meaningless, so the whole view is gated below.
const notVatRegistered = !settingsLoading && settings !== null && !settings.vat_registered
// Registered but never picked a redovisningsperiod (rare — onboarding
// requires it, but companies created outside that flow can miss it).
const momsPeriodMissing = settings?.vat_registered === true && !settings.moms_period
// Generate year options (last 5 years)
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i)
@@ -1073,24 +1097,22 @@ export function VatDeclarationView({
}
}
// Reset period when type changes
useEffect(() => {
if (periodType === 'monthly') {
setPeriod(currentMonth)
} else if (periodType === 'quarterly') {
setPeriod(currentQuarter)
} else {
setPeriod(1)
}
}, [periodType, currentMonth, currentQuarter])
// Switching periodicity resets the period to "now" in the new unit. Done in
// the change handler (not an effect) so the auto-fetch below never sees an
// inconsistent periodType/period pair.
const handlePeriodTypeChange = (value: VatPeriodType) => {
setPeriodType(value)
setPeriod(value === 'monthly' ? currentMonth : value === 'quarterly' ? currentQuarter : 1)
}
// Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar year.
// For yearly we pass the selected fiscal period so the API uses its actual
// bounds (handles extended/shortened years); monthly/quarterly stay calendar.
const isYearly = periodType === 'yearly'
const awaitingFiscalPeriod = isYearly && !fiscalPeriodId
const vatQueryString = () => {
const params = new URLSearchParams({
periodType,
periodType: periodType ?? 'quarterly',
year: String(year),
period: String(period),
})
@@ -1098,24 +1120,91 @@ export function VatDeclarationView({
return params.toString()
}
const fetchDeclaration = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(
`/api/reports/vat-declaration?${vatQueryString()}`
)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta momsdeklaration')
} finally {
setLoading(false)
// The declaration loads as soon as the period is known — no manual "Hämta"
// step. fetchKey is null while a prerequisite is missing (settings pending,
// gated, or no redovisningsperiod configured); any change to it triggers a
// refetch and stale responses are discarded.
const fetchKey =
periodType === null || notVatRegistered || momsPeriodMissing || awaitingFiscalPeriod
? null
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}:${retryKey}`
useEffect(() => {
if (!fetchKey || periodType === null) return
const params = new URLSearchParams({
periodType,
year: String(year),
period: String(period),
})
if (periodType === 'yearly') params.set('fiscal_period_id', fiscalPeriodId)
let cancelled = false
fetch(`/api/reports/vat-declaration?${params.toString()}`)
.then(async (res) => {
const json = await res.json().catch(() => null)
if (cancelled) return
if (!res.ok || json?.error) {
setResult({
key: fetchKey,
error:
typeof json?.error === 'string' ? json.error : 'Kunde inte hämta momsdeklaration',
})
} else {
setResult({ key: fetchKey, declaration: json.data })
}
})
.catch(() => {
if (!cancelled) setResult({ key: fetchKey, error: 'Kunde inte hämta momsdeklaration' })
})
return () => {
cancelled = true
}
}, [fetchKey, periodType, year, period, fiscalPeriodId])
// Derived fetch state: the previous declaration stays visible (dimmed)
// while the next period loads.
const upToDate = result !== null && result.key === fetchKey
const data = result?.declaration ?? null
const error = upToDate ? (result.error ?? null) : null
const loading = fetchKey !== null && !upToDate
// Settings not settled yet — the picker defaults and the gate both depend
// on them, so hold the whole view in a skeleton.
if (settingsLoading || periodType === null) {
return (
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-64" />
</CardContent>
</Card>
)
}
if (notVatRegistered) {
return (
<EmptyState
icon={Percent}
title="Företaget är inte momsregistrerat"
description="Momsdeklarationen bygger på företagets skatteinställningar. Om företaget är momsregistrerat anger du momsregistrering och redovisningsperiod i inställningarna, så visas deklarationen här."
actionLabel="Öppna skatteinställningar"
actionHref="/settings/tax"
/>
)
}
// Registered but no redovisningsperiod picked: block instead of guessing.
// A declaration rendered (and submittable via panelen) for the wrong
// period type is a compliance hazard, not a convenience.
if (momsPeriodMissing) {
return (
<EmptyState
icon={Percent}
title="Redovisningsperiod för moms saknas"
description="Företaget är momsregistrerat men ingen redovisningsperiod (månad, kvartal eller helår) är vald. Ange den i skatteinställningarna så visas deklarationen för rätt period."
actionLabel="Öppna skatteinställningar"
actionHref="/settings/tax"
/>
)
}
return (
@@ -1130,90 +1219,106 @@ export function VatDeclarationView({
contextRef={`vat:${year}-${periodType}-${period}`}
/>
</ReportExportMenu>
{/* Period selection */}
{/* Period selection — the declaration below follows it automatically */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Välj period</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="p-4">
<div className="flex flex-wrap items-end gap-4">
<div>
<Label>Periodicitet</Label>
<select
<Select
value={periodType}
onChange={(e) => setPeriodType(e.target.value as VatPeriodType)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
onValueChange={(value) => handlePeriodTypeChange(value as VatPeriodType)}
>
<option value="monthly">Månadsvis</option>
<option value="quarterly">Kvartalsvis</option>
<option value="yearly">Årsvis</option>
</select>
<SelectTrigger className="mt-1 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månadsvis</SelectItem>
<SelectItem value="quarterly">Kvartalsvis</SelectItem>
<SelectItem value="yearly">Årsvis</SelectItem>
</SelectContent>
</Select>
</div>
{isYearly ? (
// Annual VAT covers the selected räkenskapsår — driven by the
// fiscal-year picker on the report page, not a calendar year.
<div>
<Label>Räkenskapsår</Label>
<div className="mt-1 rounded-md border border-input bg-muted/40 px-3 py-2 text-sm tabular-nums">
{fiscalPeriodBounds
? `${formatDate(fiscalPeriodBounds.start)} – ${formatDate(fiscalPeriodBounds.end)}`
: '—'}
</div>
</div>
// Annual VAT covers a räkenskapsår — picked here, not a
// calendar year.
<FiscalYearSelector
value={fiscalPeriodId || null}
onChange={(id) => setFiscalPeriodId(id || '')}
includeAllOption={false}
hideFuturePeriods
/>
) : (
<>
<div>
<Label>År</Label>
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{yearOptions.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<Select value={String(year)} onValueChange={(value) => setYear(parseInt(value))}>
<SelectTrigger className="mt-1 w-28 tabular-nums">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((y) => (
<SelectItem key={y} value={String(y)} className="tabular-nums">
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Period</Label>
<select
value={period}
onChange={(e) => setPeriod(parseInt(e.target.value))}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
<Select
value={String(period)}
onValueChange={(value) => setPeriod(parseInt(value))}
>
{getPeriodOptions().map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<SelectTrigger className="mt-1 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getPeriodOptions().map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
<Button onClick={fetchDeclaration} disabled={loading}>
{loading ? 'Laddar...' : 'Hämta'}
</Button>
</div>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
<CardContent className="flex flex-col items-center p-8 text-center">
<AlertCircle className="mb-2 h-6 w-6 text-destructive" />
<p className="mb-4 text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={() => setRetryKey((k) => k + 1)}>
Försök igen
</Button>
</CardContent>
</Card>
)}
{data && (
<>
{!error && (awaitingFiscalPeriod || (loading && !data)) && (
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-64" />
</CardContent>
</Card>
)}
{data && !awaitingFiscalPeriod && (
<div
className={`space-y-4 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
>
<VatCompositionChart rutor={data.rutor} />
{/* Summary */}
<Card className="border-2">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Momsdeklaration - {data.period.start} till {data.period.end}</CardTitle>
@@ -1403,24 +1508,20 @@ export function VatDeclarationView({
</div>
</CardContent>
</Card>
</>
</div>
)}
{/* Skatteverket integration panel */}
<SkatteverketPanel
periodType={periodType}
year={year}
period={period}
hasData={data !== null}
rutor={data?.rutor ?? null}
/>
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Välj period och klicka &quot;Hämta&quot; för att se momsdeklaration.
</CardContent>
</Card>
{/* Skatteverket integration panel — hidden while the räkenskapsår for
helårsmoms is unresolved, so its actions can never target an
unconfirmed period. */}
{!awaitingFiscalPeriod && (
<SkatteverketPanel
periodType={periodType}
year={year}
period={period}
hasData={data !== null}
rutor={data?.rutor ?? null}
/>
)}
</div>
</VatDrillContext.Provider>
+9
View File
@@ -69,6 +69,12 @@ export interface ReportDescriptor {
dimensions?: boolean
/** Only shown when company_settings.dimensions_enabled is true. */
needsDimensions?: boolean
/**
* Nav-promoted page that happens to render in the focused-report shell.
* Hides the report-library back link and the shell's fiscal-year selector —
* the view owns all of its period controls.
*/
standalone?: boolean
}
/** Categories shown in the legacy desktop rail, in order. */
@@ -206,6 +212,9 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
category: 'tax_vat',
params: 'calendar',
exports: ['xlsx'],
// Promoted to the Skatt & bokslut nav group — reached directly, not via
// the report library, and it manages its own period selection.
standalone: true,
},
{
slug: 'periodisk-sammanstallning',
+3 -1
View File
@@ -303,7 +303,9 @@
"deleted_title": "Deadline deleted",
"delete_failed_title": "Could not delete deadline",
"retry": "Please try again.",
"overdue_invoices": "{count} overdue invoices"
"overdue_invoices": "{count} overdue invoices",
"no_system_deadlines_title": "Automatic tax deadlines missing.",
"no_system_deadlines_description": "Deadlines for VAT, employer declarations and F-tax are generated from your company's tax settings — make sure they are filled in."
},
"bureau": {
"title": "Bureau",
+3 -1
View File
@@ -303,7 +303,9 @@
"deleted_title": "Deadline borttagen",
"delete_failed_title": "Kunde inte ta bort deadline",
"retry": "Försök igen.",
"overdue_invoices": "{count} förfallna fakturor"
"overdue_invoices": "{count} förfallna fakturor",
"no_system_deadlines_title": "Automatiska skattedeadlines saknas.",
"no_system_deadlines_description": "Deadlines för moms, arbetsgivardeklaration och F-skatt skapas från företagets skatteinställningar — kontrollera att de är ifyllda."
},
"bureau": {
"title": "Byrå",