* feat(settings): let a company name each verifikationsserie letter
The series pickers show a fixed preset label next to every letter (A
Redovisning ... M Momsrapport, Fortnox's layout). A byrå that lays its
series out differently sees a wrong or missing name in every dropdown: a
partner running löner on L saw "Kontantfaktura" in the verifikat form and
asked for the series name.
- company_settings.voucher_series_labels JSONB ({"L": "Lön"}), keys A-Z,
values 1 to 40 chars, CHECK on the JSON shape. Display only; the engine
never reads it.
- UpdateSettingsSchema validates the map, trims names and strips empty
values so a cleared field removes the name.
- voucherSeriesLabel(letter, labels) is the one place that decides what a
letter is called: company name, then preset, then empty.
buildVoucherSeriesOptions replaces the three near-identical option
builders in the verifikat form and the two settings pickers.
- The Verifikationsserier list in settings edits the names: rows are the
union of used, configured and named letters, one save button.
- The SIE import review's two series pickers show the name too.
Migration applied to staging (metjnjrhvujscngnpzdv) as 20260906131300.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* fix(settings): keep imported series in the list and unsaved names through a refetch
Skeptic pass on the series-name editor refuted two things:
- The rewritten list filtered voucher_sequences to single letters, dropping
multi-character series (FT, LB, SKV, ...) that 54 production companies
carry over from Fortnox and Bokio imports; the old list showed them with
their highest number. Rows are now every used series plus the configured
and named letters; only single-letter series get a name input, since
those are what the pickers offer and the schema accepts.
- The draft re-seeded on the identity of settings.voucher_series_labels,
and the settings hook revalidates on window focus with a fresh object, so
unsaved typing was wiped after any earlier save on the page. The re-seed
is now keyed on the serialized content of the saved names.
Also folds the "new series are created on first use" footnote back into
the group help, which the rewrite had dropped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* fix(settings): name the default-series options and enforce the label shape in the database
Review pass on #2336:
- CodeRabbit: the Standardserie selector under Bokföring still rendered
bare letters; it now shows the same name the other pickers do, through
voucherSeriesLabel.
- Compliance swarm (SOC 2 PI1.1, low): the key and length rules for
voucher_series_labels lived only in UpdateSettingsSchema. Migration
20260906134700 adds voucher_series_labels_valid(jsonb) and swaps the
object-only CHECK for one that mirrors the Zod rules (keys A-Z, values
non-blank strings of at most 40 characters), so a write that bypasses
/api/settings cannot store a map the pickers cannot handle. Applied to
staging with its schema_migrations row; verified against good, empty,
lowercase, blank, over-long, numeric and array inputs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
* test(pg): cover the voucher_series_labels CHECK against real Postgres
The coverage gate refuses a migration that adds a function without a
*.pg.test.ts. voucher_series_labels_valid(jsonb) and the constraint that
wraps it now have one: accepts the empty map and single-letter keys with
names of 1 to 40 characters, rejects lowercase and multi-letter keys,
blank, over-long, numeric and null values, arrays and scalars, and leaves
the row untouched after a refused write.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBj3hzDUb8sgtxTvyAjFWC
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
221 lines
8.8 KiB
TypeScript
221 lines
8.8 KiB
TypeScript
'use client'
|
|
|
|
import { useTranslations } from 'next-intl'
|
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
import { Loader2 } from 'lucide-react'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { useCompany } from '@/contexts/CompanyContext'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Button } from '@/components/ui/button'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import {
|
|
SettingsGroup,
|
|
SettingsInput,
|
|
SettingsRow,
|
|
SettingsRowNote,
|
|
} from '@/components/settings/SettingsRows'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { voucherSeriesLabel } from '@/lib/bookkeeping/voucher-series-resolver'
|
|
import type { CompanySettings } from '@/types'
|
|
|
|
interface VoucherSeries {
|
|
voucher_series: string
|
|
last_number: number
|
|
fiscal_period_id: string
|
|
}
|
|
|
|
interface VoucherSeriesManagerProps {
|
|
settings: Pick<
|
|
CompanySettings,
|
|
'default_voucher_series' | 'default_voucher_series_per_source_type' | 'voucher_series_labels'
|
|
>
|
|
onSettingsUpdated: (settings: Partial<CompanySettings>) => void
|
|
}
|
|
|
|
const SERIES_LETTER_RE = /^[A-Z]$/
|
|
const NAME_MAX_LENGTH = 40
|
|
|
|
/**
|
|
* The series this company uses, with a name per letter.
|
|
*
|
|
* Rows are the union of every series that has vouchers (voucher_sequences,
|
|
* including multi-character series such as FT or SKV carried over from a
|
|
* Fortnox or Bokio import), the letters configured as defaults, and the
|
|
* letters that already carry a name, so a freshly assigned series (L for
|
|
* löner, no voucher yet) can be named before its first verifikat.
|
|
*
|
|
* Only single-letter series can be named: those are the ones the pickers
|
|
* offer and the settings schema accepts. Imported multi-character series are
|
|
* listed with their highest number, as before, and cannot be picked for new
|
|
* vouchers anyway. The name is display only: it is what the pickers show
|
|
* next to the letter, with the Swedish preset as the fallback. The letter
|
|
* itself stays the identifier on every journal entry.
|
|
*/
|
|
export function VoucherSeriesManager({ settings, onSettingsUpdated }: VoucherSeriesManagerProps) {
|
|
const t = useTranslations('settings_voucher_series')
|
|
const { company } = useCompany()
|
|
const { toast } = useToast()
|
|
const [series, setSeries] = useState<VoucherSeries[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
|
|
// The saved names, keyed by CONTENT rather than object identity. The
|
|
// settings hook revalidates on window focus and hands out a fresh object
|
|
// even when nothing changed; re-seeding the draft on that identity change
|
|
// would wipe whatever the user is typing. Serializing the filtered entries
|
|
// gives a key that only changes when a name actually changes.
|
|
const savedKey = useMemo(() => {
|
|
const entries: Array<[string, string]> = []
|
|
for (const [letter, name] of Object.entries(settings.voucher_series_labels ?? {})) {
|
|
if (SERIES_LETTER_RE.test(letter) && typeof name === 'string' && name.trim()) {
|
|
entries.push([letter, name.trim()])
|
|
}
|
|
}
|
|
entries.sort(([a], [b]) => a.localeCompare(b))
|
|
return JSON.stringify(entries)
|
|
}, [settings.voucher_series_labels])
|
|
const savedLabels = useMemo<Record<string, string>>(
|
|
() => Object.fromEntries(JSON.parse(savedKey) as Array<[string, string]>),
|
|
[savedKey],
|
|
)
|
|
const [draft, setDraft] = useState<Record<string, string>>(savedLabels)
|
|
// Re-seed only when a saved name actually changed (another form on the
|
|
// page saved, or the settings were refetched with different content).
|
|
useEffect(() => { setDraft(savedLabels) }, [savedLabels])
|
|
|
|
const defaultSeries = settings.default_voucher_series || 'A'
|
|
|
|
const fetchSeries = useCallback(async () => {
|
|
if (!company?.id) { setIsLoading(false); return }
|
|
const supabase = createClient()
|
|
const { data } = await supabase
|
|
.from('voucher_sequences')
|
|
.select('voucher_series, last_number, fiscal_period_id')
|
|
.eq('company_id', company.id)
|
|
.order('voucher_series')
|
|
setSeries(data || [])
|
|
setIsLoading(false)
|
|
}, [company?.id])
|
|
|
|
useEffect(() => { fetchSeries() }, [fetchSeries])
|
|
|
|
// Highest last_number per series across fiscal periods. Every series the
|
|
// ledger holds counts, whatever its shape: the number is the only place in
|
|
// the UI that shows how far an imported series has run.
|
|
const lastNumbers = useMemo(() => {
|
|
const acc: Record<string, number> = {}
|
|
for (const s of series) {
|
|
const key = s.voucher_series?.trim()
|
|
if (!key) continue
|
|
acc[key] = Math.max(acc[key] || 0, s.last_number)
|
|
}
|
|
return acc
|
|
}, [series])
|
|
|
|
const rows = useMemo(() => {
|
|
const set = new Set<string>(Object.keys(lastNumbers))
|
|
const considerLetter = (value: unknown) => {
|
|
if (typeof value === 'string' && SERIES_LETTER_RE.test(value)) set.add(value)
|
|
}
|
|
considerLetter(defaultSeries)
|
|
Object.values(settings.default_voucher_series_per_source_type ?? {}).forEach(considerLetter)
|
|
Object.keys(savedLabels).forEach(considerLetter)
|
|
return Array.from(set).sort((a, b) => a.localeCompare(b))
|
|
}, [lastNumbers, defaultSeries, settings.default_voucher_series_per_source_type, savedLabels])
|
|
|
|
const nameableLetters = useMemo(() => rows.filter((s) => SERIES_LETTER_RE.test(s)), [rows])
|
|
|
|
const hasChanges = nameableLetters.some(
|
|
(letter) => (draft[letter] ?? '').trim() !== (savedLabels[letter] ?? ''),
|
|
)
|
|
|
|
const handleSave = async () => {
|
|
setIsSaving(true)
|
|
try {
|
|
// Every nameable letter is sent, empty string meaning "clear this
|
|
// name"; the schema strips empties before storing.
|
|
const payload: Record<string, string> = {}
|
|
for (const letter of nameableLetters) payload[letter] = (draft[letter] ?? '').trim()
|
|
const res = await fetch('/api/settings', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voucher_series_labels: payload }),
|
|
})
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
toast({
|
|
title: t('per_account_save_failed'),
|
|
description: getErrorMessage(json, { context: 'settings', statusCode: res.status }),
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
const stored: Record<string, string> = {}
|
|
for (const [letter, name] of Object.entries(payload)) if (name) stored[letter] = name
|
|
onSettingsUpdated({ voucher_series_labels: stored })
|
|
toast({ title: t('names_saved_title'), description: t('names_saved_description') })
|
|
} catch (err) {
|
|
toast({
|
|
title: t('per_account_save_failed'),
|
|
description: getErrorMessage(err, { context: 'settings' }),
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SettingsGroup label={t('heading')} help={`${t('name_help')} ${t('footnote')}`}>
|
|
{isLoading ? (
|
|
<div className="space-y-2 px-1 py-3">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-4 w-24" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{rows.map((seriesKey, i) => {
|
|
const lastNum = lastNumbers[seriesKey]
|
|
const nameable = SERIES_LETTER_RE.test(seriesKey)
|
|
// Placeholder shows the preset the name would fall back to, so an
|
|
// empty field never reads as "this series has no meaning".
|
|
const preset = nameable ? voucherSeriesLabel(seriesKey) : ''
|
|
return (
|
|
<SettingsRow
|
|
key={seriesKey}
|
|
label={`${t('series_prefix')} ${seriesKey}`}
|
|
htmlFor={nameable ? `series-name-${seriesKey}` : undefined}
|
|
borderless={i === rows.length - 1}
|
|
>
|
|
{nameable && (
|
|
<SettingsInput
|
|
id={`series-name-${seriesKey}`}
|
|
value={draft[seriesKey] ?? ''}
|
|
maxLength={NAME_MAX_LENGTH}
|
|
placeholder={preset || t('name_placeholder')}
|
|
aria-label={`${t('name_column')} ${seriesKey}`}
|
|
onChange={(e) => setDraft((prev) => ({ ...prev, [seriesKey]: e.target.value }))}
|
|
className="w-full md:w-64"
|
|
/>
|
|
)}
|
|
{seriesKey === defaultSeries && (
|
|
<SettingsRowNote>{t('default_badge')}</SettingsRowNote>
|
|
)}
|
|
<span className="ml-auto shrink-0 text-sm text-muted-foreground tabular-nums">
|
|
{lastNum != null ? `${t('latest_number')}: ${lastNum}` : t('no_vouchers_yet')}
|
|
</span>
|
|
</SettingsRow>
|
|
)
|
|
})}
|
|
<div className="flex justify-end px-1 pt-4">
|
|
<Button type="button" size="sm" onClick={handleSave} disabled={!hasChanges || isSaving}>
|
|
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{t('save_names')}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</SettingsGroup>
|
|
)
|
|
}
|