Feat: Migrado de datos de MariaDB a SQL Server y Fix de Tabla

This commit is contained in:
2025-10-04 22:17:05 -03:00
parent 85bd1915e0
commit e14476ff88
21 changed files with 4607 additions and 1 deletions

5
frontend/src/App.css Normal file
View File

@@ -0,0 +1,5 @@
main {
padding: 2rem;
max-width: 1600px;
margin: 0 auto;
}

12
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,12 @@
import SimpleTable from "./components/SimpleTable";
import './App.css';
function App() {
return (
<main>
<SimpleTable />
</main>
);
}
export default App;

View 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
View 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
View 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>,
)

View 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[];
}