'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 }, ] 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('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([]) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(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) => { 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 ( Ny anläggningstillgång
setName(e.target.value)} placeholder="t.ex. MacBook Pro 14" autoFocus />
setAcquisitionDate(e.target.value)} />
setAcquisitionCost(e.target.value)} placeholder="t.ex. 25000" className="tabular-nums" />
setUsefulLifeYears(e.target.value)} className="tabular-nums" />

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

{isK3 && (

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

{useComponents && (
{componentRows.map((row, idx) => (
updateComponentRow(row.id, { name: e.target.value }) } placeholder={idx === 0 ? 't.ex. Stomme' : 'Namn'} />
updateComponentRow(row.id, { cost: e.target.value }) } className="tabular-nums" />
updateComponentRow(row.id, { useful_life_months: e.target.value }) } className="tabular-nums" />
updateComponentRow(row.id, { salvage_value: e.target.value }) } placeholder="0" className="tabular-nums" />
))}
Summa komponenter:{' '} {formatCurrency(totalComponentCost)}
{componentMismatch && (

Komponenter summerar inte till anskaffningsvärdet ( {formatCurrency(parsedAcquisitionCost)}).

)}
)}
)}
Tips: 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.
{error && (
{error}
)}
) }