388 lines
15 KiB
JavaScript
388 lines
15 KiB
JavaScript
'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 (
|
|
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<Tag className="h-3 w-3 shrink-0 text-primary" />
|
|
<span className="text-xs font-medium text-foreground truncate max-w-[10rem]" title={rule.merchant}>
|
|
{rule.merchant}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{/* Auto-attribute late payment toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={() => 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
|
|
? <Loader2 className="h-2.5 w-2.5 animate-spin" />
|
|
: <CalendarDays className="h-2.5 w-2.5" />}
|
|
{rule.auto_attribute_late ? 'Auto-fix on' : 'Auto-fix'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => 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 ? <Loader2 className="h-3 w-3 animate-spin" /> : <X className="h-3 w-3" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConflictWarning({ conflicts }) {
|
|
if (!conflicts?.length) return null;
|
|
return (
|
|
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
|
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
<span>
|
|
This pattern is already used by{' '}
|
|
{conflicts.map((c, i) => (
|
|
<span key={c.id}>
|
|
{i > 0 && ', '}
|
|
<strong>{c.name}</strong>
|
|
</span>
|
|
))}.
|
|
Transactions could match both bills — consider making your pattern more specific.
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PreviewBadge({ count, loading, error }) {
|
|
if (loading) return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />;
|
|
if (error) return <span className="rounded-full border border-destructive/30 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold text-destructive">Error</span>;
|
|
if (count === null) return null;
|
|
return (
|
|
<span className={cn(
|
|
'rounded-full border px-2 py-0.5 text-[10px] font-semibold tabular-nums',
|
|
count > 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'}`}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 [liveResults, setLiveResults] = useState([]);
|
|
const [liveSearching, setLiveSearching] = useState(false);
|
|
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]);
|
|
|
|
// Live transaction search when user types something
|
|
useEffect(() => {
|
|
if (!debouncedInput || debouncedInput.length < 2) {
|
|
setLiveResults([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLiveSearching(true);
|
|
api.subscriptionTransactionMatches({ q: debouncedInput, limit: 20 })
|
|
.then(data => {
|
|
if (cancelled) return;
|
|
const rows = Array.isArray(data) ? data : (data?.transactions ?? []);
|
|
setLiveResults(rows.filter(tx => tx.match_status !== 'matched').map(tx => ({
|
|
id: tx.id,
|
|
label: tx.payee || tx.description || tx.memo || '',
|
|
normalized: tx.merchant || (tx.payee || tx.description || tx.memo || '').toLowerCase(),
|
|
amount: tx.amount,
|
|
date: tx.posted_date || '',
|
|
})).filter(s => s.label));
|
|
})
|
|
.catch(() => { if (!cancelled) setLiveResults([]); })
|
|
.finally(() => { if (!cancelled) setLiveSearching(false); });
|
|
return () => { cancelled = true; };
|
|
}, [debouncedInput]);
|
|
|
|
// 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();
|
|
}
|
|
|
|
// When typing: use live search results. When blank: use pre-loaded recent 30.
|
|
const filteredSuggestions = (input.trim().length >= 2 ? liveResults : suggestions.filter(s =>
|
|
!rules.some(r => r.merchant === s.normalized)
|
|
)).slice(0, 10);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
Loading matching rules…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
|
|
{/* Existing rules */}
|
|
{rules.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
{rules.map(rule => (
|
|
<RuleChip
|
|
key={rule.id}
|
|
rule={rule}
|
|
billId={billId}
|
|
onDelete={handleDelete}
|
|
onToggleAutoAttr={handleToggleAutoAttr}
|
|
deleting={deleting}
|
|
togglingAutoAttr={togglingAutoAttr}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Retroactive feedback */}
|
|
{retroFeedback !== null && (
|
|
<div className="flex items-center gap-2 rounded-md border border-emerald-500/25 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">
|
|
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
|
|
{retroFeedback} existing payment{retroFeedback === 1 ? '' : 's'} imported from your transaction history.
|
|
</div>
|
|
)}
|
|
|
|
{/* Add rule input */}
|
|
<div className="relative">
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<Input
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={e => { 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}
|
|
/>
|
|
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
|
<PreviewBadge count={previewCount} loading={previewLoading} error={previewError} />
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-8 gap-1.5 px-3 text-xs"
|
|
disabled={adding || input.trim().length < 2}
|
|
onClick={() => handleAdd()}
|
|
>
|
|
{adding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
|
|
Add
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Suggestions — inline block, no absolute positioning.
|
|
Avoids overflow-y-auto clipping AND Radix Dialog pointer-event capture. */}
|
|
{showSuggestions && (liveSearching || filteredSuggestions.length > 0) && (
|
|
<div className="overflow-hidden rounded-lg border border-border/80 bg-card shadow-sm">
|
|
<p className="flex items-center gap-2 border-b border-border/50 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{input.trim().length >= 2 ? 'Matching transactions' : 'Recent unmatched transactions'}
|
|
{liveSearching && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
</p>
|
|
<div className="max-h-40 overflow-y-auto">
|
|
{filteredSuggestions.map(s => {
|
|
const amountVal = Math.abs(Number(s.amount || 0)) / 100;
|
|
return (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
className="flex w-full items-center gap-3 px-3 py-2 text-left text-xs hover:bg-muted/50 focus:bg-muted/50 focus:outline-none"
|
|
onMouseDown={e => { e.preventDefault(); pickSuggestion(s); }}
|
|
>
|
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1 truncate font-medium">{s.label}</span>
|
|
<span className="shrink-0 font-mono text-muted-foreground tabular-nums">
|
|
${amountVal.toFixed(2)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
{!liveSearching && input.trim().length >= 2 && filteredSuggestions.length === 0 && (
|
|
<p className="px-3 py-3 text-xs text-muted-foreground">No transactions found for "{input.trim()}"</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conflict warning */}
|
|
{conflicts.length > 0 && input.trim().length >= 2 && (
|
|
<div className="mt-1.5">
|
|
<ConflictWarning conflicts={conflicts} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Empty state */}
|
|
{rules.length === 0 && !input && (
|
|
<p className="text-[11px] text-muted-foreground/70">
|
|
No rules yet. Type a merchant name or pick a recent transaction above to automatically import future payments for this bill.
|
|
</p>
|
|
)}
|
|
|
|
{/* Historical import dialog — fires after a rule is added */}
|
|
<BillHistoricalImportDialog
|
|
billId={billId}
|
|
billName={billName || 'this bill'}
|
|
open={showHistoricalDialog}
|
|
onClose={() => setShowHistoricalDialog(false)}
|
|
onImported={() => {
|
|
setShowHistoricalDialog(false);
|
|
onRulesChanged?.();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|