Fase 2: Implementación de la Matriz de Operaciones (Backend & Frontend)

This commit is contained in:
2025-12-17 13:21:50 -03:00
parent 523a8394ae
commit fae9101c63
8 changed files with 251 additions and 1 deletions

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,4 @@
export interface Operation {
id: number;
name: string;
}

View File

@@ -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();
}
}

View File

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

View File

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