refactor(ts): SubscriptionsPage → TypeScript (final .jsx page)

The last .jsx page — 0 remain under client/. Recurring-services view
typed: Subscription/SubSummary/Recommendation/CatalogMatch/SubTx local
types, useOptimistic<Subscription[]> typed reducer, virtualizer
FlatItem discriminated union, drag/move-controls via DragProps/
MoveControls, setQueryData<T> optimistic caches, local branded fmt.
Fixed daysUntil date subtraction (getTime), guarded optional .length
chains, cast api.* results at boundaries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 23:48:06 -05:00
parent aa9c357fdf
commit 8229d21ab8
1 changed files with 307 additions and 103 deletions

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, type ComponentType, type ComponentProps, type ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useSubscriptions, useSubscriptionRecommendations, useCategories, useBills } from '@/hooks/useQueries'; import { useSubscriptions, useSubscriptionRecommendations, useCategories, useBills } from '@/hooks/useQueries';
@ -27,7 +27,8 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt, fmtDate, localDateString } from '@/lib/utils'; import { cn, fmtDate, localDateString, errMessage } from '@/lib/utils';
import { formatUSD, asDollars } from '@/lib/money';
import { scheduleLabel } from '@/lib/billingSchedule'; import { scheduleLabel } from '@/lib/billingSchedule';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@ -52,8 +53,14 @@ import { getLinkImportPref } from '@/pages/SettingsPage';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
import type { Bill, Category } from '@/types';
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
const TYPE_LABELS = { function fmt(v: number | null | undefined): string {
return formatUSD(asDollars(Number(v) || 0));
}
const TYPE_LABELS: Record<string, string> = {
streaming: 'Streaming', streaming: 'Streaming',
software: 'Software', software: 'Software',
cloud: 'Cloud', cloud: 'Cloud',
@ -70,9 +77,142 @@ const TYPE_LABELS = {
other: 'Other', other: 'Other',
}; };
function typeLabel(t: string | null | undefined): string {
return (t ? TYPE_LABELS[t] : undefined) || 'Other';
}
// ── Domain types ─────────────────────────────────────────────────────────────
interface CadenceLike {
cycle_type?: string | null;
billing_cycle?: string | null;
due_day?: number | null;
cycle_day?: number | string | null;
expected_amount?: number | null;
name?: string | null;
[key: string]: unknown;
}
interface Subscription {
id: number;
name: string;
active?: boolean | number;
is_subscription?: boolean | number;
category_id?: number | null;
category_name?: string | null;
expected_amount?: number | null;
monthly_equivalent?: number | null;
yearly_equivalent?: number | null;
next_due_date?: string | null;
subscription_type?: string;
due_day?: number | null;
cycle_type?: string | null;
billing_cycle?: string | null;
cycle_day?: number | string | null;
autopay_enabled?: unknown;
has_2fa?: unknown;
has_merchant_rule?: unknown;
has_linked_transactions?: unknown;
reminder_days_before?: number | null;
[key: string]: unknown;
}
interface TopType { type: string; monthly_total: number }
interface SubSummary {
active_count?: number;
paused_count?: number;
monthly_total?: number;
yearly_total?: number;
top_type?: TopType | null;
}
interface SubData { summary?: SubSummary; subscriptions?: Subscription[] }
interface CatalogMatch {
id?: number | string;
name: string;
subscription_type?: string;
website?: string;
starting_monthly_usd?: number | null;
starting_annual_usd?: number | null;
}
interface ExistingBillMatch {
id: number;
name: string;
expected_amount?: number | null;
due_day?: number | null;
reasons?: string[];
}
interface RecEvidence {
identity?: { label?: string };
amount?: { label?: string; match?: string };
cadence?: { label?: string; recurring?: boolean };
amount_range?: { min: number; max: number };
ambiguity?: { ambiguous?: boolean; label?: string; reasons?: string[] };
}
interface RecTransaction { id: number; payee?: string; description?: string; memo?: string; date?: string; account?: string; amount?: number }
interface Recommendation {
id: number | string;
name: string;
subscription_type?: string;
occurrence_count?: number;
last_seen_date?: string;
expected_amount?: number | null;
monthly_equivalent?: number | null;
confidence?: number;
cycle_type?: string;
merchant?: string;
decline_key?: string;
transaction_ids?: (number | string)[];
accounts?: string[];
reasons?: string[];
evidence?: RecEvidence;
existing_bill_match?: ExistingBillMatch | null;
catalog_match?: CatalogMatch | null;
transactions?: RecTransaction[];
[key: string]: unknown;
}
interface SubTx {
id: number;
amount: number;
payee?: string;
description?: string;
memo?: string;
account_name?: string;
data_source_name?: string;
account?: string;
match_status?: string;
matched_bill_name?: string;
catalog_match?: CatalogMatch | null;
posted_date?: string;
date?: string;
[key: string]: unknown;
}
interface SubDraft {
name?: string;
category_id?: number | null;
due_day?: number;
expected_amount?: string;
billing_cycle?: string;
cycle_type?: string;
cycle_day?: string;
is_subscription?: number;
subscription_type?: string;
website?: string;
notes?: string;
reminder_days_before?: number;
}
interface ModalState { bill: Subscription | null; initialBill?: SubDraft }
type RecAction = (rec: Recommendation) => void | Promise<void>;
type QuickLink = (rec: Recommendation, billId: number) => void | Promise<void>;
// ── Helpers ──────────────────────────────────────────────────────────────────
const SUBSCRIPTION_SORT_KEY = 'subscriptions_sort_mode'; const SUBSCRIPTION_SORT_KEY = 'subscriptions_sort_mode';
const CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other']; const CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other'];
const CADENCE_LABELS = { const CADENCE_LABELS: Record<string, string> = {
weekly: 'Weekly', weekly: 'Weekly',
biweekly: 'Biweekly', biweekly: 'Biweekly',
monthly: 'Monthly', monthly: 'Monthly',
@ -81,7 +221,7 @@ const CADENCE_LABELS = {
other: 'Other', other: 'Other',
}; };
const SUBSCRIPTION_MONTHLY_FACTORS = { const SUBSCRIPTION_MONTHLY_FACTORS: Record<string, number> = {
weekly: 52 / 12, weekly: 52 / 12,
biweekly: 26 / 12, biweekly: 26 / 12,
monthly: 1, monthly: 1,
@ -90,7 +230,7 @@ const SUBSCRIPTION_MONTHLY_FACTORS = {
annually: 1 / 12, annually: 1 / 12,
}; };
function normalizedCadence(item) { function normalizedCadence(item: CadenceLike): string {
const raw = String(item?.cycle_type || item?.billing_cycle || '').toLowerCase(); const raw = String(item?.cycle_type || item?.billing_cycle || '').toLowerCase();
if (raw.includes('week') && raw.includes('bi')) return 'biweekly'; if (raw.includes('week') && raw.includes('bi')) return 'biweekly';
if (raw === 'biweekly') return 'biweekly'; if (raw === 'biweekly') return 'biweekly';
@ -101,7 +241,7 @@ function normalizedCadence(item) {
return 'other'; return 'other';
} }
function subscriptionMonthlyEquivalent(item) { function subscriptionMonthlyEquivalent(item: CadenceLike): number {
const key = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase(); const key = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase();
const fallback = String(item?.billing_cycle || '').toLowerCase() === 'quarterly' const fallback = String(item?.billing_cycle || '').toLowerCase() === 'quarterly'
? 'quarterly' ? 'quarterly'
@ -112,7 +252,7 @@ function subscriptionMonthlyEquivalent(item) {
return Math.round(Number(item?.expected_amount || 0) * factor * 100) / 100; return Math.round(Number(item?.expected_amount || 0) * factor * 100) / 100;
} }
function subscriptionNextDueDate(item, now = new Date()) { function subscriptionNextDueDate(item: CadenceLike, now = new Date()): string {
const dueDay = Math.min(Math.max(Number(item?.due_day) || 1, 1), 31); const dueDay = Math.min(Math.max(Number(item?.due_day) || 1, 1), 31);
const cycle = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase(); const cycle = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase();
let date = new Date(now.getFullYear(), now.getMonth(), dueDay); let date = new Date(now.getFullYear(), now.getMonth(), dueDay);
@ -130,7 +270,7 @@ function subscriptionNextDueDate(item, now = new Date()) {
return localDateString(date); return localDateString(date);
} }
function decorateSavedSubscriptionBill(bill, categories) { function decorateSavedSubscriptionBill(bill: Bill, categories: Category[]): Subscription {
const monthly = subscriptionMonthlyEquivalent(bill); const monthly = subscriptionMonthlyEquivalent(bill);
const category = categories.find(item => Number(item.id) === Number(bill.category_id)); const category = categories.find(item => Number(item.id) === Number(bill.category_id));
return { return {
@ -141,14 +281,14 @@ function decorateSavedSubscriptionBill(bill, categories) {
monthly_equivalent: monthly, monthly_equivalent: monthly,
yearly_equivalent: Math.round(monthly * 12 * 100) / 100, yearly_equivalent: Math.round(monthly * 12 * 100) / 100,
next_due_date: subscriptionNextDueDate(bill), next_due_date: subscriptionNextDueDate(bill),
subscription_type: bill.subscription_type || 'other', subscription_type: (bill.subscription_type as string | undefined) || 'other',
}; };
} }
function subscriptionSummaryFromList(subscriptions) { function subscriptionSummaryFromList(subscriptions: Subscription[]): SubSummary {
const active = subscriptions.filter(item => item.active); const active = subscriptions.filter(item => item.active);
const monthlyTotal = active.reduce((sum, item) => sum + Number(item.monthly_equivalent || 0), 0); const monthlyTotal = active.reduce((sum, item) => sum + Number(item.monthly_equivalent || 0), 0);
const typeTotals = new Map(); const typeTotals = new Map<string, number>();
for (const item of active) { for (const item of active) {
const type = item.subscription_type || 'other'; const type = item.subscription_type || 'other';
typeTotals.set(type, (typeTotals.get(type) || 0) + Number(item.monthly_equivalent || 0)); typeTotals.set(type, (typeTotals.get(type) || 0) + Number(item.monthly_equivalent || 0));
@ -165,7 +305,7 @@ function subscriptionSummaryFromList(subscriptions) {
// Extended group order: monthly bills split by 1st/15th pay bucket // Extended group order: monthly bills split by 1st/15th pay bucket
const GROUP_ORDER = ['weekly', 'biweekly', 'monthly-1st', 'monthly-15th', 'quarterly', 'annual', 'other']; const GROUP_ORDER = ['weekly', 'biweekly', 'monthly-1st', 'monthly-15th', 'quarterly', 'annual', 'other'];
const GROUP_LABELS = { const GROUP_LABELS: Record<string, string> = {
'weekly': 'Weekly', 'weekly': 'Weekly',
'biweekly': 'Biweekly', 'biweekly': 'Biweekly',
'monthly-1st': '1st · Due days 114', 'monthly-1st': '1st · Due days 114',
@ -175,7 +315,7 @@ const GROUP_LABELS = {
'other': 'Other', 'other': 'Other',
}; };
function subscriptionGroup(item) { function subscriptionGroup(item: Subscription): string {
const cadence = normalizedCadence(item); const cadence = normalizedCadence(item);
if (cadence === 'monthly') { if (cadence === 'monthly') {
return (Number(item.due_day) || 1) <= 14 ? 'monthly-1st' : 'monthly-15th'; return (Number(item.due_day) || 1) <= 14 ? 'monthly-1st' : 'monthly-15th';
@ -183,19 +323,19 @@ function subscriptionGroup(item) {
return cadence; return cadence;
} }
function groupIndex(item) { function groupIndex(item: Subscription): number {
const idx = GROUP_ORDER.indexOf(subscriptionGroup(item)); const idx = GROUP_ORDER.indexOf(subscriptionGroup(item));
return idx >= 0 ? idx : GROUP_ORDER.length - 1; return idx >= 0 ? idx : GROUP_ORDER.length - 1;
} }
function daysUntil(dateStr) { function daysUntil(dateStr: string | null | undefined): number | null {
if (!dateStr) return null; if (!dateStr) return null;
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
return Math.round((new Date(dateStr + 'T00:00:00') - today) / 86400000); return Math.round((new Date(dateStr + 'T00:00:00').getTime() - today.getTime()) / 86400000);
} }
function sortSubscriptionsByCadence(items) { function sortSubscriptionsByCadence(items: Subscription[]): Subscription[] {
return [...items].sort((a, b) => ( return [...items].sort((a, b) => (
groupIndex(a) - groupIndex(b) groupIndex(a) - groupIndex(b)
|| (Number(a.due_day) || 0) - (Number(b.due_day) || 0) || (Number(a.due_day) || 0) - (Number(b.due_day) || 0)
@ -203,7 +343,7 @@ function sortSubscriptionsByCadence(items) {
)); ));
} }
function SortModeButton({ active, children, onClick }) { function SortModeButton({ active, children, onClick }: { active: boolean; children: ReactNode; onClick: () => void }) {
return ( return (
<button <button
type="button" type="button"
@ -221,11 +361,16 @@ function SortModeButton({ active, children, onClick }) {
); );
} }
function cycleLabel(item) { function cycleLabel(item: Subscription): string {
return scheduleLabel(item); return scheduleLabel(item);
} }
function StatCard({ icon: Icon, label, value, hint }) { function StatCard({ icon: Icon, label, value, hint }: {
icon: ComponentType<{ className?: string }>;
label: ReactNode;
value: ReactNode;
hint?: ReactNode;
}) {
return ( return (
<div className="min-w-0 rounded-lg border border-border/70 bg-card/80 p-4"> <div className="min-w-0 rounded-lg border border-border/70 bg-card/80 p-4">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-foreground/70"> <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-foreground/70">
@ -238,7 +383,12 @@ function StatCard({ icon: Icon, label, value, hint }) {
); );
} }
function SubscriptionRowActions({ item, onEdit, onToggle, busy }) { function SubscriptionRowActions({ item, onEdit, onToggle, busy }: {
item: Subscription;
onEdit: (item: Subscription) => void;
onToggle: (item: Subscription) => void;
busy?: boolean;
}) {
return ( return (
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 md:flex md:justify-end"> <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 md:flex md:justify-end">
<Button <Button
@ -274,7 +424,14 @@ function SubscriptionRowActions({ item, onEdit, onToggle, busy }) {
); );
} }
function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy }) { function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy }: {
item: Subscription;
onEdit: (item: Subscription) => void;
onToggle: (item: Subscription) => void;
moveControls?: MoveControls;
dragProps?: DragProps;
busy?: boolean;
}) {
return ( return (
<div <div
draggable={dragProps?.draggable} draggable={dragProps?.draggable}
@ -336,7 +493,7 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Badge variant="outline" className="capitalize border-primary/25 bg-primary/10 text-primary cursor-default"> <Badge variant="outline" className="capitalize border-primary/25 bg-primary/10 text-primary cursor-default">
{TYPE_LABELS[item.subscription_type] || 'Other'} {typeLabel(item.subscription_type)}
</Badge> </Badge>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Service category</TooltipContent> <TooltipContent>Service category</TooltipContent>
@ -429,9 +586,16 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy
); );
} }
function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, busy }) { function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, busy }: {
open: boolean;
onClose: () => void;
recommendation: Recommendation | null;
bills: Bill[];
onConfirm: (billId: number | null) => void;
busy: boolean;
}) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null); const [selectedId, setSelectedId] = useState<number | null>(null);
useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]); useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]);
@ -499,7 +663,7 @@ function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, bus
); );
} }
function TxResultRow({ tx, onTrack }) { function TxResultRow({ tx, onTrack }: { tx: SubTx; onTrack: (tx: SubTx) => void }) {
const dollars = fmt(Math.abs(tx.amount) / 100); const dollars = fmt(Math.abs(tx.amount) / 100);
const label = tx.payee || tx.description || tx.memo || '—'; const label = tx.payee || tx.description || tx.memo || '—';
const account = tx.account_name || tx.data_source_name || null; const account = tx.account_name || tx.data_source_name || null;
@ -544,7 +708,7 @@ function TxResultRow({ tx, onTrack }) {
</div> </div>
<p className="text-[11px] text-muted-foreground mt-0.5"> <p className="text-[11px] text-muted-foreground mt-0.5">
{tx.posted_date}{account ? ` · ${account}` : ''} {tx.posted_date}{account ? ` · ${account}` : ''}
{catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''} {catalogMatch ? ` · ${typeLabel(catalogMatch.subscription_type)}` : ''}
</p> </p>
</div> </div>
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">{dollars}</span> <span className="font-mono text-sm font-semibold tabular-nums shrink-0">{dollars}</span>
@ -558,7 +722,14 @@ function TxResultRow({ tx, onTrack }) {
); );
} }
function RecommendationIconButton({ label, icon: Icon, onClick, disabled, className, variant = 'outline' }) { function RecommendationIconButton({ label, icon: Icon, onClick, disabled, className, variant = 'outline' }: {
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
disabled?: boolean;
className?: string;
variant?: ComponentProps<typeof Button>['variant'];
}) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -579,7 +750,15 @@ function RecommendationIconButton({ label, icon: Icon, onClick, disabled, classN
); );
} }
function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept, onDecline, onMatch, categoryId }) { function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept, onDecline, onMatch, categoryId }: {
recommendation: Recommendation;
existingBill?: ExistingBillMatch | null;
busy?: boolean;
onAccept: RecAction;
onDecline: RecAction;
onMatch: RecAction;
categoryId: number | null;
}) {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -614,7 +793,16 @@ function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept,
); );
} }
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }) { function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }: {
recommendation: Recommendation;
categoryId: number | null;
onAccept: RecAction;
onDecline: RecAction;
onMatch: RecAction;
onQuickLink: QuickLink;
onDetails: (rec: Recommendation) => void;
busy?: boolean;
}) {
const identity = recommendation.evidence?.identity; const identity = recommendation.evidence?.identity;
const amount = recommendation.evidence?.amount; const amount = recommendation.evidence?.amount;
const cadence = recommendation.evidence?.cadence; const cadence = recommendation.evidence?.cadence;
@ -630,19 +818,19 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">{recommendation.name}</p> <p className="truncate text-sm font-semibold text-foreground">{recommendation.name}</p>
<p className="mt-1 text-xs font-medium text-muted-foreground"> <p className="mt-1 text-xs font-medium text-muted-foreground">
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)} {typeLabel(recommendation.subscription_type)} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)}
</p> </p>
{recommendation.catalog_match?.starting_monthly_usd && ( {recommendation.catalog_match?.starting_monthly_usd && (
<p className="mt-1 text-xs font-medium text-muted-foreground"> <p className="mt-1 text-xs font-medium text-muted-foreground">
Catalog starts at {fmt(recommendation.catalog_match.starting_monthly_usd)} / mo Catalog starts at {fmt(recommendation.catalog_match?.starting_monthly_usd)} / mo
{recommendation.catalog_match.starting_annual_usd {recommendation.catalog_match?.starting_annual_usd
? ` or ${fmt(recommendation.catalog_match.starting_annual_usd)} / yr` ? ` or ${fmt(recommendation.catalog_match?.starting_annual_usd)} / yr`
: ''} : ''}
</p> </p>
)} )}
{recommendation.accounts?.length > 0 && ( {!!recommendation.accounts?.length && (
<p className="mt-1 truncate text-xs font-medium text-muted-foreground"> <p className="mt-1 truncate text-xs font-medium text-muted-foreground">
{recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')} {(recommendation.accounts?.length ?? 0) > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts?.join(', ')}
</p> </p>
)} )}
{existingBill && ( {existingBill && (
@ -778,7 +966,7 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
); );
} }
function EvidenceItem({ label, value, tone = 'default' }) { function EvidenceItem({ label, value, tone = 'default' }: { label: string; value?: ReactNode; tone?: string }) {
if (!value) return null; if (!value) return null;
return ( return (
<div className={cn( <div className={cn(
@ -793,7 +981,17 @@ function EvidenceItem({ label, value, tone = 'default' }) {
); );
} }
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, onQuickLink, busy }) { function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, onQuickLink, busy }: {
open: boolean;
recommendation: Recommendation | null;
categoryId: number | null;
onClose: () => void;
onAccept: RecAction;
onDecline: RecAction;
onMatch: RecAction;
onQuickLink: QuickLink;
busy?: boolean;
}) {
if (!recommendation) return null; if (!recommendation) return null;
const identity = recommendation.evidence?.identity; const identity = recommendation.evidence?.identity;
@ -864,7 +1062,7 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
)} )}
</DialogTitle> </DialogTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · last seen {fmtDate(recommendation.last_seen_date)} {typeLabel(recommendation.subscription_type)} · {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · last seen {fmtDate(recommendation.last_seen_date)}
</p> </p>
</DialogHeader> </DialogHeader>
@ -900,9 +1098,9 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
{ambiguity.label || 'Review before tracking'} {ambiguity.label || 'Review before tracking'}
</p> </p>
{ambiguity.reasons?.length > 0 && ( {!!ambiguity.reasons?.length && (
<div className="mt-2 space-y-1 text-sm text-muted-foreground"> <div className="mt-2 space-y-1 text-sm text-muted-foreground">
{ambiguity.reasons.map(reason => ( {ambiguity.reasons?.map(reason => (
<p key={reason}>{reason}</p> <p key={reason}>{reason}</p>
))} ))}
</div> </div>
@ -916,9 +1114,9 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{existingBill.name} · {existingBill.expected_amount ? fmt(existingBill.expected_amount) : 'No amount'} · due day {existingBill.due_day || 'not set'} {existingBill.name} · {existingBill.expected_amount ? fmt(existingBill.expected_amount) : 'No amount'} · due day {existingBill.due_day || 'not set'}
</p> </p>
{existingBill.reasons?.length > 0 && ( {!!existingBill.reasons?.length && (
<div className="mt-2 space-y-1 text-sm text-muted-foreground"> <div className="mt-2 space-y-1 text-sm text-muted-foreground">
{existingBill.reasons.map(reason => ( {existingBill.reasons?.map(reason => (
<p key={reason}>{reason}</p> <p key={reason}>{reason}</p>
))} ))}
</div> </div>
@ -926,9 +1124,9 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
</div> </div>
)} )}
{recommendation.reasons?.length > 0 && ( {!!recommendation.reasons?.length && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{recommendation.reasons.map(reason => ( {recommendation.reasons?.map(reason => (
<span key={reason} className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground"> <span key={reason} className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
{reason} {reason}
</span> </span>
@ -994,43 +1192,49 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
); );
} }
type FlatItem =
| { type: 'group-header'; sectionKey: string; groupKey: string; activeState: boolean; groupItems: Subscription[]; monthlySum: number }
| { type: 'row'; item: Subscription; groupItems: Subscription[]; activeState: boolean; i: number }
| { type: 'paused-divider'; count: number };
export default function SubscriptionsPage() { export default function SubscriptionsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: subData, isPending: loading } = useSubscriptions(); const { data: subData, isPending: loading } = useSubscriptions();
const data = subData || { summary: {}, subscriptions: [] }; const data: SubData = (subData as SubData | undefined) || { summary: {}, subscriptions: [] };
const { data: recommendations = [], isPending: recommendationsLoading } = useSubscriptionRecommendations(); const { data: recommendationsRaw = [], isPending: recommendationsLoading } = useSubscriptionRecommendations();
const recommendations = recommendationsRaw as Recommendation[];
const { data: categories = [] } = useCategories(); const { data: categories = [] } = useCategories();
const { data: bills = [] } = useBills(); const { data: bills = [] } = useBills();
// Optimistic updates write through the query cache so every existing call site // Optimistic updates write through the query cache so every existing call site
// (setData(prev => …), setBills(…), setRecommendations(…)) works unchanged. // (setData(prev => …), setBills(…), setRecommendations(…)) works unchanged.
const setData = useCallback((u) => queryClient.setQueryData(['subscriptions'], const setData = useCallback((u: SubData | ((prev: SubData) => SubData)) => queryClient.setQueryData<SubData>(['subscriptions'],
prev => (typeof u === 'function' ? u(prev || { summary: {}, subscriptions: [] }) : u)), [queryClient]); prev => (typeof u === 'function' ? u(prev || { summary: {}, subscriptions: [] }) : u)), [queryClient]);
const setBills = useCallback((u) => queryClient.setQueryData(['bills'], const setBills = useCallback((u: Bill[] | ((prev: Bill[]) => Bill[])) => queryClient.setQueryData<Bill[]>(['bills'],
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]); prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
const setRecommendations = useCallback((u) => queryClient.setQueryData(['subscription-recommendations'], const setRecommendations = useCallback((u: Recommendation[] | ((prev: Recommendation[]) => Recommendation[])) => queryClient.setQueryData<Recommendation[]>(['subscription-recommendations'],
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]); prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
const [busyId, setBusyId] = useState(null); const [busyId, setBusyId] = useState<string | null>(null);
const [modal, setModal] = useState(null); const [modal, setModal] = useState<ModalState | null>(null);
const [matchTarget, setMatchTarget] = useState(null); const [matchTarget, setMatchTarget] = useState<Recommendation | null>(null);
const [detailsTarget, setDetailsTarget] = useState(null); const [detailsTarget, setDetailsTarget] = useState<Recommendation | null>(null);
const [recSearch, setRecSearch] = useState(''); const [recSearch, setRecSearch] = useState('');
const [subSearch, setSubSearch] = useState(''); const [subSearch, setSubSearch] = useState('');
const [subscriptionSort, setSubscriptionSort] = useState(() => ( const [subscriptionSort, setSubscriptionSort] = useState<'custom' | 'cadence'>(() => (
localStorage.getItem(SUBSCRIPTION_SORT_KEY) === 'cadence' ? 'cadence' : 'custom' localStorage.getItem(SUBSCRIPTION_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
)); ));
const [draggingId, setDraggingId] = useState(null); const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropTargetId, setDropTargetId] = useState(null); const [dropTargetId, setDropTargetId] = useState<number | null>(null);
const [movingBillId, setMovingBillId] = useState(null); const [movingBillId, setMovingBillId] = useState<number | string | null>(null);
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference(); const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
const [collapsedGroups, setCollapsedGroups] = useState(new Set()); const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const cardHeaderRef = useRef(null); const cardHeaderRef = useRef<HTMLDivElement>(null);
const [cardHeaderHeight, setCardHeaderHeight] = useState(0); const [cardHeaderHeight, setCardHeaderHeight] = useState(0);
const [txQuery, setTxQuery] = useState(''); const [txQuery, setTxQuery] = useState('');
const [txResults, setTxResults] = useState([]); const [txResults, setTxResults] = useState<SubTx[]>([]);
const [txSearching, setTxSearching] = useState(false); const [txSearching, setTxSearching] = useState(false);
const [importDialog, setImportDialog] = useState(null); // { billId, billName } const [importDialog, setImportDialog] = useState<{ billId: number; billName: string } | null>(null);
const txDebounce = useRef(null); const txDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const subscriptionCategoryId = useMemo(() => { const subscriptionCategoryId = useMemo(() => {
const match = categories.find(category => /subscrip/i.test(category.name)); const match = categories.find(category => /subscrip/i.test(category.name));
@ -1066,11 +1270,11 @@ export default function SubscriptionsPage() {
txDebounce.current = setTimeout(async () => { txDebounce.current = setTimeout(async () => {
setTxSearching(true); setTxSearching(true);
try { try {
const result = await api.subscriptionTransactionMatches({ q, limit: 50 }); const result = await api.subscriptionTransactionMatches({ q, limit: 50 }) as SubTx[] | { transactions?: SubTx[] };
setTxResults(Array.isArray(result) ? result : (result?.transactions ?? [])); setTxResults(Array.isArray(result) ? result : (result?.transactions ?? []));
} catch (err) { } catch (err) {
setTxResults([]); setTxResults([]);
toast.error(err.message || 'Transaction search failed.'); toast.error(errMessage(err, 'Transaction search failed.'));
} }
finally { setTxSearching(false); } finally { setTxSearching(false); }
}, 300); }, 300);
@ -1081,48 +1285,48 @@ export default function SubscriptionsPage() {
await Promise.all([load(), loadRecommendations()]); await Promise.all([load(), loadRecommendations()]);
} }
async function toggleSubscription(item) { async function toggleSubscription(item: Subscription) {
const newActive = !item.active; const newActive = !item.active;
addOptimisticSub({ id: item.id, active: newActive }); // instant — no spinner addOptimisticSub({ id: item.id, active: newActive }); // instant — no spinner
try { try {
await api.updateSubscription(item.id, { active: newActive }); await api.updateSubscription(item.id, { active: newActive });
await load(); // reconciles optimistic state await load(); // reconciles optimistic state
} catch (err) { } catch (err) {
toast.error(err.message || 'Subscription could not be updated.'); toast.error(errMessage(err, 'Subscription could not be updated.'));
await load(); // revert via useOptimistic reconciliation await load(); // revert via useOptimistic reconciliation
} }
} }
async function acceptRecommendation(recommendation) { async function acceptRecommendation(recommendation: Recommendation) {
setBusyId(`rec-${recommendation.id}`); setBusyId(`rec-${recommendation.id}`);
try { try {
const created = await api.createSubscriptionFromRecommendation(recommendation); const created = await api.createSubscriptionFromRecommendation(recommendation) as { id?: number; name?: string } | null;
toast.success(`${recommendation.name} is now tracked.`); toast.success(`${recommendation.name} is now tracked.`);
await refreshAll(); await refreshAll();
if (getLinkImportPref() && recommendation.merchant && created?.id) { if (getLinkImportPref() && recommendation.merchant && created?.id) {
setImportDialog({ billId: created.id, billName: created.name || recommendation.name }); setImportDialog({ billId: created.id, billName: created.name || recommendation.name });
} }
} catch (err) { } catch (err) {
toast.error(err.message || 'Could not create subscription.'); toast.error(errMessage(err, 'Could not create subscription.'));
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
} }
async function declineRecommendation(recommendation) { async function declineRecommendation(recommendation: Recommendation) {
if (!recommendation.decline_key) return; if (!recommendation.decline_key) return;
setBusyId(`dec-${recommendation.id}`); setBusyId(`dec-${recommendation.id}`);
try { try {
await api.declineRecommendation(recommendation); await api.declineRecommendation(recommendation);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id)); setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
} catch (err) { } catch (err) {
toast.error(err.message || 'Could not dismiss recommendation.'); toast.error(errMessage(err, 'Could not dismiss recommendation.'));
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
} }
async function linkRecommendationToBill(recommendation, billId) { async function linkRecommendationToBill(recommendation: Recommendation | null, billId: number | null) {
if (!recommendation || !billId) return; if (!recommendation || !billId) return;
setBusyId(`match-${recommendation.id}`); setBusyId(`match-${recommendation.id}`);
try { try {
@ -1132,22 +1336,22 @@ export default function SubscriptionsPage() {
recommendation.merchant, recommendation.merchant,
recommendation.catalog_match?.id, recommendation.catalog_match?.id,
recommendation.confidence, recommendation.confidence,
); ) as { matched_count?: number; bill_name?: string };
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`); toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
setMatchTarget(null); setMatchTarget(null);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id)); setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
} catch (err) { } catch (err) {
toast.error(err.message || 'Could not link recommendation to bill.'); toast.error(errMessage(err, 'Could not link recommendation to bill.'));
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
} }
async function matchRecommendationToBill(billId) { async function matchRecommendationToBill(billId: number | null) {
await linkRecommendationToBill(matchTarget, billId); await linkRecommendationToBill(matchTarget, billId);
} }
function applySavedBillToPage(savedBill) { function applySavedBillToPage(savedBill: Bill) {
if (!savedBill?.id) return; if (!savedBill?.id) return;
const decorated = decorateSavedSubscriptionBill(savedBill, categories); const decorated = decorateSavedSubscriptionBill(savedBill, categories);
@ -1176,7 +1380,7 @@ export default function SubscriptionsPage() {
}); });
} }
function handleBillModalSave(savedBill) { function handleBillModalSave(savedBill?: Bill) {
if (!savedBill?.id) return; if (!savedBill?.id) return;
applySavedBillToPage(savedBill); applySavedBillToPage(savedBill);
} }
@ -1199,7 +1403,7 @@ export default function SubscriptionsPage() {
}); });
} }
function openFromTransaction(tx) { function openFromTransaction(tx: SubTx) {
const catalogMatch = tx.catalog_match; const catalogMatch = tx.catalog_match;
const label = catalogMatch?.name || tx.payee || tx.description || tx.memo || ''; const label = catalogMatch?.name || tx.payee || tx.description || tx.memo || '';
const dollars = Math.abs(tx.amount ?? 0) / 100; const dollars = Math.abs(tx.amount ?? 0) / 100;
@ -1229,7 +1433,7 @@ export default function SubscriptionsPage() {
const [optimisticSubs, addOptimisticSub] = useOptimistic( const [optimisticSubs, addOptimisticSub] = useOptimistic(
subscriptions, subscriptions,
useCallback((state, { id, active }) => state.map(s => s.id === id ? { ...s, active } : s), []), useCallback((state: Subscription[], { id, active }: { id: number; active: boolean }) => state.map(s => s.id === id ? { ...s, active } : s), []),
); );
const filteredSubscriptions = useMemo(() => { const filteredSubscriptions = useMemo(() => {
@ -1256,10 +1460,10 @@ export default function SubscriptionsPage() {
const reorderEnabled = !loading && bills.length > 0 && subscriptionSort === 'custom'; const reorderEnabled = !loading && bills.length > 0 && subscriptionSort === 'custom';
// Flat item list for virtualizer — cadence mode only (custom mode needs DOM order for drag-reorder) // Flat item list for virtualizer — cadence mode only (custom mode needs DOM order for drag-reorder)
const flatVirtualItems = useMemo(() => { const flatVirtualItems = useMemo<FlatItem[] | null>(() => {
if (subscriptionSort !== 'cadence') return null; if (subscriptionSort !== 'cadence') return null;
const items = []; const items: FlatItem[] = [];
const buildGroup = (group, activeState) => { const buildGroup = (group: Subscription[], activeState: boolean) => {
GROUP_ORDER.forEach(groupKey => { GROUP_ORDER.forEach(groupKey => {
const groupItems = group.filter(item => subscriptionGroup(item) === groupKey); const groupItems = group.filter(item => subscriptionGroup(item) === groupKey);
if (!groupItems.length) return; if (!groupItems.length) return;
@ -1279,7 +1483,7 @@ export default function SubscriptionsPage() {
return items; return items;
}, [sortedActive, sortedPaused, subscriptionSort, collapsedGroups]); }, [sortedActive, sortedPaused, subscriptionSort, collapsedGroups]);
const listRef = useRef(null); const listRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: flatVirtualItems?.length ?? 0, count: flatVirtualItems?.length ?? 0,
getScrollElement: () => listRef.current, getScrollElement: () => listRef.current,
@ -1294,7 +1498,7 @@ export default function SubscriptionsPage() {
enabled: !!flatVirtualItems, enabled: !!flatVirtualItems,
}); });
async function persistSubscriptionOrder(nextSubscriptions, nextBills, movedId) { async function persistSubscriptionOrder(nextSubscriptions: Subscription[], nextBills: Bill[], movedId: string | number | null) {
const prevData = data; const prevData = data;
const prevBills = bills; const prevBills = bills;
setData(prev => ({ ...prev, subscriptions: nextSubscriptions })); setData(prev => ({ ...prev, subscriptions: nextSubscriptions }));
@ -1305,7 +1509,7 @@ export default function SubscriptionsPage() {
await load(); await load();
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {}); api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to save subscription order'); toast.error(errMessage(err, 'Failed to save subscription order'));
setData(prevData); setData(prevData);
setBills(prevBills); setBills(prevBills);
} finally { } finally {
@ -1313,24 +1517,24 @@ export default function SubscriptionsPage() {
} }
} }
function reorderSubscriptionGroup(activeState, orderedGroup) { function reorderSubscriptionGroup(activeState: boolean, orderedGroup: Subscription[]) {
const sourceGroup = subscriptions.filter(item => !!item.active === activeState); const sourceGroup = subscriptions.filter(item => !!item.active === activeState);
const replacements = [...orderedGroup]; const replacements = [...orderedGroup];
const nextSubscriptions = subscriptions.map(item => ( const nextSubscriptions = subscriptions.map(item => (
!!item.active === activeState ? replacements.shift() : item !!item.active === activeState ? replacements.shift()! : item
)); ));
const sourceBills = bills.length ? bills : subscriptions; const sourceBills: Bill[] = bills.length ? bills : (subscriptions as unknown as Bill[]);
const affectedIds = new Set(sourceGroup.map(item => item.id)); const affectedIds = new Set(sourceGroup.map(item => item.id));
const billById = new Map(sourceBills.map(item => [item.id, item])); const billById = new Map(sourceBills.map(item => [item.id, item]));
const orderedBills = orderedGroup.map(item => ({ ...(billById.get(item.id) || item), ...item })); const orderedBills = orderedGroup.map(item => ({ ...(billById.get(item.id) || item), ...item }) as unknown as Bill);
const nextBills = sourceBills.map(bill => ( const nextBills = sourceBills.map(bill => (
affectedIds.has(bill.id) ? orderedBills.shift() : bill affectedIds.has(bill.id) ? orderedBills.shift()! : bill
)); ));
persistSubscriptionOrder(nextSubscriptions, nextBills, movedItemId(sourceGroup, orderedGroup)); persistSubscriptionOrder(nextSubscriptions, nextBills, movedItemId(sourceGroup, orderedGroup));
} }
function moveControlsForGroup(group, activeState) { function moveControlsForGroup(group: Subscription[], activeState: boolean): (item: Subscription, index: number) => MoveControls {
return (item, index) => ({ return (item, index) => ({
enabled: reorderEnabled, enabled: reorderEnabled,
moving: movingBillId === item.id, moving: movingBillId === item.id,
@ -1341,7 +1545,7 @@ export default function SubscriptionsPage() {
}); });
} }
function dragPropsForGroup(group, activeState) { function dragPropsForGroup(group: Subscription[], activeState: boolean): (item: Subscription, index: number) => DragProps {
return (item, index) => { return (item, index) => {
if (!reorderEnabled) return { draggable: false }; if (!reorderEnabled) return { draggable: false };
return { return {
@ -1387,14 +1591,14 @@ export default function SubscriptionsPage() {
return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs; return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs;
}, [highConfidenceRecs, recSearch]); }, [highConfidenceRecs, recSearch]);
function renderGroupHeader(sectionKey, groupKey, groupItems, monthlySum) { function renderGroupHeader(sectionKey: string, groupKey: string, groupItems: Subscription[], monthlySum: number) {
const isCollapsed = collapsedGroups.has(sectionKey); const isCollapsed = collapsedGroups.has(sectionKey);
return ( return (
<button <button
type="button" type="button"
onClick={() => setCollapsedGroups(prev => { onClick={() => setCollapsedGroups(prev => {
const next = new Set(prev); const next = new Set(prev);
next.has(sectionKey) ? next.delete(sectionKey) : next.add(sectionKey); if (next.has(sectionKey)) next.delete(sectionKey); else next.add(sectionKey);
return next; return next;
})} })}
style={{ top: cardHeaderHeight }} style={{ top: cardHeaderHeight }}
@ -1416,7 +1620,7 @@ export default function SubscriptionsPage() {
} }
// Custom sort: normal DOM rendering (drag-reorder needs DOM order) // Custom sort: normal DOM rendering (drag-reorder needs DOM order)
function renderCustomRows(group, activeState) { function renderCustomRows(group: Subscription[], activeState: boolean) {
return group.map((item, index) => ( return group.map((item, index) => (
<SubscriptionRow <SubscriptionRow
key={item.id} key={item.id}
@ -1431,8 +1635,8 @@ export default function SubscriptionsPage() {
} }
// Cadence sort: virtualizer renders from flatVirtualItems // Cadence sort: virtualizer renders from flatVirtualItems
function renderVirtualItem(vRow) { function renderVirtualItem(vRow: { index: number }) {
const it = flatVirtualItems[vRow.index]; const it = flatVirtualItems?.[vRow.index];
if (!it) return null; if (!it) return null;
if (it.type === 'paused-divider') { if (it.type === 'paused-divider') {
return ( return (
@ -1769,8 +1973,8 @@ export default function SubscriptionsPage() {
{modal && ( {modal && (
<BillModal <BillModal
key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'} key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'}
bill={modal.bill} bill={modal.bill as Bill | null}
initialBill={modal.initialBill} initialBill={modal.initialBill as unknown as Partial<Bill> | undefined}
categories={categories} categories={categories}
onClose={() => setModal(null)} onClose={() => setModal(null)}
onSave={handleBillModalSave} onSave={handleBillModalSave}
@ -1803,8 +2007,8 @@ export default function SubscriptionsPage() {
/> />
<BillHistoricalImportDialog <BillHistoricalImportDialog
billId={importDialog?.billId} billId={importDialog?.billId ?? ''}
billName={importDialog?.billName} billName={importDialog?.billName ?? ''}
open={!!importDialog} open={!!importDialog}
onClose={() => setImportDialog(null)} onClose={() => setImportDialog(null)}
onImported={() => { setImportDialog(null); refreshAll(); }} onImported={() => { setImportDialog(null); refreshAll(); }}