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

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

BIN
frontend/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
frontend/img/power.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Equipos</title>
<link rel="icon" type="image/x-icon" href="/eldia.svg">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3503
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-tooltip": "^5.29.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="89" height="69">
<path d="M0 0 C29.37 0 58.74 0 89 0 C89 22.77 89 45.54 89 69 C59.63 69 30.26 69 0 69 C0 46.23 0 23.46 0 0 Z " fill="#008FBD" transform="translate(0,0)"/>
<path d="M0 0 C3.3 0 6.6 0 10 0 C13.04822999 3.04822999 12.29337257 6.08805307 12.32226562 10.25390625 C12.31904297 11.32511719 12.31582031 12.39632812 12.3125 13.5 C12.32861328 14.57121094 12.34472656 15.64242187 12.36132812 16.74609375 C12.36197266 17.76832031 12.36261719 18.79054688 12.36328125 19.84375 C12.36775269 21.25430664 12.36775269 21.25430664 12.37231445 22.69335938 C12.1880188 23.83514648 12.1880188 23.83514648 12 25 C9 27 9 27 0 27 C0 18.09 0 9.18 0 0 Z " fill="#000000" transform="translate(0,21)"/>
<path d="M0 0 C5.61 0 11.22 0 17 0 C17 3.3 17 6.6 17 10 C25.58 10 34.16 10 43 10 C43 10.99 43 11.98 43 13 C34.42 13 25.84 13 17 13 C17 17.29 17 21.58 17 26 C11.39 26 5.78 26 0 26 C0 24.68 0 23.36 0 22 C4.62 22 9.24 22 14 22 C14 16.06 14 10.12 14 4 C9.38 4 4.76 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#000000" transform="translate(46,21)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})