Files
Inventario-IT/frontend/src/components/SimpleTable.tsx
2025-10-09 11:32:56 -03:00

555 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 { equipoService, sectorService, usuarioService } from '../services/apiService';
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<Equipo[]>([]);
const [filteredData, setFilteredData] = useState<Equipo[]>([]);
const [globalFilter, setGlobalFilter] = useState('');
const [selectedSector, setSelectedSector] = useState('Todos');
const [modalData, setModalData] = useState<Equipo | null>(null);
const [sectores, setSectores] = useState<Sector[]>([]);
const [modalPasswordData, setModalPasswordData] = useState<Usuario | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [selectedEquipo, setSelectedEquipo] = useState<Equipo | null>(null);
const [historial, setHistorial] = useState<any[]>([]);
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<HTMLSelectElement>) => {
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<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
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 });
}
};
// --- DEFINICIÓN DE COLUMNAS CORREGIDA ---
const columns: ColumnDef<Equipo>[] = [
{ header: "ID", accessorKey: "id", enableHiding: true },
{
header: "Nombre", accessorKey: "hostname",
cell: (info: CellContext<Equipo, any>) => (<button onClick={() => setSelectedEquipo(info.row.original)} className={styles.hostnameButton}>{info.row.original.hostname}</button>)
},
{ 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: (info: CellContext<Equipo, any>) => {
const { row } = info;
const usuarios = row.original.usuarios || [];
return (
<div className={styles.userList}>
{usuarios.map((u: UsuarioEquipoDetalle) => (
<div key={u.id} className={styles.userItem}>
<span className={styles.userInfo}>
U: {u.username} - C: {u.password || 'N/A'}
</span>
<div className={styles.userActions}>
<button
onClick={() => setModalPasswordData(u)}
className={styles.tableButton}
data-tooltip-id={`edit-${u.id}`}
>
<Tooltip id={`edit-${u.id}`} className={styles.tooltip} place="top">Cambiar Clave</Tooltip>
</button>
<button
onClick={() => handleRemoveUser(row.original.hostname, u.username)}
className={styles.deleteUserButton}
data-tooltip-id={`remove-${u.id}`}
>
🗑
<Tooltip id={`remove-${u.id}`} className={styles.tooltip} place="top">Eliminar Usuario</Tooltip>
</button>
</div>
</div>
))}
</div>
);
}
},
{
header: "Sector", id: 'sector', accessorFn: (row: Equipo) => row.sector?.nombre || 'Asignar',
cell: (info: CellContext<Equipo, any>) => {
const { row } = info;
const sector = row.original.sector;
return (
<div className={styles.sectorContainer}>
<span className={`${styles.sectorName} ${sector ? styles.sectorNameAssigned : styles.sectorNameUnassigned}`}>{sector?.nombre || 'Asignar'}</span>
<button onClick={() => setModalData(row.original)} className={styles.tableButton} data-tooltip-id={`editSector-${row.original.id}`}><Tooltip id={`editSector-${row.original.id}`} className={styles.tooltip} place="top">Cambiar Sector</Tooltip></button>
</div>
);
}
}
];
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 (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Cargando Equipos...</h2>
</div>
);
}
const PaginacionControles = (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 0' }}>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
{'<<'}
</button>
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
{'<'}
</button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className={styles.tableButton}>
{'>'}
</button>
<button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} className={styles.tableButton}>
{'>>'}
</button>
</div>
<span>
Página{' '}
<strong>
{table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
</strong>
</span>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<span>| Ir a pág:</span>
<input
type="number"
defaultValue={table.getState().pagination.pageIndex + 1}
onChange={e => {
const page = e.target.value ? Number(e.target.value) - 1 : 0;
table.setPageIndex(page);
}}
style={{ width: '60px' }}
className={styles.searchInput}
/>
<select
value={table.getState().pagination.pageSize}
onChange={e => {
table.setPageSize(Number(e.target.value));
}}
className={styles.sectorSelect}
>
{[10, 15, 25, 50, 100].map(pageSize => (
<option key={pageSize} value={pageSize}>
Mostrar {pageSize}
</option>
))}
</select>
</div>
</div>
);
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>Equipos ({table.getFilteredRowModel().rows.length})</h2>
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => setIsAddModalOpen(true)}
>
+ Añadir Equipo
</button>
</div>
<div className={styles.controlsContainer}>
<input type="text" placeholder="Buscar en todos los campos..." value={globalFilter} onChange={(e) => setGlobalFilter(e.target.value)} className={styles.searchInput} style={{ width: '300px' }} />
<b>Sector:</b>
<select value={selectedSector} onChange={handleSectorChange} className={styles.sectorSelect}>
<option value="Todos">-Todos-</option>
<option value="Asignar">-Asignar-</option>
{sectores.map(s => (<option key={s.id} value={s.nombre}>{s.nombre}</option>))}
</select>
</div>
<div><p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La Tabla permite ordenar por multiple columnas manteniendo shift al hacer click en la cabecera. **</p></div>
{PaginacionControles}
<div style={{ overflowX: 'auto', maxHeight: '70vh', border: '1px solid #dee2e6', borderRadius: '8px' }}>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(h => (
<th key={h.id} className={styles.th} onClick={h.column.getToggleSortingHandler()}>
{flexRender(h.column.columnDef.header, h.getContext())}
{h.column.getIsSorted() && (<span className={styles.sortIndicator}>{h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}</span>)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => (
<td key={cell.id} className={styles.td}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{PaginacionControles}
{showScrollButton && (<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className={styles.scrollToTop} title="Volver arriba"></button>)}
{modalData && <ModalEditarSector modalData={modalData} setModalData={setModalData} sectores={sectores} onClose={() => setModalData(null)} onSave={handleSave} />}
{modalPasswordData && <ModalCambiarClave usuario={modalPasswordData} onClose={() => setModalPasswordData(null)} onSave={handleSavePassword} />}
{selectedEquipo && (
<ModalDetallesEquipo
equipo={selectedEquipo}
isOnline={isOnline}
historial={historial}
onClose={handleCloseModal}
onDelete={handleDelete}
onRemoveAssociation={handleRemoveAssociation}
onEdit={handleEditEquipo}
sectores={sectores}
onAddComponent={type => setAddingComponent(type)}
isChildModalOpen={addingComponent !== null}
/>
)}
{isAddModalOpen && <ModalAnadirEquipo sectores={sectores} onClose={() => setIsAddModalOpen(false)} onSave={handleCreateEquipo} />}
{addingComponent === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data: { mediatype: string, size: number }) => handleAddComponent('disco', data)} />}
{addingComponent === 'ram' && <ModalAnadirRam onClose={() => setAddingComponent(null)} onSave={(data: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => handleAddComponent('ram', data)} />}
{addingComponent === 'usuario' && <ModalAnadirUsuario onClose={() => setAddingComponent(null)} onSave={(data: { username: string }) => handleAddComponent('usuario', data)} />}
</div>
);
};
export default SimpleTable;