'use client' import { useEffect, useState } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Loader2, AlertTriangle } from 'lucide-react' import { isStandardBASAccountNumber } from '@/lib/bookkeeping/bas-account-numbers' import { classifyAccountClient as classifyAccount } from '@/lib/bookkeeping/account-classifier-client' import { useBasReference } from '@/lib/bookkeeping/use-bas-reference' import type { BASAccount } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect' import { defaultRateForVatTreatment, isVatTreatmentAllowedForAccountClass, type AccountVatTreatment, } from '@/lib/vat/account-vat-treatment' /** * The create path hands back the full row the API inserted. The reactivate * path only learns the account number back from /accounts/activate, and the * stored account is deliberately left untouched, so the rest is unknown here. * Every host refetches its own list and reads only account_number. */ type CreatedAccount = Partial & { account_number: string } interface AddAccountDialogProps { open: boolean onOpenChange: (open: boolean) => void onCreated: (account: CreatedAccount) => void initialAccountNumber?: string initialAccountName?: string } export function AddAccountDialog({ open, onOpenChange, onCreated, initialAccountNumber, initialAccountName, }: AddAccountDialogProps) { // Loads the BAS chart chunk after mount so classification and the // standard-account check get the authoritative answer once it lands. useBasReference() const [accountNumber, setAccountNumber] = useState('') const [accountName, setAccountName] = useState('') const [description, setDescription] = useState('') // "Standard moms": the moms-sats a booking line defaults to when this konto is // picked. 'none' = no default. SelectItem values are stringified decimals. const [defaultVatRate, setDefaultVatRate] = useState('none') const [defaultVatTreatment, setDefaultVatTreatment] = useState('none') const [sruCode, setSruCode] = useState('') const [normalBalance, setNormalBalance] = useState<'debit' | 'credit'>('debit') const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState('') // Set when the create failed because the number belongs to a deactivated // account. Creating it can never succeed (the unique constraint counts // inactive rows), so the dialog offers reactivation instead of a dead end. const [inactiveConflict, setInactiveConflict] = useState(false) // Apply prefill values whenever the dialog opens. Resetting on close happens // implicitly after a successful create; here we only need to seed inputs so // the user doesn't retype what the combobox already captured. useEffect(() => { if (!open) return const num = (initialAccountNumber ?? '').replace(/\D/g, '').slice(0, 4) setAccountNumber(num) setAccountName(initialAccountName ?? '') setDefaultVatRate('none') setDefaultVatTreatment('none') setError('') setInactiveConflict(false) if (num.length === 4) { setNormalBalance(classifyAccount(num).normal_balance) } }, [open, initialAccountNumber, initialAccountName]) const isBASMatch = accountNumber.length === 4 && isStandardBASAccountNumber(accountNumber) const derived = accountNumber.length === 4 ? classifyAccount(accountNumber) : null async function handleCreate() { setError('') setInactiveConflict(false) if (!/^\d{4}$/.test(accountNumber)) { setError('Kontonumret måste vara exakt 4 siffror') return } if (!accountName.trim()) { setError('Kontonamn krävs') return } setIsSaving(true) try { const response = await fetch('/api/bookkeeping/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account_number: accountNumber, account_name: accountName.trim(), account_type: derived?.account_type || 'expense', normal_balance: normalBalance, description: description || null, default_vat_rate: defaultVatRate === 'none' ? null : parseFloat(defaultVatRate), default_vat_treatment: defaultVatTreatment === 'none' ? null : defaultVatTreatment, sru_code: sruCode || null, }), }) if (!response.ok) { // Map the response itself, not `new Error(data.error)`: the route // answers thrown errors with the canonical envelope // `{ error: { code, message } }`, and the Error constructor would // stringify that object to "[object Object]", throwing away the // route's own Swedish reason. Passing the parsed body plus the status // resolves all three shapes (envelope, bare string, no body). const body = await response.json().catch(() => null) const code = (body as { error?: { code?: string } } | null)?.error?.code setInactiveConflict(code === 'ACCOUNT_EXISTS_INACTIVE') setError(getUserErrorMessage(body, { statusCode: response.status })) return } const { data: createdAccount } = await response.json() as { data: BASAccount } // Reset form setAccountNumber('') setAccountName('') setDescription('') setDefaultVatRate('none') setDefaultVatTreatment('none') setSruCode('') onCreated(createdAccount) onOpenChange(false) } catch (err) { setError(err instanceof Error ? getUserErrorMessage(err) : 'Något gick fel') } finally { setIsSaving(false) } } // Recovery for ACCOUNT_EXISTS_INACTIVE: flip the existing account back on // instead of trying to insert a second row. The values typed into this form // are intentionally dropped — the account comes back exactly as it was, and // renaming it is the kontoplan's job, not a side effect of a failed create. async function handleReactivate() { setError('') setIsSaving(true) try { const response = await fetch('/api/bookkeeping/accounts/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account_numbers: [accountNumber] }), }) if (!response.ok) { const body = await response.json().catch(() => null) setError(getUserErrorMessage(body, { statusCode: response.status })) return } setInactiveConflict(false) setAccountNumber('') setAccountName('') setDescription('') setDefaultVatRate('none') setDefaultVatTreatment('none') setSruCode('') onCreated({ account_number: accountNumber }) onOpenChange(false) } catch (err) { setError(err instanceof Error ? getUserErrorMessage(err) : 'Något gick fel') } finally { setIsSaving(false) } } return ( Lägg till eget konto Skapa ett eget konto utanför BAS-standarden
{isBASMatch && (

Kontonummer {accountNumber} finns i BAS-standarden. Använd "BAS-katalog"-fliken för att aktivera standardkonton istället.

)}
{ const v = e.target.value.replace(/\D/g, '').slice(0, 4) setAccountNumber(v) const nextClass = v.length > 0 ? Number(v[0]) : null if ( defaultVatTreatment !== 'none' && (nextClass === null || !isVatTreatmentAllowedForAccountClass( defaultVatTreatment, nextClass, )) ) { setDefaultVatTreatment('none') } // The conflict is about a specific number; editing it makes // the reactivate offer stale. setInactiveConflict(false) setError('') if (v.length === 4) { setNormalBalance(classifyAccount(v).normal_balance) } }} placeholder="T.ex. 1935" maxLength={4} className="font-mono" />
{derived && (

Auto-detekterad typ:{' '} {derived.account_type === 'asset' ? 'Tillgång' : derived.account_type === 'liability' ? 'Skuld' : derived.account_type === 'equity' ? 'Eget kapital' : derived.account_type === 'untaxed_reserves' ? 'Obeskattade reserver' : derived.account_type === 'revenue' ? 'Intäkt' : 'Kostnad'}

)}
setAccountName(e.target.value)} placeholder="T.ex. Sparkonto företag" />