diff --git a/app/blocks/expenses-tracker/one-time-expenses-table.module.css b/app/blocks/expenses-tracker/one-time-expenses-table.module.css index a29c622..a305dd5 100644 --- a/app/blocks/expenses-tracker/one-time-expenses-table.module.css +++ b/app/blocks/expenses-tracker/one-time-expenses-table.module.css @@ -5,3 +5,10 @@ .tr:last-child .td { border-bottom: none; } .catBadge { display: inline-flex; padding: 2px 8px; border-radius: var(--radius-full); font-size: 11px; font-weight: 600; background: var(--color-bg-elevated); color: var(--color-text-muted); } .amount { font-family: var(--family-mono); font-weight: 700; color: var(--color-text); } + +/* Sortable header button — inherits the .th typography, adds a click target. */ +.sortButton { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: none; background: none; cursor: pointer; font: inherit; color: inherit; text-transform: inherit; letter-spacing: inherit; } +.sortButton:hover { color: var(--color-text-muted); } +.sortButton:focus-visible { outline: 2px solid var(--color-accent, var(--color-text)); outline-offset: 2px; border-radius: var(--radius-sm); } +.sortIcon { color: var(--color-text); } +.sortIconInactive { color: var(--color-text-subtle); opacity: 0.5; } diff --git a/app/blocks/expenses-tracker/one-time-expenses-table.tsx b/app/blocks/expenses-tracker/one-time-expenses-table.tsx index f2869a9..0c6f24f 100644 --- a/app/blocks/expenses-tracker/one-time-expenses-table.tsx +++ b/app/blocks/expenses-tracker/one-time-expenses-table.tsx @@ -1,25 +1,88 @@ +import { useMemo, useState } from "react"; +import { IconChevronUp, IconChevronDown, IconSelector } from "@tabler/icons-react"; import styles from "./one-time-expenses-table.module.css"; interface Props { className?: string; expenses?: any[]; } +// Only these columns are sortable (per issue #33). +type SortKey = "date" | "amount"; +type SortDir = "asc" | "desc"; + export function OneTimeExpensesTable({ className, expenses = [] }: Props) { + // Default to date descending so the initial view matches the loader's + // `orderBy: { date: "desc" }` ordering — nothing jumps on first render. + const [sortKey, setSortKey] = useState("date"); + const [sortDir, setSortDir] = useState("desc"); + + function handleSort(key: SortKey) { + if (key === sortKey) { + // Same column clicked again → flip direction. + setSortDir(prev => (prev === "asc" ? "desc" : "asc")); + } else { + // New column → select it, start ascending. + setSortKey(key); + setSortDir("asc"); + } + } + + const sortedExpenses = useMemo(() => { + // Copy first — never mutate the prop array in place. + return [...expenses].sort((a, b) => { + let comparison = 0; + + if (sortKey === "amount") { + // `amount` is a Prisma Decimal serialized as a string. + comparison = Number(a.amount) - Number(b.amount); + } else { + // `date` is an ISO date string. + comparison = new Date(a.date).getTime() - new Date(b.date).getTime(); + } + + return sortDir === "asc" ? comparison : -comparison; + }); + }, [expenses, sortKey, sortDir]); + if (expenses.length === 0) { return

No one-time expenses logged yet.

; } + // Renders a clickable header with the correct sort indicator + aria-sort. + function SortableHeader({ label, columnKey }: { label: string; columnKey: SortKey }) { + const isActive = sortKey === columnKey; + return ( + + + + ); + } + return (
- + - + - {expenses.map(e => ( + {sortedExpenses.map(e => ( diff --git a/app/routes/expenses-tracker.tsx b/app/routes/expenses-tracker.tsx index aa0fdcf..d0aac7b 100644 --- a/app/routes/expenses-tracker.tsx +++ b/app/routes/expenses-tracker.tsx @@ -18,17 +18,22 @@ export async function loader({ request }: Route.LoaderArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user } } = await supabase.auth.getUser(); - if (!user) return { expenses: [], recurring: [], totalPages: 0, oneTimeTotal: 0 }; + if (!user) return { expenses: [], recurring: [], totalPages: 0, oneTimeTotal: 0, sort: "date", dir: "desc" }; const url = new URL(request.url); const page = Math.max(1, Number(url.searchParams.get("page")) || 1); const pageSize = Number(url.searchParams.get("pageSize")) || 10; + const allowedSort = ["date", "amount", "description", "type"]; + const sortParam = url.searchParams.get("sort") || "date"; + const sort = allowedSort.includes(sortParam) ? sortParam : "date"; + const dir = url.searchParams.get("dir") === "asc" ? "asc" : "desc"; + const [totalExpenses, expenses, recurring, sumResult] = await Promise.all([ prisma.expense.count({ where: { userId: user.id } }), prisma.expense.findMany({ where: { userId: user.id }, - orderBy: { date: "desc" }, + orderBy: { [sort]: dir }, skip: (page - 1) * pageSize, take: pageSize, }), @@ -46,7 +51,9 @@ export async function loader({ request }: Route.LoaderArgs) { expenses, recurring, totalPages: Math.ceil(totalExpenses / pageSize), - oneTimeTotal: Number(sumResult._sum.amount || 0) + oneTimeTotal: Number(sumResult._sum.amount || 0), + sort, + dir, }; } @@ -59,22 +66,16 @@ export async function action({ request }: Route.ActionArgs) { const intent = formData.get("intent"); if (intent === "toggle") { - const id = formData.get("id") as string; - const isActive = formData.get("isActive") === "true"; - - await prisma.recurringExpense.updateMany({ - where: { - id, - userId:user.id - }, - data: { - isActive, - }, - }); - return {ok: true,intent}; - - -} + const id = formData.get("id") as string; + const isActive = formData.get("isActive") === "true"; + + await prisma.recurringExpense.updateMany({ + where: { id, userId: user.id }, + data: { isActive }, + }); + return { ok: true, intent }; + } + if (intent === "create") { const isRecurring = formData.get("isRecurring") === "on"; const type = formData.get("type") as any; @@ -85,24 +86,11 @@ export async function action({ request }: Route.ActionArgs) { if (isRecurring) { const dayOfMonth = Number(formData.get("dayOfMonth")) || 1; await prisma.recurringExpense.create({ - data: { - userId: user.id, - type, - amount, - description, - dayOfMonth, - isActive: true - } + data: { userId: user.id, type, amount, description, dayOfMonth, isActive: true } }); } else { await prisma.expense.create({ - data: { - userId: user.id, - type, - amount, - description, - date, - } + data: { userId: user.id, type, amount, description, date } }); } } @@ -111,7 +99,7 @@ export async function action({ request }: Route.ActionArgs) { } export default function ExpensesTrackerPage() { - const { expenses, recurring, totalPages, oneTimeTotal } = useLoaderData(); + const { expenses, recurring, totalPages, oneTimeTotal, sort, dir } = useLoaderData(); const actionData = useActionData(); const [showAddExpense, setShowAddExpense] = useState(false); @@ -123,13 +111,13 @@ export default function ExpensesTrackerPage() { } } }, [actionData]); - + return (
setShowAddExpense(true)} /> - + {showAddExpense && setShowAddExpense(false)} />}
DateDescription CategoryAmount
{new Date(e.date).toLocaleDateString()} {e.description || "—"}