8f38baca05
* fix(assets): block Ej K2 accounts for K2 companies and fix immaterial defaults K2 companies (BFNAR 2016:10 punkt 10.4) may not capitalize internally developed intangibles, but the asset register defaulted the immaterial category onto 1010/1019 (Utvecklingsutgifter) for everyone and had no framework gate beyond K3_REQUIRED_FOR_COMPONENTS. - New K2_EXCLUDED_ACCOUNT gate (422) in POST /api/assets and PATCH /api/assets/[id]: when accounting_framework is not k3, reject any asset whose resolved asset or accumulated account is flagged k2_excluded in the BAS reference. Resolution mirrors the service defaults so category defaults cannot sneak onto 1010/1019; patches that leave category and accounts untouched skip the gate so legacy assets stay editable. - Shared guard helper in lib/bokslut/assets/k2-account-guard.ts; code registered in structured-errors.ts with Swedish and English messages. - CreateAssetDialog: non K3 companies now book immaterial assets on the purchased pair 1090/1099 with a quiet hint that egenupparbetad utveckling requires K3; K3 companies picking immaterial see a note about fond for utvecklingsutgifter (2089) per ARL 4 kap. 2 par. - Route tests: K2 rejected on 1010 defaults and explicit overrides, K2 accepted on purchased accounts, K3 accepted on 1010, PATCH equivalents and a gate skip regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): cite punkt 10.4 only when the intangible group triggered the K2 gate The K2 gate fires on ANY account the BAS chart flags k2_excluded, but the rejection hardcoded an egenupparbetade immateriella / BFNAR 2016:10 punkt 10.4 citation. The flag also covers accounts excluded from K2 for unrelated reasons (1370/2240/8940 uppskjuten skatt, 1518, 2089, 2092, 2096, 2448, 3940, 7940, 8290 to 8480), so those users got a factually wrong legal citation in a compliance product. PATCH can reach them today: UpdateAssetSchema has no BAS range refinement, so an explicit bas_asset_account override outside the category range hits the gate before updateAsset() raises its range error. - k2ExcludedAccountMessages() now picks the wording from what actually triggered the gate. The boundary is derived from the chart itself (k2_excluded + account_class 1 + kontogrupp 10), which is exactly the egenupparbetade set 1010, 1011, 1012, 1018, 1019, 1081; no magic list, so a flag change in bas-data moves the boundary with it. Other Ej K2 accounts get a generic message: the chart marks it Ej K2 and it requires K3, with no invented paragraph reference. - Both messages are bilingual (message_sv / message_en, registry shape) and the routes now return message_en alongside message. - The static K2_EXCLUDED_ACCOUNT registry entry drops the intangible citation too: it is the code level fallback for every k2_excluded account. - Tests: route level distinction pinned in id.test.ts (1010/1081 cite 10.4, 1370 must not), plus a guard unit test asserting the derived group and that no non group 10 Ej K2 account ever cites 10.4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): let K2 companies register acquired intangibles, server side The K2 gate blocked a lawful case. K2 forbids only EGENUPPARBETADE immateriella tillgangar; acquired ones may be recognized (k2-vs-k3.md:24, "Only acquired intangibles may be recognized"). But asset-service still resolved category 'immaterial' to 1010/1019 for everyone, and only CreateAssetDialog compensated with an explicit 1090/1099 override. EditAssetDialog sends just the changed fields and has no account inputs, so a K2 aktiebolag recategorizing a bought licence to "Immateriell tillgang" hit the defaults, got a 422, and was told to switch the company to K3, which would pull in komponentavskrivning and uppskjuten skatt and rewrite the whole arsredovisning. The asset stayed on 1220/1229 and kept being presented as a tangible asset. - defaultAccountsForCategory(category, framework) is the single resolution point: immaterial resolves to the acquired pair 1090/1099 unless the framework is k3, every other category is unchanged. Both createAsset() and updateAsset()'s category realign go through resolveDefaultAccounts(), which reads companies.accounting_framework only for the intangible category and throws rather than guessing when that read fails. Explicit overrides and the realign-skip semantics are untouched. - Both routes resolve gate accounts through the same function, so the check mirrors what the service will persist. A K2 company on the defaults now passes; a deliberate override onto 1010/1011/1012/1018/1019/1081 still 422s. - CreateAssetDialog drops its now redundant client override so the two surfaces cannot drift; the hint text stays. - The 422 no longer asserts the company's framework (the companies read behind it discards its error, so a transient failure would assert it against a K3 company) and no longer proposes a regelverk change. It states that the account is reserved for egenupparbetade utvecklingsutgifter, which require K3, and points at 1090 for an acquired intangible. Punkt 10.4 stays scoped to the kontogrupp 10 group, derived from the chart as before. sv and en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
497 lines
19 KiB
TypeScript
497 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState } from 'react'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Loader2, Plus, X } from 'lucide-react'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
|
import { formatCurrency } from '@/lib/utils'
|
|
import type { AssetCategory, K3Component } from '@/types'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
interface CreateAssetDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onCreated: () => void
|
|
}
|
|
|
|
/** Editor row state: strings so the user can clear inputs without zeroing
|
|
* out the component immediately. Converted to numbers at submit time. */
|
|
interface ComponentRow {
|
|
id: string
|
|
name: string
|
|
cost: string
|
|
useful_life_months: string
|
|
salvage_value: string
|
|
}
|
|
|
|
let componentRowCounter = 0
|
|
function newComponentRow(): ComponentRow {
|
|
componentRowCounter += 1
|
|
return {
|
|
id: `cmp-${componentRowCounter}`,
|
|
name: '',
|
|
cost: '',
|
|
useful_life_months: '',
|
|
salvage_value: '',
|
|
}
|
|
}
|
|
|
|
// Defaults are K2-redovisning (BFNAR 2016:10) schablon, NOT skattemässig
|
|
// avskrivning. Building / markanläggning values are conservative: IL 19/20
|
|
// kap may allow longer (50 yr) or shorter (10 yr) depending on byggnadstyp.
|
|
const CATEGORY_OPTIONS: { value: AssetCategory; label: string; defaultYears: number }[] = [
|
|
{ value: 'computer', label: 'Dator / IT-utrustning', defaultYears: 3 },
|
|
{ value: 'equipment', label: 'Inventarier', defaultYears: 5 },
|
|
{ value: 'machinery', label: 'Maskiner', defaultYears: 10 },
|
|
{ value: 'vehicle', label: 'Fordon', defaultYears: 5 },
|
|
{ value: 'building', label: 'Byggnad', defaultYears: 25 },
|
|
{ value: 'land_improvement', label: 'Markanläggning', defaultYears: 10 },
|
|
{ value: 'immaterial', label: 'Immateriell tillgång', defaultYears: 5 },
|
|
{ value: 'other_tangible', label: 'Övrig materiell tillgång', defaultYears: 5 },
|
|
]
|
|
|
|
// No account override here on purpose. The server resolves the immaterial
|
|
// default per framework (defaultAccountsForCategory in
|
|
// lib/bokslut/assets/asset-service.ts): 1090/1099 for K2, 1010/1019 for K3.
|
|
// Sending an explicit pair from this dialog would duplicate that rule on a
|
|
// second surface, and the edit dialog (which has no account inputs at all)
|
|
// could never mirror it. The hint below just tells the user where it lands.
|
|
|
|
export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAssetDialogProps) {
|
|
const { toast } = useToast()
|
|
// useCompanyOptional so the dialog still works in tests / storyboards
|
|
// that don't wrap it in CompanyProvider. K3 features simply hide.
|
|
const companyCtx = useCompanyOptional()
|
|
const isK3 = companyCtx?.company?.accounting_framework === 'k3'
|
|
|
|
const [name, setName] = useState('')
|
|
const [category, setCategory] = useState<AssetCategory>('equipment')
|
|
const [acquisitionDate, setAcquisitionDate] = useState(
|
|
new Date().toISOString().split('T')[0],
|
|
)
|
|
const [acquisitionCost, setAcquisitionCost] = useState('')
|
|
const [usefulLifeYears, setUsefulLifeYears] = useState('5')
|
|
// K3 component depreciation. `useComponents` toggles the advanced section;
|
|
// null when disabled, an array (possibly empty during editing) when enabled.
|
|
const [useComponents, setUseComponents] = useState(false)
|
|
const [componentRows, setComponentRows] = useState<ComponentRow[]>([])
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const handleCategoryChange = (next: AssetCategory) => {
|
|
setCategory(next)
|
|
const option = CATEGORY_OPTIONS.find((o) => o.value === next)
|
|
if (option) setUsefulLifeYears(option.defaultYears.toString())
|
|
}
|
|
|
|
const totalComponentCost = useMemo(() => {
|
|
return componentRows.reduce((sum, row) => {
|
|
const v = parseFloat(row.cost)
|
|
return Number.isFinite(v) ? sum + v : sum
|
|
}, 0)
|
|
}, [componentRows])
|
|
|
|
const parsedAcquisitionCost = parseFloat(acquisitionCost)
|
|
const componentMismatch =
|
|
useComponents
|
|
&& componentRows.length > 0
|
|
&& Number.isFinite(parsedAcquisitionCost)
|
|
&& Math.abs(totalComponentCost - parsedAcquisitionCost) > 1
|
|
|
|
const addComponentRow = () => {
|
|
setComponentRows((rows) => [...rows, newComponentRow()])
|
|
}
|
|
const removeComponentRow = (id: string) => {
|
|
setComponentRows((rows) => rows.filter((r) => r.id !== id))
|
|
}
|
|
const updateComponentRow = (id: string, patch: Partial<ComponentRow>) => {
|
|
setComponentRows((rows) => rows.map((r) => (r.id === id ? { ...r, ...patch } : r)))
|
|
}
|
|
const toggleUseComponents = (next: boolean) => {
|
|
setUseComponents(next)
|
|
if (next && componentRows.length === 0) {
|
|
setComponentRows([newComponentRow()])
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
setError(null)
|
|
const cost = parseFloat(acquisitionCost)
|
|
const years = parseInt(usefulLifeYears, 10)
|
|
if (!name.trim() || !Number.isFinite(cost) || cost <= 0 || !Number.isFinite(years) || years <= 0) {
|
|
setError('Fyll i namn, anskaffningsvärde och avskrivningstid.')
|
|
return
|
|
}
|
|
// K3 components: only when both the framework permits (gate at API)
|
|
// and the user opted into the section. Empty array is invalid (the
|
|
// validator rejects it) so the dialog also flips back to "off" when
|
|
// every row is removed.
|
|
let componentsPayload: K3Component[] | null = null
|
|
if (useComponents && isK3) {
|
|
if (componentRows.length === 0) {
|
|
setError('Lägg till minst en komponent eller stäng av komponentuppdelningen.')
|
|
return
|
|
}
|
|
const parsed: K3Component[] = []
|
|
for (const [index, row] of componentRows.entries()) {
|
|
const componentCost = parseFloat(row.cost)
|
|
const months = parseInt(row.useful_life_months, 10)
|
|
const salvageRaw = row.salvage_value.trim()
|
|
const salvage = salvageRaw === '' ? undefined : parseFloat(salvageRaw)
|
|
const trimmedName = row.name.trim()
|
|
if (!trimmedName) {
|
|
setError(`Komponent ${index + 1}: ange ett namn.`)
|
|
return
|
|
}
|
|
if (!Number.isFinite(componentCost) || componentCost <= 0) {
|
|
setError(`${trimmedName}: anskaffningsvärdet måste vara större än 0.`)
|
|
return
|
|
}
|
|
if (!Number.isFinite(months) || months <= 0) {
|
|
setError(`${trimmedName}: ange ett positivt heltal månader.`)
|
|
return
|
|
}
|
|
if (salvage !== undefined && (!Number.isFinite(salvage) || salvage < 0)) {
|
|
setError(`${trimmedName}: restvärdet får inte vara negativt.`)
|
|
return
|
|
}
|
|
if (salvage !== undefined && salvage > componentCost) {
|
|
setError(`${trimmedName}: restvärdet får inte överstiga anskaffningsvärdet.`)
|
|
return
|
|
}
|
|
parsed.push({
|
|
name: trimmedName,
|
|
cost: componentCost,
|
|
useful_life_months: months,
|
|
...(salvage !== undefined ? { salvage_value: salvage } : {}),
|
|
})
|
|
}
|
|
const sum = parsed.reduce((s, c) => s + c.cost, 0)
|
|
if (Math.abs(sum - cost) > 1) {
|
|
setError(
|
|
`Komponenter summerar till ${formatCurrency(sum)} men anskaffningsvärdet är ${formatCurrency(cost)}.`,
|
|
)
|
|
return
|
|
}
|
|
componentsPayload = parsed
|
|
}
|
|
|
|
setSubmitting(true)
|
|
try {
|
|
const res = await fetch('/api/assets', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: name.trim(),
|
|
category,
|
|
acquisition_date: acquisitionDate,
|
|
acquisition_cost: cost,
|
|
useful_life_months: years * 12,
|
|
...(componentsPayload !== null ? { k3_components: componentsPayload } : {}),
|
|
}),
|
|
})
|
|
const body = await res.json()
|
|
if (!res.ok) {
|
|
setError(getUserErrorMessage(body?.error) ?? 'Kunde inte spara tillgången')
|
|
return
|
|
}
|
|
toast({ title: 'Tillgång sparad', description: name.trim() })
|
|
// Reset form for next entry
|
|
setName('')
|
|
setAcquisitionCost('')
|
|
setUseComponents(false)
|
|
setComponentRows([])
|
|
onCreated()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? getUserErrorMessage(err) : 'Okänt fel')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className={isK3 ? 'sm:max-w-2xl' : 'sm:max-w-md'}>
|
|
<DialogHeader>
|
|
<DialogTitle>Ny anläggningstillgång</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="asset-name">Namn</Label>
|
|
<Input
|
|
id="asset-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="t.ex. MacBook Pro 14"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="asset-category">Kategori</Label>
|
|
<Select value={category} onValueChange={(v) => handleCategoryChange(v as AssetCategory)}>
|
|
<SelectTrigger id="asset-category">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CATEGORY_OPTIONS.map((o) => (
|
|
<SelectItem key={o.value} value={o.value}>
|
|
{o.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{category === 'immaterial' && !isK3 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Bokförs som förvärvad immateriell tillgång (konto 1090). Egenupparbetad
|
|
utveckling får inte aktiveras enligt K2 (BFNAR 2016:10 punkt 10.4): det kräver K3.
|
|
</p>
|
|
)}
|
|
{category === 'immaterial' && isK3 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
För aktiebolag medför aktivering av egenupparbetad utveckling (konto 1010) att
|
|
motsvarande belopp sätts av till fond för utvecklingsutgifter (konto 2089) enligt
|
|
ÅRL 4 kap. 2 §.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="asset-date">Anskaffat</Label>
|
|
<Input
|
|
id="asset-date"
|
|
type="date"
|
|
value={acquisitionDate}
|
|
onChange={(e) => setAcquisitionDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="asset-cost">Anskaffningsvärde (kr)</Label>
|
|
<Input
|
|
id="asset-cost"
|
|
type="number"
|
|
step="1"
|
|
min="0"
|
|
value={acquisitionCost}
|
|
onChange={(e) => setAcquisitionCost(e.target.value)}
|
|
placeholder="t.ex. 25000"
|
|
className="tabular-nums"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="asset-life">Avskrivningstid (år)</Label>
|
|
<Input
|
|
id="asset-life"
|
|
type="number"
|
|
min="1"
|
|
max="50"
|
|
step="1"
|
|
value={usefulLifeYears}
|
|
onChange={(e) => setUsefulLifeYears(e.target.value)}
|
|
className="tabular-nums"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
K2-schablon för redovisning: datorer 3 år, inventarier 5 år, byggnader 25 år.
|
|
För skattemässig avskrivning kan annan livslängd gälla (IL 18-20 kap).
|
|
</p>
|
|
</div>
|
|
{isK3 && (
|
|
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<Label className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
|
Avancerat: komponentuppdelning
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
K3 (BFNAR 2012:1 17.4): när väsentliga komponenter har olika nyttjandeperiod
|
|
skrivs varje komponent av för sig. Typisk för fastigheter (tak, fasad, stomme,
|
|
installationer).
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant={useComponents ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => toggleUseComponents(!useComponents)}
|
|
>
|
|
{useComponents ? 'Aktiverad' : 'Aktivera'}
|
|
</Button>
|
|
</div>
|
|
|
|
{useComponents && (
|
|
<div className="space-y-2">
|
|
{componentRows.map((row, idx) => (
|
|
<div
|
|
key={row.id}
|
|
className="grid grid-cols-12 items-end gap-2 rounded-md border border-border bg-background p-2"
|
|
>
|
|
<div className="col-span-12 sm:col-span-4 space-y-1">
|
|
<Label
|
|
htmlFor={`cmp-name-${row.id}`}
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Komponent
|
|
</Label>
|
|
<Input
|
|
id={`cmp-name-${row.id}`}
|
|
value={row.name}
|
|
onChange={(e) =>
|
|
updateComponentRow(row.id, { name: e.target.value })
|
|
}
|
|
placeholder={idx === 0 ? 't.ex. Stomme' : 'Namn'}
|
|
/>
|
|
</div>
|
|
<div className="col-span-6 sm:col-span-3 space-y-1">
|
|
<Label
|
|
htmlFor={`cmp-cost-${row.id}`}
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Kostnad (kr)
|
|
</Label>
|
|
<Input
|
|
id={`cmp-cost-${row.id}`}
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={row.cost}
|
|
onChange={(e) =>
|
|
updateComponentRow(row.id, { cost: e.target.value })
|
|
}
|
|
className="tabular-nums"
|
|
/>
|
|
</div>
|
|
<div className="col-span-6 sm:col-span-2 space-y-1">
|
|
<Label
|
|
htmlFor={`cmp-life-${row.id}`}
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Liv (mån)
|
|
</Label>
|
|
<Input
|
|
id={`cmp-life-${row.id}`}
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={row.useful_life_months}
|
|
onChange={(e) =>
|
|
updateComponentRow(row.id, { useful_life_months: e.target.value })
|
|
}
|
|
className="tabular-nums"
|
|
/>
|
|
</div>
|
|
<div className="col-span-9 sm:col-span-2 space-y-1">
|
|
<Label
|
|
htmlFor={`cmp-salvage-${row.id}`}
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
Restvärde
|
|
</Label>
|
|
<Input
|
|
id={`cmp-salvage-${row.id}`}
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={row.salvage_value}
|
|
onChange={(e) =>
|
|
updateComponentRow(row.id, { salvage_value: e.target.value })
|
|
}
|
|
placeholder="0"
|
|
className="tabular-nums"
|
|
/>
|
|
</div>
|
|
<div className="col-span-3 sm:col-span-1 flex justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeComponentRow(row.id)}
|
|
aria-label="Ta bort komponent"
|
|
disabled={componentRows.length === 1}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addComponentRow}
|
|
>
|
|
<Plus className="mr-1 h-4 w-4" /> Lägg till komponent
|
|
</Button>
|
|
<div className="text-xs tabular-nums text-muted-foreground">
|
|
Summa komponenter:{' '}
|
|
<span
|
|
className={
|
|
componentMismatch
|
|
? 'text-destructive font-medium'
|
|
: 'text-foreground'
|
|
}
|
|
>
|
|
{formatCurrency(totalComponentCost)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{componentMismatch && (
|
|
<p className="text-xs text-destructive">
|
|
Komponenter summerar inte till anskaffningsvärdet (
|
|
{formatCurrency(parsedAcquisitionCost)}).
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
|
<strong className="text-foreground">Tips:</strong> Anskaffningen måste redan vara
|
|
bokförd (debet på 1xxx-kontot mot t.ex. 1930/2440): registret bokför inte
|
|
själva köpet. Det här registret styr enbart de planenliga avskrivningarna under
|
|
bokslutet.
|
|
</div>
|
|
{error && (
|
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
|
Avbryt
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={submitting}>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sparar…
|
|
</>
|
|
) : (
|
|
'Spara'
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|