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:
Jakob Wennberg
2026-07-25 11:59:06 +02:00
committed by GitHub
parent 17dc5f12f6
commit 4bc2093e51
5 changed files with 231 additions and 242 deletions
+1
View File
@@ -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.
+14 -8
View File
@@ -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':
+20 -18
View File
@@ -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
+93 -120
View File
@@ -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>
}
+103 -96
View File
@@ -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 && (