From a32f0467efeb3e58592008ddeb16c6d9f206e105 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 10 Oct 2025 20:37:50 -0300 Subject: [PATCH] feat: Mejoras de usabilidad, filtros y consistencia visual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **Filtrado Insensible a Acentos y Mayúsculas:** * Se implementa un filtrado global en todas las tablas (`Equipos`, `Componentes`, `Sectores`) que ignora las tildes y no distingue entre mayúsculas y minúsculas. * Se crea una función de utilidad `accentInsensitiveFilter` para normalizar el texto antes de la comparación. * Se añade la ampliación de módulo (`tanstack-table.d.ts`) para integrar de forma segura el nuevo tipo de filtro con TypeScript. * **Refactor y Unificación de la Vista de Sectores:** * Se refactoriza por completo la vista "Gestión de Sectores" para utilizar `React Table` (`@tanstack/react-table`). * Ahora cuenta con las mismas funcionalidades que las otras vistas: ordenación por columnas, filtrado de texto, estado de carga con `TableSkeleton` y un diseño de controles unificado. * **Ajustes de Diseño y Layout:** * Se corrige el layout de las tablas en "Gestión de Componentes" y "Gestión de Sectores" para que la columna "Acciones" (y "Nº de Equipos") ocupe solo el espacio mínimo necesario, permitiendo que la columna principal se expanda. * Se eliminan estilos en línea en favor de clases de módulo CSS, asegurando que el diseño sea consistente y respete el sistema de temas (claro/oscuro). * **Corrección del Botón "Scroll to Top":** * Se soluciona definitivamente el problema del botón para volver arriba en la tabla de equipos. * Se ajusta la estructura JSX y se corrige el `useEffect` para que el listener de scroll se asigne correctamente después de que los datos hayan cargado, asegurando que el botón aparezca y se posicione de forma fija sobre el contenedor de la tabla. --- .../src/components/GestionComponentes.tsx | 26 +-- frontend/src/components/GestionSectores.tsx | 175 ++++++++++++++---- .../src/components/SimpleTable.module.css | 21 ++- frontend/src/components/SimpleTable.tsx | 5 + frontend/src/tanstack-table.d.ts | 12 ++ frontend/src/utils/filtering.ts | 32 ++++ 6 files changed, 213 insertions(+), 58 deletions(-) create mode 100644 frontend/src/tanstack-table.d.ts create mode 100644 frontend/src/utils/filtering.ts diff --git a/frontend/src/components/GestionComponentes.tsx b/frontend/src/components/GestionComponentes.tsx index 09436ca..95636bc 100644 --- a/frontend/src/components/GestionComponentes.tsx +++ b/frontend/src/components/GestionComponentes.tsx @@ -13,6 +13,7 @@ import { Pencil, Trash2 } from 'lucide-react'; import styles from './SimpleTable.module.css'; import { adminService } from '../services/apiService'; import TableSkeleton from './TableSkeleton'; +import { accentInsensitiveFilter } from '../utils/filtering'; // Interfaces interface TextValue { @@ -158,23 +159,14 @@ const GestionComponentes = () => { getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), + filterFns: { + accentInsensitive: accentInsensitiveFilter, + }, + globalFilterFn: 'accentInsensitive', }); - // CORRECCIÓN: Añadimos el tipo React.CSSProperties - const tableContainerStyle: React.CSSProperties = { - overflowX: 'auto', - border: '1px solid #dee2e6', - borderRadius: '8px', - marginTop: '1rem' - }; - - // CORRECCIÓN: Añadimos el tipo React.CSSProperties - const tableStyle: React.CSSProperties = { - minWidth: 'auto' - }; - return ( -
+

Gestión de Componentes Maestros ({table.getFilteredRowModel().rows.length})

Unifica valores inconsistentes y elimina registros no utilizados.

** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **

@@ -199,12 +191,12 @@ const GestionComponentes = () => {
{isLoading ? ( -
+
) : ( -
- +
+
{table.getHeaderGroups().map(headerGroup => ( diff --git a/frontend/src/components/GestionSectores.tsx b/frontend/src/components/GestionSectores.tsx index 4c62e2d..358c654 100644 --- a/frontend/src/components/GestionSectores.tsx +++ b/frontend/src/components/GestionSectores.tsx @@ -1,12 +1,24 @@ // frontend/src/components/GestionSectores.tsx -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import toast from 'react-hot-toast'; +import { + useReactTable, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + flexRender, + type SortingState, + type ColumnDef, +} from '@tanstack/react-table'; +import { PlusCircle, Pencil, Trash2 } from 'lucide-react'; +import { accentInsensitiveFilter } from '../utils/filtering'; + import type { Sector } from '../types/interfaces'; import styles from './SimpleTable.module.css'; import ModalSector from './ModalSector'; +import TableSkeleton from './TableSkeleton'; // <-- 1. Importar el esqueleto import { sectorService } from '../services/apiService'; -import { PlusCircle, Pencil, Trash2 } from 'lucide-react'; const GestionSectores = () => { const [sectores, setSectores] = useState([]); @@ -14,10 +26,17 @@ const GestionSectores = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [editingSector, setEditingSector] = useState(null); + // --- 2. Estados para filtro y ordenación --- + const [globalFilter, setGlobalFilter] = useState(''); + const [sorting, setSorting] = useState([]); + useEffect(() => { + setIsLoading(true); // Aseguramos que se muestre el esqueleto al cargar sectorService.getAll() .then(data => { - setSectores(data); + // Ordenar alfabéticamente por defecto + const sectoresOrdenados = [...data].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' })); + setSectores(sectoresOrdenados); }) .catch(err => { toast.error("No se pudieron cargar los sectores."); @@ -33,36 +52,38 @@ const GestionSectores = () => { setIsModalOpen(true); }; - const handleOpenEditModal = (sector: Sector) => { + const handleOpenEditModal = useCallback((sector: Sector) => { setEditingSector(sector); setIsModalOpen(true); - }; + }, []); const handleSave = async (id: number | null, nombre: string) => { const isEditing = id !== null; const toastId = toast.loading(isEditing ? 'Actualizando...' : 'Creando...'); try { + let refreshedData; if (isEditing) { await sectorService.update(id, nombre); - setSectores(prev => prev.map(s => s.id === id ? { ...s, nombre } : s)); + refreshedData = await sectorService.getAll(); toast.success('Sector actualizado.', { id: toastId }); } else { - const nuevoSector = await sectorService.create(nombre); - setSectores(prev => [...prev, nuevoSector]); + await sectorService.create(nombre); + refreshedData = await sectorService.getAll(); toast.success('Sector creado.', { id: toastId }); } + const sectoresOrdenados = [...refreshedData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' })); + setSectores(sectoresOrdenados); setIsModalOpen(false); } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; - const handleDelete = async (id: number) => { + const handleDelete = useCallback(async (id: number) => { if (!window.confirm("¿Estás seguro de eliminar este sector? Los equipos asociados quedarán sin sector.")) { return; } - const toastId = toast.loading('Eliminando...'); try { await sectorService.delete(id); @@ -71,43 +92,117 @@ const GestionSectores = () => { } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); } - }; + }, []); - if (isLoading) { - return
Cargando sectores...
; - } + // --- 3. Definición de columnas para React Table --- + const columns = useMemo[]>(() => [ + { + header: 'Nombre del Sector', + accessorKey: 'nombre', + }, + { + header: 'Acciones', + id: 'acciones', + cell: ({ row }) => ( +
+ + +
+ ) + } + ], [handleOpenEditModal, handleDelete]); + + // --- 4. Instancia de la tabla --- + const table = useReactTable({ + data: sectores, + columns, + state: { sorting, globalFilter }, + onSortingChange: setSorting, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + filterFns: { + accentInsensitive: accentInsensitiveFilter, + }, + globalFilterFn: 'accentInsensitive', + }); return (
-
-

Gestión de Sectores

-
-
- - - - - - - - {sectores.map(sector => ( - - - - - ))} - -
Nombre del SectorAcciones
{sector.nombre} -
- - -
-
+ + {isLoading ? ( +
+ +
+ ) : ( +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + const classNames = [styles.th]; + if (header.id === 'acciones') { + classNames.push(styles.thActions); + } + + return ( + + ); + })} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => { + const classNames = [styles.td]; + if (cell.column.id === 'acciones') { + classNames.push(styles.tdActions); + } + + return ( + + ); + })} + + ))} + +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getIsSorted() && ( + + {header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'} + + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ )} {isModalOpen && ( { getPaginationRowModel: getPaginationRowModel(), onColumnVisibilityChange: setColumnVisibility, onColumnSizingChange: setColumnSizing, + filterFns: { + accentInsensitive: accentInsensitiveFilter, + }, + globalFilterFn: 'accentInsensitive', initialState: { sorting: [ { id: 'sector', desc: false }, diff --git a/frontend/src/tanstack-table.d.ts b/frontend/src/tanstack-table.d.ts new file mode 100644 index 0000000..48535f0 --- /dev/null +++ b/frontend/src/tanstack-table.d.ts @@ -0,0 +1,12 @@ +// frontend/src/tanstack-table.d.ts + +// Importamos el tipo 'FilterFn' para usarlo en nuestra definición +import { type FilterFn } from '@tanstack/react-table'; + +// Ampliamos el módulo original de @tanstack/react-table +declare module '@tanstack/react-table' { + // Extendemos la interfaz FilterFns para que incluya nuestra función personalizada + interface FilterFns { + accentInsensitive: FilterFn; + } +} \ No newline at end of file diff --git a/frontend/src/utils/filtering.ts b/frontend/src/utils/filtering.ts new file mode 100644 index 0000000..2195ca4 --- /dev/null +++ b/frontend/src/utils/filtering.ts @@ -0,0 +1,32 @@ +// frontend/src/utils/filtering.ts + +import { type FilterFn } from '@tanstack/react-table'; + +/** + * Normaliza un texto: lo convierte a minúsculas y le quita los acentos. + * @param text El texto a normalizar. + * @returns El texto normalizado. + */ +const normalizeText = (text: unknown): string => { + // Nos aseguramos de que solo procesamos strings + if (typeof text !== 'string') return ''; + + return text + .toLowerCase() // 1. Convertir a minúsculas + .normalize('NFD') // 2. Descomponer caracteres (ej: 'é' se convierte en 'e' + '´') + .replace(/\p{Diacritic}/gu, ''); // 3. Eliminar los diacríticos (acentos) con una expresión regular Unicode +}; + +/** + * Función de filtro para TanStack Table que ignora acentos y mayúsculas/minúsculas. + */ +export const accentInsensitiveFilter: FilterFn = (row, columnId, filterValue) => { + const rowValue = row.getValue(columnId); + + // Normalizamos el valor de la fila y el valor del filtro + const normalizedRowValue = normalizeText(rowValue); + const normalizedFilterValue = normalizeText(filterValue); + + // Comprobamos si el valor normalizado de la fila incluye el del filtro + return normalizedRowValue.includes(normalizedFilterValue); +}; \ No newline at end of file