'use client' import { useState, useEffect, useMemo } from 'react' import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { Input } from '@/components/ui/input' import { formatCurrency, formatDate, cn } from '@/lib/utils' import { Search, FileText, Loader2 } from 'lucide-react' import { useCompany } from '@/contexts/CompanyContext' import type { Invoice, Customer } from '@/types' import type { TransactionWithInvoice } from './transaction-types' import { DOMESTIC_CURRENCY, normalizeCurrency, rankInvoicesByAmountProximity, } from './invoice-candidate-ranking' type OpenInvoice = Invoice & { customer?: Customer } interface InvoicePickerProps { transaction: TransactionWithInvoice onSelect: (invoice: OpenInvoice) => void isProcessing: boolean } export default function InvoicePicker({ transaction, onSelect, isProcessing }: InvoicePickerProps) { const t = useTranslations('tx_invoice_picker') const { company } = useCompany() const supabase = useMemo(() => createClient(), []) const [invoices, setInvoices] = useState([]) const [isLoading, setIsLoading] = useState(true) const [search, setSearch] = useState('') useEffect(() => { if (!company) return // Capture the company id once so the async closure below never // dereferences a `company` that has flipped to null between renders. // The earlier non-null assertions allowed a stale render to query // against an undefined company_id; pinning the value avoids that. const companyId = company.id let cancelled = false async function load() { setIsLoading(true) // Filter out fully-settled invoices defensively: match-invoice should // flip status to 'paid' on full settlement, but a stale 'sent'/'overdue' // row with remaining_amount=0 would otherwise be selectable here and // could be matched a second time, double-booking the income. // Also exclude proformas (PF- series): proforma is not a faktura per // ML 17 kap 24§, has no VAT obligation, and must never be matched // against a bank receipt or trigger a verifikation. const { data } = await supabase .from('invoices') .select('*, customer:customers(*)') .eq('company_id', companyId) .eq('document_type', 'invoice') .is('credited_invoice_id', null) .in('status', ['sent', 'overdue', 'partially_paid']) .gt('remaining_amount', 0) .order('invoice_date', { ascending: false }) .limit(200) if (cancelled) return const all = (data as OpenInvoice[]) || [] // Status-leak guard: if an invoice still says 'sent'/'overdue' but // already has a payment voucher attached (manual or system), hide it. // Partially-paid invoices intentionally pass through: they may take // more payments. Mirrors the server-side filter in findMatchingInvoices. const fullIds = all .filter((inv) => inv.status === 'sent' || inv.status === 'overdue') .map((inv) => inv.id) let visible = all if (fullIds.length > 0) { const { data: paid } = await supabase .from('invoice_payments') .select('invoice_id') .eq('company_id', companyId) .in('invoice_id', fullIds) .not('journal_entry_id', 'is', null) if (cancelled) return const paidSet = new Set( ((paid as { invoice_id: string }[] | null) ?? []).map((r) => r.invoice_id), ) visible = all.filter((inv) => !paidSet.has(inv.id)) } setInvoices(visible) setIsLoading(false) } load() return () => { cancelled = true } }, [company, supabase]) const sorted = useMemo(() => { const filtered = !search ? invoices : invoices.filter((inv) => { const q = search.toLowerCase() return ( (inv.invoice_number ?? '').toLowerCase().includes(q) || (inv.customer?.name ?? '').toLowerCase().includes(q) ) }) // Amount proximity is only meaningful between comparable amounts: ranking // a 1 000 EUR invoice as a perfect hit for a 1 000 SEK deposit put the // wrong row first. Foreign invoices stay in the list either way; see // ./invoice-candidate-ranking. return rankInvoicesByAmountProximity(filtered, { amount: transaction.amount, currency: transaction.currency, amountSek: transaction.amount_sek, }) }, [invoices, search, transaction.amount, transaction.currency, transaction.amount_sek]) if (isLoading) { return (
{t('loading')}
) } if (invoices.length === 0) { return (

{t('empty')}

) } return (
setSearch(e.target.value)} className="pl-9" autoFocus />
{sorted.map(({ invoice, proximity }) => { const remaining = invoice.remaining_amount ?? invoice.total const { exact, close, candidateSek } = proximity const invoiceCurrency = normalizeCurrency(invoice.currency) // The currency earns a marker only when it deviates from the bank // row's: the same marker on every row would say nothing (design.md, // "chips mark exceptions"). It is what explains why a row is or is // not ranked as close. const foreignCurrency = proximity.basis !== 'same_currency' return ( ) })} {sorted.length === 0 && (

{t('no_search_results', { term: search })}

)}
) }