polish(vat): title-row Exportera, fused period chip, chip classification, calm SKV status (#1181)
Founder feedback on the live momsdeklaration (2026-07-25):
- The black Exportera now sits on the title row like every other page:
standalone report pages render their own PageHeader (FocusedReport
passes the title and skips its own), and the period chips get their
own row below.
- Year + quarter/month fuse into ONE chip ("Kvartal 3 2026") listing
five years reverse-chronologically with month-span annotations;
cadence stays behind the Period chip. Yearly keeps FyPicker, which
is already a fused rakenskapsar chip.
- The RC-basis worklist's Leverantorstyp/Typ av inkop selects (old
boxy style with labels) become ContextPicker chips, in the toolbar
and in each expanded row.
- SkatteverketPanel connection status per the locked conventions: the
contradictory "Ansluten" + "Session utgangen" badge cluster becomes
muted "Ansluten" text for the normal state and one attn sentence
with an embedded "Fornya med BankID" action for the expired session.
Verified via sandbox screenshots (Playwright against dev).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -371,3 +371,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-25] Removed invented 6-month minimum for first räkenskapsår: BFL 3 kap 3 § sets no floor (Bolagsverket: "hur kort som helst", max 18 months); the check only existed for isFirstPeriod, exactly the case the law exempts, and blocked a customer shortening an autumn-registered first year to Dec 31.
|
||||
[2026-07-25] Article EUR-price support bug: root cause was the edit dialog omitting currency from initialData (form defaulted SEK and PATCHed it back) plus kr-hardcoded formatCurrency calls; export gets a Valuta column + suffix-free decimalColumn instead of extending CURRENCY_FORMAT, importer Valuta detection deferred as follow-up to keep the diff scoped.
|
||||
[2026-07-25] Reinstated article deactivation as an explicit PATCH active-toggle button on the detail page (support: odinaero.se) instead of reverting DELETE to soft-delete: 8a9a930f intentionally made DELETE hard-delete for unused articles, but that left invoice-referenced articles (ARTICLE_IN_USE) with no retire path; the old deactivate i18n keys were still in messages/ and are reused.
|
||||
[2026-07-25] Momsdeklaration period selection fused into one chip (year+quarter/month, reverse-chron, 5 years) instead of separate year and period pickers; cadence stays behind the Period chip. Standalone report pages now render their own PageHeader so Exportera sits on the title row per convention 9.
|
||||
|
||||
@@ -113,10 +113,13 @@ function FocusedReportInner({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<PageHeader
|
||||
title={reportName}
|
||||
action={
|
||||
isStandalone ? undefined : (
|
||||
{/* Standalone pages (Momsdeklaration) render their own PageHeader so
|
||||
the primary action can live on the title row; the view receives the
|
||||
title via pageTitle instead. */}
|
||||
{!isStandalone && (
|
||||
<PageHeader
|
||||
title={reportName}
|
||||
action={
|
||||
<FyPicker
|
||||
value={selectedPeriod || null}
|
||||
onChange={(id, period) => {
|
||||
@@ -132,9 +135,9 @@ function FocusedReportInner({
|
||||
initialPeriods={initialPeriods}
|
||||
initialCompanyId={initialCompanyId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{DATE_RANGE_SLUGS.has(slug) && selectedPeriodBounds && (
|
||||
<ReportDateRange
|
||||
@@ -159,6 +162,7 @@ function FocusedReportInner({
|
||||
) : isPeriodless || selectedPeriod ? (
|
||||
<FocusedView
|
||||
slug={slug}
|
||||
reportName={reportName}
|
||||
periodId={selectedPeriod}
|
||||
periodBounds={selectedPeriodBounds}
|
||||
dateRange={dateRange}
|
||||
@@ -182,6 +186,7 @@ function FocusedReportInner({
|
||||
|
||||
function FocusedView({
|
||||
slug,
|
||||
reportName,
|
||||
periodId,
|
||||
periodBounds,
|
||||
dateRange,
|
||||
@@ -192,6 +197,7 @@ function FocusedView({
|
||||
onNavigateToAccount,
|
||||
}: {
|
||||
slug: string
|
||||
reportName: string
|
||||
periodId: string
|
||||
periodBounds: { start: string; end: string } | null
|
||||
dateRange: DateRangeValue
|
||||
@@ -215,7 +221,7 @@ function FocusedView({
|
||||
case 'balance-sheet':
|
||||
return <BalanceSheetView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
|
||||
case 'vat-declaration':
|
||||
return <VatDeclarationView />
|
||||
return <VatDeclarationView pageTitle={reportName} />
|
||||
case 'periodisk-sammanstallning':
|
||||
return <PeriodiskSammanstallningView />
|
||||
case 'ne-declaration':
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -49,8 +48,8 @@ interface SkatteverketStatus {
|
||||
* Codes from /lib/api-client.ts's SkatteverketAuthError that mean "the user
|
||||
* needs to reconnect with BankID before this action can succeed". When the API
|
||||
* returns one of these codes we flip the local status.expired flag so the
|
||||
* "Session utgången" badge + "Förnya session" button surface, even if the
|
||||
* upstream /status endpoint hasn't reflected the change yet.
|
||||
* expired-session attn line with its "Förnya med BankID" action surfaces,
|
||||
* even if the upstream /status endpoint hasn't reflected the change yet.
|
||||
*/
|
||||
const AUTH_RECONNECT_CODES = new Set([
|
||||
'NOT_CONNECTED',
|
||||
@@ -761,21 +760,12 @@ function SkatteverketPanelInner({
|
||||
Skicka direkt till Skatteverket
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="success" className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Ansluten
|
||||
</Badge>
|
||||
{status.expired && (
|
||||
<>
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Session utgången
|
||||
</Badge>
|
||||
<Button variant="outline" onClick={handleConnect} className="gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
Förnya session
|
||||
</Button>
|
||||
</>
|
||||
{/* Connected is the normal state here (the not-connected branch is
|
||||
a different section): muted text, never a chip. The expired
|
||||
session is the one exception and gets the attn sentence below
|
||||
instead of a badge-and-button cluster. */}
|
||||
{!status.expired && (
|
||||
<span className="text-xs text-muted-foreground">Ansluten</span>
|
||||
)}
|
||||
{/* Read-only lookups and recovery actions live in the overflow
|
||||
menu: the visible surface stays the forward path (validera,
|
||||
@@ -901,6 +891,18 @@ function SkatteverketPanelInner({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{status.expired && (
|
||||
<p className="mt-2 text-[12.5px] leading-5 text-attn">
|
||||
Sessionen mot Skatteverket har gått ut.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConnect}
|
||||
className="underline underline-offset-2 hover:opacity-80"
|
||||
>
|
||||
Förnya med BankID
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* In-flight status for overflow-menu actions: their menu closes on
|
||||
|
||||
@@ -3,14 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import {
|
||||
DataList,
|
||||
DataListMeta,
|
||||
@@ -71,6 +64,27 @@ const SUPPLY_LABELS: Record<SupplyType, string> = {
|
||||
service: 'tjänst',
|
||||
goods: 'vara',
|
||||
}
|
||||
/** Chip labels (sentence case); SUPPLY_LABELS stays lowercase for dialog prose. */
|
||||
const SUPPLY_DISPLAY: Record<SupplyType, string> = {
|
||||
service: 'Tjänst',
|
||||
goods: 'Vara',
|
||||
}
|
||||
|
||||
const SUPPLIER_ITEMS = [
|
||||
{ id: 'eu_business', label: 'EU-leverantör' },
|
||||
{ id: 'non_eu_business', label: 'Utanför EU' },
|
||||
{ id: 'swedish_business', label: 'Svensk omvänd skattskyldighet' },
|
||||
]
|
||||
|
||||
// Non-EU + goods is import VAT (ruta 50/60-62), not reverse charge: the
|
||||
// goods choice disappears entirely for non-EU suppliers.
|
||||
const supplyItemsFor = (supplierType: SupplierType) =>
|
||||
supplierType === 'non_eu_business'
|
||||
? [{ id: 'service', label: 'Tjänst' }]
|
||||
: [
|
||||
{ id: 'service', label: 'Tjänst' },
|
||||
{ id: 'goods', label: 'Vara' },
|
||||
]
|
||||
|
||||
/** How many gap rows render before the "Visa alla" toggle. */
|
||||
const GAP_PREVIEW_COUNT = 8
|
||||
@@ -355,54 +369,38 @@ export function VatChecksCard({
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label htmlFor="rc-supplier-type">Leverantörstyp</Label>
|
||||
<Select
|
||||
value={sharedSel.supplierType}
|
||||
onValueChange={(value) => {
|
||||
const supplierType = value as SupplierType
|
||||
setSharedSel((prev) => ({
|
||||
supplierType,
|
||||
// Non-EU + goods is import VAT, not reverse charge:
|
||||
// coerce back to service so an invalid combo can't
|
||||
// be mass-applied.
|
||||
supplyType:
|
||||
supplierType === 'non_eu_business' ? 'service' : prev.supplyType,
|
||||
}))
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger id="rc-supplier-type" className="mt-1 w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="eu_business">EU-leverantör</SelectItem>
|
||||
<SelectItem value="non_eu_business">Utanför EU</SelectItem>
|
||||
<SelectItem value="swedish_business">Svensk omvänd skattskyldighet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rc-supply-type">Typ av inköp</Label>
|
||||
<Select
|
||||
value={sharedSel.supplyType}
|
||||
onValueChange={(value) =>
|
||||
setSharedSel((prev) => ({ ...prev, supplyType: value as SupplyType }))
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger id="rc-supply-type" className="mt-1 w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="service">Tjänst</SelectItem>
|
||||
{sharedSel.supplierType !== 'non_eu_business' && (
|
||||
<SelectItem value="goods">Vara</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Classification chips (house context-picker style), then the
|
||||
bulk action: the chip values ("EU-leverantör", "Tjänst")
|
||||
are self-describing, so no field labels. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ContextPicker
|
||||
items={SUPPLIER_ITEMS}
|
||||
value={sharedSel.supplierType}
|
||||
onChange={(value) => {
|
||||
const supplierType = value as SupplierType
|
||||
setSharedSel((prev) => ({
|
||||
supplierType,
|
||||
// Non-EU + goods is import VAT, not reverse charge:
|
||||
// coerce back to service so an invalid combo can't
|
||||
// be mass-applied.
|
||||
supplyType:
|
||||
supplierType === 'non_eu_business' ? 'service' : prev.supplyType,
|
||||
}))
|
||||
}}
|
||||
triggerLabel={SUPPLIER_LABELS[sharedSel.supplierType]}
|
||||
ariaLabel="Leverantörstyp"
|
||||
disabled={busy}
|
||||
/>
|
||||
<ContextPicker
|
||||
items={supplyItemsFor(sharedSel.supplierType)}
|
||||
value={sharedSel.supplyType}
|
||||
onChange={(value) =>
|
||||
setSharedSel((prev) => ({ ...prev, supplyType: value as SupplyType }))
|
||||
}
|
||||
triggerLabel={SUPPLY_DISPLAY[sharedSel.supplyType]}
|
||||
ariaLabel="Typ av inköp"
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button onClick={handleFixAll} disabled={!canWrite || busy}>
|
||||
{bulkProgress ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
@@ -481,70 +479,45 @@ export function VatChecksCard({
|
||||
fiktiv moms: saknar basbelopp{' '}
|
||||
{formatAmount(gap.expectedBasisAmount)} kr
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label htmlFor={`rc-supplier-${gap.entryId}`}>
|
||||
Leverantörstyp
|
||||
</Label>
|
||||
<Select
|
||||
value={sel.supplierType}
|
||||
onValueChange={(value) => {
|
||||
const supplierType = value as SupplierType
|
||||
setOverrides((prev) => ({
|
||||
...prev,
|
||||
[gap.entryId]: {
|
||||
supplierType,
|
||||
supplyType:
|
||||
supplierType === 'non_eu_business'
|
||||
? 'service'
|
||||
: sel.supplyType,
|
||||
},
|
||||
}))
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`rc-supplier-${gap.entryId}`}
|
||||
className="mt-1 w-56"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="eu_business">EU-leverantör</SelectItem>
|
||||
<SelectItem value="non_eu_business">Utanför EU</SelectItem>
|
||||
<SelectItem value="swedish_business">Svensk omvänd skattskyldighet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`rc-supply-${gap.entryId}`}>Typ av inköp</Label>
|
||||
<Select
|
||||
value={sel.supplyType}
|
||||
onValueChange={(value) =>
|
||||
setOverrides((prev) => ({
|
||||
...prev,
|
||||
[gap.entryId]: {
|
||||
...sel,
|
||||
supplyType: value as SupplyType,
|
||||
},
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`rc-supply-${gap.entryId}`}
|
||||
className="mt-1 w-32"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="service">Tjänst</SelectItem>
|
||||
{sel.supplierType !== 'non_eu_business' && (
|
||||
<SelectItem value="goods">Vara</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Same chips as the toolbar, scoped to this row:
|
||||
a changed value becomes a per-row override. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ContextPicker
|
||||
items={SUPPLIER_ITEMS}
|
||||
value={sel.supplierType}
|
||||
onChange={(value) => {
|
||||
const supplierType = value as SupplierType
|
||||
setOverrides((prev) => ({
|
||||
...prev,
|
||||
[gap.entryId]: {
|
||||
supplierType,
|
||||
supplyType:
|
||||
supplierType === 'non_eu_business'
|
||||
? 'service'
|
||||
: sel.supplyType,
|
||||
},
|
||||
}))
|
||||
}}
|
||||
triggerLabel={SUPPLIER_LABELS[sel.supplierType]}
|
||||
ariaLabel="Leverantörstyp"
|
||||
disabled={busy}
|
||||
/>
|
||||
<ContextPicker
|
||||
items={supplyItemsFor(sel.supplierType)}
|
||||
value={sel.supplyType}
|
||||
onChange={(value) =>
|
||||
setOverrides((prev) => ({
|
||||
...prev,
|
||||
[gap.entryId]: {
|
||||
...sel,
|
||||
supplyType: value as SupplyType,
|
||||
},
|
||||
}))
|
||||
}
|
||||
triggerLabel={SUPPLY_DISPLAY[sel.supplyType]}
|
||||
ariaLabel="Typ av inköp"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -14,13 +14,6 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { AlertCircle, Check, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
@@ -28,6 +21,7 @@ import { roundOre } from '@/lib/money'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { VatChecksCard } from '@/components/reports/VatChecksCard'
|
||||
import { runVatDeclarationChecks } from '@/lib/reports/vat-declaration-checks'
|
||||
import { Table, TableBody } from '@/components/ui/table'
|
||||
@@ -1451,7 +1445,13 @@ function VatStepper({
|
||||
)
|
||||
}
|
||||
|
||||
export function VatDeclarationView() {
|
||||
const MONTH_NAMES = [
|
||||
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
|
||||
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
|
||||
]
|
||||
const QUARTER_SPANS = ['jan-mar', 'apr-jun', 'jul-sep', 'okt-dec']
|
||||
|
||||
export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const currentMonth = new Date().getMonth() + 1
|
||||
const currentQuarter = Math.ceil(currentMonth / 3)
|
||||
@@ -1510,41 +1510,6 @@ export function VatDeclarationView() {
|
||||
// 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)
|
||||
|
||||
// Generate period options based on type
|
||||
const getPeriodOptions = () => {
|
||||
switch (periodType) {
|
||||
case 'monthly':
|
||||
return [
|
||||
{ value: 1, label: 'Januari' },
|
||||
{ value: 2, label: 'Februari' },
|
||||
{ value: 3, label: 'Mars' },
|
||||
{ value: 4, label: 'April' },
|
||||
{ value: 5, label: 'Maj' },
|
||||
{ value: 6, label: 'Juni' },
|
||||
{ value: 7, label: 'Juli' },
|
||||
{ value: 8, label: 'Augusti' },
|
||||
{ value: 9, label: 'September' },
|
||||
{ value: 10, label: 'Oktober' },
|
||||
{ value: 11, label: 'November' },
|
||||
{ value: 12, label: 'December' },
|
||||
]
|
||||
case 'quarterly':
|
||||
return [
|
||||
{ value: 1, label: 'Kvartal 1 (jan-mar)' },
|
||||
{ value: 2, label: 'Kvartal 2 (apr-jun)' },
|
||||
{ value: 3, label: 'Kvartal 3 (jul-sep)' },
|
||||
{ value: 4, label: 'Kvartal 4 (okt-dec)' },
|
||||
]
|
||||
case 'yearly':
|
||||
return [{ value: 1, label: 'Helår' }]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1641,26 +1606,37 @@ export function VatDeclarationView() {
|
||||
|
||||
// Settings not settled yet — the picker defaults and the gate both depend
|
||||
// on them, so hold the whole view in a skeleton.
|
||||
// The standalone page renders its own PageHeader (FocusedReport passes the
|
||||
// title and skips its own), so the H1 must survive the gated/loading
|
||||
// states too: each early return carries the action-less header.
|
||||
const bareHeader = pageTitle ? <PageHeader title={pageTitle} /> : null
|
||||
|
||||
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>
|
||||
<div className="space-y-8">
|
||||
{bareHeader}
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-64" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
{bareHeader}
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1669,23 +1645,64 @@ export function VatDeclarationView() {
|
||||
// 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"
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
{bareHeader}
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Year and concrete period fused into ONE chip: reverse-chronological
|
||||
// "Kvartal 2 2026", "Kvartal 1 2026", ... across the last five years, so
|
||||
// switching period never needs two pickers. Yearly keeps the FyPicker,
|
||||
// which is already a fused räkenskapsår chip.
|
||||
const fusedPeriodItems: { id: string; label: string; annotation?: string }[] = []
|
||||
if (!isYearly) {
|
||||
for (let y = currentYear; y > currentYear - 5; y--) {
|
||||
if (periodType === 'quarterly') {
|
||||
for (let q = 4; q >= 1; q--) {
|
||||
fusedPeriodItems.push({
|
||||
id: `${y}:${q}`,
|
||||
label: `Kvartal ${q} ${y}`,
|
||||
annotation: QUARTER_SPANS[q - 1],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
for (let m = 12; m >= 1; m--) {
|
||||
fusedPeriodItems.push({ id: `${y}:${m}`, label: `${MONTH_NAMES[m - 1]} ${y}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fusedLabel =
|
||||
periodType === 'quarterly' ? `Kvartal ${period} ${year}` : `${MONTH_NAMES[period - 1]} ${year}`
|
||||
|
||||
return (
|
||||
<VatDrillContext.Provider value={{ fiscalPeriodId: isYearly ? fiscalPeriodId : undefined }}>
|
||||
<div className="space-y-8">
|
||||
{/* One flat toolbar row (concept language): period pickers + Exportera
|
||||
far right. XML and PDF live in "Lämna in" below: they are filing
|
||||
artifacts, not report exports, and one home avoids two competing
|
||||
download surfaces. */}
|
||||
{/* Standalone page: the title row carries the primary action (locked
|
||||
convention 9), so Exportera sits beside the H1 and the period chips
|
||||
get their own row below. XML and PDF live in "Lämna in": they are
|
||||
filing artifacts, not report exports. */}
|
||||
{pageTitle && (
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
action={
|
||||
<ReportExportMenu
|
||||
variant="default"
|
||||
items={[
|
||||
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{/* Cadence lives behind a settings-style "Period" chip: the
|
||||
concrete period chip next to it already shows the cadence
|
||||
@@ -1714,36 +1731,26 @@ export function VatDeclarationView() {
|
||||
hideFuturePeriods
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ContextPicker
|
||||
items={yearOptions.map((y) => ({ id: String(y), label: String(y) }))}
|
||||
value={String(year)}
|
||||
onChange={(value) => setYear(parseInt(value))}
|
||||
triggerLabel={String(year)}
|
||||
ariaLabel="År"
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<ContextPicker
|
||||
items={getPeriodOptions().map((opt) => ({
|
||||
id: String(opt.value),
|
||||
label: opt.label,
|
||||
}))}
|
||||
value={String(period)}
|
||||
onChange={(value) => setPeriod(parseInt(value))}
|
||||
triggerLabel={
|
||||
getPeriodOptions().find((opt) => opt.value === period)?.label ??
|
||||
String(period)
|
||||
}
|
||||
ariaLabel="Redovisningsperiod"
|
||||
/>
|
||||
</>
|
||||
<ContextPicker
|
||||
items={fusedPeriodItems}
|
||||
value={`${year}:${period}`}
|
||||
onChange={(id) => {
|
||||
const [y, p] = id.split(':').map(Number)
|
||||
setYear(y)
|
||||
setPeriod(p)
|
||||
}}
|
||||
triggerLabel={fusedLabel}
|
||||
ariaLabel="Redovisningsperiod"
|
||||
/>
|
||||
)}
|
||||
{!pageTitle && (
|
||||
<ReportExportMenu
|
||||
variant="default"
|
||||
items={[
|
||||
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<ReportExportMenu
|
||||
variant="default"
|
||||
items={[
|
||||
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
Reference in New Issue
Block a user