// frontend/src/components/SimpleTable.tsx import React, { useEffect, useState } from 'react'; import { useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel, getPaginationRowModel, flexRender, type CellContext, type ColumnDef } 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 { equipoService, sectorService, usuarioService } from '../services/apiService'; import { PlusCircle, KeyRound, UserX, Pencil, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; import ModalAnadirEquipo from './ModalAnadirEquipo'; import ModalEditarSector from './ModalEditarSector'; import ModalCambiarClave from './ModalCambiarClave'; 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 const SimpleTable = () => { const [data, setData] = useState([]); const [filteredData, setFilteredData] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); const [selectedSector, setSelectedSector] = useState('Todos'); const [modalData, setModalData] = useState(null); const [sectores, setSectores] = useState([]); const [modalPasswordData, setModalPasswordData] = useState(null); const [showScrollButton, setShowScrollButton] = useState(false); const [selectedEquipo, setSelectedEquipo] = useState(null); const [historial, setHistorial] = useState([]); const [isOnline, setIsOnline] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [addingComponent, setAddingComponent] = useState<'disco' | 'ram' | 'usuario' | null>(null); const [isLoading, setIsLoading] = useState(true); const refreshHistory = async (hostname: string) => { try { const data = await equipoService.getHistory(hostname); setHistorial(data.historial); } catch (error) { console.error('Error refreshing history:', error); } }; useEffect(() => { const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth; if (selectedEquipo || modalData || modalPasswordData || isAddModalOpen) { document.body.classList.add('scroll-lock'); document.body.style.paddingRight = `${scrollBarWidth}px`; } else { document.body.classList.remove('scroll-lock'); document.body.style.paddingRight = '0'; } return () => { document.body.classList.remove('scroll-lock'); document.body.style.paddingRight = '0'; }; }, [selectedEquipo, modalData, modalPasswordData, isAddModalOpen]); useEffect(() => { if (!selectedEquipo) return; let isMounted = true; const checkPing = async () => { if (!selectedEquipo.ip) return; try { const data = await equipoService.ping(selectedEquipo.ip); if (isMounted) setIsOnline(data.isAlive); } catch (error) { if (isMounted) setIsOnline(false); console.error('Error checking ping:', error); } }; checkPing(); const interval = setInterval(checkPing, 10000); return () => { isMounted = false; clearInterval(interval); setIsOnline(false); }; }, [selectedEquipo]); const handleCloseModal = () => { if (addingComponent) { toast.error("Debes cerrar la ventana de añadir componente primero."); return; } setSelectedEquipo(null); setIsOnline(false); }; useEffect(() => { if (selectedEquipo) { equipoService.getHistory(selectedEquipo.hostname) .then(data => setHistorial(data.historial)) .catch(error => console.error('Error fetching history:', error)); } }, [selectedEquipo]); useEffect(() => { const handleScroll = () => setShowScrollButton(window.scrollY > 200); window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []); useEffect(() => { setIsLoading(true); Promise.all([ equipoService.getAll(), sectorService.getAll() ]).then(([equiposData, sectoresData]) => { setData(equiposData); setFilteredData(equiposData); const sectoresOrdenados = [...sectoresData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' })); setSectores(sectoresOrdenados); }).catch(error => { toast.error("No se pudieron cargar los datos iniciales."); console.error("Error al cargar datos:", error); }).finally(() => setIsLoading(false)); }, []); const handleSectorChange = (e: React.ChangeEvent) => { const value = e.target.value; setSelectedSector(value); if (value === 'Todos') setFilteredData(data); else if (value === 'Asignar') setFilteredData(data.filter(i => !i.sector)); else setFilteredData(data.filter(i => i.sector?.nombre === value)); }; const handleSave = async () => { if (!modalData) return; const toastId = toast.loading('Guardando...'); try { const sectorId = modalData.sector?.id ?? 0; await equipoService.updateSector(modalData.id, sectorId); const equipoActualizado = { ...modalData, sector_id: modalData.sector?.id }; const updateFunc = (prev: Equipo[]) => prev.map(e => e.id === modalData.id ? equipoActualizado : e); setData(updateFunc); setFilteredData(updateFunc); if (selectedEquipo && selectedEquipo.id === modalData.id) { setSelectedEquipo(equipoActualizado); } toast.success('Sector actualizado.', { id: toastId }); setModalData(null); } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; const handleSavePassword = async (password: string) => { if (!modalPasswordData) return; 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 => user.username === usernameToUpdate ? { ...user, password: password } : user ); return { ...equipo, usuarios: updatedUsers }; }); setData(newData); if (selectedSector === 'Todos') setFilteredData(newData); else if (selectedSector === 'Asignar') setFilteredData(newData.filter(i => !i.sector)); else setFilteredData(newData.filter(i => i.sector?.nombre === selectedSector)); if (selectedEquipo) { const updatedSelectedEquipo = newData.find(e => e.id === selectedEquipo.id); if (updatedSelectedEquipo) { setSelectedEquipo(updatedSelectedEquipo); } } toast.success(`Contraseña para '${usernameToUpdate}' actualizada en todos sus equipos.`, { id: toastId }); setModalPasswordData(null); } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; const handleRemoveUser = async (hostname: string, username: string) => { if (!window.confirm(`¿Quitar a ${username} de este equipo?`)) return; const toastId = toast.loading(`Quitando a ${username}...`); try { await usuarioService.removeUserFromEquipo(hostname, username); let equipoActualizado: Equipo | undefined; const updateFunc = (prev: Equipo[]) => prev.map(e => { if (e.hostname === hostname) { equipoActualizado = { ...e, usuarios: e.usuarios.filter(u => u.username !== username) }; return equipoActualizado; } return e; }); setData(updateFunc); setFilteredData(updateFunc); if (selectedEquipo && equipoActualizado && selectedEquipo.id === equipoActualizado.id) { setSelectedEquipo(equipoActualizado); } toast.success(`${username} quitado.`, { id: toastId }); } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; const handleDelete = async (id: number) => { if (!window.confirm('¿Eliminar este equipo y sus relaciones?')) return false; const toastId = toast.loading('Eliminando equipo...'); try { await equipoService.deleteManual(id); setData(prev => prev.filter(e => e.id !== id)); setFilteredData(prev => prev.filter(e => e.id !== id)); toast.success('Equipo eliminado.', { id: toastId }); return true; } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); return false; } }; const handleRemoveAssociation = async (type: 'disco' | 'ram' | 'usuario', associationId: number | { equipoId: number, usuarioId: number }) => { if (!window.confirm('¿Estás seguro de que quieres eliminar esta asociación manual?')) return; const toastId = toast.loading('Eliminando asociación...'); try { let successMessage = ''; if (type === 'disco' && typeof associationId === 'number') { await equipoService.removeDiscoAssociation(associationId); successMessage = 'Disco desasociado del equipo.'; } else if (type === 'ram' && typeof associationId === 'number') { await equipoService.removeRamAssociation(associationId); successMessage = 'Módulo de RAM desasociado.'; } else if (type === 'usuario' && typeof associationId === 'object') { await equipoService.removeUserAssociation(associationId.equipoId, associationId.usuarioId); successMessage = 'Usuario desasociado del equipo.'; } else { throw new Error('Tipo de asociación no válido'); } const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => { if (equipo.id !== selectedEquipo?.id) return equipo; let updatedEquipo = { ...equipo }; if (type === 'disco' && typeof associationId === 'number') { updatedEquipo.discos = equipo.discos.filter(d => d.equipoDiscoId !== associationId); } else if (type === 'ram' && typeof associationId === 'number') { updatedEquipo.memoriasRam = equipo.memoriasRam.filter(m => m.equipoMemoriaRamId !== associationId); } else if (type === 'usuario' && typeof associationId === 'object') { updatedEquipo.usuarios = equipo.usuarios.filter(u => u.id !== associationId.usuarioId); } return updatedEquipo; }); setData(updateState); setFilteredData(updateState); setSelectedEquipo(prev => prev ? updateState([prev])[0] : null); if (selectedEquipo) { await refreshHistory(selectedEquipo.hostname); } toast.success(successMessage, { id: toastId }); } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; const handleCreateEquipo = async (nuevoEquipo: Omit) => { const toastId = toast.loading('Creando nuevo equipo...'); try { const equipoCreado = await equipoService.createManual(nuevoEquipo); setData(prev => [...prev, equipoCreado]); setFilteredData(prev => [...prev, equipoCreado]); toast.success(`Equipo '${equipoCreado.hostname}' creado.`, { id: toastId }); setIsAddModalOpen(false); } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; const handleEditEquipo = async (id: number, equipoEditado: any) => { const toastId = toast.loading('Guardando cambios...'); try { const equipoActualizadoDesdeBackend = await equipoService.updateManual(id, equipoEditado); const updateState = (prev: Equipo[]) => prev.map(e => e.id === id ? equipoActualizadoDesdeBackend : e); setData(updateState); setFilteredData(updateState); setSelectedEquipo(equipoActualizadoDesdeBackend); toast.success('Equipo actualizado.', { id: toastId }); return true; } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); return false; } }; const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', componentData: any) => { if (!selectedEquipo) return; const toastId = toast.loading(`Añadiendo ${type}...`); try { let serviceCall; switch (type) { case 'disco': serviceCall = equipoService.addDisco(selectedEquipo.id, componentData); break; case 'ram': serviceCall = equipoService.addRam(selectedEquipo.id, componentData); break; case 'usuario': serviceCall = equipoService.addUsuario(selectedEquipo.id, componentData); break; default: throw new Error('Tipo de componente no válido'); } await serviceCall; const refreshedEquipo = await equipoService.getAll().then(equipos => equipos.find(e => e.id === selectedEquipo.id)); if (!refreshedEquipo) throw new Error("No se pudo recargar el equipo"); const updateState = (prev: Equipo[]) => prev.map(e => e.id === selectedEquipo.id ? refreshedEquipo : e); setData(updateState); setFilteredData(updateState); setSelectedEquipo(refreshedEquipo); await refreshHistory(selectedEquipo.hostname); toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} añadido.`, { id: toastId }); setAddingComponent(null); } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; const columns: ColumnDef[] = [ { header: "ID", accessorKey: "id", enableHiding: true }, { header: "Nombre", accessorKey: "hostname", cell: (info: CellContext) => () }, { header: "IP", accessorKey: "ip", id: 'ip' }, { header: "MAC", accessorKey: "mac", enableHiding: true }, { header: "Motherboard", accessorKey: "motherboard" }, { header: "CPU", accessorKey: "cpu" }, { header: "RAM", accessorKey: "ram_installed", id: 'ram' }, { header: "Discos", accessorFn: (row: Equipo) => row.discos?.map(d => `${d.mediatype} ${d.size}GB`).join(" | ") || "Sin discos" }, { header: "OS", accessorKey: "os" }, { header: "Arquitectura", accessorKey: "architecture", id: 'arch' }, { header: "Usuarios y Claves", id: 'usuarios', cell: (info: CellContext) => { const { row } = info; const usuarios = row.original.usuarios || []; return (
{usuarios.map((u: UsuarioEquipoDetalle) => (
U: {u.username} - C: {u.password || 'N/A'}
))}
); } }, { header: "Sector", id: 'sector', accessorFn: (row: Equipo) => row.sector?.nombre || 'Asignar', cell: (info: CellContext) => { const { row } = info; const sector = row.original.sector; return (
{sector?.nombre || 'Asignar'}
); } } ]; const table = useReactTable({ data: filteredData, columns, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), initialState: { sorting: [ { id: 'sector', desc: false }, { id: 'hostname', desc: false } ], columnVisibility: { id: false, mac: false }, pagination: { pageSize: 15, }, }, state: { globalFilter, }, onGlobalFilterChange: setGlobalFilter, }); if (isLoading) { return (

Equipos (...)

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

); } const PaginacionControles = (
Página{' '} {table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
| Ir a pág: { const page = e.target.value ? Number(e.target.value) - 1 : 0; table.setPageIndex(page); }} style={{ width: '60px' }} className={styles.searchInput} />
); return (

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

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); 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); return ( ); })} ))}
{flexRender(h.column.columnDef.header, h.getContext())} {h.column.getIsSorted() && ({h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'})}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{PaginacionControles} {showScrollButton && ()} {modalData && setModalData(null)} onSave={handleSave} />} {modalPasswordData && setModalPasswordData(null)} onSave={handleSavePassword} />} {selectedEquipo && ( setAddingComponent(type)} isChildModalOpen={addingComponent !== null} /> )} {isAddModalOpen && setIsAddModalOpen(false)} onSave={handleCreateEquipo} />} {addingComponent === 'disco' && setAddingComponent(null)} onSave={(data: { mediatype: string, size: number }) => handleAddComponent('disco', data)} />} {addingComponent === 'ram' && setAddingComponent(null)} onSave={(data: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => handleAddComponent('ram', data)} />} {addingComponent === 'usuario' && setAddingComponent(null)} onSave={(data: { username: string }) => handleAddComponent('usuario', data)} />}
); }; export default SimpleTable;