refactor(ts): SummaryPage → TypeScript (React Query summary + drag-reorder expenses)

This commit is contained in:
null 2026-07-04 22:41:25 -05:00
parent c5d2aeda29
commit 8c9702adbe
1 changed files with 123 additions and 74 deletions

View File

@ -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 { useQueryClient } from '@tanstack/react-query';
import { useSummary } from '@/hooks/useQueries'; import { useSummary } from '@/hooks/useQueries';
import { toast } from 'sonner'; 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; 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'; 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<HTMLDivElement>;
onDragEnter?: DragEventHandler<HTMLDivElement>;
onDragOver?: DragEventHandler<HTMLDivElement>;
onDragEnd?: DragEventHandler<HTMLDivElement>;
onDrop?: DragEventHandler<HTMLDivElement>;
}
const MONTHS = [ const MONTHS = [
'January', 'January', 'February', 'March', 'April', 'May', 'June',
'February', 'July', 'August', 'September', 'October', 'November', 'December',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]; ];
function selectedFromToday() { function selectedFromToday() {
@ -45,20 +110,20 @@ function selectedFromToday() {
return { year: now.getFullYear(), month: now.getMonth() + 1 }; 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); const next = new Date(year, month - 1 + delta, 1);
return { year: next.getFullYear(), month: next.getMonth() + 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}`; 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'; return value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive';
} }
function StatusMark({ expense }) { function StatusMark({ expense }: { expense: SummaryExpense }) {
if (expense.is_skipped) { if (expense.is_skipped) {
return ( return (
<span className="inline-flex min-w-16 items-center justify-center rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground"> <span className="inline-flex min-w-16 items-center justify-center rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
@ -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 max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
const chartRows = rows.map((row, index) => ({ const chartRows = rows.map((row, index) => ({
...row, ...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 ( return (
<div <div
draggable={dragProps?.draggable} draggable={dragProps?.draggable}
@ -189,7 +258,7 @@ function ExpenseRow({ expense, moveControls, dragProps }) {
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-semibold text-foreground">{expense.name}</span> <span className="truncate text-sm font-semibold text-foreground">{expense.name}</span>
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
{expense.autopay_enabled && ( {expense.autopay_enabled ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300 cursor-default"> <span className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300 cursor-default">
@ -198,8 +267,8 @@ function ExpenseRow({ expense, moveControls, dragProps }) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Autopay enabled</TooltipContent> <TooltipContent>Autopay enabled</TooltipContent>
</Tooltip> </Tooltip>
)} ) : null}
{expense.is_subscription && ( {expense.is_subscription ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300 cursor-default"> <span className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300 cursor-default">
@ -208,8 +277,8 @@ function ExpenseRow({ expense, moveControls, dragProps }) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Subscription</TooltipContent> <TooltipContent>Subscription</TooltipContent>
</Tooltip> </Tooltip>
)} ) : null}
{(expense.has_merchant_rule || expense.has_linked_transactions) && ( {(expense.has_merchant_rule || expense.has_linked_transactions) ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="inline-flex shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-emerald-600 dark:text-emerald-400 cursor-default"> <span className="inline-flex shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-emerald-600 dark:text-emerald-400 cursor-default">
@ -218,7 +287,7 @@ function ExpenseRow({ expense, moveControls, dragProps }) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Linked to bank transactions</TooltipContent> <TooltipContent>Linked to bank transactions</TooltipContent>
</Tooltip> </Tooltip>
)} ) : null}
</TooltipProvider> </TooltipProvider>
</div> </div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
@ -238,9 +307,13 @@ function ExpenseRow({ expense, moveControls, dragProps }) {
export default function SummaryPage() { export default function SummaryPage() {
const [selected, setSelected] = useState(selectedFromToday); const [selected, setSelected] = useState(selectedFromToday);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data = null, isPending: loading, error: queryError } = useSummary(selected.year, selected.month); const { data: dataRaw, isPending: loading, error: queryError } = useSummary(selected.year, selected.month);
const setData = useCallback((u) => queryClient.setQueryData(['summary', selected.year, selected.month], const data = (dataRaw ?? null) as SummaryResponse | null;
prev => (typeof u === 'function' ? u(prev) : u)), [queryClient, selected.year, selected.month]); const setData = useCallback(
(updater: (prev: SummaryResponse | null | undefined) => SummaryResponse | null | undefined) =>
queryClient.setQueryData<SummaryResponse | null>(['summary', selected.year, selected.month], updater),
[queryClient, selected.year, selected.month],
);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const error = queryError ? (queryError.message || 'Summary could not be loaded.') : ''; const error = queryError ? (queryError.message || 'Summary could not be loaded.') : '';
const [startingFirst, setStartingFirst] = useState('0'); const [startingFirst, setStartingFirst] = useState('0');
@ -250,9 +323,9 @@ export default function SummaryPage() {
const [incomeAmount, setIncomeAmount] = useState('0'); const [incomeAmount, setIncomeAmount] = useState('0');
const [incomeLabel, setIncomeLabel] = useState('Salary'); const [incomeLabel, setIncomeLabel] = useState('Salary');
const [editingIncome, setEditingIncome] = useState(false); const [editingIncome, setEditingIncome] = useState(false);
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 | null>(null);
const loadSummary = useCallback( const loadSummary = useCallback(
() => queryClient.invalidateQueries({ queryKey: ['summary', selected.year, selected.month] }), () => 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 // Seed the editable form fields (starting amounts, income) from the loaded
// summary and reset transient edit/drag state whenever the data changes — // summary and reset transient edit/drag state whenever the data changes.
// this is what loadSummary used to do inline.
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
setStartingFirst(String(data.starting_amounts?.first_amount ?? 0)); setStartingFirst(String(data.starting_amounts?.first_amount ?? 0));
@ -307,7 +379,7 @@ export default function SummaryPage() {
toast.success('Starting amounts saved.'); toast.success('Starting amounts saved.');
await loadSummary(); await loadSummary();
} catch (err) { } catch (err) {
toast.error(err.message || 'Starting amounts could not be saved.'); toast.error(errMessage(err, 'Starting amounts could not be saved.'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -327,13 +399,13 @@ export default function SummaryPage() {
setEditingIncome(false); setEditingIncome(false);
await loadSummary(); await loadSummary();
} catch (err) { } catch (err) {
toast.error(err.message || 'Income could not be saved.'); toast.error(errMessage(err, 'Income could not be saved.'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
function moveMonth(delta) { function moveMonth(delta: number) {
setSelected(current => shiftMonth(current.year, current.month, delta)); setSelected(current => shiftMonth(current.year, current.month, delta));
} }
@ -341,7 +413,7 @@ export default function SummaryPage() {
setSelected(selectedFromToday()); setSelected(selectedFromToday());
} }
async function persistExpenseOrder(nextExpenses, movedId) { async function persistExpenseOrder(nextExpenses: SummaryExpense[], movedId: number | null) {
setData(prev => prev ? { ...prev, expenses: nextExpenses } : prev); setData(prev => prev ? { ...prev, expenses: nextExpenses } : prev);
setMovingBillId(movedId); setMovingBillId(movedId);
try { try {
@ -349,20 +421,20 @@ export default function SummaryPage() {
toast.success('Summary order saved'); toast.success('Summary order saved');
await loadSummary(); await loadSummary();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to save summary order.'); toast.error(errMessage(err, 'Failed to save summary order.'));
await loadSummary(); await loadSummary();
} finally { } finally {
setMovingBillId(null); setMovingBillId(null);
} }
} }
function reorderExpenses(fromIndex, toIndex) { function reorderExpenses(fromIndex: number, toIndex: number) {
if (!reorderEnabled || fromIndex === toIndex) return; if (!reorderEnabled || fromIndex === toIndex) return;
const nextExpenses = moveInArray(expenses, fromIndex, toIndex); const nextExpenses = moveInArray(expenses, fromIndex, toIndex);
persistExpenseOrder(nextExpenses, expenses[fromIndex]?.bill_id || null); persistExpenseOrder(nextExpenses, expenses[fromIndex]?.bill_id || null);
} }
function moveControlsFor(expense, index) { function moveControlsFor(expense: SummaryExpense, index: number): ExpenseMoveControls {
return { return {
enabled: reorderEnabled, enabled: reorderEnabled,
moving: movingBillId === expense.bill_id, 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 }; if (!reorderEnabled) return { draggable: false };
return { return {
draggable: true, draggable: true,
isDragging: draggingId === expense.bill_id, isDragging: draggingId === expense.bill_id,
isDropTarget: dropTargetId === expense.bill_id && draggingId !== expense.bill_id, isDropTarget: dropTargetId === expense.bill_id && draggingId !== expense.bill_id,
onDragStart: (event) => { onDragStart: (event: DragEvent<HTMLDivElement>) => {
setDraggingId(expense.bill_id); setDraggingId(expense.bill_id);
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(expense.bill_id)); event.dataTransfer.setData('text/plain', String(expense.bill_id));
@ -387,12 +459,12 @@ export default function SummaryPage() {
onDragEnter: () => { onDragEnter: () => {
if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id); if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id);
}, },
onDragOver: (event) => { onDragOver: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = 'move'; event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id); if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id);
}, },
onDrop: (event) => { onDrop: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId); const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = expenses.findIndex(item => item.bill_id === sourceId); const fromIndex = expenses.findIndex(item => item.bill_id === sourceId);
@ -551,33 +623,15 @@ export default function SummaryPage() {
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end"> <div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end">
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">1st</span> <span className="text-xs font-medium text-muted-foreground">1st</span>
<Input <Input type="number" min="0" step="0.01" value={startingFirst} onChange={event => setStartingFirst(event.target.value)} />
type="number"
min="0"
step="0.01"
value={startingFirst}
onChange={event => setStartingFirst(event.target.value)}
/>
</label> </label>
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">15th</span> <span className="text-xs font-medium text-muted-foreground">15th</span>
<Input <Input type="number" min="0" step="0.01" value={startingFifteenth} onChange={event => setStartingFifteenth(event.target.value)} />
type="number"
min="0"
step="0.01"
value={startingFifteenth}
onChange={event => setStartingFifteenth(event.target.value)}
/>
</label> </label>
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Other</span> <span className="text-xs font-medium text-muted-foreground">Other</span>
<Input <Input type="number" min="0" step="0.01" value={startingOther} onChange={event => setStartingOther(event.target.value)} />
type="number"
min="0"
step="0.01"
value={startingOther}
onChange={event => setStartingOther(event.target.value)}
/>
</label> </label>
<Button onClick={saveStartingAmounts} disabled={saving} className="summary-edit-actions w-full md:w-auto"> <Button onClick={saveStartingAmounts} disabled={saving} className="summary-edit-actions w-full md:w-auto">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
@ -612,8 +666,8 @@ export default function SummaryPage() {
{Number(data?.income?.amount) > 0 && Number(summary?.expense_total) > 0 && ( {Number(data?.income?.amount) > 0 && Number(summary?.expense_total) > 0 && (
<div className="text-right"> <div className="text-right">
<div className="text-xs text-muted-foreground">After expenses</div> <div className="text-xs text-muted-foreground">After expenses</div>
<div className={cn('tracker-number mt-1 text-lg font-bold', moneyClass(Number(data.income.amount) - Number(summary.expense_total)))}> <div className={cn('tracker-number mt-1 text-lg font-bold', moneyClass(Number(data?.income?.amount) - Number(summary?.expense_total)))}>
{fmt(Number(data.income.amount) - Number(summary.expense_total))} {fmt(Number(data?.income?.amount) - Number(summary?.expense_total))}
</div> </div>
</div> </div>
)} )}
@ -623,12 +677,7 @@ export default function SummaryPage() {
<div className="grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_2fr_auto] md:items-end"> <div className="grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_2fr_auto] md:items-end">
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Label</span> <span className="text-xs font-medium text-muted-foreground">Label</span>
<Input <Input value={incomeLabel} onChange={e => setIncomeLabel(e.target.value)} placeholder="Salary" maxLength={80} />
value={incomeLabel}
onChange={e => setIncomeLabel(e.target.value)}
placeholder="Salary"
maxLength={80}
/>
</label> </label>
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Amount</span> <span className="text-xs font-medium text-muted-foreground">Amount</span>