Feat: Migrado de datos de MariaDB a SQL Server y Fix de Tabla
This commit is contained in:
@@ -6,6 +6,25 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
// --- 1. DEFINIR LA POLÍTICA CORS ---
|
||||||
|
// Definimos un nombre para nuestra política
|
||||||
|
var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
|
||||||
|
|
||||||
|
// Añadimos el servicio de CORS y configuramos la política
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(name: MyAllowSpecificOrigins,
|
||||||
|
policy =>
|
||||||
|
{
|
||||||
|
// Permitimos explícitamente el origen de tu frontend (Vite)
|
||||||
|
policy.WithOrigins("http://localhost:5173")
|
||||||
|
.AllowAnyHeader() // Permitir cualquier encabezado
|
||||||
|
.AllowAnyMethod(); // Permitir GET, POST, PUT, DELETE, etc.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// -----------------------------------
|
||||||
|
|
||||||
builder.Services.AddSingleton<DapperContext>();
|
builder.Services.AddSingleton<DapperContext>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
@@ -22,5 +41,12 @@ if (app.Environment.IsDevelopment())
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// --- 2. ACTIVAR EL MIDDLEWARE DE CORS ---
|
||||||
|
// ¡IMPORTANTE! Debe ir ANTES de MapControllers y DESPUÉS de UseHttpsRedirection (si se usa)
|
||||||
|
app.UseCors(MyAllowSpecificOrigins);
|
||||||
|
// ----------------------------------------
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+80210e5d4c8c41b94acb737e8a8d9935a2ef21b6")]
|
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+85bd1915e09fdb3a2af7e28d58b8ba794a9e6360")]
|
||||||
[assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")]
|
[assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
BIN
frontend/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/img/power.png
Normal file
BIN
frontend/img/power.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
3503
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/public/eldia.svg
Normal file
6
frontend/public/eldia.svg
Normal 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
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[];
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user