Fix/chrome pdf preview csp (#572)

* feat: add option to exclude year-end closing entries in SIE export and related reports

* delete docs

* fix: allow Chrome's PDF viewer in verifikat document preview

The /api/documents/:id/inline route shipped with
`object-src 'none'` in its CSP, which blocked Chrome's built-in PDF
viewer (it renders inline PDFs via an internal <embed>). Users on
Chrome saw "Det här innehållet har blockerats" when expanding a PDF
attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own
viewer) were unaffected, and JPGs worked because <img> isn't subject
to object-src.

Drops the CSP for this route to the minimum needed for embeddability:
`frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the
fixed Content-Type from the handler already block MIME confusion;
X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(auth): add webmail deep link to email confirmation screens

Mirrors Stripe's signup UX: after asking the user to verify their email,
detect their webmail provider from the domain and show a button that
opens the inbox in a new tab. Gmail gets a from:<sender> search
pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly.
Unknown / custom domains fall back to the existing copy.

Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM
(default noreply@gnubok.se) so white-label installs can match their
Supabase Auth SMTP config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): unblock first-time password set for BankID users with MFA

Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session
is required" whenever a TOTP factor is enrolled. BankID magic-link logins
produce AAL1, and middleware skips MFA enforcement for bankid_linked users,
so they had no path to AAL2 — leaving them unable to set a backup password
or disable MFA without going through the email-recovery escape hatch.

- /api/account/password: branch on app_metadata.has_password. First-time set
  writes via service.auth.admin.updateUserById (no existing credential to
  protect, AAL2 guard does not apply). Change-password keeps the user-session
  updateUser so AAL2 still fires for credential rotation.
- /mfa/verify: accept a safeReturnTo query param and route there after
  successful verify, so step-up flows can land back where they came from.
- SecuritySettings: detect the AAL2 error from both change-password and
  mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account
  instead of toasting a dead-end error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add tests and rounding utility for öre precision in bokslut calculations

- Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations.
- Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries.
- Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency.
- Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies.
- Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios.

* fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility

* fix: enhance security by rejecting data URIs in safeReturnTo function tests

* fix: improve rounding logic in roundOre function and add customer_type migration

* fix: add customer_type column to customers and enforce CHECK constraint

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-26 22:29:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 0087b7be3f
commit 32d9978f1b
207 changed files with 24187 additions and 1052 deletions
@@ -0,0 +1,179 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import type { AccountingFramework } from '@/types'
import { Loader2 } from 'lucide-react'
interface AccountingFrameworkFormProps {
/** Current framework on the company row. */
current: AccountingFramework
/** Bubble up after a successful save so parent state can refresh. */
onSaved?: (next: AccountingFramework) => void
}
/**
* K2/K3 selector for AB. Lives on the bookkeeping settings page. Renders nothing
* for non-AB entities — the parent gates this component by entity_type.
*
* UX rules (regulatory area — kept in Swedish):
* - Default is K2 (matches the column default and BFNAR 2016:10 baseline).
* - Switching K2 → K3 fires a confirmation dialog. The recommendation per
* BFN is that the choice is permanent for the company once made; we
* surface that as a warning, not a block, so the user can still revert.
* - The save is its own request (PATCH /api/company/current) — separate
* from /api/settings because the column lives on companies, not on
* company_settings.
*/
export function AccountingFrameworkForm({ current, onSaved }: AccountingFrameworkFormProps) {
const { toast } = useToast()
const [selected, setSelected] = useState<AccountingFramework>(current)
const [pending, setPending] = useState<AccountingFramework | null>(null)
const [saving, setSaving] = useState(false)
async function persist(next: AccountingFramework) {
setSaving(true)
try {
const res = await fetch('/api/company/current', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accounting_framework: next }),
})
const body = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte spara',
description: body?.error ?? 'Försök igen.',
variant: 'destructive',
})
setSelected(current)
return
}
toast({
title: 'Sparat',
description:
next === 'k3'
? 'Bolaget redovisar nu enligt K3 (BFNAR 2012:1).'
: 'Bolaget redovisar nu enligt K2 (BFNAR 2016:10).',
})
onSaved?.(next)
} catch {
toast({
title: 'Kunde inte spara',
description: 'Försök igen.',
variant: 'destructive',
})
setSelected(current)
} finally {
setSaving(false)
setPending(null)
}
}
function handleChange(next: string) {
const value = next as AccountingFramework
if (value === selected) return
// K2 → K3 is the consequential direction: confirm before persisting.
if (selected === 'k2' && value === 'k3') {
setPending(value)
return
}
setSelected(value)
void persist(value)
}
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Redovisningsregelverk
</h2>
<div className="space-y-2">
<Label htmlFor="accounting_framework">Regelverk</Label>
<Select
value={selected}
onValueChange={handleChange}
disabled={saving}
>
<SelectTrigger id="accounting_framework" className="w-full max-w-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="k2">K2 (BFNAR 2016:10) — mindre företag</SelectItem>
<SelectItem value="k3">K3 (BFNAR 2012:1) — större företag</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när
bolaget når två av tre tröskelvärden (nettoomsättning &gt; 80 MSEK, tillgångar
&gt; 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys,
komponentavskrivning på materiella anläggningstillgångar och redovisning av
uppskjuten skatt på obeskattade reserver (79,4 % eget kapital / 20,6 % skuld).
</p>
</div>
<Dialog
open={pending !== null}
onOpenChange={(open) => {
if (!open) setPending(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Byta till K3?</DialogTitle>
<DialogDescription className="space-y-2 pt-2">
<span className="block">
K3 medför löpande att kassaflödesanalys upprättas, komponentavskrivning
används och uppskjuten skatt redovisas separat (konto 2240 / 8940).
</span>
<span className="block">
Bytet är permanent enligt rekommendation. Fortsätt?
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setPending(null)}
disabled={saving}
>
Avbryt
</Button>
<Button
onClick={() => {
if (!pending) return
setSelected(pending)
void persist(pending)
}}
disabled={saving}
>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sparar…
</>
) : (
'Byt till K3'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
)
}
+123 -1
View File
@@ -2,6 +2,7 @@
import { useTranslations } from 'next-intl'
import { useState, useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
@@ -18,6 +19,15 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
const { toast } = useToast()
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
const [reminderFeeAmount, setReminderFeeAmount] = useState(
String(settings.reminder_fee_amount ?? 60),
)
// Display override as a percentage (the DB stores a decimal). Empty = no override.
const [interestRatePercent, setInterestRatePercent] = useState(
settings.reminder_interest_rate_override != null
? String(settings.reminder_interest_rate_override * 100)
: '',
)
const saveToggle = useCallback(async (field: string, value: boolean) => {
try {
@@ -61,6 +71,62 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
}
}, [onUpdate, toast, t])
const saveReminderFeeAmount = useCallback(async (raw: string) => {
const parsed = parseFloat(raw.replace(',', '.'))
if (Number.isNaN(parsed) || parsed < 0) {
toast({ title: 'Ogiltigt belopp', variant: 'destructive' })
return
}
// Lag 1981:739: maxgräns 60 kr för lagstadgad påminnelseavgift.
const clamped = Math.min(parsed, 60)
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reminder_fee_amount: clamped }),
})
if (!response.ok) throw new Error()
onUpdate({ reminder_fee_amount: clamped })
setReminderFeeAmount(String(clamped))
} catch {
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast, t])
const saveInterestOverride = useCallback(async (raw: string) => {
if (raw.trim() === '') {
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reminder_interest_rate_override: null }),
})
if (!response.ok) throw new Error()
onUpdate({ reminder_interest_rate_override: null })
} catch {
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
return
}
const percent = parseFloat(raw.replace(',', '.'))
if (Number.isNaN(percent) || percent < 0 || percent >= 100) {
toast({ title: 'Ogiltig räntesats (0–99%)', variant: 'destructive' })
return
}
const decimal = Math.round((percent / 100) * 10_000) / 10_000
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reminder_interest_rate_override: decimal }),
})
if (!response.ok) throw new Error()
onUpdate({ reminder_interest_rate_override: decimal })
} catch {
toast({ title: t('toast_save_failed'), variant: 'destructive' })
}
}, [onUpdate, toast, t])
return (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
@@ -118,7 +184,7 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
<p className="text-xs text-muted-foreground">{t('show_swish_help')}</p>
</div>
<Switch
checked={settings.invoice_show_swish ?? true}
checked={settings.invoice_show_swish ?? false}
onCheckedChange={(v) => saveToggle('invoice_show_swish', v)}
/>
</div>
@@ -221,6 +287,62 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
onCheckedChange={(v) => saveToggle('send_invoice_reminders', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Aktivera påminnelseavgift</Label>
<p className="text-xs text-muted-foreground">
Debitera lagstadgad påminnelseavgift (Lag 1981:739, max 60 kr) på varje påminnelse.
Avgiften bokförs automatiskt (1510 / 3990) och adderas till kundens fordran.
</p>
</div>
<Switch
checked={settings.reminder_fee_enabled ?? true}
onCheckedChange={(v) => saveToggle('reminder_fee_enabled', v)}
/>
</div>
{(settings.reminder_fee_enabled ?? true) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pl-0">
<div className="space-y-2">
<Label htmlFor="reminder_fee_amount">Påminnelseavgift (kr)</Label>
<Input
id="reminder_fee_amount"
type="number"
min={0}
max={60}
step={1}
value={reminderFeeAmount}
onChange={(e) => setReminderFeeAmount(e.target.value)}
onBlur={() => saveReminderFeeAmount(reminderFeeAmount)}
/>
<p className="text-xs text-muted-foreground">
Standardvärde 60 kr. Maxgräns enligt Lag 1981:739.
</p>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="reminder_interest_rate_override">
Räntesats för dröjsmålsränta (% per år)
</Label>
<Input
id="reminder_interest_rate_override"
type="number"
min={0}
max={99}
step={0.1}
placeholder="Lämna tom för Räntelagen §6 (referensränta + 8 procentenheter)"
value={interestRatePercent}
onChange={(e) => setInterestRatePercent(e.target.value)}
onBlur={() => saveInterestOverride(interestRatePercent)}
/>
<p className="text-xs text-muted-foreground">
Om tom används lagstadgad dröjsmålsränta (Räntelagen §6 = Riksbankens
referensränta + 8 procentenheter). Räntan visas i påminnelsen men bokförs inte.
</p>
</div>
</div>
</section>
)
@@ -0,0 +1,108 @@
'use client'
import { useCallback, useSyncExternalStore } from 'react'
import Link from 'next/link'
import { ExternalLink } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
/**
* Per-user toggle for the periodisering wizard's auto-detection step.
*
* Backed by localStorage (key: `periodisering_autodetect_enabled`) because
* the company_settings table does not yet have a dedicated column for this
* preference, and the task description explicitly allows the persistence to
* be UI-local. A future migration can promote this to a real
* `company_settings.periodisering_autodetect_enabled boolean` column and
* the wizard's auto-detect step will read either source.
*
* Default: enabled. The wizard's auto-detect step renders regardless — the
* toggle merely controls whether the GET response includes `autoDetected`
* on subsequent fetches. (Today the API always returns it; the wizard step
* can early-out based on this setting locally.)
*/
const STORAGE_KEY = 'periodisering_autodetect_enabled'
function readStored(): boolean {
if (typeof window === 'undefined') return true
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
return stored === null ? true : stored !== 'false'
} catch {
return true
}
}
/** Subscribe to localStorage changes from OTHER tabs. Same-tab updates are
* picked up via the explicit re-render after `setItem` — see
* `notifyChange` below. */
function subscribe(callback: () => void): () => void {
if (typeof window === 'undefined') return () => {}
const handler = (e: StorageEvent) => {
if (e.key === STORAGE_KEY || e.key === null) callback()
}
const customHandler = () => callback()
window.addEventListener('storage', handler)
window.addEventListener('gnubok-periodisering-toggle', customHandler)
return () => {
window.removeEventListener('storage', handler)
window.removeEventListener('gnubok-periodisering-toggle', customHandler)
}
}
/** Fire a same-tab notification so useSyncExternalStore re-subscribers
* see the change without a manual setState. */
function notifyChange() {
if (typeof window === 'undefined') return
window.dispatchEvent(new Event('gnubok-periodisering-toggle'))
}
export function PeriodiseringAutoDetectToggle() {
const enabled = useSyncExternalStore(
subscribe,
readStored,
// Server snapshot: default to enabled. Matches the client default so
// hydration is identical.
() => true,
)
const handleChange = useCallback((value: boolean) => {
try {
window.localStorage.setItem(STORAGE_KEY, String(value))
} catch {
// No-op; if storage is blocked the toggle simply won't persist.
}
notifyChange()
}, [])
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Periodisering
</h2>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="periodisering-autodetect" className="text-sm">
Aktivera automatisk periodiseringsdetektering
</Label>
<p className="text-xs text-muted-foreground max-w-md">
Skannar fakturor i bokslutet efter datumintervall som sträcker sig
in i nästa räkenskapsår och föreslår periodiseringar i bokslut-wizarden.
</p>
</div>
<Switch
id="periodisering-autodetect"
checked={enabled}
onCheckedChange={handleChange}
/>
</div>
<Link
href="/bookkeeping/year-end/periodisering"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Öppna periodiserings-wizarden
</Link>
</section>
)
}
+17
View File
@@ -88,6 +88,15 @@ export function SecuritySettings() {
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
// Supabase rejects updateUser({password}) with this exact message when
// the user has a TOTP factor enrolled but is at AAL1. Send them through
// /mfa/verify to step up; on return they land back here and can retry.
if (body.error?.includes('AAL2')) {
router.push(
`/mfa/verify?returnTo=${encodeURIComponent('/settings/account')}`,
)
return
}
toast({
title: t('toast_update_failed_title'),
description: body.error || t('toast_update_failed_description'),
@@ -122,6 +131,14 @@ export function SecuritySettings() {
const { error } = await supabase.auth.mfa.unenroll({ factorId: mfaFactorId })
if (error) {
// mfa.unenroll requires AAL2 — for BankID-linked users at AAL1 (the
// shouldEnforceMfa skip path), this is the only way to step up.
if (error.message?.includes('AAL2')) {
router.push(
`/mfa/verify?returnTo=${encodeURIComponent('/settings/account')}`,
)
return
}
toast({
title: t('toast_unenroll_failed_title'),
description: error.message,
+1
View File
@@ -35,6 +35,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
{ href: '/settings/skatteverket', label: t('skatteverket'), show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: t('salary'), show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: t('templates'), show: hasCompany },
{ href: '/settings/approval-rules', label: t('approval_rules'), show: hasCompany },
{ href: '/settings/account', label: t('account'), show: true },
{ href: '/settings/api', label: t('api'), show: hasCompany && hasMcpExtension },
].filter(item => item.show)
@@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { CompanySettings, JournalEntrySourceType } from '@/types'
const SERIES_OPTIONS = 'ABCDEFG'.split('')
// Subset of source_types presented to the user. The DB column accepts every
// JournalEntrySourceType, but several values (storno, correction, etc.) are
// derived from the original entry's series and would surprise the user if
// surfaced as configurable. We expose only the user-relevant subset; the
// engine still falls back to 'A' for the keys we hide.
const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: string }> = [
{ key: 'manual', labelKey: 'manual' },
{ key: 'invoice_created', labelKey: 'invoice_created' },
{ key: 'invoice_paid', labelKey: 'invoice_paid' },
{ key: 'supplier_invoice_registered', labelKey: 'supplier_invoice_registered' },
{ key: 'supplier_invoice_paid', labelKey: 'supplier_invoice_paid' },
{ key: 'salary_payment', labelKey: 'salary_payment' },
{ key: 'bank_transaction', labelKey: 'bank_transaction' },
{ key: 'reminder_fee', labelKey: 'reminder_fee' },
{ key: 'opening_balance', labelKey: 'opening_balance' },
{ key: 'year_end', labelKey: 'year_end' },
]
// 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).
const SV_LABELS: Record<string, string> = {
manual: 'Manuella verifikat',
invoice_created: 'Kundfakturor (skapande)',
invoice_paid: 'Kundfakturor (betalning)',
supplier_invoice_registered: 'Leverantörsfakturor (registrering)',
supplier_invoice_paid: 'Leverantörsfakturor (betalning)',
salary_payment: 'Lön',
bank_transaction: 'Banktransaktioner',
reminder_fee: 'Påminnelseavgifter',
opening_balance: 'Ingående balanser',
year_end: 'Bokslut',
}
interface Props {
settings: CompanySettings
onSettingsUpdated: (settings: Partial<CompanySettings>) => void
}
export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }: Props) {
const { toast } = useToast()
const initialMap = settings.default_voucher_series_per_source_type || {}
const [draft, setDraft] = useState<Partial<Record<JournalEntrySourceType, string>>>(
initialMap as Partial<Record<JournalEntrySourceType, string>>,
)
const [isSaving, setIsSaving] = useState(false)
const handleChange = (sourceType: JournalEntrySourceType, value: string) => {
setDraft((prev) => ({ ...prev, [sourceType]: value }))
}
const hasChanges =
JSON.stringify(draft) !== JSON.stringify(initialMap)
const handleSave = async () => {
setIsSaving(true)
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
default_voucher_series_per_source_type: draft,
}),
})
const json = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte spara',
description: getErrorMessage(json, { context: 'settings', statusCode: res.status }),
variant: 'destructive',
})
return
}
onSettingsUpdated({
default_voucher_series_per_source_type: draft as Record<JournalEntrySourceType, string>,
})
toast({
title: 'Verifikationsserier sparade',
description: 'Nya verifikat använder de uppdaterade serierna.',
})
} catch (err) {
toast({
title: 'Kunde inte spara',
description: getErrorMessage(err, { context: 'settings' }),
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
}
return (
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Verifikationsserier per typ
</h2>
<p className="text-xs text-muted-foreground">
Tilldela en standardserie per typ av verifikat. Vanlig svensk
praxis: leverantörsfakturor på serie B, löner på serie C, övrigt på
serie A. Kan alltid ändras per verifikat när du bokför.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{VISIBLE_SOURCE_TYPES.map(({ key, labelKey }) => (
<div key={key} className="flex items-center justify-between gap-3">
<Label
htmlFor={`series-${key}`}
className="text-sm text-foreground flex-1 cursor-pointer"
>
{SV_LABELS[labelKey] ?? key}
</Label>
<Select
value={(draft[key] as string | undefined) || 'A'}
onValueChange={(v) => handleChange(key, v)}
>
<SelectTrigger
id={`series-${key}`}
className="w-16 font-mono"
aria-label={SV_LABELS[labelKey] ?? key}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SERIES_OPTIONS.map((letter) => (
<SelectItem key={letter} value={letter} className="font-mono">
{letter}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
<div className="flex justify-end pt-2">
<Button
type="button"
onClick={handleSave}
disabled={!hasChanges || isSaving}
>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara serier
</Button>
</div>
</section>
)
}