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 { 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<HTMLDivElement>;
onDragEnter?: DragEventHandler<HTMLDivElement>;
onDragOver?: DragEventHandler<HTMLDivElement>;
onDragEnd?: DragEventHandler<HTMLDivElement>;
onDrop?: DragEventHandler<HTMLDivElement>;
}
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 (
<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 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 (
<div
draggable={dragProps?.draggable}
@ -189,7 +258,7 @@ function ExpenseRow({ expense, moveControls, dragProps }) {
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-semibold text-foreground">{expense.name}</span>
<TooltipProvider delayDuration={300}>
{expense.autopay_enabled && (
{expense.autopay_enabled ? (
<Tooltip>
<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">
@ -198,8 +267,8 @@ function ExpenseRow({ expense, moveControls, dragProps }) {
</TooltipTrigger>
<TooltipContent>Autopay enabled</TooltipContent>
</Tooltip>
)}
{expense.is_subscription && (
) : null}
{expense.is_subscription ? (
<Tooltip>
<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">
@ -208,8 +277,8 @@ function ExpenseRow({ expense, moveControls, dragProps }) {
</TooltipTrigger>
<TooltipContent>Subscription</TooltipContent>
</Tooltip>
)}
{(expense.has_merchant_rule || expense.has_linked_transactions) && (
) : null}
{(expense.has_merchant_rule || expense.has_linked_transactions) ? (
<Tooltip>
<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">
@ -218,7 +287,7 @@ function ExpenseRow({ expense, moveControls, dragProps }) {
</TooltipTrigger>
<TooltipContent>Linked to bank transactions</TooltipContent>
</Tooltip>
)}
) : null}
</TooltipProvider>
</div>
<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() {
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<SummaryResponse | null>(['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<number | null>(null);
const [dropTargetId, setDropTargetId] = useState<number | null>(null);
const [movingBillId, setMovingBillId] = useState<number | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id);
},
onDrop: (event) => {
onDrop: (event: DragEvent<HTMLDivElement>) => {
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() {
<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">
<span className="text-xs font-medium text-muted-foreground">1st</span>
<Input
type="number"
min="0"
step="0.01"
value={startingFirst}
onChange={event => setStartingFirst(event.target.value)}
/>
<Input type="number" min="0" step="0.01" value={startingFirst} onChange={event => setStartingFirst(event.target.value)} />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">15th</span>
<Input
type="number"
min="0"
step="0.01"
value={startingFifteenth}
onChange={event => setStartingFifteenth(event.target.value)}
/>
<Input type="number" min="0" step="0.01" value={startingFifteenth} onChange={event => setStartingFifteenth(event.target.value)} />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Other</span>
<Input
type="number"
min="0"
step="0.01"
value={startingOther}
onChange={event => setStartingOther(event.target.value)}
/>
<Input type="number" min="0" step="0.01" value={startingOther} onChange={event => setStartingOther(event.target.value)} />
</label>
<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" />}
@ -612,8 +666,8 @@ export default function SummaryPage() {
{Number(data?.income?.amount) > 0 && Number(summary?.expense_total) > 0 && (
<div className="text-right">
<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)))}>
{fmt(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))}
</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">
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Label</span>
<Input
value={incomeLabel}
onChange={e => setIncomeLabel(e.target.value)}
placeholder="Salary"
maxLength={80}
/>
<Input value={incomeLabel} onChange={e => setIncomeLabel(e.target.value)} placeholder="Salary" maxLength={80} />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Amount</span>