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:
parent
6a9b8b3cb8
commit
9ad8cc7e8a
|
|
@ -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);
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
Loading…
Reference in New Issue