refactor(ts): BillMerchantRules → TypeScript
Typed MerchantRule/Conflict/Suggestion + generic useDebounce; billId widened to Id (number|string) to match the api layer; asserted billId at the BillModal call site (rendered only for existing bills). Dropped unused RuleChip billId prop. typecheck 0.
This commit is contained in:
parent
4d380c8da0
commit
d4eb48de92
|
|
@ -51,7 +51,7 @@ function StatusChip({ candidate }: { candidate: Candidate }) {
|
|||
// ── Main dialog ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface BillHistoricalImportDialogProps {
|
||||
billId: number;
|
||||
billId: number | string;
|
||||
billName: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -65,7 +65,7 @@ export default function LinkedTransactionsSection({
|
|||
</div>
|
||||
<div className="px-3 py-3">
|
||||
<BillMerchantRules
|
||||
billId={billId}
|
||||
billId={billId!}
|
||||
billName={billName}
|
||||
onRulesChanged={onRulesChanged}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue