diff --git a/client/pages/SummaryPage.jsx b/client/pages/SummaryPage.tsx similarity index 85% rename from client/pages/SummaryPage.jsx rename to client/pages/SummaryPage.tsx index dc64025..c438291 100644 --- a/client/pages/SummaryPage.jsx +++ b/client/pages/SummaryPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, type DragEvent, type DragEventHandler } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useSummary } from '@/hooks/useQueries'; import { toast } from 'sonner'; @@ -22,22 +22,87 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { cn, fmt } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; +import { formatUSD, asDollars } from '@/lib/money'; import { moveInArray, reorderPayload } from '@/lib/reorder'; +function fmt(v: number | null | undefined): string { + return formatUSD(asDollars(Number(v) || 0)); +} + +interface SummaryExpense { + bill_id: number; + name: string; + category_name?: string | null; + due_day?: number | null; + autopay_enabled?: number; + is_subscription?: number; + has_merchant_rule?: unknown; + has_linked_transactions?: unknown; + is_paid?: boolean; + is_skipped?: boolean; + actual_amount?: number | null; + display_amount?: number; +} + +interface SummaryChartRow { + type: string; + amount: number; +} + +interface StartingAmounts { + first_amount?: number; + fifteenth_amount?: number; + other_amount?: number; + combined_amount?: number; + paid_total?: number; + combined_remaining?: number; +} + +interface SummarySummary { + expense_total?: number; + result?: number; + paid_expense_count?: number; + expense_count?: number; + [key: string]: unknown; +} + +interface SummaryResponse { + year: number; + month: number; + summary?: SummarySummary; + expenses?: SummaryExpense[]; + starting_amounts?: StartingAmounts; + income?: { amount?: number; label?: string }; + chart?: SummaryChartRow[]; + generated_at?: string; + previous_month?: { combined_remaining?: number }; + [key: string]: unknown; +} + +interface ExpenseMoveControls { + enabled: boolean; + moving: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + onMoveUp: () => void; + onMoveDown: () => void; +} + +interface ExpenseDragProps { + draggable: boolean; + isDragging?: boolean; + isDropTarget?: boolean; + onDragStart?: DragEventHandler; + onDragEnter?: DragEventHandler; + onDragOver?: DragEventHandler; + onDragEnd?: DragEventHandler; + onDrop?: DragEventHandler; +} + const MONTHS = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', ]; function selectedFromToday() { @@ -45,20 +110,20 @@ function selectedFromToday() { return { year: now.getFullYear(), month: now.getMonth() + 1 }; } -function shiftMonth(year, month, delta) { +function shiftMonth(year: number, month: number, delta: number) { const next = new Date(year, month - 1 + delta, 1); return { year: next.getFullYear(), month: next.getMonth() + 1 }; } -function monthLabel(year, month) { +function monthLabel(year: number, month: number): string { return `${MONTHS[month - 1]} ${year}`; } -function moneyClass(value) { +function moneyClass(value: number): string { return value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'; } -function StatusMark({ expense }) { +function StatusMark({ expense }: { expense: SummaryExpense }) { if (expense.is_skipped) { return ( @@ -84,7 +149,7 @@ function StatusMark({ expense }) { ); } -function SummaryChart({ rows = [], onStartingClick }) { +function SummaryChart({ rows = [], onStartingClick }: { rows?: SummaryChartRow[]; onStartingClick?: () => void }) { const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0))); const chartRows = rows.map((row, index) => ({ ...row, @@ -138,7 +203,11 @@ function SummaryChart({ rows = [], onStartingClick }) { ); } -function ExpenseRow({ expense, moveControls, dragProps }) { +function ExpenseRow({ expense, moveControls, dragProps }: { + expense: SummaryExpense; + moveControls?: ExpenseMoveControls; + dragProps?: ExpenseDragProps; +}) { return (
{expense.name} - {expense.autopay_enabled && ( + {expense.autopay_enabled ? ( @@ -198,8 +267,8 @@ function ExpenseRow({ expense, moveControls, dragProps }) { Autopay enabled - )} - {expense.is_subscription && ( + ) : null} + {expense.is_subscription ? ( @@ -208,8 +277,8 @@ function ExpenseRow({ expense, moveControls, dragProps }) { Subscription - )} - {(expense.has_merchant_rule || expense.has_linked_transactions) && ( + ) : null} + {(expense.has_merchant_rule || expense.has_linked_transactions) ? ( @@ -218,7 +287,7 @@ function ExpenseRow({ expense, moveControls, dragProps }) { Linked to bank transactions - )} + ) : null}
@@ -238,9 +307,13 @@ function ExpenseRow({ expense, moveControls, dragProps }) { export default function SummaryPage() { const [selected, setSelected] = useState(selectedFromToday); const queryClient = useQueryClient(); - const { data = null, isPending: loading, error: queryError } = useSummary(selected.year, selected.month); - const setData = useCallback((u) => queryClient.setQueryData(['summary', selected.year, selected.month], - prev => (typeof u === 'function' ? u(prev) : u)), [queryClient, selected.year, selected.month]); + const { data: dataRaw, isPending: loading, error: queryError } = useSummary(selected.year, selected.month); + const data = (dataRaw ?? null) as SummaryResponse | null; + const setData = useCallback( + (updater: (prev: SummaryResponse | null | undefined) => SummaryResponse | null | undefined) => + queryClient.setQueryData(['summary', selected.year, selected.month], updater), + [queryClient, selected.year, selected.month], + ); const [saving, setSaving] = useState(false); const error = queryError ? (queryError.message || 'Summary could not be loaded.') : ''; const [startingFirst, setStartingFirst] = useState('0'); @@ -250,9 +323,9 @@ export default function SummaryPage() { const [incomeAmount, setIncomeAmount] = useState('0'); const [incomeLabel, setIncomeLabel] = useState('Salary'); const [editingIncome, setEditingIncome] = useState(false); - const [draggingId, setDraggingId] = useState(null); - const [dropTargetId, setDropTargetId] = useState(null); - const [movingBillId, setMovingBillId] = useState(null); + const [draggingId, setDraggingId] = useState(null); + const [dropTargetId, setDropTargetId] = useState(null); + const [movingBillId, setMovingBillId] = useState(null); const loadSummary = useCallback( () => queryClient.invalidateQueries({ queryKey: ['summary', selected.year, selected.month] }), @@ -260,8 +333,7 @@ export default function SummaryPage() { ); // Seed the editable form fields (starting amounts, income) from the loaded - // summary and reset transient edit/drag state whenever the data changes — - // this is what loadSummary used to do inline. + // summary and reset transient edit/drag state whenever the data changes. useEffect(() => { if (!data) return; setStartingFirst(String(data.starting_amounts?.first_amount ?? 0)); @@ -307,7 +379,7 @@ export default function SummaryPage() { toast.success('Starting amounts saved.'); await loadSummary(); } catch (err) { - toast.error(err.message || 'Starting amounts could not be saved.'); + toast.error(errMessage(err, 'Starting amounts could not be saved.')); } finally { setSaving(false); } @@ -327,13 +399,13 @@ export default function SummaryPage() { setEditingIncome(false); await loadSummary(); } catch (err) { - toast.error(err.message || 'Income could not be saved.'); + toast.error(errMessage(err, 'Income could not be saved.')); } finally { setSaving(false); } } - function moveMonth(delta) { + function moveMonth(delta: number) { setSelected(current => shiftMonth(current.year, current.month, delta)); } @@ -341,7 +413,7 @@ export default function SummaryPage() { setSelected(selectedFromToday()); } - async function persistExpenseOrder(nextExpenses, movedId) { + async function persistExpenseOrder(nextExpenses: SummaryExpense[], movedId: number | null) { setData(prev => prev ? { ...prev, expenses: nextExpenses } : prev); setMovingBillId(movedId); try { @@ -349,20 +421,20 @@ export default function SummaryPage() { toast.success('Summary order saved'); await loadSummary(); } catch (err) { - toast.error(err.message || 'Failed to save summary order.'); + toast.error(errMessage(err, 'Failed to save summary order.')); await loadSummary(); } finally { setMovingBillId(null); } } - function reorderExpenses(fromIndex, toIndex) { + function reorderExpenses(fromIndex: number, toIndex: number) { if (!reorderEnabled || fromIndex === toIndex) return; const nextExpenses = moveInArray(expenses, fromIndex, toIndex); persistExpenseOrder(nextExpenses, expenses[fromIndex]?.bill_id || null); } - function moveControlsFor(expense, index) { + function moveControlsFor(expense: SummaryExpense, index: number): ExpenseMoveControls { return { enabled: reorderEnabled, moving: movingBillId === expense.bill_id, @@ -373,13 +445,13 @@ export default function SummaryPage() { }; } - function dragPropsFor(expense, index) { + function dragPropsFor(expense: SummaryExpense, index: number): ExpenseDragProps { if (!reorderEnabled) return { draggable: false }; return { draggable: true, isDragging: draggingId === expense.bill_id, isDropTarget: dropTargetId === expense.bill_id && draggingId !== expense.bill_id, - onDragStart: (event) => { + onDragStart: (event: DragEvent) => { setDraggingId(expense.bill_id); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(expense.bill_id)); @@ -387,12 +459,12 @@ export default function SummaryPage() { onDragEnter: () => { if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id); }, - onDragOver: (event) => { + onDragOver: (event: DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id); }, - onDrop: (event) => { + onDrop: (event: DragEvent) => { event.preventDefault(); const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId); const fromIndex = expenses.findIndex(item => item.bill_id === sourceId); @@ -551,33 +623,15 @@ export default function SummaryPage() {