-
+
+
+
@@ -934,7 +1015,10 @@ export default function BillsPage() {
Inactive
-
{inactive.length}
+
+ {inactive.length}
+ {!reorderEnabled && inactive.length > 1 && Clear filters to reorder}
+
)}
diff --git a/client/pages/CategoriesPage.jsx b/client/pages/CategoriesPage.jsx
index bfdd246..a2a42b4 100644
--- a/client/pages/CategoriesPage.jsx
+++ b/client/pages/CategoriesPage.jsx
@@ -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 (
@@ -463,11 +545,26 @@ export default function CategoriesPage() {
)}
- {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 (
-