Compare commits
32 Commits
10f2f2ba67
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 06be3a10da | |||
| 840df270cf | |||
| 7c2c328032 | |||
| 7effa58844 | |||
| bc9a9906c3 | |||
| acf2f9a35c | |||
| a32f0467ef | |||
| 2b2cc873e5 | |||
| d9da1c82c9 | |||
| 5f72f30931 | |||
| 8162d59331 | |||
| 6dd3c672d8 | |||
| 3893f917fc | |||
| bb3144a71b | |||
| ffd17f0cde | |||
| f50658c2d8 | |||
| 15d44aaf58 | |||
| bcd682484b | |||
| 9a6d4f0437 | |||
| afd378712c | |||
| 268c1c2bf9 | |||
| 177ad55962 | |||
| 248f5baa60 | |||
| 3b3ab53ac7 | |||
| be7b6a732d | |||
| 04f1134be4 | |||
| 242c1345c0 | |||
| 99d98cc588 | |||
| 3fbc9abf58 | |||
| e14476ff88 | |||
| 85bd1915e0 | |||
| 80210e5d4c |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
# Ignorar directorios de compilación y artefactos de .NET
|
||||
**/bin
|
||||
**/obj
|
||||
|
||||
# Ignorar dependencias de Node.js (se instalan dentro del contenedor)
|
||||
**/node_modules
|
||||
|
||||
# Ignorar archivos específicos del editor y del sistema operativo
|
||||
.vscode
|
||||
.vs
|
||||
*.suo
|
||||
*.user
|
||||
.DS_Store
|
||||
368
InventarioDB.sql
Normal file
368
InventarioDB.sql
Normal file
@@ -0,0 +1,368 @@
|
||||
-- ----------------------------
|
||||
-- Table structure for discos
|
||||
-- ----------------------------
|
||||
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[discos]') AND type IN ('U'))
|
||||
DROP TABLE [dbo].[discos]
|
||||
GO
|
||||
|
||||
CREATE TABLE [dbo].[discos] (
|
||||
[id] int IDENTITY(1,1) NOT NULL,
|
||||
[mediatype] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[size] int NOT NULL,
|
||||
[created_at] datetime2(6) DEFAULT getdate() NOT NULL,
|
||||
[updated_at] datetime2(6) DEFAULT getdate() NOT NULL
|
||||
)
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[discos] SET (LOCK_ESCALATION = TABLE)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for equipos
|
||||
-- ----------------------------
|
||||
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[equipos]') AND type IN ('U'))
|
||||
DROP TABLE [dbo].[equipos]
|
||||
GO
|
||||
|
||||
CREATE TABLE [dbo].[equipos] (
|
||||
[id] int IDENTITY(1,1) NOT NULL,
|
||||
[hostname] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[ip] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[mac] nvarchar(17) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[motherboard] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[cpu] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[ram_installed] int NOT NULL,
|
||||
[ram_slots] int NULL,
|
||||
[os] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[architecture] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[created_at] datetime2(6) DEFAULT getdate() NOT NULL,
|
||||
[updated_at] datetime2(6) DEFAULT getdate() NOT NULL,
|
||||
[sector_id] int NULL,
|
||||
[origen] nvarchar(50) COLLATE Modern_Spanish_CI_AS DEFAULT 'manual' NOT NULL
|
||||
)
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[equipos] SET (LOCK_ESCALATION = TABLE)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for equipos_discos
|
||||
-- ----------------------------
|
||||
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[equipos_discos]') AND type IN ('U'))
|
||||
DROP TABLE [dbo].[equipos_discos]
|
||||
GO
|
||||
|
||||
CREATE TABLE [dbo].[equipos_discos] (
|
||||
[id] int IDENTITY(1,1) NOT NULL,
|
||||
[equipo_id] int NULL,
|
||||
[disco_id] int NULL,
|
||||
[origen] nvarchar(50) COLLATE Modern_Spanish_CI_AS DEFAULT 'manual' NOT NULL
|
||||
)
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[equipos_discos] SET (LOCK_ESCALATION = TABLE)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for equipos_memorias_ram
|
||||
-- ----------------------------
|
||||
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[equipos_memorias_ram]') AND type IN ('U'))
|
||||
DROP TABLE [dbo].[equipos_memorias_ram]
|
||||
GO
|
||||
|
||||
CREATE TABLE [dbo].[equipos_memorias_ram] (
|
||||
[id] int IDENTITY(1,1) NOT NULL,
|
||||
[slot] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[equipo_id] int NULL,
|
||||
[memoria_ram_id] int NULL,
|
||||
[origen] nvarchar(50) COLLATE Modern_Spanish_CI_AS DEFAULT 'manual' NOT NULL
|
||||
)
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[equipos_memorias_ram] SET (LOCK_ESCALATION = TABLE)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for historial_equipos
|
||||
-- ----------------------------
|
||||
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[historial_equipos]') AND type IN ('U'))
|
||||
DROP TABLE [dbo].[historial_equipos]
|
||||
GO
|
||||
|
||||
CREATE TABLE [dbo].[historial_equipos] (
|
||||
[id] int IDENTITY(1,1) NOT NULL,
|
||||
[equipo_id] int NULL,
|
||||
[campo_modificado] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[valor_anterior] nvarchar(max) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[valor_nuevo] nvarchar(max) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[fecha_cambio] datetime2(6) DEFAULT getdate() NOT NULL
|
||||
)
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[historial_equipos] SET (LOCK_ESCALATION = TABLE)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for memorias_ram
|
||||
-- ----------------------------
|
||||
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[memorias_ram]') AND type IN ('U'))
|
||||
DROP TABLE [dbo].[memorias_ram]
|
||||
GO
|
||||
|
||||
CREATE TABLE [dbo].[memorias_ram] (
|
||||
[id] int IDENTITY(1,1) NOT NULL,
|
||||
[part_number] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[fabricante] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[tamano] int NOT NULL,
|
||||
[velocidad] int NULL,
|
||||
[created_at] datetime2(6) DEFAULT getdate() NOT NULL,
|
||||
[updated_at] datetime2(6) DEFAULT getdate() NOT NULL
|
||||
)
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[memorias_ram] SET (LOCK_ESCALATION = TABLE)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for sectores
|
||||
-- ----------------------------
|
||||
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[sectores]') AND type IN ('U'))
|
||||
DROP TABLE [dbo].[sectores]
|
||||
GO
|
||||
|
||||
CREATE TABLE [dbo].[sectores] (
|
||||
[id] int IDENTITY(1,1) NOT NULL,
|
||||
[nombre] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[created_at] datetime2(6) DEFAULT getdate() NOT NULL,
|
||||
[updated_at] datetime2(6) DEFAULT getdate() NOT NULL
|
||||
)
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[sectores] SET (LOCK_ESCALATION = TABLE)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for usuarios
|
||||
-- ----------------------------
|
||||
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[usuarios]') AND type IN ('U'))
|
||||
DROP TABLE [dbo].[usuarios]
|
||||
GO
|
||||
|
||||
CREATE TABLE [dbo].[usuarios] (
|
||||
[id] int IDENTITY(1,1) NOT NULL,
|
||||
[username] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[password] nvarchar(255) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[created_at] datetime2(6) DEFAULT getdate() NOT NULL,
|
||||
[updated_at] datetime2(6) DEFAULT getdate() NOT NULL
|
||||
)
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[usuarios] SET (LOCK_ESCALATION = TABLE)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for usuarios_equipos
|
||||
-- ----------------------------
|
||||
IF EXISTS (SELECT * FROM sys.all_objects WHERE object_id = OBJECT_ID(N'[dbo].[usuarios_equipos]') AND type IN ('U'))
|
||||
DROP TABLE [dbo].[usuarios_equipos]
|
||||
GO
|
||||
|
||||
CREATE TABLE [dbo].[usuarios_equipos] (
|
||||
[usuario_id] int NOT NULL,
|
||||
[equipo_id] int NOT NULL,
|
||||
[origen] nvarchar(50) COLLATE Modern_Spanish_CI_AS DEFAULT 'manual' NOT NULL
|
||||
)
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[usuarios_equipos] SET (LOCK_ESCALATION = TABLE)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Auto increment value for discos
|
||||
-- ----------------------------
|
||||
DBCC CHECKIDENT ('[dbo].[discos]', RESEED, 1)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table discos
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[discos] ADD CONSTRAINT [PK_discos] PRIMARY KEY CLUSTERED ([id])
|
||||
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)
|
||||
ON [PRIMARY]
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Auto increment value for equipos
|
||||
-- ----------------------------
|
||||
DBCC CHECKIDENT ('[dbo].[equipos]', RESEED, 1)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table equipos
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[equipos] ADD CONSTRAINT [PK_equipos] PRIMARY KEY CLUSTERED ([id])
|
||||
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)
|
||||
ON [PRIMARY]
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Auto increment value for equipos_discos
|
||||
-- ----------------------------
|
||||
DBCC CHECKIDENT ('[dbo].[equipos_discos]', RESEED, 1)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table equipos_discos
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[equipos_discos] ADD CONSTRAINT [PK_equipos_discos] PRIMARY KEY CLUSTERED ([id])
|
||||
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)
|
||||
ON [PRIMARY]
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Auto increment value for equipos_memorias_ram
|
||||
-- ----------------------------
|
||||
DBCC CHECKIDENT ('[dbo].[equipos_memorias_ram]', RESEED, 1)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table equipos_memorias_ram
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[equipos_memorias_ram] ADD CONSTRAINT [PK_equipos_memorias_ram] PRIMARY KEY CLUSTERED ([id])
|
||||
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)
|
||||
ON [PRIMARY]
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Auto increment value for historial_equipos
|
||||
-- ----------------------------
|
||||
DBCC CHECKIDENT ('[dbo].[historial_equipos]', RESEED, 1)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table historial_equipos
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[historial_equipos] ADD CONSTRAINT [PK_historial_equipos] PRIMARY KEY CLUSTERED ([id])
|
||||
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)
|
||||
ON [PRIMARY]
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Auto increment value for memorias_ram
|
||||
-- ----------------------------
|
||||
DBCC CHECKIDENT ('[dbo].[memorias_ram]', RESEED, 1)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table memorias_ram
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[memorias_ram] ADD CONSTRAINT [PK_memorias_ram] PRIMARY KEY CLUSTERED ([id])
|
||||
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)
|
||||
ON [PRIMARY]
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Auto increment value for sectores
|
||||
-- ----------------------------
|
||||
DBCC CHECKIDENT ('[dbo].[sectores]', RESEED, 1)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table sectores
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[sectores] ADD CONSTRAINT [PK_sectores] PRIMARY KEY CLUSTERED ([id])
|
||||
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)
|
||||
ON [PRIMARY]
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Auto increment value for usuarios
|
||||
-- ----------------------------
|
||||
DBCC CHECKIDENT ('[dbo].[usuarios]', RESEED, 1)
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table usuarios
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[usuarios] ADD CONSTRAINT [PK_usuarios] PRIMARY KEY CLUSTERED ([id])
|
||||
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)
|
||||
ON [PRIMARY]
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Primary Key structure for table usuarios_equipos
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[usuarios_equipos] ADD CONSTRAINT [PK_usuarios_equipos] PRIMARY KEY CLUSTERED ([usuario_id], [equipo_id])
|
||||
WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON)
|
||||
ON [PRIMARY]
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Foreign Keys structure for table equipos
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[equipos] ADD CONSTRAINT [FK_equipos_sectores] FOREIGN KEY ([sector_id]) REFERENCES [dbo].[sectores] ([id]) ON DELETE SET NULL ON UPDATE NO ACTION
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Foreign Keys structure for table equipos_discos
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[equipos_discos] ADD CONSTRAINT [FK_equipos_discos_equipos] FOREIGN KEY ([equipo_id]) REFERENCES [dbo].[equipos] ([id]) ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[equipos_discos] ADD CONSTRAINT [FK_equipos_discos_discos] FOREIGN KEY ([disco_id]) REFERENCES [dbo].[discos] ([id]) ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Foreign Keys structure for table equipos_memorias_ram
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[equipos_memorias_ram] ADD CONSTRAINT [FK_equipos_memorias_ram_equipos] FOREIGN KEY ([equipo_id]) REFERENCES [dbo].[equipos] ([id]) ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[equipos_memorias_ram] ADD CONSTRAINT [FK_equipos_memorias_ram_memorias_ram] FOREIGN KEY ([memoria_ram_id]) REFERENCES [dbo].[memorias_ram] ([id]) ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Foreign Keys structure for table historial_equipos
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[historial_equipos] ADD CONSTRAINT [FK_historial_equipos_equipos] FOREIGN KEY ([equipo_id]) REFERENCES [dbo].[equipos] ([id]) ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
GO
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- Foreign Keys structure for table usuarios_equipos
|
||||
-- ----------------------------
|
||||
ALTER TABLE [dbo].[usuarios_equipos] ADD CONSTRAINT [FK_usuarios_equipos_usuarios] FOREIGN KEY ([usuario_id]) REFERENCES [dbo].[usuarios] ([id]) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
GO
|
||||
|
||||
ALTER TABLE [dbo].[usuarios_equipos] ADD CONSTRAINT [FK_usuarios_equipos_equipos] FOREIGN KEY ([equipo_id]) REFERENCES [dbo].[equipos] ([id]) ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
GO
|
||||
|
||||
139
README.md
Normal file
139
README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Inventario IT 🖥️
|
||||
|
||||
**Inventario IT** es un sistema de gestión de inventario de hardware diseñado para ser poblado tanto por scripts automáticos como por gestión manual. Permite tener un registro centralizado y detallado de todos los equipos informáticos de una organización, sus componentes y su historial de cambios.
|
||||
|
||||
El sistema se compone de un **backend RESTful API desarrollado en ASP.NET Core** y un **frontend interactivo de tipo SPA (Single Page Application) construido con React, TypeScript y Vite**.
|
||||
|
||||
---
|
||||
## 🚀 Funcionalidades Principales
|
||||
|
||||
El sistema está diseñado en torno a la gestión y visualización de activos de TI, con un fuerte énfasis en la calidad y consistencia de los datos.
|
||||
|
||||
### 🔒 Seguridad y Autenticación
|
||||
- **Autenticación Basada en JWT:** Acceso a la API protegido mediante JSON Web Tokens.
|
||||
- **Login Simple y Seguro:** Un único par de credenciales (usuario/contraseña) configurables en el backend para acceder a toda la aplicación.
|
||||
- **Sesión Persistente Opcional:** En la pantalla de login, el usuario puede elegir "Mantener sesión iniciada".
|
||||
- **Si se marca:** El token se almacena de forma persistente (`localStorage`), sobreviviendo a cierres del navegador. El token tiene una validez de **1 año**.
|
||||
- **Si no se marca:** El token se almacena en la sesión del navegador (`sessionStorage`), cerrándose automáticamente al finalizar la sesión. El token tiene una validez de **6 horas**.
|
||||
|
||||
### 📋 Módulo de Equipos
|
||||
- **Vista Principal Centralizada:** Una tabla paginada, con capacidad de búsqueda y filtrado por sector, que muestra todos los equipos del inventario.
|
||||
- **Detalle Completo del Equipo:** Al hacer clic en un equipo, se abre una vista detallada con toda su información:
|
||||
- **Datos Principales:** Hostname, IP, MAC, OS, etc.
|
||||
- **Componentes Asociados:** Lista detallada de discos, módulos de RAM, usuarios asociados, CPU, Motherboard, RAM instalada, etc.
|
||||
- **Estado en Tiempo Real:** Indicador visual que muestra si el equipo está `En línea` o `Sin conexión` mediante un ping.
|
||||
- **Historial de Cambios:** Un registro cronológico de todas las modificaciones realizadas en el equipo.
|
||||
- **Acciones Remotas:**
|
||||
- **Wake On Lan (WOL):** 🚀 Botón para enviar un "Magic Packet" a través de un servidor SSH y encender un equipo de forma remota.
|
||||
|
||||
### ✍️ Gestión Manual vs. Automática
|
||||
- **Origen de Datos:** El sistema diferencia entre datos generados por scripts automáticos (`⚙️ Automático`) y los introducidos manualmente (`⌨️ Manual`).
|
||||
- **Protección de Datos:** Las entradas automáticas están protegidas contra modificaciones o eliminaciones desde la UI, asegurando la integridad de los datos recopilados en campo.
|
||||
- **CRUD Completo para Entradas Manuales:**
|
||||
- Creación de nuevos equipos con sus propiedades básicas.
|
||||
- Edición de los campos principales de un equipo manual.
|
||||
- Asociación manual de componentes (Discos, RAM, Usuarios).
|
||||
- Eliminación segura de equipos y componentes de origen manual.
|
||||
|
||||
### 🗂️ Módulo de Administración
|
||||
- **Gestión de Sectores:** Un CRUD completo para crear, editar y eliminar los sectores o departamentos de la organización.
|
||||
- **Limpieza de Datos Maestros:** Una potente herramienta para asegurar la consistencia de los datos.
|
||||
- **Visualización de Valores Únicos:** Permite ver todos los valores distintos existentes para un tipo de componente (ej: `Microsoft Windows 10 Pro`, `w10`, `Windows 10`) y cuántos equipos usan cada uno.
|
||||
- **Unificación de Valores:** ✏️ Permite reemplazar todas las instancias de un valor incorrecto (ej: `w10`) por uno correcto (ej: `Microsoft Windows 10 Pro`) en toda la base de datos con un solo clic.
|
||||
- **Eliminación de Registros Maestros:** 🗑️ Permite eliminar componentes maestros (ej: un módulo de RAM) que ya no están en uso por ningún equipo.
|
||||
|
||||
### 🎨 UI/UX
|
||||
- **Interfaz Moderna y Responsiva:** Construida con Vite, React y TypeScript para una experiencia rápida y fluida.
|
||||
- **Notificaciones en Tiempo Real:** El sistema utiliza `react-hot-toast` para dar feedback instantáneo sobre el estado de las operaciones (Cargando, Éxito, Error).
|
||||
- **Consistencia de Datos Mejorada:** Uso de campos de autocompletado que sugieren valores existentes para campos como CPU, OS y Motherboard, reduciendo errores humanos.
|
||||
- **Validación de Entradas:** Validación de formato en tiempo real para campos críticos como la MAC Address, con auto-formateo para ayudar al usuario.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
### Backend (`backend/`)
|
||||
- **Framework:** ASP.NET Core 9
|
||||
- **Lenguaje:** C#
|
||||
- **Acceso a Datos:** Dapper (Micro ORM)
|
||||
- **Base de Datos:** Microsoft SQL Server
|
||||
- **Autenticación:** JWT Bearer Token (`Microsoft.AspNetCore.Authentication.JwtBearer`)
|
||||
- **Comandos Remotos:** Renci.SshNet para ejecución de comandos SSH (Wake On Lan)
|
||||
|
||||
### Frontend (`frontend/`)
|
||||
- **Framework/Librería:** React 19
|
||||
- **Lenguaje:** TypeScript
|
||||
- **Gestión de Tabla:** TanStack Table v8
|
||||
- **Gráficos:** Chart.js con `react-chartjs-2`
|
||||
- **Notificaciones:** React Hot Toast
|
||||
- **Tooltips:** React Tooltip
|
||||
- **Iconos:** Lucide React
|
||||
- **Build Tool:** Vite
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Puesta en Marcha (Getting Started)
|
||||
|
||||
Siga estos pasos para configurar y ejecutar el proyecto en un entorno de desarrollo local.
|
||||
|
||||
### Prerrequisitos
|
||||
- **.NET SDK 9.0** o superior.
|
||||
- **Node.js** v20.x o superior (con npm).
|
||||
- **Microsoft SQL Server** (2019 o superior) y SQL Server Management Studio (SSMS).
|
||||
- Un editor de código como **Visual Studio Code**.
|
||||
|
||||
### 1. Clonar el Repositorio
|
||||
```bash
|
||||
git clone <URL_DEL_REPOSITORIO_GITEA>
|
||||
cd nombre-del-repositorio
|
||||
```
|
||||
|
||||
### 2. Configuración de la Base de Datos
|
||||
1. Abra SSMS y conecte a su instancia de SQL Server.
|
||||
2. Cree una nueva base de datos llamada `InventarioDB`.
|
||||
3. Ejecute el script SQL `InventarioDB.sql` (ubicado en la raíz del proyecto) sobre la base de datos recién creada. Esto creará todas las tablas, relaciones y claves necesarias.
|
||||
|
||||
### 3. Configuración del Backend
|
||||
1. Navegue al directorio del backend: `cd backend`.
|
||||
2. Abra el archivo `appsettings.Development.json` (o `appsettings.json` para producción).
|
||||
3. **Modifique la `ConnectionString`** para que apunte a su instancia de SQL Server.
|
||||
|
||||
```json
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=NOMBRE_SU_SERVIDOR;Database=InventarioDB;User Id=su_usuario_sql;Password=su_contraseña;TrustServerCertificate=True"
|
||||
}
|
||||
```
|
||||
4. **Configure las credenciales de la aplicación y la clave JWT**. Es crucial cambiar los valores por defecto por unos seguros y únicos.
|
||||
|
||||
```json
|
||||
"AuthSettings": {
|
||||
"Username": "admin",
|
||||
"Password": "SU_NUEVA_CLAVE_SEGURA_AQUI"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "SU_CLAVE_SECRETA_LARGA_Y_COMPLEJA_PARA_JWT",
|
||||
"Issuer": "InventarioAPI",
|
||||
"Audience": "InventarioClient"
|
||||
}
|
||||
```
|
||||
5. **Configure las credenciales del servidor SSH** que se usará para la función Wake On Lan en la sección `SshSettings`.
|
||||
|
||||
6. Instale las dependencias y ejecute el backend:
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet run
|
||||
La API estará disponible en `http://localhost:5198` y la UI de Swagger en la misma URL.
|
||||
|
||||
### 4. Configuración del Frontend
|
||||
1. En una nueva terminal, navegue al directorio del frontend: `cd frontend`.
|
||||
2. Instale las dependencias:
|
||||
```bash
|
||||
npm install
|
||||
3. **Verificar la configuración del Proxy.** El frontend utiliza un proxy de Vite para redirigir las peticiones de `/api` al backend. Esta configuración se encuentra en `frontend/vite.config.ts`. Si estás ejecutando el backend en el puerto por defecto (`5198`), no necesitas hacer ningún cambio.
|
||||
|
||||
4. Ejecute el frontend en modo de desarrollo:
|
||||
```bash
|
||||
npm run dev
|
||||
La aplicación web estará disponible en `http://localhost:5173` (o el puerto que indique Vite).
|
||||
|
||||
---
|
||||
204
backend/Controllers/AdminController.cs
Normal file
204
backend/Controllers/AdminController.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
// backend/Controllers/AdminController.cs
|
||||
using Dapper;
|
||||
using Inventario.API.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Inventario.API.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
private readonly DapperContext _context;
|
||||
|
||||
public AdminController(DapperContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// --- DTOs para los componentes ---
|
||||
public class ComponenteValorDto
|
||||
{
|
||||
public string Valor { get; set; } = "";
|
||||
public int Conteo { get; set; }
|
||||
}
|
||||
public class UnificarComponenteDto
|
||||
{
|
||||
public required string ValorNuevo { get; set; }
|
||||
public required string ValorAntiguo { get; set; }
|
||||
}
|
||||
|
||||
public class RamAgrupadaDto
|
||||
{
|
||||
public string? Fabricante { get; set; }
|
||||
public int Tamano { get; set; }
|
||||
public int? Velocidad { get; set; }
|
||||
public int Conteo { get; set; }
|
||||
}
|
||||
|
||||
public class BorrarRamAgrupadaDto
|
||||
{
|
||||
public string? Fabricante { get; set; }
|
||||
public int Tamano { get; set; }
|
||||
public int? Velocidad { get; set; }
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("componentes/{tipo}")]
|
||||
public async Task<IActionResult> GetComponenteValores(string tipo)
|
||||
{
|
||||
var allowedTypes = new Dictionary<string, string>
|
||||
{
|
||||
{ "os", "Os" },
|
||||
{ "cpu", "Cpu" },
|
||||
{ "motherboard", "Motherboard" },
|
||||
{ "architecture", "Architecture" }
|
||||
};
|
||||
|
||||
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
|
||||
{
|
||||
return BadRequest("Tipo de componente no válido.");
|
||||
}
|
||||
|
||||
var query = $@"
|
||||
SELECT {columnName} AS Valor, COUNT(*) AS Conteo
|
||||
FROM dbo.equipos
|
||||
WHERE {columnName} IS NOT NULL AND {columnName} != ''
|
||||
GROUP BY {columnName}
|
||||
ORDER BY Conteo DESC, Valor ASC;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var valores = await connection.QueryAsync<ComponenteValorDto>(query);
|
||||
return Ok(valores);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("componentes/{tipo}/unificar")]
|
||||
public async Task<IActionResult> UnificarComponenteValores(string tipo, [FromBody] UnificarComponenteDto dto)
|
||||
{
|
||||
var allowedTypes = new Dictionary<string, string>
|
||||
{
|
||||
{ "os", "Os" },
|
||||
{ "cpu", "Cpu" },
|
||||
{ "motherboard", "Motherboard" },
|
||||
{ "architecture", "Architecture" }
|
||||
};
|
||||
|
||||
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
|
||||
{
|
||||
return BadRequest("Tipo de componente no válido.");
|
||||
}
|
||||
|
||||
if (dto.ValorAntiguo == dto.ValorNuevo)
|
||||
{
|
||||
return BadRequest("El valor antiguo y el nuevo no pueden ser iguales.");
|
||||
}
|
||||
|
||||
var query = $@"
|
||||
UPDATE dbo.equipos
|
||||
SET {columnName} = @ValorNuevo
|
||||
WHERE {columnName} = @ValorAntiguo;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { dto.ValorNuevo, dto.ValorAntiguo });
|
||||
return Ok(new { message = $"Se unificaron {filasAfectadas} registros.", filasAfectadas });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Devuelve la RAM agrupada ---
|
||||
[HttpGet("componentes/ram")]
|
||||
public async Task<IActionResult> GetComponentesRam()
|
||||
{
|
||||
var query = @"
|
||||
SELECT
|
||||
mr.Fabricante,
|
||||
mr.Tamano,
|
||||
mr.Velocidad,
|
||||
COUNT(emr.memoria_ram_id) as Conteo
|
||||
FROM
|
||||
dbo.memorias_ram mr
|
||||
LEFT JOIN
|
||||
dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
|
||||
GROUP BY
|
||||
mr.Fabricante,
|
||||
mr.Tamano,
|
||||
mr.Velocidad
|
||||
ORDER BY
|
||||
Conteo DESC, mr.Fabricante, mr.Tamano;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var valores = await connection.QueryAsync<RamAgrupadaDto>(query);
|
||||
return Ok(valores);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Elimina un grupo completo ---
|
||||
[HttpDelete("componentes/ram")]
|
||||
public async Task<IActionResult> BorrarComponenteRam([FromBody] BorrarRamAgrupadaDto dto)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
// Verificación de seguridad: Asegurarse de que el grupo no esté en uso.
|
||||
var usageQuery = @"
|
||||
SELECT COUNT(emr.id)
|
||||
FROM dbo.memorias_ram mr
|
||||
LEFT JOIN dbo.equipos_memorias_ram emr ON mr.id = emr.memoria_ram_id
|
||||
WHERE (mr.Fabricante = @Fabricante OR (mr.Fabricante IS NULL AND @Fabricante IS NULL))
|
||||
AND mr.Tamano = @Tamano
|
||||
AND (mr.Velocidad = @Velocidad OR (mr.Velocidad IS NULL AND @Velocidad IS NULL));";
|
||||
|
||||
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, dto);
|
||||
|
||||
if (usageCount > 0)
|
||||
{
|
||||
return Conflict(new { message = $"Este grupo de RAM está en uso por {usageCount} equipo(s) y no puede ser eliminado." });
|
||||
}
|
||||
|
||||
// Si no está en uso, proceder con la eliminación de todos los registros maestros que coincidan.
|
||||
var deleteQuery = @"
|
||||
DELETE FROM dbo.memorias_ram
|
||||
WHERE (Fabricante = @Fabricante OR (Fabricante IS NULL AND @Fabricante IS NULL))
|
||||
AND Tamano = @Tamano
|
||||
AND (Velocidad = @Velocidad OR (Velocidad IS NULL AND @Velocidad IS NULL));";
|
||||
|
||||
await connection.ExecuteAsync(deleteQuery, dto);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("componentes/{tipo}/{valor}")]
|
||||
public async Task<IActionResult> BorrarComponenteTexto(string tipo, string valor)
|
||||
{
|
||||
var allowedTypes = new Dictionary<string, string>
|
||||
{
|
||||
{ "os", "Os" },
|
||||
{ "cpu", "Cpu" },
|
||||
{ "motherboard", "Motherboard" },
|
||||
{ "architecture", "Architecture" }
|
||||
};
|
||||
|
||||
if (!allowedTypes.TryGetValue(tipo.ToLower(), out var columnName))
|
||||
{
|
||||
return BadRequest("Tipo de componente no válido.");
|
||||
}
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var usageQuery = $"SELECT COUNT(*) FROM dbo.equipos WHERE {columnName} = @Valor;";
|
||||
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Valor = valor });
|
||||
|
||||
if (usageCount > 0)
|
||||
{
|
||||
return Conflict(new { message = $"Este valor está en uso por {usageCount} equipo(s) y no puede ser eliminado. Intente unificarlo en su lugar." });
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
backend/Controllers/AuthController.cs
Normal file
70
backend/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
// backend/Controllers/AuthController.cs
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace Inventario.API.Controllers
|
||||
{
|
||||
// DTO para recibir las credenciales del usuario
|
||||
public class LoginDto
|
||||
{
|
||||
public required string Username { get; set; }
|
||||
public required string Password { get; set; }
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public AuthController(IConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public IActionResult Login([FromBody] LoginDto login)
|
||||
{
|
||||
if (login.Username == _config["AuthSettings:Username"] && login.Password == _config["AuthSettings:Password"])
|
||||
{
|
||||
// Pasamos el valor de RememberMe a la función de generación
|
||||
var token = GenerateJwtToken(login.Username, login.RememberMe);
|
||||
return Ok(new { token });
|
||||
}
|
||||
return Unauthorized(new { message = "Credenciales inválidas." });
|
||||
}
|
||||
|
||||
private string GenerateJwtToken(string username, bool rememberMe)
|
||||
{
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, username),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
};
|
||||
|
||||
// --- LÓGICA DE EXPIRACIÓN DINÁMICA ---
|
||||
// Si "rememberMe" es true, expira en 1 año.
|
||||
// Si es false, expira en 6 horas.
|
||||
var expirationTime = rememberMe
|
||||
? DateTime.Now.AddYears(1)
|
||||
: DateTime.Now.AddHours(6);
|
||||
// ------------------------------------
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _config["Jwt:Issuer"],
|
||||
audience: _config["Jwt:Audience"],
|
||||
claims: claims,
|
||||
expires: expirationTime,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
backend/Controllers/DashboardController.cs
Normal file
86
backend/Controllers/DashboardController.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Dapper;
|
||||
using Inventario.API.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Inventario.API.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class DashboardController : ControllerBase
|
||||
{
|
||||
private readonly DapperContext _context;
|
||||
|
||||
public DashboardController(DapperContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public class StatItemDto
|
||||
{
|
||||
// Cambiamos el tipo de Label a string para que Dapper no tenga problemas
|
||||
// al leer el ram_installed (que es un int). Lo formatearemos en el frontend.
|
||||
public string Label { get; set; } = "";
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
public class DashboardStatsDto
|
||||
{
|
||||
public IEnumerable<StatItemDto> OsStats { get; set; } = new List<StatItemDto>();
|
||||
public IEnumerable<StatItemDto> SectorStats { get; set; } = new List<StatItemDto>();
|
||||
public IEnumerable<StatItemDto> CpuStats { get; set; } = new List<StatItemDto>();
|
||||
public IEnumerable<StatItemDto> RamStats { get; set; } = new List<StatItemDto>(); // <-- 1. Añadir propiedad para RAM
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
public async Task<IActionResult> GetDashboardStats()
|
||||
{
|
||||
var osQuery = @"
|
||||
SELECT Os AS Label, COUNT(Id) AS Count
|
||||
FROM dbo.equipos
|
||||
WHERE Os IS NOT NULL AND Os != ''
|
||||
GROUP BY Os
|
||||
ORDER BY Count DESC;";
|
||||
|
||||
var sectorQuery = @"
|
||||
SELECT s.Nombre AS Label, COUNT(e.Id) AS Count
|
||||
FROM dbo.equipos e
|
||||
JOIN dbo.sectores s ON e.sector_id = s.id
|
||||
GROUP BY s.Nombre
|
||||
ORDER BY Count DESC;";
|
||||
|
||||
var cpuQuery = @"
|
||||
SELECT Cpu AS Label, COUNT(Id) AS Count
|
||||
FROM dbo.equipos
|
||||
WHERE Cpu IS NOT NULL AND Cpu != ''
|
||||
GROUP BY Cpu
|
||||
ORDER BY Count DESC;";
|
||||
|
||||
var ramQuery = @"
|
||||
SELECT CAST(ram_installed AS VARCHAR) AS Label, COUNT(Id) AS Count
|
||||
FROM dbo.equipos
|
||||
WHERE ram_installed > 0
|
||||
GROUP BY ram_installed
|
||||
ORDER BY ram_installed ASC;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var osStats = await connection.QueryAsync<StatItemDto>(osQuery);
|
||||
var sectorStats = await connection.QueryAsync<StatItemDto>(sectorQuery);
|
||||
var cpuStats = await connection.QueryAsync<StatItemDto>(cpuQuery);
|
||||
var ramStats = await connection.QueryAsync<StatItemDto>(ramQuery);
|
||||
|
||||
var result = new DashboardStatsDto
|
||||
{
|
||||
OsStats = osStats,
|
||||
SectorStats = sectorStats,
|
||||
CpuStats = cpuStats,
|
||||
RamStats = ramStats
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
backend/Controllers/DiscosController.cs
Normal file
116
backend/Controllers/DiscosController.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using Dapper;
|
||||
using Inventario.API.Data;
|
||||
using Inventario.API.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Inventario.API.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class DiscosController : ControllerBase
|
||||
{
|
||||
private readonly DapperContext _context;
|
||||
|
||||
public DiscosController(DapperContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// --- GET /api/discos ---
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Consultar()
|
||||
{
|
||||
var query = "SELECT Id, Mediatype, Size FROM dbo.discos;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var discos = await connection.QueryAsync<Disco>(query);
|
||||
return Ok(discos);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/discos/{id} ---
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> ConsultarDetalle(int id)
|
||||
{
|
||||
var query = "SELECT Id, Mediatype, Size FROM dbo.discos WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var disco = await connection.QuerySingleOrDefaultAsync<Disco>(query, new { Id = id });
|
||||
if (disco == null)
|
||||
{
|
||||
return NotFound("Disco no encontrado.");
|
||||
}
|
||||
return Ok(disco);
|
||||
}
|
||||
}
|
||||
|
||||
// --- POST /api/discos ---
|
||||
// Replica la lógica de recibir uno o varios discos y crear solo los que no existen.
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Ingresar([FromBody] List<Disco> discos)
|
||||
{
|
||||
var queryCheck = "SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;";
|
||||
var queryInsert = "INSERT INTO dbo.discos (Mediatype, Size) VALUES (@Mediatype, @Size); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
var resultados = new List<object>();
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
foreach (var disco in discos)
|
||||
{
|
||||
var existente = await connection.QuerySingleOrDefaultAsync<Disco>(queryCheck, new { disco.Mediatype, disco.Size });
|
||||
|
||||
if (existente == null)
|
||||
{
|
||||
var nuevoId = await connection.ExecuteScalarAsync<int>(queryInsert, new { disco.Mediatype, disco.Size });
|
||||
var nuevoDisco = new Disco { Id = nuevoId, Mediatype = disco.Mediatype, Size = disco.Size };
|
||||
resultados.Add(new { action = "created", registro = nuevoDisco });
|
||||
}
|
||||
else
|
||||
{
|
||||
// Opcional: podrías añadirlo si quieres saber cuáles ya existían
|
||||
// resultados.Add(new { action = "exists", registro = existente });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Devolvemos HTTP 200 OK con la lista de los discos que se crearon.
|
||||
return Ok(resultados);
|
||||
}
|
||||
|
||||
// --- PUT /api/discos/{id} ---
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Actualizar(int id, [FromBody] Disco disco)
|
||||
{
|
||||
var query = "UPDATE dbo.discos SET Mediatype = @Mediatype, Size = @Size WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { disco.Mediatype, disco.Size, Id = id });
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Disco no encontrado.");
|
||||
}
|
||||
|
||||
var discoActualizado = new Disco { Id = id, Mediatype = disco.Mediatype, Size = disco.Size };
|
||||
return Ok(discoActualizado);
|
||||
}
|
||||
}
|
||||
|
||||
// --- DELETE /api/discos/{id} ---
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Borrar(int id)
|
||||
{
|
||||
var query = "DELETE FROM dbo.discos WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Disco no encontrado.");
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
977
backend/Controllers/EquiposController.cs
Normal file
977
backend/Controllers/EquiposController.cs
Normal file
@@ -0,0 +1,977 @@
|
||||
using Dapper;
|
||||
using Inventario.API.Data;
|
||||
using Inventario.API.DTOs;
|
||||
using Inventario.API.Helpers;
|
||||
using Inventario.API.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Data;
|
||||
using System.Net.NetworkInformation;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Renci.SshNet;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Inventario.API.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class EquiposController : ControllerBase
|
||||
{
|
||||
private readonly DapperContext _context;
|
||||
private readonly IConfiguration _configuration; // 1. Añadimos el campo para la configuración
|
||||
|
||||
// 2. Modificamos el constructor para inyectar IConfiguration
|
||||
public EquiposController(DapperContext context, IConfiguration configuration)
|
||||
{
|
||||
_context = context;
|
||||
_configuration = configuration; // Asignamos la configuración inyectada
|
||||
}
|
||||
|
||||
// --- MÉTODOS CRUD BÁSICOS ---
|
||||
// GET /api/equipos
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Consultar()
|
||||
{
|
||||
var query = @"
|
||||
SELECT
|
||||
e.Id, e.Hostname, e.Ip, e.Mac, e.Motherboard, e.Cpu, e.Ram_installed, e.Ram_slots, e.Os, e.Architecture, e.created_at, e.updated_at, e.Origen, e.sector_id,
|
||||
s.Id as Id, s.Nombre,
|
||||
u.Id as Id, u.Username, u.Password, ue.Origen as Origen,
|
||||
d.Id as Id, d.Mediatype, d.Size, ed.Origen as Origen, ed.Id as EquipoDiscoId,
|
||||
mr.Id as Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad, emr.Slot, emr.Origen as Origen, emr.Id as EquipoMemoriaRamId
|
||||
FROM dbo.equipos e
|
||||
LEFT JOIN dbo.sectores s ON e.sector_id = s.id
|
||||
LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
|
||||
LEFT JOIN dbo.usuarios u ON ue.usuario_id = u.id
|
||||
LEFT JOIN dbo.equipos_discos ed ON e.id = ed.equipo_id
|
||||
LEFT JOIN dbo.discos d ON ed.disco_id = d.id
|
||||
LEFT JOIN dbo.equipos_memorias_ram emr ON e.id = emr.equipo_id
|
||||
LEFT JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipoDict = new Dictionary<int, Equipo>();
|
||||
|
||||
await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, DiscoDetalle, MemoriaRamEquipoDetalle, Equipo>(
|
||||
query, (equipo, sector, usuario, disco, memoria) =>
|
||||
{
|
||||
if (!equipoDict.TryGetValue(equipo.Id, out var equipoActual))
|
||||
{
|
||||
equipoActual = equipo;
|
||||
equipoActual.Sector = sector;
|
||||
equipoDict.Add(equipoActual.Id, equipoActual);
|
||||
}
|
||||
if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id))
|
||||
equipoActual.Usuarios.Add(usuario);
|
||||
if (disco != null && !equipoActual.Discos.Any(d => d.EquipoDiscoId == disco.EquipoDiscoId))
|
||||
equipoActual.Discos.Add(disco);
|
||||
if (memoria != null && !equipoActual.MemoriasRam.Any(m => m.EquipoMemoriaRamId == memoria.EquipoMemoriaRamId))
|
||||
equipoActual.MemoriasRam.Add(memoria);
|
||||
|
||||
return equipoActual;
|
||||
},
|
||||
splitOn: "Id,Id,Id,Id"
|
||||
);
|
||||
return Ok(equipoDict.Values.OrderBy(e => e.Sector?.Nombre).ThenBy(e => e.Hostname));
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/equipos/{hostname} ---
|
||||
[HttpGet("{hostname}")]
|
||||
public async Task<IActionResult> ConsultarDetalle(string hostname)
|
||||
{
|
||||
var query = @"SELECT
|
||||
e.*,
|
||||
s.Id as SectorId, s.Nombre as SectorNombre,
|
||||
u.Id as UsuarioId, u.Username, u.Password, ue.Origen as Origen
|
||||
FROM dbo.equipos e
|
||||
LEFT JOIN dbo.sectores s ON e.sector_id = s.id
|
||||
LEFT JOIN dbo.usuarios_equipos ue ON e.id = ue.equipo_id
|
||||
LEFT JOIN dbo.usuarios u ON ue.usuario_id = u.id
|
||||
WHERE e.Hostname = @Hostname;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipoDict = new Dictionary<int, Equipo>();
|
||||
|
||||
var equipo = (await connection.QueryAsync<Equipo, Sector, UsuarioEquipoDetalle, Equipo>(
|
||||
query, (e, sector, usuario) =>
|
||||
{
|
||||
if (!equipoDict.TryGetValue(e.Id, out var equipoActual))
|
||||
{
|
||||
equipoActual = e;
|
||||
equipoActual.Sector = sector;
|
||||
equipoDict.Add(equipoActual.Id, equipoActual);
|
||||
}
|
||||
if (usuario != null && !equipoActual.Usuarios.Any(u => u.Id == usuario.Id))
|
||||
equipoActual.Usuarios.Add(usuario);
|
||||
|
||||
return equipoActual;
|
||||
},
|
||||
new { Hostname = hostname },
|
||||
splitOn: "SectorId,UsuarioId"
|
||||
)).FirstOrDefault();
|
||||
|
||||
if (equipo == null) return NotFound("Equipo no encontrado.");
|
||||
|
||||
var discosQuery = "SELECT d.*, ed.Origen, ed.Id as EquipoDiscoId FROM dbo.discos d JOIN dbo.equipos_discos ed ON d.Id = ed.disco_id WHERE ed.equipo_id = @Id";
|
||||
equipo.Discos = (await connection.QueryAsync<DiscoDetalle>(discosQuery, new { equipo.Id })).ToList();
|
||||
|
||||
var ramQuery = "SELECT mr.*, emr.Slot, emr.Origen, emr.Id as EquipoMemoriaRamId FROM dbo.memorias_ram mr JOIN dbo.equipos_memorias_ram emr ON mr.Id = emr.memoria_ram_id WHERE emr.equipo_id = @Id";
|
||||
equipo.MemoriasRam = (await connection.QueryAsync<MemoriaRamEquipoDetalle>(ramQuery, new { equipo.Id })).ToList();
|
||||
|
||||
return Ok(equipo);
|
||||
}
|
||||
}
|
||||
|
||||
// --- POST /api/equipos/{hostname} ---
|
||||
[HttpPost("{hostname}")]
|
||||
public async Task<IActionResult> Ingresar(string hostname, [FromBody] Equipo equipoData)
|
||||
{
|
||||
var findQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipoExistente = await connection.QuerySingleOrDefaultAsync<Equipo>(findQuery, new { Hostname = hostname });
|
||||
|
||||
if (equipoExistente == null)
|
||||
{
|
||||
// Crear
|
||||
var insertQuery = @"INSERT INTO dbo.equipos (Hostname, Ip, Mac, Motherboard, Cpu, Ram_installed, Ram_slots, Os, Architecture, Origen)
|
||||
VALUES (@Hostname, @Ip, @Mac, @Motherboard, @Cpu, @Ram_installed, @Ram_slots, @Os, @Architecture, 'automatica');
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoData);
|
||||
equipoData.Id = nuevoId;
|
||||
return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = equipoData.Hostname }, equipoData);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Actualizar y registrar historial
|
||||
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
|
||||
|
||||
// Comparamos campos para registrar en historial
|
||||
if (equipoData.Ip != equipoExistente.Ip) cambios["ip"] = (equipoExistente.Ip, equipoData.Ip);
|
||||
if (equipoData.Mac != equipoExistente.Mac) cambios["mac"] = (equipoExistente.Mac ?? "", equipoData.Mac ?? "");
|
||||
|
||||
var updateQuery = @"UPDATE dbo.equipos SET Ip = @Ip, Mac = @Mac, Motherboard = @Motherboard,
|
||||
Cpu = @Cpu, Ram_installed = @Ram_installed, Ram_slots = @Ram_slots, Os = @Os, Architecture = @Architecture
|
||||
WHERE Hostname = @Hostname;";
|
||||
await connection.ExecuteAsync(updateQuery, equipoData);
|
||||
|
||||
if (cambios.Count > 0)
|
||||
{
|
||||
await HistorialHelper.RegistrarCambios(_context, equipoExistente.Id, cambios);
|
||||
}
|
||||
|
||||
return Ok(equipoData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- PUT /api/equipos/{id} ---
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Actualizar(int id, [FromBody] Equipo equipoData)
|
||||
{
|
||||
var updateQuery = @"UPDATE dbo.equipos SET Hostname = @Hostname, Ip = @Ip, Mac = @Mac, Motherboard = @Motherboard,
|
||||
Cpu = @Cpu, Ram_installed = @Ram_installed, Ram_slots = @Ram_slots, Os = @Os, Architecture = @Architecture
|
||||
WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
// Asignamos el ID del parámetro de la ruta al objeto que recibimos.
|
||||
equipoData.Id = id;
|
||||
// Ahora pasamos el objeto completo a Dapper.
|
||||
var filasAfectadas = await connection.ExecuteAsync(updateQuery, equipoData);
|
||||
if (filasAfectadas == 0) return NotFound("Equipo no encontrado.");
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// --- DELETE /api/equipos/{id} ---
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Borrar(int id)
|
||||
{
|
||||
var query = "DELETE FROM dbo.equipos WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Equipo no encontrado.");
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/equipos/{hostname}/historial ---
|
||||
[HttpGet("{hostname}/historial")]
|
||||
public async Task<IActionResult> ConsultarHistorial(string hostname)
|
||||
{
|
||||
var query = @"SELECT h.* FROM dbo.historial_equipos h
|
||||
JOIN dbo.equipos e ON h.equipo_id = e.id
|
||||
WHERE e.Hostname = @Hostname
|
||||
ORDER BY h.fecha_cambio DESC;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname", new { Hostname = hostname });
|
||||
if (equipo == null) return NotFound("Equipo no encontrado.");
|
||||
|
||||
var historial = await connection.QueryAsync<HistorialEquipo>(query, new { Hostname = hostname });
|
||||
return Ok(new { equipo = hostname, historial });
|
||||
}
|
||||
}
|
||||
// --- MÉTODOS DE ASOCIACIÓN Y COMANDOS ---
|
||||
|
||||
[HttpPatch("{id_equipo}/sector/{id_sector}")]
|
||||
public async Task<IActionResult> AsociarSector(int id_equipo, int id_sector)
|
||||
{
|
||||
var query = "UPDATE dbo.equipos SET sector_id = @IdSector WHERE Id = @IdEquipo;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { IdSector = id_sector, IdEquipo = id_equipo });
|
||||
if (filasAfectadas == 0) return NotFound("Equipo o sector no encontrado.");
|
||||
return Ok(new { success = true }); // Devolvemos una respuesta simple de éxito
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{hostname}/asociarusuario")]
|
||||
public async Task<IActionResult> AsociarUsuario(string hostname, [FromBody] AsociacionUsuarioDto dto)
|
||||
{
|
||||
var query = @"
|
||||
INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen)
|
||||
SELECT e.id, u.id, 'automatica'
|
||||
FROM dbo.equipos e, dbo.usuarios u
|
||||
WHERE e.Hostname = @Hostname AND u.Username = @Username
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.usuarios_equipos ue
|
||||
WHERE ue.equipo_id = e.id AND ue.usuario_id = u.id
|
||||
);";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
await connection.ExecuteAsync(query, new { Hostname = hostname, dto.Username });
|
||||
return Ok(new { success = true, message = "Asociación asegurada." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{hostname}/usuarios/{username}")]
|
||||
public async Task<IActionResult> DesasociarUsuario(string hostname, string username)
|
||||
{
|
||||
var query = @"
|
||||
DELETE FROM dbo.usuarios_equipos
|
||||
WHERE equipo_id = (SELECT id FROM dbo.equipos WHERE Hostname = @Hostname)
|
||||
AND usuario_id = (SELECT id FROM dbo.usuarios WHERE Username = @Username);";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { Hostname = hostname, Username = username });
|
||||
if (filasAfectadas == 0) return NotFound("Asociación no encontrada.");
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{hostname}/asociardiscos")]
|
||||
public async Task<IActionResult> AsociarDiscos(string hostname, [FromBody] List<Disco> discosDesdeCliente)
|
||||
{
|
||||
var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
|
||||
using var connection = _context.CreateConnection();
|
||||
connection.Open();
|
||||
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>(equipoQuery, new { Hostname = hostname });
|
||||
|
||||
if (equipo == null)
|
||||
{
|
||||
return NotFound("Equipo no encontrado.");
|
||||
}
|
||||
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var discosActualesQuery = @"
|
||||
SELECT d.Id, d.Mediatype, d.Size, ed.Id as EquipoDiscoId
|
||||
FROM dbo.equipos_discos ed
|
||||
JOIN dbo.discos d ON ed.disco_id = d.id
|
||||
WHERE ed.equipo_id = @EquipoId;";
|
||||
var discosEnDb = (await connection.QueryAsync<DiscoAsociado>(discosActualesQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
|
||||
|
||||
var discosClienteContados = discosDesdeCliente
|
||||
.GroupBy(d => $"{d.Mediatype}_{d.Size}")
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var discosDbContados = discosEnDb
|
||||
.GroupBy(d => $"{d.Mediatype}_{d.Size}")
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
|
||||
|
||||
var discosAEliminar = new List<int>();
|
||||
foreach (var discoDb in discosEnDb)
|
||||
{
|
||||
var key = $"{discoDb.Mediatype}_{discoDb.Size}";
|
||||
if (discosClienteContados.TryGetValue(key, out int count) && count > 0)
|
||||
{
|
||||
discosClienteContados[key]--;
|
||||
}
|
||||
else
|
||||
{
|
||||
discosAEliminar.Add(discoDb.EquipoDiscoId);
|
||||
var nombreDisco = $"Disco {discoDb.Mediatype} {discoDb.Size}GB";
|
||||
var anterior = discosDbContados.GetValueOrDefault(key, 0);
|
||||
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior - 1).ToString());
|
||||
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) - 1).ToString());
|
||||
}
|
||||
}
|
||||
if (discosAEliminar.Any())
|
||||
{
|
||||
await connection.ExecuteAsync("DELETE FROM dbo.equipos_discos WHERE Id IN @Ids;", new { Ids = discosAEliminar }, transaction);
|
||||
}
|
||||
|
||||
foreach (var discoCliente in discosDesdeCliente)
|
||||
{
|
||||
var key = $"{discoCliente.Mediatype}_{discoCliente.Size}";
|
||||
if (discosDbContados.TryGetValue(key, out int count) && count > 0)
|
||||
{
|
||||
discosDbContados[key]--;
|
||||
}
|
||||
else
|
||||
{
|
||||
var disco = await connection.QueryFirstOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size;", discoCliente, transaction);
|
||||
if (disco == null) continue;
|
||||
|
||||
await connection.ExecuteAsync("INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'automatica');", new { EquipoId = equipo.Id, DiscoId = disco.Id }, transaction);
|
||||
var nombreDisco = $"Disco {disco.Mediatype} {disco.Size}GB";
|
||||
var anterior = discosDbContados.GetValueOrDefault(key, 0);
|
||||
if (!cambios.ContainsKey(nombreDisco)) cambios[nombreDisco] = (anterior.ToString(), (anterior + 1).ToString());
|
||||
else cambios[nombreDisco] = (anterior.ToString(), (int.Parse(cambios[nombreDisco].nuevo!) + 1).ToString());
|
||||
}
|
||||
}
|
||||
|
||||
if (cambios.Count > 0)
|
||||
{
|
||||
var cambiosFormateados = cambios.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => ((string?)$"{kvp.Value.anterior} Instalados", (string?)$"{kvp.Value.nuevo} Instalados")
|
||||
);
|
||||
|
||||
await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambiosFormateados);
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
|
||||
return Ok(new { message = "Discos sincronizados correctamente." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
Console.WriteLine($"Error al asociar discos para {hostname}: {ex.Message}");
|
||||
return StatusCode(500, "Ocurrió un error interno al procesar la solicitud.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{hostname}/ram")]
|
||||
public async Task<IActionResult> AsociarRam(string hostname, [FromBody] List<MemoriaRamEquipoDetalle> memoriasDesdeCliente)
|
||||
{
|
||||
var equipoQuery = "SELECT * FROM dbo.equipos WHERE Hostname = @Hostname;";
|
||||
using var connection = _context.CreateConnection();
|
||||
connection.Open();
|
||||
var equipo = await connection.QuerySingleOrDefaultAsync<Equipo>(equipoQuery, new { Hostname = hostname });
|
||||
if (equipo == null) return NotFound("Equipo no encontrado.");
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var ramActualQuery = @"
|
||||
SELECT emr.Id as EquipoMemoriaRamId, emr.Slot, mr.Id, mr.part_number as PartNumber, mr.Fabricante, mr.Tamano, mr.Velocidad
|
||||
FROM dbo.equipos_memorias_ram emr
|
||||
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
|
||||
WHERE emr.equipo_id = @EquipoId;";
|
||||
var ramEnDb = (await connection.QueryAsync<dynamic>(ramActualQuery, new { EquipoId = equipo.Id }, transaction)).ToList();
|
||||
|
||||
Func<dynamic, string> crearHuella = ram => $"{ram.Slot}_{ram.PartNumber ?? ""}_{ram.Tamano}_{ram.Velocidad ?? 0}";
|
||||
var huellasCliente = new HashSet<string>(memoriasDesdeCliente.Select(crearHuella));
|
||||
var huellasDb = new HashSet<string>(ramEnDb.Select(crearHuella));
|
||||
|
||||
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
|
||||
|
||||
Func<dynamic, string> formatRamDetails = ram =>
|
||||
{
|
||||
var parts = new List<string?> { ram.Fabricante, $"{ram.Tamano}GB", ram.PartNumber, ram.Velocidad?.ToString() + "MHz" };
|
||||
return string.Join(" ", parts.Where(p => !string.IsNullOrEmpty(p)));
|
||||
};
|
||||
|
||||
var modulosEliminados = ramEnDb.Where(ramDb => !huellasCliente.Contains(crearHuella(ramDb))).ToList();
|
||||
foreach (var modulo in modulosEliminados)
|
||||
{
|
||||
var campo = $"RAM Slot {modulo.Slot}";
|
||||
cambios[campo] = (formatRamDetails(modulo), "Vacio");
|
||||
}
|
||||
|
||||
var modulosInsertados = memoriasDesdeCliente.Where(ramCliente => !huellasDb.Contains(crearHuella(ramCliente))).ToList();
|
||||
foreach (var modulo in modulosInsertados)
|
||||
{
|
||||
var campo = $"RAM Slot {modulo.Slot}";
|
||||
var valorNuevo = formatRamDetails(modulo);
|
||||
if (cambios.ContainsKey(campo))
|
||||
{
|
||||
cambios[campo] = (cambios[campo].anterior, valorNuevo);
|
||||
}
|
||||
else
|
||||
{
|
||||
cambios[campo] = ("Vacio", valorNuevo);
|
||||
}
|
||||
}
|
||||
|
||||
var asociacionesAEliminar = modulosEliminados.Select(ramDb => (int)ramDb.EquipoMemoriaRamId).ToList();
|
||||
if (asociacionesAEliminar.Any())
|
||||
{
|
||||
await connection.ExecuteAsync("DELETE FROM dbo.equipos_memorias_ram WHERE Id IN @Ids;", new { Ids = asociacionesAEliminar }, transaction);
|
||||
}
|
||||
|
||||
foreach (var memInfo in modulosInsertados)
|
||||
{
|
||||
var findRamQuery = @"SELECT * FROM dbo.memorias_ram WHERE (part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND tamano = @Tamano AND (velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
|
||||
var memoriaMaestra = await connection.QuerySingleOrDefaultAsync<MemoriaRam>(findRamQuery, memInfo, transaction);
|
||||
int memoriaMaestraId;
|
||||
if (memoriaMaestra == null)
|
||||
{
|
||||
var insertRamQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad) VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
memoriaMaestraId = await connection.ExecuteScalarAsync<int>(insertRamQuery, memInfo, transaction);
|
||||
}
|
||||
else
|
||||
{
|
||||
memoriaMaestraId = memoriaMaestra.Id;
|
||||
}
|
||||
// Crear la asociación en la tabla intermedia
|
||||
var insertAsociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @MemoriaRamId, @Slot, 'automatica');";
|
||||
await connection.ExecuteAsync(insertAsociacionQuery, new { EquipoId = equipo.Id, MemoriaRamId = memoriaMaestraId, memInfo.Slot }, transaction);
|
||||
}
|
||||
|
||||
if (cambios.Count > 0)
|
||||
{
|
||||
await HistorialHelper.RegistrarCambios(_context, equipo.Id, cambios);
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
return Ok(new { message = "Módulos de RAM sincronizados correctamente." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
Console.WriteLine($"Error al asociar RAM para {hostname}: {ex.Message}");
|
||||
return StatusCode(500, "Ocurrió un error interno al procesar la solicitud de RAM.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("ping")]
|
||||
public async Task<IActionResult> EnviarPing([FromBody] PingRequestDto request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Ip))
|
||||
return BadRequest("La dirección IP es requerida.");
|
||||
|
||||
try
|
||||
{
|
||||
using (var ping = new Ping())
|
||||
{
|
||||
var reply = await ping.SendPingAsync(request.Ip, 2000);
|
||||
bool isAlive = reply.Status == IPStatus.Success;
|
||||
if (!isAlive)
|
||||
{
|
||||
reply = await ping.SendPingAsync(request.Ip, 2000);
|
||||
isAlive = reply.Status == IPStatus.Success;
|
||||
}
|
||||
return Ok(new { isAlive, latency = isAlive ? reply.RoundtripTime : (long?)null });
|
||||
}
|
||||
}
|
||||
catch (PingException ex)
|
||||
{
|
||||
Console.WriteLine($"Error de Ping para {request.Ip}: {ex.Message}");
|
||||
return Ok(new { isAlive = false, error = "Host no alcanzable o desconocido." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error interno al hacer ping a {request.Ip}: {ex.Message}");
|
||||
return StatusCode(500, "Error interno del servidor al realizar el ping.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("wake-on-lan")]
|
||||
public IActionResult EnviarWol([FromBody] WolRequestDto request)
|
||||
{
|
||||
var mac = request.Mac;
|
||||
var ip = request.Ip;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mac) || !Regex.IsMatch(mac, "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"))
|
||||
{
|
||||
return BadRequest("Formato de dirección MAC inválido.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(ip) || !Regex.IsMatch(ip, @"^(\d{1,3}\.){3}\d{1,3}$"))
|
||||
{
|
||||
return BadRequest("Formato de dirección IP inválido.");
|
||||
}
|
||||
|
||||
var octetos = ip.Split('.');
|
||||
if (octetos.Length != 4)
|
||||
{
|
||||
return BadRequest("Formato de dirección IP incorrecto.");
|
||||
}
|
||||
|
||||
var vlanNumber = octetos[2];
|
||||
var interfaceName = $"vlan{vlanNumber}";
|
||||
|
||||
// 3. Leemos los valores desde la configuración en lugar de hardcodearlos
|
||||
var sshHost = _configuration.GetValue<string>("SshSettings:Host");
|
||||
var sshPort = _configuration.GetValue<int>("SshSettings:Port");
|
||||
var sshUser = _configuration.GetValue<string>("SshSettings:User");
|
||||
var sshPass = _configuration.GetValue<string>("SshSettings:Password");
|
||||
|
||||
if (string.IsNullOrEmpty(sshHost) || string.IsNullOrEmpty(sshUser) || string.IsNullOrEmpty(sshPass))
|
||||
{
|
||||
Console.WriteLine("Error: La configuración SSH no está completa en appsettings.json.");
|
||||
return StatusCode(500, "La configuración del servidor SSH está incompleta.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var client = new SshClient(sshHost, sshPort, sshUser, sshPass))
|
||||
{
|
||||
client.Connect();
|
||||
if (client.IsConnected)
|
||||
{
|
||||
var command = $"/usr/sbin/etherwake -b -i {interfaceName} {mac}";
|
||||
var sshCommand = client.CreateCommand(command);
|
||||
sshCommand.Execute();
|
||||
|
||||
Console.WriteLine($"Comando WOL ejecutado: {sshCommand.CommandText}");
|
||||
|
||||
client.Disconnect();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Error: No se pudo conectar al servidor SSH.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error al ejecutar comando WOL: {ex.Message}");
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("manual")]
|
||||
public async Task<IActionResult> CrearEquipoManual([FromBody] CrearEquipoManualDto equipoDto)
|
||||
{
|
||||
var findQuery = "SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname;";
|
||||
var insertQuery = @"
|
||||
INSERT INTO dbo.equipos (Hostname, Ip, Motherboard, Cpu, Os, Sector_id, Origen, Ram_installed, Architecture)
|
||||
VALUES (@Hostname, @Ip, @Motherboard, @Cpu, @Os, @Sector_id, 'manual', 0, '');
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var existente = await connection.QueryFirstOrDefaultAsync<int?>(findQuery, new { equipoDto.Hostname });
|
||||
if (existente.HasValue)
|
||||
{
|
||||
return Conflict($"El hostname '{equipoDto.Hostname}' ya existe.");
|
||||
}
|
||||
|
||||
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, equipoDto);
|
||||
|
||||
await HistorialHelper.RegistrarCambioUnico(_context, nuevoId, "Equipo", null, "Equipo creado manualmente");
|
||||
|
||||
var nuevoEquipo = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = nuevoId });
|
||||
if (nuevoEquipo == null)
|
||||
{
|
||||
return StatusCode(500, "No se pudo recuperar el equipo después de crearlo.");
|
||||
}
|
||||
return CreatedAtAction(nameof(ConsultarDetalle), new { hostname = nuevoEquipo.Hostname }, nuevoEquipo);
|
||||
}
|
||||
}
|
||||
|
||||
// --- ENDPOINTS PARA BORRADO MANUAL DE ASOCIACIONES ---
|
||||
|
||||
[HttpDelete("asociacion/disco/{equipoDiscoId}")]
|
||||
public async Task<IActionResult> BorrarAsociacionDisco(int equipoDiscoId)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var infoQuery = @"
|
||||
SELECT ed.equipo_id, d.Mediatype, d.Size
|
||||
FROM dbo.equipos_discos ed
|
||||
JOIN dbo.discos d ON ed.disco_id = d.id
|
||||
WHERE ed.Id = @EquipoDiscoId AND ed.Origen = 'manual'";
|
||||
var info = await connection.QueryFirstOrDefaultAsync<(int equipo_id, string Mediatype, int Size)>(infoQuery, new { EquipoDiscoId = equipoDiscoId });
|
||||
|
||||
if (info == default)
|
||||
{
|
||||
return NotFound("Asociación de disco no encontrada o no es manual.");
|
||||
}
|
||||
|
||||
var deleteQuery = "DELETE FROM dbo.equipos_discos WHERE Id = @EquipoDiscoId;";
|
||||
await connection.ExecuteAsync(deleteQuery, new { EquipoDiscoId = equipoDiscoId });
|
||||
|
||||
var descripcion = $"Disco {info.Mediatype} {info.Size}GB";
|
||||
await HistorialHelper.RegistrarCambioUnico(_context, info.equipo_id, "Componente", descripcion, "Eliminado");
|
||||
|
||||
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = info.equipo_id });
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("asociacion/ram/{equipoMemoriaRamId}")]
|
||||
public async Task<IActionResult> BorrarAsociacionRam(int equipoMemoriaRamId)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var infoQuery = @"
|
||||
SELECT emr.equipo_id, emr.Slot, mr.Fabricante, mr.Tamano, mr.Velocidad
|
||||
FROM dbo.equipos_memorias_ram emr
|
||||
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
|
||||
WHERE emr.Id = @Id AND emr.Origen = 'manual'";
|
||||
var info = await connection.QueryFirstOrDefaultAsync<(int equipo_id, string Slot, string? Fabricante, int Tamano, int? Velocidad)>(infoQuery, new { Id = equipoMemoriaRamId });
|
||||
|
||||
if (info == default)
|
||||
{
|
||||
return NotFound("Asociación de RAM no encontrada o no es manual.");
|
||||
}
|
||||
|
||||
var deleteQuery = "DELETE FROM dbo.equipos_memorias_ram WHERE Id = @EquipoMemoriaRamId;";
|
||||
await connection.ExecuteAsync(deleteQuery, new { EquipoMemoriaRamId = equipoMemoriaRamId });
|
||||
|
||||
var descripcion = $"Módulo RAM: Slot {info.Slot} - {info.Fabricante ?? ""} {info.Tamano}GB {info.Velocidad?.ToString() ?? ""}MHz";
|
||||
await HistorialHelper.RegistrarCambioUnico(_context, info.equipo_id, "Componente", descripcion, "Eliminado");
|
||||
|
||||
var updateQuery = @"
|
||||
UPDATE e
|
||||
SET
|
||||
e.ram_installed = ISNULL((SELECT SUM(mr.Tamano)
|
||||
FROM dbo.equipos_memorias_ram emr
|
||||
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
|
||||
WHERE emr.equipo_id = @Id), 0),
|
||||
e.updated_at = GETDATE()
|
||||
FROM dbo.equipos e
|
||||
WHERE e.Id = @Id;";
|
||||
|
||||
await connection.ExecuteAsync(updateQuery, new { Id = info.equipo_id });
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("asociacion/usuario/{equipoId}/{usuarioId}")]
|
||||
public async Task<IActionResult> BorrarAsociacionUsuario(int equipoId, int usuarioId)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var username = await connection.QuerySingleOrDefaultAsync<string>("SELECT Username FROM dbo.usuarios WHERE Id = @UsuarioId", new { UsuarioId = usuarioId });
|
||||
if (username == null)
|
||||
{
|
||||
return NotFound("Usuario no encontrado.");
|
||||
}
|
||||
|
||||
var deleteQuery = "DELETE FROM dbo.usuarios_equipos WHERE equipo_id = @EquipoId AND usuario_id = @UsuarioId AND Origen = 'manual';";
|
||||
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { EquipoId = equipoId, UsuarioId = usuarioId });
|
||||
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Asociación de usuario no encontrada o no es manual.");
|
||||
}
|
||||
|
||||
var descripcion = $"Usuario {username}";
|
||||
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", descripcion, "Eliminado");
|
||||
|
||||
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId });
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("manual/{id}")]
|
||||
public async Task<IActionResult> ActualizarEquipoManual(int id, [FromBody] EditarEquipoManualDto equipoDto)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipoActual = await connection.QuerySingleOrDefaultAsync<Equipo>("SELECT * FROM dbo.equipos WHERE Id = @Id", new { Id = id });
|
||||
if (equipoActual == null)
|
||||
{
|
||||
return NotFound("El equipo no existe.");
|
||||
}
|
||||
if (equipoActual.Origen != "manual")
|
||||
{
|
||||
return Forbid("No se puede modificar un equipo generado automáticamente.");
|
||||
}
|
||||
|
||||
if (equipoActual.Hostname != equipoDto.Hostname)
|
||||
{
|
||||
var hostExistente = await connection.QuerySingleOrDefaultAsync<int?>("SELECT Id FROM dbo.equipos WHERE Hostname = @Hostname AND Id != @Id", new { equipoDto.Hostname, Id = id });
|
||||
if (hostExistente.HasValue)
|
||||
{
|
||||
return Conflict($"El hostname '{equipoDto.Hostname}' ya está en uso por otro equipo.");
|
||||
}
|
||||
}
|
||||
|
||||
var allSectores = await connection.QueryAsync<Sector>("SELECT Id, Nombre FROM dbo.sectores;");
|
||||
var sectorMap = allSectores.ToDictionary(s => s.Id, s => s.Nombre);
|
||||
|
||||
var cambios = new Dictionary<string, (string? anterior, string? nuevo)>();
|
||||
|
||||
if (equipoActual.Hostname != equipoDto.Hostname) cambios["Hostname"] = (equipoActual.Hostname, equipoDto.Hostname);
|
||||
if (equipoActual.Ip != equipoDto.Ip) cambios["IP"] = (equipoActual.Ip, equipoDto.Ip);
|
||||
if (equipoActual.Mac != equipoDto.Mac) cambios["MAC Address"] = (equipoActual.Mac ?? "N/A", equipoDto.Mac ?? "N/A");
|
||||
if (equipoActual.Motherboard != equipoDto.Motherboard) cambios["Motherboard"] = (equipoActual.Motherboard ?? "N/A", equipoDto.Motherboard ?? "N/A");
|
||||
if (equipoActual.Cpu != equipoDto.Cpu) cambios["CPU"] = (equipoActual.Cpu ?? "N/A", equipoDto.Cpu ?? "N/A");
|
||||
if (equipoActual.Os != equipoDto.Os) cambios["Sistema Operativo"] = (equipoActual.Os ?? "N/A", equipoDto.Os ?? "N/A");
|
||||
if (equipoActual.Architecture != equipoDto.Architecture) cambios["Arquitectura"] = (equipoActual.Architecture ?? "N/A", equipoDto.Architecture ?? "N/A");
|
||||
if (equipoActual.Ram_slots != equipoDto.Ram_slots) cambios["Slots RAM"] = (equipoActual.Ram_slots?.ToString() ?? "N/A", equipoDto.Ram_slots?.ToString() ?? "N/A");
|
||||
|
||||
if (equipoActual.Sector_id != equipoDto.Sector_id)
|
||||
{
|
||||
string nombreAnterior = equipoActual.Sector_id.HasValue && sectorMap.TryGetValue(equipoActual.Sector_id.Value, out var oldName)
|
||||
? oldName
|
||||
: "Ninguno";
|
||||
string nombreNuevo = equipoDto.Sector_id.HasValue && sectorMap.TryGetValue(equipoDto.Sector_id.Value, out var newName)
|
||||
? newName
|
||||
: "Ninguno";
|
||||
cambios["Sector"] = (nombreAnterior, nombreNuevo);
|
||||
}
|
||||
|
||||
var updateQuery = @"UPDATE dbo.equipos SET
|
||||
Hostname = @Hostname,
|
||||
Ip = @Ip,
|
||||
Mac = @Mac,
|
||||
Motherboard = @Motherboard,
|
||||
Cpu = @Cpu,
|
||||
Os = @Os,
|
||||
Sector_id = @Sector_id,
|
||||
Ram_slots = @Ram_slots,
|
||||
Architecture = @Architecture, -- Campo añadido a la actualización
|
||||
updated_at = GETDATE()
|
||||
OUTPUT INSERTED.*
|
||||
WHERE Id = @Id AND Origen = 'manual';";
|
||||
|
||||
var equipoActualizado = await connection.QuerySingleOrDefaultAsync<Equipo>(updateQuery, new
|
||||
{
|
||||
equipoDto.Hostname,
|
||||
equipoDto.Ip,
|
||||
mac = equipoDto.Mac,
|
||||
equipoDto.Motherboard,
|
||||
equipoDto.Cpu,
|
||||
equipoDto.Os,
|
||||
equipoDto.Sector_id,
|
||||
equipoDto.Ram_slots,
|
||||
equipoDto.Architecture,
|
||||
Id = id
|
||||
});
|
||||
|
||||
if (equipoActualizado == null)
|
||||
{
|
||||
return StatusCode(500, "No se pudo actualizar el equipo.");
|
||||
}
|
||||
|
||||
if (cambios.Count > 0)
|
||||
{
|
||||
await HistorialHelper.RegistrarCambios(_context, id, cambios);
|
||||
}
|
||||
|
||||
var equipoCompleto = await ConsultarDetalle(equipoActualizado.Hostname);
|
||||
|
||||
return equipoCompleto;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("manual/{equipoId}/disco")]
|
||||
public async Task<IActionResult> AsociarDiscoManual(int equipoId, [FromBody] AsociarDiscoManualDto dto)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
||||
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||
|
||||
var discoMaestro = await connection.QueryFirstOrDefaultAsync<Disco>("SELECT * FROM dbo.discos WHERE Mediatype = @Mediatype AND Size = @Size", dto);
|
||||
int discoId;
|
||||
if (discoMaestro == null)
|
||||
{
|
||||
discoId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.discos (Mediatype, Size) VALUES (@Mediatype, @Size); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
|
||||
}
|
||||
else
|
||||
{
|
||||
discoId = discoMaestro.Id;
|
||||
}
|
||||
|
||||
var asociacionQuery = "INSERT INTO dbo.equipos_discos (equipo_id, disco_id, origen) VALUES (@EquipoId, @DiscoId, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, DiscoId = discoId });
|
||||
|
||||
var descripcion = $"Disco {dto.Mediatype} {dto.Size}GB";
|
||||
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}");
|
||||
|
||||
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId });
|
||||
|
||||
return Ok(new { message = "Disco asociado manualmente.", equipoDiscoId = nuevaAsociacionId });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("manual/{equipoId}/ram")]
|
||||
public async Task<IActionResult> AsociarRamManual(int equipoId, [FromBody] AsociarRamManualDto dto)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
||||
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||
int ramId;
|
||||
var ramMaestra = await connection.QueryFirstOrDefaultAsync<MemoriaRam>(
|
||||
"SELECT * FROM dbo.memorias_ram WHERE (Fabricante = @Fabricante OR (Fabricante IS NULL AND @Fabricante IS NULL)) AND Tamano = @Tamano AND (Velocidad = @Velocidad OR (Velocidad IS NULL AND @Velocidad IS NULL))", dto);
|
||||
|
||||
if (ramMaestra == null)
|
||||
{
|
||||
var insertQuery = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad)
|
||||
VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
ramId = await connection.ExecuteScalarAsync<int>(insertQuery, dto);
|
||||
}
|
||||
else
|
||||
{
|
||||
ramId = ramMaestra.Id;
|
||||
}
|
||||
|
||||
var asociacionQuery = "INSERT INTO dbo.equipos_memorias_ram (equipo_id, memoria_ram_id, slot, origen) VALUES (@EquipoId, @RamId, @Slot, 'manual'); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
var nuevaAsociacionId = await connection.ExecuteScalarAsync<int>(asociacionQuery, new { EquipoId = equipoId, RamId = ramId, dto.Slot });
|
||||
|
||||
var descripcion = $"Módulo RAM: Slot {dto.Slot} - {dto.Fabricante ?? ""} {dto.Tamano}GB {dto.Velocidad?.ToString() ?? ""}MHz";
|
||||
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}");
|
||||
|
||||
var updateQuery = @"
|
||||
UPDATE e SET
|
||||
e.ram_installed = ISNULL((SELECT SUM(mr.Tamano)
|
||||
FROM dbo.equipos_memorias_ram emr
|
||||
JOIN dbo.memorias_ram mr ON emr.memoria_ram_id = mr.id
|
||||
WHERE emr.equipo_id = @Id), 0),
|
||||
e.updated_at = GETDATE()
|
||||
FROM dbo.equipos e
|
||||
WHERE e.Id = @Id;";
|
||||
await connection.ExecuteAsync(updateQuery, new { Id = equipoId });
|
||||
|
||||
return Ok(new { message = "RAM asociada manualmente.", equipoMemoriaRamId = nuevaAsociacionId });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("manual/{equipoId}/usuario")]
|
||||
public async Task<IActionResult> AsociarUsuarioManual(int equipoId, [FromBody] AsociarUsuarioManualDto dto)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var equipo = await connection.QueryFirstOrDefaultAsync<Equipo>("SELECT Origen FROM dbo.equipos WHERE Id = @Id", new { Id = equipoId });
|
||||
if (equipo == null || equipo.Origen != "manual") return Forbid("Solo se pueden añadir componentes a equipos manuales.");
|
||||
|
||||
int usuarioId;
|
||||
var usuario = await connection.QueryFirstOrDefaultAsync<Usuario>("SELECT * FROM dbo.usuarios WHERE Username = @Username", dto);
|
||||
if (usuario == null)
|
||||
{
|
||||
usuarioId = await connection.ExecuteScalarAsync<int>("INSERT INTO dbo.usuarios (Username) VALUES (@Username); SELECT CAST(SCOPE_IDENTITY() as int);", dto);
|
||||
}
|
||||
else
|
||||
{
|
||||
usuarioId = usuario.Id;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var asociacionQuery = "INSERT INTO dbo.usuarios_equipos (equipo_id, usuario_id, origen) VALUES (@EquipoId, @UsuarioId, 'manual');";
|
||||
await connection.ExecuteAsync(asociacionQuery, new { EquipoId = equipoId, UsuarioId = usuarioId });
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 2627)
|
||||
{
|
||||
return Conflict("El usuario ya está asociado a este equipo.");
|
||||
}
|
||||
|
||||
var descripcion = $"Usuario {dto.Username}";
|
||||
await HistorialHelper.RegistrarCambioUnico(_context, equipoId, "Componente", null, $"Añadido: {descripcion}");
|
||||
|
||||
await connection.ExecuteAsync("UPDATE dbo.equipos SET updated_at = GETDATE() WHERE Id = @Id", new { Id = equipoId });
|
||||
|
||||
return Ok(new { message = "Usuario asociado manualmente." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("distinct/{fieldName}")]
|
||||
public async Task<IActionResult> GetDistinctFieldValues(string fieldName)
|
||||
{
|
||||
// 1. Lista blanca de campos permitidos para evitar inyección SQL y exposición de datos.
|
||||
var allowedFields = new List<string> { "os", "cpu", "motherboard", "architecture" };
|
||||
|
||||
if (!allowedFields.Contains(fieldName.ToLower()))
|
||||
{
|
||||
return BadRequest("El campo especificado no es válido o no está permitido.");
|
||||
}
|
||||
|
||||
// 2. Construir la consulta de forma segura
|
||||
var query = $"SELECT DISTINCT {fieldName} FROM dbo.equipos WHERE {fieldName} IS NOT NULL AND {fieldName} != '' ORDER BY {fieldName};";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var values = await connection.QueryAsync<string>(query);
|
||||
return Ok(values);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs locales para las peticiones
|
||||
public class PingRequestDto { public string? Ip { get; set; } }
|
||||
|
||||
public class WolRequestDto
|
||||
{
|
||||
public string? Mac { get; set; }
|
||||
public string? Ip { get; set; }
|
||||
}
|
||||
|
||||
class DiscoAsociado
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Mediatype { get; set; } = "";
|
||||
public int Size { get; set; }
|
||||
public int EquipoDiscoId { get; set; }
|
||||
}
|
||||
|
||||
public class CrearEquipoManualDto
|
||||
{
|
||||
public required string Hostname { get; set; }
|
||||
public required string Ip { get; set; }
|
||||
public string? Motherboard { get; set; }
|
||||
public string? Cpu { get; set; }
|
||||
public string? Os { get; set; }
|
||||
public int? Sector_id { get; set; }
|
||||
}
|
||||
|
||||
public class EditarEquipoManualDto
|
||||
{
|
||||
public required string Hostname { get; set; }
|
||||
public required string Ip { get; set; }
|
||||
public string? Mac { get; set; }
|
||||
public string? Motherboard { get; set; }
|
||||
public string? Cpu { get; set; }
|
||||
public string? Os { get; set; }
|
||||
public int? Sector_id { get; set; }
|
||||
public int? Ram_slots { get; set; }
|
||||
public string? Architecture { get; set; }
|
||||
}
|
||||
|
||||
public class AsociarDiscoManualDto
|
||||
{
|
||||
public required string Mediatype { get; set; }
|
||||
public int Size { get; set; }
|
||||
}
|
||||
|
||||
public class AsociarRamManualDto
|
||||
{
|
||||
public required string Slot { get; set; }
|
||||
public int Tamano { get; set; }
|
||||
public string? Fabricante { get; set; }
|
||||
public int? Velocidad { get; set; }
|
||||
public string? PartNumber { get; set; }
|
||||
}
|
||||
|
||||
public class AsociarUsuarioManualDto
|
||||
{
|
||||
public required string Username { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
171
backend/Controllers/MemoriasRamController.cs
Normal file
171
backend/Controllers/MemoriasRamController.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
// backend/Controllers/MemoriasRamController.cs
|
||||
|
||||
using Dapper;
|
||||
using Inventario.API.Data;
|
||||
using Inventario.API.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Inventario.API.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class MemoriasRamController : ControllerBase
|
||||
{
|
||||
private readonly DapperContext _context;
|
||||
|
||||
public MemoriasRamController(DapperContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Consultar()
|
||||
{
|
||||
var query = @"
|
||||
SELECT
|
||||
MIN(Id) as Id,
|
||||
MIN(part_number) as PartNumber,
|
||||
Fabricante,
|
||||
Tamano,
|
||||
Velocidad
|
||||
FROM
|
||||
dbo.memorias_ram
|
||||
WHERE
|
||||
Fabricante IS NOT NULL AND Fabricante != ''
|
||||
GROUP BY
|
||||
Fabricante,
|
||||
Tamano,
|
||||
Velocidad
|
||||
ORDER BY
|
||||
Fabricante, Tamano, Velocidad;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var memorias = await connection.QueryAsync<MemoriaRam>(query);
|
||||
return Ok(memorias);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> ConsultarDetalle(int id)
|
||||
{
|
||||
var query = "SELECT Id, part_number as PartNumber, Fabricante, Tamano, Velocidad FROM dbo.memorias_ram WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var memoria = await connection.QuerySingleOrDefaultAsync<MemoriaRam>(query, new { Id = id });
|
||||
if (memoria == null)
|
||||
{
|
||||
return NotFound("Módulo de memoria RAM no encontrado.");
|
||||
}
|
||||
return Ok(memoria);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Ingresar([FromBody] List<MemoriaRam> memorias)
|
||||
{
|
||||
var queryCheck = @"SELECT * FROM dbo.memorias_ram WHERE
|
||||
(part_number = @PartNumber OR (part_number IS NULL AND @PartNumber IS NULL)) AND
|
||||
(fabricante = @Fabricante OR (fabricante IS NULL AND @Fabricante IS NULL)) AND
|
||||
tamano = @Tamano AND
|
||||
(velocidad = @Velocidad OR (velocidad IS NULL AND @Velocidad IS NULL));";
|
||||
|
||||
var queryInsert = @"INSERT INTO dbo.memorias_ram (part_number, fabricante, tamano, velocidad)
|
||||
VALUES (@PartNumber, @Fabricante, @Tamano, @Velocidad);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
var resultados = new List<object>();
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
foreach (var memoria in memorias)
|
||||
{
|
||||
var existente = await connection.QuerySingleOrDefaultAsync<MemoriaRam>(queryCheck, memoria);
|
||||
|
||||
if (existente == null)
|
||||
{
|
||||
var nuevoId = await connection.ExecuteScalarAsync<int>(queryInsert, memoria);
|
||||
memoria.Id = nuevoId;
|
||||
resultados.Add(new { action = "created", registro = memoria });
|
||||
}
|
||||
else
|
||||
{
|
||||
resultados.Add(new { action = "exists", registro = existente });
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(resultados);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Actualizar(int id, [FromBody] MemoriaRam memoria)
|
||||
{
|
||||
var query = @"UPDATE dbo.memorias_ram SET
|
||||
part_number = @PartNumber,
|
||||
fabricante = @Fabricante,
|
||||
tamano = @Tamano,
|
||||
velocidad = @Velocidad
|
||||
WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { memoria.PartNumber, memoria.Fabricante, memoria.Tamano, memoria.Velocidad, Id = id });
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Módulo de memoria RAM no encontrado.");
|
||||
}
|
||||
|
||||
memoria.Id = id;
|
||||
return Ok(memoria);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Borrar(int id)
|
||||
{
|
||||
var deleteAssociationsQuery = "DELETE FROM dbo.equipos_memorias_ram WHERE memoria_ram_id = @Id;";
|
||||
var deleteRamQuery = "DELETE FROM dbo.memorias_ram WHERE Id = @Id;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
connection.Open();
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(deleteAssociationsQuery, new { Id = id }, transaction: transaction);
|
||||
var filasAfectadas = await connection.ExecuteAsync(deleteRamQuery, new { Id = id }, transaction: transaction);
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
transaction.Rollback();
|
||||
return NotFound("Módulo de memoria RAM no encontrado.");
|
||||
}
|
||||
transaction.Commit();
|
||||
return NoContent();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("buscar/{termino}")]
|
||||
public async Task<IActionResult> BuscarMemoriasRam(string termino)
|
||||
{
|
||||
var query = @"SELECT Id, part_number as PartNumber, Fabricante, Tamano, Velocidad
|
||||
FROM dbo.memorias_ram
|
||||
WHERE Fabricante LIKE @SearchTerm OR part_number LIKE @SearchTerm
|
||||
ORDER BY Fabricante, Tamano;";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var memorias = await connection.QueryAsync<MemoriaRam>(query, new { SearchTerm = $"%{termino}%" });
|
||||
return Ok(memorias);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,137 @@
|
||||
// backend/Controllers/SectoresController.cs
|
||||
|
||||
using Dapper;
|
||||
using Inventario.API.Data;
|
||||
using Inventario.API.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Inventario.API.Controllers
|
||||
{
|
||||
// [ApiController] habilita comportamientos estándar de API como la validación automática.
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
// [Route("api/[controller]")] define la URL base para este controlador.
|
||||
// "[controller]" se reemplaza por el nombre de la clase sin "Controller", es decir, "api/sectores".
|
||||
[Route("api/[controller]")]
|
||||
public class SectoresController : ControllerBase
|
||||
{
|
||||
// Campo privado para guardar la referencia a nuestro contexto de Dapper.
|
||||
private readonly DapperContext _context;
|
||||
|
||||
// El constructor recibe el DapperContext a través de la inyección de dependencias que configuramos en Program.cs.
|
||||
public SectoresController(DapperContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// [HttpGet] marca este método para que responda a peticiones GET a la ruta base ("api/sectores").
|
||||
// --- GET /api/sectores ---
|
||||
// Método para obtener todos los sectores.
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ConsultarSectores()
|
||||
{
|
||||
// Definimos nuestra consulta SQL. Es buena práctica listar las columnas explícitamente.
|
||||
var query = "SELECT Id, Nombre FROM dbo.sectores ORDER BY Nombre;";
|
||||
|
||||
// Creamos una conexión a la base de datos. El 'using' asegura que la conexión se cierre y se libere correctamente, incluso si hay errores.
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
// Usamos el método QueryAsync de Dapper.
|
||||
// <Sector> le dice a Dapper que mapee cada fila del resultado a un objeto de nuestra clase Sector.
|
||||
// 'await' espera a que la base de datos responda sin bloquear el servidor.
|
||||
var sectores = await connection.QueryAsync<Sector>(query);
|
||||
|
||||
// Ok() crea una respuesta HTTP 200 OK y serializa la lista de sectores a formato JSON.
|
||||
return Ok(sectores);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/sectores/{id} ---
|
||||
// Método para obtener un único sector por su ID.
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> ConsultarSectorDetalle(int id)
|
||||
{
|
||||
var query = "SELECT Id, Nombre FROM dbo.sectores WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
// QuerySingleOrDefaultAsync es perfecto para obtener un solo registro.
|
||||
// Devuelve el objeto si lo encuentra, o null si no existe.
|
||||
var sector = await connection.QuerySingleOrDefaultAsync<Sector>(query, new { Id = id });
|
||||
|
||||
if (sector == null)
|
||||
{
|
||||
// Si no se encuentra, devolvemos un error HTTP 404 Not Found.
|
||||
return NotFound();
|
||||
}
|
||||
return Ok(sector);
|
||||
}
|
||||
}
|
||||
|
||||
// --- POST /api/sectores ---
|
||||
// Método para crear un nuevo sector.
|
||||
// Nota: Cambiado de /:nombre a un POST estándar que recibe el objeto en el body. Es más RESTful.
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> IngresarSector([FromBody] Sector sector)
|
||||
{
|
||||
var checkQuery = "SELECT COUNT(1) FROM dbo.sectores WHERE Nombre = @Nombre;";
|
||||
var insertQuery = "INSERT INTO dbo.sectores (Nombre) VALUES (@Nombre); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
// Primero, verificamos si ya existe un sector con ese nombre.
|
||||
var existe = await connection.ExecuteScalarAsync<bool>(checkQuery, new { sector.Nombre });
|
||||
if (existe)
|
||||
{
|
||||
// Si ya existe, devolvemos un error HTTP 409 Conflict.
|
||||
return Conflict($"Ya existe un sector con el nombre '{sector.Nombre}'");
|
||||
}
|
||||
|
||||
// ExecuteScalarAsync ejecuta la consulta y devuelve el primer valor de la primera fila (el nuevo ID).
|
||||
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, new { sector.Nombre });
|
||||
var nuevoSector = new Sector { Id = nuevoId, Nombre = sector.Nombre };
|
||||
|
||||
// Devolvemos una respuesta HTTP 201 Created, con la ubicación del nuevo recurso y el objeto creado.
|
||||
return CreatedAtAction(nameof(ConsultarSectorDetalle), new { id = nuevoId }, nuevoSector);
|
||||
}
|
||||
}
|
||||
|
||||
// --- PUT /api/sectores/{id} ---
|
||||
// Método para actualizar un sector existente.
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> ActualizarSector(int id, [FromBody] Sector sector)
|
||||
{
|
||||
var query = "UPDATE dbo.sectores SET Nombre = @Nombre WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
// ExecuteAsync devuelve el número de filas afectadas.
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { Nombre = sector.Nombre, Id = id });
|
||||
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
// Si no se afectó ninguna fila, significa que el ID no existía.
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Devolvemos HTTP 204 No Content para indicar que la actualización fue exitosa pero no hay nada que devolver.
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// --- DELETE /api/sectores/{id} ---
|
||||
// Método para eliminar un sector.
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> BorrarSector(int id)
|
||||
{
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
// 1. VERIFICAR SI EL SECTOR ESTÁ EN USO
|
||||
var usageQuery = "SELECT COUNT(1) FROM dbo.equipos WHERE sector_id = @Id;";
|
||||
var usageCount = await connection.ExecuteScalarAsync<int>(usageQuery, new { Id = id });
|
||||
|
||||
if (usageCount > 0)
|
||||
{
|
||||
// 2. DEVOLVER HTTP 409 CONFLICT SI ESTÁ EN USO
|
||||
return Conflict(new { message = $"No se puede eliminar. Hay {usageCount} equipo(s) asignados a este sector." });
|
||||
}
|
||||
|
||||
// 3. SI NO ESTÁ EN USO, PROCEDER CON LA ELIMINACIÓN
|
||||
var deleteQuery = "DELETE FROM dbo.sectores WHERE Id = @Id;";
|
||||
var filasAfectadas = await connection.ExecuteAsync(deleteQuery, new { Id = id });
|
||||
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
backend/Controllers/UsuariosController.cs
Normal file
143
backend/Controllers/UsuariosController.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using Dapper;
|
||||
using Inventario.API.Data;
|
||||
using Inventario.API.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Inventario.API.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class UsuariosController : ControllerBase
|
||||
{
|
||||
private readonly DapperContext _context;
|
||||
|
||||
public UsuariosController(DapperContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// --- GET /api/usuarios ---
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Consultar()
|
||||
{
|
||||
var query = "SELECT Id, Username, Password, Created_at, Updated_at FROM dbo.usuarios ORDER BY Username;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var usuarios = await connection.QueryAsync<Usuario>(query);
|
||||
return Ok(usuarios);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/usuarios/{id} ---
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> ConsultarDetalle(int id)
|
||||
{
|
||||
var query = "SELECT Id, Username, Password, Created_at, Updated_at FROM dbo.usuarios WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var usuario = await connection.QuerySingleOrDefaultAsync<Usuario>(query, new { Id = id });
|
||||
if (usuario == null)
|
||||
{
|
||||
return NotFound("Usuario no encontrado.");
|
||||
}
|
||||
return Ok(usuario);
|
||||
}
|
||||
}
|
||||
|
||||
// --- POST /api/usuarios ---
|
||||
// Este endpoint replica la lógica "upsert" del original: si el usuario existe, lo actualiza; si no, lo crea.
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Ingresar([FromBody] Usuario usuario)
|
||||
{
|
||||
var findQuery = "SELECT * FROM dbo.usuarios WHERE Username = @Username;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var usuarioExistente = await connection.QuerySingleOrDefaultAsync<Usuario>(findQuery, new { usuario.Username });
|
||||
|
||||
if (usuarioExistente != null)
|
||||
{
|
||||
// El usuario ya existe.
|
||||
// SOLO actualizamos la contraseña si se proporciona una nueva en el body de la petición.
|
||||
if (!string.IsNullOrEmpty(usuario.Password))
|
||||
{
|
||||
var updateQuery = "UPDATE dbo.usuarios SET Password = @Password, updated_at = GETDATE() WHERE Id = @Id;";
|
||||
await connection.ExecuteAsync(updateQuery, new { usuario.Password, Id = usuarioExistente.Id });
|
||||
}
|
||||
// Si no se envía contraseña, simplemente no hacemos nada y el valor en la BD se conserva.
|
||||
|
||||
// Devolvemos el usuario que ya existe (con o sin la contraseña actualizada)
|
||||
var usuarioActualizado = await connection.QuerySingleOrDefaultAsync<Usuario>(findQuery, new { usuario.Username });
|
||||
return Ok(usuarioActualizado);
|
||||
}
|
||||
else
|
||||
{
|
||||
// El usuario no existe, lo creamos
|
||||
var insertQuery = "INSERT INTO dbo.usuarios (Username, Password) VALUES (@Username, @Password); SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
var nuevoId = await connection.ExecuteScalarAsync<int>(insertQuery, new { usuario.Username, usuario.Password });
|
||||
|
||||
var nuevoUsuario = new Usuario
|
||||
{
|
||||
Id = nuevoId,
|
||||
Username = usuario.Username,
|
||||
Password = usuario.Password
|
||||
};
|
||||
return CreatedAtAction(nameof(ConsultarDetalle), new { id = nuevoId }, nuevoUsuario);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- PUT /api/usuarios/{id} ---
|
||||
// Endpoint específico para actualizar la contraseña, como en el original.
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Actualizar(int id, [FromBody] Usuario data)
|
||||
{
|
||||
var updateQuery = "UPDATE dbo.usuarios SET Password = @Password WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(updateQuery, new { data.Password, Id = id });
|
||||
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Usuario no encontrado.");
|
||||
}
|
||||
|
||||
// Para replicar la respuesta del backend original, volvemos a consultar el usuario (sin la contraseña).
|
||||
var selectQuery = "SELECT Id, Username FROM dbo.usuarios WHERE Id = @Id;";
|
||||
var usuarioActualizado = await connection.QuerySingleOrDefaultAsync(selectQuery, new { Id = id });
|
||||
|
||||
return Ok(usuarioActualizado);
|
||||
}
|
||||
}
|
||||
|
||||
// --- DELETE /api/usuarios/{id} ---
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Borrar(int id)
|
||||
{
|
||||
var query = "DELETE FROM dbo.usuarios WHERE Id = @Id;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var filasAfectadas = await connection.ExecuteAsync(query, new { Id = id });
|
||||
if (filasAfectadas == 0)
|
||||
{
|
||||
return NotFound("Usuario no encontrado.");
|
||||
}
|
||||
return NoContent(); // Respuesta HTTP 204 No Content
|
||||
}
|
||||
}
|
||||
|
||||
// --- GET /api/usuarios/buscar/{termino} ---
|
||||
[HttpGet("buscar/{termino}")]
|
||||
public async Task<IActionResult> BuscarUsuarios(string termino)
|
||||
{
|
||||
// Usamos LIKE para una búsqueda flexible. El '%' son comodines.
|
||||
var query = "SELECT Username FROM dbo.usuarios WHERE Username LIKE @SearchTerm ORDER BY Username;";
|
||||
using (var connection = _context.CreateConnection())
|
||||
{
|
||||
var usuarios = await connection.QueryAsync<string>(query, new { SearchTerm = $"%{termino}%" });
|
||||
return Ok(usuarios);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
backend/DTOs/AsociacionUsuarioDto.cs
Normal file
8
backend/DTOs/AsociacionUsuarioDto.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Inventario.API.DTOs
|
||||
{
|
||||
public class AsociacionUsuarioDto
|
||||
{
|
||||
// Usamos 'required' para asegurar que este campo nunca sea nulo al recibirlo.
|
||||
public required string Username { get; set; }
|
||||
}
|
||||
}
|
||||
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
# --- Etapa 1: Build ---
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copiamos solo los archivos .csproj para restaurar los paquetes.
|
||||
# La ruta es correcta porque el contexto es la raíz del proyecto.
|
||||
COPY ["backend/Inventario.API.csproj", "backend/"]
|
||||
RUN dotnet restore "backend/Inventario.API.csproj"
|
||||
|
||||
# Copiamos el resto de los archivos del proyecto.
|
||||
# Gracias al .dockerignore, las carpetas 'bin' y 'obj' locales no se copiarán.
|
||||
COPY . .
|
||||
|
||||
# Nos movemos al directorio del proyecto para publicarlo
|
||||
WORKDIR "/src/backend"
|
||||
RUN dotnet publish "Inventario.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# --- Etapa 2: Final ---
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# El puerto por defecto que exponen los contenedores de .NET es 8080
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "Inventario.API.dll"]
|
||||
38
backend/Helpers/HistorialHelper.cs
Normal file
38
backend/Helpers/HistorialHelper.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
// backend/Helpers/HistorialHelper.cs
|
||||
using Dapper;
|
||||
using Inventario.API.Data;
|
||||
|
||||
namespace Inventario.API.Helpers
|
||||
{
|
||||
public static class HistorialHelper
|
||||
{
|
||||
public static async Task RegistrarCambios(DapperContext context, int equipoId, Dictionary<string, (string? anterior, string? nuevo)> cambios)
|
||||
{
|
||||
var query = @"INSERT INTO dbo.historial_equipos (equipo_id, campo_modificado, valor_anterior, valor_nuevo)
|
||||
VALUES (@EquipoId, @CampoModificado, @ValorAnterior, @ValorNuevo);";
|
||||
|
||||
using (var connection = context.CreateConnection())
|
||||
{
|
||||
foreach (var cambio in cambios)
|
||||
{
|
||||
await connection.ExecuteAsync(query, new
|
||||
{
|
||||
EquipoId = equipoId,
|
||||
CampoModificado = cambio.Key,
|
||||
ValorAnterior = cambio.Value.anterior,
|
||||
ValorNuevo = cambio.Value.nuevo
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task RegistrarCambioUnico(DapperContext context, int equipoId, string campo, string? valorAnterior, string? valorNuevo)
|
||||
{
|
||||
var cambio = new Dictionary<string, (string?, string?)>
|
||||
{
|
||||
{ campo, (valorAnterior, valorNuevo) }
|
||||
};
|
||||
await RegistrarCambios(context, equipoId, cambio);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
|
||||
@@ -15,6 +16,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
|
||||
<PackageReference Include="SSH.NET" Version="2025.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// backend/Models/Equipo.cs
|
||||
namespace Inventario.API.Models
|
||||
{
|
||||
public class Equipo
|
||||
@@ -5,22 +6,40 @@ namespace Inventario.API.Models
|
||||
public int Id { get; set; }
|
||||
public string Hostname { get; set; } = string.Empty;
|
||||
public string Ip { get; set; } = string.Empty;
|
||||
public string? Mac { get; set; } // Mac puede ser nulo, así que usamos string?
|
||||
public string? Mac { get; set; }
|
||||
public string Motherboard { get; set; } = string.Empty;
|
||||
public string Cpu { get; set; } = string.Empty;
|
||||
public int Ram_installed { get; set; }
|
||||
public int? Ram_slots { get; set; } // Puede ser nulo
|
||||
public int? Ram_slots { get; set; }
|
||||
public string Os { get; set; } = string.Empty;
|
||||
public string Architecture { get; set; } = string.Empty;
|
||||
public DateTime Created_at { get; set; }
|
||||
public DateTime Updated_at { get; set; }
|
||||
public int? Sector_id { get; set; } // Puede ser nulo
|
||||
public int? Sector_id { get; set; }
|
||||
public string Origen { get; set; } = "automatica";
|
||||
|
||||
// Propiedades de navegación (no mapeadas directamente a la BD)
|
||||
public Sector? Sector { get; set; }
|
||||
public List<Usuario> Usuarios { get; set; } = new();
|
||||
public List<Disco> Discos { get; set; } = new();
|
||||
public List<MemoriaRamDetalle> MemoriasRam { get; set; } = new();
|
||||
public List<UsuarioEquipoDetalle> Usuarios { get; set; } = new();
|
||||
public List<DiscoDetalle> Discos { get; set; } = new();
|
||||
public List<MemoriaRamEquipoDetalle> MemoriasRam { get; set; } = new();
|
||||
public List<HistorialEquipo> Historial { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DiscoDetalle : Disco
|
||||
{
|
||||
public string Origen { get; set; } = "manual";
|
||||
public int EquipoDiscoId { get; set; }
|
||||
}
|
||||
|
||||
public class MemoriaRamEquipoDetalle : MemoriaRam
|
||||
{
|
||||
public string Slot { get; set; } = string.Empty;
|
||||
public string Origen { get; set; } = "manual";
|
||||
public int EquipoMemoriaRamId { get; set; }
|
||||
}
|
||||
|
||||
public class UsuarioEquipoDetalle : Usuario
|
||||
{
|
||||
public string Origen { get; set; } = "manual";
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
// Este modelo representa la tabla memorias_ram
|
||||
// backend/Models/MemoriaRam.cs
|
||||
namespace Inventario.API.Models
|
||||
{
|
||||
public class MemoriaRam
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? Part_number { get; set; }
|
||||
public string? PartNumber { get; set; }
|
||||
public string? Fabricante { get; set; }
|
||||
public int Tamano { get; set; }
|
||||
public int? Velocidad { get; set; }
|
||||
}
|
||||
|
||||
// Este es un modelo combinado para devolver la información completa al frontend
|
||||
public class MemoriaRamDetalle : MemoriaRam
|
||||
{
|
||||
public string Slot { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,84 @@
|
||||
// backend/Program.cs
|
||||
using Inventario.API.Data;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text;
|
||||
using Microsoft.OpenApi.Models;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = builder.Configuration["Jwt:Issuer"],
|
||||
ValidAudience = builder.Configuration["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
|
||||
};
|
||||
});
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// CONFIGURACIÓN DE SWAGGER
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
// 1. Definir el esquema de seguridad (JWT Bearer)
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
Description = "Autenticación JWT usando el esquema Bearer. " +
|
||||
"Introduce 'Bearer' [espacio] y luego tu token en el campo de abajo. " +
|
||||
"Ejemplo: 'Bearer 12345abcdef'",
|
||||
Name = "Authorization", // El nombre del header
|
||||
In = ParameterLocation.Header, // Dónde se envía (en la cabecera)
|
||||
Type = SecuritySchemeType.ApiKey, // Tipo de esquema
|
||||
Scheme = "Bearer"
|
||||
});
|
||||
|
||||
// 2. Aplicar el requisito de seguridad globalmente a todos los endpoints
|
||||
options.AddSecurityRequirement(new OpenApiSecurityRequirement()
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer" // Debe coincidir con el Id de AddSecurityDefinition
|
||||
},
|
||||
Scheme = "oauth2",
|
||||
Name = "Bearer",
|
||||
In = ParameterLocation.Header,
|
||||
},
|
||||
new List<string>()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- 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>();
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -22,5 +95,15 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// --- ACTIVAR EL MIDDLEWARE DE CORS ---
|
||||
// ¡IMPORTANTE! Debe ir ANTES de MapControllers y DESPUÉS de UseHttpsRedirection (si se usa)
|
||||
app.UseCors(MyAllowSpecificOrigins);
|
||||
// ----------------------------------------
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -4,5 +4,23 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AuthSettings": {
|
||||
"Username": "admin",
|
||||
"Password": "PTP847Equipos"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
|
||||
"Issuer": "InventarioAPI",
|
||||
"Audience": "InventarioClient"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=TECNICA3;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
|
||||
},
|
||||
"SshSettings": {
|
||||
"Host": "192.168.10.1",
|
||||
"Port": 22110,
|
||||
"User": "root",
|
||||
"Password": "PTP.847eld23"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,22 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"AuthSettings": {
|
||||
"Username": "admin",
|
||||
"Password": "PTP847Equipos"
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "badb1a38d221c9e23bcf70958840ca7f5a5dc54f2047dadf7ce45b578b5bc3e2",
|
||||
"Issuer": "InventarioAPI",
|
||||
"Audience": "InventarioClient"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=TECNICA3;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
|
||||
"DefaultConnection": "Server=db-sqlserver;Database=InventarioDB;User Id=apiequipos;Password=@Apiequipos513@;TrustServerCertificate=True"
|
||||
},
|
||||
"SshSettings": {
|
||||
"Host": "192.168.10.1",
|
||||
"Port": 22110,
|
||||
"User": "root",
|
||||
"Password": "PTP.847eld23"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Inventario.API")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+80eeac45d8b90ed69a6479e9d3dcadde5e317a90")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+acf2f9a35c8a559db55e21ce6dd2066c30a01669")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Inventario.API")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Inventario.API")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
"target": "Package",
|
||||
"version": "[2.1.66, )"
|
||||
},
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer": {
|
||||
"target": "Package",
|
||||
"version": "[9.0.9, )"
|
||||
},
|
||||
"Microsoft.AspNetCore.OpenApi": {
|
||||
"target": "Package",
|
||||
"version": "[9.0.5, )"
|
||||
@@ -72,6 +76,10 @@
|
||||
"target": "Package",
|
||||
"version": "[9.0.9, )"
|
||||
},
|
||||
"SSH.NET": {
|
||||
"target": "Package",
|
||||
"version": "[2025.0.0, )"
|
||||
},
|
||||
"Swashbuckle.AspNetCore": {
|
||||
"target": "Package",
|
||||
"version": "[9.0.6, )"
|
||||
|
||||
@@ -39,6 +39,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography/2.5.1": {
|
||||
"type": "package",
|
||||
"compile": {
|
||||
"lib/net6.0/BouncyCastle.Cryptography.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/BouncyCastle.Cryptography.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Dapper/2.1.66": {
|
||||
"type": "package",
|
||||
"compile": {
|
||||
@@ -65,6 +78,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer/9.0.9": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1"
|
||||
},
|
||||
"compile": {
|
||||
"lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"frameworkReferences": [
|
||||
"Microsoft.AspNetCore.App"
|
||||
]
|
||||
},
|
||||
"Microsoft.AspNetCore.OpenApi/9.0.5": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
@@ -884,96 +916,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Abstractions/7.7.1": {
|
||||
"Microsoft.IdentityModel.Abstractions/8.0.1": {
|
||||
"type": "package",
|
||||
"compile": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.JsonWebTokens/7.7.1": {
|
||||
"Microsoft.IdentityModel.JsonWebTokens/8.0.1": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Tokens": "7.7.1"
|
||||
"Microsoft.IdentityModel.Tokens": "8.0.1"
|
||||
},
|
||||
"compile": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Logging/7.7.1": {
|
||||
"Microsoft.IdentityModel.Logging/8.0.1": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Abstractions": "7.7.1"
|
||||
"Microsoft.IdentityModel.Abstractions": "8.0.1"
|
||||
},
|
||||
"compile": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.Logging.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Logging.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.Logging.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Logging.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Protocols/7.7.1": {
|
||||
"Microsoft.IdentityModel.Protocols/8.0.1": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Tokens": "7.7.1"
|
||||
"Microsoft.IdentityModel.Tokens": "8.0.1"
|
||||
},
|
||||
"compile": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Protocols.OpenIdConnect/7.7.1": {
|
||||
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Protocols": "7.7.1",
|
||||
"System.IdentityModel.Tokens.Jwt": "7.7.1"
|
||||
"Microsoft.IdentityModel.Protocols": "8.0.1",
|
||||
"System.IdentityModel.Tokens.Jwt": "8.0.1"
|
||||
},
|
||||
"compile": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.IdentityModel.Tokens/7.7.1": {
|
||||
"Microsoft.IdentityModel.Tokens/8.0.1": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.Logging": "7.7.1"
|
||||
"Microsoft.IdentityModel.Logging": "8.0.1"
|
||||
},
|
||||
"compile": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll": {
|
||||
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
}
|
||||
@@ -1019,6 +1051,23 @@
|
||||
"buildTransitive/Mono.TextTemplating.targets": {}
|
||||
}
|
||||
},
|
||||
"SSH.NET/2025.0.0": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.5.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
},
|
||||
"compile": {
|
||||
"lib/net9.0/Renci.SshNet.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net9.0/Renci.SshNet.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Swashbuckle.AspNetCore/9.0.6": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
@@ -1325,19 +1374,19 @@
|
||||
"buildTransitive/net8.0/_._": {}
|
||||
}
|
||||
},
|
||||
"System.IdentityModel.Tokens.Jwt/7.7.1": {
|
||||
"System.IdentityModel.Tokens.Jwt/8.0.1": {
|
||||
"type": "package",
|
||||
"dependencies": {
|
||||
"Microsoft.IdentityModel.JsonWebTokens": "7.7.1",
|
||||
"Microsoft.IdentityModel.Tokens": "7.7.1"
|
||||
"Microsoft.IdentityModel.JsonWebTokens": "8.0.1",
|
||||
"Microsoft.IdentityModel.Tokens": "8.0.1"
|
||||
},
|
||||
"compile": {
|
||||
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": {
|
||||
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll": {
|
||||
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll": {
|
||||
"related": ".xml"
|
||||
}
|
||||
}
|
||||
@@ -1531,6 +1580,26 @@
|
||||
"lib/netstandard2.0/Azure.Identity.xml"
|
||||
]
|
||||
},
|
||||
"BouncyCastle.Cryptography/2.5.1": {
|
||||
"sha512": "zy8TMeTP+1FH2NrLaNZtdRbBdq7u5MI+NFZQOBSM69u5RFkciinwzV2eveY6Kjf5MzgsYvvl6kTStsj3JrXqkg==",
|
||||
"type": "package",
|
||||
"path": "bouncycastle.cryptography/2.5.1",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"LICENSE.md",
|
||||
"README.md",
|
||||
"bouncycastle.cryptography.2.5.1.nupkg.sha512",
|
||||
"bouncycastle.cryptography.nuspec",
|
||||
"lib/net461/BouncyCastle.Cryptography.dll",
|
||||
"lib/net461/BouncyCastle.Cryptography.xml",
|
||||
"lib/net6.0/BouncyCastle.Cryptography.dll",
|
||||
"lib/net6.0/BouncyCastle.Cryptography.xml",
|
||||
"lib/netstandard2.0/BouncyCastle.Cryptography.dll",
|
||||
"lib/netstandard2.0/BouncyCastle.Cryptography.xml",
|
||||
"packageIcon.png"
|
||||
]
|
||||
},
|
||||
"Dapper/2.1.66": {
|
||||
"sha512": "/q77jUgDOS+bzkmk3Vy9SiWMaetTw+NOoPAV0xPBsGVAyljd5S6P+4RUW7R3ZUGGr9lDRyPKgAMj2UAOwvqZYw==",
|
||||
"type": "package",
|
||||
@@ -1568,6 +1637,22 @@
|
||||
"logo.png"
|
||||
]
|
||||
},
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer/9.0.9": {
|
||||
"sha512": "U5gW2DS/yAE9X0Ko63/O2lNApAzI/jhx4IT1Th6W0RShKv6XAVVgLGN3zqnmcd6DtAnp5FYs+4HZrxsTl0anLA==",
|
||||
"type": "package",
|
||||
"path": "microsoft.aspnetcore.authentication.jwtbearer/9.0.9",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"Icon.png",
|
||||
"PACKAGE.md",
|
||||
"THIRD-PARTY-NOTICES.TXT",
|
||||
"lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.dll",
|
||||
"lib/net9.0/Microsoft.AspNetCore.Authentication.JwtBearer.xml",
|
||||
"microsoft.aspnetcore.authentication.jwtbearer.9.0.9.nupkg.sha512",
|
||||
"microsoft.aspnetcore.authentication.jwtbearer.nuspec"
|
||||
]
|
||||
},
|
||||
"Microsoft.AspNetCore.OpenApi/9.0.5": {
|
||||
"sha512": "yZLOciYlpaOO/mHPOpgeSZTv8Lc7fOOVX40eWJJoGs/S9Ny9CymDuKKQofGE9stXGGM9EEnnuPeq0fhR8kdFfg==",
|
||||
"type": "package",
|
||||
@@ -3419,15 +3504,13 @@
|
||||
"microsoft.identity.client.extensions.msal.nuspec"
|
||||
]
|
||||
},
|
||||
"Microsoft.IdentityModel.Abstractions/7.7.1": {
|
||||
"sha512": "S7sHg6gLg7oFqNGLwN1qSbJDI+QcRRj8SuJ1jHyCmKSipnF6ZQL+tFV2NzVfGj/xmGT9TykQdQiBN+p5Idl4TA==",
|
||||
"Microsoft.IdentityModel.Abstractions/8.0.1": {
|
||||
"sha512": "OtlIWcyX01olfdevPKZdIPfBEvbcioDyBiE/Z2lHsopsMD7twcKtlN9kMevHmI5IIPhFpfwCIiR6qHQz1WHUIw==",
|
||||
"type": "package",
|
||||
"path": "microsoft.identitymodel.abstractions/7.7.1",
|
||||
"path": "microsoft.identitymodel.abstractions/8.0.1",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"lib/net461/Microsoft.IdentityModel.Abstractions.dll",
|
||||
"lib/net461/Microsoft.IdentityModel.Abstractions.xml",
|
||||
"lib/net462/Microsoft.IdentityModel.Abstractions.dll",
|
||||
"lib/net462/Microsoft.IdentityModel.Abstractions.xml",
|
||||
"lib/net472/Microsoft.IdentityModel.Abstractions.dll",
|
||||
@@ -3436,21 +3519,21 @@
|
||||
"lib/net6.0/Microsoft.IdentityModel.Abstractions.xml",
|
||||
"lib/net8.0/Microsoft.IdentityModel.Abstractions.dll",
|
||||
"lib/net8.0/Microsoft.IdentityModel.Abstractions.xml",
|
||||
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll",
|
||||
"lib/net9.0/Microsoft.IdentityModel.Abstractions.xml",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.Abstractions.dll",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.Abstractions.xml",
|
||||
"microsoft.identitymodel.abstractions.7.7.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.abstractions.8.0.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.abstractions.nuspec"
|
||||
]
|
||||
},
|
||||
"Microsoft.IdentityModel.JsonWebTokens/7.7.1": {
|
||||
"sha512": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==",
|
||||
"Microsoft.IdentityModel.JsonWebTokens/8.0.1": {
|
||||
"sha512": "s6++gF9x0rQApQzOBbSyp4jUaAlwm+DroKfL8gdOHxs83k8SJfUXhuc46rDB3rNXBQ1MVRxqKUrqFhO/M0E97g==",
|
||||
"type": "package",
|
||||
"path": "microsoft.identitymodel.jsonwebtokens/7.7.1",
|
||||
"path": "microsoft.identitymodel.jsonwebtokens/8.0.1",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"lib/net461/Microsoft.IdentityModel.JsonWebTokens.dll",
|
||||
"lib/net461/Microsoft.IdentityModel.JsonWebTokens.xml",
|
||||
"lib/net462/Microsoft.IdentityModel.JsonWebTokens.dll",
|
||||
"lib/net462/Microsoft.IdentityModel.JsonWebTokens.xml",
|
||||
"lib/net472/Microsoft.IdentityModel.JsonWebTokens.dll",
|
||||
@@ -3459,21 +3542,21 @@
|
||||
"lib/net6.0/Microsoft.IdentityModel.JsonWebTokens.xml",
|
||||
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.dll",
|
||||
"lib/net8.0/Microsoft.IdentityModel.JsonWebTokens.xml",
|
||||
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll",
|
||||
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.xml",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.JsonWebTokens.dll",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.JsonWebTokens.xml",
|
||||
"microsoft.identitymodel.jsonwebtokens.7.7.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.jsonwebtokens.8.0.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.jsonwebtokens.nuspec"
|
||||
]
|
||||
},
|
||||
"Microsoft.IdentityModel.Logging/7.7.1": {
|
||||
"sha512": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==",
|
||||
"Microsoft.IdentityModel.Logging/8.0.1": {
|
||||
"sha512": "UCPF2exZqBXe7v/6sGNiM6zCQOUXXQ9+v5VTb9gPB8ZSUPnX53BxlN78v2jsbIvK9Dq4GovQxo23x8JgWvm/Qg==",
|
||||
"type": "package",
|
||||
"path": "microsoft.identitymodel.logging/7.7.1",
|
||||
"path": "microsoft.identitymodel.logging/8.0.1",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"lib/net461/Microsoft.IdentityModel.Logging.dll",
|
||||
"lib/net461/Microsoft.IdentityModel.Logging.xml",
|
||||
"lib/net462/Microsoft.IdentityModel.Logging.dll",
|
||||
"lib/net462/Microsoft.IdentityModel.Logging.xml",
|
||||
"lib/net472/Microsoft.IdentityModel.Logging.dll",
|
||||
@@ -3482,21 +3565,21 @@
|
||||
"lib/net6.0/Microsoft.IdentityModel.Logging.xml",
|
||||
"lib/net8.0/Microsoft.IdentityModel.Logging.dll",
|
||||
"lib/net8.0/Microsoft.IdentityModel.Logging.xml",
|
||||
"lib/net9.0/Microsoft.IdentityModel.Logging.dll",
|
||||
"lib/net9.0/Microsoft.IdentityModel.Logging.xml",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.Logging.dll",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.Logging.xml",
|
||||
"microsoft.identitymodel.logging.7.7.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.logging.8.0.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.logging.nuspec"
|
||||
]
|
||||
},
|
||||
"Microsoft.IdentityModel.Protocols/7.7.1": {
|
||||
"sha512": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==",
|
||||
"Microsoft.IdentityModel.Protocols/8.0.1": {
|
||||
"sha512": "uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==",
|
||||
"type": "package",
|
||||
"path": "microsoft.identitymodel.protocols/7.7.1",
|
||||
"path": "microsoft.identitymodel.protocols/8.0.1",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"lib/net461/Microsoft.IdentityModel.Protocols.dll",
|
||||
"lib/net461/Microsoft.IdentityModel.Protocols.xml",
|
||||
"lib/net462/Microsoft.IdentityModel.Protocols.dll",
|
||||
"lib/net462/Microsoft.IdentityModel.Protocols.xml",
|
||||
"lib/net472/Microsoft.IdentityModel.Protocols.dll",
|
||||
@@ -3505,21 +3588,21 @@
|
||||
"lib/net6.0/Microsoft.IdentityModel.Protocols.xml",
|
||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.dll",
|
||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.xml",
|
||||
"lib/net9.0/Microsoft.IdentityModel.Protocols.dll",
|
||||
"lib/net9.0/Microsoft.IdentityModel.Protocols.xml",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.dll",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.xml",
|
||||
"microsoft.identitymodel.protocols.7.7.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.protocols.8.0.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.protocols.nuspec"
|
||||
]
|
||||
},
|
||||
"Microsoft.IdentityModel.Protocols.OpenIdConnect/7.7.1": {
|
||||
"sha512": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==",
|
||||
"Microsoft.IdentityModel.Protocols.OpenIdConnect/8.0.1": {
|
||||
"sha512": "AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==",
|
||||
"type": "package",
|
||||
"path": "microsoft.identitymodel.protocols.openidconnect/7.7.1",
|
||||
"path": "microsoft.identitymodel.protocols.openidconnect/8.0.1",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"lib/net461/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
|
||||
"lib/net461/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
||||
"lib/net462/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
|
||||
"lib/net462/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
||||
"lib/net472/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
|
||||
@@ -3528,21 +3611,21 @@
|
||||
"lib/net6.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
|
||||
"lib/net8.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
||||
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
|
||||
"lib/net9.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.dll",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.Protocols.OpenIdConnect.xml",
|
||||
"microsoft.identitymodel.protocols.openidconnect.7.7.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.protocols.openidconnect.8.0.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.protocols.openidconnect.nuspec"
|
||||
]
|
||||
},
|
||||
"Microsoft.IdentityModel.Tokens/7.7.1": {
|
||||
"sha512": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==",
|
||||
"Microsoft.IdentityModel.Tokens/8.0.1": {
|
||||
"sha512": "kDimB6Dkd3nkW2oZPDkMkVHfQt3IDqO5gL0oa8WVy3OP4uE8Ij+8TXnqg9TOd9ufjsY3IDiGz7pCUbnfL18tjg==",
|
||||
"type": "package",
|
||||
"path": "microsoft.identitymodel.tokens/7.7.1",
|
||||
"path": "microsoft.identitymodel.tokens/8.0.1",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"lib/net461/Microsoft.IdentityModel.Tokens.dll",
|
||||
"lib/net461/Microsoft.IdentityModel.Tokens.xml",
|
||||
"lib/net462/Microsoft.IdentityModel.Tokens.dll",
|
||||
"lib/net462/Microsoft.IdentityModel.Tokens.xml",
|
||||
"lib/net472/Microsoft.IdentityModel.Tokens.dll",
|
||||
@@ -3551,9 +3634,11 @@
|
||||
"lib/net6.0/Microsoft.IdentityModel.Tokens.xml",
|
||||
"lib/net8.0/Microsoft.IdentityModel.Tokens.dll",
|
||||
"lib/net8.0/Microsoft.IdentityModel.Tokens.xml",
|
||||
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll",
|
||||
"lib/net9.0/Microsoft.IdentityModel.Tokens.xml",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.Tokens.dll",
|
||||
"lib/netstandard2.0/Microsoft.IdentityModel.Tokens.xml",
|
||||
"microsoft.identitymodel.tokens.7.7.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.tokens.8.0.1.nupkg.sha512",
|
||||
"microsoft.identitymodel.tokens.nuspec"
|
||||
]
|
||||
},
|
||||
@@ -3607,6 +3692,29 @@
|
||||
"readme.md"
|
||||
]
|
||||
},
|
||||
"SSH.NET/2025.0.0": {
|
||||
"sha512": "AKYbB+q2zFkNQbBFx5gXdv+Wje0baBtADQ35WnMKi4bg1ka74wTQtWoPd+fOWcydohdfsD0nfT8ErMOAPxtSfA==",
|
||||
"type": "package",
|
||||
"path": "ssh.net/2025.0.0",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"README.md",
|
||||
"SS-NET-icon-h500.png",
|
||||
"lib/net462/Renci.SshNet.dll",
|
||||
"lib/net462/Renci.SshNet.xml",
|
||||
"lib/net8.0/Renci.SshNet.dll",
|
||||
"lib/net8.0/Renci.SshNet.xml",
|
||||
"lib/net9.0/Renci.SshNet.dll",
|
||||
"lib/net9.0/Renci.SshNet.xml",
|
||||
"lib/netstandard2.0/Renci.SshNet.dll",
|
||||
"lib/netstandard2.0/Renci.SshNet.xml",
|
||||
"lib/netstandard2.1/Renci.SshNet.dll",
|
||||
"lib/netstandard2.1/Renci.SshNet.xml",
|
||||
"ssh.net.2025.0.0.nupkg.sha512",
|
||||
"ssh.net.nuspec"
|
||||
]
|
||||
},
|
||||
"Swashbuckle.AspNetCore/9.0.6": {
|
||||
"sha512": "q/UfEAgrk6qQyjHXgsW9ILw0YZLfmPtWUY4wYijliX6supozC+TkzU0G6FTnn/dPYxnChjM8g8lHjWHF6VKy+A==",
|
||||
"type": "package",
|
||||
@@ -4017,15 +4125,13 @@
|
||||
"useSharedDesignerContext.txt"
|
||||
]
|
||||
},
|
||||
"System.IdentityModel.Tokens.Jwt/7.7.1": {
|
||||
"sha512": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==",
|
||||
"System.IdentityModel.Tokens.Jwt/8.0.1": {
|
||||
"sha512": "GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==",
|
||||
"type": "package",
|
||||
"path": "system.identitymodel.tokens.jwt/7.7.1",
|
||||
"path": "system.identitymodel.tokens.jwt/8.0.1",
|
||||
"files": [
|
||||
".nupkg.metadata",
|
||||
".signature.p7s",
|
||||
"lib/net461/System.IdentityModel.Tokens.Jwt.dll",
|
||||
"lib/net461/System.IdentityModel.Tokens.Jwt.xml",
|
||||
"lib/net462/System.IdentityModel.Tokens.Jwt.dll",
|
||||
"lib/net462/System.IdentityModel.Tokens.Jwt.xml",
|
||||
"lib/net472/System.IdentityModel.Tokens.Jwt.dll",
|
||||
@@ -4034,9 +4140,11 @@
|
||||
"lib/net6.0/System.IdentityModel.Tokens.Jwt.xml",
|
||||
"lib/net8.0/System.IdentityModel.Tokens.Jwt.dll",
|
||||
"lib/net8.0/System.IdentityModel.Tokens.Jwt.xml",
|
||||
"lib/net9.0/System.IdentityModel.Tokens.Jwt.dll",
|
||||
"lib/net9.0/System.IdentityModel.Tokens.Jwt.xml",
|
||||
"lib/netstandard2.0/System.IdentityModel.Tokens.Jwt.dll",
|
||||
"lib/netstandard2.0/System.IdentityModel.Tokens.Jwt.xml",
|
||||
"system.identitymodel.tokens.jwt.7.7.1.nupkg.sha512",
|
||||
"system.identitymodel.tokens.jwt.8.0.1.nupkg.sha512",
|
||||
"system.identitymodel.tokens.jwt.nuspec"
|
||||
]
|
||||
},
|
||||
@@ -4344,10 +4452,12 @@
|
||||
"projectFileDependencyGroups": {
|
||||
"net9.0": [
|
||||
"Dapper >= 2.1.66",
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer >= 9.0.9",
|
||||
"Microsoft.AspNetCore.OpenApi >= 9.0.5",
|
||||
"Microsoft.Data.SqlClient >= 6.1.1",
|
||||
"Microsoft.EntityFrameworkCore.Design >= 9.0.9",
|
||||
"Microsoft.EntityFrameworkCore.SqlServer >= 9.0.9",
|
||||
"SSH.NET >= 2025.0.0",
|
||||
"Swashbuckle.AspNetCore >= 9.0.6"
|
||||
]
|
||||
},
|
||||
@@ -4405,6 +4515,10 @@
|
||||
"target": "Package",
|
||||
"version": "[2.1.66, )"
|
||||
},
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer": {
|
||||
"target": "Package",
|
||||
"version": "[9.0.9, )"
|
||||
},
|
||||
"Microsoft.AspNetCore.OpenApi": {
|
||||
"target": "Package",
|
||||
"version": "[9.0.5, )"
|
||||
@@ -4423,6 +4537,10 @@
|
||||
"target": "Package",
|
||||
"version": "[9.0.9, )"
|
||||
},
|
||||
"SSH.NET": {
|
||||
"target": "Package",
|
||||
"version": "[2025.0.0, )"
|
||||
},
|
||||
"Swashbuckle.AspNetCore": {
|
||||
"target": "Package",
|
||||
"version": "[9.0.6, )"
|
||||
|
||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
# Servicio del Backend
|
||||
inventario-api:
|
||||
build:
|
||||
context: . # El contexto es la raíz del proyecto
|
||||
dockerfile: backend/Dockerfile # Docker encontrará este archivo dentro del contexto
|
||||
container_name: inventario-api
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8080"
|
||||
networks:
|
||||
- inventario-net
|
||||
- shared-net
|
||||
|
||||
# Servicio del Frontend
|
||||
inventario-frontend:
|
||||
build:
|
||||
context: ./frontend # El contexto es la carpeta 'frontend'
|
||||
dockerfile: Dockerfile # Docker buscará 'Dockerfile' dentro de la carpeta 'frontend'
|
||||
container_name: inventario-frontend
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
networks:
|
||||
- inventario-net
|
||||
|
||||
# Proxy Inverso
|
||||
inventario-proxy:
|
||||
image: nginx:1.25-alpine
|
||||
container_name: inventario-proxy
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./frontend/proxy/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
ports:
|
||||
- "8900:80"
|
||||
networks:
|
||||
- inventario-net
|
||||
depends_on:
|
||||
- inventario-api
|
||||
- inventario-frontend
|
||||
|
||||
networks:
|
||||
inventario-net:
|
||||
driver: bridge
|
||||
shared-net:
|
||||
external: true
|
||||
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?
|
||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# --- Etapa 1: Build ---
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Etapa 2: Producción ---
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
# Copia los archivos estáticos construidos a la carpeta que Nginx sirve por defecto
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copia la configuración de Nginx específica para el frontend
|
||||
COPY frontend.nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
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,
|
||||
},
|
||||
},
|
||||
])
|
||||
10
frontend/frontend.nginx.conf
Normal file
10
frontend/frontend.nginx.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
# Esta línea es crucial para que las rutas de React (SPA) funcionen
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
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>
|
||||
3569
frontend/package-lock.json
generated
Normal file
3569
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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",
|
||||
"chart.js": "^4.5.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"react": "^19.1.1",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
21
frontend/proxy/nginx.conf
Normal file
21
frontend/proxy/nginx.conf
Normal file
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Pasa todas las peticiones a la API al servicio del backend
|
||||
location /api/ {
|
||||
proxy_pass http://inventario-api:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Pasa el resto de las peticiones al servicio del frontend
|
||||
location / {
|
||||
proxy_pass http://inventario-frontend:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
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 |
BIN
frontend/public/power.png
Normal file
BIN
frontend/public/power.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
47
frontend/src/App.css
Normal file
47
frontend/src/App.css
Normal file
@@ -0,0 +1,47 @@
|
||||
main {
|
||||
padding: 0rem 2rem;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: var(--color-navbar-bg);
|
||||
padding: 0 2rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border); /* Borde sutil */
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-navbar-text);
|
||||
padding: 1rem 1.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||
border-bottom: 3px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--color-navbar-text-hover);
|
||||
}
|
||||
|
||||
.nav-link-active {
|
||||
color: var(--color-navbar-text-hover);
|
||||
border-bottom: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-navbar-text-hover);
|
||||
font-weight: bold;
|
||||
}
|
||||
40
frontend/src/App.tsx
Normal file
40
frontend/src/App.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import SimpleTable from "./components/SimpleTable";
|
||||
import GestionSectores from "./components/GestionSectores";
|
||||
import GestionComponentes from './components/GestionComponentes';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import Navbar from './components/Navbar';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import Login from './components/Login';
|
||||
import './App.css';
|
||||
|
||||
export type View = 'equipos' | 'sectores' | 'admin' | 'dashboard';
|
||||
|
||||
function App() {
|
||||
const [currentView, setCurrentView] = useState<View>('equipos');
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Muestra un loader mientras se verifica la sesión
|
||||
if (isLoading) {
|
||||
return <div>Cargando...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar currentView={currentView} setCurrentView={setCurrentView} />
|
||||
|
||||
<main>
|
||||
{currentView === 'equipos' && <SimpleTable />}
|
||||
{currentView === 'sectores' && <GestionSectores />}
|
||||
{currentView === 'admin' && <GestionComponentes />}
|
||||
{currentView === 'dashboard' && <Dashboard />}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
75
frontend/src/components/AutocompleteInput.tsx
Normal file
75
frontend/src/components/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
// --- Interfaces de Props más robustas usando una unión discriminada ---
|
||||
type AutocompleteInputProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
} & ( // Esto crea una unión: o es estático o es dinámico
|
||||
| {
|
||||
mode: 'static';
|
||||
fetchSuggestions: () => Promise<string[]>; // No necesita 'query'
|
||||
}
|
||||
| {
|
||||
mode: 'dynamic';
|
||||
fetchSuggestions: (query: string) => Promise<string[]>; // Necesita 'query'
|
||||
}
|
||||
);
|
||||
|
||||
const AutocompleteInput: React.FC<AutocompleteInputProps> = (props) => {
|
||||
const { value, onChange, name, placeholder, className } = props;
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const dataListId = `suggestions-for-${name}`;
|
||||
|
||||
// --- Lógica para el modo ESTÁTICO ---
|
||||
// Se ejecuta UNA SOLA VEZ cuando el componente se monta
|
||||
useEffect(() => {
|
||||
if (props.mode === 'static') {
|
||||
props.fetchSuggestions()
|
||||
.then(setSuggestions)
|
||||
.catch(err => console.error(`Error fetching static suggestions for ${name}:`, err));
|
||||
}
|
||||
// La lista de dependencias asegura que solo se ejecute si estas props cambian (lo cual no harán)
|
||||
}, [props.mode, props.fetchSuggestions, name]);
|
||||
|
||||
// --- Lógica para el modo DINÁMICO ---
|
||||
// Se ejecuta cada vez que el usuario escribe, con un debounce
|
||||
useEffect(() => {
|
||||
if (props.mode === 'dynamic') {
|
||||
if (value.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const handler = setTimeout(() => {
|
||||
props.fetchSuggestions(value)
|
||||
.then(setSuggestions)
|
||||
.catch(err => console.error(`Error fetching dynamic suggestions for ${name}:`, err));
|
||||
}, 300);
|
||||
return () => clearTimeout(handler);
|
||||
}
|
||||
}, [value, props.mode, props.fetchSuggestions, name]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
list={dataListId}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<datalist id={dataListId}>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<option key={index} value={suggestion} />
|
||||
))}
|
||||
</datalist>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutocompleteInput;
|
||||
59
frontend/src/components/CpuChart.tsx
Normal file
59
frontend/src/components/CpuChart.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import type { StatItem } from '../types/interfaces';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
||||
|
||||
interface Props {
|
||||
data: StatItem[];
|
||||
}
|
||||
|
||||
const CpuChart: React.FC<Props> = ({ data }) => {
|
||||
// Altura dinámica: 40px de base + 20px por cada CPU
|
||||
const chartHeight = 40 + data.length * 20;
|
||||
|
||||
const options = {
|
||||
indexAxis: 'y' as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Equipos por CPU',
|
||||
font: {
|
||||
size: 16
|
||||
}
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { beginAtZero: true, ticks: { stepSize: 1 } },
|
||||
y: { ticks: { font: { size: 10 } } }
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(item => item.label),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Nº de Equipos',
|
||||
data: data.map(item => item.count),
|
||||
backgroundColor: 'rgba(153, 102, 255, 0.8)',
|
||||
borderColor: 'rgba(153, 102, 255, 1)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Envolvemos la barra en un div con altura calculada
|
||||
return (
|
||||
<div style={{ position: 'relative', height: `${chartHeight}px`, minHeight: '400px' }}>
|
||||
<Bar options={options} data={chartData} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CpuChart;
|
||||
42
frontend/src/components/Dashboard.module.css
Normal file
42
frontend/src/components/Dashboard.module.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.dashboardHeader {
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dashboardHeader h2 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
/* Grilla simple de 2 columnas */
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.chartContainer {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
/* Contenedor especial para los gráficos de barras horizontales con scroll */
|
||||
.scrollableChartContainer {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.statsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
84
frontend/src/components/Dashboard.tsx
Normal file
84
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
// frontend/src/components/Dashboard.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { dashboardService } from '../services/apiService';
|
||||
import type { DashboardStats } from '../types/interfaces';
|
||||
import OsChart from './OsChart';
|
||||
import SectorChart from './SectorChart';
|
||||
import CpuChart from './CpuChart';
|
||||
import RamChart from './RamChart';
|
||||
import styles from './Dashboard.module.css';
|
||||
import skeletonStyles from './Skeleton.module.css';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
dashboardService.getStats()
|
||||
.then(data => {
|
||||
setStats(data);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('No se pudieron cargar las estadísticas del dashboard.');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.dashboardHeader}>
|
||||
<h2>Dashboard de Inventario</h2>
|
||||
</div>
|
||||
<div className={styles.statsGrid}>
|
||||
{/* Replicamos la estructura de la grilla con esqueletos */}
|
||||
<div className={`${styles.chartContainer} ${skeletonStyles.skeleton}`}></div>
|
||||
<div className={`${styles.chartContainer} ${skeletonStyles.skeleton}`}></div>
|
||||
<div className={`${styles.chartContainer} ${skeletonStyles.skeleton}`}></div>
|
||||
<div className={`${styles.chartContainer} ${skeletonStyles.skeleton}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className={styles.dashboardHeader}>
|
||||
<h2>No hay datos disponibles para mostrar.</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.dashboardHeader}>
|
||||
<h2>Dashboard de Inventario</h2>
|
||||
</div>
|
||||
<div className={styles.statsGrid}>
|
||||
{/* Fila 1, Columna 1 */}
|
||||
<div className={styles.chartContainer}>
|
||||
<OsChart data={stats.osStats} />
|
||||
</div>
|
||||
|
||||
{/* Fila 1, Columna 2 */}
|
||||
<div className={styles.chartContainer}>
|
||||
<RamChart data={stats.ramStats} />
|
||||
</div>
|
||||
|
||||
{/* Fila 2, Columna 1 */}
|
||||
<div className={`${styles.chartContainer} ${styles.scrollableChartContainer}`}>
|
||||
<CpuChart data={stats.cpuStats} />
|
||||
</div>
|
||||
|
||||
{/* Fila 2, Columna 2 */}
|
||||
<div className={`${styles.chartContainer} ${styles.scrollableChartContainer}`}>
|
||||
<SectorChart data={stats.sectorStats} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
268
frontend/src/components/GestionComponentes.tsx
Normal file
268
frontend/src/components/GestionComponentes.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
// frontend/src/components/GestionComponentes.tsx
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
flexRender,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import styles from './SimpleTable.module.css';
|
||||
import { adminService } from '../services/apiService';
|
||||
import TableSkeleton from './TableSkeleton';
|
||||
import { accentInsensitiveFilter } from '../utils/filtering';
|
||||
|
||||
// Interfaces
|
||||
interface TextValue {
|
||||
valor: string;
|
||||
conteo: number;
|
||||
}
|
||||
interface RamValue {
|
||||
fabricante?: string;
|
||||
tamano: number;
|
||||
velocidad?: number;
|
||||
conteo: number;
|
||||
}
|
||||
type ComponentValue = TextValue | RamValue;
|
||||
|
||||
const GestionComponentes = () => {
|
||||
const [componentType, setComponentType] = useState('os');
|
||||
const [valores, setValores] = useState<ComponentValue[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [valorAntiguo, setValorAntiguo] = useState('');
|
||||
const [valorNuevo, setValorNuevo] = useState('');
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
adminService.getComponentValues(componentType)
|
||||
.then(data => {
|
||||
setValores(data);
|
||||
})
|
||||
.catch(_err => {
|
||||
toast.error(`No se pudieron cargar los datos de ${componentType}.`);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [componentType]);
|
||||
|
||||
const handleOpenModal = useCallback((valor: string) => {
|
||||
setValorAntiguo(valor);
|
||||
setValorNuevo(valor);
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleUnificar = async () => {
|
||||
const toastId = toast.loading('Unificando valores...');
|
||||
try {
|
||||
await adminService.unifyComponentValues(componentType, valorAntiguo, valorNuevo);
|
||||
const refreshedData = await adminService.getComponentValues(componentType);
|
||||
setValores(refreshedData);
|
||||
toast.success('Valores unificados correctamente.', { id: toastId });
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRam = useCallback(async (ramGroup: RamValue) => {
|
||||
if (!window.confirm("¿Estás seguro de eliminar todas las entradas maestras para este tipo de RAM? Esta acción es irreversible.")) return;
|
||||
const toastId = toast.loading('Eliminando grupo de módulos...');
|
||||
try {
|
||||
await adminService.deleteRamComponent({ fabricante: ramGroup.fabricante, tamano: ramGroup.tamano, velocidad: ramGroup.velocidad });
|
||||
setValores(prev => prev.filter(v => {
|
||||
const currentRam = v as RamValue;
|
||||
return !(currentRam.fabricante === ramGroup.fabricante && currentRam.tamano === ramGroup.tamano && currentRam.velocidad === ramGroup.velocidad);
|
||||
}));
|
||||
toast.success("Grupo de módulos de RAM eliminado.", { id: toastId });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteTexto = useCallback(async (valor: string) => {
|
||||
if (!window.confirm(`Este valor ya no está en uso. ¿Quieres eliminarlo de la base de datos maestra?`)) return;
|
||||
const toastId = toast.loading('Eliminando valor...');
|
||||
try {
|
||||
await adminService.deleteTextComponent(componentType, valor);
|
||||
setValores(prev => prev.filter(v => (v as TextValue).valor !== valor));
|
||||
toast.success("Valor eliminado.", { id: toastId });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
}, [componentType]);
|
||||
|
||||
const renderValor = useCallback((item: ComponentValue) => {
|
||||
if (componentType === 'ram') {
|
||||
const ram = item as RamValue;
|
||||
return `${ram.fabricante || 'Desconocido'} ${ram.tamano}GB ${ram.velocidad ? ram.velocidad + 'MHz' : ''}`;
|
||||
}
|
||||
return (item as TextValue).valor;
|
||||
}, [componentType]);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
header: 'Valor Registrado',
|
||||
id: 'valor',
|
||||
accessorFn: (row: ComponentValue) => renderValor(row),
|
||||
},
|
||||
{
|
||||
header: 'Nº de Equipos',
|
||||
accessorKey: 'conteo',
|
||||
},
|
||||
{
|
||||
header: 'Acciones',
|
||||
id: 'acciones',
|
||||
cell: ({ row }: { row: { original: ComponentValue } }) => (
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
{componentType === 'ram' ? (
|
||||
<button
|
||||
onClick={() => handleDeleteRam(row.original as RamValue)}
|
||||
className={styles.deleteUserButton}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px' }}
|
||||
disabled={row.original.conteo > 0}
|
||||
title={row.original.conteo > 0 ? `En uso por ${row.original.conteo} equipo(s)` : 'Eliminar este grupo de módulos maestros'}
|
||||
>
|
||||
<Trash2 size={14} /> Eliminar
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => handleOpenModal((row.original as TextValue).valor)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Pencil size={14} /> Unificar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTexto((row.original as TextValue).valor)}
|
||||
className={styles.deleteUserButton}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.9em', border: '1px solid', borderRadius: '4px', padding: '4px 8px' }}
|
||||
disabled={row.original.conteo > 0}
|
||||
title={row.original.conteo > 0 ? `En uso por ${row.original.conteo} equipo(s). Unifique primero.` : 'Eliminar este valor'}
|
||||
>
|
||||
<Trash2 size={14} /> Eliminar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
], [componentType, renderValor, handleDeleteRam, handleDeleteTexto, handleOpenModal]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: valores,
|
||||
columns,
|
||||
state: { sorting, globalFilter },
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
filterFns: {
|
||||
accentInsensitive: accentInsensitiveFilter,
|
||||
},
|
||||
globalFilterFn: 'accentInsensitive',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<h2>Gestión de Componentes Maestros ({table.getFilteredRowModel().rows.length})</h2>
|
||||
<p>Unifica valores inconsistentes y elimina registros no utilizados.</p>
|
||||
<p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p>
|
||||
|
||||
<div className={styles.controlsContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrar registros..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
style={{ width: '300px' }}
|
||||
/>
|
||||
<label><strong>Tipo de componente:</strong></label>
|
||||
<select value={componentType} onChange={e => setComponentType(e.target.value)} className={styles.sectorSelect}>
|
||||
<option value="os">Sistema Operativo</option>
|
||||
<option value="cpu">CPU</option>
|
||||
<option value="motherboard">Motherboard</option>
|
||||
<option value="architecture">Arquitectura</option>
|
||||
<option value="ram">Memorias RAM</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.tableContainer}>
|
||||
<TableSkeleton rows={6} columns={3} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map(header => {
|
||||
const classNames = [styles.th];
|
||||
if (header.id === 'conteo') classNames.push(styles.thNumeric);
|
||||
if (header.id === 'acciones') classNames.push(styles.thActions);
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
className={classNames.join(' ')}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getIsSorted() && (
|
||||
<span className={styles.sortIndicator}>
|
||||
{header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map(row => (
|
||||
<tr key={row.id} className={styles.tr}>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
const classNames = [styles.td];
|
||||
if (cell.column.id === 'conteo') classNames.push(styles.tdNumeric);
|
||||
if (cell.column.id === 'acciones') classNames.push(styles.tdActions);
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={classNames.join(' ')}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<h3>Unificar Valor</h3>
|
||||
<p>Se reemplazarán todas las instancias de:</p>
|
||||
<strong className={styles.highlightBox}>{valorAntiguo}</strong>
|
||||
|
||||
<label>Por el nuevo valor:</label>
|
||||
<input type="text" value={valorNuevo} onChange={e => setValorNuevo(e.target.value)} className={styles.modalInput} />
|
||||
<div className={styles.modalActions}>
|
||||
<button onClick={handleUnificar} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!valorNuevo.trim() || valorNuevo === valorAntiguo}>Unificar</button>
|
||||
<button onClick={() => setIsModalOpen(false)} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GestionComponentes;
|
||||
218
frontend/src/components/GestionSectores.tsx
Normal file
218
frontend/src/components/GestionSectores.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
// frontend/src/components/GestionSectores.tsx
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
flexRender,
|
||||
type SortingState,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table';
|
||||
import { PlusCircle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { accentInsensitiveFilter } from '../utils/filtering';
|
||||
|
||||
import type { Sector } from '../types/interfaces';
|
||||
import styles from './SimpleTable.module.css';
|
||||
import ModalSector from './ModalSector';
|
||||
import TableSkeleton from './TableSkeleton'; // <-- 1. Importar el esqueleto
|
||||
import { sectorService } from '../services/apiService';
|
||||
|
||||
const GestionSectores = () => {
|
||||
const [sectores, setSectores] = useState<Sector[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingSector, setEditingSector] = useState<Sector | null>(null);
|
||||
|
||||
// --- 2. Estados para filtro y ordenación ---
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true); // Aseguramos que se muestre el esqueleto al cargar
|
||||
sectorService.getAll()
|
||||
.then(data => {
|
||||
// Ordenar alfabéticamente por defecto
|
||||
const sectoresOrdenados = [...data].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
|
||||
setSectores(sectoresOrdenados);
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error("No se pudieron cargar los sectores.");
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setEditingSector(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = useCallback((sector: Sector) => {
|
||||
setEditingSector(sector);
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSave = async (id: number | null, nombre: string) => {
|
||||
const isEditing = id !== null;
|
||||
const toastId = toast.loading(isEditing ? 'Actualizando...' : 'Creando...');
|
||||
|
||||
try {
|
||||
let refreshedData;
|
||||
if (isEditing) {
|
||||
await sectorService.update(id, nombre);
|
||||
refreshedData = await sectorService.getAll();
|
||||
toast.success('Sector actualizado.', { id: toastId });
|
||||
} else {
|
||||
await sectorService.create(nombre);
|
||||
refreshedData = await sectorService.getAll();
|
||||
toast.success('Sector creado.', { id: toastId });
|
||||
}
|
||||
const sectoresOrdenados = [...refreshedData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
|
||||
setSectores(sectoresOrdenados);
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = useCallback(async (id: number) => {
|
||||
if (!window.confirm("¿Estás seguro de eliminar este sector? Los equipos asociados quedarán sin sector.")) {
|
||||
return;
|
||||
}
|
||||
const toastId = toast.loading('Eliminando...');
|
||||
try {
|
||||
await sectorService.delete(id);
|
||||
setSectores(prev => prev.filter(s => s.id !== id));
|
||||
toast.success("Sector eliminado.", { id: toastId });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- 3. Definición de columnas para React Table ---
|
||||
const columns = useMemo<ColumnDef<Sector>[]>(() => [
|
||||
{
|
||||
header: 'Nombre del Sector',
|
||||
accessorKey: 'nombre',
|
||||
},
|
||||
{
|
||||
header: 'Acciones',
|
||||
id: 'acciones',
|
||||
cell: ({ row }) => (
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button onClick={() => handleOpenEditModal(row.original)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><Pencil size={16} /> Editar</button>
|
||||
<button onClick={() => handleDelete(row.original.id)} className={styles.deleteUserButton} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '1em', padding: '0.375rem 0.75rem', border: '1px solid', borderRadius: '4px' }}>
|
||||
<Trash2 size={16} /> Eliminar
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
], [handleOpenEditModal, handleDelete]);
|
||||
|
||||
// --- 4. Instancia de la tabla ---
|
||||
const table = useReactTable({
|
||||
data: sectores,
|
||||
columns,
|
||||
state: { sorting, globalFilter },
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
filterFns: {
|
||||
accentInsensitive: accentInsensitiveFilter,
|
||||
},
|
||||
globalFilterFn: 'accentInsensitive',
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Gestión de Sectores ({table.getFilteredRowModel().rows.length})</h2>
|
||||
<p>Crea, edita y elimina los sectores de la organización.</p>
|
||||
<div className={styles.controlsContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrar sectores..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
style={{ width: '300px' }}
|
||||
/>
|
||||
<button onClick={handleOpenCreateModal} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginLeft: 'auto' }}>
|
||||
<PlusCircle size={18} /> Añadir Sector
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.tableContainer}>
|
||||
<TableSkeleton rows={6} columns={2} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map(header => {
|
||||
const classNames = [styles.th];
|
||||
if (header.id === 'acciones') {
|
||||
classNames.push(styles.thActions);
|
||||
}
|
||||
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
className={classNames.join(' ')}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getIsSorted() && (
|
||||
<span className={styles.sortIndicator}>
|
||||
{header.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map(row => (
|
||||
<tr key={row.id} className={styles.tr}>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
const classNames = [styles.td];
|
||||
if (cell.column.id === 'acciones') {
|
||||
classNames.push(styles.tdActions);
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={cell.id} className={classNames.join(' ')}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<ModalSector
|
||||
sector={editingSector}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GestionSectores;
|
||||
75
frontend/src/components/Login.tsx
Normal file
75
frontend/src/components/Login.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// frontend/src/components/Login.tsx
|
||||
import React, { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { authService } from '../services/apiService';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import styles from './SimpleTable.module.css';
|
||||
|
||||
const Login = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 2. Pasar el estado del checkbox al servicio
|
||||
const data = await authService.login(username, password, rememberMe);
|
||||
// 3. Pasar el token Y el estado del checkbox al contexto
|
||||
login(data.token, rememberMe);
|
||||
toast.success('¡Bienvenido!');
|
||||
} catch (error) {
|
||||
toast.error('Usuario o contraseña incorrectos.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay} style={{ animation: 'none' }}>
|
||||
<div className={styles.modal} style={{ animation: 'none' }}>
|
||||
<h3>Iniciar Sesión - Inventario IT</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label>Usuario</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className={styles.modalInput}
|
||||
required
|
||||
/>
|
||||
<label style={{ marginTop: '1rem' }}>Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={styles.modalInput}
|
||||
required
|
||||
/>
|
||||
<div style={{ marginTop: '1rem', display: 'flex', alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rememberMe"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
/>
|
||||
<label htmlFor="rememberMe" style={{ marginBottom: 0, fontWeight: 'normal' }}>
|
||||
Mantener sesión iniciada
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.modalActions} style={{ marginTop: '1.5rem' }}>
|
||||
<button type="submit" className={`${styles.btn} ${styles.btnPrimary}`} disabled={isLoading || !username || !password}>
|
||||
{isLoading ? 'Ingresando...' : 'Ingresar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
46
frontend/src/components/ModalAnadirDisco.tsx
Normal file
46
frontend/src/components/ModalAnadirDisco.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// frontend/src/components/ModalAnadirDisco.tsx
|
||||
import React, { useState } from 'react';
|
||||
import styles from './SimpleTable.module.css';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSave: (disco: { mediatype: string, size: number }) => void;
|
||||
}
|
||||
|
||||
const ModalAnadirDisco: React.FC<Props> = ({ onClose, onSave }) => {
|
||||
const [mediatype, setMediatype] = useState('SSD');
|
||||
const [size, setSize] = useState('');
|
||||
|
||||
const handleSave = () => {
|
||||
if (size && parseInt(size, 10) > 0) {
|
||||
onSave({ mediatype, size: parseInt(size, 10) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
|
||||
<div className={styles.modal}>
|
||||
<h3>Añadir Disco Manualmente</h3>
|
||||
<label>Tipo de Disco</label>
|
||||
<select value={mediatype} onChange={e => setMediatype(e.target.value)} className={styles.modalInput}>
|
||||
<option value="SSD">SSD</option>
|
||||
<option value="HDD">HDD</option>
|
||||
</select>
|
||||
<label>Tamaño (GB)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={size}
|
||||
onChange={e => setSize(e.target.value)}
|
||||
className={styles.modalInput}
|
||||
placeholder="Ej: 500"
|
||||
/>
|
||||
<div className={styles.modalActions}>
|
||||
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!size}>Guardar</button>
|
||||
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalAnadirDisco;
|
||||
137
frontend/src/components/ModalAnadirEquipo.tsx
Normal file
137
frontend/src/components/ModalAnadirEquipo.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// frontend/src/components/ModalAnadirEquipo.tsx
|
||||
|
||||
import React, { useState, useCallback } from 'react'; // <-- 1. Importar useCallback
|
||||
import type { Sector, Equipo } from '../types/interfaces';
|
||||
import AutocompleteInput from './AutocompleteInput';
|
||||
import styles from './SimpleTable.module.css';
|
||||
|
||||
interface ModalAnadirEquipoProps {
|
||||
sectores: Sector[];
|
||||
onClose: () => void;
|
||||
onSave: (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => void;
|
||||
}
|
||||
|
||||
const BASE_URL = '/api';
|
||||
|
||||
const ModalAnadirEquipo: React.FC<ModalAnadirEquipoProps> = ({ sectores, onClose, onSave }) => {
|
||||
const [nuevoEquipo, setNuevoEquipo] = useState({
|
||||
hostname: '',
|
||||
ip: '',
|
||||
motherboard: '',
|
||||
cpu: '',
|
||||
os: '',
|
||||
sector_id: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setNuevoEquipo(prev => ({
|
||||
...prev,
|
||||
[name]: name === 'sector_id' ? (value ? parseInt(value, 10) : undefined) : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveClick = () => {
|
||||
onSave(nuevoEquipo as any);
|
||||
};
|
||||
|
||||
const isFormValid = nuevoEquipo.hostname.trim() !== '' && nuevoEquipo.ip.trim() !== '';
|
||||
|
||||
// --- 2. Memorizar las funciones con useCallback ---
|
||||
// El array vacío `[]` al final asegura que la función NUNCA se vuelva a crear.
|
||||
const fetchMotherboardSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/motherboard`).then(res => res.json()), []);
|
||||
const fetchCpuSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/cpu`).then(res => res.json()), []);
|
||||
const fetchOsSuggestions = useCallback(() => fetch(`${BASE_URL}/equipos/distinct/os`).then(res => res.json()), []);
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal} style={{ minWidth: '500px' }}>
|
||||
<h3>Añadir Nuevo Equipo Manualmente</h3>
|
||||
|
||||
<label>Hostname (Requerido)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="hostname"
|
||||
value={nuevoEquipo.hostname}
|
||||
onChange={handleChange}
|
||||
className={styles.modalInput}
|
||||
placeholder="Ej: TECNICA10"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<label>Dirección IP (Requerido)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="ip"
|
||||
value={nuevoEquipo.ip}
|
||||
onChange={handleChange}
|
||||
className={styles.modalInput}
|
||||
placeholder="Ej: 192.168.10.50"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<label>Sector</label>
|
||||
<select
|
||||
name="sector_id"
|
||||
value={nuevoEquipo.sector_id || ''}
|
||||
onChange={handleChange}
|
||||
className={styles.modalInput}
|
||||
>
|
||||
<option value="">- Sin Asignar -</option>
|
||||
{sectores.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* --- 3. Usar las funciones memorizadas --- */}
|
||||
<label>Motherboard (Opcional)</label>
|
||||
<AutocompleteInput
|
||||
mode="static"
|
||||
name="motherboard"
|
||||
value={nuevoEquipo.motherboard}
|
||||
onChange={handleChange}
|
||||
className={styles.modalInput}
|
||||
fetchSuggestions={fetchMotherboardSuggestions}
|
||||
/>
|
||||
|
||||
<label>CPU (Opcional)</label>
|
||||
<AutocompleteInput
|
||||
mode="static"
|
||||
name="cpu"
|
||||
value={nuevoEquipo.cpu}
|
||||
onChange={handleChange}
|
||||
className={styles.modalInput}
|
||||
fetchSuggestions={fetchCpuSuggestions}
|
||||
/>
|
||||
|
||||
<label>Sistema Operativo (Opcional)</label>
|
||||
<AutocompleteInput
|
||||
mode="static"
|
||||
name="os"
|
||||
value={nuevoEquipo.os}
|
||||
onChange={handleChange}
|
||||
className={styles.modalInput}
|
||||
fetchSuggestions={fetchOsSuggestions}
|
||||
/>
|
||||
|
||||
<div className={styles.modalActions}>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={handleSaveClick}
|
||||
disabled={!isFormValid}
|
||||
>
|
||||
Guardar Equipo
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnSecondary}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalAnadirEquipo;
|
||||
107
frontend/src/components/ModalAnadirRam.tsx
Normal file
107
frontend/src/components/ModalAnadirRam.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
// frontend/src/components/ModalAnadirRam.tsx
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import styles from './SimpleTable.module.css';
|
||||
import AutocompleteInput from './AutocompleteInput';
|
||||
import { memoriaRamService } from '../services/apiService';
|
||||
import type { MemoriaRam } from '../types/interfaces';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSave: (ram: { slot: string, tamano: number, fabricante?: string, velocidad?: number, partNumber?: string }) => void;
|
||||
}
|
||||
|
||||
const ModalAnadirRam: React.FC<Props> = ({ onClose, onSave }) => {
|
||||
const [ram, setRam] = useState({
|
||||
slot: '',
|
||||
tamano: '',
|
||||
fabricante: '',
|
||||
velocidad: '',
|
||||
partNumber: ''
|
||||
});
|
||||
|
||||
const [allRamModules, setAllRamModules] = useState<MemoriaRam[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
memoriaRamService.getAll()
|
||||
.then(setAllRamModules)
|
||||
.catch(err => console.error("No se pudieron cargar los módulos de RAM", err));
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setRam(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const fetchRamSuggestions = useCallback(async () => {
|
||||
return allRamModules.map(r =>
|
||||
`${r.fabricante || 'Desconocido'} | ${r.tamano}GB | ${r.velocidad ? r.velocidad + 'MHz' : 'N/A'}`
|
||||
);
|
||||
}, [allRamModules]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedSuggestion = ram.partNumber;
|
||||
|
||||
const match = allRamModules.find(s =>
|
||||
`${s.fabricante || 'Desconocido'} | ${s.tamano}GB | ${s.velocidad ? s.velocidad + 'MHz' : 'N/A'}` === selectedSuggestion
|
||||
);
|
||||
|
||||
if (match) {
|
||||
setRam(prev => ({
|
||||
...prev,
|
||||
fabricante: match.fabricante || '',
|
||||
tamano: match.tamano.toString(),
|
||||
velocidad: match.velocidad?.toString() || '',
|
||||
partNumber: match.partNumber || ''
|
||||
}));
|
||||
}
|
||||
}, [ram.partNumber, allRamModules]);
|
||||
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
slot: ram.slot,
|
||||
tamano: parseInt(ram.tamano, 10),
|
||||
fabricante: ram.fabricante || undefined,
|
||||
velocidad: ram.velocidad ? parseInt(ram.velocidad, 10) : undefined,
|
||||
partNumber: ram.partNumber || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
|
||||
<div className={styles.modal}>
|
||||
<h3>Añadir Módulo de RAM</h3>
|
||||
|
||||
<label>Slot (Requerido)</label>
|
||||
<input type="text" name="slot" value={ram.slot} onChange={handleChange} className={styles.modalInput} placeholder="Ej: DIMM0" />
|
||||
|
||||
<label>Buscar Módulo Existente (Opcional)</label>
|
||||
<AutocompleteInput
|
||||
mode="static"
|
||||
name="partNumber"
|
||||
value={ram.partNumber}
|
||||
onChange={handleChange}
|
||||
className={styles.modalInput}
|
||||
fetchSuggestions={fetchRamSuggestions}
|
||||
placeholder="Clic para ver todos o escribe para filtrar"
|
||||
/>
|
||||
|
||||
<label>Tamaño (GB) (Requerido)</label>
|
||||
<input type="number" name="tamano" value={ram.tamano} onChange={handleChange} className={styles.modalInput} placeholder="Ej: 8" />
|
||||
|
||||
<label>Fabricante</label>
|
||||
<input type="text" name="fabricante" value={ram.fabricante} onChange={handleChange} className={styles.modalInput} />
|
||||
|
||||
<label>Velocidad (MHz)</label>
|
||||
<input type="number" name="velocidad" value={ram.velocidad} onChange={handleChange} className={styles.modalInput} />
|
||||
|
||||
<div className={styles.modalActions}>
|
||||
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!ram.slot || !ram.tamano}>Guardar</button>
|
||||
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalAnadirRam;
|
||||
48
frontend/src/components/ModalAnadirUsuario.tsx
Normal file
48
frontend/src/components/ModalAnadirUsuario.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// frontend/src/components/ModalAnadirUsuario.tsx
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import styles from './SimpleTable.module.css';
|
||||
import AutocompleteInput from './AutocompleteInput';
|
||||
import { usuarioService } from '../services/apiService';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSave: (usuario: { username: string }) => void;
|
||||
}
|
||||
|
||||
const ModalAnadirUsuario: React.FC<Props> = ({ onClose, onSave }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
|
||||
const fetchUserSuggestions = useCallback(async (query: string): Promise<string[]> => {
|
||||
if (!query) return [];
|
||||
try {
|
||||
return await usuarioService.search(query);
|
||||
} catch (error) {
|
||||
console.error("Error buscando usuarios", error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`${styles.modalOverlay} ${styles['modalOverlay--nested']}`}>
|
||||
<div className={styles.modal}>
|
||||
<h3>Añadir Usuario Manualmente</h3>
|
||||
<label>Nombre de Usuario</label>
|
||||
<AutocompleteInput
|
||||
mode="dynamic"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className={styles.modalInput}
|
||||
fetchSuggestions={fetchUserSuggestions}
|
||||
placeholder="Escribe para buscar o crear un nuevo usuario"
|
||||
/>
|
||||
<div className={styles.modalActions}>
|
||||
<button onClick={() => onSave({ username })} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!username.trim()}>Guardar</button>
|
||||
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalAnadirUsuario;
|
||||
65
frontend/src/components/ModalCambiarClave.tsx
Normal file
65
frontend/src/components/ModalCambiarClave.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// frontend/src/components/ModalCambiarClave.tsx
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import type { Usuario } from '../types/interfaces';
|
||||
import styles from './SimpleTable.module.css';
|
||||
|
||||
interface ModalCambiarClaveProps {
|
||||
usuario: Usuario; // El componente padre asegura que esto no sea nulo
|
||||
onClose: () => void;
|
||||
onSave: (password: string) => void;
|
||||
}
|
||||
|
||||
const ModalCambiarClave: React.FC<ModalCambiarClaveProps> = ({ usuario, onClose, onSave }) => {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Enfocar el input cuando el modal se abre
|
||||
setTimeout(() => passwordInputRef.current?.focus(), 100);
|
||||
}, []);
|
||||
|
||||
const handleSaveClick = () => {
|
||||
if (newPassword.trim()) {
|
||||
onSave(newPassword);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<h3>
|
||||
Cambiar contraseña para {usuario.username}
|
||||
</h3>
|
||||
<label>
|
||||
Nueva contraseña:
|
||||
<input
|
||||
type="text"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className={styles.modalInput}
|
||||
placeholder="Ingrese la nueva contraseña"
|
||||
ref={passwordInputRef}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveClick()}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.modalActions}>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={handleSaveClick}
|
||||
disabled={!newPassword.trim()}
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnSecondary}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalCambiarClave;
|
||||
265
frontend/src/components/ModalDetallesEquipo.tsx
Normal file
265
frontend/src/components/ModalDetallesEquipo.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
// frontend/src/components/ModalDetallesEquipo.tsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import type { Equipo, HistorialEquipo, Sector } from '../types/interfaces';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import styles from './SimpleTable.module.css';
|
||||
import toast from 'react-hot-toast';
|
||||
import AutocompleteInput from './AutocompleteInput';
|
||||
import { equipoService } from '../services/apiService';
|
||||
import { X, Pencil, HardDrive, MemoryStick, UserPlus, Trash2, Power, Info, Component, Keyboard, Cog, Zap, History } from 'lucide-react';
|
||||
|
||||
interface ModalDetallesEquipoProps {
|
||||
equipo: Equipo;
|
||||
isOnline: boolean;
|
||||
historial: HistorialEquipo[];
|
||||
sectores: Sector[];
|
||||
isChildModalOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDelete: (id: number) => Promise<boolean>;
|
||||
onRemoveAssociation: (type: 'disco' | 'ram' | 'usuario', id: any) => void;
|
||||
onEdit: (id: number, equipoEditado: any) => Promise<boolean>;
|
||||
onAddComponent: (type: 'disco' | 'ram' | 'usuario') => void;
|
||||
}
|
||||
|
||||
const ModalDetallesEquipo: React.FC<ModalDetallesEquipoProps> = ({
|
||||
equipo, isOnline, historial, sectores, isChildModalOpen,
|
||||
onClose, onDelete, onRemoveAssociation, onEdit, onAddComponent
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editableEquipo, setEditableEquipo] = useState({ ...equipo });
|
||||
const [isMacValid, setIsMacValid] = useState(true);
|
||||
const macRegex = /^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$/;
|
||||
|
||||
useEffect(() => {
|
||||
if (editableEquipo.mac && editableEquipo.mac.length > 0) {
|
||||
setIsMacValid(macRegex.test(editableEquipo.mac));
|
||||
} else {
|
||||
setIsMacValid(true);
|
||||
}
|
||||
}, [editableEquipo.mac]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditableEquipo({ ...equipo });
|
||||
}, [equipo]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setEditableEquipo(prev => ({
|
||||
...prev,
|
||||
[name]: name === 'sector_id' ? (value ? parseInt(value, 10) : null) : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMacBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
let value = e.target.value;
|
||||
let cleaned = value.replace(/[^0-9A-Fa-f]/gi, '').toUpperCase().substring(0, 12);
|
||||
if (cleaned.length === 12) {
|
||||
value = cleaned.match(/.{1,2}/g)?.join(':') || '';
|
||||
} else {
|
||||
value = cleaned;
|
||||
}
|
||||
setEditableEquipo(prev => ({ ...prev, mac: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isMacValid) {
|
||||
toast.error("El formato de la MAC Address es incorrecto.");
|
||||
return;
|
||||
}
|
||||
const success = await onEdit(equipo.id, editableEquipo);
|
||||
if (success) setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditableEquipo({ ...equipo });
|
||||
setIsMacValid(true);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
setEditableEquipo({ ...equipo });
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleWolClick = async () => {
|
||||
if (!equipo.mac || !equipo.ip) {
|
||||
toast.error("Este equipo no tiene MAC o IP para encenderlo.");
|
||||
return;
|
||||
}
|
||||
const toastId = toast.loading('Enviando paquete WOL...');
|
||||
try {
|
||||
await equipoService.wakeOnLan(equipo.mac, equipo.ip);
|
||||
toast.success('Solicitud de encendido enviada.', { id: toastId });
|
||||
} catch (error) {
|
||||
toast.error('Error al enviar la solicitud.', { id: toastId });
|
||||
console.error('Error al enviar la solicitud WOL:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = async () => {
|
||||
const success = await onDelete(equipo.id);
|
||||
if (success) onClose();
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | undefined | null) => {
|
||||
if (!dateString || dateString.startsWith('0001-01-01')) return 'No registrado';
|
||||
const utcDate = new Date(dateString.replace(' ', 'T') + 'Z');
|
||||
return utcDate.toLocaleString('es-ES', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const isSaveDisabled = !editableEquipo.hostname || !editableEquipo.ip || !isMacValid;
|
||||
|
||||
const fetchOsSuggestions = useCallback(() => equipoService.getDistinctValues('os'), []);
|
||||
const fetchMotherboardSuggestions = useCallback(() => equipoService.getDistinctValues('motherboard'), []);
|
||||
const fetchCpuSuggestions = useCallback(() => equipoService.getDistinctValues('cpu'), []);
|
||||
|
||||
return (
|
||||
<div className={styles.modalLarge}>
|
||||
<button onClick={onClose} className={styles.closeButton} disabled={isChildModalOpen}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<div className={styles.modalLargeContent}>
|
||||
<div className={styles.modalLargeHeader}>
|
||||
<h2>Detalles del equipo: <strong>{equipo.hostname}</strong></h2>
|
||||
{equipo.origen === 'manual' && (
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={isSaveDisabled}>Guardar Cambios</button>
|
||||
<button onClick={handleCancel} className={`${styles.btn} ${styles.btnSecondary}`}>Cancelar</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={handleEditClick} className={`${styles.btn} ${styles.btnPrimary}`} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}><Pencil size={16} /> Editar</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.modalBodyColumns}>
|
||||
<div className={styles.mainColumn}>
|
||||
<div className={styles.section}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 className={styles.sectionTitle} style={{ border: 'none', padding: 0 }}><Info size={20} /> Datos Principales</h3>
|
||||
{equipo.origen === 'manual' && (
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
<button onClick={() => onAddComponent('disco')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><HardDrive size={16} /> Disco</button>
|
||||
<button onClick={() => onAddComponent('ram')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><MemoryStick size={16} /> RAM</button>
|
||||
<button onClick={() => onAddComponent('usuario')} className={styles.tableButtonMas} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}><UserPlus size={16} /> Usuario</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.componentsGrid}>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Hostname:</strong>{isEditing ? <input type="text" name="hostname" value={editableEquipo.hostname} onChange={handleChange} className={styles.modalInput} autoComplete="off" /> : <span className={styles.detailValue}>{equipo.hostname}</span>}</div>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>IP:</strong>{isEditing ? <input type="text" name="ip" value={editableEquipo.ip} onChange={handleChange} className={styles.modalInput} autoComplete="off" /> : <span className={styles.detailValue}>{equipo.ip}</span>}</div>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>MAC Address:</strong>{isEditing ? (<div><input type="text" name="mac" value={editableEquipo.mac || ''} onChange={handleChange} onBlur={handleMacBlur} className={`${styles.modalInput} ${!isMacValid ? styles.inputError : ''}`} placeholder="FC:AA:14:92:12:99" />{!isMacValid && <small className={styles.errorMessage}>Formato inválido.</small>}</div>) : (<span className={styles.detailValue}>{equipo.mac || 'N/A'}</span>)}</div>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Sistema Operativo:</strong>{isEditing ? <AutocompleteInput mode="static" name="os" value={editableEquipo.os} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchOsSuggestions} /> : <span className={styles.detailValue}>{equipo.os || 'N/A'}</span>}</div>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Sector:</strong>{isEditing ? <select name="sector_id" value={editableEquipo.sector_id || ''} onChange={handleChange} className={styles.modalInput}><option value="">- Sin Asignar -</option>{sectores.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}</select> : <span className={styles.detailValue}>{equipo.sector?.nombre || 'No asignado'}</span>}</div>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Creación:</strong><span className={styles.detailValue}>{formatDate(equipo.created_at)}</span></div>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Última Actualización:</strong><span className={styles.detailValue}>{formatDate(equipo.updated_at)}</span></div>
|
||||
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Usuarios:</strong><span className={styles.detailValue}>{equipo.usuarios?.length > 0 ? equipo.usuarios.map(u => (<div key={u.id} className={styles.componentItem}><div><span title={`Origen: ${u.origen}`}>{u.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` ${u.username}`}</div>{u.origen === 'manual' && (<button onClick={() => onRemoveAssociation('usuario', { equipoId: equipo.id, usuarioId: u.id })} className={styles.deleteUserButton} title="Quitar este usuario"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}><Component size={20} /> Componentes</h3>
|
||||
<div className={styles.detailsGrid}>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Motherboard:</strong>{isEditing ? <AutocompleteInput mode="static" name="motherboard" value={editableEquipo.motherboard} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchMotherboardSuggestions} /> : <span className={styles.detailValue}>{equipo.motherboard || 'N/A'}</span>}</div>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>CPU:</strong>{isEditing ? <AutocompleteInput mode="static" name="cpu" value={editableEquipo.cpu} onChange={handleChange} className={styles.modalInput} fetchSuggestions={fetchCpuSuggestions} /> : <span className={styles.detailValue}>{equipo.cpu || 'N/A'}</span>}</div>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>RAM Instalada:</strong><span className={styles.detailValue}>{equipo.ram_installed} GB</span></div>
|
||||
<div className={styles.detailItem}>
|
||||
<strong className={styles.detailLabel}>Arquitectura:</strong>
|
||||
{isEditing ? (
|
||||
<select
|
||||
name="architecture"
|
||||
value={editableEquipo.architecture || ''}
|
||||
onChange={handleChange}
|
||||
className={styles.modalInput}
|
||||
>
|
||||
<option value="">- Seleccionar -</option>
|
||||
<option value="64 bits">64 bits</option>
|
||||
<option value="32 bits">32 bits</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className={styles.detailValue}>{equipo.architecture || 'N/A'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Discos:</strong><span className={styles.detailValue}>{equipo.discos?.length > 0 ? equipo.discos.map(d => (<div key={d.equipoDiscoId} className={styles.componentItem}><div><span title={`Origen: ${d.origen}`}>{d.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` ${d.mediatype} ${d.size}GB`}</div>{d.origen === 'manual' && (<button onClick={() => onRemoveAssociation('disco', d.equipoDiscoId)} className={styles.deleteUserButton} title="Eliminar este disco"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
|
||||
<div className={styles.detailItem}>
|
||||
<strong className={styles.detailLabel}>Total Slots RAM:</strong>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
name="ram_slots"
|
||||
value={editableEquipo.ram_slots || ''}
|
||||
onChange={handleChange}
|
||||
className={styles.modalInput}
|
||||
placeholder="Ej: 4"
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.detailValue}>{equipo.ram_slots || 'N/A'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.detailItemFull}><strong className={styles.detailLabel}>Módulos RAM:</strong><span className={styles.detailValue}>{equipo.memoriasRam?.length > 0 ? equipo.memoriasRam.map(m => (<div key={m.equipoMemoriaRamId} className={styles.componentItem}><div><span title={`Origen: ${m.origen}`}>{m.origen === 'manual' ? <Keyboard size={16} /> : <Cog size={16} />}</span>{` Slot ${m.slot}: ${m.partNumber || 'N/P'} ${m.tamano}GB ${m.velocidad || ''}MHz`}</div>{m.origen === 'manual' && (<button onClick={() => onRemoveAssociation('ram', m.equipoMemoriaRamId)} className={styles.deleteUserButton} title="Eliminar este módulo"><Trash2 size={16} /></button>)}</div>)) : 'N/A'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sidebarColumn}>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}><Zap size={20} /> Acciones y Estado</h3>
|
||||
<div className={styles.actionsGrid}>
|
||||
<div className={styles.detailItem}><strong className={styles.detailLabel}>Estado:</strong><div className={styles.statusIndicator}><div className={`${styles.statusDot} ${isOnline ? styles.statusOnline : styles.statusOffline}`} /><span>{isOnline ? 'En línea' : 'Sin conexión'}</span></div></div>
|
||||
|
||||
<div className={styles.detailItem}>
|
||||
<strong className={styles.detailLabel}>Wake On Lan:</strong>
|
||||
<button
|
||||
onClick={handleWolClick}
|
||||
className={styles.powerButton}
|
||||
data-tooltip-id="modal-power-tooltip"
|
||||
disabled={!equipo.mac}
|
||||
>
|
||||
<Power size={18} />
|
||||
Encender (WOL)
|
||||
</button>
|
||||
<Tooltip id="modal-power-tooltip" place="top">
|
||||
{equipo.mac ? 'Encender equipo remotamente' : 'Se requiere una dirección MAC para esta acción'}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={styles.detailItem}>
|
||||
<strong className={styles.detailLabel}>Eliminar Equipo:</strong>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className={styles.deleteButton}
|
||||
data-tooltip-id="modal-delete-tooltip"
|
||||
>
|
||||
<Trash2 size={16} /> Eliminar
|
||||
</button>
|
||||
<Tooltip id="modal-delete-tooltip" place="top">
|
||||
Eliminar este equipo permanentemente del inventario
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.section} ${styles.historySectionFullWidth}`}>
|
||||
<h3 className={styles.sectionTitle}><History size={20} /> Historial de cambios</h3>
|
||||
<div className={styles.historyContainer}>
|
||||
<table className={styles.historyTable}>
|
||||
<thead><tr><th className={styles.historyTh}>Fecha</th><th className={styles.historyTh}>Campo</th><th className={styles.historyTh}>Valor anterior</th><th className={styles.historyTh}>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} className={styles.historyTr}><td className={styles.historyTd}>{formatDate(cambio.fecha_cambio)}</td><td className={styles.historyTd}>{cambio.campo_modificado}</td><td className={styles.historyTd}>{cambio.valor_anterior}</td><td className={styles.historyTd}>{cambio.valor_nuevo}</td></tr>))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalDetallesEquipo;
|
||||
57
frontend/src/components/ModalEditarSector.tsx
Normal file
57
frontend/src/components/ModalEditarSector.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// frontend/src/components/ModalEditarSector.tsx
|
||||
import React from 'react';
|
||||
import type { Equipo, Sector } from '../types/interfaces';
|
||||
import styles from './SimpleTable.module.css';
|
||||
|
||||
interface ModalEditarSectorProps {
|
||||
modalData: Equipo; // El componente padre asegura que esto no sea nulo
|
||||
setModalData: (data: Equipo) => void;
|
||||
sectores: Sector[];
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const ModalEditarSector: React.FC<ModalEditarSectorProps> = ({ modalData, setModalData, sectores, onClose, onSave }) => {
|
||||
return (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<h3>Editar Sector para {modalData.hostname}</h3>
|
||||
<label>
|
||||
Sector:
|
||||
<select
|
||||
className={styles.modalInput}
|
||||
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 className={styles.modalActions}>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={onSave}
|
||||
disabled={!modalData.sector}
|
||||
>
|
||||
Guardar cambios
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnSecondary}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalEditarSector;
|
||||
59
frontend/src/components/ModalSector.tsx
Normal file
59
frontend/src/components/ModalSector.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import type { Sector } from '../types/interfaces';
|
||||
import styles from './SimpleTable.module.css';
|
||||
|
||||
interface Props {
|
||||
// Si 'sector' es nulo, es para crear. Si tiene datos, es para editar.
|
||||
sector: Sector | null;
|
||||
onClose: () => void;
|
||||
onSave: (id: number | null, nombre: string) => void;
|
||||
}
|
||||
|
||||
const ModalSector: React.FC<Props> = ({ sector, onClose, onSave }) => {
|
||||
const [nombre, setNombre] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isEditing = sector !== null;
|
||||
|
||||
useEffect(() => {
|
||||
// Si estamos editando, rellenamos el campo con el nombre actual
|
||||
if (isEditing) {
|
||||
setNombre(sector.nombre);
|
||||
}
|
||||
// Enfocar el input al abrir el modal
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}, [sector, isEditing]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (nombre.trim()) {
|
||||
onSave(isEditing ? sector.id : null, nombre.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<h3>{isEditing ? 'Editar Sector' : 'Añadir Nuevo Sector'}</h3>
|
||||
<label>Nombre del Sector</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={nombre}
|
||||
onChange={e => setNombre(e.target.value)}
|
||||
className={styles.modalInput}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||
/>
|
||||
<div className={styles.modalActions}>
|
||||
<button onClick={handleSave} className={`${styles.btn} ${styles.btnPrimary}`} disabled={!nombre.trim()}>
|
||||
Guardar
|
||||
</button>
|
||||
<button onClick={onClose} className={`${styles.btn} ${styles.btnSecondary}`}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalSector;
|
||||
61
frontend/src/components/Navbar.tsx
Normal file
61
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// frontend/src/components/Navbar.tsx
|
||||
import React from 'react';
|
||||
import type { View } from '../App';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import '../App.css';
|
||||
|
||||
interface NavbarProps {
|
||||
currentView: View;
|
||||
setCurrentView: (view: View) => void;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ currentView, setCurrentView }) => {
|
||||
const { logout } = useAuth();
|
||||
return (
|
||||
<header className="navbar">
|
||||
<div className="app-title">
|
||||
Inventario IT
|
||||
</div>
|
||||
<nav className="nav-links">
|
||||
<button
|
||||
className={`nav-link ${currentView === 'equipos' ? 'nav-link-active' : ''}`}
|
||||
onClick={() => setCurrentView('equipos')}
|
||||
>
|
||||
Equipos
|
||||
</button>
|
||||
<button
|
||||
className={`nav-link ${currentView === 'sectores' ? 'nav-link-active' : ''}`}
|
||||
onClick={() => setCurrentView('sectores')}
|
||||
>
|
||||
Sectores
|
||||
</button>
|
||||
<button
|
||||
className={`nav-link ${currentView === 'admin' ? 'nav-link-active' : ''}`}
|
||||
onClick={() => setCurrentView('admin')}
|
||||
>
|
||||
Administración
|
||||
</button>
|
||||
<button
|
||||
className={`nav-link ${currentView === 'dashboard' ? 'nav-link-active' : ''}`}
|
||||
onClick={() => setCurrentView('dashboard')}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: '1rem' }}>
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={logout}
|
||||
className="theme-toggle-button"
|
||||
title="Cerrar sesión"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
49
frontend/src/components/OsChart.tsx
Normal file
49
frontend/src/components/OsChart.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title } from 'chart.js';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import type { StatItem } from '../types/interfaces';
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend, Title);
|
||||
|
||||
interface Props {
|
||||
data: StatItem[];
|
||||
}
|
||||
|
||||
const OsChart: React.FC<Props> = ({ data }) => {
|
||||
const chartData = {
|
||||
labels: data.map(item => item.label),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Nº de Equipos',
|
||||
data: data.map(item => item.count),
|
||||
backgroundColor: [
|
||||
'#4E79A7', '#F28E2B', '#E15759', '#76B7B2', '#59A14F',
|
||||
'#EDC948', '#B07AA1', '#FF9DA7', '#9C755F', '#BAB0AC'
|
||||
],
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right' as const, // <-- ¡CAMBIO CLAVE! Mueve la leyenda a la derecha
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Distribución por Sistema Operativo',
|
||||
font: {
|
||||
size: 16
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return <Doughnut data={chartData} options={options} />;
|
||||
};
|
||||
|
||||
export default OsChart;
|
||||
55
frontend/src/components/RamChart.tsx
Normal file
55
frontend/src/components/RamChart.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import type { StatItem } from '../types/interfaces';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
||||
|
||||
interface Props {
|
||||
data: StatItem[];
|
||||
}
|
||||
|
||||
const RamChart: React.FC<Props> = ({ data }) => {
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Distribución de Memoria RAM',
|
||||
font: {
|
||||
size: 16
|
||||
}
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 2 // Escala de 2 en 2 para que sea más legible
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
// Formateamos la etiqueta para que diga "X GB"
|
||||
labels: data.map(item => `${item.label} GB`),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Nº de Equipos',
|
||||
data: data.map(item => item.count),
|
||||
backgroundColor: 'rgba(225, 87, 89, 0.8)', // Un color rojo/coral
|
||||
borderColor: 'rgba(225, 87, 89, 1)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <Bar options={options} data={chartData} />;
|
||||
};
|
||||
|
||||
export default RamChart;
|
||||
58
frontend/src/components/SectorChart.tsx
Normal file
58
frontend/src/components/SectorChart.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import type { StatItem } from '../types/interfaces';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
|
||||
|
||||
interface Props {
|
||||
data: StatItem[];
|
||||
}
|
||||
|
||||
const SectorChart: React.FC<Props> = ({ data }) => {
|
||||
// Altura dinámica: 40px de base + 25px por cada sector
|
||||
const chartHeight = 40 + data.length * 25;
|
||||
|
||||
const options = {
|
||||
indexAxis: 'y' as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Equipos por Sector',
|
||||
font: {
|
||||
size: 16
|
||||
}
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { beginAtZero: true, ticks: { stepSize: 2 } }
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(item => item.label),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Nº de Equipos',
|
||||
data: data.map(item => item.count),
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.8)',
|
||||
borderColor: 'rgba(76, 175, 80, 1)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Envolvemos la barra en un div con altura calculada
|
||||
return (
|
||||
<div style={{ position: 'relative', height: `${chartHeight}px`, minHeight: '400px' }}>
|
||||
<Bar options={options} data={chartData} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectorChart;
|
||||
781
frontend/src/components/SimpleTable.module.css
Normal file
781
frontend/src/components/SimpleTable.module.css
Normal file
@@ -0,0 +1,781 @@
|
||||
/* frontend/src/components/SimpleTable.module.css */
|
||||
|
||||
/* Estilos para el contenedor principal y controles */
|
||||
.header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.controlsContainer {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchInput, .sectorSelect {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 14px;
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
overflow: auto; /* Cambiado a 'auto' para ambos scrolls */
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
position: relative; /* Necesario para posicionar el botón de scroll */
|
||||
}
|
||||
|
||||
.tableContainer::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
background-color: var(--scrollbar-bg);
|
||||
}
|
||||
|
||||
.tableContainer::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.table {
|
||||
border-collapse: collapse;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.th {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
background-color: var(--color-background);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 5px;
|
||||
background: var(--scrollbar-thumb);
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
opacity: 0.25;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
z-index: 3; /* Asegura que el resizer esté sobre el contenido de la cabecera */
|
||||
}
|
||||
|
||||
.th:hover .resizer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.isResizing {
|
||||
background: var(--color-text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.sortIndicator {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 1.2em;
|
||||
display: inline-block;
|
||||
transform: translateY(-1px);
|
||||
color: var(--color-primary);
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.tr {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tr:hover {
|
||||
background-color: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
background-color: var(--color-surface);
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Estilos de botones dentro de la tabla */
|
||||
.hostnameButton {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.hostnameButton:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
|
||||
.tableButton {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: transparent;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tableButton:hover {
|
||||
background-color: var(--color-surface-hover);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tableButtonMas {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-primary);
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-navbar-text-hover);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tableButtonMas:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.deleteUserButton {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-danger);
|
||||
font-size: 1rem;
|
||||
padding: 0 5px;
|
||||
transition: opacity 0.3s ease, color 0.3s ease, background-color 0.3s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.deleteUserButton:hover:not(:disabled) {
|
||||
color: var(--color-danger-hover);
|
||||
}
|
||||
|
||||
.deleteUserButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Estilo para el botón de scroll-to-top */
|
||||
.scrollToTop {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 20px; /* Suficiente espacio para no quedar debajo de la scrollbar */
|
||||
z-index: 30; /* Un valor alto para asegurar que esté por encima de la tabla y su cabecera (z-index: 2) */
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8%;
|
||||
|
||||
/* Contenido y transiciones */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out, background-color 0.2s, color 0.2s;
|
||||
|
||||
/* Animación de entrada/salida */
|
||||
animation: pop-in 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.scrollToTop:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
|
||||
/* ===== INICIO DE CAMBIOS PARA MODALES Y ANIMACIONES ===== */
|
||||
|
||||
/* Keyframes para la animación de entrada */
|
||||
@keyframes pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Estilos genéricos para modales */
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
/* Aplicamos animación */
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12);
|
||||
z-index: 1000;
|
||||
min-width: 400px;
|
||||
max-width: 90%;
|
||||
border: 1px solid var(--color-border);
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
/* Aplicamos animación */
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.modal label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modalInput {
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-background); /* Ligeramente diferente para contraste */
|
||||
color: var(--color-text-primary);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 4px;
|
||||
/* Separado del label */
|
||||
margin-bottom: 4px;
|
||||
/* Espacio antes del siguiente elemento */
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
/* Transición para el foco */
|
||||
}
|
||||
|
||||
.modalInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
|
||||
.modalActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: flex-end;
|
||||
/* Alinea los botones a la derecha por defecto */
|
||||
}
|
||||
|
||||
/* Estilos de botones para modales */
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
.btnPrimary:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
.btnPrimary:disabled {
|
||||
background-color: var(--color-surface-hover);
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnSecondary {
|
||||
background-color: var(--color-text-muted);
|
||||
color: white;
|
||||
}
|
||||
.btnSecondary:hover {
|
||||
background-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ===== ESTILOS PARA EL MODAL DE DETALLES ===== */
|
||||
|
||||
.modalLarge {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--color-background);
|
||||
/* Un fondo ligeramente gris para el modal */
|
||||
z-index: 1003;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
/* Animación ligeramente más lenta para pantalla completa */
|
||||
}
|
||||
|
||||
.modalLargeContent {
|
||||
max-width: 1400px;
|
||||
/* Ancho máximo del contenido */
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
/* Centrar el contenido */
|
||||
}
|
||||
|
||||
.modalLargeHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.05rem;
|
||||
padding-bottom: 0.05rem;
|
||||
}
|
||||
|
||||
.modalLargeHeader h2 {
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
background: black;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1004;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.2s, background-color 0.2s;
|
||||
position: fixed;
|
||||
right: 30px;
|
||||
top: 30px;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
transform: scale(1.1);
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.modalBodyColumns {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.mainColumn {
|
||||
flex: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.sidebarColumn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 1rem 0;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detailsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.componentsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.actionsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detailItem,
|
||||
.detailItemFull {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.componentItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.powerButton,
|
||||
.deleteButton {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.powerButton {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.powerButton:hover {
|
||||
border-color: var(--color-primary);
|
||||
background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.powerIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
color: var(--color-danger);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.deleteButton:hover {
|
||||
border-color: var(--color-danger);
|
||||
background-color: var(--color-danger-background);
|
||||
color: var(--color-danger-hover);
|
||||
}
|
||||
|
||||
.deleteButton:disabled {
|
||||
color: #6c757d;
|
||||
background-color: #e9ecef;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
.historyContainer {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.historyTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.historyTh {
|
||||
background-color: var(--color-background);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.historyTd {
|
||||
padding: 12px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.historyTr:last-child .historyTd {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.historySectionFullWidth {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.statusIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.statusOnline {
|
||||
background-color: #28a745;
|
||||
box-shadow: 0 0 8px #28a74580;
|
||||
}
|
||||
|
||||
.statusOffline {
|
||||
background-color: #dc3545;
|
||||
box-shadow: 0 0 8px #dc354580;
|
||||
}
|
||||
|
||||
.inputError {
|
||||
border-color: #dc3545;
|
||||
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: #dc3545;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.userList {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.userItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 4px 0;
|
||||
padding: 6px;
|
||||
background-color: var(--color-background);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.userActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sectorContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sectorName {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sectorNameAssigned {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.sectorNameUnassigned {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.modalOverlay--nested {
|
||||
z-index: 1005;
|
||||
}
|
||||
|
||||
.modalOverlay--nested .modal {
|
||||
z-index: 1006;
|
||||
}
|
||||
|
||||
.closeButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.columnToggleContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.columnToggleDropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.columnToggleItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.columnToggleItem:hover {
|
||||
background-color: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.columnToggleItem input {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.columnToggleItem label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- ESTILOS PARA GESTIÓN DE COMPONENTES --- */
|
||||
|
||||
/* Estilos para la columna numérica (Nº de Equipos) */
|
||||
.thNumeric,
|
||||
.tdNumeric {
|
||||
text-align: Center; /* Es buena práctica alinear números a la derecha */
|
||||
padding-right: 2rem; /* Un poco más de espacio para que no se pegue a las acciones */
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Estilos para la columna de acciones */
|
||||
.thActions,
|
||||
.tdActions {
|
||||
text-align: center; /* Centramos el título 'Acciones' y los botones */
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- MODAL DE UNIFICAR --- */
|
||||
|
||||
.highlightBox {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-border-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border); /* Un borde sutil para definirlo */
|
||||
}
|
||||
661
frontend/src/components/SimpleTable.tsx
Normal file
661
frontend/src/components/SimpleTable.tsx
Normal file
@@ -0,0 +1,661 @@
|
||||
// frontend/src/components/SimpleTable.tsx
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel,
|
||||
getPaginationRowModel, flexRender, type CellContext,
|
||||
type ColumnDef, type VisibilityState, type ColumnSizingState
|
||||
} from '@tanstack/react-table';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { Equipo, Sector, Usuario, UsuarioEquipoDetalle } from '../types/interfaces';
|
||||
import styles from './SimpleTable.module.css';
|
||||
import skeletonStyles from './Skeleton.module.css';
|
||||
|
||||
import { accentInsensitiveFilter } from '../utils/filtering';
|
||||
import { equipoService, sectorService, usuarioService } from '../services/apiService';
|
||||
import { PlusCircle, KeyRound, UserX, Pencil, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Columns3 } from 'lucide-react';
|
||||
|
||||
import ModalAnadirEquipo from './ModalAnadirEquipo';
|
||||
import ModalEditarSector from './ModalEditarSector';
|
||||
import ModalCambiarClave from './ModalCambiarClave';
|
||||
import ModalDetallesEquipo from './ModalDetallesEquipo';
|
||||
import ModalAnadirDisco from './ModalAnadirDisco';
|
||||
import ModalAnadirRam from './ModalAnadirRam';
|
||||
import ModalAnadirUsuario from './ModalAnadirUsuario';
|
||||
import TableSkeleton from './TableSkeleton';
|
||||
|
||||
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 [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [selectedEquipo, setSelectedEquipo] = useState<Equipo | null>(null);
|
||||
const [historial, setHistorial] = useState<any[]>([]);
|
||||
const [isOnline, setIsOnline] = useState(false);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [addingComponent, setAddingComponent] = useState<'disco' | 'ram' | 'usuario' | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isColumnToggleOpen, setIsColumnToggleOpen] = useState(false);
|
||||
const columnToggleRef = useRef<HTMLDivElement>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => {
|
||||
const storedVisibility = localStorage.getItem('table-column-visibility');
|
||||
return storedVisibility ? JSON.parse(storedVisibility) : { id: false, mac: false, os: false, arch: false };
|
||||
});
|
||||
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() => {
|
||||
const storedSizing = localStorage.getItem('table-column-sizing');
|
||||
return storedSizing ? JSON.parse(storedSizing) : {};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('table-column-visibility', JSON.stringify(columnVisibility));
|
||||
}, [columnVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('table-column-sizing', JSON.stringify(columnSizing));
|
||||
}, [columnSizing]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (columnToggleRef.current && !columnToggleRef.current.contains(event.target as Node)) {
|
||||
setIsColumnToggleOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const refreshHistory = async (hostname: string) => {
|
||||
try {
|
||||
const data = await equipoService.getHistory(hostname);
|
||||
setHistorial(data.historial);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing history:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
if (selectedEquipo || modalData || modalPasswordData || isAddModalOpen) {
|
||||
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, isAddModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEquipo) return;
|
||||
let isMounted = true;
|
||||
const checkPing = async () => {
|
||||
if (!selectedEquipo.ip) return;
|
||||
try {
|
||||
const data = await equipoService.ping(selectedEquipo.ip);
|
||||
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]);
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (addingComponent) {
|
||||
toast.error("Debes cerrar la ventana de añadir componente primero.");
|
||||
return;
|
||||
}
|
||||
setSelectedEquipo(null);
|
||||
setIsOnline(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEquipo) {
|
||||
equipoService.getHistory(selectedEquipo.hostname)
|
||||
.then(data => setHistorial(data.historial))
|
||||
.catch(error => console.error('Error fetching history:', error));
|
||||
}
|
||||
}, [selectedEquipo]);
|
||||
|
||||
useEffect(() => {
|
||||
const tableElement = tableContainerRef.current;
|
||||
const handleScroll = () => {
|
||||
if (tableElement) {
|
||||
setShowScrollButton(tableElement.scrollTop > 200);
|
||||
}
|
||||
};
|
||||
tableElement?.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
tableElement?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
Promise.all([
|
||||
equipoService.getAll(),
|
||||
sectorService.getAll()
|
||||
]).then(([equiposData, sectoresData]) => {
|
||||
setData(equiposData);
|
||||
setFilteredData(equiposData);
|
||||
const sectoresOrdenados = [...sectoresData].sort((a, b) => a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base' }));
|
||||
setSectores(sectoresOrdenados);
|
||||
}).catch(error => {
|
||||
toast.error("No se pudieron cargar los datos iniciales.");
|
||||
console.error("Error al cargar datos:", error);
|
||||
}).finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSectorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
setSelectedSector(value);
|
||||
if (value === 'Todos') setFilteredData(data);
|
||||
else if (value === 'Asignar') setFilteredData(data.filter(i => !i.sector));
|
||||
else setFilteredData(data.filter(i => i.sector?.nombre === value));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!modalData) return;
|
||||
const toastId = toast.loading('Guardando...');
|
||||
try {
|
||||
const sectorId = modalData.sector?.id ?? 0;
|
||||
await equipoService.updateSector(modalData.id, sectorId);
|
||||
const equipoActualizado = { ...modalData, sector_id: modalData.sector?.id };
|
||||
const updateFunc = (prev: Equipo[]) => prev.map(e => e.id === modalData.id ? equipoActualizado : e);
|
||||
setData(updateFunc);
|
||||
setFilteredData(updateFunc);
|
||||
if (selectedEquipo && selectedEquipo.id === modalData.id) {
|
||||
setSelectedEquipo(equipoActualizado);
|
||||
}
|
||||
toast.success('Sector actualizado.', { id: toastId });
|
||||
setModalData(null);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePassword = async (password: string) => {
|
||||
if (!modalPasswordData) return;
|
||||
const toastId = toast.loading('Actualizando...');
|
||||
try {
|
||||
await usuarioService.updatePassword(modalPasswordData.id, password);
|
||||
|
||||
const usernameToUpdate = modalPasswordData.username;
|
||||
|
||||
const newData = data.map(equipo => {
|
||||
if (!equipo.usuarios.some(u => u.username === usernameToUpdate)) {
|
||||
return equipo;
|
||||
}
|
||||
const updatedUsers = equipo.usuarios.map(user =>
|
||||
user.username === usernameToUpdate ? { ...user, password: password } : user
|
||||
);
|
||||
return { ...equipo, usuarios: updatedUsers };
|
||||
});
|
||||
setData(newData);
|
||||
if (selectedSector === 'Todos') setFilteredData(newData);
|
||||
else if (selectedSector === 'Asignar') setFilteredData(newData.filter(i => !i.sector));
|
||||
else setFilteredData(newData.filter(i => i.sector?.nombre === selectedSector));
|
||||
if (selectedEquipo) {
|
||||
const updatedSelectedEquipo = newData.find(e => e.id === selectedEquipo.id);
|
||||
if (updatedSelectedEquipo) {
|
||||
setSelectedEquipo(updatedSelectedEquipo);
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`Contraseña para '${usernameToUpdate}' actualizada en todos sus equipos.`, { id: toastId });
|
||||
setModalPasswordData(null);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleRemoveUser = async (hostname: string, username: string) => {
|
||||
if (!window.confirm(`¿Quitar a ${username} de este equipo?`)) return;
|
||||
const toastId = toast.loading(`Quitando a ${username}...`);
|
||||
try {
|
||||
await usuarioService.removeUserFromEquipo(hostname, username);
|
||||
let equipoActualizado: Equipo | undefined;
|
||||
const updateFunc = (prev: Equipo[]) => prev.map(e => {
|
||||
if (e.hostname === hostname) {
|
||||
equipoActualizado = { ...e, usuarios: e.usuarios.filter(u => u.username !== username) };
|
||||
return equipoActualizado;
|
||||
}
|
||||
return e;
|
||||
});
|
||||
setData(updateFunc);
|
||||
setFilteredData(updateFunc);
|
||||
if (selectedEquipo && equipoActualizado && selectedEquipo.id === equipoActualizado.id) {
|
||||
setSelectedEquipo(equipoActualizado);
|
||||
}
|
||||
toast.success(`${username} quitado.`, { id: toastId });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('¿Eliminar este equipo y sus relaciones?')) return false;
|
||||
const toastId = toast.loading('Eliminando equipo...');
|
||||
try {
|
||||
await equipoService.deleteManual(id);
|
||||
setData(prev => prev.filter(e => e.id !== id));
|
||||
setFilteredData(prev => prev.filter(e => e.id !== id));
|
||||
toast.success('Equipo eliminado.', { id: toastId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAssociation = async (type: 'disco' | 'ram' | 'usuario', associationId: number | { equipoId: number, usuarioId: number }) => {
|
||||
if (!window.confirm('¿Estás seguro de que quieres eliminar esta asociación manual?')) return;
|
||||
const toastId = toast.loading('Eliminando asociación...');
|
||||
try {
|
||||
let successMessage = '';
|
||||
if (type === 'disco' && typeof associationId === 'number') {
|
||||
await equipoService.removeDiscoAssociation(associationId);
|
||||
successMessage = 'Disco desasociado del equipo.';
|
||||
} else if (type === 'ram' && typeof associationId === 'number') {
|
||||
await equipoService.removeRamAssociation(associationId);
|
||||
successMessage = 'Módulo de RAM desasociado.';
|
||||
} else if (type === 'usuario' && typeof associationId === 'object') {
|
||||
await equipoService.removeUserAssociation(associationId.equipoId, associationId.usuarioId);
|
||||
successMessage = 'Usuario desasociado del equipo.';
|
||||
} else {
|
||||
throw new Error('Tipo de asociación no válido');
|
||||
}
|
||||
const updateState = (prevEquipos: Equipo[]) => prevEquipos.map(equipo => {
|
||||
if (equipo.id !== selectedEquipo?.id) return equipo;
|
||||
let updatedEquipo = { ...equipo };
|
||||
if (type === 'disco' && typeof associationId === 'number') {
|
||||
updatedEquipo.discos = equipo.discos.filter(d => d.equipoDiscoId !== associationId);
|
||||
} else if (type === 'ram' && typeof associationId === 'number') {
|
||||
updatedEquipo.memoriasRam = equipo.memoriasRam.filter(m => m.equipoMemoriaRamId !== associationId);
|
||||
} else if (type === 'usuario' && typeof associationId === 'object') {
|
||||
updatedEquipo.usuarios = equipo.usuarios.filter(u => u.id !== associationId.usuarioId);
|
||||
}
|
||||
return updatedEquipo;
|
||||
});
|
||||
setData(updateState);
|
||||
setFilteredData(updateState);
|
||||
setSelectedEquipo(prev => prev ? updateState([prev])[0] : null);
|
||||
if (selectedEquipo) {
|
||||
await refreshHistory(selectedEquipo.hostname);
|
||||
}
|
||||
toast.success(successMessage, { id: toastId });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateEquipo = async (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => {
|
||||
const toastId = toast.loading('Creando nuevo equipo...');
|
||||
try {
|
||||
const equipoCreado = await equipoService.createManual(nuevoEquipo);
|
||||
setData(prev => [...prev, equipoCreado]);
|
||||
setFilteredData(prev => [...prev, equipoCreado]);
|
||||
toast.success(`Equipo '${equipoCreado.hostname}' creado.`, { id: toastId });
|
||||
setIsAddModalOpen(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditEquipo = async (id: number, equipoEditado: any) => {
|
||||
const toastId = toast.loading('Guardando cambios...');
|
||||
try {
|
||||
const equipoActualizadoDesdeBackend = await equipoService.updateManual(id, equipoEditado);
|
||||
const updateState = (prev: Equipo[]) => prev.map(e => e.id === id ? equipoActualizadoDesdeBackend : e);
|
||||
setData(updateState);
|
||||
setFilteredData(updateState);
|
||||
setSelectedEquipo(equipoActualizadoDesdeBackend);
|
||||
toast.success('Equipo actualizado.', { id: toastId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddComponent = async (type: 'disco' | 'ram' | 'usuario', componentData: any) => {
|
||||
if (!selectedEquipo) return;
|
||||
const toastId = toast.loading(`Añadiendo ${type}...`);
|
||||
try {
|
||||
let serviceCall;
|
||||
switch (type) {
|
||||
case 'disco': serviceCall = equipoService.addDisco(selectedEquipo.id, componentData); break;
|
||||
case 'ram': serviceCall = equipoService.addRam(selectedEquipo.id, componentData); break;
|
||||
case 'usuario': serviceCall = equipoService.addUsuario(selectedEquipo.id, componentData); break;
|
||||
default: throw new Error('Tipo de componente no válido');
|
||||
}
|
||||
await serviceCall;
|
||||
const refreshedEquipo = await equipoService.getAll().then(equipos => equipos.find(e => e.id === selectedEquipo.id));
|
||||
if (!refreshedEquipo) throw new Error("No se pudo recargar el equipo");
|
||||
const updateState = (prev: Equipo[]) => prev.map(e => e.id === selectedEquipo.id ? refreshedEquipo : e);
|
||||
setData(updateState);
|
||||
setFilteredData(updateState);
|
||||
setSelectedEquipo(refreshedEquipo);
|
||||
await refreshHistory(selectedEquipo.hostname);
|
||||
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} añadido.`, { id: toastId });
|
||||
setAddingComponent(null);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error.message, { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Equipo>[] = [
|
||||
{ header: "ID", accessorKey: "id", enableHiding: true, enableResizing: false },
|
||||
{
|
||||
header: "Nombre", accessorKey: "hostname",
|
||||
cell: (info: CellContext<Equipo, any>) => (<button onClick={() => setSelectedEquipo(info.row.original)} className={styles.hostnameButton}>{info.row.original.hostname}</button>)
|
||||
},
|
||||
{ header: "IP", accessorKey: "ip", id: 'ip' },
|
||||
{ header: "MAC", accessorKey: "mac" },
|
||||
{ header: "Motherboard", accessorKey: "motherboard" },
|
||||
{ header: "CPU", accessorKey: "cpu" },
|
||||
{ header: "RAM", accessorKey: "ram_installed", id: 'ram' },
|
||||
{ header: "Discos", accessorFn: (row: Equipo) => row.discos?.map(d => `${d.mediatype} ${d.size}GB`).join(" | ") || "Sin discos" },
|
||||
{ header: "OS", accessorKey: "os" },
|
||||
{ header: "Arquitectura", accessorKey: "architecture", id: 'arch' },
|
||||
{
|
||||
header: "Usuarios y Claves",
|
||||
id: 'usuarios',
|
||||
cell: (info: CellContext<Equipo, any>) => {
|
||||
const { row } = info;
|
||||
const usuarios = row.original.usuarios || [];
|
||||
return (
|
||||
<div className={styles.userList}>
|
||||
{usuarios.map((u: UsuarioEquipoDetalle) => (
|
||||
<div key={u.id} className={styles.userItem}>
|
||||
<span className={styles.userInfo}>
|
||||
U: {u.username} - C: {u.password || 'N/A'}
|
||||
</span>
|
||||
|
||||
<div className={styles.userActions}>
|
||||
<button
|
||||
onClick={() => setModalPasswordData(u)}
|
||||
className={styles.tableButton}
|
||||
data-tooltip-id={`edit-${u.id}`}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
<Tooltip id={`edit-${u.id}`} className={styles.tooltip} place="top">Cambiar Clave</Tooltip>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveUser(row.original.hostname, u.username)}
|
||||
className={styles.deleteUserButton}
|
||||
data-tooltip-id={`remove-${u.id}`}
|
||||
>
|
||||
<UserX size={16} />
|
||||
<Tooltip id={`remove-${u.id}`} className={styles.tooltip} place="top">Eliminar Usuario</Tooltip>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Sector", id: 'sector', accessorFn: (row: Equipo) => row.sector?.nombre || 'Asignar',
|
||||
cell: (info: CellContext<Equipo, any>) => {
|
||||
const { row } = info;
|
||||
const sector = row.original.sector;
|
||||
return (
|
||||
<div className={styles.sectorContainer}>
|
||||
<span className={`${styles.sectorName} ${sector ? styles.sectorNameAssigned : styles.sectorNameUnassigned}`}>{sector?.nombre || 'Asignar'}</span>
|
||||
<button onClick={() => setModalData(row.original)} className={styles.tableButton} data-tooltip-id={`editSector-${row.original.id}`}><Pencil size={16} /><Tooltip id={`editSector-${row.original.id}`} className={styles.tooltip} place="top">Cambiar Sector</Tooltip></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
filterFns: {
|
||||
accentInsensitive: accentInsensitiveFilter,
|
||||
},
|
||||
globalFilterFn: 'accentInsensitive',
|
||||
initialState: {
|
||||
sorting: [
|
||||
{ id: 'sector', desc: false },
|
||||
{ id: 'hostname', desc: false }
|
||||
],
|
||||
pagination: {
|
||||
pageSize: 15,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
globalFilter,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2>Equipos (...)</h2>
|
||||
<div className={`${skeletonStyles.skeleton}`} style={{ height: '40px', width: '160px' }}></div>
|
||||
</div>
|
||||
<div className={styles.controlsContainer}>
|
||||
<div className={`${skeletonStyles.skeleton}`} style={{ height: '38px', width: '300px' }}></div>
|
||||
<div className={`${skeletonStyles.skeleton}`} style={{ height: '38px', width: '200px' }}></div>
|
||||
</div>
|
||||
<div><p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p></div>
|
||||
<div className={`${skeletonStyles.skeleton}`} style={{ height: '54px', marginBottom: '1rem' }}></div>
|
||||
<TableSkeleton rows={15} columns={11} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PaginacionControles = (
|
||||
<div className={styles.paginationControls}>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
|
||||
<ChevronsLeft size={18} />
|
||||
</button>
|
||||
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className={styles.tableButton}>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className={styles.tableButton}>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
<button onClick={() => table.setPageIndex(table.getPageCount() - 1)} disabled={!table.getCanNextPage()} className={styles.tableButton}>
|
||||
<ChevronsRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<span>
|
||||
Página{' '}
|
||||
<strong>
|
||||
{table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
|
||||
</strong>
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<span>| Ir a pág:</span>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={table.getState().pagination.pageIndex + 1}
|
||||
onChange={e => {
|
||||
const page = e.target.value ? Number(e.target.value) - 1 : 0;
|
||||
table.setPageIndex(page);
|
||||
}}
|
||||
style={{ width: '60px' }}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
<select
|
||||
value={table.getState().pagination.pageSize}
|
||||
onChange={e => {
|
||||
table.setPageSize(Number(e.target.value));
|
||||
}}
|
||||
className={styles.sectorSelect}
|
||||
>
|
||||
{[10, 15, 25, 50, 100].map(pageSize => (
|
||||
<option key={pageSize} value={pageSize}>
|
||||
Mostrar {pageSize}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2>Equipos ({table.getFilteredRowModel().rows.length})</h2>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
>
|
||||
<PlusCircle size={18} /> Añadir Equipo
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.controlsContainer}>
|
||||
<input type="text" placeholder="Buscar en todos los campos..." value={globalFilter} onChange={(e) => setGlobalFilter(e.target.value)} className={styles.searchInput} style={{ width: '300px' }} />
|
||||
<b>Sector:</b>
|
||||
<select value={selectedSector} onChange={handleSectorChange} className={styles.sectorSelect}>
|
||||
<option value="Todos">-Todos-</option>
|
||||
<option value="Asignar">-Asignar-</option>
|
||||
{sectores.map(s => (<option key={s.id} value={s.nombre}>{s.nombre}</option>))}
|
||||
</select>
|
||||
<div ref={columnToggleRef} className={styles.columnToggleContainer}>
|
||||
<button onClick={() => setIsColumnToggleOpen(prev => !prev)} className={styles.tableButton} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Columns3 size={16} /> Columnas
|
||||
</button>
|
||||
{isColumnToggleOpen && (
|
||||
<div className={styles.columnToggleDropdown}>
|
||||
{table.getAllLeafColumns().map(column => {
|
||||
if (column.id === 'id') return null;
|
||||
return (
|
||||
<div key={column.id} className={styles.columnToggleItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={column.getIsVisible()}
|
||||
onChange={column.getToggleVisibilityHandler()}
|
||||
id={`col-toggle-${column.id}`}
|
||||
/>
|
||||
<label htmlFor={`col-toggle-${column.id}`}>
|
||||
{typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div><p style={{ fontSize: '12px', fontWeight: 'bold' }}>** La tabla permite ordenar por múltiples columnas manteniendo shift al hacer click en la cabecera. **</p></div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div ref={tableContainerRef} className={styles.tableContainer} style={{ maxHeight: '70vh' }}>
|
||||
<table className={styles.table} style={{ width: table.getTotalSize() }}>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map(hg => (
|
||||
<tr key={hg.id}>
|
||||
{hg.headers.map(h => (
|
||||
<th key={h.id} style={{ width: h.getSize() }} className={styles.th}>
|
||||
<div onClick={h.column.getToggleSortingHandler()} className={styles.headerContent}>
|
||||
{flexRender(h.column.columnDef.header, h.getContext())}
|
||||
{h.column.getIsSorted() && (<span className={styles.sortIndicator}>{h.column.getIsSorted() === 'asc' ? '⇑' : '⇓'}</span>)}
|
||||
</div>
|
||||
<div
|
||||
onMouseDown={h.getResizeHandler()}
|
||||
onTouchStart={h.getResizeHandler()}
|
||||
className={`${styles.resizer} ${h.column.getIsResizing() ? styles.isResizing : ''}`}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map(row => (
|
||||
<tr key={row.id} className={styles.tr}>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<td key={cell.id} style={{ width: cell.column.getSize() }} className={styles.td}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{showScrollButton && (
|
||||
<button
|
||||
onClick={() => tableContainerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className={styles.scrollToTop}
|
||||
title="Volver al inicio"
|
||||
>
|
||||
<ArrowUp size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{PaginacionControles}
|
||||
|
||||
{modalData && <ModalEditarSector modalData={modalData} setModalData={setModalData} sectores={sectores} onClose={() => setModalData(null)} onSave={handleSave} />}
|
||||
|
||||
{modalPasswordData && <ModalCambiarClave usuario={modalPasswordData} onClose={() => setModalPasswordData(null)} onSave={handleSavePassword} />}
|
||||
|
||||
{selectedEquipo && (
|
||||
<ModalDetallesEquipo
|
||||
equipo={selectedEquipo}
|
||||
isOnline={isOnline}
|
||||
historial={historial}
|
||||
onClose={handleCloseModal}
|
||||
onDelete={handleDelete}
|
||||
onRemoveAssociation={handleRemoveAssociation}
|
||||
onEdit={handleEditEquipo}
|
||||
sectores={sectores}
|
||||
onAddComponent={type => setAddingComponent(type)}
|
||||
isChildModalOpen={addingComponent !== null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAddModalOpen && <ModalAnadirEquipo sectores={sectores} onClose={() => setIsAddModalOpen(false)} onSave={handleCreateEquipo} />}
|
||||
|
||||
{addingComponent === 'disco' && <ModalAnadirDisco onClose={() => setAddingComponent(null)} onSave={(data: { mediatype: string, size: number }) => handleAddComponent('disco', data)} />}
|
||||
|
||||
{addingComponent === 'ram' && <ModalAnadirRam onClose={() => setAddingComponent(null)} onSave={(data: { slot: string, tamano: number, fabricante?: string, velocidad?: number }) => handleAddComponent('ram', data)} />}
|
||||
|
||||
{addingComponent === 'usuario' && <ModalAnadirUsuario onClose={() => setAddingComponent(null)} onSave={(data: { username: string }) => handleAddComponent('usuario', data)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleTable;
|
||||
33
frontend/src/components/Skeleton.module.css
Normal file
33
frontend/src/components/Skeleton.module.css
Normal file
@@ -0,0 +1,33 @@
|
||||
/* frontend/src/components/Skeleton.module.css */
|
||||
|
||||
.skeleton {
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.4),
|
||||
transparent
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
49
frontend/src/components/TableSkeleton.tsx
Normal file
49
frontend/src/components/TableSkeleton.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// frontend/src/components/TableSkeleton.tsx
|
||||
|
||||
import React from 'react';
|
||||
import styles from './SimpleTable.module.css';
|
||||
import skeletonStyles from './Skeleton.module.css';
|
||||
|
||||
interface SkeletonProps {
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Skeleton: React.FC<SkeletonProps> = ({ style }) => {
|
||||
return <div className={skeletonStyles.skeleton} style={style}></div>;
|
||||
};
|
||||
|
||||
interface TableSkeletonProps {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
const TableSkeleton: React.FC<TableSkeletonProps> = ({ rows = 10, columns = 8 }) => {
|
||||
return (
|
||||
<div style={{ overflowX: 'auto', border: '1px solid #dee2e6', borderRadius: '8px' }}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<th key={i} className={styles.th}>
|
||||
<Skeleton style={{ height: '20px', width: '80%' }} />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<tr key={i} className={styles.tr}>
|
||||
{Array.from({ length: columns }).map((_, j) => (
|
||||
<td key={j} className={styles.td}>
|
||||
<Skeleton style={{ height: '20px' }} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableSkeleton;
|
||||
20
frontend/src/components/ThemeToggle.css
Normal file
20
frontend/src/components/ThemeToggle.css
Normal file
@@ -0,0 +1,20 @@
|
||||
/* frontend/src/components/ThemeToggle.css */
|
||||
.theme-toggle-button {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-text-muted);
|
||||
color: var(--color-text-muted);
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle-button:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
20
frontend/src/components/ThemeToggle.tsx
Normal file
20
frontend/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
// frontend/src/components/ThemeToggle.tsx
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import './ThemeToggle.css';
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
className="theme-toggle-button"
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'light' ? 'Activar modo oscuro' : 'Activar modo claro'}
|
||||
>
|
||||
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
64
frontend/src/context/AuthContext.tsx
Normal file
64
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// frontend/src/context/AuthContext.tsx
|
||||
import React, { createContext, useState, useContext, useMemo, useEffect } from 'react';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
// 1. Modificar la firma de la función login
|
||||
login: (token: string, rememberMe: boolean) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 2. Al cargar, buscar el token en localStorage primero, y luego en sessionStorage
|
||||
const storedToken = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
|
||||
setToken(storedToken);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 3. Implementar la nueva lógica de login
|
||||
const login = (newToken: string, rememberMe: boolean) => {
|
||||
if (rememberMe) {
|
||||
// Si el usuario quiere ser recordado, usamos localStorage
|
||||
localStorage.setItem('authToken', newToken);
|
||||
} else {
|
||||
// Si no, usamos sessionStorage
|
||||
sessionStorage.setItem('authToken', newToken);
|
||||
}
|
||||
setToken(newToken);
|
||||
};
|
||||
|
||||
// 4. Asegurarnos de que el logout limpie ambos almacenamientos
|
||||
const logout = () => {
|
||||
// Asegurarse de limpiar ambos almacenamientos
|
||||
localStorage.removeItem('authToken');
|
||||
sessionStorage.removeItem('authToken');
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
const isAuthenticated = !!token;
|
||||
|
||||
// 5. Actualizar el valor del contexto
|
||||
const value = useMemo(() => ({ token, isAuthenticated, isLoading, login, logout }), [token, isLoading]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth debe ser usado dentro de un AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
58
frontend/src/context/ThemeContext.tsx
Normal file
58
frontend/src/context/ThemeContext.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
// frontend/src/context/ThemeContext.tsx
|
||||
|
||||
import React, { createContext, useState, useEffect, useContext, useMemo } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
// Creamos el contexto con un valor por defecto.
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
// Creamos el proveedor del contexto.
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
// 1. Intentamos leer el tema desde localStorage.
|
||||
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
||||
if (savedTheme) return savedTheme;
|
||||
|
||||
// 2. Si no hay nada, respetamos la preferencia del sistema operativo.
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
// 3. Como última opción, usamos el tema claro por defecto.
|
||||
return 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Aplicamos el tema al body y lo guardamos en localStorage cada vez que cambia.
|
||||
document.body.dataset.theme = theme;
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
|
||||
};
|
||||
|
||||
// Usamos useMemo para evitar que el valor del contexto se recalcule en cada render.
|
||||
const value = useMemo(() => ({ theme, toggleTheme }), [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook personalizado para usar el contexto de forma sencilla.
|
||||
export const useTheme = (): ThemeContextType => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme debe ser utilizado dentro de un ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
78
frontend/src/index.css
Normal file
78
frontend/src/index.css
Normal file
@@ -0,0 +1,78 @@
|
||||
/* frontend/src/index.css */
|
||||
|
||||
/* 1. Definición de variables de color para el tema claro (por defecto) */
|
||||
:root {
|
||||
--color-background: #f8f9fa;
|
||||
--color-text-primary: #212529;
|
||||
--color-text-secondary: #495057;
|
||||
--color-text-muted: #6c757d;
|
||||
|
||||
--color-surface: #ffffff; /* Para tarjetas, modales, etc. */
|
||||
--color-surface-hover: #f1f3f5;
|
||||
--color-border: #dee2e6;
|
||||
--color-border-subtle: #e9ecef;
|
||||
|
||||
--color-primary: #007bff;
|
||||
--color-primary-hover: #0056b3;
|
||||
--color-danger: #dc3545;
|
||||
--color-danger-hover: #a4202e;
|
||||
--color-danger-background: #fbebee;
|
||||
|
||||
--color-navbar-bg: #343a40;
|
||||
--color-navbar-text: #adb5bd;
|
||||
--color-navbar-text-hover: #ffffff;
|
||||
|
||||
--scrollbar-bg: #f1f1f1;
|
||||
--scrollbar-thumb: #888;
|
||||
}
|
||||
|
||||
/* 2. Sobrescribir variables para el tema oscuro */
|
||||
[data-theme='dark'] {
|
||||
--color-background: #121212;
|
||||
--color-text-primary: #e0e0e0;
|
||||
--color-text-secondary: #b0b0b0;
|
||||
--color-text-muted: #888;
|
||||
|
||||
--color-surface: #1e1e1e;
|
||||
--color-surface-hover: #2a2a2a;
|
||||
--color-border: #333;
|
||||
--color-border-subtle: #2c2c2c;
|
||||
|
||||
--color-primary: #3a97ff;
|
||||
--color-primary-hover: #63b0ff;
|
||||
--color-danger: #ff5252;
|
||||
--color-danger-hover: #ff8a80;
|
||||
--color-danger-background: #4d2323;
|
||||
|
||||
--color-navbar-bg: #1e1e1e;
|
||||
--color-navbar-text: #888;
|
||||
--color-navbar-text-hover: #ffffff;
|
||||
|
||||
--scrollbar-bg: #2c2c2c;
|
||||
--scrollbar-thumb: #555;
|
||||
}
|
||||
|
||||
|
||||
/* 3. Aplicar las variables a los estilos base */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
transition: background-color 0.2s ease, color 0.2s ease; /* Transición suave al cambiar de tema */
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background-color: var(--scrollbar-bg);
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
body.scroll-lock {
|
||||
padding-right: 8px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
35
frontend/src/main.tsx
Normal file
35
frontend/src/main.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// frontend/src/main.tsx
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
success: {
|
||||
style: {
|
||||
background: '#28a745',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
style: {
|
||||
background: '#dc3545',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
162
frontend/src/services/apiService.ts
Normal file
162
frontend/src/services/apiService.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// frontend/src/services/apiService.ts
|
||||
|
||||
import type { Equipo, Sector, HistorialEquipo, Usuario, MemoriaRam, DashboardStats } from '../types/interfaces';
|
||||
|
||||
const BASE_URL = '/api';
|
||||
|
||||
// --- FUNCIÓN 'request' ---
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
// 1. Intentar obtener el token de localStorage primero, si no existe, buscar en sessionStorage.
|
||||
const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
|
||||
|
||||
// 2. Añadir el token al encabezado de autorización si existe
|
||||
const headers = new Headers(options.headers);
|
||||
if (token) {
|
||||
headers.append('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
options.headers = headers;
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
// 3. Manejar errores de autenticación
|
||||
if (response.status === 401) {
|
||||
// SOLO recargamos si el error 401 NO viene del endpoint de login.
|
||||
// Esto es para el caso de un token expirado en una petición a una ruta protegida.
|
||||
if (!url.includes('/auth/login')) {
|
||||
localStorage.removeItem('authToken');
|
||||
sessionStorage.removeItem('authToken');
|
||||
window.location.reload();
|
||||
// La recarga previene que el resto del código se ejecute.
|
||||
// Lanzamos un error para detener la ejecución de esta promesa.
|
||||
throw new Error('Sesión expirada. Por favor, inicie sesión de nuevo.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Para el login, el 401 llegará hasta aquí y lanzará el error
|
||||
// que será capturado por el componente Login.tsx.
|
||||
const errorData = await response.json().catch(() => ({ message: 'Error en la respuesta del servidor' }));
|
||||
throw new Error(errorData.message || 'Ocurrió un error desconocido');
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// --- SERVICIO PARA AUTENTICACIÓN ---
|
||||
export const authService = {
|
||||
// Añadimos el parámetro 'rememberMe'
|
||||
login: (username: string, password: string, rememberMe: boolean) =>
|
||||
request<{ token: string }>(`${BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, rememberMe }),
|
||||
}),
|
||||
};
|
||||
|
||||
// --- Servicio para la gestión de Sectores ---
|
||||
export const sectorService = {
|
||||
getAll: () => request<Sector[]>(`${BASE_URL}/sectores`),
|
||||
create: (nombre: string) => request<Sector>(`${BASE_URL}/sectores`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nombre }),
|
||||
}),
|
||||
update: (id: number, nombre: string) => request<void>(`${BASE_URL}/sectores/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nombre }),
|
||||
}),
|
||||
delete: (id: number) => request<void>(`${BASE_URL}/sectores/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// --- Servicio para la gestión de Equipos ---
|
||||
export const equipoService = {
|
||||
getAll: () => request<Equipo[]>(`${BASE_URL}/equipos`),
|
||||
getHistory: (hostname: string) => request<{ historial: HistorialEquipo[] }>(`${BASE_URL}/equipos/${hostname}/historial`),
|
||||
ping: (ip: string) => request<{ isAlive: boolean }>(`${BASE_URL}/equipos/ping`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip }),
|
||||
}),
|
||||
wakeOnLan: (mac: string, ip: string) => request<void>(`${BASE_URL}/equipos/wake-on-lan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mac, ip }),
|
||||
}),
|
||||
updateSector: (equipoId: number, sectorId: number) => request<void>(`${BASE_URL}/equipos/${equipoId}/sector/${sectorId}`, { method: 'PATCH' }),
|
||||
deleteManual: (id: number) => request<void>(`${BASE_URL}/equipos/${id}`, { method: 'DELETE' }),
|
||||
createManual: (nuevoEquipo: Omit<Equipo, 'id' | 'created_at' | 'updated_at'>) => request<Equipo>(`${BASE_URL}/equipos/manual`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(nuevoEquipo),
|
||||
}),
|
||||
updateManual: (id: number, equipoEditado: any) =>
|
||||
request<Equipo>(`${BASE_URL}/equipos/manual/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(equipoEditado),
|
||||
}),
|
||||
|
||||
removeDiscoAssociation: (id: number) => request<void>(`${BASE_URL}/equipos/asociacion/disco/${id}`, { method: 'DELETE' }),
|
||||
removeRamAssociation: (id: number) => request<void>(`${BASE_URL}/equipos/asociacion/ram/${id}`, { method: 'DELETE' }),
|
||||
removeUserAssociation: (equipoId: number, usuarioId: number) => request<void>(`${BASE_URL}/equipos/asociacion/usuario/${equipoId}/${usuarioId}`, { method: 'DELETE' }),
|
||||
addDisco: (equipoId: number, data: any) => request<any>(`${BASE_URL}/equipos/manual/${equipoId}/disco`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
addRam: (equipoId: number, data: any) => request<any>(`${BASE_URL}/equipos/manual/${equipoId}/ram`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
addUsuario: (equipoId: number, data: any) => request<any>(`${BASE_URL}/equipos/manual/${equipoId}/usuario`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getDistinctValues: (field: string) => request<string[]>(`${BASE_URL}/equipos/distinct/${field}`),
|
||||
};
|
||||
|
||||
// --- Servicio para Usuarios ---
|
||||
export const usuarioService = {
|
||||
updatePassword: (id: number, password: string) => request<Usuario>(`${BASE_URL}/usuarios/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
removeUserFromEquipo: (hostname: string, username: string) => request<void>(`${BASE_URL}/equipos/${hostname}/usuarios/${username}`, { method: 'DELETE' }),
|
||||
search: (term: string) => request<string[]>(`${BASE_URL}/usuarios/buscar/${term}`),
|
||||
};
|
||||
|
||||
// --- Servicio para RAM ---
|
||||
export const memoriaRamService = {
|
||||
getAll: () => request<MemoriaRam[]>(`${BASE_URL}/memoriasram`),
|
||||
search: (term: string) => request<MemoriaRam[]>(`${BASE_URL}/memoriasram/buscar/${term}`),
|
||||
};
|
||||
|
||||
// --- Servicio para Administración ---
|
||||
export const adminService = {
|
||||
getComponentValues: (type: string) => request<any[]>(`${BASE_URL}/admin/componentes/${type}`),
|
||||
unifyComponentValues: (type: string, valorAntiguo: string, valorNuevo: string) => request<any>(`${BASE_URL}/admin/componentes/${type}/unificar`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ valorAntiguo, valorNuevo }),
|
||||
}),
|
||||
|
||||
deleteRamComponent: (ramGroup: { fabricante?: string, tamano: number, velocidad?: number }) => request<void>(`${BASE_URL}/admin/componentes/ram`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ramGroup),
|
||||
}),
|
||||
|
||||
deleteTextComponent: (type: string, value: string) => request<void>(`${BASE_URL}/admin/componentes/${type}/${encodeURIComponent(value)}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export const dashboardService = {
|
||||
getStats: () => request<DashboardStats>(`${BASE_URL}/dashboard/stats`),
|
||||
};
|
||||
12
frontend/src/tanstack-table.d.ts
vendored
Normal file
12
frontend/src/tanstack-table.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// frontend/src/tanstack-table.d.ts
|
||||
|
||||
// Importamos el tipo 'FilterFn' para usarlo en nuestra definición
|
||||
import { type FilterFn } from '@tanstack/react-table';
|
||||
|
||||
// Ampliamos el módulo original de @tanstack/react-table
|
||||
declare module '@tanstack/react-table' {
|
||||
// Extendemos la interfaz FilterFns para que incluya nuestra función personalizada
|
||||
interface FilterFns {
|
||||
accentInsensitive: FilterFn<unknown>;
|
||||
}
|
||||
}
|
||||
102
frontend/src/types/interfaces.ts
Normal file
102
frontend/src/types/interfaces.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// CAMBIO: Añadimos el origen y el ID de la asociación
|
||||
export interface UsuarioEquipoDetalle extends Usuario {
|
||||
origen: 'manual' | 'automatica';
|
||||
}
|
||||
|
||||
// Corresponde al modelo 'Disco'
|
||||
export interface Disco {
|
||||
id: number;
|
||||
mediatype: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// CAMBIO: Añadimos el origen y el ID de la asociación
|
||||
export interface DiscoDetalle extends Disco {
|
||||
equipoDiscoId: number; // El ID de la tabla equipos_discos
|
||||
origen: 'manual' | 'automatica';
|
||||
}
|
||||
|
||||
// Corresponde al modelo 'MemoriaRam'
|
||||
export interface MemoriaRam {
|
||||
id: number;
|
||||
partNumber?: string;
|
||||
fabricante?: string;
|
||||
tamano: number;
|
||||
velocidad?: number;
|
||||
}
|
||||
|
||||
// CCorresponde al modelo 'MemoriaRamEquipoDetalle'
|
||||
export interface MemoriaRamEquipoDetalle extends MemoriaRam {
|
||||
equipoMemoriaRamId: number; // El ID de la tabla equipos_memorias_ram
|
||||
slot: string;
|
||||
origen: 'manual' | 'automatica';
|
||||
}
|
||||
|
||||
// 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;
|
||||
origen: 'manual' | 'automatica'; // Campo de origen para el equipo
|
||||
|
||||
// Propiedades de navegación que vienen de las relaciones (JOINs)
|
||||
sector?: Sector;
|
||||
usuarios: UsuarioEquipoDetalle[];
|
||||
discos: DiscoDetalle[];
|
||||
memoriasRam: MemoriaRamEquipoDetalle[];
|
||||
historial: HistorialEquipo[];
|
||||
}
|
||||
|
||||
// --- Interfaces para el Dashboard ---
|
||||
|
||||
export interface EquipoFilter {
|
||||
field: string; // 'os', 'cpu', 'sector', etc.
|
||||
value: string; // El valor específico del filtro
|
||||
}
|
||||
|
||||
export interface StatItem {
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
osStats: StatItem[];
|
||||
sectorStats: StatItem[];
|
||||
cpuStats: StatItem[];
|
||||
ramStats: StatItem[];
|
||||
}
|
||||
32
frontend/src/utils/filtering.ts
Normal file
32
frontend/src/utils/filtering.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// frontend/src/utils/filtering.ts
|
||||
|
||||
import { type FilterFn } from '@tanstack/react-table';
|
||||
|
||||
/**
|
||||
* Normaliza un texto: lo convierte a minúsculas y le quita los acentos.
|
||||
* @param text El texto a normalizar.
|
||||
* @returns El texto normalizado.
|
||||
*/
|
||||
const normalizeText = (text: unknown): string => {
|
||||
// Nos aseguramos de que solo procesamos strings
|
||||
if (typeof text !== 'string') return '';
|
||||
|
||||
return text
|
||||
.toLowerCase() // 1. Convertir a minúsculas
|
||||
.normalize('NFD') // 2. Descomponer caracteres (ej: 'é' se convierte en 'e' + '´')
|
||||
.replace(/\p{Diacritic}/gu, ''); // 3. Eliminar los diacríticos (acentos) con una expresión regular Unicode
|
||||
};
|
||||
|
||||
/**
|
||||
* Función de filtro para TanStack Table que ignora acentos y mayúsculas/minúsculas.
|
||||
*/
|
||||
export const accentInsensitiveFilter: FilterFn<any> = (row, columnId, filterValue) => {
|
||||
const rowValue = row.getValue(columnId);
|
||||
|
||||
// Normalizamos el valor de la fila y el valor del filtro
|
||||
const normalizedRowValue = normalizeText(rowValue);
|
||||
const normalizedFilterValue = normalizeText(filterValue);
|
||||
|
||||
// Comprobamos si el valor normalizado de la fila incluye el del filtro
|
||||
return normalizedRowValue.includes(normalizedFilterValue);
|
||||
};
|
||||
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"]
|
||||
}
|
||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// --- AÑADIR ESTA SECCIÓN COMPLETA ---
|
||||
server: {
|
||||
proxy: {
|
||||
// Cualquier petición que empiece con '/api' será redirigida.
|
||||
'/api': {
|
||||
// Redirige al servidor de backend que corre en local.
|
||||
target: 'http://localhost:5198',
|
||||
// Necesario para evitar problemas de CORS y de origen.
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
174
getDatosPost.ps1
Normal file
174
getDatosPost.ps1
Normal file
@@ -0,0 +1,174 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Recopila información de hardware y software y la envía a la API de Inventario IT.
|
||||
.DESCRIPTION
|
||||
Este script utiliza la lógica de recopilación de discos original y probada, adaptada para
|
||||
funcionar con los nuevos endpoints y la estructura de datos de la API de Inventario IT.
|
||||
.NOTES
|
||||
Versión: 2.5
|
||||
Fecha: 21/10/2025
|
||||
- Añadido flujo de autenticación JWT para obtener un token antes de realizar las llamadas a la API.
|
||||
- Todas las peticiones a la API ahora incluyen la cabecera 'Authorization: Bearer <token>'.
|
||||
- Revertida la lógica de detección de discos para W10+ a la versión original para máxima compatibilidad.
|
||||
#>
|
||||
|
||||
# =================================================================================
|
||||
# --- CONFIGURACIÓN ---
|
||||
$apiBaseUrl = "http://equipos.eldia.net/api"
|
||||
# Añadir credenciales para la autenticación en la API.
|
||||
$apiUser = "admin"
|
||||
$apiPassword = "PTP847Equipos"
|
||||
# =================================================================================
|
||||
|
||||
# Verificar versión de Windows
|
||||
$osInfo = Get-WmiObject Win32_OperatingSystem
|
||||
$isWindows7 = [version]$osInfo.Version -lt [version]"6.2"
|
||||
|
||||
if ($isWindows7) {
|
||||
Write-Host "Ejecutando versión adaptada para Windows 7..."
|
||||
# Función de conversión JSON básica
|
||||
function ConvertTo-BasicJson { param($InputObject); if ($InputObject -is [array]) { $json = @(); foreach ($item in $InputObject) { $json += ConvertTo-BasicJson -InputObject $item }; return "[$($json -join ',')]" } elseif ($InputObject -is [System.Collections.IDictionary]) { $props = @(); foreach ($key in $InputObject.Keys) { $props += "`"$key`":$(ConvertTo-BasicJson -InputObject $InputObject[$key])" }; return "{$($props -join ',')}" } else { return "`"$($InputObject.ToString().Replace('"','\"'))`"" } }
|
||||
# Obtener información del sistema
|
||||
$hostname = $env:COMPUTERNAME; $username = $env:USERNAME
|
||||
$activeInterface = Get-WmiObject -Class Win32_NetworkAdapterConfiguration | Where-Object { $_.IPEnabled -eq $true -and $_.DefaultIPGateway -ne $null } | Sort-Object InterfaceIndex | Select-Object -First 1
|
||||
$ip = if ($activeInterface) { $activeInterface.IPAddress[0] } else { "No IP" }; $mac = if ($activeInterface) { ($activeInterface.MACAddress) -replace '-',':' } else { "No MAC" }
|
||||
$cpu = (Get-WmiObject Win32_Processor).Name; $motherboard = (Get-WmiObject Win32_BaseBoard).Product
|
||||
$installedRAM = (Get-WmiObject Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum; $installedRAMGB = [math]::Round($installedRAM / 1GB, 0)
|
||||
$ram_slots = (Get-WmiObject Win32_PhysicalMemoryArray).MemoryDevices
|
||||
$osName = $osInfo.Caption; $osArchitecture = $osInfo.OSArchitecture
|
||||
$memoriasRam = @(Get-WmiObject Win32_PhysicalMemory | ForEach-Object { [PSCustomObject]@{ partNumber = $_.PartNumber.Trim(); fabricante = $_.Manufacturer.Trim(); tamano = [math]::Round($_.Capacity / 1GB, 0); velocidad = $_.Speed; slot = $_.BankLabel } })
|
||||
$jsonDataEquipo = ConvertTo-BasicJson @{ hostname = $hostname; ip = $ip; mac = $mac; cpu = $cpu -join ","; motherboard = $motherboard; ram_installed = $installedRAMGB; ram_slots = $ram_slots; os = $osName; architecture = $osArchitecture }
|
||||
$jsonDataUsuario = ConvertTo-BasicJson @{ username = $username }
|
||||
$jsonMemoriasRam = ConvertTo-BasicJson -InputObject $memoriasRam
|
||||
$memoriasRamMaestra = $memoriasRam | ForEach-Object { $obj = New-Object PSCustomObject; Add-Member -InputObject $obj -MemberType NoteProperty -Name "partNumber" -Value $_.partNumber; Add-Member -InputObject $obj -MemberType NoteProperty -Name "fabricante" -Value $_.fabricante; Add-Member -InputObject $obj -MemberType NoteProperty -Name "tamano" -Value $_.tamano; Add-Member -InputObject $obj -MemberType NoteProperty -Name "velocidad" -Value $_.velocidad; $obj }
|
||||
$jsonMemoriasRamMaestra = ConvertTo-BasicJson -InputObject $memoriasRamMaestra
|
||||
|
||||
#[MODIFICACIÓN] Actualizar la función Send-Data para aceptar cabeceras (headers)
|
||||
function Send-Data {
|
||||
param($Url, $Body, $Method = "POST", $Headers)
|
||||
try {
|
||||
$webClient = New-Object System.Net.WebClient
|
||||
$webClient.Headers.Add("Content-Type", "application/json")
|
||||
if ($Headers) {
|
||||
foreach ($key in $Headers.Keys) {
|
||||
$webClient.Headers.Add($key, $Headers[$key])
|
||||
}
|
||||
}
|
||||
return $webClient.UploadString($Url, $Method, $Body)
|
||||
} catch {
|
||||
Write-Host "Error en el envío a $Url : $($_.Exception.Message)"
|
||||
if ($_.Exception.Response) {
|
||||
try { $stream = $_.Exception.Response.GetResponseStream(); $reader = New-Object System.IO.StreamReader($stream); $responseBody = $reader.ReadToEnd(); Write-Host "Respuesta del servidor: $responseBody" } catch {}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Host "Ejecutando versión estándar para Windows 8/10/11..."
|
||||
# Obtener información del sistema
|
||||
$hostname = $env:COMPUTERNAME; $username = $env:USERNAME
|
||||
$activeInterface = Get-NetIPConfiguration | Where-Object { $_.IPv4DefaultGateway -ne $null -and $_.NetAdapter.Status -eq 'Up' } | Sort-Object InterfaceMetric | Select-Object -First 1
|
||||
$ip = if ($activeInterface) { $activeInterface.IPv4Address.IPAddress } else { "No IP" }; $mac = if ($activeInterface) { ($activeInterface.NetAdapter.MacAddress) -replace '-',':' } else { "No MAC" }
|
||||
$cpu = (Get-CimInstance Win32_Processor).Name; $motherboard = (Get-CimInstance Win32_BaseBoard).Product
|
||||
$installedRAM = (Get-CimInstance Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum; $installedRAMGB = [math]::Round($installedRAM / 1GB, 0)
|
||||
$os = Get-CimInstance Win32_OperatingSystem; $osName = $os.Caption; $osArchitecture = $os.OSArchitecture
|
||||
$memoriasRam = @(Get-CimInstance Win32_PhysicalMemory | ForEach-Object { [PSCustomObject]@{ partNumber = $_.PartNumber.Trim(); fabricante = $_.Manufacturer.Trim(); tamano = [math]::Round($_.Capacity / 1GB, 0); velocidad = $_.Speed; slot = $_.DeviceLocator } })
|
||||
$jsonDataEquipo = @{ hostname = $hostname; ip = $ip; mac = $mac; cpu = $cpu; motherboard = $motherboard; ram_installed = $installedRAMGB; ram_slots = (Get-CimInstance -ClassName Win32_PhysicalMemoryArray | Measure-Object -Property MemoryDevices -Sum).Sum; os = $osName; architecture = $osArchitecture } | ConvertTo-Json -Depth 10 -Compress
|
||||
$jsonDataUsuario = @{ username = $username } | ConvertTo-Json -Compress
|
||||
$jsonMemoriasRam = $memoriasRam | ConvertTo-Json -Depth 10 -Compress
|
||||
$jsonMemoriasRamMaestra = $memoriasRam | Select-Object -ExcludeProperty slot | ConvertTo-Json -Depth 10 -Compress
|
||||
}
|
||||
|
||||
###################################################################
|
||||
# CÓDIGO COMÚN PARA OBTENCIÓN DE DISCOS (MÉTODO UNIVERSAL)
|
||||
###################################################################
|
||||
Write-Host "Obteniendo información de discos (método compatible)..."
|
||||
$disks = @(Get-WmiObject Win32_DiskDrive | Where-Object { $_.Size -gt 0 } | ForEach-Object {
|
||||
$model = $_.Model
|
||||
$mediaType = "HDD"
|
||||
if ($model -like "*SSD*" -or $model -like "KINGSTON SA400S37*") {
|
||||
$mediaType = "SSD"
|
||||
}
|
||||
[PSCustomObject]@{ mediatype = $mediaType; size = [math]::Round($_.Size / 1GB, 0) }
|
||||
})
|
||||
|
||||
if ($null -eq $disks -or $disks.Count -eq 0) {
|
||||
Write-Host "[ADVERTENCIA] No se pudo obtener información de los discos. Se enviará una lista vacía."
|
||||
$jsonDiscos = "[]"
|
||||
} else {
|
||||
$tempJson = $disks | ConvertTo-Json -Depth 10 -Compress
|
||||
if (-not $tempJson.StartsWith("[")) {
|
||||
$jsonDiscos = "[$tempJson]"
|
||||
} else {
|
||||
$jsonDiscos = $tempJson
|
||||
}
|
||||
}
|
||||
|
||||
###################################################################
|
||||
# PASO PREVIO: AUTENTICACIÓN PARA OBTENER TOKEN JWT
|
||||
###################################################################
|
||||
Write-Host "Autenticando contra la API para obtener el token..."
|
||||
$token = $null
|
||||
$loginUrl = "$apiBaseUrl/auth/login"
|
||||
$loginBody = @{ username = $apiUser; password = $apiPassword } | ConvertTo-Json -Compress
|
||||
|
||||
try {
|
||||
# Usamos Invoke-RestMethod para el login en ambas versiones por simplicidad en el manejo de la respuesta
|
||||
$loginResponse = Invoke-RestMethod -Uri $loginUrl -Method Post -ContentType "application/json" -Body $loginBody
|
||||
$token = $loginResponse.token
|
||||
if ($token) {
|
||||
Write-Host "-> Autenticación exitosa. Token obtenido." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "-> ERROR: No se recibió un token del servidor, aunque la petición fue exitosa." -ForegroundColor Red
|
||||
}
|
||||
} catch {
|
||||
Write-Host "-> ERROR GRAVE al autenticar: $($_.Exception.Message)" -ForegroundColor Red
|
||||
if ($_.Exception.Response) {
|
||||
$errorResponse = $_.Exception.Response.GetResponseStream()
|
||||
$reader = New-Object System.IO.StreamReader($errorResponse)
|
||||
$reader.BaseStream.Position = 0
|
||||
$reader.DiscardBufferedData()
|
||||
$responseBody = $reader.ReadToEnd();
|
||||
Write-Host "Respuesta del servidor: $responseBody" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $token) {
|
||||
Write-Host "No se pudo continuar sin un token de autenticación. Saliendo del script." -ForegroundColor Red
|
||||
# Pausa para que el usuario pueda ver el error antes de que la ventana se cierre
|
||||
Read-Host "Presiona Enter para salir"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Preparamos la cabecera de autenticación que usaremos en todas las peticiones
|
||||
$authHeader = @{ "Authorization" = "Bearer $token" }
|
||||
|
||||
###################################################################
|
||||
# CÓDIGO COMÚN PARA AMBAS VERSIONES (ENVÍO DE DATOS)
|
||||
###################################################################
|
||||
$rutaEquipos = "$apiBaseUrl/equipos/$hostname"; $rutaUsuariosAsocia = "$apiBaseUrl/equipos/$hostname/asociarusuario"; $rutaUsuarios = "$apiBaseUrl/usuarios"
|
||||
$rutaDiscos = "$apiBaseUrl/discos"; $rutaDiscosAsocia = "$apiBaseUrl/equipos/$hostname/asociardiscos"; $rutaMemoriasRam = "$apiBaseUrl/MemoriasRam"
|
||||
$rutaMemoriasRamAsocia = "$apiBaseUrl/equipos/$hostname/ram"
|
||||
|
||||
try {
|
||||
Write-Host "1. Enviando/Actualizando datos del equipo..."; if ($isWindows7) { Send-Data -Url $rutaEquipos -Body $jsonDataEquipo -Headers $authHeader } else { Invoke-RestMethod -Uri $rutaEquipos -Method Post -ContentType "application/json" -Body $jsonDataEquipo -Headers $authHeader }; Write-Host "-> OK."
|
||||
Write-Host "2. Creando/Actualizando registro de usuario..."; if ($isWindows7) { Send-Data -Url $rutaUsuarios -Body $jsonDataUsuario -Headers $authHeader } else { Invoke-RestMethod -Uri $rutaUsuarios -Method Post -ContentType "application/json" -Body $jsonDataUsuario -Headers $authHeader }; Write-Host "-> OK."
|
||||
Write-Host "3. Asociando usuario al equipo..."; if ($isWindows7) { Send-Data -Url $rutaUsuariosAsocia -Body $jsonDataUsuario -Headers $authHeader } else { Invoke-RestMethod -Uri $rutaUsuariosAsocia -Method Post -ContentType "application/json" -Body $jsonDataUsuario -Headers $authHeader }; Write-Host "-> OK."
|
||||
|
||||
Write-Host "4. Enviando lista maestra de discos..."
|
||||
if ($isWindows7) { Send-Data -Url $rutaDiscos -Body $jsonDiscos -Headers $authHeader } else { Invoke-RestMethod -Uri $rutaDiscos -Method Post -ContentType "application/json" -Body $jsonDiscos -Headers $authHeader }; Write-Host "-> OK."
|
||||
|
||||
Write-Host "5. Sincronizando discos con el equipo..."
|
||||
if ($isWindows7) { Send-Data -Url $rutaDiscosAsocia -Body $jsonDiscos -Headers $authHeader } else { Invoke-RestMethod -Uri $rutaDiscosAsocia -Method Post -ContentType "application/json" -Body $jsonDiscos -Headers $authHeader }; Write-Host "-> OK."
|
||||
|
||||
Write-Host "6. Enviando lista maestra de Memorias RAM..."; if ($isWindows7) { Send-Data -Url $rutaMemoriasRam -Body $jsonMemoriasRamMaestra -Headers $authHeader } else { Invoke-RestMethod -Uri $rutaMemoriasRam -Method Post -ContentType "application/json" -Body $jsonMemoriasRamMaestra -Headers $authHeader }; Write-Host "-> OK."
|
||||
Write-Host "7. Sincronizando Memorias RAM con el equipo..."; if ($isWindows7) { Send-Data -Url $rutaMemoriasRamAsocia -Body $jsonMemoriasRam -Headers $authHeader } else { Invoke-RestMethod -Uri $rutaMemoriasRamAsocia -Method Post -ContentType "application/json" -Body $jsonMemoriasRam -Headers $authHeader }; Write-Host "-> OK."
|
||||
|
||||
Write-Host ""; Write-Host "========================================" -ForegroundColor Green; Write-Host " PROCESO FINALIZADO CORRECTAMENTE " -ForegroundColor Green; Write-Host "========================================" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host ""; Write-Host "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" -ForegroundColor Red; Write-Host " ERROR DURANTE EL ENVÍO DE DATOS " -ForegroundColor Red; Write-Host "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" -ForegroundColor Red
|
||||
Write-Host "Detalle del error: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
if ($_.Exception.Response) {
|
||||
try { $stream = $_.Exception.Response.GetResponseStream(); $reader = New-Object System.IO.StreamReader($stream); $responseBody = $reader.ReadToEnd(); $reader.Close(); $stream.Close(); Write-Host "Respuesta del servidor: $responseBody" -ForegroundColor Yellow } catch {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user