'use client' import { useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency, formatDate, cn } from '@/lib/utils' import { Loader2, Search, X, Plus, Check, AlertTriangle } from 'lucide-react' import type { Invoice, Customer, SupplierInvoice, Supplier } from '@/types' import type { TransactionWithInvoice } from './transaction-types' interface MatchAllocationDialogProps { open: boolean onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null onSuccess: () => void } /** * Direction-aware allocation candidate. The dialog normalizes customer and * supplier invoices to the same shape so the row renderer + tally math stay * a single code path. The `kind` discriminator drives the underlying API * payload at submit time. */ interface AllocationCandidate { kind: 'customer_invoice' | 'supplier_invoice' id: string label: string counterpartyName: string remaining: number total: number currency: string dueDate: string } type AllocationDraft = { candidateId: string amount: string } type CustomerInvoiceRow = Invoice & { customer?: Customer | null } type SupplierInvoiceRow = SupplierInvoice & { supplier?: Supplier | null } function parseAmount(s: string): number { // Accept Swedish-style decimal comma + thousand spaces. Empty string → 0. const cleaned = s.replace(/\s+/g, '').replace(',', '.') const n = parseFloat(cleaned) return Number.isFinite(n) ? n : 0 } function round2(n: number): number { return Math.round(n * 100) / 100 } export default function MatchAllocationDialog({ open, onOpenChange, transaction, onSuccess, }: MatchAllocationDialogProps) { const { toast } = useToast() const { company } = useCompany() const supabase = useMemo(() => createClient(), []) const t = useTranslations('tx_match_allocation') const kind: 'customer_invoice' | 'supplier_invoice' = useMemo(() => { // Strict > 0 (was >= 0): a zero-amount tx would otherwise load customer // candidates and the RPC would reject with BATCH_TX_ZERO_AMOUNT after // the user has already filled in allocations. PR #603 review fix. return transaction && transaction.amount > 0 ? 'customer_invoice' : 'supplier_invoice' }, [transaction]) const [candidates, setCandidates] = useState([]) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') const [drafts, setDrafts] = useState>({}) const [submitting, setSubmitting] = useState(false) useEffect(() => { if (!open || !transaction || !company) return const companyId = company.id let cancelled = false async function load() { setLoading(true) try { if (kind === 'customer_invoice') { // Mirror InvoicePicker's filter: only true invoices (no proformas) // in an open state with a positive remaining balance. const { data } = await supabase .from('invoices') .select('*, customer:customers(id, name)') .eq('company_id', companyId) .eq('document_type', 'invoice') .in('status', ['sent', 'overdue', 'partially_paid']) .gt('remaining_amount', 0) .order('due_date', { ascending: true }) if (cancelled) return const rows = (data ?? []) as CustomerInvoiceRow[] setCandidates( rows.map((r) => ({ kind: 'customer_invoice', id: r.id, label: r.invoice_number ?? r.id.slice(0, 8), counterpartyName: r.customer?.name ?? t('unknown_customer'), remaining: Number(r.remaining_amount ?? r.total ?? 0), total: Number(r.total ?? 0), currency: r.currency, dueDate: r.due_date, })), ) } else { const { data } = await supabase .from('supplier_invoices') .select('*, supplier:suppliers(id, name)') .eq('company_id', companyId) .in('status', ['registered', 'approved', 'overdue', 'partially_paid']) .gt('remaining_amount', 0) .order('due_date', { ascending: true }) if (cancelled) return const rows = (data ?? []) as SupplierInvoiceRow[] setCandidates( rows.map((r) => ({ kind: 'supplier_invoice', id: r.id, label: r.supplier_invoice_number ?? `LF-${r.arrival_number}`, counterpartyName: r.supplier?.name ?? t('unknown_supplier'), remaining: Number(r.remaining_amount ?? r.total ?? 0), total: Number(r.total ?? 0), currency: r.currency, dueDate: r.due_date, })), ) } } finally { if (!cancelled) setLoading(false) } } load() return () => { cancelled = true } }, [open, transaction, company, kind, supabase, t]) // Reset state every time the dialog re-opens for a new tx. useEffect(() => { if (!open) { setDrafts({}) setSearch('') } }, [open]) const txAmountAbs = transaction ? Math.abs(transaction.amount) : 0 const allocated = useMemo(() => { return Object.values(drafts).reduce((sum, d) => sum + parseAmount(d.amount), 0) }, [drafts]) const leftover = round2(txAmountAbs - allocated) const overshoot = leftover < -0.005 const balanced = Math.abs(leftover) < 0.005 && Object.keys(drafts).length > 0 const filteredCandidates = useMemo(() => { const selectedIds = new Set(Object.keys(drafts)) const sorted = [...candidates].sort((a, b) => { const aSel = selectedIds.has(a.id) const bSel = selectedIds.has(b.id) if (aSel !== bSel) return aSel ? -1 : 1 return a.dueDate.localeCompare(b.dueDate) }) if (!search.trim()) return sorted const needle = search.trim().toLowerCase() return sorted.filter((c) => { const haystack = `${c.label} ${c.counterpartyName}`.toLowerCase() return haystack.includes(needle) }) }, [candidates, drafts, search]) function addAllocation(candidate: AllocationCandidate) { setDrafts((prev) => { if (prev[candidate.id]) return prev const remainingTxBudget = Math.max(0, round2(txAmountAbs - allocated)) const defaultAmount = Math.min(candidate.remaining, remainingTxBudget) return { ...prev, [candidate.id]: { candidateId: candidate.id, amount: defaultAmount > 0 ? defaultAmount.toFixed(2).replace('.', ',') : '', }, } }) } function removeAllocation(candidateId: string) { setDrafts((prev) => { const next = { ...prev } delete next[candidateId] return next }) } function setDraftAmount(candidateId: string, amount: string) { setDrafts((prev) => ({ ...prev, [candidateId]: { candidateId, amount }, })) } async function handleConfirm() { if (!transaction) return if (!balanced && !overshoot) { // Allow undershoot — the tx keeps its leftover unallocated. But reject // a no-allocation submit. if (Object.keys(drafts).length === 0) return } if (overshoot) return setSubmitting(true) try { const allocations = Object.values(drafts) .map((d) => { const cand = candidates.find((c) => c.id === d.candidateId) if (!cand) return null const amount = parseAmount(d.amount) if (amount <= 0) return null return cand.kind === 'customer_invoice' ? { kind: 'customer_invoice' as const, invoice_id: cand.id, amount } : { kind: 'supplier_invoice' as const, supplier_invoice_id: cand.id, amount } }) .filter((a): a is NonNullable => a !== null) if (allocations.length === 0) { toast({ title: t('error_no_allocations_title'), description: t('error_no_allocations_description'), variant: 'destructive', }) setSubmitting(false) return } const response = await fetch(`/api/transactions/${transaction.id}/match-batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allocations }), }) if (!response.ok) { const body = await response.json().catch(() => null) toast({ title: t('error_submit_title'), description: getErrorMessage(body, { context: kind === 'customer_invoice' ? 'invoice' : 'supplier_invoice', statusCode: response.status, }), variant: 'destructive', }) return } toast({ title: t('success_title'), description: t('success_description', { count: allocations.length }), variant: 'success', }) onSuccess() onOpenChange(false) } catch (err) { toast({ title: t('error_submit_title'), description: getErrorMessage(err, { context: kind === 'customer_invoice' ? 'invoice' : 'supplier_invoice', }), variant: 'destructive', }) } finally { setSubmitting(false) } } if (!transaction) return null return ( {t('title')} {kind === 'customer_invoice' ? t('description_customer') : t('description_supplier')}
{/* Transaction summary */}

{t('transaction_label')}

{transaction.description}

{formatDate(transaction.date)} 0 && 'text-success', )} > {transaction.amount > 0 ? '+' : ''} {formatCurrency(transaction.amount, transaction.currency)}
{/* Search */}
setSearch(e.target.value)} placeholder={t('search_placeholder')} className="pl-9" />
{/* Candidate list */} {loading ? (
) : filteredCandidates.length === 0 ? (

{t('empty_title')}

{t('empty_description')}

) : (
    {filteredCandidates.map((c) => { const draft = drafts[c.id] const isSelected = !!draft return (
  • {c.label} {isSelected && ( {t('selected_badge')} )}

    {c.counterpartyName}

    {t('remaining_label', { amount: formatCurrency(c.remaining, c.currency), })}

    {isSelected ? (
    setDraftAmount(c.id, e.target.value)} className="h-9 w-28 font-mono text-right tabular-nums" aria-label={t('amount_input_aria', { label: c.label })} />
    ) : ( )}
  • ) })}
)} {/* Tally */}
{t('allocated_label')} {formatCurrency(allocated, transaction.currency)} /{' '} {formatCurrency(txAmountAbs, transaction.currency)}
{overshoot ? (

{t('overshoot_warning', { excess: formatCurrency(Math.abs(leftover), transaction.currency), })}

) : balanced ? (

{t('balanced_message')}

) : leftover > 0.005 && Object.keys(drafts).length > 0 ? (

{t('leftover_note', { amount: formatCurrency(leftover, transaction.currency), })}

) : null}
) }