feat: Mejoras de usabilidad, filtros y consistencia visual

*   **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.
This commit is contained in:
2025-10-10 20:37:50 -03:00
parent 2b2cc873e5
commit a32f0467ef
6 changed files with 213 additions and 58 deletions

View File

@@ -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 (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<h2>Gestión de Componentes Maestros ({table.getFilteredRowModel().rows.length})</h2>
<p>Unifica valores inconsistentes y elimina registros no utilizados.</p>
<p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p>
@@ -199,12 +191,12 @@ const GestionComponentes = () => {
</div>
{isLoading ? (
<div style={tableContainerStyle}>
<div className={styles.tableContainer}>
<TableSkeleton rows={6} columns={3} />
</div>
) : (
<div style={tableContainerStyle}>
<table className={styles.table} style={tableStyle}>
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>

View File

@@ -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<Sector[]>([]);
@@ -14,10 +26,17 @@ const GestionSectores = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingSector, setEditingSector] = useState<Sector | null>(null);
// --- 2. Estados para filtro y ordenación ---
const [globalFilter, setGlobalFilter] = useState('');
const [sorting, setSorting] = useState<SortingState>([]);
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 <div>Cargando sectores...</div>;
}
// --- 3. Definición de columnas para React Table ---
const columns = useMemo<ColumnDef<Sector>[]>(() => [
{
header: 'Nombre del Sector',
accessorKey: 'nombre',
},
{
header: 'Acciones',
id: 'acciones',
cell: ({ row }) => (
<div style={{ display: 'flex', gap: '10px' }}>
<button onClick={() => handleOpenEditModal(row.original)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Pencil size={16} /> Editar</button>
<button onClick={() => handleDelete(row.original.id)} className={styles.deleteUserButton} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid', borderRadius: '4px' }}>
<Trash2 size={16} /> Eliminar
</button>
</div>
)
}
], [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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2>Gestión de Sectores</h2>
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<h2>Gestión de Sectores ({table.getFilteredRowModel().rows.length})</h2>
<p>Crea, edita y elimina los sectores de la organización.</p>
<div className={styles.controlsContainer}>
<input
type="text"
placeholder="Filtrar sectores..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className={styles.searchInput}
style={{ width: '300px' }}
/>
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginLeft: 'auto' }}>
<PlusCircle size={18} /> Añadir Sector
</button>
</div>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Nombre del Sector</th>
<th className={styles.th} style={{ width: '200px' }}>Acciones</th>
</tr>
</thead>
<tbody>
{sectores.map(sector => (
<tr key={sector.id} className={styles.tr}>
<td className={styles.td}>{sector.nombre}</td>
<td className={styles.td}>
<div style={{ display: 'flex', gap: '10px' }}>
<button onClick={() => handleOpenEditModal(sector)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Pencil size={16} /> Editar</button>
<button onClick={() => handleDelete(sector.id)} className={styles.deleteUserButton} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid #dc3545', borderRadius: '4px' }}>
<Trash2 size={16} /> Eliminar
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{isLoading ? (
<div className={styles.tableContainer}>
<TableSkeleton rows={6} columns={2} />
</div>
) : (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => {
const classNames = [styles.th];
if (header.id === 'acciones') {
classNames.push(styles.thActions);
}
return (
<th
key={header.id}
className={classNames.join(' ')}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() && (
<span className={styles.sortIndicator}>
{header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}
</span>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => {
const classNames = [styles.td];
if (cell.column.id === 'acciones') {
classNames.push(styles.tdActions);
}
return (
<td key={cell.id} className={classNames.join(' ')}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{isModalOpen && (
<ModalSector

View File

@@ -747,4 +747,23 @@
.columnToggleItem label {
white-space: nowrap;
}
}
/* --- ESTILOS PARA GESTIÓN DE COMPONENTES --- */
/* Estilos para la columna numérica (Nº de Equipos) */
.thNumeric,
.tdNumeric {
text-align: Center; /* Es buena práctica alinear números a la derecha */
padding-right: 2rem; /* Un poco más de espacio para que no se pegue a las acciones */
width: 1%;
white-space: nowrap;
}
/* Estilos para la columna de acciones */
.thActions,
.tdActions {
text-align: center; /* Centramos el título 'Acciones' y los botones */
width: 1%;
white-space: nowrap;
}

View File

@@ -11,6 +11,7 @@ import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/int
import styles from './SimpleTable.module.css';
import skeletonStyles from './Skeleton.module.css';
import { accentInsensitiveFilter } from '../utils/filtering';
import { equipoService, sectorService, usuarioService } from '../services/apiService';
import { PlusCircle, KeyRound, UserX, Pencil, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Columns3 } from 'lucide-react';
@@ -439,6 +440,10 @@ const SimpleTable = () => {
getPaginationRowModel: getPaginationRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
filterFns: {
accentInsensitive: accentInsensitiveFilter,
},
globalFilterFn: 'accentInsensitive',
initialState: {
sorting: [
{ id: 'sector', desc: false },

12
frontend/src/tanstack-table.d.ts vendored Normal file
View File

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

View File

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