diff --git a/src/Budget.Client/src/components/SortableHeader.tsx b/src/Budget.Client/src/components/SortableHeader.tsx new file mode 100644 index 0000000..7019a6d --- /dev/null +++ b/src/Budget.Client/src/components/SortableHeader.tsx @@ -0,0 +1,32 @@ +import { ChevronUp, ChevronDown } from 'lucide-react'; + +export type SortDir = 'asc' | 'desc'; +export interface SortState { column: string; dir: SortDir; } + +export function nextSort(current: SortState | null, column: string): SortState | null { + if (current?.column !== column) return { column, dir: 'asc' }; + if (current.dir === 'asc') return { column, dir: 'desc' }; + return null; +} + +interface Props { + label: string; + column: string; + sort: SortState | null; + onSort: (col: string) => void; + className?: string; +} + +export function SortableHeader({ label, column, sort, onSort, className }: Props) { + const active = sort?.column === column; + return ( + onSort(column)} + > + {label} + {active && sort?.dir === 'asc' && } + {active && sort?.dir === 'desc' && } + + ); +} diff --git a/src/Budget.Client/src/index.css b/src/Budget.Client/src/index.css index 65698a4..e66a746 100644 --- a/src/Budget.Client/src/index.css +++ b/src/Budget.Client/src/index.css @@ -330,6 +330,20 @@ td { .col-drag:active { cursor: grabbing; } .col-actions { width: 1%; white-space: nowrap; } +.col-sortable { + cursor: pointer; + user-select: none; + white-space: nowrap; +} +.col-sortable:hover { background: var(--green-100); } + +.sort-icon { + display: inline-block; + margin-left: 3px; + vertical-align: middle; + color: var(--color-primary); +} + /* Inline edit inputs inside tables */ td input[type="text"], td input:not([type]) { min-width: 100px; diff --git a/src/Budget.Client/src/pages/IncomePage.tsx b/src/Budget.Client/src/pages/IncomePage.tsx index 8e71165..05c67fc 100644 --- a/src/Budget.Client/src/pages/IncomePage.tsx +++ b/src/Budget.Client/src/pages/IncomePage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -26,6 +26,8 @@ import { useIncomes, useCreateIncome, useUpdateIncome, useDeleteIncome, useReord import { createIncomeSchema, type CreateIncomeInput } from '../schemas/index'; import { toMonthly, toAnnually } from '../utils/frequency'; import { Check, X, Trash2, Plus } from 'lucide-react'; +import { SortableHeader, nextSort } from '../components/SortableHeader'; +import type { SortState } from '../components/SortableHeader'; const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); @@ -108,12 +110,14 @@ function SortableRow({ income, onSave, onDelete, + dragDisabled, }: { income: IncomeDto; onSave: (id: string, data: CreateIncomeInput) => void; onDelete: (id: string) => void; + dragDisabled?: boolean; }) { - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: income.id }); + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: income.id, disabled: dragDisabled }); const style = { transform: CSS.Transform.toString(transform), transition }; const [editing, setEditing] = useState(false); @@ -169,7 +173,7 @@ function SortableRow({ return ( - ⠿ + ⠿ {income.name} {income.frequency} @@ -187,6 +191,9 @@ export function IncomePage() { const { id: budgetId } = useParams<{ id: string }>(); const sensors = useSensors(useSensor(PointerSensor)); const [addingRow, setAddingRow] = useState(false); + const [sort, setSort] = useState(null); + + const handleSort = (col: string) => setSort(s => nextSort(s, col)); const { data: incomes = [], isLoading } = useIncomes(budgetId!); const [displayItems, setDisplayItems] = useState([]); @@ -206,6 +213,21 @@ export function IncomePage() { deleteIncome.mutate(id); }; + const sortedItems = useMemo(() => { + if (!sort) return displayItems; + return [...displayItems].sort((a, b) => { + let cmp = 0; + switch (sort.column) { + case 'name': cmp = a.name.localeCompare(b.name); break; + case 'frequency': cmp = a.frequency.localeCompare(b.frequency); break; + case 'amount': cmp = a.amount - b.amount; break; + case 'monthly': cmp = a.monthly - b.monthly; break; + case 'annually': cmp = a.annually - b.annually; break; + } + return sort.dir === 'asc' ? cmp : -cmp; + }); + }, [displayItems, sort]); + const handleAdd = async (data: CreateIncomeInput) => { await createIncome.mutateAsync(data); setAddingRow(false); @@ -232,23 +254,24 @@ export function IncomePage() { - Name - Frequency - Amount - Monthly - Annually + + + + + - i.id)} strategy={verticalListSortingStrategy}> + i.id)} strategy={verticalListSortingStrategy}> - {displayItems.map(income => ( + {sortedItems.map(income => ( ))} {addingRow && ( diff --git a/src/Budget.Client/src/pages/OutgoPage.tsx b/src/Budget.Client/src/pages/OutgoPage.tsx index c35b82b..9b3ed5f 100644 --- a/src/Budget.Client/src/pages/OutgoPage.tsx +++ b/src/Budget.Client/src/pages/OutgoPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -30,6 +30,8 @@ import { import { createOutgoSchema, type CreateOutgoInput } from '../schemas/index'; import { toMonthly, toAnnually } from '../utils/frequency'; import { Check, X, Trash2, Plus } from 'lucide-react'; +import { SortableHeader, nextSort } from '../components/SortableHeader'; +import type { SortState } from '../components/SortableHeader'; const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); const OUTGO_TYPES = ['Need', 'Want', 'Save'] as const; @@ -170,14 +172,16 @@ function SortableRow({ paymentSources, onSave, onDelete, + dragDisabled, }: { outgo: OutgoDto; categories: string[]; paymentSources: string[]; onSave: (id: string, data: CreateOutgoInput) => void; onDelete: (id: string) => void; + dragDisabled?: boolean; }) { - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: outgo.id }); + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: outgo.id, disabled: dragDisabled }); const style = { transform: CSS.Transform.toString(transform), transition }; const [editing, setEditing] = useState(false); @@ -284,7 +288,7 @@ function SortableRow({ return ( - ⠿ + ⠿ {outgo.name} {outgo.category} @@ -309,6 +313,9 @@ export function OutgoPage() { const { id: budgetId } = useParams<{ id: string }>(); const sensors = useSensors(useSensor(PointerSensor)); const [addingRow, setAddingRow] = useState(false); + const [sort, setSort] = useState(null); + + const handleSort = (col: string) => setSort(s => nextSort(s, col)); const { data: outgos = [], isLoading } = useOutgos(budgetId!); const { data: categories = [] } = useCategories(budgetId!); @@ -321,6 +328,25 @@ export function OutgoPage() { const createOutgo = useCreateOutgo(budgetId!); const reorderOutgos = useReorderOutgos(budgetId!); + const sortedItems = useMemo(() => { + if (!sort) return displayItems; + return [...displayItems].sort((a, b) => { + let cmp = 0; + switch (sort.column) { + case 'name': cmp = a.name.localeCompare(b.name); break; + case 'category': cmp = (a.category ?? '').localeCompare(b.category ?? ''); break; + case 'type': cmp = a.type.localeCompare(b.type); break; + case 'frequency': cmp = a.frequency.localeCompare(b.frequency); break; + case 'amount': cmp = a.amount - b.amount; break; + case 'monthly': cmp = a.monthly - b.monthly; break; + case 'annually': cmp = a.annually - b.annually; break; + case 'monthlyPercent': cmp = a.monthlyPercent - b.monthlyPercent; break; + case 'paymentSource': cmp = (a.paymentSource ?? '').localeCompare(b.paymentSource ?? ''); break; + } + return sort.dir === 'asc' ? cmp : -cmp; + }); + }, [displayItems, sort]); + const handleSave = (id: string, data: CreateOutgoInput) => { updateOutgo.mutate({ id, @@ -373,23 +399,23 @@ export function OutgoPage() { - Name - Category - Type - Frequency - Amount - Monthly - Annually - Monthly% - Payment Source + + + + + + + + + Notes - o.id)} strategy={verticalListSortingStrategy}> + o.id)} strategy={verticalListSortingStrategy}> - {displayItems.map(outgo => ( + {sortedItems.map(outgo => ( ))} {addingRow && (