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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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é...",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue