refactor(ts): BillsPage → TypeScript

Bill list/reorder/history-visibility page typed: Bill[] from useBills,
BillFilters/DisplayPrefs/ModalState/HistoryRange local types, drag +
move-controls typed via DragProps/MoveControls, makeBillDraft(...) as
Partial<Bill> at the modal call sites, setQueryData<Bill[]> for
optimistic list edits, errMessage(unknown) for strict catches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 23:35:48 -05:00
parent 3b1e7e4da9
commit aa9c357fdf
1 changed files with 125 additions and 74 deletions

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useBills, useCategories, useBillTemplates, useDeletedBills } from '@/hooks/useQueries';
@ -20,14 +20,19 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { cn, errMessage } from '@/lib/utils';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
import BillsTableInner from '@/components/BillsTableInner';
import BillModal from '@/components/BillModal';
import RecentlyDeletedBillsDialog from '@/components/RecentlyDeletedBillsDialog';
import { makeBillDraft } from '@/lib/billDrafts';
import { makeBillDraft, type SourceBill } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
import type { Bill } from '@/types';
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
interface BuiltInTemplate { id: string; name: string; data: SourceBill; categoryKeywords: string[] }
interface BillTemplate { id: number | string; name: string; data?: SourceBill; categoryKeywords?: string[] }
const VISIBILITY_OPTIONS = [
{ value: 'default', label: 'Default', description: 'Use the app default behavior for inactive bill history.' },
@ -35,13 +40,13 @@ const VISIBILITY_OPTIONS = [
{ value: 'none', label: 'Show no history', description: 'Hide historical tracker data for this inactive bill.' },
{ value: 'ranges', label: 'Show only selected date ranges', description: 'Show history only for the ranges listed below.' },
];
const MONTH_OPTIONS = [
const MONTH_OPTIONS: [string, string][] = [
['1', 'Jan'], ['2', 'Feb'], ['3', 'Mar'], ['4', 'Apr'], ['5', 'May'], ['6', 'Jun'],
['7', 'Jul'], ['8', 'Aug'], ['9', 'Sep'], ['10', 'Oct'], ['11', 'Nov'], ['12', 'Dec'],
];
const FILTER_ALL = 'all';
const BUILT_IN_TEMPLATES = [
const BUILT_IN_TEMPLATES: BuiltInTemplate[] = [
{
id: 'builtin-utility',
name: 'Utility',
@ -122,7 +127,26 @@ const BUILT_IN_TEMPLATES = [
// BillModal is imported from @/components/BillModal
function blankRange() {
interface HistoryRange {
id?: number;
_key: string;
start_year: string;
start_month: string;
end_year: string;
end_month: string;
label: string;
}
interface RawRange {
id?: number;
_key?: string;
start_year?: number | string | null;
start_month?: number | string | null;
end_year?: number | string | null;
end_month?: number | string | null;
label?: string | null;
}
function blankRange(): HistoryRange {
const year = String(new Date().getFullYear());
return {
_key: `new-${Date.now()}-${Math.random().toString(16).slice(2)}`,
@ -134,7 +158,7 @@ function blankRange() {
};
}
function normalizeRange(range) {
function normalizeRange(range: RawRange): HistoryRange {
return {
...range,
_key: range._key || String(range.id),
@ -146,7 +170,7 @@ function normalizeRange(range) {
};
}
function rangePayload(range) {
function rangePayload(range: HistoryRange) {
return {
start_year: parseInt(range.start_year, 10),
start_month: parseInt(range.start_month, 10),
@ -156,7 +180,7 @@ function rangePayload(range) {
};
}
function validateRange(range) {
function validateRange(range: HistoryRange): string | null {
const payload = rangePayload(range);
if (!Number.isInteger(payload.start_year) || payload.start_year < 2000 || payload.start_year > 2100) {
return 'Start year must be between 2000 and 2100.';
@ -167,7 +191,7 @@ function validateRange(range) {
if ((payload.end_year == null) !== (payload.end_month == null)) {
return 'End year and end month must both be provided or both left blank.';
}
if (payload.end_year != null) {
if (payload.end_year != null && payload.end_month != null) {
if (payload.end_year < 2000 || payload.end_year > 2100) {
return 'End year must be between 2000 and 2100.';
}
@ -185,7 +209,20 @@ function validateRange(range) {
const PREFS_KEY = 'bills-display-prefs-v1';
const PREFS_DEFAULTS = {
interface DisplayPrefs {
showCategory: boolean;
showDueDay: boolean;
showAmount: boolean;
showCycle: boolean;
showApr: boolean;
showBalance: boolean;
showMinPayment: boolean;
showAutopay: boolean;
show2fa: boolean;
showSubscription: boolean;
}
const PREFS_DEFAULTS: DisplayPrefs = {
showCategory: true,
showDueDay: true,
showAmount: true,
@ -198,7 +235,7 @@ const PREFS_DEFAULTS = {
showSubscription: true,
};
const PREFS_LABELS = [
const PREFS_LABELS: [keyof DisplayPrefs, string][] = [
['showCategory', 'Category'],
['showDueDay', 'Due day'],
['showAmount', 'Amount'],
@ -212,7 +249,7 @@ const PREFS_LABELS = [
];
function useDisplayPrefs() {
const [prefs, setPrefs] = useState(() => {
const [prefs, setPrefs] = useState<DisplayPrefs>(() => {
try {
const raw = localStorage.getItem(PREFS_KEY);
return raw ? { ...PREFS_DEFAULTS, ...JSON.parse(raw) } : PREFS_DEFAULTS;
@ -221,10 +258,10 @@ function useDisplayPrefs() {
}
});
const toggle = (key) => {
const toggle = (key: keyof DisplayPrefs) => {
setPrefs(prev => {
const next = { ...prev, [key]: !prev[key] };
try { localStorage.setItem(PREFS_KEY, JSON.stringify(next)); } catch {}
try { localStorage.setItem(PREFS_KEY, JSON.stringify(next)); } catch { /* ignore quota errors */ }
return next;
});
};
@ -232,14 +269,14 @@ function useDisplayPrefs() {
return { prefs, toggle };
}
function DisplayPrefsPanel({ prefs, onToggle }) {
function DisplayPrefsPanel({ prefs, onToggle }: { prefs: DisplayPrefs; onToggle: (key: keyof DisplayPrefs) => void }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
@ -290,7 +327,7 @@ function DisplayPrefsPanel({ prefs, onToggle }) {
);
}
function amountSearchText(...values) {
function amountSearchText(...values: (number | string | null | undefined)[]): string {
return values
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
.flatMap(value => {
@ -300,14 +337,14 @@ function amountSearchText(...values) {
.join(' ');
}
function billIsDebt(bill) {
function billIsDebt(bill: Bill): boolean {
const category = String(bill.category_name || '').toLowerCase();
return Number(bill.current_balance) > 0
|| bill.minimum_payment != null
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
}
function FilterChip({ active, children, onClick }) {
function FilterChip({ active, children, onClick }: { active: boolean; children: React.ReactNode; onClick: () => void }) {
return (
<button
type="button"
@ -327,7 +364,7 @@ function FilterChip({ active, children, onClick }) {
const BILLS_SORT_KEY = 'bills_sort_mode';
const BILLS_CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other'];
function normalizedBillCadence(bill) {
function normalizedBillCadence(bill: Bill): string {
const raw = String(bill?.cycle_type || bill?.billing_cycle || '').toLowerCase();
if (raw.includes('week') && raw.includes('bi')) return 'biweekly';
if (raw === 'biweekly') return 'biweekly';
@ -338,12 +375,12 @@ function normalizedBillCadence(bill) {
return 'other';
}
function billCadenceIndex(bill) {
function billCadenceIndex(bill: Bill): number {
const index = BILLS_CADENCE_ORDER.indexOf(normalizedBillCadence(bill));
return index >= 0 ? index : BILLS_CADENCE_ORDER.length - 1;
}
function sortBillsByCadence(items) {
function sortBillsByCadence(items: Bill[]): Bill[] {
return [...items].sort((a, b) => (
billCadenceIndex(a) - billCadenceIndex(b)
|| (Number(a.due_day) || 0) - (Number(b.due_day) || 0)
@ -351,7 +388,7 @@ function sortBillsByCadence(items) {
));
}
function SortModeButton({ active, children, onClick }) {
function SortModeButton({ active, children, onClick }: { active: boolean; children: React.ReactNode; onClick: () => void }) {
return (
<button
type="button"
@ -371,10 +408,10 @@ function SortModeButton({ active, children, onClick }) {
// ─────────────────────────────────────────────────────────────────────────────
function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
const [visibility, setVisibility] = useState(bill?.history_visibility || 'default');
const [ranges, setRanges] = useState([]);
const [deletedIds, setDeletedIds] = useState([]);
function HistoryVisibilityDialog({ bill, onClose, onSaved }: { bill: Bill; onClose: () => void; onSaved: () => void }) {
const [visibility, setVisibility] = useState<string>((bill?.history_visibility as string) || 'default');
const [ranges, setRanges] = useState<HistoryRange[]>([]);
const [deletedIds, setDeletedIds] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -382,25 +419,26 @@ function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
let mounted = true;
setLoading(true);
api.billHistoryRanges(bill.id)
.then(data => {
.then(raw => {
if (!mounted) return;
setVisibility(data.history_visibility || bill.history_visibility || 'default');
const data = raw as { history_visibility?: string; ranges?: RawRange[] };
setVisibility(data.history_visibility || (bill.history_visibility as string) || 'default');
setRanges((data.ranges || []).map(normalizeRange));
})
.catch(err => {
toast.error(err.message || 'Failed to load history visibility.');
toast.error(errMessage(err, 'Failed to load history visibility.'));
onClose();
})
.finally(() => mounted && setLoading(false));
.finally(() => { if (mounted) setLoading(false); });
return () => { mounted = false; };
}, [bill, onClose]);
const updateRange = (key, patch) => {
const updateRange = (key: string, patch: Partial<HistoryRange>) => {
setRanges(prev => prev.map(r => r._key === key ? { ...r, ...patch } : r));
};
const deleteRange = (range) => {
if (range.id) setDeletedIds(prev => [...prev, range.id]);
const deleteRange = (range: HistoryRange) => {
if (range.id) setDeletedIds(prev => [...prev, range.id!]);
setRanges(prev => prev.filter(r => r._key !== range._key));
};
@ -437,7 +475,7 @@ function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
onSaved();
onClose();
} catch (err) {
toast.error(err.message || 'Failed to save history visibility.');
toast.error(errMessage(err, 'Failed to save history visibility.'));
} finally {
setSaving(false);
}
@ -598,6 +636,18 @@ function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
);
}
interface ModalState { bill: Bill | null; initialBill?: Partial<Bill> }
type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'debt' | 'inactive';
interface BillFilters {
category: string;
cycle: string;
autopay: boolean;
firstBucket: boolean;
fifteenthBucket: boolean;
debt: boolean;
inactive: boolean;
}
// ── Bills Page ─────────────────────────────────────────────────────────────
export default function BillsPage() {
const [searchParams] = useSearchParams();
@ -607,11 +657,12 @@ export default function BillsPage() {
// list edits below write to the cache via setQueryData.
const { data: bills = [], isPending: billsPending } = useBills();
const { data: categories = [] } = useCategories();
const { data: savedTemplates = [] } = useBillTemplates();
const { data: savedTemplatesRaw = [] } = useBillTemplates();
const savedTemplates = savedTemplatesRaw as BillTemplate[];
const { data: deletedBills = [] } = useDeletedBills();
const loading = billsPending;
const setBills = useCallback(
(updater) => queryClient.setQueryData(['bills'], prev => (
(updater: Bill[] | ((prev: Bill[]) => Bill[])) => queryClient.setQueryData<Bill[]>(['bills'], prev => (
typeof updater === 'function' ? updater(prev || []) : updater
)),
[queryClient],
@ -625,10 +676,10 @@ export default function BillsPage() {
const [showDeleted, setShowDeleted] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [search, setSearch] = useState('');
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
const [filters, setFilters] = useState({
const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropTargetId, setDropTargetId] = useState<number | null>(null);
const [movingBillId, setMovingBillId] = useState<number | string | null>(null);
const [filters, setFilters] = useState<BillFilters>({
category: FILTER_ALL,
cycle: FILTER_ALL,
autopay: false,
@ -639,18 +690,18 @@ export default function BillsPage() {
});
// modal state: null = closed, { bill: null } = add new, { bill: {...} } = edit
const [modal, setModal] = useState(null);
const [modal, setModal] = useState<ModalState | null>(null);
// Bill pending deactivation confirmation (AlertDialog replaces window.confirm)
const [deactivate, setDeactivate] = useState(null);
const [deactivate, setDeactivate] = useState<Bill | null>(null);
const [deactivateReason, setDeactivateReason] = useState('');
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState<Bill | null>(null);
const [deleteConfirmed, setDeleteConfirmed] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const [historyTarget, setHistoryTarget] = useState(null);
const [historyTarget, setHistoryTarget] = useState<Bill | null>(null);
const { prefs, toggle: togglePref } = useDisplayPrefs();
const [billsSort, setBillsSort] = useState(() => (
const [billsSort, setBillsSort] = useState<'custom' | 'cadence'>(() => (
localStorage.getItem(BILLS_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
));
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
@ -674,8 +725,8 @@ export default function BillsPage() {
if (filters.inactive) setShowInactive(true);
}, [filters.inactive]);
const toggleFilter = (key) => setFilters(prev => ({ ...prev, [key]: !prev[key] }));
const setFilterValue = (key, value) => setFilters(prev => ({ ...prev, [key]: value }));
const toggleFilter = (key: BoolFilterKey) => setFilters(prev => ({ ...prev, [key]: !prev[key] }));
const setFilterValue = (key: 'category' | 'cycle', value: string) => setFilters(prev => ({ ...prev, [key]: value }));
const hasFilters = !!(
search.trim()
|| filters.category !== FILTER_ALL
@ -700,34 +751,34 @@ export default function BillsPage() {
});
};
async function handleEdit(billId) {
async function handleEdit(billId: number) {
try {
const bill = await api.bill(billId);
setModal({ bill });
} catch (err) {
toast.error(err.message);
toast.error(errMessage(err, 'Failed to load bill'));
}
}
function handleDuplicateBill(bill) {
setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) });
function handleDuplicateBill(bill: Bill) {
setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) as Partial<Bill> });
}
function handleTemplateSelect(value) {
function handleTemplateSelect(value: string) {
if (!value || value === 'placeholder') return;
const builtIn = BUILT_IN_TEMPLATES.find(template => template.id === value);
if (builtIn) {
setModal({ bill: null, initialBill: makeBillDraft(builtIn.data, { template: builtIn, categories }) });
setModal({ bill: null, initialBill: makeBillDraft(builtIn.data, { template: builtIn, categories }) as Partial<Bill> });
return;
}
const saved = savedTemplates.find(template => `saved-${template.id}` === value);
if (saved) {
setModal({ bill: null, initialBill: makeBillDraft(saved.data, { template: saved, categories }) });
setModal({ bill: null, initialBill: makeBillDraft(saved.data, { template: saved, categories }) as Partial<Bill> });
}
}
function handleToggle(bill) {
function handleToggle(bill: Bill) {
if (bill.active) {
// Prompt confirmation before deactivating
setDeactivate(bill);
@ -736,20 +787,20 @@ export default function BillsPage() {
}
}
async function doToggle(bill, reason) {
async function doToggle(bill: Bill | null, reason?: string) {
if (!bill) return;
try {
const payload = { active: bill.active ? 0 : 1 };
const payload: { active: number; inactive_reason?: string } = { active: bill.active ? 0 : 1 };
if (bill.active && reason) payload.inactive_reason = reason;
await api.updateBill(bill.id, payload);
toast.success(bill.active ? 'Bill deactivated' : 'Bill activated');
refresh();
} catch (err) {
toast.error(err.message);
toast.error(errMessage(err, 'Failed to update bill'));
}
}
function handleDeleteRequest(bill) {
function handleDeleteRequest(bill: Bill) {
setDeleteConfirmed(false);
setDeleteTarget(bill);
}
@ -771,7 +822,7 @@ export default function BillsPage() {
toast.success(`"${bill.name}" restored`);
refresh();
} catch (err) {
toast.error(err.message || 'Failed to restore bill');
toast.error(errMessage(err, 'Failed to restore bill'));
}
},
},
@ -780,19 +831,19 @@ export default function BillsPage() {
setDeleteConfirmed(false);
refresh();
} catch (err) {
toast.error(err.message || 'Failed to delete bill');
toast.error(errMessage(err, 'Failed to delete bill'));
} finally {
setDeleteBusy(false);
}
}
async function handleRestoreDeleted(bill) {
async function handleRestoreDeleted(bill: Bill) {
try {
await api.restoreBill(bill.id);
toast.success(`"${bill.name}" restored`);
await refresh();
} catch (err) {
toast.error(err.message || 'Failed to restore bill');
toast.error(errMessage(err, 'Failed to restore bill'));
}
}
@ -805,7 +856,7 @@ export default function BillsPage() {
}
const cycleOptions = useMemo(() => (
Array.from(new Set(bills.map(scheduleValue))).sort()
Array.from(new Set(bills.map(bill => scheduleValue(bill)))).sort()
), [bills]);
const filteredBills = useMemo(() => {
@ -842,7 +893,7 @@ export default function BillsPage() {
const sortedActive = billsSort === 'cadence' ? sortBillsByCadence(active) : active;
const sortedInactive = billsSort === 'cadence' ? sortBillsByCadence(inactive) : inactive;
async function persistBillOrder(nextBills, movedId) {
async function persistBillOrder(nextBills: Bill[], movedId: string | number | null) {
setBills(nextBills);
setMovingBillId(movedId);
try {
@ -850,23 +901,23 @@ export default function BillsPage() {
toast.success('Bill order saved');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to save bill order');
toast.error(errMessage(err, 'Failed to save bill order'));
refresh();
} finally {
setMovingBillId(null);
}
}
function reorderBillGroup(activeState, orderedGroup) {
function reorderBillGroup(activeState: boolean, orderedGroup: Bill[]) {
const sourceGroup = bills.filter(bill => !!bill.active === activeState);
const replacements = [...orderedGroup];
const nextBills = bills.map(bill => (
!!bill.active === activeState ? replacements.shift() : bill
!!bill.active === activeState ? replacements.shift()! : bill
));
persistBillOrder(nextBills, movedItemId(sourceGroup, orderedGroup));
}
function moveControlsForGroup(group, activeState) {
function moveControlsForGroup(group: Bill[], activeState: boolean): (bill: Bill, index: number) => MoveControls {
return (bill, index) => ({
enabled: reorderEnabled,
moving: movingBillId === bill.id,
@ -877,7 +928,7 @@ export default function BillsPage() {
});
}
function dragPropsForGroup(group, activeState) {
function dragPropsForGroup(group: Bill[], activeState: boolean): (bill: Bill, index: number) => DragProps {
return (bill, index) => {
if (!reorderEnabled) return { draggable: false };
return {