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 { useState, useEffect } from 'react';
|
||||||
import { Category } from '../../types/Category';
|
import { Category } from '../../types/Category';
|
||||||
|
import OperationManager from './OperationManager'; // Import added
|
||||||
import { categoryService } from '../../services/categoryService';
|
import { categoryService } from '../../services/categoryService';
|
||||||
import Modal from '../../components/Modal';
|
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 clsx from 'clsx';
|
||||||
|
import { operationService } from '../../services/operationService';
|
||||||
|
import { Operation } from '../../types/Operation';
|
||||||
|
|
||||||
// Type helper for the tree structure
|
// Type helper for the tree structure
|
||||||
interface CategoryNode extends Category {
|
interface CategoryNode extends Category {
|
||||||
@@ -20,6 +23,12 @@ export default function CategoryManager() {
|
|||||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
const [formData, setFormData] = useState<Partial<Category>>({ name: '', slug: '', active: true, parentId: 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(() => {
|
useEffect(() => {
|
||||||
loadCategories();
|
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
|
// Recursive component for rendering specific tree items
|
||||||
const CategoryItem = ({ node }: { node: CategoryNode }) => {
|
const CategoryItem = ({ node }: { node: CategoryNode }) => {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
@@ -112,6 +154,13 @@ export default function CategoryManager() {
|
|||||||
<span className="flex-1">{node.name}</span>
|
<span className="flex-1">{node.name}</span>
|
||||||
|
|
||||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<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
|
<button
|
||||||
onClick={() => handleCreate(node.id)}
|
onClick={() => handleCreate(node.id)}
|
||||||
className="p-1 text-green-600 hover:bg-green-100 rounded"
|
className="p-1 text-green-600 hover:bg-green-100 rounded"
|
||||||
@@ -160,6 +209,9 @@ export default function CategoryManager() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Operation Manager Section */}
|
||||||
|
<OperationManager />
|
||||||
|
|
||||||
<div className="bg-white rounded shadow p-6 min-h-[500px]">
|
<div className="bg-white rounded shadow p-6 min-h-[500px]">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center text-gray-500 py-10">Cargando taxonomía...</div>
|
<div className="text-center text-gray-500 py-10">Cargando taxonomía...</div>
|
||||||
@@ -228,6 +280,38 @@ export default function CategoryManager() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</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>
|
</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> => {
|
delete: async (id: number): Promise<void> => {
|
||||||
await api.delete(`/categories/${id}`);
|
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;
|
||||||
|
}
|
||||||
@@ -52,4 +52,26 @@ public class CategoriesController : ControllerBase
|
|||||||
await _repository.DeleteAsync(id);
|
await _repository.DeleteAsync(id);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/operations")]
|
||||||
|
public async Task<IActionResult> GetOperations(int id)
|
||||||
|
{
|
||||||
|
var operations = await _repository.GetOperationsAsync(id);
|
||||||
|
return Ok(operations);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/operations/{operationId}")]
|
||||||
|
public async Task<IActionResult> AddOperation(int id, int operationId)
|
||||||
|
{
|
||||||
|
await _repository.AddOperationAsync(id, operationId);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}/operations/{operationId}")]
|
||||||
|
public async Task<IActionResult> RemoveOperation(int id, int operationId)
|
||||||
|
{
|
||||||
|
await _repository.RemoveOperationAsync(id, operationId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ public interface ICategoryRepository
|
|||||||
Task UpdateAsync(Category category);
|
Task UpdateAsync(Category category);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
Task<IEnumerable<Category>> GetSubCategoriesAsync(int parentId);
|
Task<IEnumerable<Category>> GetSubCategoriesAsync(int parentId);
|
||||||
|
Task<IEnumerable<Operation>> GetOperationsAsync(int categoryId);
|
||||||
|
Task AddOperationAsync(int categoryId, int operationId);
|
||||||
|
Task RemoveOperationAsync(int categoryId, int operationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
using SIGCM.Domain.Entities;
|
using SIGCM.Domain.Entities;
|
||||||
using SIGCM.Domain.Interfaces;
|
using SIGCM.Domain.Interfaces;
|
||||||
using SIGCM.Infrastructure.Data;
|
using SIGCM.Infrastructure.Data;
|
||||||
@@ -57,4 +58,38 @@ public class CategoryRepository : ICategoryRepository
|
|||||||
using var conn = _connectionFactory.CreateConnection();
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
return await conn.QueryAsync<Category>("SELECT * FROM Categories WHERE ParentId = @ParentId", new { ParentId = parentId });
|
return await conn.QueryAsync<Category>("SELECT * FROM Categories WHERE ParentId = @ParentId", new { ParentId = parentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Operation>> GetOperationsAsync(int categoryId)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
var sql = @"
|
||||||
|
SELECT o.*
|
||||||
|
FROM Operations o
|
||||||
|
INNER JOIN CategoryOperations co ON o.Id = co.OperationId
|
||||||
|
WHERE co.CategoryId = @CategoryId";
|
||||||
|
return await conn.QueryAsync<Operation>(sql, new { CategoryId = categoryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddOperationAsync(int categoryId, int operationId)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
var sql = "INSERT INTO CategoryOperations (CategoryId, OperationId) VALUES (@CategoryId, @OperationId)";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync(sql, new { CategoryId = categoryId, OperationId = operationId });
|
||||||
|
}
|
||||||
|
catch (SqlException)
|
||||||
|
{
|
||||||
|
// Ignore duplicate key errors if it already exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveOperationAsync(int categoryId, int operationId)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"DELETE FROM CategoryOperations WHERE CategoryId = @CategoryId AND OperationId = @OperationId",
|
||||||
|
new { CategoryId = categoryId, OperationId = operationId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user