|
|
|
|
@ -3,16 +3,47 @@
|
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
import {
|
|
|
|
|
AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X,
|
|
|
|
|
AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, X,
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
import { api } from '@/api';
|
|
|
|
|
import { cn, fmt } from '@/lib/utils';
|
|
|
|
|
import { cn, fmt, errMessage } from '@/lib/utils';
|
|
|
|
|
import { asDollars } from '@/lib/money';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
|
|
|
|
|
|
|
|
|
|
interface MerchantRule {
|
|
|
|
|
id: number | string;
|
|
|
|
|
merchant?: string;
|
|
|
|
|
auto_attribute_late?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Conflict {
|
|
|
|
|
id: number | string;
|
|
|
|
|
name?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Suggestion {
|
|
|
|
|
id: number | string;
|
|
|
|
|
label?: string;
|
|
|
|
|
normalized?: string;
|
|
|
|
|
amount?: number;
|
|
|
|
|
date?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SubTx {
|
|
|
|
|
id: number | string;
|
|
|
|
|
payee?: string | null;
|
|
|
|
|
description?: string | null;
|
|
|
|
|
memo?: string | null;
|
|
|
|
|
merchant?: string | null;
|
|
|
|
|
amount?: number;
|
|
|
|
|
posted_date?: string | null;
|
|
|
|
|
match_status?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Debounce helper
|
|
|
|
|
function useDebounce(value, delay) {
|
|
|
|
|
function useDebounce<T>(value: T, delay: number): T {
|
|
|
|
|
const [debounced, setDebounced] = useState(value);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const t = setTimeout(() => setDebounced(value), delay);
|
|
|
|
|
@ -21,7 +52,13 @@ function useDebounce(value, delay) {
|
|
|
|
|
return debounced;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }) {
|
|
|
|
|
function RuleChip({ rule, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }: {
|
|
|
|
|
rule: MerchantRule;
|
|
|
|
|
onDelete: (rule: MerchantRule) => void;
|
|
|
|
|
onToggleAutoAttr: (rule: MerchantRule, enabled: boolean) => void;
|
|
|
|
|
deleting: number | string | null;
|
|
|
|
|
togglingAutoAttr: number | string | null;
|
|
|
|
|
}) {
|
|
|
|
|
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">
|
|
|
|
|
@ -65,7 +102,7 @@ function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, toggling
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ConflictWarning({ conflicts }) {
|
|
|
|
|
function ConflictWarning({ conflicts }: { conflicts?: Conflict[] }) {
|
|
|
|
|
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">
|
|
|
|
|
@ -84,7 +121,7 @@ function ConflictWarning({ conflicts }) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PreviewBadge({ count, loading, error }) {
|
|
|
|
|
function PreviewBadge({ count, loading, error }: { count: number | null; loading: boolean; error: boolean }) {
|
|
|
|
|
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;
|
|
|
|
|
@ -100,24 +137,28 @@ function PreviewBadge({ count, loading, error }) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function BillMerchantRules({ billId, billName, onRulesChanged }) {
|
|
|
|
|
const [rules, setRules] = useState([]);
|
|
|
|
|
const [suggestions, setSuggestions] = useState([]);
|
|
|
|
|
export default function BillMerchantRules({ billId, billName, onRulesChanged }: {
|
|
|
|
|
billId: number | string;
|
|
|
|
|
billName?: string;
|
|
|
|
|
onRulesChanged?: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [rules, setRules] = useState<MerchantRule[]>([]);
|
|
|
|
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [deleting, setDeleting] = useState(null);
|
|
|
|
|
const [deleting, setDeleting] = useState<number | string | null>(null);
|
|
|
|
|
const [adding, setAdding] = useState(false);
|
|
|
|
|
const [input, setInput] = useState('');
|
|
|
|
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
|
|
|
const [previewCount, setPreviewCount] = useState(null);
|
|
|
|
|
const [previewCount, setPreviewCount] = useState<number | null>(null);
|
|
|
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
|
|
|
const [previewError, setPreviewError] = useState(false);
|
|
|
|
|
const [conflicts, setConflicts] = useState([]);
|
|
|
|
|
const [retroFeedback, setRetroFeedback] = useState(null);
|
|
|
|
|
const [conflicts, setConflicts] = useState<Conflict[]>([]);
|
|
|
|
|
const [retroFeedback, setRetroFeedback] = useState<number | null>(null);
|
|
|
|
|
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
|
|
|
|
|
const [togglingAutoAttr, setTogglingAutoAttr] = useState(null);
|
|
|
|
|
const [liveResults, setLiveResults] = useState([]);
|
|
|
|
|
const [togglingAutoAttr, setTogglingAutoAttr] = useState<number | string | null>(null);
|
|
|
|
|
const [liveResults, setLiveResults] = useState<Suggestion[]>([]);
|
|
|
|
|
const [liveSearching, setLiveSearching] = useState(false);
|
|
|
|
|
const inputRef = useRef(null);
|
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
const debouncedInput = useDebounce(input.trim(), 380);
|
|
|
|
|
|
|
|
|
|
@ -125,7 +166,7 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|
|
|
|
if (!billId) return;
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const data = await api.billMerchantRules(billId);
|
|
|
|
|
const data = await api.billMerchantRules(billId) as { rules?: MerchantRule[]; suggestions?: Suggestion[] };
|
|
|
|
|
setRules(data.rules || []);
|
|
|
|
|
setSuggestions(data.suggestions || []);
|
|
|
|
|
} catch {
|
|
|
|
|
@ -148,9 +189,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|
|
|
|
setPreviewLoading(true);
|
|
|
|
|
setPreviewError(false);
|
|
|
|
|
api.previewMerchantRule(billId, debouncedInput)
|
|
|
|
|
.then(data => {
|
|
|
|
|
.then(raw => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setPreviewCount(data.match_count);
|
|
|
|
|
const data = raw as { match_count?: number; conflicts?: Conflict[] };
|
|
|
|
|
setPreviewCount(data.match_count ?? null);
|
|
|
|
|
setConflicts(data.conflicts || []);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
@ -169,9 +211,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
setLiveSearching(true);
|
|
|
|
|
api.subscriptionTransactionMatches({ q: debouncedInput, limit: 20 })
|
|
|
|
|
.then(data => {
|
|
|
|
|
.then(raw => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
const rows = Array.isArray(data) ? data : (data?.transactions ?? []);
|
|
|
|
|
const data = raw as SubTx[] | { transactions?: SubTx[] };
|
|
|
|
|
const rows: SubTx[] = 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 || '',
|
|
|
|
|
@ -187,16 +230,16 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|
|
|
|
|
|
|
|
|
// Popover handles its own outside-click dismissal — no manual handler needed
|
|
|
|
|
|
|
|
|
|
async function handleAdd(merchantText) {
|
|
|
|
|
async function handleAdd(merchantText?: string) {
|
|
|
|
|
const text = (merchantText || input).trim();
|
|
|
|
|
if (!text) return;
|
|
|
|
|
setAdding(true);
|
|
|
|
|
setRetroFeedback(null);
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.addMerchantRule(billId, text);
|
|
|
|
|
const result = await api.addMerchantRule(billId, text) as { rule?: MerchantRule };
|
|
|
|
|
setRules(prev => {
|
|
|
|
|
if (prev.some(r => r.id === result.rule?.id)) return prev;
|
|
|
|
|
return [...prev, result.rule].filter(Boolean);
|
|
|
|
|
return [...prev, result.rule].filter(Boolean) as MerchantRule[];
|
|
|
|
|
});
|
|
|
|
|
setInput('');
|
|
|
|
|
setPreviewCount(null);
|
|
|
|
|
@ -207,13 +250,13 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|
|
|
|
setShowHistoricalDialog(true);
|
|
|
|
|
onRulesChanged?.();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Failed to add rule');
|
|
|
|
|
toast.error(errMessage(err, 'Failed to add rule'));
|
|
|
|
|
} finally {
|
|
|
|
|
setAdding(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleDelete(rule) {
|
|
|
|
|
async function handleDelete(rule: MerchantRule) {
|
|
|
|
|
setDeleting(rule.id);
|
|
|
|
|
try {
|
|
|
|
|
await api.deleteMerchantRule(billId, rule.id);
|
|
|
|
|
@ -221,13 +264,13 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|
|
|
|
toast.success(`Rule "${rule.merchant}" removed`);
|
|
|
|
|
onRulesChanged?.();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Failed to remove rule');
|
|
|
|
|
toast.error(errMessage(err, 'Failed to remove rule'));
|
|
|
|
|
} finally {
|
|
|
|
|
setDeleting(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleToggleAutoAttr(rule, enabled) {
|
|
|
|
|
async function handleToggleAutoAttr(rule: MerchantRule, enabled: boolean) {
|
|
|
|
|
setTogglingAutoAttr(rule.id);
|
|
|
|
|
try {
|
|
|
|
|
await api.toggleRuleAutoAttribute(billId, rule.id, enabled);
|
|
|
|
|
@ -236,14 +279,14 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|
|
|
|
? `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');
|
|
|
|
|
toast.error(errMessage(err, 'Failed to update rule'));
|
|
|
|
|
} finally {
|
|
|
|
|
setTogglingAutoAttr(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickSuggestion(s) {
|
|
|
|
|
setInput(s.label);
|
|
|
|
|
function pickSuggestion(s: Suggestion) {
|
|
|
|
|
setInput(s.label || '');
|
|
|
|
|
setShowSuggestions(false);
|
|
|
|
|
inputRef.current?.focus();
|
|
|
|
|
}
|
|
|
|
|
@ -272,7 +315,6 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|
|
|
|
<RuleChip
|
|
|
|
|
key={rule.id}
|
|
|
|
|
rule={rule}
|
|
|
|
|
billId={billId}
|
|
|
|
|
onDelete={handleDelete}
|
|
|
|
|
onToggleAutoAttr={handleToggleAutoAttr}
|
|
|
|
|
deleting={deleting}
|
|
|
|
|
@ -344,7 +386,7 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|
|
|
|
<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">
|
|
|
|
|
{fmt(amountVal)}
|
|
|
|
|
{fmt(asDollars(amountVal))}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|