Added sorting to Income and Outgo
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user