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:
parent
3b1e7e4da9
commit
aa9c357fdf
|
|
@ -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 {
|
||||||
Loading…
Reference in New Issue