refactor(ts): convert SearchFilterPanel, RecentlyDeletedBillsDialog, BillRulesManager to TSX (B15)

SearchFilterPanel (presentational props), RecentlyDeletedBillsDialog (Bill[],
days_left cast, formatUSD on Dollars), BillRulesManager (MerchantRule/RuleGroup,
reduce<Record<number,RuleGroup>> with `?? (acc[key]=…)` to satisfy
noUncheckedIndexedAccess). typecheck 0, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 20:42:24 -05:00
parent 6a9b8b3cb8
commit 9ad8cc7e8a
3 changed files with 55 additions and 18 deletions

View File

@ -1,22 +1,36 @@
import React, { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ChevronDown, Trash2, ToggleLeft, ToggleRight, Settings2 } from 'lucide-react'; import { ChevronDown, Trash2, ToggleLeft, ToggleRight, Settings2 } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
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[];
}
export default function BillRulesManager() { export default function BillRulesManager() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [rules, setRules] = useState([]); const [rules, setRules] = useState<MerchantRule[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const d = await api.allBillMerchantRules(); const d = await api.allBillMerchantRules() as { rules?: MerchantRule[] };
setRules(d.rules || []); setRules(d.rules || []);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load bill matching rules'); toast.error(errMessage(err, 'Failed to load bill matching rules'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -24,32 +38,32 @@ export default function BillRulesManager() {
useEffect(() => { if (open) load(); }, [open, load]); useEffect(() => { if (open) load(); }, [open, load]);
const handleDelete = async (billId, ruleId, merchant) => { const handleDelete = async (billId: number, ruleId: number, merchant: string) => {
try { try {
await api.deleteMerchantRule(billId, ruleId); await api.deleteMerchantRule(billId, ruleId);
setRules(prev => prev.filter(r => r.id !== ruleId)); setRules(prev => prev.filter(r => r.id !== ruleId));
toast.success(`Rule "${merchant}" removed`); toast.success(`Rule "${merchant}" removed`);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to delete rule'); toast.error(errMessage(err, 'Failed to delete rule'));
} }
}; };
const handleToggleAutoLate = async (billId, ruleId, current) => { const handleToggleAutoLate = async (billId: number, ruleId: number, current: boolean) => {
try { try {
await api.toggleRuleAutoAttribute(billId, ruleId, !current); await api.toggleRuleAutoAttribute(billId, ruleId, !current);
setRules(prev => prev.map(r => setRules(prev => prev.map(r =>
r.id === ruleId ? { ...r, auto_attribute_late: current ? 0 : 1 } : r r.id === ruleId ? { ...r, auto_attribute_late: current ? 0 : 1 } : r
)); ));
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update rule'); toast.error(errMessage(err, 'Failed to update rule'));
} }
}; };
// Group rules by bill // Group rules by bill
const byBill = rules.reduce((acc, r) => { const byBill = rules.reduce<Record<number, RuleGroup>>((acc, r) => {
const key = r.bill_id; const key = r.bill_id;
if (!acc[key]) acc[key] = { bill_name: r.bill_name, bill_id: r.bill_id, rules: [] }; const g = acc[key] ?? (acc[key] = { bill_name: r.bill_name, bill_id: r.bill_id, rules: [] });
acc[key].rules.push(r); g.rules.push(r);
return acc; return acc;
}, {}); }, {});
const groups = Object.values(byBill); const groups = Object.values(byBill);

View File

@ -5,14 +5,22 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { formatUSD } from '@/lib/money'; import { formatUSD } from '@/lib/money';
import type { Bill } from '@/types';
function daysLeftLabel(days) { function daysLeftLabel(days: number | null | undefined): string | null {
if (days == null) return null; if (days == null) return null;
if (days <= 0) return 'purges today'; if (days <= 0) return 'purges today';
if (days === 1) return '1 day left'; if (days === 1) return '1 day left';
return `${days} days left`; return `${days} days left`;
} }
interface RecentlyDeletedBillsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bills?: Bill[];
onRestore: (bill: Bill) => void | Promise<void>;
}
/** /**
* Lists bills that were soft-deleted within the 30-day recovery window and lets * Lists bills that were soft-deleted within the 30-day recovery window and lets
* the user restore them a durable path beyond the transient "Undo" toast. * the user restore them a durable path beyond the transient "Undo" toast.
@ -20,10 +28,10 @@ function daysLeftLabel(days) {
* Presentational: the parent owns the list (`bills`) and the async `onRestore`, * Presentational: the parent owns the list (`bills`) and the async `onRestore`,
* so restoring refreshes the page's active bills too. * so restoring refreshes the page's active bills too.
*/ */
export default function RecentlyDeletedBillsDialog({ open, onOpenChange, bills = [], onRestore }) { export default function RecentlyDeletedBillsDialog({ open, onOpenChange, bills = [], onRestore }: RecentlyDeletedBillsDialogProps) {
const [busyId, setBusyId] = useState(null); const [busyId, setBusyId] = useState<number | null>(null);
async function handleRestore(bill) { async function handleRestore(bill: Bill) {
setBusyId(bill.id); setBusyId(bill.id);
try { try {
await onRestore(bill); await onRestore(bill);
@ -51,7 +59,7 @@ export default function RecentlyDeletedBillsDialog({ open, onOpenChange, bills =
) : ( ) : (
<ul className="-mx-2 max-h-[55vh] divide-y divide-border/60 overflow-y-auto"> <ul className="-mx-2 max-h-[55vh] divide-y divide-border/60 overflow-y-auto">
{bills.map(bill => { {bills.map(bill => {
const left = daysLeftLabel(bill.days_left); const left = daysLeftLabel(bill.days_left as number | null | undefined);
const busy = busyId === bill.id; const busy = busyId === bill.id;
return ( return (
<li key={bill.id} className="flex items-center gap-3 px-2 py-2.5"> <li key={bill.id} className="flex items-center gap-3 px-2 py-2.5">

View File

@ -1,7 +1,22 @@
import type { ReactNode } from 'react';
import { ChevronDown, ChevronUp, Search, X } from 'lucide-react'; import { ChevronDown, ChevronUp, Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface SearchFilterPanelProps {
title?: string;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
hasFilters?: boolean;
resultLabel?: string;
sortLabel?: string;
onClear?: () => void;
children?: ReactNode;
className?: string;
variant?: 'default' | 'embedded';
headerActions?: ReactNode;
}
export default function SearchFilterPanel({ export default function SearchFilterPanel({
title = 'Search & filters', title = 'Search & filters',
collapsed, collapsed,
@ -14,7 +29,7 @@ export default function SearchFilterPanel({
className, className,
variant = 'default', variant = 'default',
headerActions, headerActions,
}) { }: SearchFilterPanelProps) {
const embedded = variant === 'embedded'; const embedded = variant === 'embedded';
const ToggleIcon = collapsed ? ChevronUp : ChevronDown; const ToggleIcon = collapsed ? ChevronUp : ChevronDown;