refactor(ts): BillHistoricalImportDialog + BillsTableInner → TypeScript
Branded Dollars on historical-import candidate amounts; typed BillPrefs + MoveControls/DragProps on the bills table. typecheck 0.
This commit is contained in:
parent
41e847a33d
commit
4d380c8da0
|
|
@ -1,24 +1,40 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, type ComponentType } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||
import type { Dollars } from '@/lib/money';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface StatusMeta {
|
||||
label: string | null;
|
||||
className: string;
|
||||
icon: ComponentType<{ className?: string }> | null;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
id: number | string;
|
||||
status?: string;
|
||||
payee?: string | null;
|
||||
paid_date?: string | null;
|
||||
amount: Dollars;
|
||||
matched_bill_name?: string | null;
|
||||
}
|
||||
|
||||
const STATUS_META = {
|
||||
unmatched: { label: 'Unmatched', className: 'text-muted-foreground', icon: null },
|
||||
matched_this_bill: { label: 'Already linked', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 },
|
||||
matched_other_bill:{ label: null, className: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle },
|
||||
payment_exists: { label: 'Payment exists', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 },
|
||||
};
|
||||
} satisfies Record<string, StatusMeta>;
|
||||
|
||||
function StatusChip({ candidate }) {
|
||||
const meta = STATUS_META[candidate.status] ?? STATUS_META.unmatched;
|
||||
function StatusChip({ candidate }: { candidate: Candidate }) {
|
||||
const meta: StatusMeta = STATUS_META[candidate.status as keyof typeof STATUS_META] ?? STATUS_META.unmatched;
|
||||
const Icon = meta.icon;
|
||||
const label = candidate.status === 'matched_other_bill'
|
||||
? `Matched to ${candidate.matched_bill_name || 'another bill'}`
|
||||
|
|
@ -34,11 +50,19 @@ function StatusChip({ candidate }) {
|
|||
|
||||
// ── Main dialog ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }) {
|
||||
const [step, setStep] = useState('choice'); // 'choice' | 'pick'
|
||||
const [candidates, setCandidates] = useState([]);
|
||||
interface BillHistoricalImportDialogProps {
|
||||
billId: number;
|
||||
billName: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onImported?: (result: { imported?: number }) => void;
|
||||
}
|
||||
|
||||
export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }: BillHistoricalImportDialogProps) {
|
||||
const [step, setStep] = useState<'choice' | 'pick'>('choice');
|
||||
const [candidates, setCandidates] = useState<Candidate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [selected, setSelected] = useState<Set<number | string>>(new Set());
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
// Load candidates whenever the dialog opens
|
||||
|
|
@ -48,11 +72,12 @@ export default function BillHistoricalImportDialog({ billId, billName, open, onC
|
|||
setSelected(new Set());
|
||||
setLoading(true);
|
||||
api.merchantRuleCandidates(billId)
|
||||
.then(data => {
|
||||
.then(raw => {
|
||||
const data = raw as { candidates?: Candidate[] };
|
||||
// Pre-select importable candidates (not already a payment for this bill)
|
||||
const importable = (data.candidates || []).filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill');
|
||||
const importableList = (data.candidates || []).filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill');
|
||||
setCandidates(data.candidates || []);
|
||||
setSelected(new Set(importable.map(c => c.id)));
|
||||
setSelected(new Set(importableList.map(c => c.id)));
|
||||
})
|
||||
.catch(() => setCandidates([]))
|
||||
.finally(() => setLoading(false));
|
||||
|
|
@ -61,29 +86,30 @@ export default function BillHistoricalImportDialog({ billId, billName, open, onC
|
|||
const importable = candidates.filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill');
|
||||
const alreadyDone = candidates.filter(c => c.status === 'payment_exists' || c.status === 'matched_this_bill');
|
||||
|
||||
async function doImport(ids) {
|
||||
async function doImport(ids: (number | string)[]) {
|
||||
if (ids.length === 0) { onClose(); return; }
|
||||
setImporting(true);
|
||||
try {
|
||||
const result = await api.importHistoricalPayments(billId, ids);
|
||||
const result = await api.importHistoricalPayments(billId, ids) as { imported?: number };
|
||||
toast.success(`${result.imported} payment${result.imported === 1 ? '' : 's'} imported for ${billName}`);
|
||||
onImported?.(result);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Import failed');
|
||||
toast.error(errMessage(err, 'Import failed'));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll(checked) {
|
||||
function toggleAll(checked: boolean) {
|
||||
setSelected(checked ? new Set(importable.map(c => c.id)) : new Set());
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
function toggle(id: number | string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +1,27 @@
|
|||
import React from 'react';
|
||||
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { scheduleLabel } from '@/lib/billingSchedule';
|
||||
import { formatUSDWhole } from '@/lib/money';
|
||||
import { MobileBillRow } from '@/components/MobileBillRow';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { Bill } from '@/types';
|
||||
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
|
||||
|
||||
function ordinal(n) {
|
||||
interface BillPrefs {
|
||||
showCategory?: boolean;
|
||||
showDueDay?: boolean;
|
||||
showAmount?: boolean;
|
||||
showCycle?: boolean;
|
||||
showApr?: boolean;
|
||||
showBalance?: boolean;
|
||||
showMinPayment?: boolean;
|
||||
showAutopay?: boolean;
|
||||
show2fa?: boolean;
|
||||
showSubscription?: boolean;
|
||||
}
|
||||
|
||||
function ordinal(n: number | null | undefined): string {
|
||||
const d = Number(n);
|
||||
if (!d) return '—';
|
||||
if (d > 3 && d < 21) return `${d}th`;
|
||||
|
|
@ -17,11 +33,11 @@ function ordinal(n) {
|
|||
}
|
||||
}
|
||||
|
||||
function hasHistoricalVisibility(bill) {
|
||||
return !!bill.has_history_ranges || (bill.history_visibility && bill.history_visibility !== 'default');
|
||||
function hasHistoricalVisibility(bill: Bill): boolean {
|
||||
return !!bill.has_history_ranges || (!!bill.history_visibility && bill.history_visibility !== 'default');
|
||||
}
|
||||
|
||||
function AprColor({ rate }) {
|
||||
function AprColor({ rate }: { rate: number }) {
|
||||
const cls =
|
||||
rate >= 25 ? 'text-rose-400' :
|
||||
rate >= 15 ? 'text-amber-400' :
|
||||
|
|
@ -29,23 +45,35 @@ function AprColor({ rate }) {
|
|||
return <span className={cn('tracker-number text-[11px] font-semibold', cls)}>{rate}% APR</span>;
|
||||
}
|
||||
|
||||
const ALL_ON = {
|
||||
const ALL_ON: BillPrefs = {
|
||||
showCategory: true, showDueDay: true, showAmount: true, showCycle: true,
|
||||
showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true,
|
||||
};
|
||||
|
||||
function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) {
|
||||
interface BillCardProps {
|
||||
bill: Bill;
|
||||
prefs?: BillPrefs;
|
||||
onEdit?: (id: number) => void;
|
||||
onToggle?: (bill: Bill) => void;
|
||||
onDelete?: (bill: Bill) => void;
|
||||
onHistory?: (bill: Bill) => void;
|
||||
onDuplicate?: (bill: Bill) => void;
|
||||
moveControls?: MoveControls;
|
||||
dragProps?: DragProps;
|
||||
}
|
||||
|
||||
function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }: BillCardProps) {
|
||||
const isDebt = bill.current_balance != null || bill.minimum_payment != null;
|
||||
const hasHistory = hasHistoricalVisibility(bill);
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={dragProps?.draggable}
|
||||
onDragStart={dragProps?.onDragStart}
|
||||
onDragEnter={dragProps?.onDragEnter}
|
||||
onDragOver={dragProps?.onDragOver}
|
||||
onDragEnd={dragProps?.onDragEnd}
|
||||
onDrop={dragProps?.onDrop}
|
||||
onDragStart={dragProps?.onDragStart as React.ComponentProps<'div'>['onDragStart']}
|
||||
onDragEnter={dragProps?.onDragEnter as React.ComponentProps<'div'>['onDragEnter']}
|
||||
onDragOver={dragProps?.onDragOver as React.ComponentProps<'div'>['onDragOver']}
|
||||
onDragEnd={dragProps?.onDragEnd as React.ComponentProps<'div'>['onDragEnd']}
|
||||
onDrop={dragProps?.onDrop as React.ComponentProps<'div'>['onDrop']}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 px-5 py-3.5 transition-colors',
|
||||
'hover:bg-accent/20',
|
||||
|
|
@ -260,7 +288,19 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
|
|||
);
|
||||
}
|
||||
|
||||
export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControlsFor, dragPropsFor }) {
|
||||
interface BillsTableInnerProps {
|
||||
bills: Bill[];
|
||||
prefs?: BillPrefs;
|
||||
onEdit?: (id: number) => void;
|
||||
onToggle?: (bill: Bill) => void;
|
||||
onDelete?: (bill: Bill) => void;
|
||||
onHistory?: (bill: Bill) => void;
|
||||
onDuplicate?: (bill: Bill) => void;
|
||||
moveControlsFor?: (bill: Bill, index: number) => MoveControls | undefined;
|
||||
dragPropsFor?: (bill: Bill, index: number) => DragProps | undefined;
|
||||
}
|
||||
|
||||
export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControlsFor, dragPropsFor }: BillsTableInnerProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="hidden divide-y divide-border/30 sm:block">
|
||||
Loading…
Reference in New Issue