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:
parent
20cae64f60
commit
732302cb44
10 changed files with 484 additions and 74 deletions
61
package-lock.json
generated
61
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,37 +79,53 @@ 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" />
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect?.(node.id)}
|
||||||
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: node.color ?? "#9ca3af" }}
|
style={{ backgroundColor: node.color ?? "#9ca3af" }}
|
||||||
|
|
@ -74,10 +138,57 @@ function TreeRow({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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 (
|
||||||
|
<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">
|
<div className="flex flex-col gap-0.5">
|
||||||
{tree.map((parent) => (
|
{flatItems.map((item) => (
|
||||||
<div key={parent.id}>
|
<SortableTreeRow
|
||||||
<TreeRow
|
key={item.id}
|
||||||
node={parent}
|
item={item}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onToggle={toggle}
|
||||||
|
isDragActive={activeId !== null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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}
|
depth={0}
|
||||||
selectedId={selectedId}
|
selectedId={null}
|
||||||
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}
|
expanded={false}
|
||||||
onToggle={() => {}}
|
hasChildren={activeItem.hasChildren}
|
||||||
hasChildren={false}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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...",
|
||||||
|
|
|
||||||
|
|
@ -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é...",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue