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:
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
12
frontend/src/tanstack-table.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
}
|
||||
32
frontend/src/utils/filtering.ts
Normal file
32
frontend/src/utils/filtering.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user