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:
null 2026-07-04 22:08:05 -05:00
parent 4d380c8da0
commit d4eb48de92
3 changed files with 77 additions and 35 deletions

View File

@ -51,7 +51,7 @@ function StatusChip({ candidate }: { candidate: Candidate }) {
// ── Main dialog ───────────────────────────────────────────────────────────────
interface BillHistoricalImportDialogProps {
billId: number;
billId: number | string;
billName: string;
open: boolean;
onClose: () => void;

View File

@ -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>
);

View File

@ -65,7 +65,7 @@ export default function LinkedTransactionsSection({
</div>
<div className="px-3 py-3">
<BillMerchantRules
billId={billId}
billId={billId!}
billName={billName}
onRulesChanged={onRulesChanged}
/>