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:
co-authored by
Claude Opus 4.7
parent
0087b7be3f
commit
32d9978f1b
@@ -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 > 80 MSEK, tillgångar
|
||||
> 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user