'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { Loader2 } from 'lucide-react' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } 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 { useToast } from '@/components/ui/use-toast' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { useCompanyOptional } from '@/contexts/CompanyContext' import { useAccounts } from '@/lib/reference-data/hooks' import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency } from '@/lib/utils' import { roundOre } from '@/lib/money' import { ACCOUNT_NUMBER_RE, ISO_DATE_RE } from '@/lib/invariants' import type { InvoiceExtractionResult } from '@/types' /** * Who paid for the underlag out of their own pocket. The owner's liability * account follows the entity type (2893 skuld till ägare in an AB, 2018 egen * insättning in an enskild firma); an employee is always 2820. */ export type ExpensePayer = 'owner' | 'employee' interface InboxItemLike { id: string document_id: string | null extracted_data: InvoiceExtractionResult | null } interface EmployeeOption { id: string first_name: string last_name: string } interface Props { open: boolean onOpenChange: (open: boolean) => void item: InboxItemLike payer: ExpensePayer /** Re-read the item after the claim posted its verifikat. */ onSuccess: () => void | Promise } const OWNER_FALLBACK_NAME = 'Ägare' function todayIso(): string { return new Date().toISOString().slice(0, 10) } function parseAmount(raw: string): number { const n = parseFloat(raw.replace(/\s/g, '').replace(',', '.')) return Number.isFinite(n) ? n : 0 } /** * RegisterExpenseDialog: the confirm step behind "Vem betalade? Jag, privat / * En anställd" in the Underlag pane. One screen, prefilled from the * extraction: who, cost account, amount and VAT, then the outcome spelled out * before anything posts (design convention 10). Posts through * POST /api/expense-claims, which books cost + moms against the person's * liability account and stamps the inbox item as booked. */ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer, onSuccess }: Props) { const t = useTranslations('inbox_workspace') const { toast } = useToast() const { accounts } = useAccounts() const entityType = useCompanyOptional()?.company?.entity_type ?? null const ownerLiability = entityType === 'enskild_firma' ? '2018' : '2893' const liabilityAccount = payer === 'owner' ? ownerLiability : '2820' const data = item.extracted_data const [description, setDescription] = useState('') const [expenseDate, setExpenseDate] = useState(todayIso()) const [amountInput, setAmountInput] = useState('') const [vatInput, setVatInput] = useState('') const [expenseAccount, setExpenseAccount] = useState('') const [ownerName, setOwnerName] = useState('') const [employeeId, setEmployeeId] = useState('') const [employees, setEmployees] = useState([]) const [employeesLoaded, setEmployeesLoaded] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase() // Reset per open so a previous underlag's numbers never carry over. useEffect(() => { if (!open) return setDescription(data?.supplier?.name?.trim() || '') setExpenseDate(data?.invoice?.invoiceDate || todayIso()) const total = data?.totals?.total const vat = data?.totals?.vatAmount setAmountInput(total != null && total > 0 ? String(roundOre(total)).replace('.', ',') : '') setVatInput(vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0') setExpenseAccount('') setEmployeeId('') }, [open, item.id, data]) useEffect(() => { if (!open || payer !== 'employee' || employeesLoaded) return fetch('/api/salary/employees') .then((res) => (res.ok ? res.json() : null)) .then((json) => setEmployees((json?.data ?? []) as EmployeeOption[])) .catch(() => setEmployees([])) .finally(() => setEmployeesLoaded(true)) }, [open, payer, employeesLoaded]) const amount = parseAmount(amountInput) const vatAmount = parseAmount(vatInput) const net = roundOre(amount - vatAmount) const employee = employees.find((e) => e.id === employeeId) ?? null const claimantName = payer === 'owner' ? ownerName.trim() || OWNER_FALLBACK_NAME : employee ? `${employee.first_name} ${employee.last_name}`.trim() : '' const canSubmit = !isSubmitting && description.trim().length > 0 && ISO_DATE_RE.test(expenseDate) && amount > 0 && vatAmount >= 0 && vatAmount < amount && ACCOUNT_NUMBER_RE.test(expenseAccount) && /^[4-8]/.test(expenseAccount) && (payer === 'owner' || !!employeeId) const accountName = useMemo( () => accounts.find((a) => a.account_number === expenseAccount)?.account_name ?? '', [accounts, expenseAccount], ) const handleSubmit = useCallback(async () => { if (!canSubmit) return setIsSubmitting(true) try { const body: Record = { description: description.trim(), expense_date: expenseDate, amount, vat_amount: vatAmount, currency, expense_account: expenseAccount, inbox_item_id: item.id, document_id: item.document_id ?? undefined, } if (payer === 'owner') body.claimant_name = claimantName else body.employee_id = employeeId const res = await fetch('/api/expense-claims', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) const json = (await res.json().catch(() => ({}))) as { data?: { journal_entry_id?: string | null } error?: unknown } if (!res.ok) { toast({ title: t('expense_failed_title'), description: getErrorMessage(json, { context: 'journal_entry', statusCode: res.status }), variant: 'destructive', }) return } toast({ title: t('expense_booked_title'), description: t('expense_booked_description', { name: claimantName }), }) await onSuccess() onOpenChange(false) } finally { setIsSubmitting(false) } }, [ canSubmit, description, expenseDate, amount, vatAmount, currency, expenseAccount, item.id, item.document_id, payer, claimantName, employeeId, toast, t, onSuccess, onOpenChange, ]) return ( !isSubmitting && onOpenChange(next)}> {t('expense_dialog_title')} {payer === 'owner' ? t('expense_dialog_help_owner', { account: liabilityAccount }) : t('expense_dialog_help_employee')}
{payer === 'owner' ? (
setOwnerName(e.target.value)} placeholder={OWNER_FALLBACK_NAME} disabled={isSubmitting} />
) : (
)}
setDescription(e.target.value)} disabled={isSubmitting} />
setExpenseDate(e.target.value)} disabled={isSubmitting} />
setAmountInput(e.target.value)} disabled={isSubmitting} className="tabular-nums" />
setVatInput(e.target.value)} disabled={isSubmitting} className="tabular-nums" />
{amount > 0 && vatAmount < amount && (

{expenseAccount || '____'} D {formatCurrency(net, currency)} {vatAmount > 0 ? ` · 2641 D ${formatCurrency(vatAmount, currency)}` : ''} {` · ${liabilityAccount} K ${formatCurrency(amount, currency)}`}

{claimantName && (

{t('expense_outcome_att_gora', { name: claimantName })}

)} {currency !== 'SEK' &&

{t('expense_fx_note')}

}
)}
) }