feat: add drag-and-drop reorder for categories and fix duplicate sort_order

Auto-fix duplicate sort_order values on load, auto-assign sort_order on
category creation, and add drag-and-drop via @dnd-kit to reorder and
reparent categories in the tree (with 2-level nesting constraint).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-16 23:25:45 +00:00
parent 20cae64f60
commit 732302cb44
10 changed files with 484 additions and 74 deletions

61
package-lock.json generated
View file

@ -1,13 +1,16 @@
{
"name": "simpl_result_scaffold",
"version": "0.1.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl_result_scaffold",
"version": "0.1.0",
"version": "0.3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
@ -305,6 +308,55 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -2890,6 +2942,11 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",

View file

@ -10,6 +10,9 @@
"tauri": "tauri"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",

View file

@ -133,10 +133,6 @@ export default function CategoryDetailPanel({
<span className="text-[var(--muted-foreground)]">{t("categories.type")}</span>
<p className="font-medium capitalize">{t(`categories.${selectedCategory.type}`)}</p>
</div>
<div>
<span className="text-[var(--muted-foreground)]">{t("categories.sortOrder")}</span>
<p className="font-medium">{selectedCategory.sort_order}</p>
</div>
<div>
<span className="text-[var(--muted-foreground)]">{t("categories.parent")}</span>
<p className="font-medium">

View file

@ -131,16 +131,6 @@ export default function CategoryForm({
<span className="text-xs text-[var(--muted-foreground)]">{t("categories.isInputableHint")}</span>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("categories.sortOrder")}</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
className="w-24 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
</div>
<div className="flex items-center gap-2 pt-2">
<button
type="submit"

View file

@ -1,12 +1,60 @@
import { useState } from "react";
import { useState, useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, ChevronDown } from "lucide-react";
import { ChevronRight, ChevronDown, GripVertical } from "lucide-react";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { CategoryTreeNode } from "../../shared/types";
interface FlatItem {
id: number;
node: CategoryTreeNode;
depth: number;
parentId: number | null;
isExpanded: boolean;
hasChildren: boolean;
}
interface Props {
tree: CategoryTreeNode[];
selectedId: number | null;
onSelect: (id: number) => void;
onMoveCategory: (id: number, newParentId: number | null, newIndex: number) => Promise<void>;
}
function flattenTree(tree: CategoryTreeNode[], expandedSet: Set<number>): FlatItem[] {
const items: FlatItem[] = [];
for (const node of tree) {
const hasChildren = node.children.length > 0;
const isExpanded = expandedSet.has(node.id);
items.push({ id: node.id, node, depth: 0, parentId: null, isExpanded, hasChildren });
if (isExpanded) {
for (const child of node.children) {
items.push({
id: child.id,
node: child,
depth: 1,
parentId: node.id,
isExpanded: false,
hasChildren: false,
});
}
}
}
return items;
}
function TypeBadge({ type }: { type: string }) {
@ -23,7 +71,7 @@ function TypeBadge({ type }: { type: string }) {
);
}
function TreeRow({
function TreeRowContent({
node,
depth,
selectedId,
@ -31,53 +79,116 @@ function TreeRow({
expanded,
onToggle,
hasChildren,
dragHandleProps,
isDragging,
}: {
node: CategoryTreeNode;
depth: number;
selectedId: number | null;
onSelect: (id: number) => void;
onSelect?: (id: number) => void;
expanded: boolean;
onToggle: () => void;
onToggle?: () => void;
hasChildren: boolean;
dragHandleProps?: Record<string, unknown>;
isDragging?: boolean;
}) {
const { t } = useTranslation();
const isSelected = node.id === selectedId;
return (
<button
onClick={() => onSelect(node.id)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left rounded-lg transition-colors
${isSelected ? "bg-[var(--muted)] border-l-2 border-[var(--primary)]" : "hover:bg-[var(--muted)]/50"}`}
style={{ paddingLeft: `${depth * 20 + 12}px` }}
<div
className={`w-full flex items-center gap-1.5 px-2 py-2 text-sm rounded-lg transition-colors
${isSelected ? "bg-[var(--muted)] border-l-2 border-[var(--primary)]" : "hover:bg-[var(--muted)]/50"}
${isDragging ? "opacity-40" : ""}`}
style={{ paddingLeft: `${depth * 20 + 4}px` }}
>
<span
{...dragHandleProps}
className="w-5 h-5 flex items-center justify-center cursor-grab text-[var(--muted-foreground)] hover:text-[var(--foreground)] flex-shrink-0"
title={t("categories.dragToReorder")}
>
<GripVertical size={14} />
</span>
{hasChildren ? (
<span
onClick={(e) => {
e.stopPropagation();
onToggle();
onToggle?.();
}}
className="w-4 h-4 flex items-center justify-center cursor-pointer text-[var(--muted-foreground)]"
className="w-4 h-4 flex items-center justify-center cursor-pointer text-[var(--muted-foreground)] flex-shrink-0"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
) : (
<span className="w-4" />
<span className="w-4 flex-shrink-0" />
)}
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: node.color ?? "#9ca3af" }}
/>
<span className="flex-1 truncate">{node.name}</span>
<TypeBadge type={node.type} />
{node.keyword_count > 0 && (
<span className="text-[11px] text-[var(--muted-foreground)]">
{node.keyword_count}
</span>
)}
</button>
<button
onClick={() => onSelect?.(node.id)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: node.color ?? "#9ca3af" }}
/>
<span className="flex-1 truncate">{node.name}</span>
<TypeBadge type={node.type} />
{node.keyword_count > 0 && (
<span className="text-[11px] text-[var(--muted-foreground)]">
{node.keyword_count}
</span>
)}
</button>
</div>
);
}
export default function CategoryTree({ tree, selectedId, onSelect }: Props) {
function SortableTreeRow({
item,
selectedId,
onSelect,
onToggle,
isDragActive,
}: {
item: FlatItem;
selectedId: number | null;
onSelect: (id: number) => void;
onToggle: (id: number) => void;
isDragActive: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
position: "relative" as const,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<TreeRowContent
node={item.node}
depth={item.depth}
selectedId={isDragActive ? null : selectedId}
onSelect={onSelect}
expanded={item.isExpanded}
onToggle={() => onToggle(item.id)}
hasChildren={item.hasChildren}
dragHandleProps={listeners}
isDragging={isDragging}
/>
</div>
);
}
export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) {
const [expanded, setExpanded] = useState<Set<number>>(() => {
const ids = new Set<number>();
for (const node of tree) {
@ -85,44 +196,136 @@ export default function CategoryTree({ tree, selectedId, onSelect }: Props) {
}
return ids;
});
const [activeId, setActiveId] = useState<number | null>(null);
const toggle = (id: number) => {
// Update expanded set when tree changes (new parents appear)
const flatItems = useMemo(() => flattenTree(tree, expanded), [tree, expanded]);
const activeItem = useMemo(
() => (activeId !== null ? flatItems.find((i) => i.id === activeId) ?? null : null),
[activeId, flatItems]
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
})
);
const toggle = useCallback((id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
}, []);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as number);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeIdx = flatItems.findIndex((i) => i.id === active.id);
const overIdx = flatItems.findIndex((i) => i.id === over.id);
if (activeIdx === -1 || overIdx === -1) return;
const activeItem = flatItems[activeIdx];
const overItem = flatItems[overIdx];
// Determine the new parent and index
let newParentId: number | null;
let newIndex: number;
if (overItem.depth === 0) {
// Dropping onto/near a root item
if (activeItem.depth === 0) {
// Root reorder: keep as root
newParentId = null;
// Count the root index of the over item
const rootItems = flatItems.filter((i) => i.depth === 0);
const overRootIdx = rootItems.findIndex((i) => i.id === over.id);
newIndex = overRootIdx;
} else {
// Child moving to root level
newParentId = null;
const rootItems = flatItems.filter((i) => i.depth === 0);
const overRootIdx = rootItems.findIndex((i) => i.id === over.id);
newIndex = overIdx > activeIdx ? overRootIdx + 1 : overRootIdx;
}
} else {
// Dropping onto/near a child item
if (activeItem.hasChildren) {
// Block: moving a root with children to become a child (would create 3 levels)
return;
}
newParentId = overItem.parentId;
// Find the index within that parent's children
const siblings = flatItems.filter(
(i) => i.depth === 1 && i.parentId === overItem.parentId
);
const overSiblingIdx = siblings.findIndex((i) => i.id === over.id);
newIndex = overIdx > activeIdx ? overSiblingIdx + 1 : overSiblingIdx;
// If moving from same parent, adjust index
if (activeItem.parentId === newParentId) {
const activeSiblingIdx = siblings.findIndex((i) => i.id === active.id);
if (activeSiblingIdx < overSiblingIdx) {
newIndex = overSiblingIdx;
} else {
newIndex = overSiblingIdx;
}
}
}
// Validate 2-level constraint: can't drop a root with children into a child position
if (newParentId !== null && activeItem.hasChildren) {
return;
}
onMoveCategory(active.id as number, newParentId, newIndex);
},
[flatItems, onMoveCategory]
);
return (
<div className="flex flex-col gap-0.5">
{tree.map((parent) => (
<div key={parent.id}>
<TreeRow
node={parent}
depth={0}
selectedId={selectedId}
onSelect={onSelect}
expanded={expanded.has(parent.id)}
onToggle={() => toggle(parent.id)}
hasChildren={parent.children.length > 0}
/>
{expanded.has(parent.id) &&
parent.children.map((child) => (
<TreeRow
key={child.id}
node={child}
depth={1}
selectedId={selectedId}
onSelect={onSelect}
expanded={false}
onToggle={() => {}}
hasChildren={false}
/>
))}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={flatItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-0.5">
{flatItems.map((item) => (
<SortableTreeRow
key={item.id}
item={item}
selectedId={selectedId}
onSelect={onSelect}
onToggle={toggle}
isDragActive={activeId !== null}
/>
))}
</div>
))}
</div>
</SortableContext>
<DragOverlay dropAnimation={null}>
{activeItem && (
<div className="bg-[var(--card)] rounded-lg shadow-lg border border-[var(--primary)] opacity-90">
<TreeRowContent
node={activeItem.node}
depth={0}
selectedId={null}
expanded={false}
hasChildren={activeItem.hasChildren}
/>
</div>
)}
</DragOverlay>
</DndContext>
);
}

View file

@ -16,6 +16,10 @@ import {
updateKeyword,
deactivateKeyword,
reinitializeCategories as reinitializeCategoriesSvc,
hasDuplicateSortOrders,
fixDuplicateSortOrders,
getNextSortOrder,
updateCategorySortOrders,
} from "../services/categoryService";
interface CategoriesState {
@ -35,6 +39,7 @@ type CategoriesAction =
| { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } }
| { type: "SET_TREE"; payload: CategoryTreeNode[] }
| { type: "SELECT_CATEGORY"; payload: number | null }
| { type: "SET_KEYWORDS"; payload: Keyword[] }
| { type: "START_CREATING" }
@ -72,6 +77,17 @@ function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
return roots;
}
function flattenTreeToCategories(tree: CategoryTreeNode[]): CategoryTreeNode[] {
const result: CategoryTreeNode[] = [];
for (const node of tree) {
result.push(node);
for (const child of node.children) {
result.push(child);
}
}
return result;
}
function reducer(state: CategoriesState, action: CategoriesAction): CategoriesState {
switch (action.type) {
case "SET_LOADING":
@ -82,6 +98,8 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
return { ...state, error: action.payload, isLoading: false, isSaving: false };
case "SET_CATEGORIES":
return { ...state, categories: action.payload.flat, tree: action.payload.tree, isLoading: false };
case "SET_TREE":
return { ...state, tree: action.payload, categories: flattenTreeToCategories(action.payload) };
case "SELECT_CATEGORY":
return { ...state, selectedCategoryId: action.payload, editingCategory: null, isCreating: false, keywords: [] };
case "SET_KEYWORDS":
@ -106,6 +124,7 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
export function useCategories() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const duplicateCheckDone = useRef(false);
const loadCategories = useCallback(async () => {
const fetchId = ++fetchIdRef.current;
@ -113,6 +132,14 @@ export function useCategories() {
dispatch({ type: "SET_ERROR", payload: null });
try {
if (!duplicateCheckDone.current) {
duplicateCheckDone.current = true;
const hasDups = await hasDuplicateSortOrders();
if (hasDups) {
await fixDuplicateSortOrders();
}
}
const rows = await getAllCategoriesWithCounts();
if (fetchId !== fetchIdRef.current) return;
const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] }));
@ -171,7 +198,8 @@ export function useCategories() {
try {
if (state.isCreating) {
const newId = await createCategory(formData);
const sortOrder = await getNextSortOrder(formData.parent_id);
const newId = await createCategory({ ...formData, sort_order: sortOrder });
await loadCategories();
await selectCategory(newId);
} else if (state.selectedCategoryId !== null) {
@ -226,6 +254,79 @@ export function useCategories() {
}
}, [loadCategories]);
const moveCategory = useCallback(
async (categoryId: number, newParentId: number | null, newIndex: number) => {
// Clone current tree
const cloneNode = (n: CategoryTreeNode): CategoryTreeNode => ({
...n,
children: n.children.map(cloneNode),
});
const newTree = state.tree.map(cloneNode);
// Find and remove the category from its current position
let movedNode: CategoryTreeNode | null = null;
// Search in roots
const rootIdx = newTree.findIndex((n) => n.id === categoryId);
if (rootIdx !== -1) {
movedNode = newTree.splice(rootIdx, 1)[0];
} else {
// Search in children
for (const parent of newTree) {
const childIdx = parent.children.findIndex((c) => c.id === categoryId);
if (childIdx !== -1) {
movedNode = parent.children.splice(childIdx, 1)[0];
break;
}
}
}
if (!movedNode) return;
// Update parent_id
movedNode.parent_id = newParentId;
// Insert at new position
if (newParentId === null) {
newTree.splice(newIndex, 0, movedNode);
} else {
const newParent = newTree.find((n) => n.id === newParentId);
if (!newParent) return;
newParent.children.splice(newIndex, 0, movedNode);
}
// Optimistic update
dispatch({ type: "SET_TREE", payload: newTree });
// Compute batch updates for affected sibling groups
const updates: Array<{ id: number; sort_order: number; parent_id: number | null }> = [];
// Collect all affected sibling groups
const affectedGroups = new Set<number | null>();
affectedGroups.add(newParentId);
// Also include the old parent group (category may have moved away)
// We recompute all roots and all children groups to be safe
// Roots
newTree.forEach((n, i) => {
updates.push({ id: n.id, sort_order: i + 1, parent_id: null });
});
// Children
for (const parent of newTree) {
parent.children.forEach((c, i) => {
updates.push({ id: c.id, sort_order: i + 1, parent_id: parent.id });
});
}
try {
await updateCategorySortOrders(updates);
} catch {
// Revert on error
await loadCategories();
}
},
[state.tree, loadCategories]
);
const loadKeywords = useCallback(async (categoryId: number) => {
try {
const kws = await getKeywordsByCategoryId(categoryId);
@ -290,5 +391,6 @@ export function useCategories() {
editKeyword,
removeKeyword,
reinitializeCategories,
moveCategory,
};
}

View file

@ -246,7 +246,7 @@
"noParent": "No parent (top-level)",
"isInputable": "Allow input",
"isInputableHint": "Uncheck to hide from budget and transaction dropdowns",
"sortOrder": "Sort Order",
"dragToReorder": "Drag to reorder or change parent",
"selectCategory": "Select a category to view details",
"keywordCount": "Keywords",
"keywordText": "Keyword...",

View file

@ -246,7 +246,7 @@
"noParent": "Aucun parent (niveau supérieur)",
"isInputable": "Autoriser la saisie",
"isInputableHint": "Décocher pour masquer du budget et des listes de catégories",
"sortOrder": "Ordre de tri",
"dragToReorder": "Glisser pour réordonner ou changer le parent",
"selectCategory": "Sélectionnez une catégorie pour voir les détails",
"keywordCount": "Mots-clés",
"keywordText": "Mot-clé...",

View file

@ -23,6 +23,7 @@ export default function CategoriesPage() {
editKeyword,
removeKeyword,
reinitializeCategories,
moveCategory,
} = useCategories();
const handleReinitialize = async () => {
@ -94,6 +95,7 @@ export default function CategoriesPage() {
tree={state.tree}
selectedId={state.selectedCategoryId}
onSelect={selectCategory}
onMoveCategory={moveCategory}
/>
</div>
<CategoryDetailPanel

View file

@ -60,6 +60,63 @@ export async function updateCategory(
);
}
export async function getNextSortOrder(parentId: number | null): Promise<number> {
const db = await getDb();
const rows = parentId === null
? await db.select<Array<{ max_sort: number | null }>>(
`SELECT MAX(sort_order) AS max_sort FROM categories WHERE is_active = 1 AND parent_id IS NULL`
)
: await db.select<Array<{ max_sort: number | null }>>(
`SELECT MAX(sort_order) AS max_sort FROM categories WHERE is_active = 1 AND parent_id = $1`,
[parentId]
);
return (rows[0]?.max_sort ?? 0) + 1;
}
export async function hasDuplicateSortOrders(): Promise<boolean> {
const db = await getDb();
const rows = await db.select<Array<{ cnt: number }>>(
`SELECT COUNT(*) AS cnt FROM (
SELECT parent_id, sort_order FROM categories WHERE is_active = 1
GROUP BY parent_id, sort_order HAVING COUNT(*) > 1
)`
);
return (rows[0]?.cnt ?? 0) > 0;
}
export async function fixDuplicateSortOrders(): Promise<void> {
const db = await getDb();
const rows = await db.select<Array<{ id: number; parent_id: number | null }>>(
`SELECT id, parent_id FROM categories WHERE is_active = 1 ORDER BY parent_id, sort_order, name`
);
let currentParentId: number | null | undefined = undefined;
let seq = 0;
for (const row of rows) {
if (row.parent_id !== currentParentId) {
currentParentId = row.parent_id;
seq = 0;
}
seq++;
await db.execute(
`UPDATE categories SET sort_order = $1 WHERE id = $2`,
[seq, row.id]
);
}
}
export async function updateCategorySortOrders(
updates: Array<{ id: number; sort_order: number; parent_id: number | null }>
): Promise<void> {
const db = await getDb();
for (const u of updates) {
await db.execute(
`UPDATE categories SET sort_order = $1, parent_id = $2 WHERE id = $3`,
[u.sort_order, u.parent_id, u.id]
);
}
}
export async function deactivateCategory(id: number): Promise<void> {
const db = await getDb();
// Promote children to root level so they don't become orphans