Feat: Migrado de datos de MariaDB a SQL Server y Fix de Tabla
This commit is contained in:
5
frontend/src/App.css
Normal file
5
frontend/src/App.css
Normal file
@@ -0,0 +1,5 @@
|
||||
main {
|
||||
padding: 2rem;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
12
frontend/src/App.tsx
Normal file
12
frontend/src/App.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import SimpleTable from "./components/SimpleTable";
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<main>
|
||||
<SimpleTable />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
718
frontend/src/components/SimpleTable.tsx
Normal file
718
frontend/src/components/SimpleTable.tsx
Normal file
@@ -0,0 +1,718 @@
|
||||
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;
|
||||
24
frontend/src/index.css
Normal file
24
frontend/src/index.css
Normal file
@@ -0,0 +1,24 @@
|
||||
/* Limpieza básica y configuración de fuente */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Estilos de la scrollbar que estaban en index.html */
|
||||
body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-thumb {
|
||||
background-color: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Clase para bloquear el scroll cuando un modal está abierto */
|
||||
body.scroll-lock {
|
||||
padding-right: 8px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css' // Importaremos un CSS base aquí
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
69
frontend/src/types/interfaces.ts
Normal file
69
frontend/src/types/interfaces.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// frontend/src/types/interfaces.ts
|
||||
|
||||
// Corresponde al modelo 'Sector'
|
||||
export interface Sector {
|
||||
id: number;
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
// Corresponde al modelo 'Usuario'
|
||||
export interface Usuario {
|
||||
id: number;
|
||||
username: string;
|
||||
password?: string; // Es opcional ya que no siempre lo enviaremos
|
||||
}
|
||||
|
||||
// Corresponde al modelo 'Disco'
|
||||
export interface Disco {
|
||||
id: number;
|
||||
mediatype: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// Corresponde al modelo 'MemoriaRam'
|
||||
export interface MemoriaRam {
|
||||
id: number;
|
||||
partNumber?: string;
|
||||
fabricante?: string;
|
||||
tamano: number;
|
||||
velocidad?: number;
|
||||
}
|
||||
|
||||
// Interfaz combinada para mostrar los detalles de la RAM en la tabla de equipos
|
||||
export interface MemoriaRamDetalle extends MemoriaRam {
|
||||
slot: string;
|
||||
}
|
||||
|
||||
// Corresponde al modelo 'HistorialEquipo'
|
||||
export interface HistorialEquipo {
|
||||
id: number;
|
||||
equipo_id: number;
|
||||
campo_modificado: string;
|
||||
valor_anterior?: string;
|
||||
valor_nuevo?: string;
|
||||
fecha_cambio: string;
|
||||
}
|
||||
|
||||
// Corresponde al modelo principal 'Equipo' y sus relaciones
|
||||
export interface Equipo {
|
||||
id: number;
|
||||
hostname: string;
|
||||
ip: string;
|
||||
mac?: string;
|
||||
motherboard: string;
|
||||
cpu: string;
|
||||
ram_installed: number;
|
||||
ram_slots?: number;
|
||||
os: string;
|
||||
architecture: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
sector_id?: number;
|
||||
|
||||
// Propiedades de navegación que vienen de las relaciones (JOINs)
|
||||
sector?: Sector;
|
||||
usuarios: Usuario[];
|
||||
discos: Disco[];
|
||||
memoriasRam: MemoriaRamDetalle[];
|
||||
historial: HistorialEquipo[];
|
||||
}
|
||||
Reference in New Issue
Block a user