feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2358)

* feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2184)

A new company_settings row now defaults to the standard series set
(A manual/bank, B kundfakturor, C inbetalningar, D leverantörsfakturor,
E utbetalningar, H periodisering, I bokslut, K lön, L kontantfaktura,
M moms) instead of everything on A. The set lives once, as the
exhaustive STANDARD_VOUCHER_SERIES_MAP in the resolver; a pg-real test
holds the column default equal to it and to the source_type CHECK.

Existing rows are not remapped: the per-type settings form gets an
"Använd standarduppsättningen" action that fills the set for review
and save through the existing PUT, so the switch is a deliberate,
audited act rather than a mid-year numbering change nobody decided.
Payment rows bound to the other bokföringsmetod are dimmed, not hidden.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

* test(bookkeeping): fresh company_settings row asserts the standard series set, not all-A

voucher-series-defaults.pg.test.ts codified the pre-#2184 column default
(every source type on A). Migration 20260906210500 replaces that default
with the standard set, so the "freshly inserted row" case now asserts the
representative letters and full equality with STANDARD_VOUCHER_SERIES_MAP.
The explicit-override case keeps proving a company's own layout replaces
the default wholesale. No other pg or tool test asserted on the old map.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:18:22 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 4fce2d7b94
commit bf7773d74b
11 changed files with 465 additions and 34 deletions
@@ -13,7 +13,12 @@ import {
} from '@/components/settings/SettingsRows'
import { cn } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { buildVoucherSeriesOptions } from '@/lib/bookkeeping/voucher-series-resolver'
import { HelpPopover } from '@/components/ui/help-popover'
import {
buildVoucherSeriesOptions,
isStandardVoucherSeriesMap,
STANDARD_VOUCHER_SERIES_MAP,
} from '@/lib/bookkeeping/voucher-series-resolver'
import type { CompanySettings, JournalEntrySourceType } from '@/types'
// Subset of source_types presented to the user. The DB column accepts every
@@ -43,6 +48,18 @@ const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: strin
// Keeps the map's iteration order intact: we only split it, never reorder.
const ALWAYS_VISIBLE_COUNT = 3
// The payment types that belong to one bokföringsmetod. Under the other
// method their rows are dimmed, never hidden (#2184): the choice stays visible
// and editable, so a company that switches method finds it already made and
// nothing in the map goes stale out of sight. Only the payment rows are
// bound to a method; registering an invoice happens under both.
const METHOD_BOUND: Partial<Record<JournalEntrySourceType, 'accrual' | 'cash'>> = {
invoice_paid: 'accrual',
supplier_invoice_paid: 'accrual',
invoice_cash_payment: 'cash',
supplier_invoice_cash_payment: 'cash',
}
// Swedish labels. Kept inline so this component is self-contained: these
// labels are bookkeeping-domain terms that intentionally stay Swedish across
// locales (see CLAUDE.md i18n table).
@@ -73,6 +90,7 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
// Generic fold labels ("Visa alla (n)" / "Visa färre") shared with the
// dashboard widgets; the domain labels themselves stay hardcoded Swedish.
const tCommon = useTranslations('dashboard')
const t = useTranslations('settings_bookkeeping')
const { toast } = useToast()
const initialMap = settings.default_voucher_series_per_source_type || {}
const [draft, setDraft] = useState<Partial<Record<JournalEntrySourceType, string>>>(
@@ -80,6 +98,7 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
)
const [isSaving, setIsSaving] = useState(false)
const [showAll, setShowAll] = useState(false)
const method: 'accrual' | 'cash' = settings.accounting_method === 'cash' ? 'cash' : 'accrual'
// Same closed list as the manual verifikat form and the per-bankkonto
// picker: the fixed Swedish presets, then any letter already configured in
@@ -111,6 +130,15 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
const hasChanges =
JSON.stringify(draft) !== JSON.stringify(initialMap)
// Fill the form with the set a new company starts with. Fills, never
// saves: a series switch mid-year is a deliberate act, so the fold opens to
// show every row it changes and the user commits with Spara serier.
const onStandardSet = isStandardVoucherSeriesMap(draft)
const handleUseStandardSet = () => {
setDraft({ ...STANDARD_VOUCHER_SERIES_MAP })
setShowAll(true)
}
const handleSave = async () => {
setIsSaving(true)
try {
@@ -151,27 +179,35 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
const renderRow = (
{ key, labelKey }: (typeof VISIBLE_SOURCE_TYPES)[number],
borderless = false,
) => (
<SettingsRow
key={key}
label={SV_LABELS[labelKey] ?? key}
htmlFor={`series-${key}`}
borderless={borderless}
>
<SettingsSelect
id={`series-${key}`}
value={(draft[key] as string | undefined) || 'A'}
onChange={(e) => handleChange(key, e.target.value)}
className="font-mono"
) => {
const boundTo = METHOD_BOUND[key]
const dimmed = boundTo !== undefined && boundTo !== method
return (
<SettingsRow
key={key}
label={SV_LABELS[labelKey] ?? key}
htmlFor={`series-${key}`}
help={
dimmed ? t(boundTo === 'cash' ? 'series_row_cash_only' : 'series_row_accrual_only') : undefined
}
borderless={borderless}
className={dimmed ? 'opacity-60' : undefined}
>
{seriesOptions.map((option) => (
<option key={option.letter} value={option.letter}>
{option.label ? `${option.letter} ${option.label}` : option.letter}
</option>
))}
</SettingsSelect>
</SettingsRow>
)
<SettingsSelect
id={`series-${key}`}
value={(draft[key] as string | undefined) || 'A'}
onChange={(e) => handleChange(key, e.target.value)}
className="font-mono"
>
{seriesOptions.map((option) => (
<option key={option.letter} value={option.letter}>
{option.label ? `${option.letter} ${option.label}` : option.letter}
</option>
))}
</SettingsSelect>
</SettingsRow>
)
}
const alwaysVisible = VISIBLE_SOURCE_TYPES.slice(0, ALWAYS_VISIBLE_COUNT)
const folded = VISIBLE_SOURCE_TYPES.slice(ALWAYS_VISIBLE_COUNT)
@@ -204,7 +240,19 @@ export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }:
{folded.map((entry, i) => renderRow(entry, i === folded.length - 1))}
</SettingsReveal>
<div className="flex justify-end px-1 pt-4">
<div className="flex flex-wrap items-center justify-between gap-3 px-1 pt-4">
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleUseStandardSet}
disabled={onStandardSet || isSaving}
>
{t('series_standard_set_button')}
</Button>
<HelpPopover className="shrink-0">{t('series_standard_set_help')}</HelpPopover>
</div>
<Button
type="button"
size="sm"