718 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			718 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import React, { useEffect, useRef, useState, type CSSProperties } from 'react'; | |||
|  | import { | |||
|  |   useReactTable, | |||
|  |   getCoreRowModel, | |||
|  |   getFilteredRowModel, | |||
|  |   getSortedRowModel, | |||
|  |   flexRender, | |||
|  |   type CellContext | |||
|  | } from '@tanstack/react-table'; | |||
|  | import { Tooltip } from 'react-tooltip'; | |||
|  | import type { Equipo, Sector, Usuario, HistorialEquipo } from '../types/interfaces'; | |||
|  | 
 | |||
|  | 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 [newPassword, setNewPassword] = useState(''); | |||
|  |   const [showScrollButton, setShowScrollButton] = useState(false); | |||
|  |   const [selectedEquipo, setSelectedEquipo] = useState<Equipo | null>(null); | |||
|  |   const [historial, setHistorial] = useState<HistorialEquipo[]>([]); | |||
|  |   const [isOnline, setIsOnline] = useState(false); | |||
|  |   const passwordInputRef = useRef<HTMLInputElement>(null); | |||
|  |   const BASE_URL = 'http://localhost:5198/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(() => { | |||
|  |     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?.ip]); | |||
|  | 
 | |||
|  |   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(() => { | |||
|  |     if (modalPasswordData && passwordInputRef.current) { | |||
|  |       passwordInputRef.current.focus(); | |||
|  |     } | |||
|  |   }, [modalPasswordData]); | |||
|  | 
 | |||
|  |   useEffect(() => { | |||
|  |     fetch(`${BASE_URL}/equipos`) | |||
|  |       .then(response => response.json()) | |||
|  |       .then((fetchedData: Equipo[]) => { | |||
|  |         setData(fetchedData); | |||
|  |         setFilteredData(fetchedData); | |||
|  |       }); | |||
|  | 
 | |||
|  |     fetch(`${BASE_URL}/sectores`) | |||
|  |       .then(response => response.json()) | |||
|  |       .then((fetchedSectores: Sector[]) => { | |||
|  |         const sectoresOrdenados = [...fetchedSectores].sort((a, b) => | |||
|  |           a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }) | |||
|  |         ); | |||
|  |         setSectores(sectoresOrdenados); | |||
|  |       }); | |||
|  |   }, []); | |||
|  | 
 | |||
|  |   const handleSectorChange = (e: React.ChangeEvent<HTMLSelectElement>) => { | |||
|  |     const selectedValue = e.target.value; | |||
|  |     setSelectedSector(selectedValue); | |||
|  | 
 | |||
|  |     if (selectedValue === 'Todos') { | |||
|  |       setFilteredData(data); | |||
|  |     } else if (selectedValue === 'Asignar') { | |||
|  |       const filtered = data.filter(item => !item.sector); | |||
|  |       setFilteredData(filtered); | |||
|  |     } else { | |||
|  |       const filtered = data.filter(item => item.sector?.nombre === selectedValue); | |||
|  |       setFilteredData(filtered); | |||
|  |     } | |||
|  |   }; | |||
|  | 
 | |||
|  |   const handleSave = async () => { | |||
|  |     if (!modalData || !modalData.sector) return; | |||
|  | 
 | |||
|  |     try { | |||
|  |       const response = await fetch(`${BASE_URL}/equipos/${modalData.id}/sector/${modalData.sector.id}`, { | |||
|  |         method: 'PATCH', | |||
|  |         headers: { 'Content-Type': 'application/json' } | |||
|  |       }); | |||
|  | 
 | |||
|  |       if (!response.ok) throw new Error('Error al asociar el sector'); | |||
|  | 
 | |||
|  |       // Actualizamos el dato localmente para reflejar el cambio inmediatamente
 | |||
|  |       const updatedData = data.map(e => e.id === modalData.id ? { ...e, sector: modalData.sector } : e); | |||
|  |       setData(updatedData); | |||
|  |       setFilteredData(updatedData); | |||
|  | 
 | |||
|  |       setModalData(null); | |||
|  |     } catch (error) { | |||
|  |       console.error(error); | |||
|  |     } | |||
|  |   }; | |||
|  | 
 | |||
|  |   const handleSavePassword = async () => { | |||
|  |     if (!modalPasswordData) return; | |||
|  |     try { | |||
|  |       const response = await fetch(`${BASE_URL}/usuarios/${modalPasswordData.id}`, { | |||
|  |         method: 'PUT', | |||
|  |         headers: { 'Content-Type': 'application/json' }, | |||
|  |         body: JSON.stringify({ password: newPassword }), | |||
|  |       }); | |||
|  | 
 | |||
|  |       if (!response.ok) { | |||
|  |         const errorData = await response.json(); | |||
|  |         throw new Error(errorData.error || 'Error al actualizar la contraseña'); | |||
|  |       } | |||
|  | 
 | |||
|  |       const updatedUser = await response.json(); | |||
|  | 
 | |||
|  |       const updatedData = data.map(equipo => ({ | |||
|  |         ...equipo, | |||
|  |         usuarios: equipo.usuarios?.map(user => | |||
|  |           user.id === updatedUser.id ? { ...user, password: newPassword } : user | |||
|  |         ) | |||
|  |       })); | |||
|  | 
 | |||
|  |       setData(updatedData); | |||
|  |       setFilteredData(updatedData); | |||
|  |       setModalPasswordData(null); | |||
|  |       setNewPassword(''); | |||
|  | 
 | |||
|  |     } catch (error) { | |||
|  |       if (error instanceof Error) { | |||
|  |         console.error('Error:', error); | |||
|  |         alert(error.message); | |||
|  |       } | |||
|  |     } | |||
|  |   }; | |||
|  | 
 | |||
|  |   const handleRemoveUser = async (hostname: string, username: string) => { | |||
|  |     if (!window.confirm(`¿Quitar a ${username} de este equipo?`)) return; | |||
|  | 
 | |||
|  |     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 usuario'); | |||
|  | 
 | |||
|  |       const updateFunc = (prevData: Equipo[]) => prevData.map(equipo => { | |||
|  |         if (equipo.hostname === hostname) { | |||
|  |           return { | |||
|  |             ...equipo, | |||
|  |             usuarios: equipo.usuarios.filter(u => u.username !== username), | |||
|  |           }; | |||
|  |         } | |||
|  |         return equipo; | |||
|  |       }); | |||
|  | 
 | |||
|  |       setData(updateFunc); | |||
|  |       setFilteredData(updateFunc); | |||
|  | 
 | |||
|  |     } catch (error) { | |||
|  |       if (error instanceof Error) { | |||
|  |         console.error('Error:', error); | |||
|  |         alert(error.message); | |||
|  |       } | |||
|  |     } | |||
|  |   }; | |||
|  | 
 | |||
|  |   const handleDelete = async (id: number) => { | |||
|  |     if (!window.confirm('¿Estás seguro de eliminar este equipo y todas sus relaciones?')) return false; | |||
|  | 
 | |||
|  |     try { | |||
|  |       const response = await fetch(`${BASE_URL}/equipos/${id}`, { | |||
|  |         method: 'DELETE' | |||
|  |       }); | |||
|  | 
 | |||
|  |       if (response.status === 204) { | |||
|  |         setData(prev => prev.filter(equipo => equipo.id !== id)); | |||
|  |         setFilteredData(prev => prev.filter(equipo => equipo.id !== id)); | |||
|  |         return true; | |||
|  |       } | |||
|  | 
 | |||
|  |       const errorText = await response.text(); | |||
|  |       throw new Error(errorText); | |||
|  | 
 | |||
|  |     } catch (error) { | |||
|  |       if (error instanceof Error) { | |||
|  |         console.error('Error eliminando equipo:', error); | |||
|  |         alert(`Error al eliminar el equipo: ${error.message}`); | |||
|  |       } | |||
|  |       return false; | |||
|  |     } | |||
|  |   }; | |||
|  | 
 | |||
|  |   const columns = [ | |||
|  |     { header: "ID", accessorKey: "id", enableHiding: true }, | |||
|  |     { | |||
|  |       header: "Nombre", | |||
|  |       accessorKey: "hostname", | |||
|  |       cell: ({ row }: CellContext<Equipo, any>) => ( | |||
|  |         <button | |||
|  |           onClick={() => setSelectedEquipo(row.original)} | |||
|  |           style={{ | |||
|  |             background: 'none', | |||
|  |             border: 'none', | |||
|  |             color: '#007bff', | |||
|  |             cursor: 'pointer', | |||
|  |             textDecoration: 'underline', | |||
|  |             padding: 0, | |||
|  |             fontSize: 'inherit' | |||
|  |           }} | |||
|  |         > | |||
|  |           {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?.length > 0 | |||
|  |         ? 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<Equipo, any>) => { | |||
|  |         const usuarios = row.original.usuarios || []; | |||
|  |         return ( | |||
|  |           <div style={{ minWidth: "240px" }}> | |||
|  |             {usuarios.map((u: Usuario) => ( | |||
|  |               <div | |||
|  |                 key={u.id} | |||
|  |                 style={{ | |||
|  |                   display: "flex", | |||
|  |                   alignItems: "center", | |||
|  |                   justifyContent: "space-between", | |||
|  |                   margin: "4px 0", | |||
|  |                   padding: "6px", | |||
|  |                   backgroundColor: '#f8f9fa', | |||
|  |                   borderRadius: '4px', | |||
|  |                   position: 'relative' | |||
|  |                 }} | |||
|  |               > | |||
|  |                 <span style={{ color: '#495057' }}> | |||
|  |                   U: {u.username} - C: {u.password || 'N/A'} | |||
|  |                 </span> | |||
|  | 
 | |||
|  |                 <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}> | |||
|  |                   <button | |||
|  |                     onClick={() => setModalPasswordData(u)} | |||
|  |                     style={tableButtonStyle} | |||
|  |                     data-tooltip-id={`edit-${u.id}`} | |||
|  |                   > | |||
|  |                     ✏️ | |||
|  |                     <Tooltip id={`edit-${u.id}`} place="top"> | |||
|  |                       Cambiar contraseña | |||
|  |                     </Tooltip> | |||
|  |                   </button> | |||
|  | 
 | |||
|  |                   <button | |||
|  |                     onClick={() => handleRemoveUser(row.original.hostname, u.username)} | |||
|  |                     style={deleteUserButtonStyle} | |||
|  |                     data-tooltip-id={`remove-${u.id}`} | |||
|  |                   > | |||
|  |                     × | |||
|  |                     <Tooltip id={`remove-${u.id}`} place="top"> | |||
|  |                       Quitar del equipo | |||
|  |                     </Tooltip> | |||
|  |                   </button> | |||
|  |                 </div> | |||
|  |               </div> | |||
|  |             ))} | |||
|  |           </div> | |||
|  |         ); | |||
|  |       } | |||
|  |     }, | |||
|  |     { | |||
|  |       header: "Sector", | |||
|  |       id: 'sector', | |||
|  |       accessorFn: (row: Equipo) => row.sector?.nombre || 'Asignar', | |||
|  |       cell: ({ row }: CellContext<Equipo, any>) => { | |||
|  |         const sector = row.original.sector; | |||
|  | 
 | |||
|  |         return ( | |||
|  |           <div style={{ | |||
|  |             display: 'flex', | |||
|  |             justifyContent: 'space-between', | |||
|  |             alignItems: 'center', | |||
|  |             width: '100%', | |||
|  |             gap: '0.5rem' | |||
|  |           }}> | |||
|  |             <span style={{ | |||
|  |               color: sector ? '#212529' : '#6c757d', | |||
|  |               fontStyle: sector ? 'normal' : 'italic', | |||
|  |               flex: 1, | |||
|  |               overflow: 'hidden', | |||
|  |               textOverflow: 'ellipsis', | |||
|  |               whiteSpace: 'nowrap' | |||
|  |             }}> | |||
|  |               {sector?.nombre || 'Asignar'} | |||
|  |             </span> | |||
|  | 
 | |||
|  |             <button | |||
|  |               onClick={() => setModalData(row.original)} | |||
|  |               style={tableButtonStyle} | |||
|  |               data-tooltip-id={`editSector-${row.id}`} | |||
|  |             > | |||
|  |               ✏️ | |||
|  |               <Tooltip id={`editSector-${row.id}`} place="top"> | |||
|  |                 Cambiar sector | |||
|  |               </Tooltip> | |||
|  |             </button> | |||
|  |           </div> | |||
|  |         ); | |||
|  |       } | |||
|  |     } | |||
|  |   ]; | |||
|  | 
 | |||
|  |   const table = useReactTable({ | |||
|  |     data: filteredData, | |||
|  |     columns, | |||
|  |     getCoreRowModel: getCoreRowModel(), | |||
|  |     getFilteredRowModel: getFilteredRowModel(), | |||
|  |     getSortedRowModel: getSortedRowModel(), | |||
|  |     initialState: { | |||
|  |       sorting: [ | |||
|  |         { id: 'sector', desc: false }, | |||
|  |         { id: 'hostname', desc: false } | |||
|  |       ], | |||
|  |       columnVisibility: { id: false, mac: false } | |||
|  |     }, | |||
|  |     state: { | |||
|  |       globalFilter, | |||
|  |     }, | |||
|  |     onGlobalFilterChange: setGlobalFilter, | |||
|  |   }); | |||
|  | 
 | |||
|  |   return ( | |||
|  |     <div> | |||
|  |       <h2>Equipos</h2> | |||
|  |       <div style={{ display: 'flex', gap: '20px', marginBottom: '10px' }}> | |||
|  |         <input | |||
|  |           type="text" | |||
|  |           placeholder="Buscar..." | |||
|  |           value={globalFilter} | |||
|  |           onChange={(e) => setGlobalFilter(e.target.value)} /> | |||
|  |         <b>Selección de sector:</b> | |||
|  |         <select value={selectedSector} onChange={handleSectorChange}> | |||
|  |           <option value="Todos">-Todos-</option> | |||
|  |           <option value="Asignar">-Asignar-</option> | |||
|  |           {sectores.map(sector => ( | |||
|  |             <option key={sector.id} value={sector.nombre}>{sector.nombre}</option> | |||
|  |           ))} | |||
|  |         </select> | |||
|  |       </div> | |||
|  |       <hr /> | |||
|  |       <table style={tableStyle}> | |||
|  |         <thead> | |||
|  |           {table.getHeaderGroups().map(headerGroup => ( | |||
|  |             <tr key={headerGroup.id}> | |||
|  |               {headerGroup.headers.map(header => ( | |||
|  |                 <th | |||
|  |                   key={header.id} | |||
|  |                   style={headerStyle} | |||
|  |                   onClick={header.column.getToggleSortingHandler()}> | |||
|  |                   {flexRender(header.column.columnDef.header, header.getContext())} | |||
|  |                   {header.column.getIsSorted() && ( | |||
|  |                     <span style={{ | |||
|  |                       marginLeft: '0.5rem', | |||
|  |                       fontSize: '1.2em', | |||
|  |                       display: 'inline-block', | |||
|  |                       transform: 'translateY(-1px)', | |||
|  |                       color: '#007bff', | |||
|  |                       minWidth: '20px' | |||
|  |                     }}> | |||
|  |                       {header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'} | |||
|  |                     </span> | |||
|  |                   )} | |||
|  |                 </th> | |||
|  |               ))} | |||
|  |             </tr> | |||
|  |           ))} | |||
|  |         </thead> | |||
|  |         <tbody> | |||
|  |           {table.getRowModel().rows.map(row => ( | |||
|  |             <tr key={row.id} style={rowStyle}> | |||
|  |               {row.getVisibleCells().map(cell => ( | |||
|  |                 <td key={cell.id} style={cellStyle}> | |||
|  |                   {flexRender(cell.column.columnDef.cell, cell.getContext())} | |||
|  |                 </td> | |||
|  |               ))} | |||
|  |             </tr> | |||
|  |           ))} | |||
|  |         </tbody> | |||
|  |       </table> | |||
|  | 
 | |||
|  |       {showScrollButton && ( | |||
|  |         <button | |||
|  |           onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} | |||
|  |           style={scrollToTopStyle} | |||
|  |           title="Volver arriba" | |||
|  |         > | |||
|  |           ↑ | |||
|  |         </button> | |||
|  |       )} | |||
|  | 
 | |||
|  |       {modalData && ( | |||
|  |         <div style={modalStyle}> | |||
|  |           <h3 style={{ margin: '0 0 1.5rem', color: '#2d3436' }}>Editar Sector</h3> | |||
|  |           <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}> | |||
|  |             Sector: | |||
|  |             <select | |||
|  |               style={inputStyle} | |||
|  |               value={modalData.sector?.id || ""} | |||
|  |               onChange={(e) => { | |||
|  |                 const selectedId = e.target.value; | |||
|  |                 const nuevoSector = selectedId === "" ? undefined : sectores.find(s => s.id === Number(selectedId)); | |||
|  |                 setModalData({ ...modalData, sector: nuevoSector }); | |||
|  |               }} | |||
|  |             > | |||
|  |               <option value="">Asignar</option> | |||
|  |               {sectores.map(sector => ( | |||
|  |                 <option key={sector.id} value={sector.id}>{sector.nombre}</option> | |||
|  |               ))} | |||
|  |             </select> | |||
|  |           </label> | |||
|  | 
 | |||
|  |           <div style={{ display: 'flex', gap: '10px', marginTop: '1.5rem' }}> | |||
|  |             <button | |||
|  |               style={{ ...buttonStyle.base, ...(modalData.sector ? buttonStyle.primary : buttonStyle.disabled) }} | |||
|  |               onClick={handleSave} | |||
|  |               disabled={!modalData.sector} | |||
|  |             > | |||
|  |               Guardar cambios | |||
|  |             </button> | |||
|  |             <button | |||
|  |               style={{ ...buttonStyle.base, ...buttonStyle.secondary }} | |||
|  |               onClick={() => setModalData(null)} | |||
|  |             > | |||
|  |               Cancelar | |||
|  |             </button> | |||
|  |           </div> | |||
|  |         </div> | |||
|  |       )} | |||
|  |       {modalPasswordData && ( | |||
|  |         <div style={modalStyle}> | |||
|  |           <h3 style={{ margin: '0 0 1.5rem', color: '#2d3436' }}> | |||
|  |             Cambiar contraseña para {modalPasswordData.username} | |||
|  |           </h3> | |||
|  |           <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}> | |||
|  |             Nueva contraseña: | |||
|  |             <input | |||
|  |               type="text" | |||
|  |               value={newPassword} | |||
|  |               onChange={(e) => setNewPassword(e.target.value)} | |||
|  |               style={inputStyle} | |||
|  |               placeholder="Ingrese la nueva contraseña" | |||
|  |               ref={passwordInputRef} | |||
|  |             /> | |||
|  |           </label> | |||
|  |           <div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}> | |||
|  |             <button | |||
|  |               style={{ ...buttonStyle.base, ...buttonStyle.primary }} | |||
|  |               onClick={handleSavePassword} | |||
|  |             > | |||
|  |               Guardar | |||
|  |             </button> | |||
|  |             <button | |||
|  |               style={{ ...buttonStyle.base, ...buttonStyle.secondary }} | |||
|  |               onClick={() => { setModalPasswordData(null); setNewPassword(''); }} | |||
|  |             > | |||
|  |               Cancelar | |||
|  |             </button> | |||
|  |           </div> | |||
|  |         </div> | |||
|  |       )} | |||
|  |       {selectedEquipo && ( | |||
|  |         <div style={modalGrandeStyle}> | |||
|  |           <button onClick={handleCloseModal} style={closeButtonStyle}> | |||
|  |             × | |||
|  |           </button> | |||
|  |           <div style={{ maxWidth: '95vw', marginBottom: '10px', width: '100%', padding: '10px' }}> | |||
|  |             <div style={modalHeaderStyle}> | |||
|  |               <h2>Datos del equipo '{selectedEquipo.hostname}'</h2> | |||
|  |             </div> | |||
|  |             <div style={seccionStyle}> | |||
|  |               <h3 style={tituloSeccionStyle}>Datos actuales</h3> | |||
|  |               <div style={gridDatosStyle}> | |||
|  |                 {Object.entries(selectedEquipo).map(([key, value]) => { | |||
|  |                   // Omitimos claves que mostraremos de forma personalizada o no son relevantes aquí
 | |||
|  |                   if (['id', 'usuarios', 'sector', 'discos', 'historial', 'equiposDiscos', 'memoriasRam', 'sector_id'].includes(key)) return null; | |||
|  | 
 | |||
|  |                   const formattedValue = (key === 'created_at' || key === 'updated_at') | |||
|  |                     ? new Date(value as string).toLocaleString('es-ES') | |||
|  |                     : (value as any)?.toString() || 'N/A'; | |||
|  | 
 | |||
|  |                   return ( | |||
|  |                     <div key={key} style={datoItemStyle}> | |||
|  |                       <strong style={labelStyle}>{key.replace(/_/g, ' ')}:</strong> | |||
|  |                       <span style={valorStyle}>{formattedValue}</span> | |||
|  |                     </div> | |||
|  |                   ); | |||
|  |                 })} | |||
|  | 
 | |||
|  |                 {/* --- CORRECCIÓN 1: Mostrar nombre del sector --- */} | |||
|  |                 <div style={datoItemStyle}> | |||
|  |                   <strong style={labelStyle}>Sector:</strong> | |||
|  |                   <span style={valorStyle}> | |||
|  |                     {selectedEquipo.sector?.nombre || 'No asignado'} | |||
|  |                   </span> | |||
|  |                 </div> | |||
|  | 
 | |||
|  |                 <div style={datoItemStyle}> | |||
|  |                   <strong style={labelStyle}>Modulos RAM:</strong> | |||
|  |                   <span style={valorStyle}> | |||
|  |                     {selectedEquipo.memoriasRam?.map(m => `Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`).join(' | ') || 'N/A'} | |||
|  |                   </span> | |||
|  |                 </div> | |||
|  |                 <div style={datoItemStyle}> | |||
|  |                   <strong style={labelStyle}>Discos:</strong> | |||
|  |                   <span style={valorStyle}> | |||
|  |                     {selectedEquipo.discos?.map(d => `${d.mediatype} ${d.size}GB`).join(', ') || 'N/A'} | |||
|  |                   </span> | |||
|  |                 </div> | |||
|  |                 <div style={datoItemStyle}> | |||
|  |                   <strong style={labelStyle}>Usuarios:</strong> | |||
|  |                   <span style={valorStyle}> | |||
|  |                     {selectedEquipo.usuarios?.map(u => u.username).join(', ') || 'N/A'} | |||
|  |                   </span> | |||
|  |                 </div> | |||
|  |                 <div style={datoItemStyle}> | |||
|  |                   <strong style={labelStyle}>Estado:</strong> | |||
|  |                   <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> | |||
|  |                     <div style={{ width: '12px', height: '12px', borderRadius: '50%', backgroundColor: isOnline ? '#28a745' : '#dc3545', boxShadow: `0 0 8px ${isOnline ? '#28a74580' : '#dc354580'}` }} /> | |||
|  |                     <span style={valorStyle}>{isOnline ? 'En línea' : 'Sin conexión'}</span> | |||
|  |                   </div> | |||
|  |                 </div> | |||
|  |                 <div style={datoItemStyle}> | |||
|  |                   <strong style={labelStyle}>Wake On Lan:</strong> | |||
|  |                   <button onClick={async () => { | |||
|  |                     try { | |||
|  |                       await fetch(`${BASE_URL}/equipos/wake-on-lan`, { | |||
|  |                         method: 'POST', | |||
|  |                         headers: { 'Content-Type': 'application/json' }, | |||
|  |                         body: JSON.stringify({ mac: selectedEquipo.mac, ip: selectedEquipo.ip }) | |||
|  |                       }); | |||
|  |                       alert('Solicitud de encendido enviada!'); | |||
|  |                     } catch (error) { | |||
|  |                       console.error('Error al enviar la solicitud:', error); | |||
|  |                       alert('Error al enviar la solicitud'); | |||
|  |                     } | |||
|  |                   }} style={powerButtonStyle} data-tooltip-id="modal-power-tooltip"> | |||
|  |                     <img src="./img/power.png" alt="Encender equipo" style={powerIconStyle} /> | |||
|  |                     <Tooltip id="modal-power-tooltip" place="top">Encender equipo (WOL)</Tooltip> | |||
|  |                   </button> | |||
|  |                 </div> | |||
|  |                 <div style={datoItemStyle}> | |||
|  |                   <strong style={labelStyle}>Eliminar Equipo:</strong> | |||
|  |                   <button onClick={async () => { | |||
|  |                     const success = await handleDelete(selectedEquipo.id); | |||
|  |                     if (success) handleCloseModal(); | |||
|  |                   }} style={deleteButtonStyle} data-tooltip-id="modal-delete-tooltip"> | |||
|  |                     × | |||
|  |                     <Tooltip id="modal-delete-tooltip" place="top">Eliminar equipo permanentemente</Tooltip> | |||
|  |                   </button> | |||
|  |                 </div> | |||
|  |               </div> | |||
|  |             </div> | |||
|  |             <div style={seccionStyle}> | |||
|  |               <h3 style={tituloSeccionStyle}>Historial de cambios</h3> | |||
|  |               <table style={historialTableStyle}> | |||
|  |                 <thead> | |||
|  |                   <tr> | |||
|  |                     <th style={historialHeaderStyle}>Fecha</th> | |||
|  |                     <th style={historialHeaderStyle}>Campo modificado</th> | |||
|  |                     <th style={historialHeaderStyle}>Valor anterior</th> | |||
|  |                     <th style={historialHeaderStyle}>Valor nuevo</th> | |||
|  |                   </tr> | |||
|  |                 </thead> | |||
|  |                 <tbody> | |||
|  |                   {historial | |||
|  |                     .sort((a, b) => new Date(b.fecha_cambio).getTime() - new Date(a.fecha_cambio).getTime()) | |||
|  |                     .map((cambio, index) => ( | |||
|  |                       <tr key={index} style={index % 2 === 0 ? historialRowStyle : { ...historialRowStyle, backgroundColor: '#f8f9fa' }}> | |||
|  |                         <td style={historialCellStyle}> | |||
|  |                           {new Date(cambio.fecha_cambio).toLocaleString('es-ES', { | |||
|  |                             year: 'numeric', month: '2-digit', day: '2-digit', | |||
|  |                             hour: '2-digit', minute: '2-digit' | |||
|  |                           })} | |||
|  |                         </td> | |||
|  |                         <td style={historialCellStyle}>{cambio.campo_modificado}</td> | |||
|  |                         <td style={historialCellStyle}>{cambio.valor_anterior}</td> | |||
|  |                         <td style={historialCellStyle}>{cambio.valor_nuevo}</td> | |||
|  |                       </tr> | |||
|  |                     ))} | |||
|  |                 </tbody> | |||
|  |               </table> | |||
|  |             </div> | |||
|  |           </div> | |||
|  |         </div> | |||
|  |       )} | |||
|  |     </div> | |||
|  |   ); | |||
|  | }; | |||
|  | 
 | |||
|  | // --- ESTILOS ---
 | |||
|  | const modalStyle: CSSProperties = { | |||
|  |   position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', backgroundColor: '#ffffff', | |||
|  |   borderRadius: '12px', padding: '2rem', boxShadow: '0px 8px 30px rgba(0, 0, 0, 0.12)', zIndex: 1000, | |||
|  |   minWidth: '400px', maxWidth: '90%', border: '1px solid #e0e0e0', fontFamily: 'Segoe UI, sans-serif' | |||
|  | }; | |||
|  | const buttonStyle = { | |||
|  |   base: { padding: '8px 20px', borderRadius: '6px', border: 'none', cursor: 'pointer', transition: 'all 0.2s ease', fontWeight: '500', fontSize: '14px' } as CSSProperties, | |||
|  |   primary: { backgroundColor: '#007bff', color: 'white' } as CSSProperties, | |||
|  |   secondary: { backgroundColor: '#6c757d', color: 'white' } as CSSProperties, | |||
|  |   disabled: { backgroundColor: '#e9ecef', color: '#6c757d', cursor: 'not-allowed' } as CSSProperties | |||
|  | }; | |||
|  | const inputStyle: CSSProperties = { padding: '10px', borderRadius: '6px', border: '1px solid #ced4da', width: '100%', boxSizing: 'border-box', margin: '8px 0' }; | |||
|  | const tableStyle: CSSProperties = { borderCollapse: 'collapse', fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.875rem', boxShadow: '0 1px 3px rgba(0,0,0,0.08)', tableLayout: 'auto', width: '100%' }; | |||
|  | const headerStyle: CSSProperties = { color: '#212529', fontWeight: 600, padding: '0.75rem 1rem', borderBottom: '2px solid #dee2e6', textAlign: 'left', cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', position: 'sticky', top: -1, zIndex: 2, backgroundColor: '#f8f9fa' }; | |||
|  | const cellStyle: CSSProperties = { padding: '0.75rem 1rem', borderBottom: '1px solid #e9ecef', color: '#495057', backgroundColor: 'white' }; | |||
|  | const rowStyle: CSSProperties = { transition: 'background-color 0.2s ease' }; | |||
|  | const tableButtonStyle: CSSProperties = { padding: '0.375rem 0.75rem', borderRadius: '4px', border: '1px solid #dee2e6', backgroundColor: 'transparent', color: '#212529', cursor: 'pointer', transition: 'all 0.2s ease' }; | |||
|  | const deleteButtonStyle: CSSProperties = { background: 'none', border: 'none', cursor: 'pointer', color: '#dc3545', fontSize: '1.5em', padding: '0 5px', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%' }; | |||
|  | const deleteUserButtonStyle: CSSProperties = { background: 'none', border: 'none', cursor: 'pointer', color: '#dc3545', fontSize: '1.2em', padding: '0 5px', opacity: 0.7, transition: 'opacity 0.3s ease, color 0.3s ease' }; | |||
|  | const scrollToTopStyle: CSSProperties = { position: 'fixed', bottom: '60px', right: '20px', width: '40px', height: '40px', borderRadius: '50%', backgroundColor: '#007bff', color: 'white', border: 'none', cursor: 'pointer', boxShadow: '0 2px 5px rgba(0,0,0,0.2)', fontSize: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'opacity 0.3s, transform 0.3s', zIndex: 1002 }; | |||
|  | const powerButtonStyle: CSSProperties = { background: 'none', border: 'none', cursor: 'pointer', padding: '5px', display: 'flex', alignItems: 'center', transition: 'transform 0.2s ease' }; | |||
|  | const powerIconStyle: CSSProperties = { width: '24px', height: '24px' }; | |||
|  | const modalGrandeStyle: CSSProperties = { position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh', backgroundColor: 'white', zIndex: 1003, overflowY: 'auto', display: 'flex', flexDirection: 'column', padding: '0px' }; | |||
|  | const modalHeaderStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #ddd', marginBottom: '10px', top: 0, backgroundColor: 'white', zIndex: 1000, paddingTop: '5px' }; | |||
|  | const tituloSeccionStyle: CSSProperties = { fontSize: '1rem', margin: '0 0 15px 0', color: '#2d3436', fontWeight: 600 }; | |||
|  | const closeButtonStyle: CSSProperties = { background: 'black', color: 'white', border: 'none', borderRadius: '50%', width: '30px', height: '30px', cursor: 'pointer', fontSize: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1004, boxShadow: '0 2px 5px rgba(0,0,0,0.2)', transition: 'transform 0.2s', position: 'fixed', right: '30px', top: '30px' }; | |||
|  | const seccionStyle: CSSProperties = { backgroundColor: '#f8f9fa', borderRadius: '8px', padding: '15px', boxShadow: '0 2px 4px rgba(0,0,0,0.05)', marginBottom: '10px' }; | |||
|  | const gridDatosStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '15px', marginTop: '15px' }; | |||
|  | const datoItemStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', padding: '10px', backgroundColor: 'white', borderRadius: '4px', boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }; | |||
|  | const labelStyle: CSSProperties = { color: '#6c757d', textTransform: 'capitalize', fontSize: '1rem', fontWeight: 800, marginRight: '8px' }; | |||
|  | const valorStyle: CSSProperties = { color: '#495057', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: '0.8125rem', lineHeight: '1.4' }; | |||
|  | const historialTableStyle: CSSProperties = { width: '100%', borderCollapse: 'collapse', marginTop: '15px' }; | |||
|  | const historialHeaderStyle: CSSProperties = { backgroundColor: '#007bff', color: 'white', padding: '12px', textAlign: 'left', fontSize: '0.875rem' }; | |||
|  | const historialCellStyle: CSSProperties = { padding: '12px', color: '#495057', fontSize: '0.8125rem' }; | |||
|  | const historialRowStyle: CSSProperties = { borderBottom: '1px solid #dee2e6' }; | |||
|  | 
 | |||
|  | export default SimpleTable; |