Fase 2: Implementación de la Matriz de Operaciones (Backend & Frontend)
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Category } from '../../types/Category';
|
||||
import OperationManager from './OperationManager'; // Import added
|
||||
import { categoryService } from '../../services/categoryService';
|
||||
import Modal from '../../components/Modal';
|
||||
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Folder, FolderOpen } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Folder, FolderOpen, Settings } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { operationService } from '../../services/operationService';
|
||||
import { Operation } from '../../types/Operation';
|
||||
|
||||
// Type helper for the tree structure
|
||||
interface CategoryNode extends Category {
|
||||
@@ -20,6 +23,12 @@ export default function CategoryManager() {
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Category>>({ name: '', slug: '', active: true, parentId: null });
|
||||
|
||||
// New state for operations matrix
|
||||
const [selectedCategoryOps, setSelectedCategoryOps] = useState<Operation[]>([]);
|
||||
const [allOperations, setAllOperations] = useState<Operation[]>([]);
|
||||
const [configuringCategory, setConfiguringCategory] = useState<Category | null>(null);
|
||||
const [isOpsModalOpen, setIsOpsModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
@@ -85,6 +94,39 @@ export default function CategoryManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigureOps = async (category: Category) => {
|
||||
setConfiguringCategory(category);
|
||||
setIsOpsModalOpen(true);
|
||||
|
||||
// Load data in parallel
|
||||
const [ops, catOps] = await Promise.all([
|
||||
operationService.getAll(),
|
||||
categoryService.getOperations(category.id)
|
||||
]);
|
||||
|
||||
setAllOperations(ops);
|
||||
setSelectedCategoryOps(catOps);
|
||||
};
|
||||
|
||||
const toggleOperation = async (opId: number, isChecked: boolean) => {
|
||||
if (!configuringCategory) return;
|
||||
|
||||
try {
|
||||
if (isChecked) {
|
||||
await categoryService.addOperation(configuringCategory.id, opId);
|
||||
// Optimistic update
|
||||
const opToAdd = allOperations.find(o => o.id === opId);
|
||||
if (opToAdd) setSelectedCategoryOps([...selectedCategoryOps, opToAdd]);
|
||||
} else {
|
||||
await categoryService.removeOperation(configuringCategory.id, opId);
|
||||
setSelectedCategoryOps(selectedCategoryOps.filter(o => o.id !== opId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Error actualizando operación");
|
||||
}
|
||||
};
|
||||
|
||||
// Recursive component for rendering specific tree items
|
||||
const CategoryItem = ({ node }: { node: CategoryNode }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
@@ -112,6 +154,13 @@ export default function CategoryManager() {
|
||||
<span className="flex-1">{node.name}</span>
|
||||
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleConfigureOps(node)}
|
||||
className="p-1 text-purple-600 hover:bg-purple-100 rounded"
|
||||
title="Configurar Operaciones"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCreate(node.id)}
|
||||
className="p-1 text-green-600 hover:bg-green-100 rounded"
|
||||
@@ -160,6 +209,9 @@ export default function CategoryManager() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Operation Manager Section */}
|
||||
<OperationManager />
|
||||
|
||||
<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>
|
||||
@@ -228,6 +280,38 @@ export default function CategoryManager() {
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Matrix Operations Modal */}
|
||||
<Modal
|
||||
isOpen={isOpsModalOpen}
|
||||
onClose={() => setIsOpsModalOpen(false)}
|
||||
title={`Operaciones para: ${configuringCategory?.name}`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-4">Seleccione las operaciones habilitadas para este rubro.</p>
|
||||
{allOperations.length === 0 && <p>No hay operaciones definidas.</p>}
|
||||
<div className="space-y-2">
|
||||
{allOperations.map(op => {
|
||||
const isSelected = selectedCategoryOps.some(so => so.id === op.id);
|
||||
return (
|
||||
<label key={op.id} className="flex items-center gap-2 p-2 rounded hover:bg-gray-50 border border-gray-200 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => toggleOperation(op.id, e.target.checked)}
|
||||
className="rounded text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>{op.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="pt-4 text-right">
|
||||
<button onClick={() => setIsOpsModalOpen(false)} className="bg-blue-600 text-white px-4 py-2 rounded">Listo</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Operation } from '../../types/Operation'; // ID: type-import-fix
|
||||
import { operationService } from '../../services/operationService';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function OperationManager() {
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const [newOpName, setNewOpName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadOperations();
|
||||
}, []);
|
||||
|
||||
const loadOperations = async () => {
|
||||
const ops = await operationService.getAll();
|
||||
setOperations(ops);
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newOpName.trim()) return;
|
||||
|
||||
try {
|
||||
await operationService.create(newOpName);
|
||||
setNewOpName('');
|
||||
loadOperations();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Eliminar operación?')) return;
|
||||
try {
|
||||
await operationService.delete(id);
|
||||
loadOperations();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded shadow p-6 mb-6">
|
||||
<h3 className="text-xl font-bold mb-4">Operaciones Globales</h3>
|
||||
|
||||
<form onSubmit={handleCreate} className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newOpName}
|
||||
onChange={e => setNewOpName(e.target.value)}
|
||||
placeholder="Nueva operación (ej: Venta)"
|
||||
className="border p-2 rounded flex-1"
|
||||
/>
|
||||
<button type="submit" className="bg-green-600 text-white p-2 rounded flex items-center gap-1">
|
||||
<Plus size={18} /> Agregar
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{operations.map(op => (
|
||||
<div key={op.id} className="bg-gray-100 rounded px-3 py-1 flex items-center gap-2">
|
||||
<span>{op.name}</span>
|
||||
<button onClick={() => handleDelete(op.id)} className="text-red-500 hover:text-red-700">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,5 +23,18 @@ export const categoryService = {
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/categories/${id}`);
|
||||
},
|
||||
|
||||
getOperations: async (id: number): Promise<import('../types/Operation').Operation[]> => {
|
||||
const response = await api.get(`/categories/${id}/operations`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addOperation: async (categoryId: number, operationId: number): Promise<void> => {
|
||||
await api.post(`/categories/${categoryId}/operations/${operationId}`);
|
||||
},
|
||||
|
||||
removeOperation: async (categoryId: number, operationId: number): Promise<void> => {
|
||||
await api.delete(`/categories/${categoryId}/operations/${operationId}`);
|
||||
}
|
||||
};
|
||||
|
||||
18
frontend/admin-panel/src/services/operationService.ts
Normal file
18
frontend/admin-panel/src/services/operationService.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import api from './api';
|
||||
import { Operation } from '../types/Operation'; // ID: type-import-fix
|
||||
|
||||
export const operationService = {
|
||||
getAll: async (): Promise<Operation[]> => {
|
||||
const response = await api.get<Operation[]>('/operations');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (name: string): Promise<Operation> => {
|
||||
const response = await api.post<Operation>('/operations', { name });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/operations/${id}`);
|
||||
}
|
||||
};
|
||||
4
frontend/admin-panel/src/types/Operation.ts
Normal file
4
frontend/admin-panel/src/types/Operation.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Operation {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
Reference in New Issue
Block a user