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", "name": "simpl_result_scaffold",
"version": "0.1.0", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.1.0", "version": "0.3.0",
"dependencies": { "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/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
@ -305,6 +308,55 @@
"node": ">=6.9.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -2890,6 +2942,11 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",

View file

@ -10,6 +10,9 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "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/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1", "@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> <span className="text-[var(--muted-foreground)]">{t("categories.type")}</span>
<p className="font-medium capitalize">{t(`categories.${selectedCategory.type}`)}</p> <p className="font-medium capitalize">{t(`categories.${selectedCategory.type}`)}</p>
</div> </div>
<div>
<span className="text-[var(--muted-foreground)]">{t("categories.sortOrder")}</span>
<p className="font-medium">{selectedCategory.sort_order}</p>
</div>
<div> <div>
<span className="text-[var(--muted-foreground)]">{t("categories.parent")}</span> <span className="text-[var(--muted-foreground)]">{t("categories.parent")}</span>
<p className="font-medium"> <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> <span className="text-xs text-[var(--muted-foreground)]">{t("categories.isInputableHint")}</span>
</div> </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"> <div className="flex items-center gap-2 pt-2">
<button <button
type="submit" type="submit"

View file

@ -1,12 +1,60 @@
import { useState } from "react"; import { useState, useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next"; 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"; import type { CategoryTreeNode } from "../../shared/types";
interface FlatItem {
id: number;
node: CategoryTreeNode;
depth: number;
parentId: number | null;
isExpanded: boolean;
hasChildren: boolean;
}
interface Props { interface Props {
tree: CategoryTreeNode[]; tree: CategoryTreeNode[];
selectedId: number | null; selectedId: number | null;
onSelect: (id: number) => void; 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 }) { function TypeBadge({ type }: { type: string }) {
@ -23,7 +71,7 @@ function TypeBadge({ type }: { type: string }) {
); );
} }
function TreeRow({ function TreeRowContent({
node, node,
depth, depth,
selectedId, selectedId,
@ -31,53 +79,116 @@ function TreeRow({
expanded, expanded,
onToggle, onToggle,
hasChildren, hasChildren,
dragHandleProps,
isDragging,
}: { }: {
node: CategoryTreeNode; node: CategoryTreeNode;
depth: number; depth: number;
selectedId: number | null; selectedId: number | null;
onSelect: (id: number) => void; onSelect?: (id: number) => void;
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle?: () => void;
hasChildren: boolean; hasChildren: boolean;
dragHandleProps?: Record<string, unknown>;
isDragging?: boolean;
}) { }) {
const { t } = useTranslation();
const isSelected = node.id === selectedId; const isSelected = node.id === selectedId;
return ( return (
<button <div
onClick={() => onSelect(node.id)} className={`w-full flex items-center gap-1.5 px-2 py-2 text-sm rounded-lg transition-colors
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"}
${isSelected ? "bg-[var(--muted)] border-l-2 border-[var(--primary)]" : "hover:bg-[var(--muted)]/50"}`} ${isDragging ? "opacity-40" : ""}`}
style={{ paddingLeft: `${depth * 20 + 12}px` }} 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 ? ( {hasChildren ? (
<span <span
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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} />} {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span> </span>
) : ( ) : (
<span className="w-4" /> <span className="w-4 flex-shrink-0" />
)} )}
<span <button
className="w-3 h-3 rounded-full flex-shrink-0" onClick={() => onSelect?.(node.id)}
style={{ backgroundColor: node.color ?? "#9ca3af" }} className="flex items-center gap-2 flex-1 min-w-0 text-left"
/> >
<span className="flex-1 truncate">{node.name}</span> <span
<TypeBadge type={node.type} /> className="w-3 h-3 rounded-full flex-shrink-0"
{node.keyword_count > 0 && ( style={{ backgroundColor: node.color ?? "#9ca3af" }}
<span className="text-[11px] text-[var(--muted-foreground)]"> />
{node.keyword_count} <span className="flex-1 truncate">{node.name}</span>
</span> <TypeBadge type={node.type} />
)} {node.keyword_count > 0 && (
</button> <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 [expanded, setExpanded] = useState<Set<number>>(() => {
const ids = new Set<number>(); const ids = new Set<number>();
for (const node of tree) { for (const node of tree) {
@ -85,44 +196,136 @@ export default function CategoryTree({ tree, selectedId, onSelect }: Props) {
} }
return ids; 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) => { setExpanded((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
return next; 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 ( return (
<div className="flex flex-col gap-0.5"> <DndContext
{tree.map((parent) => ( sensors={sensors}
<div key={parent.id}> collisionDetection={closestCenter}
<TreeRow onDragStart={handleDragStart}
node={parent} onDragEnd={handleDragEnd}
depth={0} >
selectedId={selectedId} <SortableContext items={flatItems.map((i) => i.id)} strategy={verticalListSortingStrategy}>
onSelect={onSelect} <div className="flex flex-col gap-0.5">
expanded={expanded.has(parent.id)} {flatItems.map((item) => (
onToggle={() => toggle(parent.id)} <SortableTreeRow
hasChildren={parent.children.length > 0} key={item.id}
/> item={item}
{expanded.has(parent.id) && selectedId={selectedId}
parent.children.map((child) => ( onSelect={onSelect}
<TreeRow onToggle={toggle}
key={child.id} isDragActive={activeId !== null}
node={child} />
depth={1} ))}
selectedId={selectedId}
onSelect={onSelect}
expanded={false}
onToggle={() => {}}
hasChildren={false}
/>
))}
</div> </div>
))} </SortableContext>
</div> <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, updateKeyword,
deactivateKeyword, deactivateKeyword,
reinitializeCategories as reinitializeCategoriesSvc, reinitializeCategories as reinitializeCategoriesSvc,
hasDuplicateSortOrders,
fixDuplicateSortOrders,
getNextSortOrder,
updateCategorySortOrders,
} from "../services/categoryService"; } from "../services/categoryService";
interface CategoriesState { interface CategoriesState {
@ -35,6 +39,7 @@ type CategoriesAction =
| { type: "SET_SAVING"; payload: boolean } | { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null } | { type: "SET_ERROR"; payload: string | null }
| { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } } | { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } }
| { type: "SET_TREE"; payload: CategoryTreeNode[] }
| { type: "SELECT_CATEGORY"; payload: number | null } | { type: "SELECT_CATEGORY"; payload: number | null }
| { type: "SET_KEYWORDS"; payload: Keyword[] } | { type: "SET_KEYWORDS"; payload: Keyword[] }
| { type: "START_CREATING" } | { type: "START_CREATING" }
@ -72,6 +77,17 @@ function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
return roots; 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 { function reducer(state: CategoriesState, action: CategoriesAction): CategoriesState {
switch (action.type) { switch (action.type) {
case "SET_LOADING": case "SET_LOADING":
@ -82,6 +98,8 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
return { ...state, error: action.payload, isLoading: false, isSaving: false }; return { ...state, error: action.payload, isLoading: false, isSaving: false };
case "SET_CATEGORIES": case "SET_CATEGORIES":
return { ...state, categories: action.payload.flat, tree: action.payload.tree, isLoading: false }; 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": case "SELECT_CATEGORY":
return { ...state, selectedCategoryId: action.payload, editingCategory: null, isCreating: false, keywords: [] }; return { ...state, selectedCategoryId: action.payload, editingCategory: null, isCreating: false, keywords: [] };
case "SET_KEYWORDS": case "SET_KEYWORDS":
@ -106,6 +124,7 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
export function useCategories() { export function useCategories() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const duplicateCheckDone = useRef(false);
const loadCategories = useCallback(async () => { const loadCategories = useCallback(async () => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
@ -113,6 +132,14 @@ export function useCategories() {
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
try { try {
if (!duplicateCheckDone.current) {
duplicateCheckDone.current = true;
const hasDups = await hasDuplicateSortOrders();
if (hasDups) {
await fixDuplicateSortOrders();
}
}
const rows = await getAllCategoriesWithCounts(); const rows = await getAllCategoriesWithCounts();
if (fetchId !== fetchIdRef.current) return; if (fetchId !== fetchIdRef.current) return;
const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] })); const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] }));
@ -171,7 +198,8 @@ export function useCategories() {
try { try {
if (state.isCreating) { 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 loadCategories();
await selectCategory(newId); await selectCategory(newId);
} else if (state.selectedCategoryId !== null) { } else if (state.selectedCategoryId !== null) {
@ -226,6 +254,79 @@ export function useCategories() {
} }
}, [loadCategories]); }, [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) => { const loadKeywords = useCallback(async (categoryId: number) => {
try { try {
const kws = await getKeywordsByCategoryId(categoryId); const kws = await getKeywordsByCategoryId(categoryId);
@ -290,5 +391,6 @@ export function useCategories() {
editKeyword, editKeyword,
removeKeyword, removeKeyword,
reinitializeCategories, reinitializeCategories,
moveCategory,
}; };
} }

View file

@ -246,7 +246,7 @@
"noParent": "No parent (top-level)", "noParent": "No parent (top-level)",
"isInputable": "Allow input", "isInputable": "Allow input",
"isInputableHint": "Uncheck to hide from budget and transaction dropdowns", "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", "selectCategory": "Select a category to view details",
"keywordCount": "Keywords", "keywordCount": "Keywords",
"keywordText": "Keyword...", "keywordText": "Keyword...",

View file

@ -246,7 +246,7 @@
"noParent": "Aucun parent (niveau supérieur)", "noParent": "Aucun parent (niveau supérieur)",
"isInputable": "Autoriser la saisie", "isInputable": "Autoriser la saisie",
"isInputableHint": "Décocher pour masquer du budget et des listes de catégories", "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", "selectCategory": "Sélectionnez une catégorie pour voir les détails",
"keywordCount": "Mots-clés", "keywordCount": "Mots-clés",
"keywordText": "Mot-clé...", "keywordText": "Mot-clé...",

View file

@ -23,6 +23,7 @@ export default function CategoriesPage() {
editKeyword, editKeyword,
removeKeyword, removeKeyword,
reinitializeCategories, reinitializeCategories,
moveCategory,
} = useCategories(); } = useCategories();
const handleReinitialize = async () => { const handleReinitialize = async () => {
@ -94,6 +95,7 @@ export default function CategoriesPage() {
tree={state.tree} tree={state.tree}
selectedId={state.selectedCategoryId} selectedId={state.selectedCategoryId}
onSelect={selectCategory} onSelect={selectCategory}
onMoveCategory={moveCategory}
/> />
</div> </div>
<CategoryDetailPanel <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> { export async function deactivateCategory(id: number): Promise<void> {
const db = await getDb(); const db = await getDb();
// Promote children to root level so they don't become orphans // Promote children to root level so they don't become orphans