From f686f5fafc6870359e7742100e6588f2e9b7b6ff Mon Sep 17 00:00:00 2001
From: Spencer Twaddle <7374698+stwaddle@users.noreply.github.com>
Date: Sun, 3 May 2026 07:26:46 -0500
Subject: [PATCH] Added sorting to Income and Outgo
---
.../src/components/SortableHeader.tsx | 32 +++++++++++
src/Budget.Client/src/index.css | 14 +++++
src/Budget.Client/src/pages/IncomePage.tsx | 43 +++++++++++----
src/Budget.Client/src/pages/OutgoPage.tsx | 55 ++++++++++++++-----
4 files changed, 120 insertions(+), 24 deletions(-)
create mode 100644 src/Budget.Client/src/components/SortableHeader.tsx
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 && (
|