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:
co-authored by
Claude Fable 5
parent
100a4d1291
commit
9f7c842a33
@@ -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.
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
@@ -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 "Hämta" 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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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å",
|
||||
|
||||
Reference in New Issue
Block a user