'use client' import { useState, useEffect, useMemo, useRef } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Search, X } from 'lucide-react' import { formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' /** * Map the endpoint's 0-1 match confidence (attached only when candidates are * ranked for a specific transaction) to a labelled strength badge, so the user * can tell an exact-amount hit from a fuzzy guess before vouching for an * immutable verifikat. Returns null when no confidence was attached. */ function confidenceBadge( confidence: number | undefined, ): { label: string; variant: 'success' | 'secondary' | 'outline' } | null { if (confidence == null) return null if (confidence >= 0.85) return { label: 'Stark träff', variant: 'success' } if (confidence >= 0.6) return { label: 'Trolig träff', variant: 'secondary' } return { label: 'Svag träff', variant: 'outline' } } /** * A posted journal entry line on a cash account (e.g. 1930) not yet linked to * any bank transaction: a candidate for manual reconciliation. Mirrors the * `UnlinkedGLLine` returned by GET /api/reconciliation/bank/unmatched-entries. * * Defined here (not imported from lib/reconciliation/bank-reconciliation) so the * client bundle never pulls in that module's server-only dependencies (event * bus, match-log). The optional `confidence` is attached when the endpoint * ranks candidates for a specific transaction. */ export interface UnlinkedGLLine { line_id: string journal_entry_id: string debit_amount: number credit_amount: number line_description: string | null entry_date: string voucher_number: number voucher_series: string entry_description: string source_type: string confidence?: number /** How many bank transactions already settle this entry on the account being * matched (links on OTHER cash accounts, e.g. a transfer's outgoing leg, * don't count). > 0 means the voucher is already matched on this account: * surfaced (behind the "visa matchade" opt-in) so a second/third * transaction can be attached to it (N:1). */ linked_transaction_count?: number } interface MatchPickerProps { glLines: UnlinkedGLLine[] value: string onChange: (journalEntryId: string) => void disabled?: boolean placeholder?: string /** * Render the candidate list in normal document flow (always visible below the * search box) instead of as an absolutely-positioned overlay. Use inside a * Dialog or any `overflow-y-auto` container: an absolute dropdown is clipped at * the container's edge (the "klipper i dialogerna" bug: the list got cut off * and the dialog couldn't scroll to it). The reconciliation view keeps the * compact overlay (one picker per transaction row); the modal uses inline. */ inline?: boolean } /** * Inline combobox for choosing a journal entry to match a bank transaction * against. The native { setSearch(e.target.value) setOpen(true) }} onFocus={() => setOpen(true)} placeholder={placeholder} disabled={disabled} className="pl-9" /> ) // Inline: list lives in normal flow so it can never be clipped by a scroll // container (Dialog). The enclosing modal scrolls if the whole thing is tall. if (inline) { return (
{searchBox}
{listContent}
) } // Overlay: compact, opens on focus, dismisses on outside click. Right for the // reconciliation view's one-picker-per-row layout. return (
{searchBox} {open && (
{listContent}
)}
) }