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 Dashboard from './pages/Dashboard';
|
||||||
import ProtectedLayout from './layouts/ProtectedLayout';
|
import ProtectedLayout from './layouts/ProtectedLayout';
|
||||||
|
|
||||||
|
import CategoryManager from './pages/Categories/CategoryManager';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -11,6 +13,7 @@ function App() {
|
|||||||
|
|
||||||
<Route element={<ProtectedLayout />}>
|
<Route element={<ProtectedLayout />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/categories" element={<CategoryManager />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</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">
|
<li className="mb-2">
|
||||||
<a href="/" className="block p-2 hover:bg-gray-800 rounded">Dashboard</a>
|
<a href="/" className="block p-2 hover:bg-gray-800 rounded">Dashboard</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li className="mb-2">
|
||||||
|
<a href="/categories" className="block p-2 hover:bg-gray-800 rounded">Categorías</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t border-gray-800">
|
<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
|
||||||
|
}
|
||||||
@@ -10,6 +10,17 @@ builder.Services.AddSwaggerGen();
|
|||||||
|
|
||||||
builder.Services.AddInfrastructure();
|
builder.Services.AddInfrastructure();
|
||||||
|
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("AllowFrontend",
|
||||||
|
policy =>
|
||||||
|
{
|
||||||
|
policy.WithOrigins("http://localhost:5173")
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
@@ -21,6 +32,8 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseCors("AllowFrontend");
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|||||||
Reference in New Issue
Block a user