From 732302cb441af857d7098907ad6c9a5b86c73cd0 Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Mon, 16 Feb 2026 23:25:45 +0000 Subject: [PATCH] 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 --- package-lock.json | 61 +++- package.json | 3 + .../categories/CategoryDetailPanel.tsx | 4 - src/components/categories/CategoryForm.tsx | 10 - src/components/categories/CategoryTree.tsx | 313 +++++++++++++++--- src/hooks/useCategories.ts | 104 +++++- src/i18n/locales/en.json | 2 +- src/i18n/locales/fr.json | 2 +- src/pages/CategoriesPage.tsx | 2 + src/services/categoryService.ts | 57 ++++ 10 files changed, 484 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index caa0643..a0bc8d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index eb39000..8bc4a41 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/categories/CategoryDetailPanel.tsx b/src/components/categories/CategoryDetailPanel.tsx index 8a30826..4b6f39e 100644 --- a/src/components/categories/CategoryDetailPanel.tsx +++ b/src/components/categories/CategoryDetailPanel.tsx @@ -133,10 +133,6 @@ export default function CategoryDetailPanel({ {t("categories.type")}

{t(`categories.${selectedCategory.type}`)}

-
- {t("categories.sortOrder")} -

{selectedCategory.sort_order}

-
{t("categories.parent")}

diff --git a/src/components/categories/CategoryForm.tsx b/src/components/categories/CategoryForm.tsx index aea001f..6a3f25e 100644 --- a/src/components/categories/CategoryForm.tsx +++ b/src/components/categories/CategoryForm.tsx @@ -131,16 +131,6 @@ export default function CategoryForm({ {t("categories.isInputableHint")}

-
- - 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)]" - /> -
-
+ +
); } -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 ( +
+ onToggle(item.id)} + hasChildren={item.hasChildren} + dragHandleProps={listeners} + isDragging={isDragging} + /> +
+ ); +} + +export default function CategoryTree({ tree, selectedId, onSelect, onMoveCategory }: Props) { const [expanded, setExpanded] = useState>(() => { const ids = new Set(); for (const node of tree) { @@ -85,44 +196,136 @@ export default function CategoryTree({ tree, selectedId, onSelect }: Props) { } return ids; }); + const [activeId, setActiveId] = useState(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 ( -
- {tree.map((parent) => ( -
- toggle(parent.id)} - hasChildren={parent.children.length > 0} - /> - {expanded.has(parent.id) && - parent.children.map((child) => ( - {}} - hasChildren={false} - /> - ))} + + i.id)} strategy={verticalListSortingStrategy}> +
+ {flatItems.map((item) => ( + + ))}
- ))} -
+ + + {activeItem && ( +
+ +
+ )} +
+ ); } diff --git a/src/hooks/useCategories.ts b/src/hooks/useCategories.ts index b88d45a..4edfefa 100644 --- a/src/hooks/useCategories.ts +++ b/src/hooks/useCategories.ts @@ -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(); + 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, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7bc16a3..e1286b7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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...", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index d7d8a65..9128084 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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é...", diff --git a/src/pages/CategoriesPage.tsx b/src/pages/CategoriesPage.tsx index 284621d..dd52d43 100644 --- a/src/pages/CategoriesPage.tsx +++ b/src/pages/CategoriesPage.tsx @@ -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} />
{ + const db = await getDb(); + const rows = parentId === null + ? await db.select>( + `SELECT MAX(sort_order) AS max_sort FROM categories WHERE is_active = 1 AND parent_id IS NULL` + ) + : await db.select>( + `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 { + const db = await getDb(); + const rows = await db.select>( + `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 { + const db = await getDb(); + const rows = await db.select>( + `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 { + 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 { const db = await getDb(); // Promote children to root level so they don't become orphans