From 2b2cc873e5de13fde6d01883a2d00202113c8911 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 10 Oct 2025 20:02:11 -0300 Subject: [PATCH] Feat Tabla MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Selector de Columnas - Control de Ancho de Columnas - Botón Volver Arriba - Refinamiento de Modo Oscuro --- .../src/components/SimpleTable.module.css | 195 +++++++++++---- frontend/src/components/SimpleTable.tsx | 227 ++++++++++++------ 2 files changed, 296 insertions(+), 126 deletions(-) diff --git a/frontend/src/components/SimpleTable.module.css b/frontend/src/components/SimpleTable.module.css index e2b62c2..647e821 100644 --- a/frontend/src/components/SimpleTable.module.css +++ b/frontend/src/components/SimpleTable.module.css @@ -1,8 +1,20 @@ +/* frontend/src/components/SimpleTable.module.css */ + /* Estilos para el contenedor principal y controles */ +.header { + margin-bottom: 0.75rem; +} + +.header h2 { + margin-top: 5px; + margin-bottom: 1rem; + font-size: 1.25rem; +} + .controlsContainer { display: flex; gap: 20px; - margin-bottom: 10px; + margin-bottom: 0.5rem; align-items: center; } @@ -15,14 +27,37 @@ color: var(--color-text-primary); } +.paginationControls { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; +} + +.tableContainer { + overflow: auto; /* Cambiado a 'auto' para ambos scrolls */ + border: 1px solid var(--color-border); + border-radius: 8px; + position: relative; /* Necesario para posicionar el botón de scroll */ +} + +.tableContainer::-webkit-scrollbar { + height: 8px; + width: 8px; + background-color: var(--scrollbar-bg); +} + +.tableContainer::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); + border-radius: 4px; +} + .table { border-collapse: collapse; font-family: system-ui, -apple-system, sans-serif; font-size: 0.875rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); - width: 100%; - min-width: 1200px; - table-layout: fixed; + min-width: 100%; } .th { @@ -31,17 +66,45 @@ padding: 0.75rem 1rem; border-bottom: 2px solid var(--color-border); text-align: left; - cursor: pointer; user-select: none; white-space: nowrap; - position: sticky; - top: 0; - z-index: 2; background-color: var(--color-background); overflow: hidden; text-overflow: ellipsis; + position: sticky; + top: 0; + z-index: 2; } +.headerContent { + cursor: pointer; +} + +.resizer { + position: absolute; + right: 0; + top: 0; + height: 100%; + width: 5px; + background: var(--scrollbar-thumb); + cursor: col-resize; + user-select: none; + touch-action: none; + opacity: 0.25; + transition: opacity 0.2s ease-in-out; + z-index: 3; /* Asegura que el resizer esté sobre el contenido de la cabecera */ +} + +.th:hover .resizer { + opacity: 1; +} + +.isResizing { + background: var(--color-text-muted); + opacity: 1; +} + + .sortIndicator { margin-left: 0.5rem; font-size: 1.2em; @@ -69,30 +132,10 @@ color: var(--color-text-secondary); background-color: var(--color-surface); word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; } -/* NUEVA SECCIÓN PARA ANCHOS DE COLUMNA */ - -/* Columna numérica (Nº de Equipos) */ -.thNumeric, -.tdNumeric { - width: 150px; - text-align: left; -} - -.thActions, -.tdActions { - width: 220px; - white-space: nowrap; -} - -/* NUEVO: Columnas de Tabla Principal (SimpleTable) */ -.thIp, .tdIp { width: 90px; } -.thRam, .tdRam { width: 40px; text-align: center; } -.thArch, .tdArch { width: 80px; } -.thUsers, .tdUsers { width: 230px; } -.thSector, .tdSector { width: 170px; } - /* Estilos de botones dentro de la tabla */ .hostnameButton { background: none; @@ -163,32 +206,49 @@ /* Estilo para el botón de scroll-to-top */ .scrollToTop { - position: fixed; - bottom: 60px; - right: 20px; - width: 40px; - height: 40px; - border-radius: 50%; - background-color: var(--color-primary); - color: white; - border: none; - cursor: pointer; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - font-size: 20px; + position: absolute; + top: 6px; + right: 20px; /* Suficiente espacio para no quedar debajo de la scrollbar */ + z-index: 30; /* Un valor alto para asegurar que esté por encima de la tabla y su cabecera (z-index: 2) */ + width: 36px; + height: 36px; + background-color: var(--color-surface); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: 8%; + + /* Contenido y transiciones */ display: flex; align-items: center; justify-content: center; - transition: opacity 0.3s, transform 0.3s, background-color 0.3s; - z-index: 1002; + cursor: pointer; + transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out, background-color 0.2s, color 0.2s; + + /* Animación de entrada/salida */ + animation: pop-in 0.3s ease-out forwards; } .scrollToTop:hover { - transform: translateY(-3px); - background-color: var(--color-primary-hover); + background-color: var(--color-primary); + color: white; + transform: scale(1.1); } + /* ===== INICIO DE CAMBIOS PARA MODALES Y ANIMACIONES ===== */ +/* Keyframes para la animación de entrada */ +@keyframes pop-in { + from { + opacity: 0; + transform: scale(0.5); + } + to { + opacity: 1; + transform: scale(1); + } +} + @keyframes fadeIn { from { opacity: 0; @@ -315,7 +375,7 @@ background-color: var(--color-text-secondary); } -/* ===== NUEVOS ESTILOS PARA EL MODAL DE DETALLES ===== */ +/* ===== ESTILOS PARA EL MODAL DE DETALLES ===== */ .modalLarge { position: fixed; @@ -486,6 +546,10 @@ justify-content: center; } +.powerButton { + color: var(--color-text-secondary); +} + .powerButton:hover { border-color: var(--color-primary); background-color: color-mix(in srgb, var(--color-primary) 15%, transparent); @@ -646,4 +710,41 @@ cursor: not-allowed; opacity: 0.5; background-color: #6c757d; +} + +.columnToggleContainer { + position: relative; +} + +.columnToggleDropdown { + position: absolute; + top: 100%; + right: 0; + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.5rem; + z-index: 10; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + min-width: 200px; +} + +.columnToggleItem { + display: flex; + align-items: center; + padding: 0.5rem; + cursor: pointer; + border-radius: 4px; +} + +.columnToggleItem:hover { + background-color: var(--color-surface-hover); +} + +.columnToggleItem input { + margin-right: 0.5rem; +} + +.columnToggleItem label { + white-space: nowrap; } \ No newline at end of file diff --git a/frontend/src/components/SimpleTable.tsx b/frontend/src/components/SimpleTable.tsx index a0924b0..4e8ea18 100644 --- a/frontend/src/components/SimpleTable.tsx +++ b/frontend/src/components/SimpleTable.tsx @@ -1,18 +1,18 @@ // frontend/src/components/SimpleTable.tsx -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel, getPaginationRowModel, flexRender, type CellContext, - type ColumnDef + type ColumnDef, type VisibilityState, type ColumnSizingState } from '@tanstack/react-table'; import { Tooltip } from 'react-tooltip'; import toast from 'react-hot-toast'; import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces'; import styles from './SimpleTable.module.css'; -import skeletonStyles from './Skeleton.module.css'; // Importamos el estilo del esqueleto +import skeletonStyles from './Skeleton.module.css'; import { equipoService, sectorService, usuarioService } from '../services/apiService'; -import { PlusCircle, KeyRound, UserX, Pencil, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { PlusCircle, KeyRound, UserX, Pencil, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Columns3 } from 'lucide-react'; import ModalAnadirEquipo from './ModalAnadirEquipo'; import ModalEditarSector from './ModalEditarSector'; @@ -21,7 +21,7 @@ import ModalDetallesEquipo from './ModalDetallesEquipo'; import ModalAnadirDisco from './ModalAnadirDisco'; import ModalAnadirRam from './ModalAnadirRam'; import ModalAnadirUsuario from './ModalAnadirUsuario'; -import TableSkeleton from './TableSkeleton'; // Importamos el componente de esqueleto +import TableSkeleton from './TableSkeleton'; const SimpleTable = () => { const [data, setData] = useState([]); @@ -38,6 +38,40 @@ const SimpleTable = () => { const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [addingComponent, setAddingComponent] = useState<'disco' | 'ram' | 'usuario' | null>(null); const [isLoading, setIsLoading] = useState(true); + const [isColumnToggleOpen, setIsColumnToggleOpen] = useState(false); + const columnToggleRef = useRef(null); + const tableContainerRef = useRef(null); + + const [columnVisibility, setColumnVisibility] = useState(() => { + const storedVisibility = localStorage.getItem('table-column-visibility'); + return storedVisibility ? JSON.parse(storedVisibility) : { id: false, mac: false, os: false, arch: false }; + }); + + const [columnSizing, setColumnSizing] = useState(() => { + const storedSizing = localStorage.getItem('table-column-sizing'); + return storedSizing ? JSON.parse(storedSizing) : {}; + }); + + useEffect(() => { + localStorage.setItem('table-column-visibility', JSON.stringify(columnVisibility)); + }, [columnVisibility]); + + useEffect(() => { + localStorage.setItem('table-column-sizing', JSON.stringify(columnSizing)); + }, [columnSizing]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (columnToggleRef.current && !columnToggleRef.current.contains(event.target as Node)) { + setIsColumnToggleOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + const refreshHistory = async (hostname: string) => { try { @@ -99,10 +133,17 @@ const SimpleTable = () => { }, [selectedEquipo]); useEffect(() => { - const handleScroll = () => setShowScrollButton(window.scrollY > 200); - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, []); + const tableElement = tableContainerRef.current; + const handleScroll = () => { + if (tableElement) { + setShowScrollButton(tableElement.scrollTop > 200); + } + }; + tableElement?.addEventListener('scroll', handleScroll); + return () => { + tableElement?.removeEventListener('scroll', handleScroll); + }; + }, [isLoading]); useEffect(() => { setIsLoading(true); @@ -153,14 +194,14 @@ const SimpleTable = () => { const toastId = toast.loading('Actualizando...'); try { await usuarioService.updatePassword(modalPasswordData.id, password); - + const usernameToUpdate = modalPasswordData.username; const newData = data.map(equipo => { if (!equipo.usuarios.some(u => u.username === usernameToUpdate)) { return equipo; } - const updatedUsers = equipo.usuarios.map(user => + const updatedUsers = equipo.usuarios.map(user => user.username === usernameToUpdate ? { ...user, password: password } : user ); return { ...equipo, usuarios: updatedUsers }; @@ -319,15 +360,15 @@ const SimpleTable = () => { if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; - + const columns: ColumnDef[] = [ - { header: "ID", accessorKey: "id", enableHiding: true }, + { header: "ID", accessorKey: "id", enableHiding: true, enableResizing: false }, { header: "Nombre", accessorKey: "hostname", cell: (info: CellContext) => () }, { header: "IP", accessorKey: "ip", id: 'ip' }, - { header: "MAC", accessorKey: "mac", enableHiding: true }, + { header: "MAC", accessorKey: "mac" }, { header: "Motherboard", accessorKey: "motherboard" }, { header: "CPU", accessorKey: "cpu" }, { header: "RAM", accessorKey: "ram_installed", id: 'ram' }, @@ -391,22 +432,26 @@ const SimpleTable = () => { const table = useReactTable({ data: filteredData, columns, + columnResizeMode: 'onChange', getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onColumnSizingChange: setColumnSizing, initialState: { sorting: [ { id: 'sector', desc: false }, { id: 'hostname', desc: false } ], - columnVisibility: { id: false, mac: false }, pagination: { pageSize: 15, }, }, state: { globalFilter, + columnVisibility, + columnSizing, }, onGlobalFilterChange: setGlobalFilter, }); @@ -430,7 +475,7 @@ const SimpleTable = () => { } const PaginacionControles = ( -
+
-
-
- setGlobalFilter(e.target.value)} className={styles.searchInput} style={{ width: '300px' }} /> - Sector: - -
-

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

- - {PaginacionControles} -
- - - {table.getHeaderGroups().map(hg => ( - - {hg.headers.map(h => { - const classNames = [styles.th]; - if (h.id === 'ip') classNames.push(styles.thIp); - if (h.id === 'ram') classNames.push(styles.thRam); - if (h.id === 'arch') classNames.push(styles.thArch); - if (h.id === 'usuarios') classNames.push(styles.thUsers); - if (h.id === 'sector') classNames.push(styles.thSector); - +
+
+

Equipos ({table.getFilteredRowModel().rows.length})

+ +
+
+ setGlobalFilter(e.target.value)} className={styles.searchInput} style={{ width: '300px' }} /> + Sector: + +
+ + {isColumnToggleOpen && ( +
+ {table.getAllLeafColumns().map(column => { + if (column.id === 'id') return null; return ( -
- ); +
+ + +
+ ) })} - - ))} - - - {table.getRowModel().rows.map(row => ( - - {row.getVisibleCells().map(cell => { - const classNames = [styles.td]; - if (cell.column.id === 'ip') classNames.push(styles.tdIp); - if (cell.column.id === 'ram') classNames.push(styles.tdRam); - if (cell.column.id === 'arch') classNames.push(styles.tdArch); - if (cell.column.id === 'usuarios') classNames.push(styles.tdUsers); - if (cell.column.id === 'sector') classNames.push(styles.tdSector); + + )} + + +

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

+ - return ( - + ))} + +
- {flexRender(h.column.columnDef.header, h.getContext())} - {h.column.getIsSorted() && ({h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'})} -
+
+
+ + + {table.getHeaderGroups().map(hg => ( + + {hg.headers.map(h => ( + + ))} + + ))} + + + {table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + - ); - })} - - ))} - -
+
+ {flexRender(h.column.columnDef.header, h.getContext())} + {h.column.getIsSorted() && ({h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'})} +
+
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ ))} +
+
+ {showScrollButton && ( + + )}
{PaginacionControles} - {showScrollButton && ()} - {modalData && setModalData(null)} onSave={handleSave} />} {modalPasswordData && setModalPasswordData(null)} onSave={handleSavePassword} />}