// frontend/src/components/SimpleTable.tsx import React, { useEffect, useState } from 'react'; import { useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel, getPaginationRowModel, flexRender, type CellContext } 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 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'; 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 BASE_URL = '/api'; useEffect(() => { const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth; if (selectedEquipo || modalData || modalPasswordData) { 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]); useEffect(() => { if (!selectedEquipo) return; let isMounted = true; const checkPing = async () => { if (!selectedEquipo.ip) return; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); const response = await fetch(`${BASE_URL}/equipos/ping`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip: selectedEquipo.ip }), signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) throw new Error('Error en la respuesta'); const data = await response.json(); 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 = () => { setSelectedEquipo(null); setIsOnline(false); }; useEffect(() => { if (selectedEquipo) { fetch(`${BASE_URL}/equipos/${selectedEquipo.hostname}/historial`) .then(response => response.json()) .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([ fetch(`${BASE_URL}/equipos`).then(res => res.json()), fetch(`${BASE_URL}/sectores`).then(res => res.json()) ]).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 || !modalData.sector) return; const toastId = toast.loading('Guardando...'); try { const response = await fetch(`${BASE_URL}/equipos/${modalData.id}/sector/${modalData.sector.id}`, { method: 'PATCH' }); if (!response.ok) throw new Error('Error al asociar el sector'); const updatedData = data.map(e => e.id === modalData.id ? { ...e, sector: modalData.sector } : e); setData(updatedData); setFilteredData(updatedData); toast.success('Sector actualizado.', { id: toastId }); setModalData(null); } catch (error) { toast.error('No se pudo actualizar.', { id: toastId }); console.error(error); } }; const handleSavePassword = async (password: string) => { if (!modalPasswordData) return; const toastId = toast.loading('Actualizando...'); try { const response = await fetch(`${BASE_URL}/usuarios/${modalPasswordData.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }); if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Error al actualizar'); } const updatedUser = await response.json(); const updatedData = data.map(equipo => ({ ...equipo, usuarios: equipo.usuarios?.map(user => user.id === updatedUser.id ? { ...user, password } : user) })); setData(updatedData); setFilteredData(updatedData); toast.success(`Contraseña actualizada.`, { 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 { const response = await fetch(`${BASE_URL}/equipos/${hostname}/usuarios/${username}`, { method: 'DELETE' }); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Error al desasociar'); const updateFunc = (prev: Equipo[]) => prev.map(e => e.hostname === hostname ? { ...e, usuarios: e.usuarios.filter(u => u.username !== username) } : e); setData(updateFunc); setFilteredData(updateFunc); 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 { const response = await fetch(`${BASE_URL}/equipos/${id}`, { method: 'DELETE' }); if (response.status === 204) { setData(prev => prev.filter(e => e.id !== id)); setFilteredData(prev => prev.filter(e => e.id !== id)); toast.success('Equipo eliminado.', { id: toastId }); return true; } const errorText = await response.text(); throw new Error(errorText || 'Error desconocido'); } catch (error) { if (error instanceof Error) toast.error(`Error: ${error.message}`, { id: toastId }); return false; } }; const handleRemoveAssociation = async ( type: 'disco' | 'ram' | 'usuario', associationId: number | { equipoId: number, usuarioId: number } ) => { let url = ''; let successMessage = ''; if (type === 'disco' && typeof associationId === 'number') { url = `${BASE_URL}/equipos/asociacion/disco/${associationId}`; successMessage = 'Disco desasociado del equipo.'; } else if (type === 'ram' && typeof associationId === 'number') { url = `${BASE_URL}/equipos/asociacion/ram/${associationId}`; successMessage = 'Módulo de RAM desasociado.'; } else if (type === 'usuario' && typeof associationId === 'object') { url = `${BASE_URL}/equipos/asociacion/usuario/${associationId.equipoId}/${associationId.usuarioId}`; successMessage = 'Usuario desasociado del equipo.'; } else { return; // No hacer nada si los parámetros son incorrectos } if (!window.confirm('¿Estás seguro de que quieres eliminar esta asociación manual?')) return; const toastId = toast.loading('Eliminando asociación...'); try { const response = await fetch(url, { method: 'DELETE' }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || `Error al eliminar la asociación.`); } // Actualizar el estado local para reflejar el cambio inmediatamente 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); // Actualiza también el modal 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 response = await fetch(`${BASE_URL}/equipos/manual`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(nuevoEquipo), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Error al crear el equipo.'); } const equipoCreado = await response.json(); // Actualizamos el estado local para ver el nuevo equipo inmediatamente setData(prev => [...prev, equipoCreado]); setFilteredData(prev => [...prev, equipoCreado]); toast.success(`Equipo '${equipoCreado.hostname}' creado.`, { id: toastId }); setIsAddModalOpen(false); // Cerramos el modal } catch (error) { if (error instanceof Error) { toast.error(error.message, { id: toastId }); } } }; const handleEditEquipo = async (id: number, equipoEditado: Omit) => { const toastId = toast.loading('Guardando cambios...'); try { const response = await fetch(`${BASE_URL}/equipos/manual/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(equipoEditado), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Error al actualizar el equipo.'); } // Actualizar el estado local para reflejar los cambios const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => { if (equipo.id === id) { return { ...equipo, ...equipoEditado }; } return equipo; }); setData(updateState); setFilteredData(updateState); setSelectedEquipo(prev => prev ? updateState([prev])[0] : null); toast.success('Equipo actualizado.', { id: toastId }); return true; // Indica que el guardado fue exitoso } catch (error) { if (error instanceof Error) { toast.error(error.message, { id: toastId }); } return false; // Indica que el guardado falló } }; const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', data: any) => { if (!selectedEquipo) return; const toastId = toast.loading(`Añadiendo ${type}...`); try { const response = await fetch(`${BASE_URL}/equipos/manual/${selectedEquipo.id}/${type}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || `Error al añadir ${type}.`); } // Refrescar los datos del equipo para ver el cambio const refreshedEquipo = await (await fetch(`${BASE_URL}/equipos/${selectedEquipo.hostname}`)).json(); const updateState = (prev: Equipo[]) => prev.map(e => e.id === selectedEquipo.id ? refreshedEquipo : e); setData(updateState); setFilteredData(updateState); setSelectedEquipo(refreshedEquipo); toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} añadido.`, { id: toastId }); setAddingComponent(null); // Cerrar modal } catch (error) { if (error instanceof Error) toast.error(error.message, { id: toastId }); } }; const columns = [ { header: "ID", accessorKey: "id", enableHiding: true }, { header: "Nombre", accessorKey: "hostname", cell: ({ row }: CellContext) => () }, { header: "IP", accessorKey: "ip" }, { header: "MAC", accessorKey: "mac", enableHiding: true }, { header: "Motherboard", accessorKey: "motherboard" }, { header: "CPU", accessorKey: "cpu" }, { header: "RAM", accessorKey: "ram_installed" }, { 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" }, { header: "Usuarios y Claves", cell: ({ row }: CellContext) => { 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: ({ row }: CellContext) => { 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, // Mostrar 15 filas por página por defecto }, }, state: { globalFilter, }, onGlobalFilterChange: setGlobalFilter, }); if (isLoading) { return (

Cargando Equipos...

); } 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:
{/* --- 2. Renderizar los controles ANTES de la tabla --- */} {PaginacionControles}
{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())}
{/* --- 3. Renderizar los controles DESPUÉS de la tabla --- */} {PaginacionControles} {showScrollButton && ()} {modalData && ( setModalData(null)} onSave={handleSave} /> )} {modalPasswordData && ( setModalPasswordData(null)} onSave={handleSavePassword} /> )} {selectedEquipo && ( setAddingComponent(type)} /> )} {isAddModalOpen && ( setIsAddModalOpen(false)} onSave={handleCreateEquipo} /> )} {addingComponent === 'disco' && setAddingComponent(null)} onSave={(data) => handleAddComponent('disco', data)} />} {addingComponent === 'ram' && setAddingComponent(null)} onSave={(data) => handleAddComponent('ram', data)} />} {addingComponent === 'usuario' && setAddingComponent(null)} onSave={(data) => handleAddComponent('usuario', data)} />}
); }; export default SimpleTable;