diff --git a/app/dashboard/categories/page.tsx b/app/dashboard/categories/page.tsx index 8a84f8f..aef0102 100644 --- a/app/dashboard/categories/page.tsx +++ b/app/dashboard/categories/page.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import Layout from "@/components/layout/layout"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, Pencil, Trash2, ChevronRight, ChevronDown } from "lucide-react"; +import { Plus, Pencil, Trash2, ChevronRight, ChevronDown, MoveVertical } from "lucide-react"; import { toast } from "sonner"; import { Select, @@ -27,17 +27,39 @@ import { apiRequest } from "@/lib/api"; import type { Category } from "@/models/categories"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +// Drag and Drop imports +import { DndProvider, useDrag, useDrop, DropTargetMonitor } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; + +// Define interfaces for drag item +interface DragItem { + id: string; + parentId?: string; +} + +// Item Types +const ItemTypes = { + CATEGORY: 'category', +}; + export default function CategoriesPage() { const [categories, setCategories] = useState([]); const [newCategoryName, setNewCategoryName] = useState(""); const [selectedParentId, setSelectedParentId] = useState(""); const [editingCategory, setEditingCategory] = useState(null); const [categoryToDelete, setCategoryToDelete] = useState(null); + const [expanded, setExpanded] = useState>({}); - const rootCategories = categories.filter(cat => !cat.parentId); + // Get root categories sorted by order + const rootCategories = categories + .filter(cat => !cat.parentId) + .sort((a, b) => (a.order || 0) - (b.order || 0)); + // Get subcategories sorted by order const getSubcategories = (parentId: string) => - categories.filter(cat => cat.parentId === parentId); + categories + .filter(cat => cat.parentId === parentId) + .sort((a, b) => (a.order || 0) - (b.order || 0)); useEffect(() => { fetchCategories(); @@ -59,9 +81,18 @@ export default function CategoriesPage() { } try { + // Find the highest order in the selected parent group + const siblings = selectedParentId + ? categories.filter(cat => cat.parentId === selectedParentId) + : categories.filter(cat => !cat.parentId); + + const maxOrder = siblings.reduce((max, cat) => + Math.max(max, cat.order || 0), 0); + const response = await apiRequest("/categories", "POST", { name: newCategoryName, parentId: selectedParentId || undefined, + order: maxOrder + 1, }); setCategories([...categories, response]); @@ -103,21 +134,142 @@ export default function CategoriesPage() { } }; - const renderCategoryItem = (category: Category, level: number = 0) => { + const moveCategory = async (dragId: string, hoverId: string, parentId?: string) => { + // Create new array with updated orders + const dragIndex = categories.findIndex(cat => cat._id === dragId); + const hoverIndex = categories.findIndex(cat => cat._id === hoverId); + + if (dragIndex === -1 || hoverIndex === -1) return; + + const draggedCategory = categories[dragIndex]; + + // Make sure we're only reordering within the same parent group + if (draggedCategory.parentId !== parentId) return; + + // Create a copy for reordering + const updatedCategories = [...categories]; + + // Get only categories with the same parent for reordering + const siblingCategories = updatedCategories + .filter(cat => cat.parentId === parentId) + .sort((a, b) => (a.order || 0) - (b.order || 0)); + + // Remove the dragged category from its position + const draggedItem = siblingCategories.find(cat => cat._id === dragId); + if (!draggedItem) return; + + const filteredSiblings = siblingCategories.filter(cat => cat._id !== dragId); + + // Find where to insert the dragged item + const hoverItem = siblingCategories.find(cat => cat._id === hoverId); + if (!hoverItem) return; + + const hoverPos = filteredSiblings.findIndex(cat => cat._id === hoverId); + + // Insert the dragged item at the new position + filteredSiblings.splice(hoverPos, 0, draggedItem); + + // Update the order for all siblings + filteredSiblings.forEach((cat, index) => { + cat.order = index; + }); + + // Update the categories state + setCategories(updatedCategories.map(cat => { + const updatedCat = filteredSiblings.find(c => c._id === cat._id); + return updatedCat || cat; + })); + + // Save the new order to the server + try { + await apiRequest(`/categories/${dragId}`, "PUT", { + order: filteredSiblings.find(cat => cat._id === dragId)?.order, + }); + } catch (error) { + toast.error("Failed to update category order"); + // Revert to original order + fetchCategories(); + } + }; + + const toggleExpand = (categoryId: string) => { + setExpanded(prev => ({ + ...prev, + [categoryId]: !prev[categoryId] + })); + }; + + // Category Item Component with Drag & Drop + const CategoryItem = ({ category, level = 0 }: { category: Category, level?: number }) => { const subcategories = getSubcategories(category._id); const hasSubcategories = subcategories.length > 0; const isEditing = editingCategory?._id === category._id; + const isExpanded = expanded[category._id]; + + const ref = useRef(null); + + // Set up drag + const [{ isDragging }, drag] = useDrag({ + type: ItemTypes.CATEGORY, + item: { id: category._id, parentId: category.parentId }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + // Set up drop + const [{ handlerId, isOver }, drop] = useDrop({ + accept: ItemTypes.CATEGORY, + collect: (monitor) => ({ + handlerId: monitor.getHandlerId(), + isOver: monitor.isOver(), + }), + hover(item: DragItem, monitor) { + if (!ref.current) return; + + const dragId = item.id; + const hoverId = category._id; + + // Don't replace items with themselves + if (dragId === hoverId) return; + + // Only allow reordering within the same parent + if (item.parentId !== category.parentId) return; + + moveCategory(dragId, hoverId, category.parentId); + }, + }); + + // Connect the drag and drop refs + drag(drop(ref)); return (
+
+ +
+ {hasSubcategories && ( - + )} +
{isEditing ? (
- {subcategories.map(subcat => renderCategoryItem(subcat, level + 1))} + {isExpanded && subcategories.map(subcat => + + )}
); }; @@ -242,40 +396,40 @@ export default function CategoriesPage() { Category List -
- {rootCategories.length === 0 ? ( -

- No categories yet. Add your first category above. -

- ) : ( - rootCategories.map(category => renderCategoryItem(category)) - )} -
+ +
+ {rootCategories.length === 0 ? ( +

+ No categories yet. Add your first category above. +

+ ) : ( + rootCategories.map(category => ( + + )) + )} +
+
- + {/* Delete Confirmation Dialog */} - setCategoryToDelete(null)}> - - - Are you absolutely sure? - - This will permanently delete the category "{categoryToDelete?.name}" - {getSubcategories(categoryToDelete?._id || "").length > 0 && - " and all its subcategories"}. This action cannot be undone. - - - - Cancel - - Delete - - - - + {categoryToDelete && ( + setCategoryToDelete(null)}> + + + Are you sure? + + This will permanently delete the category "{categoryToDelete.name}". + This action cannot be undone. + + + + Cancel + Delete + + + + )}
); diff --git a/models/categories.ts b/models/categories.ts index 4920d41..d7f2728 100644 --- a/models/categories.ts +++ b/models/categories.ts @@ -1,5 +1,6 @@ export interface Category { _id: string; name: string; - parentId?: string + parentId?: string; + order?: number; } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0c0ca60..503854e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,8 @@ "pusher-js": "^8.4.0", "react": "^19.0.0", "react-day-picker": "8.10.1", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.0.0", "react-hook-form": "^7.54.1", "react-markdown": "^10.0.0", @@ -2421,6 +2423,24 @@ "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "license": "MIT" }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", + "license": "MIT" + }, "node_modules/@react-three/drei": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.0.6.tgz", @@ -4463,6 +4483,17 @@ "dev": true, "license": "MIT" }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5197,7 +5228,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-equals": { @@ -5842,6 +5872,15 @@ "integrity": "sha512-7GOkcqn0Y9EqU2OJZlzkwxj9Uynuln7URvr7dRjgqNJNZ5UbbjL/v1BjAvQogy57Psdd/ek1u2s6IDEFYlabrA==", "license": "Apache-2.0" }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -8216,6 +8255,45 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "license": "MIT", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", @@ -8478,6 +8556,15 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", diff --git a/package.json b/package.json index 6fe345d..8d9404f 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ "pusher-js": "^8.4.0", "react": "^19.0.0", "react-day-picker": "8.10.1", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.0.0", "react-hook-form": "^7.54.1", "react-markdown": "^10.0.0",