BillTracker/client/components/BillRulesManager.jsx

124 lines
5.1 KiB
React
Raw Permalink Normal View History

import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { ChevronDown, Trash2, ToggleLeft, ToggleRight, Settings2 } from 'lucide-react';
import { api } from '@/api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
export default function BillRulesManager() {
const [open, setOpen] = useState(false);
const [rules, setRules] = useState([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const d = await api.allBillMerchantRules();
setRules(d.rules || []);
} catch (err) {
toast.error(err.message || 'Failed to load bill matching rules');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { if (open) load(); }, [open, load]);
const handleDelete = async (billId, ruleId, merchant) => {
try {
await api.deleteMerchantRule(billId, ruleId);
setRules(prev => prev.filter(r => r.id !== ruleId));
toast.success(`Rule "${merchant}" removed`);
} catch (err) {
toast.error(err.message || 'Failed to delete rule');
}
};
const handleToggleAutoLate = async (billId, ruleId, current) => {
try {
await api.toggleRuleAutoAttribute(billId, ruleId, !current);
setRules(prev => prev.map(r =>
r.id === ruleId ? { ...r, auto_attribute_late: current ? 0 : 1 } : r
));
} catch (err) {
toast.error(err.message || 'Failed to update rule');
}
};
// Group rules by bill
const byBill = rules.reduce((acc, r) => {
const key = r.bill_id;
if (!acc[key]) acc[key] = { bill_name: r.bill_name, bill_id: r.bill_id, rules: [] };
acc[key].rules.push(r);
return acc;
}, {});
const groups = Object.values(byBill);
return (
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
<button
type="button"
onClick={() => setOpen(v => !v)}
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 && (
<Badge variant="secondary" className="text-xs">{rules.length}</Badge>
)}
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
</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">
No rules saved yet. Open a bill and add merchant matching rules to auto-match bank transactions.
</p>
) : (
<div className="divide-y divide-border/30 max-h-96 overflow-y-auto">
{groups.map(group => (
<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>
{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">
<span className="font-mono text-xs flex-1 truncate">{rule.merchant}</span>
<button
type="button"
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)}
className={`shrink-0 transition-colors ${rule.auto_attribute_late ? 'text-primary' : 'text-muted-foreground/40 hover:text-muted-foreground'}`}
>
{rule.auto_attribute_late
? <ToggleRight className="h-4 w-4" />
: <ToggleLeft className="h-4 w-4" />
}
</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">
Merchant patterns are matched with word-boundary rules. Toggle icon = auto-apply late month attribution.
</p>
</div>
)}
</div>
);
}