2026-07-04 20:42:24 -05:00
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
2026-06-04 20:45:11 -05:00
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
import { ChevronDown, Trash2, ToggleLeft, ToggleRight, Settings2 } from 'lucide-react';
|
|
|
|
|
import { api } from '@/api';
|
2026-07-04 20:42:24 -05:00
|
|
|
import { errMessage } from '@/lib/utils';
|
2026-06-04 20:45:11 -05:00
|
|
|
import { Badge } from '@/components/ui/badge';
|
2026-07-04 20:42:24 -05:00
|
|
|
|
|
|
|
|
interface MerchantRule {
|
|
|
|
|
id: number;
|
|
|
|
|
bill_id: number;
|
|
|
|
|
bill_name?: string;
|
|
|
|
|
merchant: string;
|
|
|
|
|
auto_attribute_late?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RuleGroup {
|
|
|
|
|
bill_name?: string;
|
|
|
|
|
bill_id: number;
|
|
|
|
|
rules: MerchantRule[];
|
|
|
|
|
}
|
2026-06-04 20:45:11 -05:00
|
|
|
|
|
|
|
|
export default function BillRulesManager() {
|
2026-07-06 14:23:53 -05:00
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [rules, setRules] = useState<MerchantRule[]>([]);
|
2026-06-04 20:45:11 -05:00
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const d = (await api.allBillMerchantRules()) as { rules?: MerchantRule[] };
|
2026-06-04 20:45:11 -05:00
|
|
|
setRules(d.rules || []);
|
|
|
|
|
} catch (err) {
|
2026-07-04 20:42:24 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to load bill matching rules'));
|
2026-06-04 20:45:11 -05:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (open) load();
|
|
|
|
|
}, [open, load]);
|
2026-06-04 20:45:11 -05:00
|
|
|
|
2026-07-04 20:42:24 -05:00
|
|
|
const handleDelete = async (billId: number, ruleId: number, merchant: string) => {
|
2026-06-04 20:45:11 -05:00
|
|
|
try {
|
|
|
|
|
await api.deleteMerchantRule(billId, ruleId);
|
2026-07-06 14:23:53 -05:00
|
|
|
setRules((prev) => prev.filter((r) => r.id !== ruleId));
|
2026-06-04 20:45:11 -05:00
|
|
|
toast.success(`Rule "${merchant}" removed`);
|
|
|
|
|
} catch (err) {
|
2026-07-04 20:42:24 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to delete rule'));
|
2026-06-04 20:45:11 -05:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-04 20:42:24 -05:00
|
|
|
const handleToggleAutoLate = async (billId: number, ruleId: number, current: boolean) => {
|
2026-06-04 20:45:11 -05:00
|
|
|
try {
|
|
|
|
|
await api.toggleRuleAutoAttribute(billId, ruleId, !current);
|
2026-07-06 14:23:53 -05:00
|
|
|
setRules((prev) =>
|
|
|
|
|
prev.map((r) => (r.id === ruleId ? { ...r, auto_attribute_late: current ? 0 : 1 } : r)),
|
|
|
|
|
);
|
2026-06-04 20:45:11 -05:00
|
|
|
} catch (err) {
|
2026-07-04 20:42:24 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to update rule'));
|
2026-06-04 20:45:11 -05:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Group rules by bill
|
2026-07-04 20:42:24 -05:00
|
|
|
const byBill = rules.reduce<Record<number, RuleGroup>>((acc, r) => {
|
2026-06-04 20:45:11 -05:00
|
|
|
const key = r.bill_id;
|
2026-07-04 20:42:24 -05:00
|
|
|
const g = acc[key] ?? (acc[key] = { bill_name: r.bill_name, bill_id: r.bill_id, rules: [] });
|
|
|
|
|
g.rules.push(r);
|
2026-06-04 20:45:11 -05:00
|
|
|
return acc;
|
|
|
|
|
}, {});
|
|
|
|
|
const groups = Object.values(byBill);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-07-06 14:23:53 -05:00
|
|
|
onClick={() => setOpen((v) => !v)}
|
2026-06-04 20:45:11 -05:00
|
|
|
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/20 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<span className="text-sm font-medium flex-1">Bill Matching Rules</span>
|
|
|
|
|
{!open && rules.length > 0 && (
|
2026-07-06 14:23:53 -05:00
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
{rules.length}
|
|
|
|
|
</Badge>
|
2026-06-04 20:45:11 -05:00
|
|
|
)}
|
2026-07-06 14:23:53 -05:00
|
|
|
<ChevronDown
|
|
|
|
|
className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
|
|
|
|
|
/>
|
2026-06-04 20:45:11 -05:00
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{open && (
|
|
|
|
|
<div className="border-t border-border/40">
|
|
|
|
|
{loading ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground text-center py-6">Loading…</p>
|
|
|
|
|
) : groups.length === 0 ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground text-center py-6 px-4">
|
2026-07-06 14:23:53 -05:00
|
|
|
No rules saved yet. Open a bill and add merchant matching rules to auto-match bank
|
|
|
|
|
transactions.
|
2026-06-04 20:45:11 -05:00
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="divide-y divide-border/30 max-h-96 overflow-y-auto">
|
2026-07-06 14:23:53 -05:00
|
|
|
{groups.map((group) => (
|
2026-06-04 20:45:11 -05:00
|
|
|
<div key={group.bill_id}>
|
|
|
|
|
<div className="px-4 py-2 bg-muted/20">
|
|
|
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide truncate">
|
|
|
|
|
{group.bill_name}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
{group.rules.map((rule) => (
|
|
|
|
|
<div
|
|
|
|
|
key={rule.id}
|
|
|
|
|
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/10 transition-colors"
|
|
|
|
|
>
|
2026-06-04 20:45:11 -05:00
|
|
|
<span className="font-mono text-xs flex-1 truncate">{rule.merchant}</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-07-06 14:23:53 -05:00
|
|
|
title={
|
|
|
|
|
rule.auto_attribute_late
|
|
|
|
|
? 'Auto-apply late attribution — click to disable'
|
|
|
|
|
: 'Enable auto late attribution'
|
|
|
|
|
}
|
|
|
|
|
onClick={() =>
|
|
|
|
|
handleToggleAutoLate(group.bill_id, rule.id, !!rule.auto_attribute_late)
|
|
|
|
|
}
|
2026-06-04 20:45:11 -05:00
|
|
|
className={`shrink-0 transition-colors ${rule.auto_attribute_late ? 'text-primary' : 'text-muted-foreground/40 hover:text-muted-foreground'}`}
|
|
|
|
|
>
|
2026-07-06 14:23:53 -05:00
|
|
|
{rule.auto_attribute_late ? (
|
|
|
|
|
<ToggleRight className="h-4 w-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<ToggleLeft className="h-4 w-4" />
|
|
|
|
|
)}
|
2026-06-04 20:45:11 -05:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => handleDelete(group.bill_id, rule.id, rule.merchant)}
|
|
|
|
|
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<p className="px-4 py-2 text-[11px] text-muted-foreground border-t border-border/30">
|
2026-07-06 14:23:53 -05:00
|
|
|
Merchant patterns are matched with word-boundary rules. Toggle icon = auto-apply late
|
|
|
|
|
month attribution.
|
2026-06-04 20:45:11 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|