'use client' import { useState, useEffect, useMemo, useCallback } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } 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 { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' import { Loader2, Plus, Trash2, AlertTriangle, Search, Check } from 'lucide-react' import { cn, formatCurrency } from '@/lib/utils' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { useSubmitWithAccountActivation, throwOnStructuredError, } from '@/lib/hooks/use-submit-with-account-activation' import { getErrorMessage } from '@/lib/errors/get-error-message' import type { BASAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types' interface InboxItem { id: string document_id: string | null matched_transaction_id: string | null extracted_data: InvoiceExtractionResult | null } interface PickerTransaction { id: string date: string description: string amount: number currency: string | null } interface FormLine { account_number: string debit_amount: string credit_amount: string } const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '' } interface Props { open: boolean onOpenChange: (v: boolean) => void item: InboxItem onSuccess: () => void | Promise } // Compute the prefill lines. Booking is always in SEK (BFL/BFNAR), so when // a transaction is selected and the document is in a foreign currency, the // transaction's SEK amount is the canonical figure. The cost-account row // stays blank — the user must pick a cost account themselves. function buildPrefillLines( item: InboxItem, selectedTransactionAmount: number | null = null ): FormLine[] { const docTotal = item.extracted_data?.totals?.total ?? null const docVat = item.extracted_data?.totals?.vatAmount ?? null const docCurrency = item.extracted_data?.invoice?.currency ?? 'SEK' // Prefer the transaction amount when available — it's already in SEK and // matches the bank movement we'll be marking as booked. const total = selectedTransactionAmount != null ? Math.abs(selectedTransactionAmount) : docTotal if (total == null || total <= 0) { return [{ ...BLANK_LINE }, { ...BLANK_LINE }] } const totalRounded = Math.round(total * 100) / 100 // VAT prefill rules: // - Foreign-currency document → skip VAT (reverse charge is the common // case; user can add it manually if needed). // - SEK-denominated document with extracted VAT → split it out on 2641. // - SEK without extracted VAT → leave VAT row out, single net row. const useDocVat = docCurrency === 'SEK' && selectedTransactionAmount == null && docVat != null && docVat > 0 const vatRounded = useDocVat ? Math.round((docVat ?? 0) * 100) / 100 : 0 const net = Math.round((totalRounded - vatRounded) * 100) / 100 const lines: FormLine[] = [ { account_number: '', debit_amount: String(net), credit_amount: '', }, ] if (vatRounded > 0) { lines.push({ account_number: '2641', debit_amount: String(vatRounded), credit_amount: '', }) } lines.push({ account_number: '1930', debit_amount: '', credit_amount: String(totalRounded), }) return lines } function rankByAmount( rows: PickerTransaction[], target: number | null ): PickerTransaction[] { if (target == null) return rows const abs = Math.abs(target) return [...rows].sort((a, b) => { const da = Math.abs(Math.abs(a.amount) - abs) const db = Math.abs(Math.abs(b.amount) - abs) return da - db }) } export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess }: Props) { const { toast } = useToast() const [periods, setPeriods] = useState([]) const [accounts, setAccounts] = useState([]) const [entryDate, setEntryDate] = useState( item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10) ) const [periodId, setPeriodId] = useState('') const [description, setDescription] = useState(() => { const supplier = item.extracted_data?.supplier?.name?.trim() || '' const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || '' return [supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg' }) const [notes, setNotes] = useState('') const [lines, setLines] = useState(() => buildPrefillLines(item)) // Transaction picker — optional selection. const [selectedTransactionId, setSelectedTransactionId] = useState( item.matched_transaction_id ) const [transactions, setTransactions] = useState([]) const [isLoadingTransactions, setIsLoadingTransactions] = useState(false) const [txSearch, setTxSearch] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // Reset state when a different item opens the dialog useEffect(() => { if (!open) return setEntryDate(item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10)) setLines(buildPrefillLines(item)) setSelectedTransactionId(item.matched_transaction_id) const supplier = item.extracted_data?.supplier?.name?.trim() || '' const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || '' setDescription([supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg') setNotes('') // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, item.id]) // When the user picks a transaction (or the toggle changes), re-derive // the prefilled amounts so foreign-currency invoices follow the SEK // figure on the actual bank movement. const selectedTransactionAmount = useMemo(() => { if (!selectedTransactionId) return null const tx = transactions.find((t) => t.id === selectedTransactionId) return tx?.amount ?? null }, [selectedTransactionId, transactions]) useEffect(() => { if (!open) return // Update amounts when the transaction selection changes, but preserve // user-entered account numbers. This handles "user typed cost account, // then picked an SEK-denominated transaction" — we want the SEK figure // to flow into the line amounts without forgetting their account pick. setLines((current) => { const next = buildPrefillLines(item, selectedTransactionAmount) return next.map((nl, i) => { const existing = current[i] if (!existing) return nl return { ...nl, account_number: existing.account_number || nl.account_number, } }) }) }, [open, item, selectedTransactionAmount]) // Fetch fiscal periods and accounts on first open useEffect(() => { if (!open) return let cancelled = false ;(async () => { try { const [periodsRes, accountsRes] = await Promise.all([ fetch('/api/bookkeeping/fiscal-periods'), fetch('/api/bookkeeping/accounts'), ]) const periodsJson = await periodsRes.json() const accountsJson = await accountsRes.json() if (cancelled) return setPeriods(periodsJson.data || []) setAccounts(accountsJson.data || []) } catch (err) { console.error('[book-direct] fetch reference data failed:', err) } })() return () => { cancelled = true } }, [open]) // Auto-select fiscal period matching the entry date useEffect(() => { if (periods.length === 0) return const match = periods.find( (p) => entryDate >= p.period_start && entryDate <= p.period_end ) if (match) { setPeriodId(match.id) } else if (!periodId && periods.length > 0) { setPeriodId(periods[0].id) } }, [entryDate, periods, periodId]) // Fetch unmatched transactions whenever the dialog opens — the picker // is always visible now (selection is optional). useEffect(() => { if (!open) return let cancelled = false setIsLoadingTransactions(true) const targetAmount = item.extracted_data?.totals?.total ?? null ;(async () => { try { const res = await fetch('/api/transactions?unmatched=true') const json = await res.json() if (cancelled) return const rows: PickerTransaction[] = (Array.isArray(json.data) ? json.data : []) .map((t: PickerTransaction) => ({ id: t.id, date: t.date, description: t.description, amount: t.amount, currency: t.currency || 'SEK', })) setTransactions(rankByAmount(rows, targetAmount)) } catch (err) { console.error('[book-direct] fetch transactions failed:', err) } finally { if (!cancelled) setIsLoadingTransactions(false) } })() return () => { cancelled = true } }, [open, item.extracted_data?.totals?.total]) const filteredTransactions = useMemo(() => { const term = txSearch.trim().toLowerCase() if (!term) return transactions return transactions.filter((t) => (t.description || '').toLowerCase().includes(term)) }, [transactions, txSearch]) const totals = useMemo(() => { const debit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) const credit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) const roundedDebit = Math.round(debit * 100) / 100 const roundedCredit = Math.round(credit * 100) / 100 return { debit: roundedDebit, credit: roundedCredit, balanced: roundedDebit === roundedCredit && roundedDebit > 0, diff: Math.round((roundedDebit - roundedCredit) * 100) / 100, } }, [lines]) const updateLine = useCallback((idx: number, patch: Partial) => { setLines((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l))) }, []) const addLine = useCallback(() => { setLines((prev) => [...prev, { ...BLANK_LINE }]) }, []) const removeLine = useCallback((idx: number) => { setLines((prev) => prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx)) }, []) const disabledReason = useMemo(() => { if (isSubmitting) return null if (!entryDate) return 'Välj datum' if (!periodId) return 'Välj räkenskapsperiod' if (description.trim().length === 0) return 'Fyll i beskrivning' if (lines.some((l) => l.account_number.trim().length === 0)) return 'Alla rader behöver ett konto' if (!totals.balanced) return 'Debet och kredit måste vara lika' return null }, [isSubmitting, entryDate, periodId, description, lines, totals.balanced]) const canSubmit = !isSubmitting && disabledReason === null const postBooking = useCallback(async () => { const payload = { fiscal_period_id: periodId, entry_date: entryDate, description: description.trim(), notes: notes.trim() || undefined, lines: lines.map((l) => ({ account_number: l.account_number.trim(), debit_amount: parseFloat(l.debit_amount) || 0, credit_amount: parseFloat(l.credit_amount) || 0, })), transaction_id: selectedTransactionId ?? undefined, } const res = await fetch( `/api/extensions/ext/invoice-inbox/items/${item.id}/book-direct`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), } ) return (await throwOnStructuredError(res)) as { data?: { journal_entry?: { voucher_series: string; voucher_number: number } } } }, [periodId, entryDate, description, notes, lines, selectedTransactionId, item.id]) const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } = useSubmitWithAccountActivation(postBooking) const handleSubmit = useCallback(async () => { if (!canSubmit) return setIsSubmitting(true) try { const json = await runSubmit() const voucher = json?.data?.journal_entry toast({ title: 'Bokfört', description: voucher ? `Verifikation ${voucher.voucher_series}${voucher.voucher_number} skapad.` : 'Verifikation skapad.', }) await onSuccess() onOpenChange(false) } catch (err) { if (err instanceof Error && err.message === 'cancelled') { // User dismissed the activation dialog — no toast needed } else { const anyErr = err as { body?: unknown; status?: number } toast({ title: 'Kunde inte bokföra', description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status, }), variant: 'destructive', }) } } finally { setIsSubmitting(false) } }, [canSubmit, runSubmit, toast, onSuccess, onOpenChange]) const targetAmount = item.extracted_data?.totals?.total ?? null const targetCurrency = item.extracted_data?.invoice?.currency ?? 'SEK' return ( Bokför direkt Skapa en verifikation från underlaget. Dokumentet bifogas verifikationen som underlag.
{/* Metadata row */}
setEntryDate(e.target.value)} disabled={isSubmitting} className="tabular-nums" />
setDescription(e.target.value)} disabled={isSubmitting} placeholder="Leverantör · fakturanummer" />
{/* Transaction picker — always shown, selection is optional. */}

Välj en transaktion om dokumentet motsvarar en redan-bokad bankhändelse — den bokas då samtidigt. Lämna tom för en fristående verifikation.

setTxSearch(e.target.value)} className="pl-10" disabled={isSubmitting} />
{isLoadingTransactions ? (
Laddar…
) : filteredTransactions.length === 0 ? (

Inga okategoriserade transaktioner.

) : (
    {filteredTransactions.slice(0, 30).map((tx) => { const isSelected = selectedTransactionId === tx.id return (
  • ) })}
)}
{selectedTransactionId && ( )}
{/* Journal entry lines */}
{targetAmount != null && ( Underlag:{' '} {formatCurrency(targetAmount, targetCurrency)} )} {selectedTransactionAmount != null && ( {targetAmount != null && ' · '} Transaktion:{' '} {formatCurrency(Math.abs(selectedTransactionAmount), 'SEK')} )}
{targetCurrency !== 'SEK' && selectedTransactionAmount != null && (

Underlaget är i {targetCurrency}. Bokföringen sker i SEK enligt transaktionens belopp. Momsraden har lämnats bort — vid behov lägg till en rad för omvänd skattskyldighet manuellt.

)}
{lines.map((line, idx) => ( ))}
Konto Debet Kredit
updateLine(idx, { account_number: v })} /> updateLine(idx, { debit_amount: e.target.value, credit_amount: e.target.value ? '' : line.credit_amount })} disabled={isSubmitting} className="text-right tabular-nums" placeholder="0,00" /> updateLine(idx, { credit_amount: e.target.value, debit_amount: e.target.value ? '' : line.debit_amount })} disabled={isSubmitting} className="text-right tabular-nums" placeholder="0,00" />
Summa {totals.debit.toFixed(2)} {totals.credit.toFixed(2)}
{totals.balanced ? ( Balanserad ) : ( Diff {totals.diff.toFixed(2)} )}