'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, Building2, CheckCircle2, Loader2, Plus, Tag, Trash2, X, } from 'lucide-react'; import { api } from '@/api'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; // Debounce helper function useDebounce(value, delay) { const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(t); }, [value, delay]); return debounced; } function RuleChip({ rule, onDelete, deleting }) { return ( {rule.merchant} ); } function ConflictWarning({ conflicts }) { if (!conflicts?.length) return null; return (
This pattern is already used by{' '} {conflicts.map((c, i) => ( {i > 0 && ', '} {c.name} ))}. Transactions could match both bills — consider making your pattern more specific.
); } function PreviewBadge({ count, loading, error }) { if (loading) return ; if (error) return Error; if (count === null) return null; return ( 0 ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'border-border/60 bg-muted/40 text-muted-foreground', )}> {count === 0 ? 'No matches' : `${count} match${count === 1 ? '' : 'es'}`} ); } export default function BillMerchantRules({ billId, onRulesChanged }) { const [rules, setRules] = useState([]); const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(true); const [deleting, setDeleting] = useState(null); const [adding, setAdding] = useState(false); const [input, setInput] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); const [previewCount, setPreviewCount] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); const [previewError, setPreviewError] = useState(false); const [conflicts, setConflicts] = useState([]); const [retroFeedback, setRetroFeedback] = useState(null); const inputRef = useRef(null); const dropdownRef = useRef(null); const debouncedInput = useDebounce(input.trim(), 380); const load = useCallback(async () => { if (!billId) return; setLoading(true); try { const data = await api.billMerchantRules(billId); setRules(data.rules || []); setSuggestions(data.suggestions || []); } catch { // non-fatal } finally { setLoading(false); } }, [billId]); useEffect(() => { load(); }, [load]); // Preview debounced input useEffect(() => { if (!debouncedInput || debouncedInput.length < 2) { setPreviewCount(null); setConflicts([]); return; } let cancelled = false; setPreviewLoading(true); setPreviewError(false); api.previewMerchantRule(billId, debouncedInput) .then(data => { if (cancelled) return; setPreviewCount(data.match_count); setConflicts(data.conflicts || []); }) .catch(() => { if (!cancelled) setPreviewError(true); }) .finally(() => { if (!cancelled) setPreviewLoading(false); }); return () => { cancelled = true; }; }, [debouncedInput, billId]); // Close suggestion dropdown on outside click useEffect(() => { function handler(e) { if (!dropdownRef.current?.contains(e.target) && !inputRef.current?.contains(e.target)) { setShowSuggestions(false); } } document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, []); async function handleAdd(merchantText) { const text = (merchantText || input).trim(); if (!text) return; setAdding(true); setRetroFeedback(null); try { const result = await api.addMerchantRule(billId, text); setRules(prev => { if (prev.some(r => r.id === result.rule?.id)) return prev; return [...prev, result.rule].filter(Boolean); }); setInput(''); setPreviewCount(null); setConflicts([]); setShowSuggestions(false); if (result.retroactive_matches > 0) { setRetroFeedback(result.retroactive_matches); toast.success(`Rule added — ${result.retroactive_matches} past payment${result.retroactive_matches === 1 ? '' : 's'} imported`); } else { toast.success('Rule added — will match future transactions automatically'); } onRulesChanged?.(); } catch (err) { toast.error(err.message || 'Failed to add rule'); } finally { setAdding(false); } } async function handleDelete(rule) { setDeleting(rule.id); try { await api.deleteMerchantRule(billId, rule.id); setRules(prev => prev.filter(r => r.id !== rule.id)); toast.success(`Rule "${rule.merchant}" removed`); onRulesChanged?.(); } catch (err) { toast.error(err.message || 'Failed to remove rule'); } finally { setDeleting(null); } } function pickSuggestion(s) { setInput(s.label); setShowSuggestions(false); inputRef.current?.focus(); } // Filter suggestions: not already a rule, and not already matched to something const filteredSuggestions = suggestions.filter(s => !rules.some(r => r.merchant === s.normalized) && (input.length < 2 || s.label.toLowerCase().includes(input.toLowerCase())) ).slice(0, 8); if (loading) { return (
Loading matching rules…
); } return (
{/* Existing rules */} {rules.length > 0 && (
{rules.map(rule => ( ))}
)} {/* Retroactive feedback */} {retroFeedback !== null && (
{retroFeedback} existing payment{retroFeedback === 1 ? '' : 's'} imported from your transaction history.
)} {/* Add rule input */}
{ setInput(e.target.value); setShowSuggestions(true); setRetroFeedback(null); setPreviewError(false); }} onFocus={() => setShowSuggestions(true)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } if (e.key === 'Escape') setShowSuggestions(false); }} placeholder="Type merchant name or pick from recent transactions…" className="h-8 pr-20 text-xs" disabled={adding} />
{/* Conflict warning */} {conflicts.length > 0 && input.trim().length >= 2 && (
)} {/* Suggestions dropdown */} {showSuggestions && filteredSuggestions.length > 0 && (

Recent unmatched transactions

{filteredSuggestions.map(s => { const amountVal = Math.abs(Number(s.amount || 0)) / 100; return ( ); })}
)}
{/* Empty state */} {rules.length === 0 && !input && (

No rules yet. Type a merchant name or pick a recent transaction above to automatically import future payments for this bill.

)}
); }