diff --git a/DECISIONS.md b/DECISIONS.md index 7656a2b2..c2a66369 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -7,3 +7,7 @@ One line per decision: `[YYYY-MM-DD] — `. 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. diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index ad866f2e..92bb7f6f 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -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(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(null) const [selectedPeriodId, setSelectedPeriodId] = useState( 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 && ( - + hasAnyPeriods ? ( + + ) : ( + + ) )} {showWizard && periods && periods.length > 1 && step !== 'result' && ( diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index db3c87bf..c9fd7de6 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -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 (
+ {!hasSystemDeadlines && ( + +
+
+ +

+ {t('no_system_deadlines_title')} + + {t('no_system_deadlines_description')} + +

+
+ +
+ + )} + {/* Overdue invoices alert */} {overdueInvoices.count > 0 && ( diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index 024853e3..0052a854 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -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(null) const [notConnected, setNotConnected] = useState(false) + const [loadError, setLoadError] = useState(false) const [matchOpenFor, setMatchOpenFor] = useState( 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 ( +
+ + + + +

Kunde inte hämta skattekontot

+

+ Något gick fel när saldo och transaktioner skulle hämtas. Försök + igen om en stund. +

+ +
+
+
+ ) + } + return (
- - - {t('back_to_library')} - + {!isStandalone && ( + + + {t('back_to_library')} + + )} { - setSelectedPeriod(id || '') - setSelectedPeriodBounds( - period ? { start: period.period_start, end: period.period_end } : null, - ) - setDateRange({}) - }} - includeAllOption={false} - hideFuturePeriods - onReady={() => setIsReady(true)} - /> + isStandalone ? undefined : ( + { + 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 case 'vat-declaration': - return + return case 'periodisk-sammanstallning': return case 'ne-declaration': diff --git a/components/reports/SkatteverketPanel.tsx b/components/reports/SkatteverketPanel.tsx index da503a75..a9edbed9 100644 --- a/components/reports/SkatteverketPanel.tsx +++ b/components/reports/SkatteverketPanel.tsx @@ -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 () => { diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 9f72dfef..b35d30bf 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -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('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(null) const [year, setYear] = useState(currentYear) const [period, setPeriod] = useState(currentQuarter) - const [data, setData] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(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(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(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 ( + + + + + + + ) + } + + if (notVatRegistered) { + return ( + + ) + } + + // 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 ( + + ) } return ( @@ -1130,90 +1219,106 @@ export function VatDeclarationView({ contextRef={`vat:${year}-${periodType}-${period}`} /> - {/* Period selection */} + + {/* Period selection — the declaration below follows it automatically */} - - Välj period - - +
- + + + + + Månadsvis + Kvartalsvis + Årsvis + +
{isYearly ? ( - // Annual VAT covers the selected räkenskapsår — driven by the - // fiscal-year picker on the report page, not a calendar year. -
- -
- {fiscalPeriodBounds - ? `${formatDate(fiscalPeriodBounds.start)} – ${formatDate(fiscalPeriodBounds.end)}` - : '—'} -
-
+ // Annual VAT covers a räkenskapsår — picked here, not a + // calendar year. + setFiscalPeriodId(id || '')} + includeAllOption={false} + hideFuturePeriods + /> ) : ( <>
- +
- setPeriod(parseInt(value))} > - {getPeriodOptions().map((opt) => ( - - ))} - + + + + + {getPeriodOptions().map((opt) => ( + + {opt.label} + + ))} + +
)} -
{error && ( - - - {error} + + +

{error}

+
)} - {data && ( - <> + {!error && (awaitingFiscalPeriod || (loading && !data)) && ( + + + + + + + )} + + {data && !awaitingFiscalPeriod && ( +
{/* Summary */} - +
Momsdeklaration - {data.period.start} till {data.period.end} @@ -1403,24 +1508,20 @@ export function VatDeclarationView({
- +
)} - {/* Skatteverket integration panel */} - - - {!data && !loading && !error && ( - - - Välj period och klicka "Hämta" för att se momsdeklaration. - - + {/* Skatteverket integration panel — hidden while the räkenskapsår for + helårsmoms is unresolved, so its actions can never target an + unconfirmed period. */} + {!awaitingFiscalPeriod && ( + )}
diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts index b24dba80..b6d56523 100644 --- a/lib/reports/catalog.ts +++ b/lib/reports/catalog.ts @@ -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', diff --git a/messages/en.json b/messages/en.json index bb9776a0..057bd766 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index c1effa3a..d2da8108 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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å",