Added sorting to Income and Outgo

This commit is contained in:
Spencer Twaddle
2026-05-03 07:26:46 -05:00
parent f3fe1ea146
commit f686f5fafc
4 changed files with 120 additions and 24 deletions
@@ -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 (
<th
className={`col-sortable${className ? ` ${className}` : ''}`}
onClick={() => onSort(column)}
>
{label}
{active && sort?.dir === 'asc' && <ChevronUp size={12} className="sort-icon" />}
{active && sort?.dir === 'desc' && <ChevronDown size={12} className="sort-icon" />}
</th>
);
}
+14
View File
@@ -330,6 +330,20 @@ td {
.col-drag:active { cursor: grabbing; } .col-drag:active { cursor: grabbing; }
.col-actions { width: 1%; white-space: nowrap; } .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 */ /* Inline edit inputs inside tables */
td input[type="text"], td input:not([type]) { td input[type="text"], td input:not([type]) {
min-width: 100px; min-width: 100px;
+33 -10
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -26,6 +26,8 @@ import { useIncomes, useCreateIncome, useUpdateIncome, useDeleteIncome, useReord
import { createIncomeSchema, type CreateIncomeInput } from '../schemas/index'; import { createIncomeSchema, type CreateIncomeInput } from '../schemas/index';
import { toMonthly, toAnnually } from '../utils/frequency'; import { toMonthly, toAnnually } from '../utils/frequency';
import { Check, X, Trash2, Plus } from 'lucide-react'; 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 fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
@@ -108,12 +110,14 @@ function SortableRow({
income, income,
onSave, onSave,
onDelete, onDelete,
dragDisabled,
}: { }: {
income: IncomeDto; income: IncomeDto;
onSave: (id: string, data: CreateIncomeInput) => void; onSave: (id: string, data: CreateIncomeInput) => void;
onDelete: (id: string) => 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 style = { transform: CSS.Transform.toString(transform), transition };
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@@ -169,7 +173,7 @@ function SortableRow({
return ( return (
<tr ref={setNodeRef} style={style}> <tr ref={setNodeRef} style={style}>
<td className="col-drag" {...attributes} {...listeners}></td> <td className="col-drag" {...(!dragDisabled ? { ...attributes, ...listeners } : {})} style={{ cursor: dragDisabled ? 'default' : 'grab', opacity: dragDisabled ? 0.2 : 1 }}></td>
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{income.name}</td> <td onClick={startEdit} style={{ cursor: 'pointer' }}>{income.name}</td>
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{income.frequency}</td> <td onClick={startEdit} style={{ cursor: 'pointer' }}>{income.frequency}</td>
<td className="col-money" onClick={startEdit} style={{ cursor: 'pointer' }}><MoneyDisplay value={income.amount} /></td> <td className="col-money" onClick={startEdit} style={{ cursor: 'pointer' }}><MoneyDisplay value={income.amount} /></td>
@@ -187,6 +191,9 @@ export function IncomePage() {
const { id: budgetId } = useParams<{ id: string }>(); const { id: budgetId } = useParams<{ id: string }>();
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
const [addingRow, setAddingRow] = useState(false); const [addingRow, setAddingRow] = useState(false);
const [sort, setSort] = useState<SortState | null>(null);
const handleSort = (col: string) => setSort(s => nextSort(s, col));
const { data: incomes = [], isLoading } = useIncomes(budgetId!); const { data: incomes = [], isLoading } = useIncomes(budgetId!);
const [displayItems, setDisplayItems] = useState<IncomeDto[]>([]); const [displayItems, setDisplayItems] = useState<IncomeDto[]>([]);
@@ -206,6 +213,21 @@ export function IncomePage() {
deleteIncome.mutate(id); 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) => { const handleAdd = async (data: CreateIncomeInput) => {
await createIncome.mutateAsync(data); await createIncome.mutateAsync(data);
setAddingRow(false); setAddingRow(false);
@@ -232,23 +254,24 @@ export function IncomePage() {
<thead> <thead>
<tr> <tr>
<th style={{ width: 32 }}></th> <th style={{ width: 32 }}></th>
<th>Name</th> <SortableHeader label="Name" column="name" sort={sort} onSort={handleSort} />
<th>Frequency</th> <SortableHeader label="Frequency" column="frequency" sort={sort} onSort={handleSort} />
<th className="col-money">Amount</th> <SortableHeader label="Amount" column="amount" sort={sort} onSort={handleSort} className="col-money" />
<th className="col-money">Monthly</th> <SortableHeader label="Monthly" column="monthly" sort={sort} onSort={handleSort} className="col-money" />
<th className="col-money">Annually</th> <SortableHeader label="Annually" column="annually" sort={sort} onSort={handleSort} className="col-money" />
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={displayItems.map(i => i.id)} strategy={verticalListSortingStrategy}> <SortableContext items={sortedItems.map(i => i.id)} strategy={verticalListSortingStrategy}>
<tbody> <tbody>
{displayItems.map(income => ( {sortedItems.map(income => (
<SortableRow <SortableRow
key={income.id} key={income.id}
income={income} income={income}
onSave={handleSave} onSave={handleSave}
onDelete={handleDelete} onDelete={handleDelete}
dragDisabled={!!sort}
/> />
))} ))}
{addingRow && ( {addingRow && (
+41 -14
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@@ -30,6 +30,8 @@ import {
import { createOutgoSchema, type CreateOutgoInput } from '../schemas/index'; import { createOutgoSchema, type CreateOutgoInput } from '../schemas/index';
import { toMonthly, toAnnually } from '../utils/frequency'; import { toMonthly, toAnnually } from '../utils/frequency';
import { Check, X, Trash2, Plus } from 'lucide-react'; 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 fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
const OUTGO_TYPES = ['Need', 'Want', 'Save'] as const; const OUTGO_TYPES = ['Need', 'Want', 'Save'] as const;
@@ -170,14 +172,16 @@ function SortableRow({
paymentSources, paymentSources,
onSave, onSave,
onDelete, onDelete,
dragDisabled,
}: { }: {
outgo: OutgoDto; outgo: OutgoDto;
categories: string[]; categories: string[];
paymentSources: string[]; paymentSources: string[];
onSave: (id: string, data: CreateOutgoInput) => void; onSave: (id: string, data: CreateOutgoInput) => void;
onDelete: (id: string) => 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 style = { transform: CSS.Transform.toString(transform), transition };
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@@ -284,7 +288,7 @@ function SortableRow({
return ( return (
<tr ref={setNodeRef} style={style}> <tr ref={setNodeRef} style={style}>
<td className="col-drag" {...attributes} {...listeners}></td> <td className="col-drag" {...(!dragDisabled ? { ...attributes, ...listeners } : {})} style={{ cursor: dragDisabled ? 'default' : 'grab', opacity: dragDisabled ? 0.2 : 1 }}></td>
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{outgo.name}</td> <td onClick={startEdit} style={{ cursor: 'pointer' }}>{outgo.name}</td>
<td onClick={startEdit} style={{ cursor: 'pointer' }}>{outgo.category}</td> <td onClick={startEdit} style={{ cursor: 'pointer' }}>{outgo.category}</td>
<td onClick={startEdit} style={{ cursor: 'pointer' }}> <td onClick={startEdit} style={{ cursor: 'pointer' }}>
@@ -309,6 +313,9 @@ export function OutgoPage() {
const { id: budgetId } = useParams<{ id: string }>(); const { id: budgetId } = useParams<{ id: string }>();
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
const [addingRow, setAddingRow] = useState(false); const [addingRow, setAddingRow] = useState(false);
const [sort, setSort] = useState<SortState | null>(null);
const handleSort = (col: string) => setSort(s => nextSort(s, col));
const { data: outgos = [], isLoading } = useOutgos(budgetId!); const { data: outgos = [], isLoading } = useOutgos(budgetId!);
const { data: categories = [] } = useCategories(budgetId!); const { data: categories = [] } = useCategories(budgetId!);
@@ -321,6 +328,25 @@ export function OutgoPage() {
const createOutgo = useCreateOutgo(budgetId!); const createOutgo = useCreateOutgo(budgetId!);
const reorderOutgos = useReorderOutgos(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) => { const handleSave = (id: string, data: CreateOutgoInput) => {
updateOutgo.mutate({ updateOutgo.mutate({
id, id,
@@ -373,23 +399,23 @@ export function OutgoPage() {
<thead> <thead>
<tr> <tr>
<th style={{ width: 32 }}></th> <th style={{ width: 32 }}></th>
<th>Name</th> <SortableHeader label="Name" column="name" sort={sort} onSort={handleSort} />
<th>Category</th> <SortableHeader label="Category" column="category" sort={sort} onSort={handleSort} />
<th>Type</th> <SortableHeader label="Type" column="type" sort={sort} onSort={handleSort} />
<th>Frequency</th> <SortableHeader label="Frequency" column="frequency" sort={sort} onSort={handleSort} />
<th className="col-money">Amount</th> <SortableHeader label="Amount" column="amount" sort={sort} onSort={handleSort} className="col-money" />
<th className="col-money">Monthly</th> <SortableHeader label="Monthly" column="monthly" sort={sort} onSort={handleSort} className="col-money" />
<th className="col-money">Annually</th> <SortableHeader label="Annually" column="annually" sort={sort} onSort={handleSort} className="col-money" />
<th className="col-pct">Monthly%</th> <SortableHeader label="Monthly%" column="monthlyPercent" sort={sort} onSort={handleSort} className="col-pct" />
<th>Payment Source</th> <SortableHeader label="Payment Source" column="paymentSource" sort={sort} onSort={handleSort} />
<th>Notes</th> <th>Notes</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={displayItems.map(o => o.id)} strategy={verticalListSortingStrategy}> <SortableContext items={sortedItems.map(o => o.id)} strategy={verticalListSortingStrategy}>
<tbody> <tbody>
{displayItems.map(outgo => ( {sortedItems.map(outgo => (
<SortableRow <SortableRow
key={outgo.id} key={outgo.id}
outgo={outgo} outgo={outgo}
@@ -397,6 +423,7 @@ export function OutgoPage() {
paymentSources={paymentSources} paymentSources={paymentSources}
onSave={handleSave} onSave={handleSave}
onDelete={handleDelete} onDelete={handleDelete}
dragDisabled={!!sort}
/> />
))} ))}
{addingRow && ( {addingRow && (