diff --git a/client/pages/BillsPage.jsx b/client/pages/BillsPage.tsx similarity index 87% rename from client/pages/BillsPage.jsx rename to client/pages/BillsPage.tsx index 9f865a0..67d6165 100644 --- a/client/pages/BillsPage.jsx +++ b/client/pages/BillsPage.tsx @@ -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(() => { 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(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 (