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