Fase 2: Gestor de Taxonomía (Árbol de Categorías) - Backend CORS y Frontend CRUD Recursivo
This commit is contained in:
@@ -3,6 +3,8 @@ import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import ProtectedLayout from './layouts/ProtectedLayout';
|
||||
|
||||
import CategoryManager from './pages/Categories/CategoryManager';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
@@ -11,6 +13,7 @@ function App() {
|
||||
|
||||
<Route element={<ProtectedLayout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/categories" element={<CategoryManager />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
28
frontend/admin-panel/src/components/Modal.tsx
Normal file
28
frontend/admin-panel/src/components/Modal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md animate-fade-in">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-800">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-red-500">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,9 @@ export default function ProtectedLayout() {
|
||||
<li className="mb-2">
|
||||
<a href="/" className="block p-2 hover:bg-gray-800 rounded">Dashboard</a>
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<a href="/categories" className="block p-2 hover:bg-gray-800 rounded">Categorías</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="p-4 border-t border-gray-800">
|
||||
|
||||
233
frontend/admin-panel/src/pages/Categories/CategoryManager.tsx
Normal file
233
frontend/admin-panel/src/pages/Categories/CategoryManager.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Category } from '../../types/Category';
|
||||
import { categoryService } from '../../services/categoryService';
|
||||
import Modal from '../../components/Modal';
|
||||
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Folder, FolderOpen } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// Type helper for the tree structure
|
||||
interface CategoryNode extends Category {
|
||||
children: CategoryNode[];
|
||||
level: number;
|
||||
}
|
||||
|
||||
export default function CategoryManager() {
|
||||
const [categories, setCategories] = useState<CategoryNode[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Category>>({ name: '', slug: '', active: true, parentId: null });
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadCategories = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await categoryService.getAll();
|
||||
const tree = buildTree(data);
|
||||
setCategories(tree);
|
||||
} catch (error) {
|
||||
console.error('Error loading categories:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildTree = (cats: Category[], parentId: number | null = null, level = 0): CategoryNode[] => {
|
||||
return cats
|
||||
.filter(c => c.parentId === parentId)
|
||||
.map(c => ({
|
||||
...c,
|
||||
level,
|
||||
children: buildTree(cats, c.id, level + 1)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreate = (parentId: number | null = null) => {
|
||||
setEditingCategory(null);
|
||||
setFormData({ name: '', slug: '', active: true, parentId });
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (category: Category) => {
|
||||
setEditingCategory(category);
|
||||
setFormData({ ...category });
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('¿Está seguro de eliminar esta categoría y todas sus subcategorías?')) return;
|
||||
try {
|
||||
await categoryService.delete(id);
|
||||
loadCategories();
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error);
|
||||
alert('Error al eliminar');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingCategory) {
|
||||
await categoryService.update(editingCategory.id, formData);
|
||||
} else {
|
||||
await categoryService.create(formData);
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
loadCategories();
|
||||
} catch (error) {
|
||||
console.error('Error saving category:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Recursive component for rendering specific tree items
|
||||
const CategoryItem = ({ node }: { node: CategoryNode }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="mb-1">
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center p-2 rounded hover:bg-gray-100 transition-colors group border-b border-gray-100",
|
||||
node.level === 0 && "font-semibold bg-gray-50 border-gray-200"
|
||||
)}
|
||||
style={{ marginLeft: `${node.level * 20}px` }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={clsx("mr-2 text-gray-500", node.children.length === 0 && "opacity-0")}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
|
||||
<span className="text-blue-500 mr-2">
|
||||
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
|
||||
</span>
|
||||
|
||||
<span className="flex-1">{node.name}</span>
|
||||
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleCreate(node.id)}
|
||||
className="p-1 text-green-600 hover:bg-green-100 rounded"
|
||||
title="Agregar Subcategoría"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(node)}
|
||||
className="p-1 text-blue-600 hover:bg-blue-100 rounded"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(node.id)}
|
||||
className="p-1 text-red-600 hover:bg-red-100 rounded"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && node.children.length > 0 && (
|
||||
<div className="border-l border-gray-200 ml-4 py-1">
|
||||
{node.children.map(child => (
|
||||
<CategoryItem key={child.id} node={child} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">Estructura de Categorías</h2>
|
||||
<button
|
||||
onClick={() => handleCreate(null)}
|
||||
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>Nueva Categoría Raíz</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded shadow p-6 min-h-[500px]">
|
||||
{isLoading ? (
|
||||
<div className="text-center text-gray-500 py-10">Cargando taxonomía...</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-10">No hay categorías definidas. Crea la primera.</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{categories.map(node => (
|
||||
<CategoryItem key={node.id} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={editingCategory ? 'Editar Categoría' : 'Nueva Categoría'}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Slug (URL)</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
id="activeCheck"
|
||||
className="rounded text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="activeCheck" className="text-sm font-medium text-gray-700">Activa</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
frontend/admin-panel/src/services/categoryService.ts
Normal file
27
frontend/admin-panel/src/services/categoryService.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import api from './api';
|
||||
import { Category } from '../types/Category';
|
||||
|
||||
export const categoryService = {
|
||||
getAll: async (): Promise<Category[]> => {
|
||||
const response = await api.get<Category[]>('/categories');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: number): Promise<Category> => {
|
||||
const response = await api.get<Category>(`/categories/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (category: Partial<Category>): Promise<Category> => {
|
||||
const response = await api.post<Category>('/categories', category);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: number, category: Partial<Category>): Promise<void> => {
|
||||
await api.put(`/categories/${id}`, category);
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/categories/${id}`);
|
||||
}
|
||||
};
|
||||
8
frontend/admin-panel/src/types/Category.ts
Normal file
8
frontend/admin-panel/src/types/Category.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface Category {
|
||||
id: number;
|
||||
parentId?: number | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
active: boolean;
|
||||
subcategories?: Category[]; // Para uso en el frontend
|
||||
}
|
||||
Reference in New Issue
Block a user