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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, type ComponentType } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react';
|
import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react';
|
||||||
import { api } from '@/api';
|
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} 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 = {
|
const STATUS_META = {
|
||||||
unmatched: { label: 'Unmatched', className: 'text-muted-foreground', icon: null },
|
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_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 },
|
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 },
|
payment_exists: { label: 'Payment exists', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 },
|
||||||
};
|
} satisfies Record<string, StatusMeta>;
|
||||||
|
|
||||||
function StatusChip({ candidate }) {
|
function StatusChip({ candidate }: { candidate: Candidate }) {
|
||||||
const meta = STATUS_META[candidate.status] ?? STATUS_META.unmatched;
|
const meta: StatusMeta = STATUS_META[candidate.status as keyof typeof STATUS_META] ?? STATUS_META.unmatched;
|
||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const label = candidate.status === 'matched_other_bill'
|
const label = candidate.status === 'matched_other_bill'
|
||||||
? `Matched to ${candidate.matched_bill_name || 'another bill'}`
|
? `Matched to ${candidate.matched_bill_name || 'another bill'}`
|
||||||
|
|
@ -34,11 +50,19 @@ function StatusChip({ candidate }) {
|
||||||
|
|
||||||
// ── Main dialog ───────────────────────────────────────────────────────────────
|
// ── Main dialog ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }) {
|
interface BillHistoricalImportDialogProps {
|
||||||
const [step, setStep] = useState('choice'); // 'choice' | 'pick'
|
billId: number;
|
||||||
const [candidates, setCandidates] = useState([]);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [selected, setSelected] = useState(new Set());
|
const [selected, setSelected] = useState<Set<number | string>>(new Set());
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
// Load candidates whenever the dialog opens
|
// Load candidates whenever the dialog opens
|
||||||
|
|
@ -48,11 +72,12 @@ export default function BillHistoricalImportDialog({ billId, billName, open, onC
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.merchantRuleCandidates(billId)
|
api.merchantRuleCandidates(billId)
|
||||||
.then(data => {
|
.then(raw => {
|
||||||
|
const data = raw as { candidates?: Candidate[] };
|
||||||
// Pre-select importable candidates (not already a payment for this bill)
|
// 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 || []);
|
setCandidates(data.candidates || []);
|
||||||
setSelected(new Set(importable.map(c => c.id)));
|
setSelected(new Set(importableList.map(c => c.id)));
|
||||||
})
|
})
|
||||||
.catch(() => setCandidates([]))
|
.catch(() => setCandidates([]))
|
||||||
.finally(() => setLoading(false));
|
.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 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');
|
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; }
|
if (ids.length === 0) { onClose(); return; }
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
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}`);
|
toast.success(`${result.imported} payment${result.imported === 1 ? '' : 's'} imported for ${billName}`);
|
||||||
onImported?.(result);
|
onImported?.(result);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Import failed');
|
toast.error(errMessage(err, 'Import failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAll(checked) {
|
function toggleAll(checked: boolean) {
|
||||||
setSelected(checked ? new Set(importable.map(c => c.id)) : new Set());
|
setSelected(checked ? new Set(importable.map(c => c.id)) : new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(id) {
|
function toggle(id: number | string) {
|
||||||
setSelected(prev => {
|
setSelected(prev => {
|
||||||
const next = new Set(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;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,27 @@
|
||||||
|
import React from 'react';
|
||||||
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
|
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { scheduleLabel } from '@/lib/billingSchedule';
|
import { scheduleLabel } from '@/lib/billingSchedule';
|
||||||
import { formatUSDWhole } from '@/lib/money';
|
import { formatUSDWhole } from '@/lib/money';
|
||||||
import { MobileBillRow } from '@/components/MobileBillRow';
|
import { MobileBillRow } from '@/components/MobileBillRow';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
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);
|
const d = Number(n);
|
||||||
if (!d) return '—';
|
if (!d) return '—';
|
||||||
if (d > 3 && d < 21) return `${d}th`;
|
if (d > 3 && d < 21) return `${d}th`;
|
||||||
|
|
@ -17,11 +33,11 @@ function ordinal(n) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasHistoricalVisibility(bill) {
|
function hasHistoricalVisibility(bill: Bill): boolean {
|
||||||
return !!bill.has_history_ranges || (bill.history_visibility && bill.history_visibility !== 'default');
|
return !!bill.has_history_ranges || (!!bill.history_visibility && bill.history_visibility !== 'default');
|
||||||
}
|
}
|
||||||
|
|
||||||
function AprColor({ rate }) {
|
function AprColor({ rate }: { rate: number }) {
|
||||||
const cls =
|
const cls =
|
||||||
rate >= 25 ? 'text-rose-400' :
|
rate >= 25 ? 'text-rose-400' :
|
||||||
rate >= 15 ? 'text-amber-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>;
|
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,
|
showCategory: true, showDueDay: true, showAmount: true, showCycle: true,
|
||||||
showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: 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 isDebt = bill.current_balance != null || bill.minimum_payment != null;
|
||||||
const hasHistory = hasHistoricalVisibility(bill);
|
const hasHistory = hasHistoricalVisibility(bill);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable={dragProps?.draggable}
|
draggable={dragProps?.draggable}
|
||||||
onDragStart={dragProps?.onDragStart}
|
onDragStart={dragProps?.onDragStart as React.ComponentProps<'div'>['onDragStart']}
|
||||||
onDragEnter={dragProps?.onDragEnter}
|
onDragEnter={dragProps?.onDragEnter as React.ComponentProps<'div'>['onDragEnter']}
|
||||||
onDragOver={dragProps?.onDragOver}
|
onDragOver={dragProps?.onDragOver as React.ComponentProps<'div'>['onDragOver']}
|
||||||
onDragEnd={dragProps?.onDragEnd}
|
onDragEnd={dragProps?.onDragEnd as React.ComponentProps<'div'>['onDragEnd']}
|
||||||
onDrop={dragProps?.onDrop}
|
onDrop={dragProps?.onDrop as React.ComponentProps<'div'>['onDrop']}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center gap-3 px-5 py-3.5 transition-colors',
|
'group flex items-center gap-3 px-5 py-3.5 transition-colors',
|
||||||
'hover:bg-accent/20',
|
'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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="hidden divide-y divide-border/30 sm:block">
|
<div className="hidden divide-y divide-border/30 sm:block">
|
||||||
Loading…
Reference in New Issue