Fase 2: Gestor de Taxonomía (Árbol de Categorías) - Backend CORS y Frontend CRUD Recursivo

This commit is contained in:
2025-12-17 13:16:55 -03:00
parent f19cd781ad
commit 523a8394ae
7 changed files with 315 additions and 0 deletions

View File

@@ -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>

View 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>
);
}

View File

@@ -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">

View 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>
);
}

View 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}`);
}
};

View 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
}