Files
accounted/components/settings/VoucherSeriesManager.tsx
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

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>
)
}