feat: reordering across management pages (Bills, Subscriptions, Categories, Snowball) — batch v0.34.1.2
This commit is contained in:
parent
6edb23cd66
commit
c23cae1107
11
HISTORY.md
11
HISTORY.md
|
|
@ -1,15 +1,18 @@
|
|||
# Bill Tracker — Changelog
|
||||
|
||||
## v0.34.1.1
|
||||
## v0.34.1.2
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **Reordering across management pages** — Bills, Subscriptions, Categories, and Snowball now expose tracker-style drag/up/down controls. Bill-backed pages persist through `sort_order`; Categories adds its own persisted `sort_order` API.
|
||||
|
||||
### 🔧 Changed
|
||||
|
||||
- **Bump** — `0.34.1` → `0.34.1.1`
|
||||
- **Claude.ai catalog seed** — Updated subscription catalog to match Claude Pro transaction descriptions.
|
||||
- **Bump** — `0.34.1.1` → `0.34.1.2`
|
||||
|
||||
---
|
||||
|
||||
## v0.34.1
|
||||
## v0.34.1.1
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ export const api = {
|
|||
// Categories
|
||||
categories: () => get('/categories'),
|
||||
createCategory: (data) => post('/categories', data),
|
||||
reorderCategories: (order) => put('/categories/reorder', order),
|
||||
updateCategory: (id, data) => put(`/categories/${id}`, data),
|
||||
deleteCategory: (id) => del(`/categories/${id}`),
|
||||
restoreCategory: (id) => post(`/categories/${id}/restore`),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Copy, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileBillRow } from '@/components/MobileBillRow';
|
||||
|
||||
|
|
@ -31,17 +31,59 @@ const ALL_ON = {
|
|||
showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true,
|
||||
};
|
||||
|
||||
function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate }) {
|
||||
function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) {
|
||||
const isDebt = bill.current_balance != null || bill.minimum_payment != null;
|
||||
const hasHistory = hasHistoricalVisibility(bill);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 px-5 py-3.5 transition-colors',
|
||||
<div
|
||||
draggable={dragProps?.draggable}
|
||||
onDragStart={dragProps?.onDragStart}
|
||||
onDragEnter={dragProps?.onDragEnter}
|
||||
onDragOver={dragProps?.onDragOver}
|
||||
onDragEnd={dragProps?.onDragEnd}
|
||||
onDrop={dragProps?.onDrop}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 px-5 py-3.5 transition-colors',
|
||||
'hover:bg-accent/20',
|
||||
!bill.active && 'opacity-60',
|
||||
dragProps?.isDragging && 'opacity-45',
|
||||
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
||||
)}>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<GripVertical
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground/55',
|
||||
moveControls?.enabled && 'cursor-grab group-active:cursor-grabbing',
|
||||
!moveControls?.enabled && 'opacity-30',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="hidden flex-col sm:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls?.onMoveUp}
|
||||
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move bill up"
|
||||
aria-label={`Move ${bill.name} up`}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls?.onMoveDown}
|
||||
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move bill down"
|
||||
aria-label={`Move ${bill.name} down`}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main info */}
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
|
||||
|
|
@ -186,11 +228,11 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
|
|||
);
|
||||
}
|
||||
|
||||
export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate }) {
|
||||
export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControlsFor, dragPropsFor }) {
|
||||
return (
|
||||
<>
|
||||
<div className="hidden divide-y divide-border/30 sm:block">
|
||||
{bills.map(bill => (
|
||||
{bills.map((bill, index) => (
|
||||
<BillCard
|
||||
key={bill.id}
|
||||
bill={bill}
|
||||
|
|
@ -200,11 +242,13 @@ export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggl
|
|||
onDelete={onDelete}
|
||||
onHistory={onHistory}
|
||||
onDuplicate={onDuplicate}
|
||||
moveControls={moveControlsFor?.(bill, index)}
|
||||
dragProps={dragPropsFor?.(bill, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3 p-3 sm:hidden">
|
||||
{bills.map(bill => (
|
||||
{bills.map((bill, index) => (
|
||||
<MobileBillRow
|
||||
key={bill.id}
|
||||
bill={bill}
|
||||
|
|
@ -213,6 +257,8 @@ export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggl
|
|||
onDelete={onDelete}
|
||||
onHistory={onHistory}
|
||||
onDuplicate={onDuplicate}
|
||||
moveControls={moveControlsFor?.(bill, index)}
|
||||
dragProps={dragPropsFor?.(bill, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Copy, History } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, Copy, GripVertical, History } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ function hasHistoricalVisibility(bill) {
|
|||
return !!bill.has_history_ranges || (visibility && visibility !== 'default');
|
||||
}
|
||||
|
||||
export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate }) {
|
||||
export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) {
|
||||
const hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]);
|
||||
|
||||
const statusClass = useMemo(() => {
|
||||
|
|
@ -37,9 +37,54 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
|
|||
}, [bill.active]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/80 bg-card/90 p-3 shadow-sm shadow-black/10">
|
||||
<div
|
||||
draggable={dragProps?.draggable}
|
||||
onDragStart={dragProps?.onDragStart}
|
||||
onDragEnter={dragProps?.onDragEnter}
|
||||
onDragOver={dragProps?.onDragOver}
|
||||
onDragEnd={dragProps?.onDragEnd}
|
||||
onDrop={dragProps?.onDrop}
|
||||
className={cn(
|
||||
'group rounded-xl border border-border/80 bg-card/90 p-3 shadow-sm shadow-black/10',
|
||||
dragProps?.isDragging && 'opacity-45',
|
||||
dragProps?.isDropTarget && 'ring-2 ring-primary/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<GripVertical
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground/55',
|
||||
moveControls?.enabled && 'cursor-grab',
|
||||
!moveControls?.enabled && 'opacity-30',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls?.onMoveUp}
|
||||
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move bill up"
|
||||
aria-label={`Move ${bill.name} up`}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls?.onMoveDown}
|
||||
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move bill down"
|
||||
aria-label={`Move ${bill.name} down`}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
export function moveInArray(items, fromIndex, toIndex) {
|
||||
if (!Array.isArray(items)) return [];
|
||||
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length || toIndex >= items.length) {
|
||||
return items;
|
||||
}
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, moved);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function reorderPayload(items) {
|
||||
return Object.fromEntries((items || []).map((item, index) => [item.id, index]));
|
||||
}
|
||||
|
||||
export function movedItemId(before, after) {
|
||||
const moved = (after || []).find((item, index) => item.id !== before?.[index]?.id);
|
||||
return moved?.id || after?.[0]?.id || null;
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from '@/components/ui/select';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||
import BillsTableInner from '@/components/BillsTableInner';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
|
|
@ -555,6 +556,9 @@ export default function BillsPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [dropTargetId, setDropTargetId] = useState(null);
|
||||
const [movingBillId, setMovingBillId] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
category: FILTER_ALL,
|
||||
cycle: FILTER_ALL,
|
||||
|
|
@ -759,6 +763,78 @@ export default function BillsPage() {
|
|||
const inactive = filteredBills.filter(b => !b.active);
|
||||
const totalActive = bills.filter(b => b.active).length;
|
||||
const totalInactive = bills.filter(b => !b.active).length;
|
||||
const reorderEnabled = !hasFilters && !loading;
|
||||
|
||||
async function persistBillOrder(nextBills, movedId) {
|
||||
setBills(nextBills);
|
||||
setMovingBillId(movedId);
|
||||
try {
|
||||
await api.reorderBills(reorderPayload(nextBills));
|
||||
toast.success('Bill order saved');
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save bill order');
|
||||
load();
|
||||
} finally {
|
||||
setMovingBillId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function reorderBillGroup(activeState, orderedGroup) {
|
||||
const sourceGroup = bills.filter(bill => !!bill.active === activeState);
|
||||
const replacements = [...orderedGroup];
|
||||
const nextBills = bills.map(bill => (
|
||||
!!bill.active === activeState ? replacements.shift() : bill
|
||||
));
|
||||
persistBillOrder(nextBills, movedItemId(sourceGroup, orderedGroup));
|
||||
}
|
||||
|
||||
function moveControlsForGroup(group, activeState) {
|
||||
return (bill, index) => ({
|
||||
enabled: reorderEnabled,
|
||||
moving: movingBillId === bill.id,
|
||||
canMoveUp: index > 0,
|
||||
canMoveDown: index < group.length - 1,
|
||||
onMoveUp: () => reorderBillGroup(activeState, moveInArray(group, index, index - 1)),
|
||||
onMoveDown: () => reorderBillGroup(activeState, moveInArray(group, index, index + 1)),
|
||||
});
|
||||
}
|
||||
|
||||
function dragPropsForGroup(group, activeState) {
|
||||
return (bill, index) => {
|
||||
if (!reorderEnabled) return { draggable: false };
|
||||
return {
|
||||
draggable: true,
|
||||
isDragging: draggingId === bill.id,
|
||||
isDropTarget: dropTargetId === bill.id && draggingId !== bill.id,
|
||||
onDragStart: (event) => {
|
||||
setDraggingId(bill.id);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(bill.id));
|
||||
},
|
||||
onDragEnter: () => {
|
||||
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
|
||||
},
|
||||
onDragOver: (event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
|
||||
},
|
||||
onDrop: (event) => {
|
||||
event.preventDefault();
|
||||
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
|
||||
const fromIndex = group.findIndex(item => item.id === sourceId);
|
||||
if (fromIndex >= 0) reorderBillGroup(activeState, moveInArray(group, fromIndex, index));
|
||||
setDraggingId(null);
|
||||
setDropTargetId(null);
|
||||
},
|
||||
onDragEnd: () => {
|
||||
setDraggingId(null);
|
||||
setDropTargetId(null);
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -873,7 +949,10 @@ export default function BillsPage() {
|
|||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Active
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{active.length}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{active.length}
|
||||
{!reorderEnabled && active.length > 1 && <span className="ml-2 hidden sm:inline">Clear filters to reorder</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -906,6 +985,8 @@ export default function BillsPage() {
|
|||
onToggle={handleToggle}
|
||||
onDelete={handleDeleteRequest}
|
||||
onDuplicate={handleDuplicateBill}
|
||||
moveControlsFor={moveControlsForGroup(active, true)}
|
||||
dragPropsFor={dragPropsForGroup(active, true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -934,7 +1015,10 @@ export default function BillsPage() {
|
|||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Inactive
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{inactive.length}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{inactive.length}
|
||||
{!reorderEnabled && inactive.length > 1 && <span className="ml-2 hidden sm:inline">Clear filters to reorder</span>}
|
||||
</span>
|
||||
</div>
|
||||
<BillsTableInner
|
||||
bills={inactive}
|
||||
|
|
@ -944,6 +1028,8 @@ export default function BillsPage() {
|
|||
onDelete={handleDeleteRequest}
|
||||
onHistory={setHistoryTarget}
|
||||
onDuplicate={handleDuplicateBill}
|
||||
moveControlsFor={moveControlsForGroup(inactive, false)}
|
||||
dragPropsFor={dragPropsForGroup(inactive, false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ChevronDown, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw,
|
||||
ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api.js';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||
|
||||
function plural(count, label) {
|
||||
return `${count} ${label}${count === 1 ? '' : 's'}`;
|
||||
|
|
@ -236,6 +237,9 @@ export default function CategoriesPage() {
|
|||
const [adding, setAdding] = useState(false);
|
||||
const [expanded, setExpanded] = useState(() => new Set());
|
||||
const [showEmptyCategories, setShowEmptyCategories] = useState(false);
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [dropTargetId, setDropTargetId] = useState(null);
|
||||
const [movingCategoryId, setMovingCategoryId] = useState(null);
|
||||
const addInputRef = useRef(null);
|
||||
|
||||
const [renameTarget, setRenameTarget] = useState(null);
|
||||
|
|
@ -361,6 +365,84 @@ export default function CategoriesPage() {
|
|||
const visibleCategories = showEmptyCategories
|
||||
? categories
|
||||
: categories.filter(cat => categoryBillCount(cat) > 0);
|
||||
const reorderEnabled = !loading && !loadError;
|
||||
|
||||
async function persistCategoryOrder(nextCategories, movedId) {
|
||||
setCategories(nextCategories);
|
||||
setMovingCategoryId(movedId);
|
||||
try {
|
||||
await api.reorderCategories(reorderPayload(nextCategories));
|
||||
toast.success('Category order saved');
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save category order');
|
||||
load();
|
||||
} finally {
|
||||
setMovingCategoryId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function reorderVisibleCategories(orderedVisible) {
|
||||
if (!reorderEnabled) return;
|
||||
const visibleIds = new Set(visibleCategories.map(cat => cat.id));
|
||||
const replacements = [...orderedVisible];
|
||||
const nextCategories = categories.map(cat => (
|
||||
visibleIds.has(cat.id) ? replacements.shift() : cat
|
||||
));
|
||||
persistCategoryOrder(nextCategories, movedItemId(visibleCategories, orderedVisible));
|
||||
}
|
||||
|
||||
function categoryMoveControls(cat, index) {
|
||||
return {
|
||||
enabled: reorderEnabled,
|
||||
moving: movingCategoryId === cat.id,
|
||||
canMoveUp: index > 0,
|
||||
canMoveDown: index < visibleCategories.length - 1,
|
||||
onMoveUp: (event) => {
|
||||
event.stopPropagation();
|
||||
reorderVisibleCategories(moveInArray(visibleCategories, index, index - 1));
|
||||
},
|
||||
onMoveDown: (event) => {
|
||||
event.stopPropagation();
|
||||
reorderVisibleCategories(moveInArray(visibleCategories, index, index + 1));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function categoryDragProps(cat, index) {
|
||||
if (!reorderEnabled) return { draggable: false };
|
||||
return {
|
||||
draggable: true,
|
||||
isDragging: draggingId === cat.id,
|
||||
isDropTarget: dropTargetId === cat.id && draggingId !== cat.id,
|
||||
onDragStart: (event) => {
|
||||
setDraggingId(cat.id);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(cat.id));
|
||||
},
|
||||
onDragEnter: () => {
|
||||
if (draggingId && draggingId !== cat.id) setDropTargetId(cat.id);
|
||||
},
|
||||
onDragOver: (event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
if (draggingId && draggingId !== cat.id) setDropTargetId(cat.id);
|
||||
},
|
||||
onDrop: (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
|
||||
const fromIndex = visibleCategories.findIndex(item => item.id === sourceId);
|
||||
if (fromIndex >= 0) reorderVisibleCategories(moveInArray(visibleCategories, fromIndex, index));
|
||||
setDraggingId(null);
|
||||
setDropTargetId(null);
|
||||
},
|
||||
onDragEnd: () => {
|
||||
setDraggingId(null);
|
||||
setDropTargetId(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
|
|
@ -463,11 +545,26 @@ export default function CategoriesPage() {
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{visibleCategories.map((cat) => {
|
||||
{visibleCategories.map((cat, index) => {
|
||||
const isExpanded = expanded.has(cat.id);
|
||||
const preview = billPreview(cat.bill_names);
|
||||
const moveControls = categoryMoveControls(cat, index);
|
||||
const dragProps = categoryDragProps(cat, index);
|
||||
return (
|
||||
<section key={cat.id} className="bg-card/35">
|
||||
<section
|
||||
key={cat.id}
|
||||
draggable={dragProps?.draggable}
|
||||
onDragStart={dragProps?.onDragStart}
|
||||
onDragEnter={dragProps?.onDragEnter}
|
||||
onDragOver={dragProps?.onDragOver}
|
||||
onDragEnd={dragProps?.onDragEnd}
|
||||
onDrop={dragProps?.onDrop}
|
||||
className={cn(
|
||||
'bg-card/35',
|
||||
dragProps?.isDragging && 'opacity-45',
|
||||
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
|
@ -482,6 +579,42 @@ export default function CategoriesPage() {
|
|||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-0.5"
|
||||
onClick={event => event.stopPropagation()}
|
||||
onKeyDown={event => event.stopPropagation()}
|
||||
>
|
||||
<GripVertical
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground/55',
|
||||
moveControls.enabled && 'cursor-grab group-active:cursor-grabbing',
|
||||
!moveControls.enabled && 'opacity-30',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls.onMoveUp}
|
||||
disabled={!moveControls.enabled || !moveControls.canMoveUp || moveControls.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move category up"
|
||||
aria-label={`Move ${cat.name} up`}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls.onMoveDown}
|
||||
disabled={!moveControls.enabled || !moveControls.canMoveDown || moveControls.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move category down"
|
||||
aria-label={`Move ${cat.name} down`}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label';
|
|||
import { Switch } from '@/components/ui/switch';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { moveInArray } from '@/lib/reorder';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner';
|
||||
|
|
@ -494,6 +495,12 @@ export default function SnowballPage() {
|
|||
toast.success('Arranged smallest-to-largest balance');
|
||||
};
|
||||
|
||||
const moveDebt = (fromIndex, toIndex) => {
|
||||
if (ramseyMode || saving || fromIndex === toIndex) return;
|
||||
setBills(prev => moveInArray(prev, fromIndex, toIndex));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
// ── save order ────────────────────────────────────────────────────────────
|
||||
const handleSaveOrder = async () => {
|
||||
setSaving(true);
|
||||
|
|
@ -929,18 +936,42 @@ export default function SnowballPage() {
|
|||
<div className="flex items-stretch">
|
||||
|
||||
{/* Grip */}
|
||||
<div
|
||||
data-grip
|
||||
onPointerDown={e => { if (!ramseyMode) onPointerDown(e, index); }}
|
||||
className={cn(
|
||||
'flex items-center px-3 transition-colors touch-none shrink-0',
|
||||
ramseyMode
|
||||
? 'text-muted-foreground/10 cursor-not-allowed'
|
||||
: 'text-muted-foreground/20 hover:text-muted-foreground/60 cursor-grab active:cursor-grabbing',
|
||||
)}
|
||||
aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
<div className="flex items-center gap-0.5 px-3 transition-colors touch-none shrink-0">
|
||||
<div
|
||||
data-grip
|
||||
onPointerDown={e => { if (!ramseyMode) onPointerDown(e, index); }}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
ramseyMode
|
||||
? 'text-muted-foreground/10 cursor-not-allowed'
|
||||
: 'text-muted-foreground/35 hover:text-muted-foreground/70 cursor-grab active:cursor-grabbing',
|
||||
)}
|
||||
aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveDebt(index, index - 1)}
|
||||
disabled={ramseyMode || saving || index === 0}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move debt up"
|
||||
aria-label={`Move ${bill.name} up`}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveDebt(index, index + 1)}
|
||||
disabled={ramseyMode || saving || index === bills.length - 1}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move debt down"
|
||||
aria-label={`Move ${bill.name} down`}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import {
|
|||
CheckCircle2,
|
||||
CheckCircle,
|
||||
Cloud,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
GripVertical,
|
||||
Link2,
|
||||
Loader2,
|
||||
Pause,
|
||||
|
|
@ -26,6 +29,7 @@ import {
|
|||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
streaming: 'Streaming',
|
||||
|
|
@ -62,13 +66,55 @@ function StatCard({ icon: Icon, label, value, hint }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SubscriptionRow({ item, onEdit, onToggle }) {
|
||||
function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
<div
|
||||
draggable={dragProps?.draggable}
|
||||
onDragStart={dragProps?.onDragStart}
|
||||
onDragEnter={dragProps?.onDragEnter}
|
||||
onDragOver={dragProps?.onDragOver}
|
||||
onDragEnd={dragProps?.onDragEnd}
|
||||
onDrop={dragProps?.onDrop}
|
||||
className={cn(
|
||||
'group',
|
||||
'grid min-w-0 gap-3 border-b border-border/40 px-4 py-4 last:border-b-0',
|
||||
'md:grid-cols-[minmax(0,1fr)_14rem_auto] md:items-center',
|
||||
'md:grid-cols-[auto_minmax(0,1fr)_14rem_auto] md:items-center',
|
||||
!item.active && 'opacity-60',
|
||||
dragProps?.isDragging && 'opacity-45',
|
||||
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
||||
)}>
|
||||
<div className="flex shrink-0 items-center gap-0.5 self-start md:self-center">
|
||||
<GripVertical
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground/55',
|
||||
moveControls?.enabled && 'cursor-grab group-active:cursor-grabbing',
|
||||
!moveControls?.enabled && 'opacity-30',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls?.onMoveUp}
|
||||
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move subscription up"
|
||||
aria-label={`Move ${item.name} up`}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={moveControls?.onMoveDown}
|
||||
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
|
||||
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
|
||||
title="Move subscription down"
|
||||
aria-label={`Move ${item.name} down`}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
|
|
@ -316,6 +362,9 @@ export default function SubscriptionsPage() {
|
|||
const [modal, setModal] = useState(null);
|
||||
const [matchTarget, setMatchTarget] = useState(null);
|
||||
const [recSearch, setRecSearch] = useState('');
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [dropTargetId, setDropTargetId] = useState(null);
|
||||
const [movingBillId, setMovingBillId] = useState(null);
|
||||
|
||||
const [txQuery, setTxQuery] = useState('');
|
||||
const [txResults, setTxResults] = useState([]);
|
||||
|
|
@ -358,7 +407,7 @@ export default function SubscriptionsPage() {
|
|||
useEffect(() => {
|
||||
load();
|
||||
loadRecommendations();
|
||||
api.bills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
|
||||
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
|
||||
}, [load, loadRecommendations]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -481,6 +530,88 @@ export default function SubscriptionsPage() {
|
|||
const subscriptions = data.subscriptions || [];
|
||||
const active = subscriptions.filter(item => item.active);
|
||||
const paused = subscriptions.filter(item => !item.active);
|
||||
const reorderEnabled = !loading && bills.length > 0;
|
||||
|
||||
async function persistSubscriptionOrder(nextSubscriptions, nextBills, movedId) {
|
||||
setData(prev => ({ ...prev, subscriptions: nextSubscriptions }));
|
||||
setBills(nextBills);
|
||||
setMovingBillId(movedId);
|
||||
try {
|
||||
await api.reorderBills(reorderPayload(nextBills));
|
||||
toast.success('Subscription order saved');
|
||||
await load();
|
||||
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save subscription order');
|
||||
await load();
|
||||
} finally {
|
||||
setMovingBillId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function reorderSubscriptionGroup(activeState, orderedGroup) {
|
||||
const sourceGroup = subscriptions.filter(item => !!item.active === activeState);
|
||||
const replacements = [...orderedGroup];
|
||||
const nextSubscriptions = subscriptions.map(item => (
|
||||
!!item.active === activeState ? replacements.shift() : item
|
||||
));
|
||||
|
||||
const sourceBills = bills.length ? bills : subscriptions;
|
||||
const affectedIds = new Set(sourceGroup.map(item => item.id));
|
||||
const billById = new Map(sourceBills.map(item => [item.id, item]));
|
||||
const orderedBills = orderedGroup.map(item => ({ ...(billById.get(item.id) || item), ...item }));
|
||||
const nextBills = sourceBills.map(bill => (
|
||||
affectedIds.has(bill.id) ? orderedBills.shift() : bill
|
||||
));
|
||||
persistSubscriptionOrder(nextSubscriptions, nextBills, movedItemId(sourceGroup, orderedGroup));
|
||||
}
|
||||
|
||||
function moveControlsForGroup(group, activeState) {
|
||||
return (item, index) => ({
|
||||
enabled: reorderEnabled,
|
||||
moving: movingBillId === item.id,
|
||||
canMoveUp: index > 0,
|
||||
canMoveDown: index < group.length - 1,
|
||||
onMoveUp: () => reorderSubscriptionGroup(activeState, moveInArray(group, index, index - 1)),
|
||||
onMoveDown: () => reorderSubscriptionGroup(activeState, moveInArray(group, index, index + 1)),
|
||||
});
|
||||
}
|
||||
|
||||
function dragPropsForGroup(group, activeState) {
|
||||
return (item, index) => {
|
||||
if (!reorderEnabled) return { draggable: false };
|
||||
return {
|
||||
draggable: true,
|
||||
isDragging: draggingId === item.id,
|
||||
isDropTarget: dropTargetId === item.id && draggingId !== item.id,
|
||||
onDragStart: (event) => {
|
||||
setDraggingId(item.id);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(item.id));
|
||||
},
|
||||
onDragEnter: () => {
|
||||
if (draggingId && draggingId !== item.id) setDropTargetId(item.id);
|
||||
},
|
||||
onDragOver: (event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
if (draggingId && draggingId !== item.id) setDropTargetId(item.id);
|
||||
},
|
||||
onDrop: (event) => {
|
||||
event.preventDefault();
|
||||
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
|
||||
const fromIndex = group.findIndex(row => row.id === sourceId);
|
||||
if (fromIndex >= 0) reorderSubscriptionGroup(activeState, moveInArray(group, fromIndex, index));
|
||||
setDraggingId(null);
|
||||
setDropTargetId(null);
|
||||
},
|
||||
onDragEnd: () => {
|
||||
setDraggingId(null);
|
||||
setDropTargetId(null);
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const MIN_CONFIDENCE = 90;
|
||||
const highConfidenceRecs = useMemo(
|
||||
|
|
@ -544,11 +675,25 @@ export default function SubscriptionsPage() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{active.map(item => (
|
||||
<SubscriptionRow key={item.id} item={item} onEdit={bill => setModal({ bill })} onToggle={toggleSubscription} />
|
||||
{active.map((item, index) => (
|
||||
<SubscriptionRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onEdit={bill => setModal({ bill })}
|
||||
onToggle={toggleSubscription}
|
||||
moveControls={moveControlsForGroup(active, true)(item, index)}
|
||||
dragProps={dragPropsForGroup(active, true)(item, index)}
|
||||
/>
|
||||
))}
|
||||
{paused.map(item => (
|
||||
<SubscriptionRow key={item.id} item={item} onEdit={bill => setModal({ bill })} onToggle={toggleSubscription} />
|
||||
{paused.map((item, index) => (
|
||||
<SubscriptionRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onEdit={bill => setModal({ bill })}
|
||||
onToggle={toggleSubscription}
|
||||
moveControls={moveControlsForGroup(paused, false)(item, index)}
|
||||
dragProps={dragPropsForGroup(paused, false)(item, index)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ const COLUMN_WHITELIST = new Set([
|
|||
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
|
||||
'sort_order', 'snowball_exempt', 'is_subscription', 'subscription_type', 'reminder_days_before',
|
||||
'subscription_source', 'subscription_detected_at', 'deleted_at', 'drift_snoozed_until',
|
||||
// categories table columns
|
||||
'sort_order',
|
||||
// sessions table columns
|
||||
'created_at',
|
||||
// financial_accounts table columns
|
||||
|
|
@ -2498,6 +2500,16 @@ function runMigrations() {
|
|||
)
|
||||
`).run();
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.75',
|
||||
description: 'categories: persistent sort order',
|
||||
dependsOn: ['v0.74'],
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
|
||||
if (!cols.includes('sort_order')) db.exec('ALTER TABLE categories ADD COLUMN sort_order INTEGER');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_sort ON categories(user_id, sort_order, name)');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -2937,6 +2949,13 @@ const ROLLBACK_SQL_MAP = {
|
|||
'ALTER TABLE bills DROP COLUMN sort_order',
|
||||
]
|
||||
},
|
||||
'v0.75': {
|
||||
description: 'categories: persistent sort order',
|
||||
sql: [
|
||||
'DROP INDEX IF EXISTS idx_categories_user_sort',
|
||||
'ALTER TABLE categories DROP COLUMN sort_order',
|
||||
]
|
||||
},
|
||||
'v0.51': {
|
||||
description: 'bills: snowball_exempt column',
|
||||
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS categories (
|
|||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.34.1.1",
|
||||
"version": "0.34.1.2",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ router.get('/', (req, res) => {
|
|||
ensureUserDefaultCategories(req.user.id);
|
||||
|
||||
const categories = db.prepare(`
|
||||
SELECT id, user_id, name, created_at, updated_at
|
||||
SELECT id, user_id, name, sort_order, created_at, updated_at
|
||||
FROM categories
|
||||
WHERE user_id = ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY name COLLATE NOCASE ASC
|
||||
ORDER BY CASE WHEN sort_order IS NULL THEN 1 ELSE 0 END,
|
||||
sort_order ASC,
|
||||
name COLLATE NOCASE ASC
|
||||
`).all(req.user.id);
|
||||
|
||||
const billsByCategory = db.prepare(`
|
||||
|
|
@ -65,6 +67,44 @@ router.get('/', (req, res) => {
|
|||
res.json(shaped);
|
||||
});
|
||||
|
||||
// PUT /api/categories/reorder
|
||||
router.put('/reorder', (req, res) => {
|
||||
const db = getDb();
|
||||
const entries = Object.entries(req.body || {}).map(([categoryId, sortOrder]) => ({
|
||||
categoryId: Number(categoryId),
|
||||
sortOrder: Number(sortOrder),
|
||||
}));
|
||||
|
||||
if (entries.length === 0) {
|
||||
return res.status(400).json(standardizeError('At least one category order is required', 'VALIDATION_ERROR', 'reorder'));
|
||||
}
|
||||
|
||||
const invalid = entries.find(({ categoryId, sortOrder }) => (
|
||||
!Number.isInteger(categoryId) || categoryId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0
|
||||
));
|
||||
if (invalid) {
|
||||
return res.status(400).json(standardizeError('Reorder payload must map category ids to non-negative integer positions', 'VALIDATION_ERROR', 'reorder'));
|
||||
}
|
||||
|
||||
const ids = entries.map(item => item.categoryId);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const owned = db.prepare(`
|
||||
SELECT id
|
||||
FROM categories
|
||||
WHERE user_id = ? AND deleted_at IS NULL AND id IN (${placeholders})
|
||||
`).all(req.user.id, ...ids);
|
||||
if (owned.length !== ids.length) {
|
||||
return res.status(404).json(standardizeError('One or more categories were not found', 'NOT_FOUND', 'category_id'));
|
||||
}
|
||||
|
||||
const update = db.prepare("UPDATE categories SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?");
|
||||
db.transaction((items) => {
|
||||
for (const item of items) update.run(item.sortOrder, item.categoryId, req.user.id);
|
||||
})(entries);
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /api/categories
|
||||
router.post('/', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
|
|||
|
|
@ -228,7 +228,11 @@ function getSubscriptions(db, userId) {
|
|||
WHERE b.user_id = ?
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.is_subscription = 1
|
||||
ORDER BY b.active DESC, b.due_day ASC, b.name COLLATE NOCASE ASC
|
||||
ORDER BY b.active DESC,
|
||||
CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END,
|
||||
b.sort_order ASC,
|
||||
b.due_day ASC,
|
||||
b.name COLLATE NOCASE ASC
|
||||
`).all(userId).map(decorateSubscription);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-category-reorder-test-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database');
|
||||
|
||||
function createUser(db, suffix) {
|
||||
return db.prepare(`
|
||||
INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at)
|
||||
VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now'))
|
||||
`).run(`category-reorder-${suffix}`, `category-reorder-${suffix}@local`).lastInsertRowid;
|
||||
}
|
||||
|
||||
function createCategory(db, userId, name) {
|
||||
return db.prepare(`
|
||||
INSERT INTO categories (user_id, name)
|
||||
VALUES (?, ?)
|
||||
`).run(userId, name).lastInsertRowid;
|
||||
}
|
||||
|
||||
function callCategoriesRoute(routePath, method, { userId, params = {}, body = {} }) {
|
||||
const categoriesRouter = require('../routes/categories');
|
||||
const layer = categoriesRouter.stack.find(item => item.route?.path === routePath && item.route.methods[method]);
|
||||
assert.ok(layer, `route ${method.toUpperCase()} ${routePath} should exist`);
|
||||
const handler = layer.route.stack[0].handle;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = {
|
||||
body,
|
||||
params,
|
||||
user: { id: userId, role: 'user' },
|
||||
};
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(data) {
|
||||
resolve({ status: this.statusCode, data });
|
||||
},
|
||||
};
|
||||
try {
|
||||
handler(req, res);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
fs.rmSync(`${dbPath}${suffix}`, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('category reorder endpoint persists category order for the current user', async () => {
|
||||
const db = getDb();
|
||||
const userId = createUser(db, 'owner');
|
||||
const food = createCategory(db, userId, 'Food');
|
||||
const loans = createCategory(db, userId, 'Loans');
|
||||
const utilities = createCategory(db, userId, 'Utilities');
|
||||
|
||||
const response = await callCategoriesRoute('/reorder', 'put', {
|
||||
userId,
|
||||
body: {
|
||||
[utilities]: 0,
|
||||
[food]: 1,
|
||||
[loans]: 2,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.data.success, true);
|
||||
|
||||
const ordered = await callCategoriesRoute('/', 'get', { userId });
|
||||
assert.deepEqual(
|
||||
ordered.data.filter(cat => [food, loans, utilities].includes(cat.id)).map(cat => cat.id),
|
||||
[utilities, food, loans],
|
||||
);
|
||||
});
|
||||
|
||||
test('category reorder rejects categories outside the current user scope', async () => {
|
||||
const db = getDb();
|
||||
const ownerId = createUser(db, 'scoped-owner');
|
||||
const otherId = createUser(db, 'scoped-other');
|
||||
const ownerCategory = createCategory(db, ownerId, 'Owner Category');
|
||||
const otherCategory = createCategory(db, otherId, 'Other Category');
|
||||
|
||||
const response = await callCategoriesRoute('/reorder', 'put', {
|
||||
userId: ownerId,
|
||||
body: {
|
||||
[ownerCategory]: 0,
|
||||
[otherCategory]: 1,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
});
|
||||
Loading…
Reference in New Issue