'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import {
AlertTriangle, Building2, CheckCircle2, CalendarDays, 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';
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
// 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, billId, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }) {
return (
{rule.merchant}
{/* Auto-attribute late payment toggle */}
onToggleAutoAttr(rule, !rule.auto_attribute_late)}
disabled={togglingAutoAttr === rule.id}
title={rule.auto_attribute_late
? 'Auto-fixing month crossing is ON — payments in the first 5 days are automatically moved to the prior month'
: 'Turn on to automatically move payments that post in the first 5 days of a month to the prior month (e.g. AT&T posts June 1, belongs to May)'}
className={cn(
'flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold transition-colors',
rule.auto_attribute_late
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20'
: 'border-border/50 bg-background text-muted-foreground hover:bg-muted/40',
)}
>
{togglingAutoAttr === rule.id
?
: }
{rule.auto_attribute_late ? 'Auto-fix on' : 'Auto-fix'}
onDelete(rule)}
disabled={deleting === rule.id}
className="rounded-full p-0.5 text-muted-foreground/60 transition-opacity hover:text-destructive disabled:opacity-30"
aria-label={`Remove rule "${rule.merchant}"`}
>
{deleting === rule.id ? : }
);
}
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, billName, 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 [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
const [togglingAutoAttr, setTogglingAutoAttr] = useState(null);
const inputRef = 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]);
// Popover handles its own outside-click dismissal — no manual handler needed
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);
toast.success('Rule added');
// Open the historical import dialog — lets user decide how to handle past transactions
setShowHistoricalDialog(true);
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);
}
}
async function handleToggleAutoAttr(rule, enabled) {
setTogglingAutoAttr(rule.id);
try {
await api.toggleRuleAutoAttribute(billId, rule.id, enabled);
setRules(prev => prev.map(r => r.id === rule.id ? { ...r, auto_attribute_late: enabled ? 1 : 0 } : r));
toast.success(enabled
? `Auto-fix on — ${rule.merchant} payments will automatically count for the prior month`
: 'Auto-fix off');
} catch (err) {
toast.error(err.message || 'Failed to update rule');
} finally {
setTogglingAutoAttr(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 */}
handleAdd()}
>
{adding ? : }
Add
{/* Suggestions — inline block, no absolute positioning.
Avoids overflow-y-auto clipping AND Radix Dialog pointer-event capture. */}
{showSuggestions && filteredSuggestions.length > 0 && (
Recent unmatched transactions
{filteredSuggestions.map(s => {
const amountVal = Math.abs(Number(s.amount || 0)) / 100;
return (
{ e.preventDefault(); pickSuggestion(s); }}
>
{s.label}
${amountVal.toFixed(2)}
);
})}
)}
{/* Conflict warning */}
{conflicts.length > 0 && input.trim().length >= 2 && (
)}
{/* 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.
)}
{/* Historical import dialog — fires after a rule is added */}
setShowHistoricalDialog(false)}
onImported={() => {
setShowHistoricalDialog(false);
onRulesChanged?.();
}}
/>
);
}